Search code examples
angularangular9angular10angular11

Angular: Reference singleton in validator from library


I have multiple Angular applications which all reference the same GraphQL API (using Apollo) and decided to migrate the API service layer to a shared library. As part of this service layer, I am exporting a validation function called "ApiValidator" which is used to trigger an error state in forms.

The way it works is whenever an error is encountered from the API, the response will register it to an observable in an ErrorService singleton. The ApiValidator function returns a promise checks ErrorService for any errors that have a matching "field" property, and returns the errors if so.

This worked perfectly when everything was at an app level, however after migrating to the library, the validator creates a new instance of the singleton each time. This is because I'm using Injector.create with "useClass" to get a reference to the service in the validator. I cannot get "useExisting" to work, however, without a circular dependency and run into the same issues trying to use DI via a constructor in a wrapper service as well.

I wasn't sure how to effectively set this up to provide a working example on stackblitz or something given that it is across a private library and application - happy to post the code on there if it would help. For now, I've posted all the relevant parts below.

Thank you so much for any insight or help in advance!

Library Code

ErrorService

@Injectable({
  providedIn: 'root'
})
export class ErrorService {
  /**
   * An observable list of response errors returned from the graphql API
   */
  private errors: BehaviorSubject<GraphQLResponseError[]> = new BehaviorSubject<GraphQLResponseError[]>([]);

  /**
   * Get any errors on the page
   * @return An observable of the current errors on the page
   */
  public getErrors(): Observable<GraphQLResponseError[]> {
    return this.errors;
  }

  /**
   * Get only field message errors for forms
   * @return An observable of current field errors on the page
   */
  public getFieldErrors(): Observable<GraphQLResponseError[]> {
      return this.errors.pipe(map((error: GraphQLResponseError[]) => {
          return error.filter((err: GraphQLResponseError) => err.context === 'field');
      }));
  }

  /**
   * Get only page message errors
   * @return An observable of current page errors on the page
   */
  public getPageErrors(): Observable<GraphQLResponseError[]> {
      return this.errors.pipe(map((error: GraphQLResponseError[]) => {
          return error.filter((err: GraphQLResponseError) => err.context === 'page');
      }));
  }

  /**
   * Records a response error in the list of errors
   * @param error The error to add to the page
   */
  public recordError(error: GraphQLResponseError): void {
      this.errors.pipe(take(1)).subscribe(errors => {
          if (!errors.includes(error)) {
              errors.push(error);
              this.errors.next(errors);
          }
      });
  }
}

ApiValidator

import { Injector } from '@angular/core';
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { GraphQLResponseError } from '../interfaces/graphql-response-error.interface';
import { take, map } from 'rxjs/operators';
import { ErrorService } from '../services/error.service';

/**
 * Validator which triggers validations based on field errors recorded in the page service
 * @param control The form control which should be validated against this validator
 * @return A promise with the errors associated with the form or null if there are none
 */
export function ApiValidator(control: AbstractControl): Promise<ValidationErrors | null> {
    const injector = Injector.create({
        providers: [{ provide: ErrorService, useClass: ErrorService, deps: [] }]
    });
    const errorService = injector.get(ErrorService);
    // get the name of the control to compare to any errors in the service
    const controlName = (control.parent) ? Object.keys(control.parent.controls).find(name => control === control.parent.controls[name]) : null;

    // return any errors that exist for the current control, or null if none do
    return errorService.getFieldErrors().pipe(take(1), map((errors: GraphQLResponseError[]) => {
        if (errors && errors.length > 0) {
            const fieldErrors = errors.filter((error: GraphQLResponseError) => error.field === controlName);
            return (fieldErrors.length > 0) ? { api: fieldErrors.map(error => error.message).join()} : null;
        }
        return null;
    })).toPromise();
}

ApiModule

import { HttpClientModule } from '@angular/common/http';
import { CommonModule, DatePipe } from '@angular/common';
import { NgModule, Optional, SkipSelf, ModuleWithProviders } from '@angular/core';
import { Apollo, ApolloBoost, ApolloBoostModule, ApolloModule } from 'apollo-angular-boost';
import { GraphQLService } from './services/graphql.service';
import { ApiValidator } from './validators/api.validator';


@NgModule({
  declarations: [
  ],
  imports: [
    HttpClientModule,
    CommonModule,
    ApolloBoostModule,
    ApolloModule
  ],
  providers: [
    DatePipe,
    {
      provide: GraphQLService,
      useClass: GraphQLService,
      deps: [Apollo, ApolloBoost, DatePipe]
    }
  ]
})
export class ApiModule {
  constructor(@Optional() @SkipSelf() parentModule?: ApiModule) {
    if (parentModule) {
      throw new Error(
        'ApiModule is already loaded. Import it in the AppModule only');
    }
  }
}

