Taming Quirky REST APIs with Angular HTTP Interceptors

rensjaspers

Rens Jaspers

Posted on October 24, 2024

Taming Quirky REST APIs with Angular HTTP Interceptors

As a frontend developer, I have worked with many web services that didn’t follow standard REST patterns or behaved in a weird way.

At first, this was frustrating because I thought handling these quirks would make my code messy and hard to maintain.

But I learned that Angular’s HTTP interceptors let you solve these issues neatly in one place, so the rest of your code can stay simple.

In this post, I will share four problems I faced with a particular API and show how I solved them easily in Angular.

Overview

Problem 1: Unusual HTTP Status Codes

The API did not use HTTP status codes in the normal way. For example, when a resource was not found, it did not return a 404 Not Found. Instead, it returned a 200 OK with a custom error message in the response body.

This caused problems when defining response interfaces. A 200 response could contain either the data I wanted or an error message. This made it hard to handle responses correctly.

Solution: HTTP Interceptor for Error Handling

We can use an Angular HTTP interceptor to check the response body. If it contains an error, the interceptor can throw an actual error. This allows the rest of the application to handle errors properly. Also, we only have to define one response interface for successful responses.

// api-error-detection-interceptor.ts
import { Injectable } from "@angular/core";
import {
  HttpInterceptor,
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpResponse,
} from "@angular/common/http";
import { Observable, throwError, of } from "rxjs";
import { switchMap } from "rxjs/operators";
import { ApiErrorResponseDetectorService } from "./api-error-response-detector.service";

@Injectable()
export class ApiErrorDetectionInterceptor implements HttpInterceptor {
  constructor(private errorDetector: ApiErrorResponseDetectorService) {}

