Introduction

Angular interceptors are a feature that is widely known, but seldom used aside from the most basic needs (authentication, authorization). However, there are lots of use cases when interceptors can be very useful. In this article, we will explore some interesting and very common scenarios in which interceptors can come to our aid.

What is an interceptor?

Well, first, let us understand what an interceptor is. An Angular interceptor is (in a modern setting anyway) a function that utilizes the "Chain of Responsibility" design pattern and allows us to interject logic into the HTTP request/response cycle. This means we can "catch" the request, modify it, and then pass it on to the next interceptor in the chain and so on. Then we can do the same with the response (this will come handy later).

Now, with this knowledge, lets embark on a journey of creating several interceptors to make our code that deals with HTTP much simpler.

The most basic example

Let's get this one out of the way quickly: almost everyone has written an AuthInterceptor in their app. Here is a simplistic example:

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const token = authService.getToken();

  if (token) {
      req = req.clone({
          setHeaders: {
              Authorization: `Bearer ${token}`,
          }
      });
  }

  return next(req);
};

Here we just pick the token from the AuthService and add it to the request headers. Pretty simple, right? However, we should be careful with this. In his article, Tim Deschryver explores a security issue that arises from this approach: what if we send requests to other, 3rd-party APIs too? This way, our request will be exposed to that API, and they might be able to read sensitive data about our users. We can fix this by checking the URL before adding the token:

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const uri = new URL(req.url);
  if (uri.hostname !== 'trusted-domain.com') {
    return next(req);
  }
  const authService = inject(AuthService);
  const token = authService.getToken();

  if (token) {
      req = req.clone({
          setHeaders: {
              Authorization: `Bearer ${token}`,
          }
      });
  }

  return next(req);
};

Great, we figured our authentication out. So, what else can we do?

Altering the request URL

In large applications, it is common to have several environments, and several API URLs, from which we then would have to pick one to use. It is customary to store such information in an environment file (this might differ from codebase to codebase), so we will assume we have an Environment injectable that provides us with the current environment and its API URL. However, this does not solve the problem of having to constantly include the URL when performing the request:

@Injectable({providedIn: 'root'})
export class DataService {
    private readonly http = inject(HttpClient);
    private readonly environment = inject(Environment);

    getData() {
        return this.http.get(this.environment.apiUrl + '/data');
    }

    getDataById(id: number) {
        return this.http.get(this.environment.apiUrl + `/data/${id}`);
    }
}

As we can see, we have to include the URL in the request every time we want to make a request. While this is not a big deal, this might be a source of short, but frustrating bugs, and, additionally, become a big headache if the logic of determining an API URL changes (might have us refactoring a billion API call methods). So, we can just use an interceptor for this:

export const apiUrlInterceptor: HttpInterceptorFn = (req, next) => {
  const environment = inject(Environment);
  const baseUrl = environment.getAPIUrl();

  req = req.clone({
      url: `${baseUrl}/${req.url}`,
  });
  return next(req);
};

Now, our service can simply do the HTTP call with a relative URL:

@Injectable({providedIn: 'root'})
export class DataService {
    private readonly http = inject(HttpClient);

    getData() {
        return this.http.get('/data');
    }

    getDataById(id: number) {
        return this.http.get(`/data/${id}`);
    }
}

Next, let us consider the connection of interceptors to the lifecycle and the state of our application.

Interacting with application state from interceptors

Consider the following example: we want to display a small loading bar on top of any page when an HTTP request is in progress. To further demonstrate the capabilities of interceptors, we will also assume that our application is using some state management solution like NgRx, and we have an action called setLoading that sets the loading state to true or false. This way, because we can use DI in interceptors, we can use our interceptor to affect the UI of our application and display that loading bar:

export const loaderInterceptor: HttpInterceptorFn = (req, next) => {
  const store = inject(Store);
  store.dispatch(setLoading(true));
  return next(req).pipe(
    tap((res) => {
      if (res.type === HttpEventType.Response) {
        store.dispatch(setLoading(false));
      }
    })
  );
};

As you can see, in this interceptor, we actually interact more with the response than the request. This will become a recurring pattern in further interceptors, as we will see.

With this interceptor, our methods don't have to change slightly, and the component will just get it's data from the store, without ever knowing how the loading got to be true or false.

But what if don't want to show the loading bar on every single request? For instance, if we are doing some "undercover" HTTP requests like logging, or third party initializations, we might want to skip showing loaders for those as to not create an impression of having a very heavy application. So how we do this?

Adding context to interceptors.

Essentially, Angular provides us with a way to add custom context to HTTP requests. This is accomplished via the HttpRequestContextToken token. This is a class that allows us to define some custom metadata to pass on with a particular request, which then can be read and acted upon by interceptors.

Let's see how we can use this to our advantage:

export const NoLoaderToken = new HttpContextToken<boolean>(() => false);

@Injectable({providedIn: 'root'})
export class LoggerService {
    log(data: any) {
        return this.http.post('log', {context: NoLoaderToken, body: data});
    }
}

Here, we use the HttpContextToken to define a NoLoaderToken token that we can use to skip the loader. Here is how modify our interceptor to use this token:

export const loaderInterceptor: HttpInterceptorFn = (req, next) => {
  
  if (req.context.get(NoLoaderToken)) {
    return next(req);
  }

    const store = inject(Store);
    store.dispatch(setLoading(true));
    return next(req).pipe(
      tap((res) => {
        if (res.type === HttpEventType.Response) {
          store.dispatch(setLoading(false));
        }
      })
    );
  }
};

Now, we can clearly differentiate between requests that should not have a loader and those that should!

Next, let us discuss resolving tensions between front-end and back-end using interceptors.

Throwing on implicit errors

In some APIs (quite commonplace nowadays), instead of having some sort of HTTP error code, we might have a response that is just an object with a boolean indicating if the request was successful or not. The typical response from such an API might look like this:

{
  "success": true,
  "data": {
    "id": 1,
    "name": "John Doe"
  },
  "error": null
}

Whether this is a good idea or not is still up for a debate, however, this can become a problem when front-end and back-end developers have different opinions on the whole thing. It can also be problematic when handling errors in services, as we might want to double-check:

@Injectable({providedIn: 'root'})
export class DataService {
    private readonly http = inject(HttpClient);

    getData() {
        return this.http.get('/data').pipe(
            map(res => {
                if (res.success) {
                    return res.data;
                }
                // handle error in some way
            }),
            catchError((err) => {
                // notice that even with this approach, 
                // we still have to write `catchError`, 
                // as errors can arise not only from the backend
                // but also from network connection
                // bugs in our won code and so on
            })
        );
    }
}

So, how do we address this in a way that helps us avoid duplicating the same code all over our project files? Surely, intercepting responses will give us an answer:

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
    return next(req).pipe(
        map(res => {
            if (res.type === HttpEventType.Response) {
                const body = res.body as {success: boolean, message?: string};
                if (body.success === false) {
                    throw new HttpErrorResponse({error: body.message});
                }
                return res;
            }
            return res;
        }),
    );
}

Here, we interject a piece of logic that detects if the backend responded with {success: false} and throws an actual error. Now, our service can just use the catchError operator to handle the error, no need to double-check.

@Injectable({providedIn: 'root'})
export class DataService {
    private readonly http = inject(HttpClient);

    getData() {
        return this.http.get('/data').pipe(
            map(res => res.data),
            catchError((err) => {
                // handle error in some way
            })
        );
    }
}

Next, let us see how we can modify the body itself.

Altering the body

We will continue looking at the previous example, and notice that because of this type of backend response, we constantly use map(res => res.data) to get the data itself from the response. Again, we can just use an interceptor to alter the body before it ever gets to the service:

export const responseUnwrapInterceptor: HttpInterceptorFn = (req, next) => {
    return next(req).pipe(
        map(res => {
            if (res.type === HttpEventType.Response) {
                const body = res.body as {success: boolean, data?: any, message?: string};
                if (body.success === true) {
                    return res.clone({body: body.data});
                }
                return res;
            }
            return res;
        }),
    );
}

And that's it, now our components (or NgRx Effects, for instance), can just read the data, or deal with an error using catchError without having to worry about the form of the response from backend.

Final things to consider

As we saw, interceptors are a powerful tool, but as anything that affects the entire application, it is important to be careful. We already saw a way to introduce a security vulnerability in the very first example, and we also saw potential issues where we got help from the HttpContextToken class.

Another thing that is important to keep in mind is that interceptors are executed in the same order as they are provided in our application configuration, so it is important to order them in a logical way. For instance, our authInterceptor uses the hostname for security reasons, so we need to place it after the apiUrlInterceptor in order to have it work. Then we can use the loaderInterceptor. Finally, response interceptors should probably come after the ones that handle only the request part, and in our case, the errorInterceptor should come before the responseUnwrapInterceptor, as we want to catch errors before we try to unwrap the response.

Ideally our config will look like this:

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withInterceptors([
        authInterceptor,
        apiUrlInterceptor,
        loaderInterceptor,
        errorInterceptor,
        responseUnwrapInterceptor,
      ])
    ),
  ],
};

So, it is important to keep all these concerns in mind.

Conclusion

Interceptors can be amazing, and are, again, underutilized, while being able to significantly simplify our code in regards to simple, recurring tasks. There are many, many more concerns that we did not cover here, like caching, logging, and so on, but some of them are already mentioned in the official docs, and can be easily implemented. Hopefully, this helps readers grasp way more capabilities that interceptors provide, and improve their codebases.

Small promotion

Modern Angular.jpeg

You might have noticed that this article is using function-based interceptors only. The recent upheaval in Angular has caused many developers to be confused about what solutions to chose, how to implement them, and how to migrate their existing codebases to the most recent features. Thankfully, I have a response to this concern: very soon, my very first book is going into print!

It is called "Modern Angular" and it is a comprehensive guide to all the new amazing features we got in recent versions (v14-v18), including standalone, improved inputs, signals (of course!), better RxJS interoperability, SSR, and much more. If this interested you, you can find it here. The book is now in the copy-editing phase with a release scheduled shortly, so it is currently in Early Access, with all the 10 chapters already available online. If you want to keep yourself updated on the print release, you can follow me on Twitter or LinkedIn, where I will be posting whenever there are news or promotions available.


Tagged in:

Articles

Last Update: November 14, 2024