Cours NestJS · Module 10: Validation des données

Projet - un système de validation complet pour l'Empire

8 min de lecture
Dans cette leçon8

Les huit leçons de ce module ont présenté les sentinelles une à une : les décorateurs de class-validator, ValidationPipe, les transformations, la validation imbriquée, les validateurs personnalisés et les groupes. Le projet est l'endroit où toutes se tiennent ensemble à une même porte - et alors ce qui compte le plus se révèle être ce dont aucune leçon isolée ne parlait : l'ordre dans lequel elles agissent.

Tu vas construire un système de gestion des légions avec un CRUD complet et la validation de toutes les entrées : des DTO pour la légion, le légionnaire et l'arme, un validateur personnalisé, des transformations de réponse et une configuration globale de la porte.

L'ordre qui explique le reste

Avant d'écrire une ligne, retiens le chemin que parcourt une requête :

  1. Elle arrive comme un objet JSON ordinaire - sans classes, sans types, tout y est chaîne ou nombre.
  2. class-transformer le change en instance de la classe DTO, en exécutant au passage @Type et @Transform.
  3. class-validator vérifie les décorateurs de validation sur cette instance.
  4. Le résultat seulement parvient au handler.

D'où une conclusion qui t'épargnera une soirée : la transformation précède la validation. @Transform(({ value }) => value.trim()) retire d'abord les espaces, et c'est ensuite que @MinLength(2) compte les caractères - si bien que " A " sera rejeté, quoiqu'il en paraisse cinq.

Étape 1 - les enums et le premier DTO

1// enums.ts
2export enum LegionaryRank {
3  MILES = 'miles',
4  OPTIO = 'optio',
5  CENTURIO = 'centurio',
6  TRIBUNUS = 'tribunus',
7  LEGATUS = 'legatus',
8}
9
10export enum WeaponType {
11  GLADIUS = 'gladius',
12  PILUM = 'pilum',
13  SCUTUM = 'scutum',
14  PUGIO = 'pugio',
15}
16
17// weapon.dto.ts
18export class CreateWeaponDto {
19  @IsString()
20  @IsNotEmpty()
21  name: string;
22
23  @IsEnum(WeaponType)
24  type: WeaponType;
25
26  @IsNumber()
27  @Min(1)
28  @Max(100)
29  damage: number;
30}

Remarque que les rangs et les types d'armes sont des enums, non des unions telles que 'miles' | 'optio'. La raison est celle-là même qui justifie l'existence de cette bibliothèque : les types TypeScript disparaissent à la compilation. Une union ne laisse rien derrière elle qu'un validateur pourrait interroger à l'exécution ; un enum demeure un objet ordinaire, et @IsEnum(WeaponType) a donc de quoi comparer.

Les @Min et @Max sur damage ne sont pas un ornement. Sans eux, un client envoie damage: -5 ou damage: 999999 et la base accepte les deux sans broncher.

Étape 2 - la validation imbriquée

1export class CreateLegionaryDto {
2  @IsString()
3  @IsNotEmpty()
4  @MinLength(2)
5  @MaxLength(50)
6  @Transform(({ value }) => value.trim())
7  name: string;
8
9  @IsEnum(LegionaryRank)
10  rank: LegionaryRank;
11
12  @IsNumber()
13  @Min(16)
14  @Max(65)
15  age: number;
16
17  @Matches(/^LEG-[A-Z]{2,4}-\d{4}$/, {
18    message: "L'ID doit avoir le format LEG-XX-0000",
19  })
20  militaryId: string;
21
22  @IsArray()
23  @ArrayMinSize(1)
24  @IsString({ each: true })
25  skills: string[];
26
27  @ValidateNested()
28  @Type(() => CreateWeaponDto)
29  primaryWeapon: CreateWeaponDto;
30
31  @IsOptional()
32  @ValidateNested()
33  @Type(() => CreateWeaponDto)
34  secondaryWeapon?: CreateWeaponDto;
35}

Ici se cache le piège le plus fréquent de tout le module. @ValidateNested() sans @Type() ne fonctionne pas - et ne signale aucune erreur. Sans @Type, le transformer laisse dans primaryWeapon un objet ordinaire et non une instance de CreateWeaponDto ; le validateur y regarde, n'y trouve aucun décorateur et conclut que tout est en règle. Une arme à dégâts négatifs franchit la porte, faute de sentinelle postée.