GraphQLService (relevant part)

import { Injectable, Injector, Inject } from '@angular/core';
import { ApolloBoost, Apollo, gql, WatchQueryFetchPolicy } from 'apollo-angular-boost';
import { Observable, BehaviorSubject } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { GraphQLResponse } from '../interfaces/graphql-response.interface';
import { GraphQLRefetchQuery } from '../interfaces/graphql-refetch-query.interface';
import { DatePipe } from '@angular/common';
import { GraphQLResponseError } from '../interfaces/graphql-response-error.interface';
import { AbstractControl, ValidationErrors } from '@angular/forms';
import { ErrorService } from './error.service';


@Injectable({
  providedIn: 'root'
})
export class GraphQLService {
  /**
   * @var API_DATE_FORMAT The format to use for representing dates
   */
  protected readonly API_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss';

  /**
   * @ignore
   */
  constructor(
    protected apollo: Apollo,
    protected apolloBoost: ApolloBoost,
    protected datePipe: DatePipe,
    protected errorService: ErrorService
  ) {
  }

  /**
   * Initializes the connection with the graphql API
   * @param url The graphql API endpoint
   * @param token The authorization token to use for requests
   */
  public connect(url: string, token: string = null): void {
    // set environment variables to app context
    this.apolloBoost.create({
      uri: url,
      request: async (operation) => {
        if (token !== null) {
          operation.setContext({
            headers: {
              authorization: token
            }
          });
        }
      },
      onError: ({ graphQLErrors, networkError }) => {
          // if the gql request returned errors, register the errors with the page
          if (graphQLErrors) {
              graphQLErrors.forEach(error => {
                  // if the error has an extensions field, consider it a field-level error
                  if (error.hasOwnProperty('extensions') && error.extensions.hasOwnProperty('field')) {
                      this.errorService.recordError({
                          context: 'field',
                          message: error.message,
                          field: error.extensions.field
                      });
                  }
                  // else, consider a page level error
                  else {
                      this.errorService.recordError({
                          context: 'page',
                          message: error.message
                      });
                  }
              });
          }
          // if there were network errors, register those with the page
          // note, it doesn't seem to work correctly with true network errors
          // and we catch these in the api.interceptor for now
          if (networkError) {
              this.errorService.recordError({
                  context: 'page',
                  message: networkError.message
              });
          }
      }
    });
  }
}

Application Code

AppModule

import { HttpClientModule, HttpClient, HTTP_INTERCEPTORS } from '@angular/common/http';
import { CommonModule } from '@angular/common';
import { NgModule, APP_INITIALIZER, Injector } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RouterModule } from '@angular/router';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { CookieService } from 'ngx-cookie-service';
import { environment } from '../environments/environment';
import { GraphQLService, ApiModule } from '@angular/library';
import { initializeAuthentication, AppContextService, AuthInterceptor } from '@application/core';
import { SharedModule } from '@application/shared';
import { LayoutModule } from './layout/layout.module';
import { routes } from './app.routing';
import { AppComponent } from './app.component';
import { DashboardComponent } from './pages/dashboard/dashboard.component';

@NgModule({
  declarations: [
    AppComponent,
    DashboardComponent
  ],
  imports: [
    RouterModule.forRoot(routes),
    BrowserModule,
    BrowserAnimationsModule,
    HttpClientModule,
    CommonModule,
    SharedModule,
    LayoutModule,
    MatTooltipModule,
    ApiModule
  ],
  providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: (appContextService: AppContextService) => function() { appContextService.setEnvironmentVariables(environment); },
      deps: [AppContextService],
      multi: true
    },
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AuthInterceptor,
      multi: true
    },
    {
      provide: APP_INITIALIZER,
      useFactory: initializeAuthentication,
      multi: true,
      deps: [HttpClient, AppContextService, CookieService]
    },
    {
      provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
      useValue: {
        appearance: 'outline'
      }
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {
  constructor(private gqlService: GraphQLService, private cookieService: CookieService) {
    // if using authorization set token
    const token = (environment.useAuthorization) ? this.cookieService.get('auth') : null;

    // initialize connection with api
    this.gqlService.connect(environment.apiUrl, token);
  }
}

UserForm (relevant part)

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormGroup, FormBuilder, FormArray } from '@angular/forms';
import { take, finalize, catchError } from 'rxjs/operators';
import { ToastrService } from 'ngx-toastr';
import { User, GraphQLResponse, UserService, ApiValidator } from '@application/library';
import { PageMode, PageService } from '@application/core';

