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

Конвейер запроса: middleware, guards, interceptors, pipes, filters

Запрос проходит middleware → guard → interceptor(pre) → pipe → handler → interceptor(post) → filter. Две ловушки: guard видит сырое тело (pipes идут после него), а enhancer, созданный через new, вне DI — биндь через APP_GUARD, чтобы инжектить.

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

RolesGuard выглядел идеально на ревью. Он читал @Roles('admin') с хендлера через Reflector, сверял роли пользователя с базой через UsersService и возвращал false для всех остальных. Автор подключил его в main.ts так, как документация показывает для «применить везде»: app.useGlobalGuards(new RolesGuard(reflector, usersService)). Вот только reflector и usersService не были в области видимости в main.ts, поэтому кто-то передал new RolesGuard(new Reflector(), null), чтобы оно скомпилировалось. Это уехало в прод. Три недели каждый аутентифицированный пользователь был админом, потому что usersService у guard был null, проверка роли выбрасывала, а окружающая обработка ошибок по умолчанию открывала доступ. Ничто в тестах это не поймало — тесты тоже конструировали guard руками, со стабом сервиса, который всегда говорил «да». Этот урок — про семистадийный конвейер, через который проходит каждый запрос, и про два места, где сеньоры обжигаются: что видит каждый enhancer и как он получает свои зависимости.

Семь стадий, по порядку

Прежде чем писать первую строку guard или interceptor, нужно знать, на какой стадии конвейера он выполняется: стадия определяет, какие данные доступны, — и именно помещение логики не на ту стадию породило трёхнедельную дыру в безопасности из хука.

Каждый HTTP-запрос, доходящий до хендлера Nest, проходит фиксированную полосу препятствий из enhancer’ов. Порядок не настраивается, и спутать его в голове — это как раз способ положить логику авторизации не туда. Слева направо:

middleware → guard → interceptor (pre) → pipe → handler → interceptor (post) → filter (при throw)

  1. Middleware идёт первым, на уровне Express/Fastify, биндится в configure(consumer) модуля. У него есть сырые req, res, next — но он работает до того, как Nest определил, какой хендлер обслужит маршрут, поэтому у него нет ExecutionContext, нет ссылки на хендлер, нет метаданных маршрута.
  2. Guards идут следующими. canActivate(ctx) возвращает булево (или Promise/Observable от него); false коротит запрос с 403. Здесь живёт авторизация. Guard получает ExecutionContext, поэтому может читать метаданные хендлера через Reflector — но тело запроса ещё не валидировано и не трансформировано.
  3. Interceptors (pre-половина) оборачивают хендлер. Код до next.handle() выполняется здесь.
  4. Pipes работают над аргументами хендлера — @Body(), @Param(), @Query() — валидируя и трансформируя их (ValidationPipe, ParseIntPipe). Они выполняются после guards и после pre-половины interceptor’а, прямо перед хендлером.
  5. Хендлер маршрута выполняется.
  6. Interceptors (post-половина) — RxJS-pipe после next.handle() (map, timeout, кеширование, формирование ответа) выполняется на выходе. Один interceptor поэтому даёт тебе две точки выполнения вокруг хендлера.
  7. Exception filters ловят всё, что выброшено на любой из стадий выше, и маппят ошибку в HTTP-ответ. Filter — последний рубеж.
// Guard работает на стадии 2 — ДО pipes. Видит метаданные хендлера, а не валидированный DTO.
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,    // инжектируется — читает метаданные @Roles()
    private readonly users: UsersService,     // инжектируется — проверяет роли в БД
  ) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const required = this.reflector.get<string[]>('roles', ctx.getHandler());
    if (!required) return true;                // нет @Roles() → публичный маршрут
    const { user } = ctx.switchToHttp().getRequest();
    return this.users.hasAnyRole(user.id, required);
  }
}
// Interceptor — это две точки выполнения: до next.handle() и после, через RxJS.
@Injectable()
export class TimingInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const start = Date.now();                  // половина ДО handler-а
    return next.handle().pipe(                 // ← здесь выполняется handler
      map((data) => ({ data, ms: Date.now() - start })), // половина ПОСЛЕ handler-а
    );
  }
}

Область биндинга: где применяется enhancer

Каждый из четырёх видов enhancer’ов (guards, interceptors, pipes, filters) биндится на одной из четырёх областей: global, controller (@UseGuards() на уровне класса), method (@UseGuards() на хендлере), а для pipes ещё и parameter (@Body(new ValidationPipe())). Внутри вида выполнение идёт global → controller → method, самый внешний первым. Область отвечает на вопрос «где это применяется»; она не меняет порядок стадий выше.

DI-ловушка: new против APP_GUARD

Вот ловушка, которая укусила RolesGuard. Есть два способа зарегистрировать глобальный enhancer, и они не эквивалентны:

// АНТИПАТТЕРН: ты сам делаешь `new`, поэтому guard живёт ВНЕ DI-контейнера.
// Не может инжектить Reflector или UsersService — пришлось бы строить вручную.
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalGuards(new RolesGuard(/* что передать? */));
  await app.listen(3000);
}

// ВЕРНО: регистрируй через токен APP_GUARD в модуле. Nest инстанцирует через DI,
// Reflector и UsersService инжектятся нормально.
@Module({
  providers: [
    UsersService,
    { provide: APP_GUARD, useClass: RolesGuard },  // глобально И DI-aware
  ],
})
export class AppModule {}

