68

I am using a custom Http provider to handle API authentication error. In my CustomHttp, I need to redirect the user to the login page when a 401 status error is emitted by the API. That works fine!

app.module.ts

export function loadCustomHttp(backend: XHRBackend, defaultOptions: AppRequestOptions,
  router: Router, dataHelper: DataHelperService) {
  return new CustomHttp(backend, defaultOptions, router, dataHelper);
}

@NgModule({
// some declarations, imports, ...
providers: [
// some services ...
 {
      provide: Http,
      useFactory: loadCustomHttp,
      deps: [XHRBackend, RequestOptions, Router, DataHelperService] 
    }
});

custom-http.ts

import { Injectable } from '@angular/core';
import { Http, RequestOptions, RequestOptionsArgs, ConnectionBackend, Request, Response } from '@angular/http';
import { Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';

import { DataHelperService } from '../helpers/data-helper.service';
import { AuthStorage } from '../services/auth/auth-storage';

import 'rxjs/add/observable/throw';
import 'rxjs/add/observable/empty';

@Injectable()
export class CustomHttp extends Http {
  constructor(backend: ConnectionBackend, defaultOptions: RequestOptions,
    private router: Router, private dataHelper: DataHelperService) {
    super(backend, defaultOptions);
  }


  request(url: string | Request, options?: RequestOptionsArgs): Observable<Response> {
    return this.intercept(super.request(url, options));
  }

  get(url: string, options?: RequestOptionsArgs): Observable<Response> {
    return this.intercept(super.get(url, options));
  }

  post(url: string, body: string, options?: RequestOptionsArgs): Observable<Response> {
    return this.intercept(super.post(url, body, options));
  }

  put(url: string, body: string, options?: RequestOptionsArgs): Observable<Response> {
    return this.intercept(super.put(url, body, options));
  }

  delete(url: string, options?: RequestOptionsArgs): Observable<Response> {
    return this.intercept(super.delete(url, options));
  }



  intercept(observable: Observable<Response>): Observable<Response> {
    return observable.catch((err, source) => {
      let token = AuthStorage.getToken();

      if (err.status === 401 && token && AuthStorage.isTokenExpired())    { 
        // token has expired -> redirecting user to login
        AuthStorage.clearAll();
        this.router.navigate(['auth/login']);
      }
      return Observable.throw(err);
    });
  }
}

Then, I tried to use the APP_INITIALIZER opaque token to get the required settings to initialize my app.

app.module.ts

@NgModule({
// some declarations, imports, ...
providers: [
// some services ...
    ConfigService,
    { 
      provide: APP_INITIALIZER, 
      useFactory: (config: ConfigService) => () => config.load(), 
      deps:[ConfigService, Http],
      multi: true
    }
});

config.service.ts

import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
import { AppSettings } from '../../environments/app-settings';
import { Observable } from 'rxjs/Observable';

import 'rxjs/add/operator/toPromise';

@Injectable()
export class ConfigService {

  public settings:AppSettings;

  constructor(private http:Http) { }

  load() : Promise<AppSettings> {
    let url = '/settings/';

    var observable= this.http.get(url)
            .map(res => res.json());

    observable.subscribe(config => this.settings = config);
    return observable.toPromise();
  }

}

This creates an error :

Uncaught Error: Provider parse errors:
Cannot instantiate cyclic dependency! ApplicationRef_: in NgModule AppModuleNgModuleProviderAnalyzer.parse @ provider_analyzer.js:291NgModuleCompiler.compile @ ng_module_compiler.js:54RuntimeCompiler._compileModule @ runtime_compiler.js:102RuntimeCompiler._compileModuleAndComponents @ runtime_compiler.js:65RuntimeCompiler.compileModuleAsync @ runtime_compiler.js:55PlatformRef_._bootstrapModuleWithZone @ application_ref.js:303PlatformRef_.bootstrapModule @ application_ref.js:285(anonymous function) @ main.ts:18__webpack_require__ @ bootstrap 0e2b412…:52(anonymous function) @ main.bundle.js:86665__webpack_require__ @ bootstrap 0e2b412…:52webpackJsonpCallback @ bootstrap 0e2b412…:23(anonymous function) @ main.bundle.js:1

If I comment out the custom Http provider, the error is not shown and the APP_INITIALIZER works as expected. If I remove the Router from the Http provider deps declaration, I don't have the error anymore but the my ConfigService.load() function is called twice.

Does anyone knows why this router dependency is causing this cyclic dependency error ? How can I prevent my ConfigService.load() function to be called twice ?

If needed, I have created a public repository reproducing the error : https://github.com/haia212/AngularErrorTestProject

rzelek
  • 3,807
  • 1
  • 30
  • 34
Anne Lacan
  • 723
  • 1
  • 5
  • 8

3 Answers3

91

The problem is that Router can async load some routes. This is why it needs Http. Your Http depends on Router and Router depends on Http. Angular injector is not able to create any of these services.

I had similar problems and one of the solutions can be injecting Injector instead of service and getting service afterwards.

Code:

@Injectable()
export class CustomHttp extends Http {
  constructor(backend: ConnectionBackend, defaultOptions: RequestOptions,
    private injector: Injector, private dataHelper: DataHelperService) {
    super(backend, defaultOptions);
  }

  public get router(): Router { //this creates router property on your service.
     return this.injector.get(Router);
  }
  ...

So, basically, you do not need Router to get instance of Http service. The injection is done when you access router property - only when you want to redirect user. router property is transparent to other parts of code.

If it will not solve problem, you can do the same thing to the rest of injected services (except these to call super).

rzelek
  • 3,807
  • 1
  • 30
  • 34
  • 1
    Be advised, that injecting the injector is considered a bad practice, because it makes your class tightly coupled with specific DI implementation and it defeats the purpose of DI in the first place. – Slava Fomin II May 28 '17 at 14:53
  • 2
    @SlavaFominII Sure thing. Injecting `Injector` isn't good practice and is often a sign of bad design (example: two services depends on each other mean that purpose of these services is not defined correctly). In this case however, I do not see any way to preserve perfect design without limiting functionality. Locking to specific DI implementation seems to not be a big issue here (virtual problem, I must say), as you are locked to Angular anyway. Also, `Injector` is an abstract class, so you can provide your implementation if needed. – rzelek May 29 '17 at 09:04
  • 1
    Brilliant!! How could you find out the problem from injecting Router service? I faced the same issue and I cannot find out why. Thanks for that! – Nguyen Tran Oct 12 '18 at 16:33
  • This makes the code compile but you still get the Circular dependency as a warning. – Patronaut Feb 22 '21 at 19:36
3

I solved it simply by removing Router from the deps declarations :

{
      provide: Http,
      useFactory: loadCustomHttp,
      deps: [XHRBackend, RequestOptions, DataHelperService]
    }

And everything else stay the same. It feels a bit like magic but it works.

Anne Lacan
  • 723
  • 1
  • 5
  • 8
  • 1
    If Router wasn't needed in the first place when loading another provider, then removing Router from 'deps' parameter as you did resolves the cyclic dependency error, but all this doesn't speak to the scenario where you actually have an actual cyclic dependency (i.e. A depends on B and B depends on A). – sarora Sep 04 '17 at 17:17
2

Maybe this helps; the way I solved this is by changing the strategy for the CustomHttp class to use composition instead.

My CustomHttp looks something like this:

@Injectable()
export class CustomHttp {

    constructor(private http: Http) {}

Now, I don't need Router nor any other service injected in my custom Http service.

In the configuration loader (config.service.ts) I made the following changes:

import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
import { AppSettings } from '../../environments/app-settings';
import { Observable } from 'rxjs/Observable';

import 'rxjs/add/operator/toPromise';

@Injectable()
export class ConfigService {

  public settings:AppSettings;

  constructor() { }

  load(http: Http) : Promise<AppSettings> {
    let url = '/settings/';

    var observable= http.get(url)
            .map(res => res.json());

    observable.subscribe(config => this.settings = config);
    return observable.toPromise();
  }

}

Removed the need to inject the Http service dependency and instead added it to the load(http: Http) method.

In my app.module.ts I have the following:

providers: [
    {
        provide: Http,
        useFactory: (backend, options) => new CustomHttp(new Http(backend, options)),
        deps: [XHRBackend, RequestOptions]
    },
    ConfigService,
    {
        provide: APP_INITIALIZER,
        useFactory: (config, http) => () => config.load(http),
        deps: [ConfigService, Http],
        multi: true
    },

This is what I am currently using on my app. Not sure if this approach will work for you but hope it helps.

Luis C
  • 21
  • 3