Une enveloppe JSON par route NestJS : intercepteurs et filtre global
Enveloppez chaque réponse de NestJS dans une enveloppe typée à l’aide d’un intercepteur générique, d’un décorateur de message et d’un filtre d’exception, tout en maintenant les contrôleurs exempts de code générique.
Lorsque chaque point d’entrée renvoie une structure légèrement différente, les clients API doivent écrire du code de manière défensive. Un enveloppe cohérente permet aux utilisateurs d’écrire un seul gestionnaire de réponse au lieu de plusieurs. NestJS rend cela possible sans avoir à modifier chaque contrôleur : un intercepteur transforme les résultats réussis, un décorateur fournit des messages par route, et un filtre d’exception donne aux erreurs la même structure. Ce guide montre comment créer ces trois éléments.
Pourquoi l’encapsulation des réponses dans les contrôleurs ne permet pas une scalabilité efficace
L’approche naïve consiste à assembler manuellement l’enveloppe dans chaque gestionnaire :
@Get()
findAll() {
const users = await this.userService.findAll();
return {
code: 200,
status: true,
message: 'Success retrieve users',
data: users
};
}
Cette duplication, présente dans des dizaines de routes, s’aggrave avec le temps. Les contrôleurs doivent accepter les entrées et retourner des données du domaine ; le formatage du payload à envoyer relève d’une préoccupation transversale qui doit faire partie du cycle de vie de la requête. (Notez également que le fragment utilise await dans une méthode non marquée async, ce qui empêchera la compilation ; c’est une raison de plus pour retirer cette logique des contrôleurs.)
Définition du contrat de réponse
Commencez par les types. IResponseEntity<T> est générique concernant le payload, et un objet meta optionnel contient les détails de pagination :
// 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;
}
Messages par route avec un décorateur de métadonnées
Un point d’entrée de création et un point d’entrée listant des éléments méritent des messages de succès différents. SetMetadata attache une valeur au gestionnaire de route, et un petit wrapper lui donne un nom lisible :
// 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);
L’intercepteur de transformation
Cet intercepteur implémente NestInterceptor<T, IResponseEntity<T>>, ce qui indique qu’il transforme le résultat du gestionnaire de type T en une enveloppe. Avant d’appeler le gestionnaire, il lit le code d’état actuel de la réponse sous-jacente et récupère le message personnalisé via Reflector, en utilisant par défaut la valeur 'Success'. Ensuite, il achemine l’observable du gestionnaire à l’aide de la méthode map d’RxJS. Si le résultat est un objet contenant à la fois data et meta, il est traité comme un résultat paginé et divisé en les champs correspondants de l’enveloppe ; tout autre type devient directement la valeur de 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,
};
}),
);
}
}
Quelques détails importants à connaître :
reflector.get()ne lit que les métadonnées provenant du gestionnaire. Si vous souhaitez également disposer d’un message par défaut au niveau de la classe,getAllAndOverride(), en utilisant à la foiscontext.getHandler()etcontext.getClass(), permet de le gérer.- L’état est lu avant l’exécution du gestionnaire. Cela permet d’obtenir les valeurs par défaut ainsi que celles définies avec
@HttpCode(), mais si un gestionnaire modifie dynamiquement l’état, il est plus sûr de lireresponse.statusCodeà l’intérieur de la fonctionmap. - Vérification de la pagination basée sur le duck typing. Un objet de domaine possédant par hasard les propriétés
dataetmetapourrait être déballé par erreur ; une classe dédiée accompagnée d’une vérificationinstanceofest plus fiable.
Enregistrement de l’intercepteur pour toute l’application
En seregistrant globalement dans main.ts, l’intercepteur est appliqué à chaque contrôleur sans avoir besoin de @UseInterceptors() sur chaque classe. Comme il est créé en dehors du système de modules, le Reflector doit être récupéré dans l’application et transmis manuellement :
// 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();
L’alternative consiste à le déclarer comme fournisseur sous le token APP_INTERCEPTOR dans le module racine. Nest le construit alors grâce à une injection de dépendances complète.
Contrôleurs après le refacteurage
Les contrôleurs retournent désormais des entités simples ou un objet { data, meta }, et déclarent leur message à l’aide du décorateur :
// 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();
}
}
Une requête vers l’endpoint de liste produit cette enveloppe :
{
"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
}
}
Donner aux erreurs la même structure
Le map d’un intercepteur ne s’exécute que pour les valeurs que le gestionnaire émet avec succès. Les instances de HttpException générées, les échecs de validation et les erreurs inattendues le contournent et parviennent aux clients dans le format d’erreur par défaut de Nest.
Un filtre d’exception comble cette lacune. Celui ci-dessous utilise simplement @Catch(), ce qui lui permet de gérer toutes les exceptions. Il déduit l’état à partir des instances de HttpException, en recourant à 500 par défaut dans le cas contraire. Il extrait un message, combine les tableaux (comme la liste produite par ValidationPipe) en une seule chaîne, et répond avec status: false et 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,
});
}
}
Enregistrez-le de manière globale, par exemple avec app.useGlobalFilters() ou le token APP_FILTER. Une précaution : pour les erreurs non HTTP, ce filtre renvoie au client le message brut exception.message, ce qui peut divulguer des détails internes. En production, enregistrez l’erreur originale et envoyez un message générique pour les réponses 500.
Si vous préférez adopter une structure d’erreur standard plutôt qu’une personnalisée, les détails du problème RFC 9457 méritent d’être consultés.
Points clés
- Gardez les contrôleurs légers et laissez le cycle de vie gérer l’enveloppe.
- Un intercepteur générique utilisant
mapde RxJS traite les cas réussis ; un décorateur de métadonnées personnalise les messages par route.