app.useGlobalGuards(new X()) нормален для enhancer’а без зависимостей. В тот момент, когда enhancer’у нужно что-то инжектить — Reflector, ConfigService, репозиторий — ты обязан регистрировать его через провайдер-токен: APP_GUARD, APP_INTERCEPTOR, APP_PIPE или APP_FILTER. Они делают enhancer настоящим провайдером, который конструирует Nest, так что зависимости конструктора резолвятся из контейнера, а не оказываются null.

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

Почему guard просто не может прочитать валидированный DTO? Потому что pipes выполняются на стадии 4, а guards — на стадии 2: guard работает до того, как хоть один pipe тронул аргументы. В момент guard request.body — это то, что выдал парсер тела: простой объект, а не экземпляр твоего DTO-класса, числа всё ещё строки, и ни одна проверка class-validator не применена. class-transformer не отработал, поэтому body instanceof CreatePostDto равно false. Любая авторизация, зависящая от валидированного или приведённого ввода (например, «поле owner должно совпадать с subject из JWT»), не может доверять телу внутри guard — она должна валидировать нужное поле в guard вручную, либо перенести это решение в хендлер или в interceptor, который выполняется после pipe. Смешивание этих двух стадий — это как authZ-проверка молча читает undefined.

Режимы отказа, на которые натыкаются сеньоры

AuthZ в pipe или guard, ждущий валидированное тело. Поскольку guards выполняются до pipes, guard видит сырое тело — DTO-класс не инстанцирован, числа всё ещё строки. Авторизация, зависящая от валидированного ввода, принадлежит хендлеру или interceptor’у после pipe, а не guard, и никогда не pipe (pipe трансформирует данные, он не граница авторизации).

Ожидание, что interceptor смаппит ошибки. Interceptor может ловить через catchError в своём RxJS-pipe, но каноничный маппинг ошибки в ответ — это exception filter (@Catch, catch(exception, host)). Делать и то, и другое ведёт к двойной обработке. И область важна для того, что filter ловит: guard выбрасывает на стадии 2, до того как в игру вступает method-scoped filter, поэтому исключение, выброшенное внутри guard, ловит только filter более высокой области (глобальный).

Middleware, тянущийся к метаданным хендлера. Middleware выполняется до разрешения маршрута, поэтому нет ExecutionContext и нет способа прочитать @Roles() или любой декоратор хендлера. Если тебе нужны метаданные хендлера — нужен guard; именно поэтому guards существуют как отдельная стадия.

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

Твоему RolesGuard нужно инжектить Reflector (чтобы читать метаданные @Roles()) и UsersService (чтобы проверять роли в базе), и ты хочешь применить его к каждому маршруту. Как зарегистрировать его глобально?

Викторина

canActivate у guard читает request.body и проверяет `body.ownerId === user.id`. @Body() хендлера типизирован как CreatePostDto с class-validator. Что guard на самом деле видит в request.body?

Викторина

Почему `app.useGlobalGuards(new RolesGuard())` не может инжектить Reflector, а `{ provide: APP_GUARD, useClass: RolesGuard }` инжектит его правильно?

Вспомните перед уходом
  1. 01
    Перечисли семь стадий конвейера запроса Nest по порядку и для каждой скажи, что она может и не может видеть.
  2. 02
    Объясни две сеньорские ловушки: почему guard не может доверять телу и почему созданный руками глобальный enhancer не может инжектить свои зависимости.
Итог

Каждый HTTP-запрос, доходящий до хендлера Nest, проходит фиксированную полосу из семи стадий, и порядок не настраивается: middleware → guard → interceptor(pre) → pipe → handler → interceptor(post) → filter(при throw). Middleware идёт первым на уровне Express/Fastify с сырыми req/res/next, но без ExecutionContext, потому что хендлер маршрута ещё не разрешён. Guards идут следующими — canActivate решает авторизацию и может читать метаданные хендлера через Reflector, но тело ещё сырое. Pre-половина interceptor’а выполняется до next.handle(); затем pipes валидируют и трансформируют аргументы хендлера; затем выполняется хендлер; затем post-половина interceptor’а прогоняет свой RxJS-pipe на выходе (так что один interceptor — это две точки выполнения); а exception filter ловит всё выброшенное где угодно и маппит в ответ. Каждый enhancer биндится на области global, controller или method (pipes ещё и parameter), выполняясь global → controller → method, но область никогда не меняет порядок стадий. Две ловушки жгут сеньоров. Первая: поскольку guards выполняются до pipes, guard видит сырое невалидированное тело — не экземпляр DTO, числа всё ещё строки — поэтому authZ, зависящая от валидированного ввода, принадлежит хендлеру или interceptor’у после pipe, никогда guard’у, читающему «валидированное» тело, и никогда pipe. Вторая: забиндить глобальный enhancer двумя способами не эквивалентно: app.useGlobalGuards(new RolesGuard()) живёт вне DI-контейнера и ничего не инжектит (Reflector и сервисы — null, тот самый инцидент, где каждый пользователь стал админом), тогда как { provide: APP_GUARD, useClass: RolesGuard } (или APP_INTERCEPTOR/APP_PIPE/APP_FILTER) делает его DI-aware провайдером, чьи Reflector и UsersService резолвятся из контейнера. useGlobalGuards безопасен только для enhancer’а без зависимостей; всё, что с зависимостями, обязано биндиться через токен APP_*. Теперь, когда увидишь guard, который всегда пропускает, или дыру в авторизации после рефакторинга, — задай два вопроса: на какой из семи стадий выполнялась логика, и был ли enhancer сконструирован внутри или вне DI-контейнера?

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.