InteractiveFrameworks

Pipes, guards and interceptors

The request pipeline you know from HTTP, on a message: a ValidationPipe that refuses a bad payload with an RpcException, a guard that reads its key from the payload, and an interceptor that audits every handler, in the order Nest runs them.

What you'll learn

  • Bind pipes, guards and interceptors to message handlers exactly as to routes, and know they run in the same order: guards, interceptors, pipes, handler
  • Validate a payload with ValidationPipe and an exceptionFactory that throws an RpcException, since its default BadRequestException means nothing to a transport
  • Know that pipes only run on decorated parameters: a DTO needs @Payload() to be validated
  • Read a message's data and handler in a guard or interceptor through ExecutionContext.switchToRpc() and getHandler()

The gateway now forwards whatever it is given: a new cat's body, a request to remove one. It would be easy to validate and authorize there. But the cats service will not only be called by this gateway: the adoption desk and the vet's scheduler will send it messages too. Rules that live in the gateway protect one door out of three. The service must defend itself, and it can, with the tools you already know from HTTP.

Pipes, guards, interceptors and filters work on message handlers exactly as on routes. They are bound with the same decorators and run in the same order: guards, then interceptors, then pipes, then the handler, and the interceptors again on the way out. Filters catch whatever is thrown anywhere along the way. There is no middleware: a message is not an HTTP request. What changes is where the data lives and what an error must be.

Validating a payload

ValidationPipe validates a message's payload against a DTO as it validates a body. Its default refusal is a BadRequestException, and the previous lesson showed what an HttpException becomes on a transport: Internal server error, logged nowhere. The exceptionFactory option decides what the pipe throws instead:

@MessagePattern('orders.place')
@UsePipes(new ValidationPipe({ exceptionFactory: (errors) => new RpcException({ status: 422, errors: errors.map((e) => e.property) }) }))
place(@Payload() order: PlaceOrderDto) { ... }

errors is class-validator's list of ValidationErrors. Each one's constraints maps a rule to its message, such as { isString: 'name must be a string' }. Build the refusal your callers can use.

The pipe runs on the payload, the handler's first parameter. A handler with no decorated parameter gets it anyway, as if it carried @Payload(). Writing the decorator changes nothing, but it says where the data comes from.

Guarding a message

A guard reads the message through the ExecutionContext. switchToRpc() gives its payload (getData()) and the transport's context (getContext()). A TCP message has no headers, so anything the guard needs, a key or a user's id, must travel in the payload:

@Injectable()
export class OpenHoursGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const { shelterId } = context.switchToRpc().getData<{ shelterId: number }>();
    if (!this.hours.isOpen(shelterId)) throw new RpcException({ status: 423, message: 'Closed' });
    return true;
  }
}

Returning false also refuses, but with Nest's { status: 'error', message: 'Forbidden resource' }, which carries no number the caller could turn into a 403. Throw the RpcException you want the caller to see.

Intercepting a message

An interceptor wraps the handler, as on HTTP. context.getType() is 'rpc' for a message. context.getHandler() is the method about to run, and context.getClass() its controller. next.handle() is the reply stream: operators on it see every value, and the error if the handler, or a pipe before it, throws. Interceptors bound to a controller wrap its event handlers as well: an event handler's "reply" is the undefined it returns.

An interceptor that needs a provider, a log or a clock, is bound by its class, @UseInterceptors(AuditInterceptor). Nest then creates it with its dependencies. An instance you create yourself with new gets none.

Your task

The service can create and remove cats now, and it has an AuditLog (a list of strings) that the gateway serves at GET /audit. The gateway forwards POST /cats bodies as they are, and puts the x-staff-key header of DELETE /cats/:id into the payload as staffKey.

  1. Validate { cmd: 'create' }'s payload against CreateCatDto. A payload that fails is refused with { status: 400, message: [every failed constraint's message] }.
  2. Write StaffGuard: a message whose payload's staffKey is whiskers passes, anything else is refused with { status: 403, message: 'Staff only' }. Guard { cmd: 'remove' } with it.
  3. Write AuditInterceptor: when a handler replies it adds <handler name> ok to the AuditLog, when it errors <handler name> failed. The error still reaches the caller. Bind it to the whole CatsController.

Then read GET /audit after a refused removal, and find out which of the three ran first.

When it fails

  • An invalid cat is a 502 Internal server error: the pipe threw its default BadRequestException. Give it an exceptionFactory that throws an RpcException.
  • A removal without a key is a 502 Forbidden resource: the guard returned false. Throw an RpcException with a status.
  • Every call is a 502 and the service logs TypeError: Cannot read properties of undefined (reading 'entries'): the interceptor was bound as new AuditInterceptor(), so nothing injected its AuditLog. Bind the class.
  • The audit misses the failures: tap(() => ...) only sees values. tap({ next, error }) sees both.

Remember

  • Guards, interceptors, pipes and filters bind to message handlers as to routes, and run in the same order.
  • Whatever they refuse with must be an RpcException: exceptionFactory for pipes, a throw in guards.
  • switchToRpc().getData() is the payload; a key or an identity travels inside it.
  • Bind by class to get dependency injection; a refused message never reaches an interceptor.
Stuck? Show a hint

@UsePipes(new ValidationPipe({ exceptionFactory: (errors) => new RpcException({ status: 400, message: errors.flatMap(e => Object.values(e.constraints ?? {})) }) })) and @Payload() on the DTO parameter. StaffGuard: context.switchToRpc().getData(), throw new RpcException({ status: 403, message: 'Staff only' }). AuditInterceptor: inject AuditLog, read context.getHandler().name, and next.handle().pipe(tap({ next, error })). @UseInterceptors(AuditInterceptor) on the controller class.