Jeden kopertka JSON na każdą trasę NestJS: interceptorzy plus filtr globalny
Otocz każdą odpowiedź NestJS typowanym „kopertem” za pomocą interceptora ogólnego, dekoratora wiadomości oraz filtru wyjątków, zachowując przy tym kontrolery wolne od kodu szablonowego.
Gdy każdy punkt końcowy zwraca nieco inny format, klienci API muszą pisać kod w sposób obronny. Spójna struktura opakowania pozwala użytkownikom napisać jeden obsługujący odpowiedzi mechanizm zamiast wielu. NestJS umożliwia to bez konieczności modyfikowania każdego kontrolera: interceptor przekształca udane wyniki, dekorator dostarcza komunikaty specyficzne dla danej trasy, a filtr wyjątków nadaje błędom ten sam format. Ten przewodnik opisuje implementację wszystkich trzech elementów.
Dlaczego opakowywanie odpowiedzi w kontrolerach nie jest skalowalne
Naiwny podejście polega na ręcznym tworzeniu tej struktury w każdym mechanizmie obsługi:
@Get()
findAll() {
const users = await this.userService.findAll();
return {
code: 200,
status: true,
message: 'Success retrieve users',
data: users
};
}
Powtarzanie się tego w dziesiątkach tras powoduje z biegiem czasu coraz większą duplikację kodu. Kontrolery powinny przyjmować dane wejściowe i zwracać informacje z domeny; formatowanie wysyłanych danych to kwestia wspólna dla wielu elementów, która powinna być obsługiwana w ramach cyklu życia żądania. (Należy również zauważyć, że w tym fragmencie użyto await w metodzie, która nie jest oznaczona jako async, co uniemożliwia skompilowanie kodu; to kolejny powód, by usunąć takie operacje z kontrolerów.)
Definiowanie kontraktu odpowiedzi
Należy zacząć od typów. IResponseEntity<T> jest generyczny w odniesieniu do treści odpowiedzi, a opcjonalny obiekt meta zawiera informacje o paginacji:
// 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;
}
Wiadomości specyficzne dla danej trasy z dekoratorem metadanych
Koniecpunkt tworzenia i koniecpunkt listy wymagają różnych komunikatów o sukcesie. SetMetadata przypisuje wartość do obsługi trasy, a niewielki wrapper nadaje jej czytelne nazwę:
// 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);
Interceptor transformacji
Interceptor implementuje NestInterceptor>, co wskazuje, że przekształca wynik obsługi o typie T w kopertę. Przed wywołaniem obsługi odczytuje aktualny kod stanu z odpowiedzi i pobiera wiadomość dostosowaną za pomocą Reflector, przyjmując wartość domyślną 'Success'. Następnie przekazuje obserwowalny obiekt obsługi przez funkcję map w RxJS. Jeśli wynik to obiekt zawierający zarówno data, jak i meta, traktuje się go jako wynik paginowany i dzieli na odpowiednie pola koperty; wszystko inne staje się bezpośrednio wartością 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,
};
}),
);
}
}
Kilka szczegółów, które warto znać:
reflector.get()odczytuje wyłącznie metadane z obsługiwanego elementu. Jeśli chcesz również domyślną wiadomość na poziomie klasy,getAllAndOverride()w połączeniu zcontext.getHandler()icontext.getClass()rozwiązuje ten problem.- Stan jest odczytywany przed uruchomieniem obsługiwanego elementu. Dzięki temu pobierane są wartości domyślne oraz informacje z
@HttpCode(), ale jeśli obsługa zmienia stan dynamicznie, bezpieczniej jest odczytaćresponse.statusCodewewnątrz funkcjimap. - Sprawdzenie paginacji opiera się na zasadzie duck typing. Obiekt domenowy, który przypadkowo posiada właściwości
dataimeta, mógłby zostać błędnie rozpakowany; dedykowana klasa wraz z sprawdzeniem typu za pomocąinstanceofjest bardziej niezawodna.
Rегистrowanie interfektora dla całego aplikacji
Rегистrowanie na poziomie globalnym w main.ts stosuje interceptor do każdego kontrolera bez konieczności używania @UseInterceptors() w każdej klasie. Ponieważ jest on tworzony poza systemem modułów, Reflector musi być pobrany z aplikacji i przekazany ręcznie:
// 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();
Alternatywą jest zarejestrowanie go jako dostawcy pod tokenem APP_INTERCEPTOR w module korzeniowym. Wtedy Nest konstruuje go przy użyciu pełnej iniekcji zależności.
Kontrolery po refaktoryzacji
Kontrolery zwracają teraz proste entity lub obiekt { data, meta }, a ich funkcje są deklarowane za pomocą dekoratora:
// 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();
}
}
Zapytanie do endpointu listy generuje następujący obiekt:
{
"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
}
}
Dawanie błędom tej samej struktury
map interceptora jest wykonywany tylko dla wartości, które z powodzeniem zostały wysłane przez obsługę. Przypadek rzucenia instancji HttpException, błędy walidacji oraz nieoczekiwane błędy omijają ten interceptor i docierają do klientów w domyślnym formacie błędów Nesta.
Filtr wyjątków zamienia tę lukę. Ten poniżej używa prostego @Catch(), więc obsługuje każdy wyjątek. Status błędu pobiera z instancji HttpException, w przeciwnym razie używa wartości 500. Wyodrębnia komunikat, łączy tablice (takie jak lista wygenerowana przez ValidationPipe) w jedną ciąg znaków, a następnie odpowiada z wartościami status: false i 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,
});
}
}
Zarejestruj go globalnie, na przykład za pomocą app.useGlobalFilters() lub tokena APP_FILTER. Jedna uwaga: w przypadku błędów innych niż HTTP ten filtr zwraca do klienta surowy tekst exception.message, co może ujawnić wewnętrzne szczegóły. W środowisku produkcyjnym należy zapisywać oryginalny błąd i wysyłać ogólny komunikat w odpowiedziach typu 500.
Jeśli wolisz zastosować standardową strukturę błędów zamiast własnej, warto zapoznać się z szczegółami problemu RFC 9457.
Główne wnioski
- Zachowuj kontrolery proste i pozwalaj mechanizmom cyklu życia zajmować się obsługą odpowiedzi.
- Ogólny interceptor z użyciem funkcji
mapz RxJS przekształca pomyślne operacje; dekorator metadanych dostosowuje komunikaty do poszczególnych tras.
Literatura pokrewna
- Wewnatrz interceptorów NestJS: Naprawa regresji opóźnienia 96% na wielką skalę — Dowiedz się, jak pipeline wykonywania AOP w NestJS oraz pułapki przy dezinstalacji RxJS spowodowały gwałtowny wzrost opóźnienia P99, oraz jak stworzyć interceptor audytowy bez zużycia pamięci w celu jego naprawy.
- Balansowanie generowania CRUD Prisma z umyślną kontrolą ścieżek — Dowiedz się, jak generowanie routerów CRUD Prisma oparte na schematach może wyeliminować powtarzalne fragmenty kodu, zachowując jednocześnie decyzje dotyczące zaufania, zakresu działania i dostępu w kodzie aplikacji.
- Ważny JSON, złamany kontrakt: warstwowe sprawdzenia dla regresji ładunku — Naucz się wykrywać regresje w ładunku JSON, które są poprawnie parsowane: różnice semantyczne, skoncentrowane JSON Schema, twierdzenia dotyczące reguł biznesowych w Node oraz ograniczenia generowanych typów.