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.
Źródła: Standardizing NestJS API Responses with Custom Interceptors and Generics
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.