Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
March 20, 2022 11:13 am GMT

Loading external configurations via http using APP_INITIALIZER

Given that title, I don't think I need to explain. So let's get on with it.

Fetching Configuration from server

Environment variables pollute the compiled source code, which does not allow for multiple server deployments. External configuration, allows multiple custom configuration for the same source code. The downside is, you have to maintain them manually.
Remote Configuration in Angular

Let's begin by creating the config json file with some keys:

The full project is on StackBlitz

{  "API": {   "apiRoot": "http://localhost:8888/.netlify/functions"  },  "MyKey": "MyValue"}

The end result in some component, is to be able to get the configuration as a property of a service, or as a static member.

 // Componentconstructor(private configService: ConfigService) {}ngOnInit(): void {    const myValue = this.configService.Config.MyKey;    // or    const myStaticValue  = ConfigService.Config.MyKey;}

APP_INITIALZER token

In AppModule: (refer to mysterious three tokens post).

@NgModule({  imports: [BrowserModule, HttpClientModule, CommonModule],  declarations: [AppComponent, HelloComponent],  bootstrap: [AppComponent],  providers: [    {      // TODO: create ConfigService and configFactory      provide: APP_INITIALIZER,      useFactory: configFactory,      multi: true,      deps: [ConfigService]    },  ],})export class AppModule {}

In a service file for ConfigService:

export const configFactory = (config: ConfigService): (() => Observable<boolean>) => {  return () => config.loadAppConfig();};@Injectable({  providedIn: 'root',})export class ConfigService {  constructor(private http: HttpClient) {  }  // retursn observable, right now just http.get  loadAppConfig(): Observable<boolean> {    return this.http.get(environment.configUrl).pipe(      map((response) => {        // do something to reflect into local model        this.CreateConfig(response);        return true;      }),      catchError((error) => {        // if in error, set default fall back from environment         this.CreateConfig(defaultConfig);        return of(false);      })    );  }}

The evironment.configUrl in development would be the local file, ore remote server. Later will be elaborating more on strategy of how to handle the config file and location.

The Config model:

export interface IConfig {  API: {    apiRoot: string;  };  MyKey: string;}

The private method to cast configuration, should also return default configuration in case of failure. The extra configuration though does not have to match IConfig.

The default fallback config:

import { environment } from '../enviornments/dev.env';export const Config = {  API: {    apiRoot: environment.apiRoot,  },  MyKey: 'default value',  ExtraKeys: 'wont harm',};

Back to the service, the CreateConfig should only try to cast, then set to a public property. This, later, is going to fail. But let's go on.

export class ConfigService {  constructor(private http: HttpClient) {}  private _createConfig(config: any): IConfig {    // cast all keys as are    const _config = { ...(<IConfig>config) };    return _config;  }  // public property  public Config: IConfig;  loadAppConfig(): Observable<boolean> {    return this.http.get(environment.configUrl).pipe(      map((response) => {        // set to public property        this.Config = this._createConfig(response);        return true;      }),      catchError((error) => {        // if in error, return set fall back from Config        this.Config = Config;        return of(false);      })    );  }}

The curious case of Router Initialization

The Router Module uses APP_INITIALIZE, as referenced in master branch of Angular 13, and initialization functions are run in parallel according to source code. Without digging deeper into navigation options, it is already an open wound that needs to be patched. The sequence of events cannot be guaranteed in a module that uses both configuration and Route modules. One is going to happen before the other.

Route guards and resolves are one example of routing happening sooner than initialization response. The extreme case I reached after multiple trials:

  • The external configuration is remote, thus a bit slower than local
  • Routing option InitialNavigation is set to enabledBlocking, according to Angular docs, this is required for SSR.

A word of caution, leaving the InitialNavigation to its default "enableNonBlocking" will produce unexpected results in the resolve service. Filtering out unready configuration to avoid "fallback" values, the benefit of "non blocking" is nullified. Read the code comments as you go along.

So let's create an app routing module and add a router resolve with these extreme conditions.

// the routing moduleconst routes: Routes = [  {    path: 'project',    component: ProjectComponent,    resolve: {      // add a project resolve      ready: ProjectResolve,    },  }, // ...];@NgModule({  imports: [    RouterModule.forRoot(routes, {      // enableBlocking for SSR, but also enabledNonBlocking is not as good as it sounds in this setup      initialNavigation: 'enabledBlocking',    }),  ],  exports: [RouterModule],})export class AppRoutingModule {}

Import the AppRoutingModule into root AppModule, add a project component, and let's create the project resolve, that returns a Boolean.

@Injectable({ providedIn: 'root' })export class ProjectResolve implements Resolve<boolean> {  // inject the service  constructor(private configService: ConfigService) {}  resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {    // log the value of the configuration here    // if this is too soon, the result is undefined    console.log('on resolve', this.configService.Config);    return of(true);  }}

Running this on Stackblitz and loading the app on /project, consoles "undefined." Which means, the initial Route Resolve was faster than getting http config result. The solution to that, if we see it backwards, should be like this:

wait till this.configService.Config is ready

That translates to RxJS observable. So let me head to ConfigService and create an observable of an internal subject (much like RxJS state management).

// config serviceexport class ConfigService {  constructor(private http: HttpClient) {}  // keep track of config, initialize with fall back Config  private config = new BehaviorSubject<IConfig>(Config as IConfig);  config$: Observable<IConfig> = this.config.asObservable();  private _createConfig(config: any): IConfig {    // cast all keys as are    const _config = { ...(<IConfig>config) };    return _config;  }  loadAppConfig(): Observable<boolean> {    return this.http.get(environment.configUrl).pipe(      map((response) => {        const config = this._createConfig(response);        // here next        this.config.next(config);        return true;      }),      catchError((error) => {        // if in error, return set fall back from Config        this.config.next(Config);        console.log(error);        return of(false);      })    );  }}

In the resolve service, watching updates is not good enough, we need to signal end of stream, to return and move on. RxJS take(1) is usually recommended, but before we take 1, we need to filter out configuration that is not ready yet, otherwise, that "1" would be the fallback one. This, is why enableNonBlocking is useless in this setup (I hope I'm clear, if not, let me know in the comments and I will try to clear that out).

// in resolve, need to take 1 and return// This is he first attempt return this.configService.config$.pipe(  take(1),  map(n => {      if (n.MyKey === 'default') {        // the first one will actually be the fallback          return false;      }      return true;  }));// attempt two: filter before you takereturn this.configService.config$.pipe(  filter(n => n['somevalue to distinguish remote config'])  take(1),  map(n => {      if (n.MyKey === 'default') {          return false;      }      // it will be true for sure      return true;  }));// last attempt, two in one:return this.configService.config$.pipe(  first(n => n['somevalue to distinguish remote config']  map(n => {      // always same value      return true;  }));

isServed is my new configuration property to "distinguish remote configuration" from fallback one. It's just a Boolean set to true in remote config.

// config json{  "isServed": true,  "API": {    "apiRoot": "http://localhost:8888/server/app"  },  "MyKey": "MyValue"}

Add it to the config model, and to the default Config.

// config model:export interface IConfig {  isServed: boolean;  API: {    apiRoot: string;  };  MyKey: string;}// the default Config with isServed: falseexport const Config = {  isServed: false,  API: {    apiRoot: environment.apiRoot,  },  MyKey: 'default value',  ExtraKeys: 'wont harm',};

The project resolve is ready

@Injectable({ providedIn: 'root' })export class ProjectResolve implements Resolve<boolean> {  constructor(private configService: ConfigService) {}  resolve(    route: ActivatedRouteSnapshot,    state: RouterStateSnapshot  ): Observable<boolean> {    // watch it until it's served    return this.configService.config$.pipe(      first((n) => n.isServed),      map((n) => true)    );  }}

The observable in the current setup shall produce two values, the first is isServed set to false. To read the configuration in a component:

@Component({  template: `Project page with resolve  <p>  {{ config$ | async | json}}  </p>`,  changeDetection: ChangeDetectionStrategy.OnPush,})export class ProjectComponent implements OnInit {  config$: Observable<IConfig>;  constructor(private configService: ConfigService) {  }  ngOnInit(): void {       this.config$ = this.configService.config$;  }}

A final touch to garnish, for off the track usage, we add static getter, that returns the value of the configuration:

// config service// make a static memberprivate static _config: IConfig;// and a static getter with fallbackstatic get Config(): IConfig {  return this._config || Config;}private _createConfig(config: any): IConfig {  const _config = { ...(<IConfig>config) };  // set static member  ConfigService._config = _config;  return _config;}// ...// This can be used directly, for example  in template{{ ConfigService.Config.isServed }}

Pitfalls

  1. If the remote configuration does not have all keys expected, they will be overwritten to "null". To overcome, extend the configuration, via shallow cloning.
private _createConfig(config: any): IConfig {    // shallow extension of fallback    const _config = {...Config, ...(<IConfig>config) };    ConfigService._config = _config;    return _config; }
  1. The default Config may be mistaken for ConfigService.Config, if ever used, the default fallback value is in place. To fix that, a separation between the general Config, and remote Config fallback may be needed, or a little bit of attention.

  2. If the config file needed in Route Resolve or Guard fails to be served, we're blocked. Placing the config file on the same server, or a combination of RxJS operators, are possible solutions.

  3. The url of the config file, cannot be part of the configuration keys!

  4. Remember to filter out config url in your HTTP interceptor, if you prefix urls with a value fed by configuration.

Where to place the config file

The benefit aspired for is to have a production-specific configurations for every deployed version, ready to be adjusted for whatever prompt reason. As much as you would like to believe that touching production is taboo, there shall be times when the kitchen is on fire.

The question is, where to place configuration during development.

  1. Remote server. Can be an inhouse local server, or a staging server.

  2. Mock server, a nodejs local server that you run before starting Angular.

  3. On a root folder, e.g. "configs", served via angular.json assets

Wherever you decide to place your configuration, remember to update respective environments.

// add this to assets in angular.json "assets": [  {    "glob": "*.json",    "input": "configs",    "output": "/localdata"  }]// now, every ./configs/*.json will be accessed in dev env as /localdata/*.json

Inline, SSR, and other issues

There is another way to load configuration without HTTP, and that is to inject the JS file in the header of the index file. To accomplish that ...come back next week.

Thank you for tuning in. Let me know in the comments if I pressed any wrong buttons.

This configuration setup is part of the Cricket Seed.

Resources


Original Link: https://dev.to/ayyash/loading-external-configurations-via-http-using-appinitializer-34i7

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To