Главная / Статьи / Один JSON-конверт на каждый маршрут NestJS: интерцепторы плюс глобальный фильтр

Один JSON-конверт на каждый маршрут NestJS: интерцепторы плюс глобальный фильтр

Оберните каждый ответ NestJS в типизированный контейнер с помощью универсального интерцептора, декоратора сообщений и фильтра исключений, сохраняя при этом контроллеры без лишнего кода.

1288 слов

Когда каждый конечный пункт возвращает данные с незначительными отличиями, клиентам API приходится писать код с защитными мерами. Единая структура ответа позволяет пользователям использовать один обработчик ответов вместо множества. NestJS делает это возможным без изменения каждого контроллера: интерцептор преобразует успешные результаты, декоратор добавляет сообщения для каждого маршрута, а фильтр исключений придает ошибкам одинаковую структуру. В этом руководстве описывается создание всех трех компонентов.

Почему обертка ответов в контроллерах не способствует масштабированию

Примитивный подход заключается в ручном формировании такой обертки в каждом обработчике:

@Get()
findAll() {
  const users = await this.userService.findAll();
  return {
    code: 200,
    status: true,
    message: 'Success retrieve users',
    data: users
  };
}

Это дублирование, встречающееся в десятках маршрутов, со временем усиливается. Контроллеры должны принимать входные данные и возвращать информацию из домена; форматирование отправляемого данных — это общая проблема, связанная с жизненным циклом запроса. (Также обратите внимание, что в примере используется await в методе, который не помечен как async, что приведет к ошибке компиляции; еще одна причина изолировать эту логику от контроллеров.)

Определение контракта ответа

Начните с типов. IResponseEntity<T> является генерическим по содержимому данных, а необязательный объект meta хранит информацию о пагинации:

// src/common/interfaces/response.interface.ts

export interface ImetaPagination {
  page: number;
  limit: number;
  totalItems: number;
  totalPages: number;
  hasNextPage: boolean;
  hasPrevPage: boolean;
}

export interface IResponseEntity<T> {
  code: number;
  status: boolean;
  message: string;
  data?: T;
  meta?: ImetaPagination;
}

Сообщения для отдельных маршрутов с декоратором метаданных

Эндпоинт для создания объекта и эндпоинт для получения списка требуют разных сообщений об успешном выполнении. SetMetadata привязывает значение к обработчику маршрута, а небольшой оберточный класс придает ему понятное название:

// src/common/decorators/response-message.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const RESPONSE_MESSAGE_METADATA = 'response_message';
export const ResponseMessage = (message: string) =>
  SetMetadata(RESPONSE_MESSAGE_METADATA, message);

Интерцептор преобразования

Интерцептор реализует класс NestInterceptor<T, IResponseEntity<T>>, что указывает на то, что он преобразует результат работы обработчика типа T в специальную оболочку. Перед вызовом обработчика он считывает текущий код состояния из исходного ответа и получает пользовательское сообщение с помощью Reflector, применяя значение по умолчанию 'Success'. Затем он передаёт результат работы обработчика через метод map библиотеки RxJS. Если результат представляет собой объект, содержащий как поля data, так и meta, он рассматривается как результат с пагинацией и разделяется на соответствующие поля оболочки; все остальные значения напрямую становятся содержимым поля data.

// src/common/interceptors/transform-response.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { IResponseEntity } from '../interfaces/response.interface';
import { RESPONSE_MESSAGE_METADATA } from '../decorators/response-message.decorator';

@Injectable()
export class TransformResponseInterceptor<T>
  implements NestInterceptor<T, IResponseEntity<T>>
{
  constructor(private readonly reflector: Reflector) {}

  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<IResponseEntity<T>> {
    const http = context.switchToHttp();
    const response = http.getResponse();
    const statusCode = response.statusCode;

    // Extract custom message if set; default to 'Success'
    const customMessage =
      this.reflector.get<string>(
        RESPONSE_MESSAGE_METADATA,
        context.getHandler(),
      ) || 'Success';

    return next.handle().pipe(
      map((res) => {
        // Handle cases where service returns { data, meta } for pagination
        const hasMeta = res && typeof res === 'object' && 'meta' in res && 'data' in res;

        return {
          code: statusCode,
          status: true,
          message: customMessage,
          data: hasMeta ? res.data : res,
          meta: hasMeta ? res.meta : undefined,
        };
      }),
    );
  }
}

