We use cookies to enhance your experience on the site
CodeWorlds

Interceptors - the courier who comes back with an answer

The sentry at the outer post counts those coming in. The treasury guard decides who may enter. Both end their duty at the same moment - when the visitor crosses the threshold. Neither sees what he walks out with.

The courier does. He rides to the governor with an order, waits for the reply, and brings it back - and along the way he may add something to it. An interceptor is the first mechanism of the request cycle that also runs code after the handler has finished, and that single sentence explains almost everything it does.

The interface and the signature

An interceptor is a class implementing the

NestInterceptor
interface. Not
CanActivate
- that belongs to the guards of the previous lesson; not
PipeTransform
- those are pipes, which come right after this lesson; not
ExceptionFilter
- that one deals with catching errors. The interface calls for a single method:

1import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
2import { Observable } from 'rxjs';
3
4@Injectable()
5export class AuditInterceptor implements NestInterceptor {
6  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
7    return next.handle();
8  }
9}

The signature has three parts written in this order:

intercept(
opens the method,
context: ExecutionContext,
is the first argument, and
next: CallHandler)
the second. The interceptor above does nothing yet - it passes the request on and hands back the result untouched.

You already know

ExecutionContext
from guards: it is the same object, with the same
getHandler()
and
switchToHttp()
methods. What is new is
CallHandler
- a handle to the rest of the chain. Until you call
handle()
on it, the controller's handler does not run at all.

Four phases

An interceptor's work divides into four stages, always in this order:

  1. Calling the
    intercept()
    method.
  2. Logic before passing the request (before).
  3. Executing the route handler (
    next.handle()
    ).
  4. Response processing (after).

The division is plain to the eye in the code, and one boundary is all you need to remember: everything you write before

return next.handle()
is the before phase; everything that goes into
.pipe(...)
is the after phase.
A single method covers both legs of the courier's journey - the ride out and the ride home.

That is exactly why an interceptor can measure how long a request took, which neither middleware nor a guard can do: it records a mark in the before phase and reads it in the after phase, within one method and off the same variable.

tap - acting without changing the result

next.handle()
does not return finished data but an
Observable
- a stream from the RxJS library into which the response will arrive. Operations on the stream are attached with the
.pipe()
method, and the operator most commonly used to execute logic after receiving a response is
tap
:

1@Injectable()
2export class LoggingInterceptor implements NestInterceptor {
3  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
4    const start = Date.now();
5
6    return next.handle().pipe(
7      tap(() => console.log(`Time: ${Date.now() - start}ms`)),
8    );
9  }
10}

const start = Date.now()
runs in the before phase, the function inside
tap
in the after phase, once the response is back. The difference between the two readings is the time the request took.

The name

tap
comes from wiretapping: the operator looks at the value flowing past and lets it through unchanged. That is precisely what we want for logging - a client's response must not depend on whether we happen to be writing to the console. Its three neighbours in RxJS do something else:
map
replaces the value,
filter
can hold it back, and
reduce
folds a whole stream into one value and waits for it to end - in an HTTP request there is a single value, so there is nothing to fold.

map - changing the shape of the response

When the response really is meant to look different, you reach for

map
. The typical use is a common envelope for every endpoint:

1@Injectable()
2export class TransformInterceptor implements NestInterceptor {
3  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
4    return next.handle().pipe(
5      map((data) => ({ success: true, data })),
6    );
7  }
8}

The controller returns a list of tributes and the client receives

{ success: true, data: [...] }
- and the same at every other endpoint, without adding a single line to any controller. The difference from the previous example fits in one sentence:
tap
watches,
map
replaces
. If the function in
tap
returns something, it is ignored; what the function in
map
returns becomes the response.

RxJS has more such operators, and some are useful in interceptors straight away:

catchError
catches an exception thrown by the handler,
timeout
cuts off a request that takes too long. All of them go into the same
.pipe()
.

Attaching it

An interceptor is attached with the

@UseInterceptors
decorator - on a method or on a whole controller:

1@Controller('tributes')
2@UseInterceptors(LoggingInterceptor)
3export class TributesController {
4  @Get()
5  findAll() {
6    return this.tributesService.findAll();
7  }
8}

You pass the class, not an instance of it - creation is left to dependency injection, which is what lets an interceptor take its own dependencies in the constructor. Several interceptors separated by commas form a chain: the before phase runs in the order written, the after phase in reverse, because the stream comes home the way it went.

When the envelope is to cover the whole application, you register the interceptor globally:

app.useGlobalInterceptors(new TransformInterceptor())
in
main.ts
, or as a provider with the
APP_INTERCEPTOR
token. The second road is the better one wherever the interceptor needs something itself - a provider goes through dependency injection,
new
does not.

Summary

The courier rides out and comes back, @name:

  • an interceptor implements the
    NestInterceptor
    interface
    - not
    CanActivate
    , not
    PipeTransform
    , not
    ExceptionFilter
    ,
  • the signature in order:
    intercept(
    context: ExecutionContext,
    next: CallHandler)
    ,
  • four phases: calling
    intercept()
    → logic before passing the request (before) → executing the route handler (
    next.handle()
    ) → response processing (after),
  • the boundary is simple: before
    return next.handle()
    is the before phase, inside
    .pipe(...)
    is the after phase,
  • next.handle()
    returns an
    Observable
    ; without calling it the handler never runs,
  • tap
    is the operator most commonly used for logic after receiving a response
    - it watches the value and lets it through unchanged, unlike
    map
    (replaces),
    filter
    (holds back) and
    reduce
    (folds the stream),
  • timing:
    const start = Date.now()
    in the before phase,
    tap(() => ...)
    with a second
    Date.now()
    in the after phase,
  • map((data) => ({ success: true, data }))
    wraps the response in a common envelope,
  • attached with
    @UseInterceptors(LoggingInterceptor)
    , globally through
    useGlobalInterceptors
    or the
    APP_INTERCEPTOR
    token.

In the next lesson you will meet pipes - the fourth and last mechanism of the cycle, which takes on the individual arguments of a method before it runs at all. For now remember: middleware and guards work on the way in; the interceptor alone rides both ways.

Go to CodeWorlds