Defining Dynamic AJAX-Driven Service Providers Using APP_INITIALIZER In Angular 6.1.4
On the latest Adventures In Angular podcast, Dave Bush discusses his blog post about storing Angular configuration data outside of the compiled application files. By separating the application logic from the configuration data, it makes your compiled application portable across your various environments. In his post, Dave uses the APP_INITIALIZER multi-provider to load remote configuration data using an AJAX request as part of the application bootstrapping process. I thought this was totally fascinating; and, I wanted to riff on it a bit more. Now that Dave has demonstrated how to load the configuration data, I wanted to take a closer look at how you might then use that configuration data to actually configure the Angular application.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
In the past, I've looked at the APP_INITIALIZER as a means to re-create the "run blocks" that we knew in AngularJS. I soon discovered, however, that the NgModule constructors could provide an even more straight-forward "run block". But, in both cases, I was only using the "run block" to manipulate services that had already been instantiated. The thing that's so fascinating about Dave's post is that he's using the APP_INITIALIZER to actually load remote configuration data from the server using an AJAX request.
I'm not going to get into the mechanics of the APP_INITIALIZER very much, since I think Dave's post covered it quite nicely. But, the APP_INITIALIZER is a multi-provider service that is composed of functions that each return a Promise. The Angular application will "block and defer" bootstrapping until each APP_INITIALIZER Promise is resolved. And, if any of the Promises are rejected, the Angular application won't be initialized at all.
It's in this "block and defer" period that we can use Angular's HttpClient to make an AJAX (Asynchronous JavaScript and JSON) request to the server. Using the AJAX request we can load configuration data that can be used to customize our application for a specific environment. For example, we probably want to use different API keys in Production and in Development. Take, as a thought experiment, a simple Geolocation Service that leverages the IPInfo.io API:
// Import the core angular services.
import { HttpClient } from "@angular/common/http";
import { Inject } from "@angular/core";
import { Injectable } from "@angular/core";
import { InjectionToken } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export var IP_INFO_API_KEY = new InjectionToken<string>( "IPInfo.io Api Key" );
export interface IPLocation {
ip: string;
country: string;
city: string;
latitude: number;
longitude: number;
}
interface IPInfoResponse {
city?: string;
country?: string;
ip: string;
loc?: string;
}
@Injectable({
providedIn: "root"
})
export class GeocodeService {
private apiToken: string;
private httpClient: HttpClient;
// I initialize the geocode service.
constructor(
@Inject( IP_INFO_API_KEY ) apiToken: string,
httpClient: HttpClient
) {
this.apiToken = apiToken;
this.httpClient = httpClient;
}
// ---
// PUBLIC METHODS.
// ---
// I try to locate the given IP address.
public async locate( ipAddress: string ) : Promise<IPLocation> {
var url = `https://ipinfo.io/${ ipAddress }?token=${ this.apiToken }`;
var result = await this.httpClient
.get<IPInfoResponse>( url )
.toPromise()
;
var country = ( result.country || "Unknown" );
var city = ( result.city || "Unknown" );
var coordinates = ( result.loc || "" ).split( "," );
return({
ip: ipAddress,
country: country,
city: city,
latitude: ( +coordinates.shift() || 0 ),
longitude: ( +coordinates.shift() || 0 )
});
}
}
Each of these API calls needs to include an API Token. And, that API Token is tied to a particular rate-limit. As such, we probably want to use a "paid" API Token with a high rate-limit in production. But, in the local development environment(s), we can use a "free" API Token that has a smaller rate-limit.
In order to keep the application portable, we don't want to compile the API Token into the Angular code. Instead, we want to pull the API Token from an external source. For the sake of this demo, I'm going to use a simple app.config.json text file:
{
"ipInfo": {
"apiToken": "b2bf05e1d6c24a"
}
}
The question then becomes, how do we grab that API Token; and, do it in such a way that makes it available for ************ dependency injection. After all, if we look at our GeocodeService class, it doesn't know anything about APP_INITIALIZER. It just requires the API Token as part of its constructor. That's the beauty of dependency-injection (DI) - it inverts the relationships. The GeocodeService doesn't have to care about where the configuration data is coming from - it leaves that decision making up to the DI container.
Now, one thing that is awesome about Angular's dependency-injection algorithm is that services aren't created until they are actually required by the application. So, if you have a Service that is used by a single component; and that component is never mounted; then, Angular will never instantiate that Service.
This "lazy evaluation" is going to be a key ingredient in our remote configuration integration. It means that we don't actually have to configure all of the application Services at the same time. Instead, we can use Factory functions to defer configuration until just before a given Service is instantiated.
So, we can use the APP_INITIALIZER to load the remote data. Then, once the remote data is loaded - and the application bootstrapping is allowed to complete - we can consume the configuration data within subsequent Factory functions in order to configure the dependency-injection tokens for our configurable services (like the GeocodeService).
To see this in action, I've created an AppInitializer Service that both loads the remote data and acts as a conduit for the subsequent Factory functions:
// Import the core angular services.
import { APP_INITIALIZER } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Provider } from "@angular/core";
// Import the application components and services.
import { IP_INFO_API_KEY } from "./geocode.service";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// I am the interface for the Config object that will be loaded off disk. This object
// will be made available to Provider Factory functions such that other Providers can
// then be dynamically configured during the application bootstrapping process.
interface Config {
ipInfo: {
apiToken: string;
}
}
@Injectable({
providedIn: "root"
})
export class AppInitializer {
public config: Config | null;
private httpClient: HttpClient;
// I initialize the app initializer.
constructor( httpClient: HttpClient ) {
this.httpClient = httpClient;
// We will be loading the configuration from a known location on the server.
// Once it is loaded, this property will be used to dynamically define other
// injectables in the application providers.
this.config = null;
}
// ---
// PUBLIC METHODS.
// ---
// I load the remote config file into the config property.
public async loadConfig() : Promise<void> {
try {
this.config = await this.httpClient
.get<Config>( "./app.config.json" )
.toPromise()
;
console.group( "Configuration Loaded" );
console.log( this.config );
console.groupEnd();
} catch ( error ) {
console.group( "Configuration Failed" );
console.error( error );
console.groupEnd();
// NOTE: Throwing this error will prevent the application from bootstrapping.
throw( error );
}
}
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export var APP_INITIALIZER_PROVIDERS: Provider[] = [
// In Angular, the Providers are evaluated in a LAZY FASHION. This means that they
// are not created until they are actually required by the application. This works
// to our benefit because it means that we can use Factory Functions to define some
// of the providers using data that isn't necessarily available at compile-time. In
// this case, our "APP_INITIALIZER" will be evaluated first, which will block the
// rest of the bootstrapping process. Then, once the APP_INITIALIZER is evaluated,
// and application bootstrapping is allowed to continue, we'll have the data needed
// to configure the rest of the dynamic providers.
{
provide: APP_INITIALIZER,
useFactory: function( appInitializer: AppInitializer ) {
return(
function() : Promise<void> {
// The application will "block" until this promise is resolved.
return( appInitializer.loadConfig() );
}
);
},
deps: [ AppInitializer ],
multi: true
},
// Providers aren't evaluated in Angular until they are required. Which means that
// as long as !!!! NOTHING MAKES EAGER USE OF THE GEOCODESERVICE !!!!, then we can
// safely define the IP_INFO_API_KEY after the APP_INITIALIZER has run.
{
provide: IP_INFO_API_KEY,
useFactory: function( appInitializer: AppInitializer ) {
return( appInitializer.config.ipInfo.apiToken );
},
deps: [ AppInitializer ]
}
];
As you can see, the AppInitializer loads the remote configuration data and then stores it as a public property. Then, the AppInitializer is required as a dependency for our IP_INFO_API_KEY factory function. It is inside this factory function that we then translate the remote configuration data into a local dependency-injection Provider for our GeocodeService.
This works because nothing in the application is attempting to eagerly consume the GeocodeService. As such, it won't get instantiated until after the APP_INITIALIZER runs and the IP_INFO_API_KEY has been configured.
Once we have our AppInitializer, we just need to pull the Providers into our application module:
// Import the core angular services.
import { BrowserModule } from "@angular/platform-browser";
import { HttpClientModule } from "@angular/common/http";
import { NgModule } from "@angular/core";
// Import the application components and services.
import { AppComponent } from "./app.component";
import { APP_INITIALIZER_PROVIDERS } from "./app.initializer";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@NgModule({
imports: [
BrowserModule,
HttpClientModule
],
bootstrap: [
AppComponent
],
declarations: [
AppComponent
],
providers: [
APP_INITIALIZER_PROVIDERS
]
})
export class AppModule {
// ...
}
Now, if we run this Angular application, we can see that the remote configuration data is successfully loaded, allowing the application to be bootstrapped:
As you can see, this worked quite nicely! And, the beautiful part of this is that the GeocodeService remains blissfully unaware of where any of its configuration data came from. That's the miracle of dependency-injection (DI) and inversion of control (IoC). And, part of makes Angular such a powerful framework.
Want to use code from this post? Check out the license.
Reader Comments
@All,
As a quick follow-up to this post, here's another approach to configuring an Angular application using an embedded JSON payload:
www.bennadel.com/blog/3561-using-embedded-data-to-provide-request-specific-application-configuration-in-angular-7-2-0.htm
This linked approach requires some server-side processing so that it can serialize and embed page-specific configuration data in the page that is serving up the Single-Page Application (SPA). That said, if you are already in that kind of a context, embedded the configuration data can make life a bit more simple.
Hey Ben,
I see the use of APP_INITIALIZER being recommended for runtime config of an Angular app, almost exclusively. Unfortunately it runs into a brick wall quick when the project uses NGRX as the effects are merged prior to the APP_INITIALIZER (https://github.com/ngrx/platform/issues/931). Have you run into this problem? If so, what methods have you used to get around the issue and still have runtime config?
Thank you,
Shaun
@Shaun,
Sorry, I am not sure. I haven't really used NgRX much (I'm still very much a noob when it comes to externalized state management). I also haven't created too many applications where I have complex configuration requirements. So, much of this is trial-and-error as I come up against issues.