Некоторые детали, которые стоит знать:

  • reflector.get() считывает метаданные только из обработчика. Если вам также нужно значение по умолчанию на уровне класса, для этого подходит getAllAndOverride() в сочетании с context.getHandler() и context.getClass().
  • Статус считывается до запуска обработчика. Это позволяет использовать значения по умолчанию и атрибут @HttpCode(), но если обработчик динамически изменяет статус, более надежно будет считывать response.statusCode внутри функции map.
  • Проверка на пагинацию основана на принципе duck typing. Объект домена, у которого случайно есть свойства data и meta, может быть ошибочно распакован; более надежным решением будет использование специального класса с проверкой через instanceof.

Регистрация интерцептора для всего приложения

Регистрация глобально в main.ts применяет интерцептор ко всем контроллерам без необходимости использования @UseInterceptors() в каждом классе. Поскольку он создается вне системы модулей, Reflector необходимо получать из приложения и передавать вручную:

// src/main.ts
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { TransformResponseInterceptor } from './common/interceptors/transform-response.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const reflector = app.get(Reflector);
  app.useGlobalInterceptors(new TransformResponseInterceptor(reflector));

  await app.listen(3000);
}
bootstrap();

Альтернатива — зарегистрировать его как поставщик под токеном APP_INTERCEPTOR в корневом модуле. Тогда Nest будет создавать его с полным внедрением зависимостей.

Контроллеры после рефакторинга

Теперь контроллеры возвращают простые сущности или объект { data, meta }, а их сообщения объявляются с помощью декоратора:

// src/users/users.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UsersService } from './users.service';
import { ResponseMessage } from '../common/decorators/response-message.decorator';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @ResponseMessage('User successfully created')
  create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto);
  }

  @Get()
  @ResponseMessage('Users retrieved successfully')
  findAll() {
    // Returns { data: [...], meta: { page: 1, limit: 10, ... } }
    return this.usersService.findAllPaginated();
  }
}

Запрос к концу списка генерирует следующую структуру данных:

{
  "code": 200,
  "status": true,
  "message": "Users retrieved successfully",
  "data": [
    {
      "id": "usr_99",
      "name": "Alex Mercer",
      "email": "alex@example.com"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 10,
    "totalItems": 1,
    "totalPages": 1,
    "hasNextPage": false,
    "hasPrevPage": false
  }
}

Одинаковая структура ошибок

Метод map интерцептора выполняется только для тех значений, которые успешно отправляются обработчиком. Инстансы HttpException, сбои валидации и неожиданные ошибки обходят его и достигают клиентов в стандартном формате ошибок Nest.

Фильтр исключений закрывает этот пробел. Приведённый ниже фильтр использует простой синтаксис @Catch(), поэтому обрабатывает все исключения. Статус ошибки берётся из инстанцей HttpException; в противном случае используется код 500. Фильтр извлекает сообщение, объединяет массивы (например, те, что генерируются ValidationPipe) в одну строку и отвечает с параметрами status: false и data: null:

// src/common/filters/http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const exceptionResponse =
      exception instanceof HttpException ? exception.getResponse() : null;

    const message =
      typeof exceptionResponse === 'object' && exceptionResponse !== null
        ? (exceptionResponse as any).message || exception.toString()
        : exception instanceof Error
        ? exception.message
        : 'Internal server error';

    response.status(status).json({
      code: status,
      status: false,
      message: Array.isArray(message) ? message.join(', ') : message,
      data: null,
    });
  }
}

Зарегистрируйте его глобально, например с помощью app.useGlobalFilters() или токена APP_FILTER. Однако существует ограничение: для ошибок, не связанных с HTTP, этот фильтр возвращает клиенту необработанное значение exception.message, что может привести к утечке внутренних деталей. В производственной среде рекомендуется логировать исходную ошибку и отправлять общее сообщение для ответов кода 500.

Если вы предпочитаете использовать стандартную структуру ошибок вместо пользовательской, стоит ознакомиться с подробностями проблемы RFC 9457.

Основные выводы

  • Сохраняйте контроллеры простыми и позвольте механизму жизненного цикла обрабатывать вспомогательные функции.
  • Генерический интерцептор с использованием метода map из RxJS обрабатывает успешные случаи; декоратор с метаданными позволяет настраивать сообщения для каждого маршрута.
  • Интерцепторы никогда не видят возникающих ошибок, поэтому их следует сочетать с глобальным фильтром исключений, чтобы оба пути оставались в одинаковом формате.
  • Никогда не выдавайте сырые внутренние сообщения об ошибках.