Our system is largely a PHP-driven system. We're not doing the full split back/front-end. Most pages are plain HTML served directly by PHP. However, a few parts of the system are very dynamic and so are built with an Angular front-end.
A few relevant details:
- I wanted the transition from the PHP-driven website to the Angular app to be seamless for the user (i.e. they should not have to login again)
- Both PHP and Angular should be designed with the idea in mind of a future transition to a pure-Angular front-end. When that happens, I shouldn't have to re-write the Angular app or the PHP backend.
- The Angular app uses redux
- The Angular app does not have to do any routing.
- This particular app manages rules and actions for a rules engine. The details are fairly complicated, but also not entirely relevant. My primary concern here is over how the Angular app is organized and initialized, regardless of what exactly the app is actually doing.
Regarding the transition from PHP to Angular: keeping in mind a future goal of a completely split back and front-end, I wanted my app to communicate exclusively with REST APIs, and not to be using the cookie that the rest of the PHP application uses to store details about the logged in user. As a result, I have a special PHP end point that authenticates the user via their cookie and returns an API token. That API token is then stored and used by the Angular app for all other API calls in a RESTful fashion. As a result, the actual "startup" procedures for the Angular app are:
- POST to login API and fetch API Token
- POST to various endpoints to fetch all the configuration data needed for the app to do its job
- Use that data to populate the Redux store
Starting with my App component, here is some more relevant background:
- The API URL for all of this is not fixed: this application is used to manage the Rules engine for a variety of different modules, and each module has its own API URL with slightly different configuration details but the exact same API mechanics. As a result, the API URL is not hard-coded. In the development environment the API URL comes out of the environment, and in production it is passed down from the PHP page that launches the angular app via a simple global variable that is pulled out of the window object.
- All the services here are my action creators for the redux store. The various calls to
service.fetchY()
all take the configuration (which contains theApiUrl
andApiToken
) and make their own API call, updating the Redux store with the results. app.component.html
is relatively empty. It just contains a small skeleton and then defers everything to a couple sub-modules that have main components which get their data out of the Redux store. As a result,app.component
doesn't do much other than initializing the app. I've considered having it select different parts of the store to pass off to said sub-modules, but that will likely end up being the entire store, so I'd rather just let each sub-module pick out only the parts they need.
app.component.ts
import {
Component,
OnInit
} from '@angular/core';
import { select } from '@angular-redux/store';
import { IEnvironment } from './store/config/i-environment';
import { ConfigService } from './store/config/config.service';
import { RuleModelService } from './store/rule-model/rule-model.service';
import { FieldService } from './store/field/field.service';
import { PlaceHolderService } from './store/place-holder/place-holder.service';
import { WindowService } from './window.service';
import { environment } from '../environments/environment';
@Component({
selector: 'my-app',
templateUrl: './app.component.html'
})
export class AppComponent implements OnInit{
@select() ruleModels$;
constructor(
private configService: ConfigService,
private uiService: UiService,
private windowService: WindowService,
private ruleModelService: RuleModelService,
private fieldService: FieldService,
private placeHolderService: PlaceHolderService
){}
ngOnInit(){
// this pretty much starts the whole app.
// in production, we get the ApiUrl out of the window, because
// it is set as a variable in a <script> tag. Otherwise these
// things come out of our environment
let environmentData: IEnvironment = Object.assign({}, environment);
if (environmentData.production){
environmentData.ApiUrl = this.windowService.window().ApiUrl;
}
// configService.getConfig() will call the login endpoint and get the API Token.
// This is actually stored in the Redux store, but rather than subscribing
// to the config portion of the Redux store, I had the configService also return a
// promise that returns the configuration. I do this simply to make the connection
// between that first API call and the subsequent initialization steps more obvious.
this.configService.getConfig(environmentData).then((config) => {
this.ruleModelService.fetchRuleModels(config);
this.fieldService.fetchFields(config);
this.placeHolderService.fetchPlaceHolders(config);
});
}
};
store/config/config.service.ts
import { Headers, Http } from '@angular/http';
import { Injectable } from '@angular/core';
import { NgRedux } from '@angular-redux/store';
import { IConfig } from './i-config';
import { IEnvironment } from './i-environment';
import { IState } from '../i-state';
import { SET_CONFIG } from '../actions';
import 'rxjs/add/operator/toPromise';
@Injectable()
export class ConfigService {
constructor(
private ngRedux: NgRedux<IState>,
private http: Http
) { }
getConfig(environment: IEnvironment): Promise<IConfig> {
let ApiUrl = environment.ApiUrl;
// initialize an HTTP request to get the user's login credentials
let headers = new Headers({
'Content-Type': 'application/json'
});
// our API key is actually fetched via an HTTP request that relies on cookie-based auth.
// This isn't ideal, but it is a temporary hack that helps with logins as we transfer back and
// forth between the PHP-driven system and the angular driven system. It will go away
// once we switch fully to a split back and front end, and it shouldn't introduce any
// actual security risks.
return new Promise<IConfig>( ( resolve: Function, reject: Function ): void => {
// In the development environment these details are set in our environment
if ( !environment.production ) {
let config: IConfig = {
ApiUrl,
RuleId: null,
ApiKey: environment.ApiKey,
MembershipId: environment.MembershipId
};
// update the redux store and resolve our promise
this.setConfig( config );
resolve( config );
return;
}
this.http
.post( `${ApiUrl}?route=login`, '', { headers: headers } )
.toPromise()
.then( ( response ) => {
let auth = response.json().data;
// get the data we care about out of the results
let config: IConfig = {
ApiUrl,
RuleId: null,
ApiKey: auth.ApiKey,
MembershipId: auth.MembershipId,
};
// update the redux store and resolve our promise
this.setConfig( config );
resolve( config );
} )
.catch( ( error ) => {
reject( error )
} );
} );
}
setConfig( config: IConfig ) {
this.ngRedux.dispatch<any>( { type: SET_CONFIG, config } );
}
}
store/place-holder/place-holder.service.ts
RuleService
, PlaceHolderService
, and FieldService
are all nearly identical: just slightly different endpoints and interfaces. As a result, I'm only going to include one for the sake of space:
import { NgRedux } from '@angular-redux/store';
import { Injectable } from '@angular/core';
import { IState } from '../i-state';
import { IPlaceHolder } from './i-place-holder';
import { IConfig } from '../config/i-config';
import { Headers, Http } from '@angular/http';
import 'rxjs/add/operator/toPromise';
import { SET_PLACE_HOLDERS } from '../actions';
@Injectable()
export class PlaceHolderService{
constructor(
private ngRedux: NgRedux<IState>,
private http: Http
){}
setPlaceHolders(placeHolders: IPlaceHolder[]){
this.ngRedux.dispatch<any>({type: SET_PLACE_HOLDERS, placeHolders});
}
fetchPlaceHolders(config: IConfig): void{
// initialize an HTTP request to get the full series data
let headers = new Headers({
'Content-Type': 'application/json',
'MembershipId': config.MembershipId,
'Authorization': 'Bearer ' + config.ApiKey
});
this.http
.get(`${config.ApiUrl}?route=get_placeholders`, { headers: headers })
.toPromise()
.then((response) => {
this.setPlaceHolders(response.json().data.map((incoming: any): IPlaceHolder => {
return {
name: incoming.name,
label: incoming.label,
}
}));
})
.catch(this.handleError);
}
handleError(error: any): void{
console.error(error);
}
}
I'm interested in any and all feedback, but here are some particular questions I have:
- I'm especially interested in any feedback on my initialization procedures: I ask the configService to get the application config (which primarily means the authentication token) and then use the result of that to trigger a call to the other endpoints, fully initializing the app. Is that reasonable?
- I am calling 3 different endpoints to get all the data I need. I had considered wrapping this all up in one endpoint that returns three different pieces of information. That would certainly involve less server calls (which is good), but also seems like a poor Separation of Concerns for an API endpoint (which is bad). Thoughts?
- These HTTP calls are mixed up inside my action-creator services. This seemed pretty reasonable to me, but I'm new to Redux and thought others might disagree. Am I being crazy here?
- The
fetch
methods in all the services both return promises that they resolve with the answer, and also update the store with the new data. This is certainly redundant. Obviously updating the store is a requirement. I could ditch the promise to minimize duplication (and I feel like that would be more in line with typical application flow), but in this one particular case I like being able to have that direct connection between "get some stuff" and "then do some more stuff". Is this a reasonable time to step outside of the norm? - Is it reasonable to have a relatively empty
app.component
with most behavior being handled by some sub-modules that access the store directly? Or should I have theapp.component
fetch data out of the store and attach itself to the inputs and outputs of the other components that are exported by the sub-modules? These guys are fairly complicated, and there would be a lot of data flowing back and forth.