Skip to content

Angular services for REST APIs

Posted on:
3 min Frontend Development

Objectives

  1. Create a repeatable pattern for services to access the REST API
  2. Create a service responsible for storing artifacts throughout the session. This will be known as the cache for the application.

Approach

There are a few abstractions that I created to ensure consistency. At the lowest level of this abstraction is a singleton service ApiService that wraps around Angular’s HttpClient library to make different HTTP requests to the Web API. I used the firstValueFrom() function to convert an Observable to a Promise to ensure we could onboard people familiar with Promises to the new codebase.

@Injectable({
  providedIn: 'root',
})
export class ApiService {
  /**
   * The baseUrl of the API to use. Depends on the current environment.
   */
  baseUrl = environment.apiBaseUrl;

  constructor(private http: HttpClient) {}

  public post<T>(path: string, body: any): Promise<HttpResponse<any>> {
    const url = `this.baseUrl/${path}`;
    const $request = this.http
      .post<T>(url, body, { observe: 'response' })
      .pipe(timeout(DEFAULT_TIMEOUT));

    return firstValueFrom($request);
  }
  ...
}

On top of the ApiService, a ResourceService abstract class (not Injectable) defines methods that use ApiService to make HTTP requests. It contains methods like create, update, and delete which make corresponding HTTP requests to the Web API in accordance with the REST API and also invalidate any stored values. Extending this abstract class to create a service for a Resource would have uniform cache invalidation built-in.

export abstract class ResourceService {
  /**
   * This property is overriden in child classes to specify the API URL being used.
   * For example, the resource path will be `v3/products` for the ProductService.
   */
  abstract resourceApiPath: string;

  /**
   * Defines the name of the resource the service is defined for.
   */
  abstract resourceName: ResourceName;

  constructor(
    public apiService: ApiService,
    public cache: CacheService
  ) { }

  /**
   * Create a resource
   */
  create(body: any): Promise<HttpResponse<any>> {
    return this.apiService.post(this.resourceApiPath, body).then((response) => {
      // Invalidates any in-memory cache held in the DataCacheService
      this.cache.invalidate(this.resourceName);
      return response;
    });
  }

  ...
}

The most interesting part of this code, in my opinion is the cache invalidation system

/** Resource Name should ALWAYS be in singular form */
export const RESOURCE_NAMES = [
  'profile',
  'product',
  'license',
  ...
] as const;
export type ResourceName = (typeof RESOURCE_NAMES)[number];

const CACHED_RESOURCE_NAMES = [
  'account',
  'profile',
  ...
] as const;
type _CN = (typeof CACHED_RESOURCE_NAMES)[number];
export type CachedResourceName = Extract<ResourceName, _CN>;

/** Type for cacheObject */
type DataCache = Partial<Record<CachedResourceName, any>>;

@Injectable({
  providedIn: 'root',
})
export class CacheService {
  private dataCache: DataCache = {};

  /**
   * set checks if the resourceName is cacheable and stores
   * it in the cache accordingly.
   */
  set(resourceName: ResourceName, value: any): void {
    /**
     * https://github.com/microsoft/TypeScript/issues/26255
     * https://github.com/microsoft/TypeScript/issues/14520
     * https://github.com/microsoft/TypeScript/issues/14520#issuecomment-904915610
     *
     */

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore: This is a workaround till TS supports subtyping
    if (CACHED_RESOURCE_NAMES.includes(resourceName)) {
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore: This is a workaround till TS supports subtyping
      this.dataCache[resourceName] = value;
    }
  }
  /**
   * Returns a value from the cache.
   */
  get(resourceName: CachedResourceName): any | undefined {
    return this.dataCache[resourceName];
  }

  /**
   * Set cache value to undefined
   */
  invalidate(resourceName: ResourceName): void {
    this.set(resourceName, undefined);
  }
}

Finally, this is how a ResolverFn that caches a value is created.

export const resolveResourceToCache = (
  _route: ActivatedRouteSnapshot,
  _state: RouterStateSnapshot,
  provider: ProviderToken<ResourceService>,
  resourceName: CachedResourceName
): Promise<any> => {
  const cache = inject(CacheService);

  //  If cached value is present, return immediately
  if (cache.get(resourceName)) {
    return Promise.resolve(cache.get(resourceName));
  } else {
    // Else make a request to the API and cache the response
    const _resourceService = inject(provider);
    return _resourceService
      .list(1, 100, {})
      .then((response: { body: any }) => {
        cache.set(resourceName, response.body);
        return response.body;
      })
      .catch((error: any) => {
        ...
      });
  }
};

export const resolveChannels: ResolveFn<any> = (route, state) => {
  return resolveEntitiesToCache(
    route,
    state,
    ChannelService,
    'channel'
  );
};

Final thoughts

  1. It feels “hack-y” to disable TypeScript in the cache invalidation, even though I have validated the code to be correct.
  2. While unrelated to invalidation, it would be nice if every ResourceService was strictly typed and consistent with every change made to the Web API. This could probably be done using a code generator but those have their own set of drawbacks.