  intercept(
    request: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    return next.handle(request).pipe(
      switchMap((event) => {
        if (
          event instanceof HttpResponse &&
          this.errorDetector.isError(event.body)
        ) {
          return throwError(
            () => new Error(this.errorDetector.extractErrorMessage(event.body))
          );
        }
        return of(event);
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Problem 2: Missing HTTP Methods

Another issue was that the API only supported POST requests. Instead of using different HTTP methods, the API used the action in the URL. For example:

  • GET /todos/1 became POST /get/todo/1
  • POST /todos became POST /submit/todo
  • DELETE /todos/1 became POST /delete/todo/1

Solution: HTTP Interceptor to Adjust Requests

We can write an HTTP interceptor that changes our requests to match what the API expects. It can change the HTTP method to POST and adjust the URL. This way, in the rest of the application, we can use http.get, http.post, http.delete, and so on, as usual.

// method-adjustment-interceptor.ts
import { Injectable } from "@angular/core";
import {
  HttpInterceptor,
  HttpRequest,
  HttpHandler,
  HttpEvent,
} from "@angular/common/http";
import { Observable } from "rxjs";

@Injectable()
export class MethodAdjustmentInterceptor implements HttpInterceptor {
  intercept(
    request: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    // Map HTTP methods to API actions
    const actionMap: { [key: string]: string } = {
      GET: "get",
      POST: "submit",
      DELETE: "delete",
      PUT: "update",
      PATCH: "patch",
    };

    const originalMethod = request.method.toUpperCase();
    const action = actionMap[originalMethod];

    // Adjust the URL and set method to POST
    const adjustedUrl = request.url.replace(/^(\/\w+)/, `/${action}$1`);
    const modifiedRequest = request.clone({
      url: adjustedUrl,
      method: "POST",
    });

    return next.handle(modifiedRequest);
  }
}
Enter fullscreen mode Exit fullscreen mode

Problem 3: Hidden Error Messages

The error messages from the backend were often hard to find. They were sometimes deep inside the response body, under properties like hint or error. This made it difficult to get the actual error message.

Solution: Service to Extract Error Messages

We can create a service that goes through the response body to find error messages. This service can collect and combine these messages. We can use this service in our HTTP interceptor. When an error is detected, the interceptor can use the service to get the error message.

// error-message-extractor.service.ts
import { Injectable } from "@angular/core";

@Injectable({
  providedIn: "root",
})
export class ErrorMessageExtractorService {
  public extractErrorMessage(responseBody: any): string {
    const messages: string[] = [];
    this.collectErrorMessages(responseBody, messages);
    return messages.join(". ");
  }

  private collectErrorMessages(obj: any, messages: string[]): void {
    if (obj && typeof obj === "object") {
      Object.keys(obj).forEach((key) => {
        const value = obj[key];
        if (key === "error" && typeof value === "string") {
          messages.push(value);
        } else if (typeof value === "object") {
          this.collectErrorMessages(value, messages);
        }
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The above service can be injected into the error detection interceptor I created for the first problem. This way the interceptor can add the error message to the thrown error.

Problem 4: Bad Performance

The backend was using a non-standard way of serializing data to JSON, which caused performance problems. Therefore it could not handle many requests at the same time. I needed to find a way to reduce the number of requests without adding complexity to the frontend code.

Solutions:

1. Caching Interceptor

We can use a caching interceptor to save responses for a short time. This reduces the number of requests for the same data.

// caching-interceptor.ts
import { Injectable } from "@angular/core";
import {
  HttpInterceptor,
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpResponse,
} from "@angular/common/http";
import { Observable, of } from "rxjs";
import { tap } from "rxjs/operators";
import { CacheService } from "./cache.service";

@Injectable()
export class CachingInterceptor implements HttpInterceptor {
  constructor(private cacheService: CacheService) {}

  intercept(
    request: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    // Only cache GET requests
    if (request.method !== "GET") {
      return next.handle(request);
    }

    const cachedResponse = this.cacheService.get(request.urlWithParams);
    if (cachedResponse) {
      // Return cached response if available
      return of(cachedResponse);
    }

    // Send request and add response to cache
    return next.handle(request).pipe(
      tap((event) => {
        if (event instanceof HttpResponse) {
          this.cacheService.put(request.urlWithParams, event);
        }
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The CacheService manages how to store and use cached responses. It decides things like how long to keep the cache and if a request should be cached. Setting up the CacheService can be done in different ways, which is not explained here. The main point is that the interceptor uses this service to lower the number of HTTP requests by giving back cached responses when possible.

2. Deduplication Interceptor

We can create an interceptor that keeps track of ongoing requests. If there is already a request for the same resource, it can wait for that request to finish and share the result. This avoids making duplicate requests.

import { Injectable } from "@angular/core";
import {
  HttpEvent,
  HttpHandler,
  HttpInterceptor,
  HttpRequest,
} from "@angular/common/http";
import { Observable } from "rxjs";
import { shareReplay, finalize } from "rxjs/operators";

@Injectable()
export class DedupInterceptor implements HttpInterceptor {
  private pendingRequests = new Map<string, Observable<HttpEvent<any>>>();

  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    // Only deduplicate GET requests, let other methods pass through
    if (req.method !== "GET") {
      return next.handle(req);
    }

    // Create a unique key for the request based on the URL and parameters
    const requestKey = req.urlWithParams;

    // If there is already a request with this key, return the existing one
    if (this.pendingRequests.has(requestKey)) {
      return this.pendingRequests.get(requestKey) as Observable<HttpEvent<any>>;
    }

    // Make the request and save it, so others can use the same result
    const request$ = next.handle(req).pipe(
      // shareReplay makes sure all subscribers get the same response
      shareReplay(1),
      // Remove the request from the map when it is done (success or error)
      finalize(() => this.pendingRequests.delete(requestKey))
    );

    // Save the running request so others can use it
    this.pendingRequests.set(requestKey, request$);
    return request$;
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

By using Angular's HTTP interceptors and services, we can handle non-standard APIs easily. They help us hide the quirks of the backend. We can build our application as if we were working with a standard REST API. This keeps our code clean and simple.

Have you had to deal with unconventional REST APIs in Angular? I'd love to hear how you handled it!

💖 💪 🙅 🚩
rensjaspers
Rens Jaspers

Posted on October 24, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related