open atlas
↑ К треку
NestJS с нуля до senior NEST · 02 · 02

Guards, interceptors и exception filters

Request lifecycle в Nest фиксирован — middleware, guard, interceptor (pre), pipe, handler, interceptor (post), filter. Авторизацию — в guard, валидацию — в pipe, логирование/трансформацию — в interceptor, форму ошибки — в filter.

NEST Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Ревьюер задаёт один вопрос в твоём PR: «почему проверка роли внутри контроллера?» Хендлер начинается с if (req.user.role !== 'admin') throw new ForbiddenException(), потом валидирует тело руками, потом логирует длительность через Date.now() сверху и снизу, потом форматирует каждую ошибку в один и тот же JSON. Это работает. И это четыре сквозные задачи, размазанные по бизнес-логике и скопированные в девятнадцать хендлеров, — а в день, когда кто-то забудет строку с ролью в хендлере двадцать, это уедет в прод. У Nest уже есть слот для каждой из них. Ревьюер не просит тебя перенести код; он спрашивает, знаешь ли ты request lifecycle.

Lifecycle — это весь урок

Каждый HTTP-запрос в Nest проходит одну и ту же упорядоченную цепочку до и после твоего хендлера. Знание порядка — не мелочь: оно говорит, где каждая задача может стоять и, что не менее важно, что она видит, когда выполняется.

Последовательность: middleware → guards → interceptors (pre) → pipes → handler → interceptors (post) → exception filters (при выбросе). Guard выполняется после middleware, но до любого interceptor или pipe. Pipes выполняются после того, как guards уже решили, что запрос может пройти, — так что к моменту, когда pipe трансформирует тело, авторизация уже улажена. Interceptors — единственный этап, который выполняется дважды: на входе (до хендлера) и на выходе (оборачивая ответ). А exception filters стоят сбоку, ловя всё, что выброшено где угодно ниже.

Две тонкости отделяют сеньоров от тех, кто пролистал доки. Первая: interceptors на выходе разворачиваются по принципу first-in-last-out — на входе они идут global → controller → route, но ответ разматывается route → controller → global, как возвраты вложенных функций. Вторая: filter — это catch-all сток — pipe, который выбросил, guard, который выбросил, хендлер, который выбросил, и даже стрим interceptor, который зафейлился, — всё приземляется в один и тот же filter. Поэтому форматирование ошибки живёт там и нигде больше.

Guards: решение да/нет, прочитанное из метаданных

Guard реализует CanActivate. Он возвращает boolean (или Promise/Observable от него) — true пропускает запрос, false (или выброшенное исключение) останавливает. Это дом для аутентификации и авторизации. Guard получает ExecutionContext — надмножество ArgumentsHost, которое к тому же знает, какой хендлер и контроллер вот-вот выполнятся, — а это ровно то, что нужно, чтобы читать метаданные уровня маршрута.

Канонический паттерн — декоратор @Roles(), который штампует метаданные на хендлер, плюс RolesGuard, который читает их назад через Reflector:

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!required) return true; // нет @Roles() на маршруте -> открыт
    const { user } = context.switchToHttp().getRequest();
    return required.some((role) => user?.roles?.includes(role));
  }
}

getAllAndOverride читает метаданные сначала с хендлера, затем откатывается на контроллер, так что @Roles('admin') на уровне маршрута перекрывает дефолт контроллера. Выбрасывай ForbiddenException вместо возврата false, если хочешь 403 с сообщением, а не голый отказ.

Interceptors: оборачивают хендлер с обеих сторон

intercept(context, next) interceptor выполняет код до хендлера, затем вызывает next.handle() — который возвращает RxJS Observable с будущим ответом — и может выполнить код после, навесив операторы на этот стрим. Эта двойная позиция делает interceptors верным инструментом для всего, что обрамляет вызов: логирования и тайминга, кэширования, формирования ответа и таймаутов.

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const start = Date.now();
    return next.handle().pipe(
      tap(() => console.log(`took ${Date.now() - start}ms`)), // side-effect, данные не меняются
      map((data) => ({ data })),                              // переформировать каждый ответ
    );
  }
}

tap — для side-effect’ов, которые не трогают payload (логирование, метрики); map трансформирует тело ответа — здесь оборачивает возврат каждого хендлера в { data: ... }, чтобы весь API говорил одним конвертом. Поскольку next.handle() ленивый, pre-код выполняется синхронно в момент вызова intercept, но ничто после next.handle() не выполнится, пока хендлер не зарезолвится. Interceptor также может закоротить цепочку (вернуть свой Observable, не вызывая next.handle(), например отдав попадание в кэш) или ловить ошибки ниже оператором catchError.

Exception filters: формируй ошибку, один раз

