Адзін 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 ўартуюць увагі.
Ключовыя вынікі
- Робіце кантролеры простымі і дазвольце механізму жыцёвага циклу кераваць ўсімі процесамі.
- Універсальны інтэрцептор за дапамою RxJS
mapадпраўляе успехі; декоратар метадаў настраюе паведамленні для кожнага маршруту.