Deux ajouts méritent l'attention. @IsString({ each: true }) contrôle chaque élément du tableau et non le tableau entier - sans each, la règle porterait sur le champ skills lui-même. Et @IsOptional() doit se placer avant les autres décorateurs d'un champ facultatif : il dit au validateur d'ignorer les règles restantes lorsque la valeur est absente, au lieu de l'exiger.

Étape 3 - un tableau d'objets imbriqués

1export class CreateLegionDto {
2  @IsString()
3  @IsNotEmpty()
4  @MinLength(3)
5  name: string;
6
7  @IsEnum(LegionStatus)
8  status: LegionStatus;
9
10  @IsNumber()
11  @Min(1000)
12  @Max(6000)
13  maxSoldiers: number;
14
15  @IsArray()
16  @ArrayMinSize(1)
17  @ValidateNested({ each: true })
18  @Type(() => CreateLegionaryDto)
19  soldiers: CreateLegionaryDto[];
20}

La différence avec l'étape précédente tient en deux mots : { each: true }. Sans cela, @ValidateNested() vérifierait le tableau comme un objet unique - c'est-à-dire rien en pratique. Avec, chaque légionnaire subit une validation complète, séparément, armes comprises, car l'imbrication opère récursivement : légion → légionnaire → arme.

Il vaut la peine d'en mesurer le coût. Une légion de six mille soldats, chacun avec deux armes, représente des dizaines de milliers de contrôles pour une seule requête. À de telles tailles, on ajoute @ArrayMaxSize et l'on envisage d'accueillir les soldats par un endpoint distinct - la validation est peu coûteuse, mais elle n'est pas gratuite.

Étape 4 - un validateur personnalisé

1@ValidatorConstraint({ async: false })
2class IsValidMilitaryIdConstraint implements ValidatorConstraintInterface {
3  validate(value: string): boolean {
4    if (!value) return false;
5    const parts = value.split('-');
6    if (parts.length !== 3) return false;
7    if (parts[0] !== 'LEG') return false;
8    const numericPart = parseInt(parts[2], 10);
9    return numericPart > 0 && numericPart <= 9999;
10  }
11
12  defaultMessage(): string {
13    return 'Identifiant militaire invalide';
14  }
15}
16
17export function IsValidMilitaryId(options?: ValidationOptions) {
18  return function (object: object, propertyName: string) {
19    registerDecorator({
20      target: object.constructor,
21      propertyName,
22      options,
23      validator: IsValidMilitaryIdConstraint,
24    });
25  };
26}

Un validateur personnalisé comporte toujours deux parties : la classe qui porte la règle et la fonction décoratrice qui l'enregistre. La classe fournit un validate renvoyant un booléen et un defaultMessage contenant le libellé ; la fonction permet d'employer la règle exactement comme une règle intégrée, par @IsValidMilitaryId().

À quoi bon, puisque @Matches de l'étape deux fait chose semblable ? Parce qu'une expression régulière vérifie la forme, non le sens. Une condition supplémentaire apparaît ici - que le nombre soit compris entre 1 et 9999 - règle qui relève du domaine et non de la syntaxe. À mesure que de telles conditions se multiplient, elles ont toutes un même foyer, et le message d'erreur peut s'écrire en clair. L'option { async: false } indique que la règle ne consulte pas la base ; pour un contrôle d'unicité, tu mettras true et validate renverra une Promise.

Étape 5 - le DTO de réponse

1export class LegionaryResponseDto {
2  @Expose()
3  name: string;
4
5  @Expose()
6  rank: string;
7
8  @Expose()
9  @Transform(({ value }) => (value > 10 ? 'Veteranus' : 'Tiro'))
10  experienceLevel: string;
11
12  @Exclude()
13  secretMissionCode: string;
14
15  @Exclude()
16  salary: number;
17}

La validation surveille ce qui entre. Ici, nous surveillons ce qui sort - tout aussi important, car une fuite de solde et de codes de mission ne s'annoncera pas d'elle-même. @Expose() laisse passer un champ, @Exclude() le retire, @Transform calcule une valeur dérivée à la volée.

Une chose s'oublie facilement : les décorateurs seuls ne font rien. Pour qu'ils prennent effet, le contrôleur doit porter @UseInterceptors(ClassSerializerInterceptor), ou bien il faut appeler plainToInstance avec excludeExtraneousValues: true. Sans quoi le service renvoie un objet ordinaire et @Exclude() demeure une annotation sans conséquence - et c'est exactement ainsi que fuient des données que tout le monde croyait masquées.

Étape 6 - la configuration de la porte

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      transformOptions: {
10        enableImplicitConversion: true,
11      },
12    }),
13  );
14
15  await app.listen(3000);
16}

