Cours NestJS · Module 2: Routing et cycle de requête

Pipes - le contrôle à la porte du camp

8 min de lecture
Dans cette leçon7

Un chariot de tribut arrive à la porte du camp. La sentinelle ne demande pas qui l'envoie - cela s'est réglé plus tôt, au poste avancé. Elle demande autre chose : si les caisses contiennent ce qu'annonce le bordereau, si les quantités concordent, si le « douze » inscrit sur le reçu se laisse seulement compter. Alors seulement le chariot entre, et l'intendant reçoit une cargaison comptée et vérifiée - non un tas de sacs à trier.

Dans ce module, tu as déjà rencontré trois mécanismes postés sur le chemin d'une requête : les middlewares, les guards et les interceptors. Les Pipes sont le quatrième et dernier mécanisme du cycle de requête de NestJS - et ils font quelque chose qu'aucun des autres ne fait.

Deux tâches, pas davantage

Les Pipes ont deux usages : la validation et la transformation des données d'entrée. Il vaut la peine de les délimiter d'emblée face à leurs voisins, car dans le cycle d'une requête ils se tiennent côte à côte :

  • du routage s'occupent les décorateurs @Get et @Post de la première leçon du module, et du traitement initial de la requête, les middlewares ;
  • l'autorisation - la question de savoir qui a le droit d'entrer - est tranchée par les guards ;
  • la journalisation, la mise en cache et la refonte de la réponse sont l'affaire des interceptors.

Un pipe ne touche ni à la requête entière ni à la réponse. Il reçoit un seul argument d'une méthode de contrôleur et, ou bien le laisse passer - souvent transformé - ou bien lève une exception. Il s'exécute après les guards, juste avant l'entrée dans la méthode.

ParseIntPipe - la transformation intégrée

Un paramètre tiré d'une URL est toujours une chaîne. La requête /tributes/12 te donne '12', non 12 :

1@Controller('tributes')
2export class TributeController {
3  @Get(':id')
4  findById(@Param('id', ParseIntPipe) id: number) {
5    return this.tributeService.findById(id);
6  }
7}

Le pipe se place en deuxième argument de @Param : le premier est le nom du paramètre dans la route, le second est le pipe chargé de le traiter. ParseIntPipe convertit une chaîne en entier ou lève une exception - et rien de plus. Il n'analyse pas du JSON en objet, ne convertit pas un booléen en chaîne, ne transforme pas une date en timestamp.

L'exception compte ici autant que la conversion. Après un appel à /tributes/abc, la méthode findById ne s'exécute pas du tout - le client reçoit un 400 Bad Request, et à l'intérieur de la méthode id est garanti number. C'est pourquoi l'annotation id: number n'est pas un vœu pieux.

Ses semblables fonctionnent de même : ParseBoolPipe pour 'true' et 'false', ParseUUIDPipe pour les identifiants UUID.

Le DTO - des règles inscrites auprès des champs

Pour une cargaison plus lourde, des pipes isolés ne suffisent plus. Nous décrivons alors la forme attendue des données dans un DTO (Data Transfer Object) - une classe dont les champs portent des décorateurs de la bibliothèque class-validator. Les espèces de tribut autorisées sont d'abord rangées dans l'enum TributeType :

1export enum TributeType {
2  GOLD = 'gold',
3  SILVER = 'silver',
4  GOODS = 'goods',
5}
6
7export class CreateTributeDto {
8  @IsNotEmpty()
9  @IsString()
10  province: string;
11
12  @IsNumber()
13  @Min(1)
14  amount: number;
15
16  @IsEnum(TributeType)
17  type: TributeType;
18}