@Component({
    selector: 'sm-user-form',
    templateUrl: './user-form.component.html',
    styleUrls: ['./user-form.component.scss']
})
export class UserFormComponent implements OnInit {
    /**
     * @ignore
     */
    constructor(
        private fb: FormBuilder,
        private activatedRoute: ActivatedRoute,
        private router: Router,
        public pageService: PageService,
        private userService: UserService,
        private toastService: ToastrService
    ) {
        // define form structure
        this.form = this.fb.group({
            email: ['', null, ApiValidator],
            password: ['', null, ApiValidator],
            prefix: ['', null, ApiValidator],
            firstName: ['', null, ApiValidator],
            middleName: ['', null, ApiValidator],
            lastName: ['', null, ApiValidator]
        });
    }
}

UserFormTemplate (relevant part)

<div fxLayout="row">
        <mat-form-field fxFlex="10">
            <mat-label>Prefix</mat-label>
            <input matInput formControlName="prefix">
            <mat-error *ngIf="form.get('prefix').hasError('api')">{{form.get('prefix').getError('api')}}</mat-error>
        </mat-form-field>
        <mat-form-field fxFlex="30">
            <mat-label>First Name</mat-label>
            <input matInput formControlName="firstName">
            <mat-error *ngIf="form.get('firstName').hasError('api')">{{form.get('firstName').getError('api')}}</mat-error>
        </mat-form-field>
        <mat-form-field fxFlex="20">
            <mat-label>Middle Name</mat-label>
            <input matInput formControlName="middleName">
            <mat-error *ngIf="form.get('middleName').hasError('api')">{{form.get('middleName').getError('api')}}</mat-error>
        </mat-form-field>
        <mat-form-field fxFlex="30">
            <mat-label>Last Name</mat-label>
            <input matInput formControlName="lastName">
            <mat-error *ngIf="form.get('lastName').hasError('api')">{{form.get('lastName').getError('api')}}</mat-error>
        </mat-form-field>
        <mat-form-field fxFlex="10">
            <mat-label>Suffix</mat-label>
            <input matInput formControlName="suffix">
            <mat-error *ngIf="form.get('suffix').hasError('api')">{{form.get('suffix').getError('api')}}</mat-error>
        </mat-form-field>
    </div>

Solution

  • I was finally able to solve this. No combination of approaches using providers configurations, injector metadata, etc. was stopping the issue with the validator creating new instances of the ErrorService.

    The main issue was that since I was using Injector.create(), it was creating a new instance of the injector itself - which meant that any reference to singletons didn't exist in that injector forcing a new instance every time. I was not able to successfully get the service into the validator function, however, via providers/etc.

    So, to fix the problem, I used a service locator pattern and created a static reference to the injector from the module, then directly used that static reference to access the singleton service (Originally discovered this approach a couple years back from Inject a service manually ).

    First, I created a new utility class:

    import {Injector} from '@angular/core';
    
    export class ServiceLocator {
        static injector: Injector = null;
    }
    

    Then, in the constructor in ApiModule:

      constructor(private injector: Injector, @Optional() @SkipSelf() parentModule?: ApiModule) {
        if (parentModule) {
          throw new Error(
            'ApiModule is already loaded. Import it in the AppModule only');
        }
    
        // create a static reference to the module injector that can be used to reliably retrieve singleton classes throughout the library
        ServiceLocator.injector = this.injector;
      }
    

    And in ApiValidator, replace this part:

        const injector = Injector.create({
            providers: [{ provide: ErrorService, useClass: ErrorService, deps: [] }]
        });
        const errorService = injector.get(ErrorService);
    

    with the reference to the static injector:

        const errorService = ServiceLocator.injector.get(ErrorService);
    

    Finally, updated the GraphQLService to also use the same static injector:

      constructor(
        protected apollo: Apollo,
        protected apolloBoost: ApolloBoost,
        protected datePipe: DatePipe
      ) {
        this.errorService = ServiceLocator.injector.get(ErrorService);
      }
    

    ... and now everything shares the same instance of ErrorService, letting the validator correctly reference errors triggered by graphqlservice (or anything else).

    I'm sure there's probably a more elegant or "correct" way to do this - so if there is a better way, I'm certainly appreciative of suggestion. Otherwise, I hope someone can find this useful.