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>