Rens Jaspers
Posted on October 24, 2024
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
- Problem 2: Missing HTTP Methods
- Problem 3: Hidden Error Messages
- Problem 4: Bad Performance
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);
})
);
}
}
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
becamePOST /get/todo/1
-
POST /todos
becamePOST /submit/todo
-
DELETE /todos/1
becamePOST /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);
}
}
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);
}
});
}
}
}
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);
}
})
);
}
}
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$;
}
}
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!
Posted on October 24, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.