DEV Community

Cover image for Loading external configurations via http using APP_INITIALIZER
Ayyash
Ayyash

Posted on • Edited on • Originally published at garage.sekrab.com

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"
}
Enter fullscreen mode Exit fullscreen mode

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.

 // Component
constructor(private configService: ConfigService) {

}
ngOnInit(): void {
    const myValue = this.configService.Config.MyKey;
    // or
    const myStaticValue  = ConfigService.Config.MyKey;
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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);
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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',
};
Enter fullscreen mode Exit fullscreen mode

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);
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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 "enabledNonBlocking" 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 module
const routes: Routes = [
  {
    path: 'project',
    component: ProjectComponent,
    resolve: {
      // add a project resolve
      ready: ProjectResolve,
    },
  },
 // ...
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      // enabledBlocking for SSR, but also enabledNonBlocking is not as good as it sounds in this setup
      initialNavigation: 'enabledBlocking',
    }),
  ],
  exports: [RouterModule],
})
export class AppRoutingModule {}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 service
export 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);
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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 enabledNonBlocking 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 take
return 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;
  }));
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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: false
export const Config = {
  isServed: false,
  API: {
    apiRoot: environment.apiRoot,
  },
  MyKey: 'default value',
  ExtraKeys: 'wont harm',
};
Enter fullscreen mode Exit fullscreen mode

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)
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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$;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 member
private static _config: IConfig;

// and a static getter with fallback
static 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 }}
Enter fullscreen mode Exit fullscreen mode

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;
 }
Enter fullscreen mode Exit fullscreen mode
  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.
    Update: this will copy the file into production under the same url (localdata/config.json), if that is not your intention, remember to remove this entry from production assets in angular.json

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)