Interceptors
Wrap handlers with interceptors: name the handler in a response header on the way in, and map every response body into an envelope on the way out.
What you'll learn
- Implement NestInterceptor and use the ExecutionContext to name the handler and set a response header before it runs
- Transform response bodies with the map operator, and explain why a thrown exception is not mapped
- Bind interceptors to a controller with @UseInterceptors and to the application with useGlobalInterceptors
Guards decide before a handler runs; filters act after one throws. Between them is a gap: code that runs around a handler on the way in and on the way out, with access to what the handler returned. That is an interceptor. It is the tool for concerns that wrap handlers: timing and logging, response envelopes, mapping errors, caching, timeouts. It knows the handler, like a guard, and it sees the result, which nothing else in the pipeline does.
The interface
@Injectable()
export class TimingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const started = Date.now();
return next.handle().pipe(tap(() => console.log(`took ${Date.now() - started}ms`)));
}
}
intercept() receives two things. The ExecutionContext is the one from lesson 8, so context.getClass() and context.getHandler() name the controller and the method, and context.switchToHttp().getResponse() reaches the response, on which setHeader(name, value) adds a header before the body goes out. next.handle() calls the handler and returns its result as a stream.
Nothing runs until you call next.handle(). An interceptor that never calls it never runs the handler, which is how a cache interceptor answers from memory: it returns of(cachedValue) instead.
The stream
next.handle() returns an RxJS Observable. You do not need to know RxJS for this lesson; one idea is enough. An Observable is a value that will arrive later, like a Promise, except that it can be transformed with operators through .pipe(), and each operator describes what to do with the value when it comes:
tap(fn)runsfnwith the value and passes the value on unchanged. Logging, timing, metrics.map(fn)replaces the value with whateverfnreturns. Envelopes, renaming, stripping fields.catchError(fn)runs when the handler threw, and can replace the error with another or with a value.timeout(ms)fails the stream if the handler takes too long.
Here is the docs' interceptor that replaces null results with an empty string:
return next.handle().pipe(map((value) => (value === null ? '' : value)));
map receives exactly what the handler returned and its return value becomes the response body. The status code is untouched. Two things a map never sees: a thrown exception, because an error is not a value on the stream, so error responses keep their shape unless catchError handles them; and handlers that write the response themselves through @Res(), because there is no return value to map.
Binding
@UseInterceptors(TimingInterceptor) on a handler or on a controller class; app.useGlobalInterceptors(new TimingInterceptor()) in main.ts for everything; the APP_INTERCEPTOR token for a global interceptor that needs dependencies. Several interceptors nest: the first one bound runs first on the way in and last on the way out.
Where it sits
Interceptors run after guards and before pipes on the way in, then again after the handler on the way out, before the response is sent. A guard's refusal never reaches an interceptor. A handler's exception passes through catchError, if there is one, on its way to the exceptions layer.
Your task
The starter has two interceptors, both incomplete.
LoggingInterceptoralready times the handler. Make it also name the handler in anx-handlerresponse header, asClassName#method, for instanceCatsController#findAll, and bind it toCatsController.TransformInterceptorshould wrap whatever a handler returns in an envelope,{ data: ... }. Bind it to the whole application.
The tests check the envelope on successes, the header on every response, and that a 404 keeps Nest's error body while still carrying the header, because the interceptor ran before the handler threw.
When it fails
- The header is missing: it is set after
next.handle()resolved, when the response has already gone. Set it before returning the stream. - The header reads
CatsController#:getHandler().nameis empty because the method lost its name; in this editor that does not happen, but it is what a minifier does to unnamed functions. - The 404 body is
{ "data": ... }: something turned the error into a value.mapshould not;catchErrorreturning a value would. - Bodies are not wrapped:
TransformInterceptoris not bound globally, ormapreturnsdataitself instead of an object containing it.
Remember
- An interceptor wraps the handler: code before
next.handle()runs on the way in, operators on the stream run on the way out. tapobserves,mapreplaces,catchErrorhandles errors,timeoutbounds.- Errors are not values on the stream;
mapnever sees them. - The
ExecutionContextnames the class and the handler, and reaches the response for headers.
Stuck? Show a hint
In LoggingInterceptor: context.getClass().name and context.getHandler().name give the two halves; context.switchToHttp().getResponse().setHeader() sets the header, and it must happen before next.handle() is returned. In TransformInterceptor: next.handle().pipe(map(...)) where the function returns an object with a data property. Bind with @UseInterceptors on the class and app.useGlobalInterceptors in main.ts.