Ein JSON-Umschlag pro NestJS-Route: Interceptor sowie ein globaler Filter
Umhüllen Sie jede NestJS-Antwort in eine typisierte Struktur mithilfe eines generischen Interceptors, eines Message-Decorators und eines Exception-Filters, wobei die Controller frei von Boilerplate-Code bleiben.
Wenn jeder Endpunkt eine leicht unterschiedliche Struktur zurückgibt, müssen API-Cliente defensiv programmieren. Eine konsistente Struktur ermöglicht es den Nutzern, anstelle von vielen einzelnen Antwort-Verarbeitern nur einen zu schreiben. NestJS macht dies ohne Eingriff in jeden Controller möglich: Ein Interceptor wandelt erfolgreiche Ergebnisse um, ein Dekorator liefert route-spezifische Nachrichten und ein Exception-Filter gibt Fehlern dieselbe Struktur. Diese Anleitung zeigt die Umsetzung aller drei Komponenten.
Warum das Verpacken von Antworten in Controllern nicht skalierbar ist
Der naive Ansatz besteht darin, die Struktur in jedem Handler manuell zusammenzustellen:
@Get()
findAll() {
const users = await this.userService.findAll();
return {
code: 200,
status: true,
message: 'Success retrieve users',
data: users
};
}
Da dies in Dutzenden von Routen wiederholt wird, nimmt diese Duplikation im Laufe der Zeit zu. Controller sollten Eingaben entgegennehmen und Domänendaten zurückgeben; das Formatieren des ausgehenden Datenträgers ist eine Querschnittsaufgabe, die zum Anfragen-Lebenszyklus gehört. (Beachten Sie außerdem, dass der Codeausschnitt await in einer nicht als async markierten Methode verwendet, was zu einem Kompilierfehler führt; ein weiterer Grund, die Verarbeitung solcher Aufgaben aus den Controllern herauszunehmen.)
Definieren des Antwortvertrags
Beginnen Sie mit Typen. IResponseEntity<T> ist generisch für den Datenträger, und ein optionales meta-Objekt enthält Details zur Paginierung:
// 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;
}
Routenspezifische Nachrichten mit einem Metadaten-Decorator
Ein Erstellungs-Endpunkt und ein Listen-Endpunkt verdienen unterschiedliche Erfolgsnachrichten. SetMetadata fügt einem Routenhandler einen Wert hinzu, und ein kleiner Wrapper gibt diesem einen verständlichen Namen:
// 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);
Der Transformations-Interceptor
Der Interceptor implementiert NestInterceptor<T, IResponseEntity<T>>, was besagt, dass er ein Ergebnis des Handlers vom Typ T in eine Umhüllung umwandelt. Bevor der Handler aufgerufen wird, liest er den aktuellen Statuscode aus der zugrunde liegenden Antwort und holt die benutzerdefinierte Nachricht über Reflector ab, wobei standardmäßig 'Success' verwendet wird. Anschließend leitet er das observable des Handlers über die RxJS-Funktion map weiter. Wenn das Ergebnis ein Objekt enthält, das sowohl data als auch meta enthält, wird es als paginiertes Ergebnis behandelt und in die entsprechenden Felder der Umhüllung aufgeteilt; alles andere wird direkt als data verwendet.
// 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,
};
}),
);
}
}
Einige Details, die man wissen sollte:
reflector.get()liest ausschließlich Metadaten vom Handler. Wenn Sie auch eine Standardnachricht auf Klassenebene benötigen, erfülltgetAllAndOverride()zusammen mitcontext.getHandler()undcontext.getClass()diesen Anforderung.- Der Status wird vor dem Ausführen des Handlers gelesen. Dadurch werden Standardwerte sowie
@HttpCode()berücksichtigt, doch wenn ein Handler den Status dynamisch ändert, ist es sicherer,response.statusCodeinnerhalb vonmapzu lesen. - Die Prüfung zur Paginierung erfolgt nach dem Duck-Typing-Prinzip. Ein Domänenobjekt, das zufällig
dataundmeta-Eigenschaften hat, würde versehentlich entpackt werden; eine eigene Klasse zusammen mit einerinstanceof-Prüfung ist robuster.
Registrierung des Interceptors für die gesamte Anwendung
Durch die globale Registrierung in main.ts wird der Interceptor auf jeden Controller angewendet, ohne dass für jede Klasse @UseInterceptors() benötigt wird. Da er außerhalb des Modulsystems erstellt wird, muss der Reflector aus der Anwendung abgerufen und manuell übergeben werden:
// 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();
Die Alternative besteht darin, ihn als Anbieter unter dem Token APP_INTERCEPTOR im Root-Modul zu registrieren. Nest konstruiert ihn anschließend mithilfe vollständiger Abhängigkeitsinjektion.
Controller nach der Refaktorierung
Controller geben nun einfache Entitäten oder ein { data, meta }-Objekt zurück und deklarieren ihre Nachricht mit dem Dekorator:
// 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();
}
}
Eine Anfrage an den List-Endpunkt erzeugt dieses Envelope:
{
"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
}
}
Einheitliche Struktur für Fehler
Der map-Methoden eines Interceptors wird nur für Werte ausgeführt, die vom Handler erfolgreich ausgesendet werden. Aufgeworfene HttpException-Instanzen, Validierungsfehler sowie unerwartete Fehler umgehen diesen Mechanismus und erreichen die Clients im Standardfehlerformat von Nest.
Ein Exception-Filter schließt diese Lücke. Der untenstehende Filter verwendet ein einfaches @Catch(), wodurch er alle Ausnahmen handhabt. Er leitet den Status aus HttpException-Instanzen ab, fällt bei Nichtvorhandensein darauf zurück und verwendet 500. Zudem extrahiert er eine Nachricht, kombiniert Arrays (wie die von ValidationPipe erzeugte Liste) zu einem einzigen String und antwortet mit status: false sowie 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,
});
}
}
Registrieren Sie es global, beispielsweise mit app.useGlobalFilters() oder dem Token APP_FILTER. Eine Vorsichtsmaßnahme: Für nicht-HTTP-Fehler gibt dieser Filter die rohe exception.message an den Client zurück, was interne Details preisgeben kann. In der Produktion sollten Sie den ursprünglichen Fehler protokollieren und bei 500-Antworten eine allgemeine Nachricht senden.
Falls Sie lieber ein standardisiertes Fehlerformat anwenden möchten statt eines benutzerdefinierten, lohnt es sich, einen Blick in die Problembeschreibungen von RFC 9457 zu werfen.
Haupterkenntnisse
- Lassen Sie Controller schlank bleiben und überlassen Sie die Verarbeitung des Fehlerformats dem Lebenszyklus.
- Ein allgemeiner Interceptor mit RxJS
mapformatiert Erfolge; ein Metadaten-Decorator passt die Nachrichten pro Route an.