Quatre options, quatre décisions. whitelist: true retire les champs absents du DTO. forbidNonWhitelisted: true va plus loin et rejette une telle requête par un 400 - le choix le plus strict, et par là le plus honnête : le client apprend qu'il a envoyé quelque chose d'inattendu, au lieu de croire que tu as enregistré son champ.

transform: true fait parvenir au handler une instance de la classe DTO plutôt qu'un objet ordinaire - sans quoi les @Type et @Transform des étapes précédentes restent lettre morte. enableImplicitConversion: true ajoute la conversion des types simples : "25" venu d'un paramètre de requête devient le nombre 25, parce que le type du champ dit number. Active cette dernière en connaissance de cause - elle est commode, mais elle sait changer "0" en 0 et "false" en true.

Ce que tu rends

Le projet est achevé lorsqu'il satisfait cinq conditions :

  1. Un CRUD pour les légions - endpoints de création, lecture, mise à jour et suppression.
  2. Des DTO avec class-validator pour chaque entrée, avec validation imbriquée et tableaux.
  3. Au moins un validateur personnalisé enregistré par registerDecorator.
  4. Des transformations avec class-transformer à l'entrée (@Type, @Transform) et à la sortie (@Expose, @Exclude).
  5. Un ValidationPipe global avec les options de l'étape six.

Vérifie deux choses pour finir, les deux qui passent le plus souvent inaperçues : que chaque @ValidateNested a bien un @Type à ses côtés, et que les champs marqués @Exclude() n'apparaissent réellement pas dans la réponse. L'un et l'autre échouent en silence - sans erreur, sans avertissement, jusqu'au jour où quelqu'un s'en aperçoit.

Envoie le lien de ton dépôt une fois terminé.

Code de cette leçon : src/complete-validation.ts
1// Projet principal - Système de validation complet de l'Imperium
2import {
3  IsString, IsNotEmpty, IsEnum, IsNumber,
4  Min, Max, MinLength, MaxLength, IsArray,
5  ArrayMinSize, ValidateNested, IsOptional, Matches,
6  ValidatorConstraint, ValidatorConstraintInterface,
7  registerDecorator, ValidationOptions,
8} from 'class-validator';
9import { Type, Transform, Exclude, Expose } from 'class-transformer';
10
11// === ENUMS ===
12enum LegionaryRank {
13  MILES = 'miles',
14  OPTIO = 'optio',
15  CENTURIO = 'centurio',
16  LEGATUS = 'legatus',
17}
18
19enum WeaponType {
20  GLADIUS = 'gladius',
21  PILUM = 'pilum',
22  SCUTUM = 'scutum',
23}
24
25// === VALIDATEUR PERSONNALISÉ ===
26// TODO : Implémenter IsValidMilitaryId
27@ValidatorConstraint({ async: false })
28class IsValidMilitaryIdConstraint
29  implements ValidatorConstraintInterface {
30  validate(value: string): boolean {
31    if (!value) return false;
32    return /^LEG-[A-Z]{2,4}-\d{4}$/.test(value);
33  }
34  defaultMessage(): string {
35    return 'Invalid military ID!';
36  }
37}
38
39// === WEAPON DTO ===
40class CreateWeaponDto {
41  @IsString()
42  @IsNotEmpty()
43  name: string;
44
45  @IsEnum(WeaponType)
46  type: WeaponType;
47
48  @IsNumber()
49  @Min(1)
50  @Max(100)
51  damage: number;
52}
53
54// === LEGIONARY DTO ===
55// TODO : Compléter les décorateurs manquants
56class CreateLegionaryDto {
57  @IsString()
58  @IsNotEmpty()
59  @MinLength(2)
60  @Transform(({ value }) => value.trim())
61  name: string;
62
63  @IsEnum(LegionaryRank)
64  rank: LegionaryRank;
65
66  @IsNumber()
67  @Min(16)
68  @Max(65)
69  age: number;
70
71  // TODO : Ajouter la validation imbriquée pour l'arme
72  @ValidateNested()
73  @Type(() => CreateWeaponDto)
74  weapon: CreateWeaponDto;
75
76  @IsArray()
77  @ArrayMinSize(1)
78  @IsString({ each: true })
79  skills: string[];
80}
81
82// === RESPONSE DTO ===
83class LegionaryResponse {
84  @Expose()
85  name: string;
86
87  @Expose()
88  rank: string;
89
90  @Exclude()
91  salary: number;
92}
93
94console.log('Système de validation complet de l\'Imperium prêt !');
95

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