L'ordre d'écriture est fixe : d'abord l'ouverture de la classe (export class CreateTributeDto {), puis les décorateurs - chacun sur sa propre ligne - et en tout dernier le champ qu'ils concernent. Un décorateur se tient toujours au-dessus de son champ, jamais à côté, et chaque champ suivant de la classe s'écrit de la même façon.

Chaque décorateur est une règle. @IsNotEmpty() rejette une valeur vide, @IsString() veille au type, @IsNumber() exige un nombre, @Min(1) un nombre au moins égal à un - car un tribut de « zéro pièce d'or » n'est pas un tribut - et @IsEnum(TributeType) n'admet que les valeurs de l'enum (en cas d'erreur, le message les énumère toutes). Les règles s'additionnent : amount doit satisfaire les deux à la fois.

ValidationPipe - quatre étapes

Les décorateurs, seuls, ne vérifient rien ; ils ne sont qu'une description des exigences. L'exécutant est ValidationPipe, et son travail tient en quatre étapes, toujours dans cet ordre :

  1. Recevoir les données brutes de la requête.
  2. Vérifier les règles de validation des décorateurs du DTO.
  3. Lever une BadRequestException si les données sont invalides.
  4. Renvoyer les données validées au handler.

La troisième étape interrompt le cycle - le handler ne verra jamais de données mauvaises, puisqu'il ne s'exécute pas, et le client reçoit un 400 énumérant les champs en défaut. La quatrième étape est ce qui justifie tout le procédé : la méthode reçoit un objet dont on sait qu'il satisfait chaque règle.

Le branchement sur une méthode se présente ainsi :

1@Controller('tributes')
2export class TributeController {
3  @Post()
4  createTribute(@Body(ValidationPipe) tribute: CreateTributeDto) {
5    return this.tributeService.create(tribute);
6  }
7}

Lis-le de gauche à droite. createTribute(@Body( ouvre un paramètre pris dans le corps de la requête, ValidationPipe) referme @Body en désignant le pipe qui doit le vérifier, et tribute: CreateTributeDto) nomme le paramètre et fournit la classe qui porte les règles. Sans cette dernière partie, le pipe n'aurait rien sur quoi se régler - c'est le type qui lui dit quels décorateurs chercher.

L'enregistrement global

Ajouter ValidationPipe à chaque @Body devient vite fastidieux, et un seul oubli fait un endpoint sans contrôle. Nous l'enregistrons donc une fois, globalement, dans main.ts :

1async function bootstrap() {
2  const app = await NestFactory.create(AppModule);
3
4  app.useGlobalPipes(
5    new ValidationPipe({
6      whitelist: true,
7      transform: true,
8    }),
9  );
10
11  await app.listen(3000);
12}

app.useGlobalPipes(new ValidationPipe()) couvre d'un coup tous les endpoints de l'application. Remarque qu'il s'agit de code exécuté au démarrage, non d'une entrée de configuration - raison pour laquelle tu ne le trouveras ni dans package.json (la liste des dépendances), ni dans tsconfig.json (les réglages du compilateur), ni dans .env (les variables d'environnement). Aucun de ces fichiers n'exécute quoi que ce soit.

Deux options méritent d'être activées d'emblée. whitelist: true retire les champs non décrits dans le DTO - un client peut envoyer isAdmin: true, cela n'atteindra jamais le service. transform: true change l'objet brut issu du JSON en une instance de la classe DTO, et convertit les paramètres de route et de requête de type simple depuis le texte : avec @Param('id') id: number tu obtiens 12, et non '12'. Il ne convertit pas les champs du DTO - si un client envoie "amount": "500", @IsNumber() rejette la requête avec un 400. Pour obtenir aussi cette conversion, ajoute transformOptions: { enableImplicitConversion: true }.

Ton propre pipe

Quand une règle est propre à ton domaine, tu écris ton propre pipe - une classe implémentant l'interface PipeTransform avec une unique méthode transform :

1@Injectable()
2export class TributeSealPipe implements PipeTransform {
3  transform(value: string, metadata: ArgumentMetadata) {
4    const seal = value.trim().toUpperCase();
5
6    if (seal.length !== 8 || !seal.startsWith('SPQR')) {
7      throw new BadRequestException(
8        `Le sceau ${value} n'est pas un sceau de l'empire`,
9      );
10    }
11
12    return seal;
13  }
14}

La méthode reçoit deux arguments : value - la valeur à vérifier - et metadata, de type ArgumentMetadata, où se trouvent entre autres type ('body', 'query' ou 'param') et le type attendu du paramètre. Le contrat est simple : renvoie une valeur ou lève une exception. La valeur renvoyée - ici rognée et passée en majuscules - parvient à la méthode du contrôleur à la place de l'originale.

Ce sont là les deux mêmes tâches qu'au début : la validation (le contrôle de la longueur et du préfixe) et la transformation (trim avec toUpperCase). Il s'emploie exactement comme un pipe intégré : @Param('seal', TributeSealPipe).

Récapitulatif

Le chariot de tribut n'entre pas au camp sans contrôle :

  • les pipes servent à la validation et à la transformation des données d'entrée - non au routage ni au middleware, non à l'autorisation ni à la journalisation, non à la mise en cache ni à la compression,
  • un pipe reçoit un seul argument d'une méthode et s'exécute après les guards, juste avant le handler,
  • ParseIntPipe convertit une chaîne en entier ou lève une exception ; tu le passes en deuxième argument : @Param('id', ParseIntPipe) id: number,
  • un champ de DTO s'écrit dans l'ordre : export class CreateTributeDto { → les décorateurs (@IsNotEmpty(), @IsString() ; leur ordre entre eux ne change pas le résultat de la validation) → le nom du champ avec son type,
  • les règles du tribut : province avec @IsString(), amount avec @IsNumber() et @Min(1), type avec @IsEnum(TributeType),
  • ValidationPipe en quatre étapes : données brutes de la requête → vérification des règles des décorateurs du DTO → BadRequestException en cas d'échec → données validées au handler,
  • dans la méthode du contrôleur : createTribute(@Body( → ValidationPipe) → tribute: CreateTributeDto),
  • globalement dans main.ts : app.useGlobalPipes(new ValidationPipe()) - non dans package.json, non dans tsconfig.json, non dans .env,
  • whitelist: true retire les champs étrangers au DTO, transform: true donne une instance de DTO et convertit les paramètres simples de route et de requête,
  • ton propre pipe implémente PipeTransform et la méthode transform(value, metadata: ArgumentMetadata) - il renvoie une valeur ou lève une exception.

Dans la prochaine leçon, nous commencerons à frapper nos propres sceaux - en écrivant des décorateurs taillés pour l'empire. En attendant, retiens ceci : un pipe est la dernière porte avant ton code, et tout ce qui la franchit est déjà ce que tu attends.

Code de cette leçon : src/pipes.ts
1// Pipes dans NestJS - Artisans et contrôleurs qualité
2import {
3  PipeTransform, Injectable, ArgumentMetadata,
4  BadRequestException, ParseIntPipe, ParseBoolPipe,
5  DefaultValuePipe, ValidationPipe,
6} from '@nestjs/common';
7
8console.log("Pipes - transformation et validation des données !");
9
10// ===========================================
11// 1. Pipes intégrés
12// ===========================================
13
14// ParseIntPipe - convertit une chaîne en nombre
15// @Get(':id')
16// findOne(@Param('id', ParseIntPipe) id: number) { ... }
17
18// ParseBoolPipe - convertit une chaîne en booléen
19// @Get()
20// findAll(@Query('active', ParseBoolPipe) active: boolean) { ... }
21
22// DefaultValuePipe - valeur par défaut
23// @Get()
24// findAll(@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number)
25
26// ===========================================
27// 2. Pipe personnalisé - validation
28// ===========================================
29
30@Injectable()
31export class RomanRankValidationPipe implements PipeTransform {
32  private readonly validRanks = [
33    'Miles', 'Optio', 'Centurio', 'Tribunus', 'Legatus',
34  ];
35
36  transform(value: any, metadata: ArgumentMetadata) {
37    if (!this.validRanks.includes(value)) {
38      throw new BadRequestException(
39        'Le rang "' + value + '" est inconnu dans notre empire ! '
40        + 'Rangs disponibles : ' + this.validRanks.join(', ')
41      );
42    }
43    return value;
44  }
45}
46
47// Utilisation : @Param('rank', RomanRankValidationPipe) rank: string
48
49// ===========================================
50// 3. Pipe personnalisé - transformation
51// ===========================================
52
53@Injectable()
54export class TrimPipe implements PipeTransform {
55  transform(value: any, metadata: ArgumentMetadata) {
56    if (typeof value === 'string') {
57      return value.trim();
58    }
59    if (typeof value === 'object' && value !== null) {
60      for (const key of Object.keys(value)) {
61        if (typeof value[key] === 'string') {
62          value[key] = value[key].trim();
63        }
64      }
65    }
66    return value;
67  }
68}
69
70// ===========================================
71// 4. ValidationPipe avec class-validator
72// ===========================================
73
74// class CreateLegionDto {
75//   @IsString() @IsNotEmpty()
76//   name: string;
77//
78//   @IsNumber() @Min(1000) @Max(6000)
79//   soldiers: number;
80//
81//   @IsEnum(LegionType)
82//   type: LegionType;
83// }
84
85// Globalement dans main.ts :
86// app.useGlobalPipes(new ValidationPipe({
87//   whitelist: true,      // Supprime les champs inconnus
88//   forbidNonWhitelisted: true, // Erreur sur les champs inconnus
89//   transform: true,      // Transformation automatique des types
90// }));
91
92console.log("\n=== RÉSUMÉ SUR LES PIPES ===");
93console.log("ParseIntPipe, ParseBoolPipe - transformateurs intégrés");
94console.log("DefaultValuePipe - valeurs par défaut");
95console.log("PipeTransform - interface des pipes personnalisés");
96console.log("ValidationPipe + class-validator - validation de DTO");
97console.log("whitelist: true - supprime les champs inconnus du body");
98

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quels sont les deux usages principaux des Pipes dans NestJS ?

  2. 2. Que fait ParseIntPipe dans NestJS ?

  3. 3. Comment enregistre-t-on ValidationPipe globalement dans une application NestJS ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Écris un endpoint @Get(':id') qui utilise ParseIntPipe pour valider le paramètre id

  • Tri vertical

    Ordonne les étapes de validation des données par ValidationPipe :

  • Clique dans l'ordre

    Range les éléments de la définition d'un champ de DTO avec validation dans le bon ordre :

  • Éditeur de code

    Écris un CreateTributeDto avec les champs province (@IsString), amount (@IsNumber, @Min(1)) et type (@IsEnum)

  • Tri horizontal

    Range les éléments de l'utilisation de ValidationPipe dans une méthode de contrôleur :