Когда что-то в цепочке выбрасывает, встроенный слой исключений Nest ловит это и формирует ответ — HttpException и его наследники мапятся на свой статус-код, всё остальное становится 500. Кастомный @Catch()-filter позволяет владеть этой формой ответа глобально, вместо форматирования ошибок руками в каждом хендлере.

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';

@Catch(HttpException)
export class HttpErrorFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse();
    const status = exception.getStatus();
    res.status(status).json({
      statusCode: status,
      message: exception.getResponse(),
      timestamp: new Date().toISOString(),
    });
  }
}

@Catch(HttpException) сужает filter до типа; @Catch() без аргумента ловит всё. Поскольку filter — единственный сток для каждого выброса в pipeline, форматирование ошибок живёт здесь и только здесь — guard, pipe или хендлер просто выбрасывают нужное исключение и доверяют filter его отрисовать.

Куда идёт каждая задача?

Это то сеньорское суждение, к которому ведёт весь урок. Ошибка в хуке была не в плохом коде — она была в четырёх задачах, живущих не в своём слоте. Сопоставь задачу с этапом lifecycle:

ЭтапЗадачаКогда выполняетсяМожет блокировать запрос?Видит ответ?
GuardAuthn / authz (да/нет)После middleware, до interceptor + pipeДа — false / throw останавливаетНет
PipeВалидация + трансформация входаПосле guard, прямо перед хендлеромДа — выбрасывает на невалидном входеНет
InterceptorЛогирование, кэш, трансформация, таймаутДо И после хендлераДа — может пропустить next.handle()Да — мапит стрим
FilterФормирует ответ-ошибкуТолько когда что-то выброшеноN/A — обрабатывает выбросВидит ошибку, не успешное тело

Каждый из четырёх можно привязать на трёх уровнях: метод (@UseGuards() на хендлере), контроллер (тот же декоратор на классе) или глобально (app.useGlobalGuards() в bootstrap, или токен-провайдер для DI-дружелюбных глобалов). Правило: привязывай широко и перекрывай узко — глобальный filter для дефолтной формы ошибки, глобальный ValidationPipe, auth-guard на уровне контроллера и @Roles()-guard плюс метаданные на маршрут там, где живёт гранулярное решение.

Почему это работает

Почему для авторизации guard, а не middleware? Middleware выполняется первым и является фреймворк-агностичным кодом Express/Fastify — у него нет доступа к ExecutionContext Nest, поэтому он не видит, какой хендлер или контроллер — цель, и не может читать метаданные маршрута вроде @Roles(). Guard выполняется на шаг позже, с полным execution context, — именно поэтому авторизация, зависящая от маршрута, принадлежит guard. Middleware — для задач, которым не нужно знать пункт назначения: разбор сырого тела, request ID, CORS.

Выбери лучший вариант

Нужно обеспечить, чтобы эндпоинт могли вызывать только пользователи с ролью 'admin', и иначе вернуть чистый 403. Какой компонент lifecycle владеет этим?

Викторина

В request lifecycle Nest когда guard выполняется относительно pipes и interceptors?

Викторина

Ты хочешь оборачивать каждый ответ в { data: ... } и логировать, сколько занял каждый хендлер. Какой компонент и как?

Вспомните перед уходом
  1. 01
    Назови порядок request lifecycle в Nest и объясни, что особенного в том, где сидят interceptors и exception filters.
  2. 02
    Сопоставь каждую из аутентификации/авторизации, валидации входа, логирования запроса и форматирования ошибок с её верным компонентом lifecycle и назови уровни привязки.
Итог

Guards (охранники-авторизаторы), interceptors (перехватчики), pipes и exception filters — это слой сквозных задач Nest, и request lifecycle прикалывает каждый к фиксированному слоту: middleware, затем guard, затем pre-код interceptor, затем pipe, затем хендлер, затем post-код interceptor, с exception filter, ловящим любой выброс по пути. Guard реализует CanActivate, возвращает boolean или выбрасывает и читает метаданные маршрута через ExecutionContext и Reflector — что делает его домом аутентификации и авторизации, улаженной до валидации любого входа. Interceptor оборачивает хендлер с обеих сторон: он прогоняет Observable из next.handle() через RxJS-операторы вроде tap (side-effect’ы вроде логирования или тайминга) и map (формирование ответа) и может закоротить цепочку или ловить ошибки ниже. Pipe валидирует и трансформирует вход прямо перед хендлером. Exception filter, объявленный через @Catch(), — единственный сток для каждого выброса, так что форматирование ошибок живёт там одно. Сеньорское суждение — это размещение: authz — это guard, валидация — это pipe, логирование/трансформация/таймаут — это interceptor, форма ошибки — это filter, — каждый привязываемый на уровне метода, контроллера или глобально, где ты привязываешь широко и перекрываешь узко. Теперь, когда увидишь проверку роли внутри хендлера или try/catch, форматирующий ошибки прямо в сервисе, — сразу поймёшь, в какой слот это перенести.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.