How to use TS decorators to add caching logic to API calls

Post Editor

In this article I'll go over a couple of possible implementations of network requests caching and I'll elaborate on the one we adopted that uses TS decorators.

6 min read
2 comments
post

How to use TS decorators to add caching logic to API calls

In this article I'll go over a couple of possible implementations of network requests caching and I'll elaborate on the one we adopted that uses TS decorators.

post
post
6 min read
2 comments
2 comments

Some requests to external API services occur frequently with the same parameters and return the same data. When that the case, the application can greatly benefit from caching of the response data. Caching API calls reduces additional requests to the server-side, improves performance, and reduces data traffic.

The general approach to caching can be described like this:

Content imageContent image
Figure 1. General workflow of caching API calls with additional functionality (cache invalidation)

Here you can see that we have a basic scenario that simply caches responses and returns data from the cache on subsequent requests. But it's also possible to have more elaborate requirements like cache invalidation via an independent request to an external API service and emitting updated data to all subscribers.

That's the kind of task I recently faced. In this article I'll go over a couple of possible implementations that we considered and I'll elaborate on the one we adopted that uses TS decorators.

Solution 1: Caching on the level HTTP Interceptors
Link to this section

The most common solution is caching through HttpInterceptor. The code below shows a generic implementation that I frequently encountered in various applications:

<>Copy
const CACHE_REQUEST_FLAG_HEADER_NAME = 'CACHE_REQUEST_FLAG'; const CACHE_REQUEST_ID_HEADER_NAME = 'CACHE_REQUEST_ID'; @Injectable() export class CacheService { setItem(key: string, item: Observable<any>): void { // ... } getItem(key: string): Observable<any> | undefined { // ... } invalidate(key: string): void { // ... } } @Injectable() export class DataApiService { private requestDataId = '12345'; getData(http: HttpClient) { return this.http.get('/api/data', { headers: { [CACHE_REQUEST_FLAG_HEADER_NAME]: 'true', [CACHE_REQUEST_ID_HEADER_NAME]: this.requestDataId } }) } refreshData(): void { this.cache.invalidate(this.requestDataId); } } @Injectable() export class HttpCacheInterceptor implements HttpInterceptor { intercept(request: HttpRequest, next: HttpHandler) { const isCached = Boolean(request.headers.get(CACHE_REQUEST_FLAG_HEADER_NAME)); const cacheId = request.headers.get(CACHE_REQUEST_ID_HEADER_NAME) if(isCached) { let observable = this.chache.get(cacheId); if (observable) { return observable; } // Cache request } return next.handle(request); } }

Angular 12 introduced a new way to send specific data to HTTP interceptors through context like this:

<>Copy
const CACHE_REQUEST = new HttpContextToken<{ cached: boolean; id: string; }>(() => { cached: false, id: null, }); @Injectable() export class DataApiService { private requestDataId = '12345'; getData(http: HttpClient) { return this.http.get('/api/data', { context: new HttpContext().set(CACHE_REQUEST , { cached: true, id: this.requestDataId }) }) } } @Injectable() export class CacheInterceptor implements HttpInterceptor { intercept(request: HttpRequest, next: HttpHandler): Observable<HttpEvent<any>> { const { cached, id } = request.context.get(CACHE_REQUEST) if(cached && id) { // ... } return next.handle(request); } }

I have several objections to using this approach for API request caching:

  • For request caching, you must send an additional request header, check and delete it in the interceptor. Of course, you should create a constant value of a header name for caching flag and share it between API services and interceptor to keep them consistent. Fortunately, the trouble with an additional header is no longer relevant if you're using Angular v12. This problem is fixed by HttpContext.
  • This is not the best way to use an interceptor to detect several requests that we need to cache among thousands of other requests.
  • How to know which observable should I invalidate? As one of the possible ways to add a header with a unique identification, which is saved in a private property of service and use as a key in the future.
  • The main concern consists in returning a shared observer from HttpInterceptor, which could be a potential memory leak. It isn't a clean behavior for other developers. They expect an observer, which is completed on the first data emitting.
  • Hard to manage concurrent requests (in the case when you don’t unsubscribe from them or send request parameters)

Solution 2: Putting caching logic into API service
Link to this section

This is a better way to cache API requests, but it still has its drawbacks:

<>Copy
@Injectable() export class ProductApiService { productData = of( this.http.get('...'), defer(() => this.refreshProductSubject.pipe( switchMap(() => this.http.get('...'))) ) ).pipe( mergeAll(), shareReplay(1), ); private readonly refreshProductSubject = new Subject(); refreshProducts(): void { this.refreshProductSubject.next(); } }

Here we have several limitations. The main of them is sending HTTP request parameters.

Also, there are a lot of developers, who have objections about a variable, which sends requests to the HTTP server. From their side, it is something like a double responsibility.

Solution 3: Encapsulation caching logic inside TS decorator
Link to this section

Since I had some time to think about it I wanted to come up with a solution that would be easier to work with and more generic so that it can be reused in other applications. I also wanted this solution to be easily testable.

I had an idea. So we have an API service with methods that make HTTP requests. What if we could apply a caching logic to each method individually on such services. That's where TS decorators come very handy.

You can read about TS decorators in this article or check the official documentation.

We could put the caching logic and the other heavy stuff inside the decorator and just apply it to a service like this:

<>Copy
class DataApiService { ... @HttpRequestCache<DataApiService>(function() { return { storage: this.cache, refreshSubject: this.refreshSubject }; }) getData(): Observable<any[]> { return this.http.get('/api/data') }

It turned out a pretty interesting solution that I decided to use in my project. So far I haven't encountered any problems with it. Let me show you how to implement it!

Step 1: Create decorator wrapper

<>Copy
interface IHttpCacheStorage { setItem(key: string, item: Observable<any>): void; getItem(key: string): Observable<any> | undefined; } interface IHttpCacheOptions { storage: IHttpCacheStorage; refreshSubject: Observable<unknown> | Subject<unknown>; } export function HttpRequestCache<T extends Record<string, any>>( optionsHandler: (this: T) => IHttpCacheOptions ) { // ... }

High order function, which wraps the decorator, is important to get the settings. optionHandler argument is a function, which returns an IHttpCacheOptions. It is absolutely needed to do that because we don’t have access to the class instance from the decorator function or its wrapper. It could be available from method calls or property getters/setters only. The options handler can be as an arrow function and as a function declaration without a name as well.

IHttpCacheOptions parameters:

  • storage - the place where we save our shared observables. It could be a custom service that implements IHttpCacheStorage interface.
  • refreshSubject - an Observable stream or a Subject, which is used as an  invalidation trigger.

Step 2: Create decorator

<>Copy
type HttpRequestCacheMethod = (...args: any[]) => Observable<any>; export function HttpRequestCache<T extends Record<string, any>>( optionsHandler: (this: T) => IHttpCacheOptions ) { return ( target: T, methodName: string, descriptor: TypedPropertyDescriptor<HttpRequestCacheMethod> ): TypedPropertyDescriptor<HttpRequestCacheMethod> => { // … } }

It is a decorator function for a class method still without implementation but with parameters defined by the TS specification. A decorator returns method descriptor, which will be applied to a class method before class initialization.

Step 3: Implement decorator logic

<>Copy
export function HttpRequestCache<T extends Record<string, any>>( optionsHandler: (this: T) => IHttpCacheOptions ) { return ( target: T, methodName: string, descriptor: TypedPropertyDescriptor<HttpRequestCacheMethod> ): TypedPropertyDescriptor<HttpRequestCacheMethod> => { if (!(descriptor?.value instanceof Function)) { throw Error(`'@HttpRequestCache' can be applied only to the class method which returns Observable`); } const cacheKeyPrefix = `${target.constructor.name}_${methodName}`; const originalMethod = descriptor.value; descriptor.value = function (...args: any[]): Observable<any> { const { storage, refreshSubject } = optionsHandler.call(this) const key = `${cacheKeyPrefix}_${JSON.stringify(args)}`; let observable = storage.getItem(key); if (observable) { return observable; } observable = of( originalMethod.apply(this, args), refreshSubject.pipe(switchMap(() => originalMethod.apply(this, args))) ).pipe(mergeAll(), shareReplay(1)); storage.setItem(key, observable); return observable; }; return descriptor; }

Here's the logic that is put into the decorator:

  1. Create a cache key prefix and save the original method. The name of the constructor needs to correctly identify cached observable in the case when we have two class instances with the same method name.
  2. Constructing a cache key by adding function arguments to the cache key prefix. For converting function arguments to the string we use the same solution, which you can find in typical implementation for function memorization.
  3. Calling optionHandler higher-order function and getting a storage with refresh subject. As I wrote previously, it is important to call option handler from the method instance.
  4. In case when cached observable isn’t present, create a shared observable with invalidation stream, save it in cache storage and return it

Step 4: Usage

<>Copy
@Injectable() export class CacheService { setItem(key: string, item: Observable<any>): void { // ... } getItem(key: string): Observable<any> | undefined { // ... } } @Injectable() export class DataApiService { constructor(private readonly cache: CacheService) {} private readonly refreshSubject = new Subject(); @HttpRequestCache<DataApiService>(function() { return { storage: this.cache, refreshSubject: this.refreshSubject }; }) getData(): Observable<any[]> { return this.http.get('/api/data') } refreshData(): void { this.refreshSubject.next(); } }

In conclusion
Link to this section

The first two implementations have a bigger complexity than the third. The main reasons are human factors, knowledge transfer for newcomers, and following code styles. The more code, the more bugs, mistakes, and unit tests.

As you can see, in our case TS decorator is a clean and simple way to implement   request caching to any external API services without creating additional interceptors and services to manage it. It allows us to not think about implementation and use it in a declarative way. This solution meets the SOLID principles as much as possible.

As a benefit, we can use this implementation for caching of not only API calls, but for any other methods which return observable data. As an additional feature, we could change the implementation and set the refresh subject as an optional parameter to cache data for all application runtime.

Check out this Stackblitz demo to explore the final solution yourself.

Comments (2)

authormenelai
30 April 2021
observable = of(
         originalMethod.apply(this, args),
         refreshSubject.pipe(switchMap(() => originalMethod.apply(this, args)))
       ).pipe(mergeAll(), shareReplay(1));

can be simplified

observable = refreshSubject.pipe(
  startWith(true),
  switchMap(() => originalMethod.apply(this, args)),
  shareReplay(1)
)
authorNillcon248
25 September 2021

Maybe just use state? =)

authorfirstpersonl
23 December 2021

optionsHandler.call(this,this) not work

authorfirstpersonl
23 December 2021

TS2345: Argument of type 'TypedPropertyDescriptor<HttpCacheMethod>' is not assignable to parameter of type 'T'.   'TypedPropertyDescriptor<HttpCacheMethod>' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Record<string, any>'.

Share

About the author

author_image

Frontend Developer

author_image

About the author

Maksym Honchar

Frontend Developer

About the author

author_image

Frontend Developer

Featured articles