Один JSON-конверт на каждый маршрут NestJS: интерцепторы плюс глобальный фильтр
Оберните каждый ответ NestJS в типизированный контейнер с помощью универсального интерцептора, декоратора сообщений и фильтра исключений, сохраняя при этом контроллеры без лишнего кода.
Когда каждый конечный пункт возвращает данные с незначительными отличиями, клиентам 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 обрабатывает успешные случаи; декоратор с метаданными позволяет настраивать сообщения для каждого маршрута.