Cours NestJS · Module 2: Routing et cycle de requête
PROJET - le trésor des tributs, ou le cycle complet d'une requête
Dans cette leçon7
Ce module a parcouru toute la route qu'emprunte une requête : de la route elle-même aux middlewares, aux guards, aux interceptors et aux pipes, jusqu'aux filtres d'exception et au cycle de vie complet. Tu as rencontré chaque mécanisme séparément. Le projet est l'endroit où ils doivent agir ensemble - et où l'on verra si tu sais lequel répond de quoi.
Tu vas construire l'API d'un trésor des tributs : un registre des richesses de l'Empire, avec contrôle d'accès, validation et traitement uniforme des erreurs.
La mesure du succès n'est pas un nombre de fonctionnalités
Ce projet ne se juge pas au nombre d'endpoints écrits. Il se juge à une seule chose : chaque responsabilité a-t-elle atterri dans le bon mécanisme ?
Le partage est strict et découle de ce que chacun peut faire :
- Le middleware - journalisation, identifiant de corrélation, en-têtes CORS. Il ne connaît pas la route cible : il ne doit donc pas décider de l'accès.
- Le guard - qui a le droit d'entrer. Il connaît le handler et renvoie un booléen.
- Le pipe - un seul argument de méthode : validation et transformation.
- L'interceptor - la forme de la réponse et la mesure du temps ; le seul à agir dans les deux sens.
- Le filtre d'exception - une forme uniforme pour l'erreur qui part vers le client.
- Le décorateur personnalisé - un raccourci pour extraire sans cesse les mêmes données du contexte.
Les deux erreurs les plus fréquentes viennent d'une confusion de ces rôles. L'autorisation dans un middleware ne peut pas fonctionner correctement, car à cet instant NestJS ignore encore quel endpoint traitera la requête - et donc quel rôle il exige. La validation dans un service fonctionne, mais elle est au mauvais endroit : elle se répète dans chaque méthode et s'exécute après que les données ont déjà traversé la moitié de l'application.
Étape 1 - les routes et le contrôleur
Commence par ce qui se voit de l'extérieur :
1@Controller('tributes')
2export class TributesController {
3 @Get(':id')
4 findOne(@Param('id', ParseIntPipe) id: number) {
5 return this.tributesService.findOne(id);
6 }
7
8 @Post()
9 @UseGuards(AuthGuard, RolesGuard)
10 @Roles('quaestor')
11 create(@Body() dto: CreateTributeDto, @CurrentUser() user: User) {
12 return this.tributesService.create(dto, user);
13 }
14}Deux choses y sont délibérées. ParseIntPipe sur @Param garantit qu'un number parvient à la méthode : l'annotation de type n'est donc pas un vœu. Et @CurrentUser() est ton propre décorateur - sans lui, chaque méthode devrait aller chercher request.user via @Req(), répétant le même code en une douzaine d'endroits.
Étape 2 - la configuration globale
Ce qui doit valoir partout s'enregistre une fois, dans main.ts :
1async function bootstrap() {
2 const app = await NestFactory.create(AppModule);
3
4 app.useGlobalPipes(
5 new ValidationPipe({
6 whitelist: true,
7 forbidNonWhitelisted: true,
8 transform: true,
9 }),
10 );
11
12 app.useGlobalInterceptors(new TransformInterceptor());
13 app.useGlobalFilters(new HttpExceptionFilter());
14
15 await app.listen(3000);
16}L'enregistrement global est une décision, non une commodité. ValidationPipe doit valoir partout, car un endpoint sans validation est une faille et non une exception. TransformInterceptor de même - sinon la moitié de l'API renverrait une enveloppe { success, data } et l'autre moitié un objet brut, et chaque client devrait traiter les deux cas.
N'enregistre pas les guards globalement dans ce projet. Le trésor comporte des endpoints publics (la lecture du catalogue) et protégés (l'écriture) - et un guard global assorti d'exceptions par @Public() convient à un système plus vaste que celui-ci.
Étape 3 - le guard et les métadonnées
Le contrôle d'accès repose sur un couple : un décorateur inscrit l'exigence, un guard la relit.
1export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
2
3@Injectable()
4export class RolesGuard implements CanActivate {
5 constructor(private reflector: Reflector) {}
6
7 canActivate(context: ExecutionContext): boolean {
8 const required = this.reflector.get<string[]>('roles', context.getHandler());
9
10 if (!required) {
11 return true;
12 }
13
14 const { user } = context.switchToHttp().getRequest();
15
16 return required.includes(user?.role);
17 }
18}Remarque l'ordre des guards dans @UseGuards(AuthGuard, RolesGuard). Ils sont vérifiés depuis la gauche : AuthGuard pose donc request.user avant que RolesGuard n'aille le chercher. Inverser ce couple donne un guard comparant le rôle d'un utilisateur qui n'existe pas encore - et un refus pour tout le monde.
Étape 4 - l'interceptor et le filtre d'exception
Le dernier couple veille sur ce qui sort :
1@Injectable()
2export class TransformInterceptor implements NestInterceptor {
3 intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
4 const start = Date.now();
5
6 return next.handle().pipe(
7 tap(() => console.log('temps:', Date.now() - start, 'ms')),
8 map((data) => ({ success: true, data })),
9 );
10 }
11}
12
13@Catch(HttpException)
14export class HttpExceptionFilter implements ExceptionFilter {
15 catch(exception: HttpException, host: ArgumentsHost) {
16 const response = host.switchToHttp().getResponse();
17 const status = exception.getStatus();
18
19 response.status(status).json({
20 success: false,
21 message: exception.message,
22 timestamp: new Date().toISOString(),
23 });
24 }
25}Observe que tous deux composent la réponse dans la même forme : success et le contenu. Ce n'est pas fortuit - le client doit distinguer la réussite de l'échec par un seul champ, quelle que soit la voie empruntée. Un écart entre les deux oblige celui qui écrit le client à deviner.
Dans l'interceptor, tap et map figurent dans un même .pipe() et font des choses différentes : le premier observe la valeur pour mesurer le temps, le second la remplace.
L'ordre que tu dois savoir réciter
L'épreuve de compréhension de ce module tient en une question : que se passe-t-il, et dans quel ordre ?
Middleware → guard → interceptor (avant) → pipe → handler → interceptor (après) → filtre d'exception.
Il en découle des conséquences que tu verras dans ton propre code. Un guard qui rejette une requête fait que le pipe ne valide rien et que le handler ne s'exécute pas - mais le middleware, lui, s'est déjà exécuté et l'entrée de journal existe. Une exception levée par un pipe saute le tap() et le map() de l'intercepteur et arrive au filtre - le temps d'une telle requête n'est donc pas mesuré (sauf si l'intercepteur a aussi catchError ou finalize).
Ce que tu rends
Le projet est achevé lorsqu'il comporte :
- Un contrôleur avec un CRUD complet et des paramètres de route traités par
ParseIntPipe. - Un middleware journalisant la méthode, le chemin et un identifiant de corrélation, enregistré via
NestModule. - Deux guards - authentification et rôles - avec des rôles inscrits par
SetMetadataet relus parReflector. - Un
ValidationPipeglobal avec un DTO pour chaque entrée. - Un interceptor uniformisant la réponse et mesurant le temps.
- Un filtre d'exception renvoyant les erreurs dans la même forme que les réponses réussies.
- Un décorateur personnalisé
@CurrentUser()employé à la place de@Req().
Termine par une épreuve qui met le tout à l'essai : envoie une requête POST sans token et avec un corps invalide à la fois. Tu dois obtenir un 403, non un 400 - car le guard précède le pipe. Si tu obtiens un 400, ta validation survient trop tôt et révèle quels champs sont requis à quelqu'un qui n'a même pas le droit d'appeler cet endpoint.
Envoie le lien de ton dépôt une fois terminé.
Code de cette leçon : src/tribute-project.ts
1// PROJET : Système de gestion des tributs de l'empire
2import {
3 Module, Controller, Injectable, Get, Post, Put,
4 Body, Param,
5} from '@nestjs/common';
6
7console.log("=== PROJET : ImperiumTributeVault ===");
8
9// ===========================================
10// 1. Modèle Tribute
11// ===========================================
12
13interface Tribute {
14 id: number;
15 name: string;
16 province: string;
17 amount: number;
18 type: 'gold' | 'silver' | 'goods';
19 collector: string;
20 collectedAt: Date;
21 verified: boolean;
22}
23
24// ===========================================
25// 2. Service Tribute avec CRUD complet
26// ===========================================
27
28@Injectable()
29export class TributeService {
30 private tributes: Tribute[] = [
31 {
32 id: 1, name: 'Aurum Galliae', province: 'Gallia',
33 amount: 50000, type: 'gold', collector: 'Marcus',
34 collectedAt: new Date(), verified: true,
35 },
36 {
37 id: 2, name: 'Argentum Hispaniae', province: 'Hispania',
38 amount: 30000, type: 'silver', collector: 'Titus',
39 collectedAt: new Date(), verified: false,
40 },
41 ];
42
43 findAll(filters?: { province?: string; type?: string }) {
44 let result = this.tributes;
45 if (filters?.province) {
46 result = result.filter(t => t.province === filters.province);
47 }
48 if (filters?.type) {
49 result = result.filter(t => t.type === filters.type);
50 }
51 return result;
52 }
53
54 findById(id: number) {
55 return this.tributes.find(t => t.id === id);
56 }
57
58 create(data: Omit<Tribute, 'id' | 'collectedAt' | 'verified'>) {
59 const tribute = {
60 id: this.tributes.length + 1,
61 ...data,
62 collectedAt: new Date(),
63 verified: false,
64 };
65 this.tributes.push(tribute);
66 return tribute;
67 }
68
69 verify(id: number) {
70 const tribute = this.findById(id);
71 if (tribute) tribute.verified = true;
72 return tribute;
73 }
74
75 getStatistics() {
76 return {
77 total: this.tributes.reduce((sum, t) => sum + t.amount, 0),
78 byType: {
79 gold: this.tributes.filter(t => t.type === 'gold').length,
80 silver: this.tributes.filter(t => t.type === 'silver').length,
81 goods: this.tributes.filter(t => t.type === 'goods').length,
82 },
83 verified: this.tributes.filter(t => t.verified).length,
84 pending: this.tributes.filter(t => !t.verified).length,
85 };
86 }
87}
88
89// ===========================================
90// 3. Contrôleur Tribute
91// ===========================================
92
93@Controller('tributes')
94export class TributeController {
95 constructor(private tributeService: TributeService) {}
96
97 @Get()
98 findAll() { return this.tributeService.findAll(); }
99
100 @Get('stats')
101 getStats() { return this.tributeService.getStatistics(); }
102
103 @Get(':id')
104 findOne(@Param('id') id: string) { return this.tributeService.findById(+id); }
105
106 @Post()
107 create(@Body() data: any) { return this.tributeService.create(data); }
108
109 @Put(':id/verify')
110 verify(@Param('id') id: string) { return this.tributeService.verify(+id); }
111}
112
113// ===========================================
114// Module du projet
115// ===========================================
116
117@Module({
118 controllers: [TributeController],
119 providers: [TributeService],
120})
121export class TributeModule {}
122
123console.log("\n=== STRUCTURE DU PROJET ===");
124console.log("TributeModule - module principal");
125console.log("TributeService - logique métier (CRUD + statistiques)");
126console.log("TributeController - endpoints de l'API REST");
127console.log("\nLe projet réunit un module, un service et un contrôleur ; tu ajouteras les guards, l'intercepteur et le filtre en suivant la leçon projet.");
128Tu as repéré une erreur dans cette leçon ?