Angular: make the code readable for the back-end. Bonus: swapping APIs and query caching

Very often on a project, the frontend development pace is ahead of the backend development pace. In this situation, two things arise:

  1. the ability to launch the front without a backend, or without separate endpoints;
  2. Describe to the back-end what endpoints are needed, the format of the request, response, etc.

I want to share my way of organizing the code responsible for API requests, which perfectly solves these two problems, and also allows you to cache requests.

Create the api.config.json configuration file in the project root:

{
  "apiUrl": "https://api.example.com",
  "useFakeApiByDefault": false,
  "fakeEndPoints": ["Sample"]
}

Here we set the base URL for the API, if the useFakeApiByDefault = true parameter, then our application will use only stubs instead of all requests. If false, then stubs will be used only for requests from the fakeEndPoints array.

In order to import JSON into the code, we add two lines to the CompilerOptions section of the tsconfig.json file:

    "resolveJsonModule": true,
    "esModuleInterop": true,

Create the base class BaseEndpoint.

/src/app/api/_base.endpoint.ts:

import {ApiMethod, ApiService} from '../services/api/api.service';
import * as ApiConfig from '../../../api.config.json';
import {ErrorService} from '../services/error/error.service';
import {NgModule} from '@angular/core';

@NgModule({providers: [ApiService, ErrorService]})

export abstract class BaseEndpoint<Request, ResponseModel> {
  protected url: string;
  protected name: string;
  protected method: ApiMethod = ApiMethod.Post;
  protected sampleResponse: ResponseModel;
  protected cache: boolean = false;
  protected responseCache: ResponseModel = null;

  constructor(private api: ApiService, private error: ErrorService) {
  }

  public execute(request: Request): Promise<ResponseModel> {
    if (this.cache && this.responseCache !== null) {
      return new Promise<ResponseModel>((resolve, reject) => {
        resolve(this.responseCache);
      });
    } else {
      if (ApiConfig.useFakeApiByDefault || ApiConfig.fakeEndPoints.includes(this.name)) {
        console.log('Fake Api Request:: ', this.name, request);
        console.log('Fake Api Response:: ', this.sampleResponse);
        return new Promise<ResponseModel>((resolve) => resolve(this.sampleResponse));
      } else {
        return new Promise<ResponseModel>((resolve, reject) => {
          this.api.execute(this.url, this.method, request).subscribe(
            (response: Response<ResponseModel>) => {
              if (response.status === 200) {
                if (this.cache) { this.responseCache = response.payload; }
                resolve(this.payloadMap(response.payload));
              } else {
                this.error.emit(response.error);
                reject(response.error);
              }
            }, response => {
              this.error.emit('    ')
              reject('    '));
           }
        });
      }
    }
  }

  protected payloadMap(payload: ResponseModel): ResponseModel { return payload; }
}

abstract class Response<T> {
  public status: number;
  public error: string;
  public payload: T;
}

<Request, ResponseModel> - types of request and response instances. In response, payload is already pulled out.

I will not post ApiService and ErrorService classes, so as not to inflate the post, there is nothing special there. ApiService sends http requests, ErrorService allows you to subscribe to errors. To display errors, you need to subscribe to ErrorService in the component where we actually want to display errors (I sign the main layout in which I make a modal or a tooltip).

Magic begins when we inherit from this class. The

derived class will look like this: /src/app/api/get-users.endpoint.ts

import {BaseEndpoint} from './_base.endpoint';
import {Injectable} from '@angular/core';
import {UserModel} from '../models/user.model';

@Injectable()
export class GetUsersEndpoint extends BaseEndpoint<GetUsersRequest, UserModel[]> {
  protected name = 'GetUsers';
  protected url= '/getUsers';
  protected sampleResponse = [{
      id: 0,
      name: 'Ivan',
      age: 18
  },
  {
      id: 1,
      name: 'Igor',
      age: 25
   }

  protected payloadMap(payload) {
    return payload.map(user => new UserModel(user));
  }
}

export class GetUsersRequest {
   page: number;
   limit: number;
}

Nothing more, from the contents of the file it is immediately clear which URL, what request format (GetUserRequest), what response format.

If such files are in a separate folder / api, each file corresponds to its own endpoint, I think you can show this folder to the back-end, and theoretically, if you are too lazy to write documentation on api, you can do without it. You can still scatter files inside folders / api into folders according to the controllers.

If you add 'GetUsers' to the โ€œfakeEndPointsโ€ array of the config, then there will be no request, and the response will be replaced with stub data from sampleResponse.

In order for fake requests to be debugged (in the Network tab, of course, we will not see anything), I provided for the output of the console to output two lines to the console class:

console.log('Fake Api Request:: ', this.name, request);
console.log('Fake Api Response:: ', this.sampleResponse);

If you override the class property cache = true, then the request will be cached (the first time a request to the API is made, then the result of the first request is always returned). The truth here is to finalize: you need to make caching work only if the request parameters (the contents of an instance of the UserRequest class).

We redefine the payloadMap method if we need any transformations of the data received from the server. If the method is not redefined, then the data from payload will be in the returned Promise.

Now we get the data from the API in the component:

import {Component, OnInit, ViewChild} from '@angular/core';
import {UserModel} from '../../models/user.model';
import {GetUsersEndpoint, GetUsersRequest} from '../../api/get_users.endpoint';

@Component({
  selector: 'app-users',
  templateUrl: './users.component.html',
  styleUrls: ['./users.component.css'],
  providers: [GetUsersEndpoint]
})
export class UsersComponent implements OnInit {

  public users: UserModel[];
  public request: GetUsersRequest = {page: 1, limit: 20};

  constructor(private getUsersEndpoint: GetUsersEndpoint) {    
  }

  private load() {
    this.getUsersEndpoint.execute(this.request).then(data => {
      this.users = data;
    });
  }

  ngOnInit(): void {
    this.load();
  }
}


In such an implementation, it is possible to show the customer the result of completing his Wishlist, even if the backend has not yet been completed for these Wishlist. Put the necessary endpoints on the stub - and you can already "touch" the features and get a feedback.

In the comments I look forward to constructive criticism of what problems may arise when building functionality, which other pitfalls can come out.

In the future I want to rewrite this solution from promises to async / await. I think the code will be even more elegant.

In stock there are several more architectural solutions for Angular, in the near future I plan to share.

All Articles