Cours NestJS · Module 10: Validation des données

class-transformer - l'art de transformer les données

5 min de lecture
Dans cette leçon5

La validation surveille ce qui entre. Cette leçon porte sur l'inverse : ce qui sort de ton API, et sous quelle forme.

Le problème se voit le mieux par un exemple. Le service récupère en base une entité de sénateur - avec le mot de passe, la solde, l'identifiant de session interne. Le contrôleur renvoie cet objet. NestJS le change en JSON et l'envoie au client en entier, car personne n'a dit qu'il fallait en retenir quoi que ce soit. Une fuite de données n'exige aucun bogue ; l'absence de décision suffit.

Trois décorateurs

class-transformer offre trois outils pour déclarer ce qu'il advient d'un champ lorsqu'un objet devient une réponse :

1export class SenatorResponseDto {
2  @Expose()
3  name: string;
4
5  @Expose()
6  province: string;
7
8  @Expose()
9  @Transform(({ value }) => value.toISOString().slice(0, 10))
10  appointedAt: Date;
11
12  @Exclude()
13  password: string;
14
15  @Exclude()
16  salary: number;
17}

@Exclude() masque un champ lors de la conversion de l'objet en réponse JSON. Il ne supprime pas le champ de la base de données - l'entité reste intacte. Il ne désactive pas la validation de ce champ. Et il ne bloque pas l'accès en TypeScript ; dans le service, senator.password fonctionne toujours.

@Expose() fait l'inverse - il marque un champ comme visible. Par défaut, tous les champs le sont : @Expose() seul change donc peu de chose ; il prend son sens avec l'option excludeExtraneousValues: true, qui renverse la règle, si bien que seul ce qui est explicitement exposé sort.

@Transform() définit une logique de transformation personnalisée pour la valeur d'un champ. Il n'anime pas un changement de valeur, ne crée pas de sauvegarde et ne change pas le type du champ en TypeScript. La fonction reçoit un objet doté d'un champ value et renvoie ce qui doit parvenir à la réponse - ici une date réduite au seul jour.

Quand cela prend réellement effet

La phrase la plus importante de cette leçon : les décorateurs seuls ne font rien. Ce sont des annotations ; quelqu'un doit les exécuter.

Pour que @Exclude et @Expose fonctionnent dans un contrôleur, il faut ajouter @UseInterceptors(ClassSerializerInterceptor). Non installer une bibliothèque class-serializer supplémentaire - elle n'existe pas. Non configurer un middleware dans app.module.ts. Et il n'existe aucun décorateur spécial @Serialize() à poser au-dessus de la méthode.

1@Controller('senators')
2@UseInterceptors(ClassSerializerInterceptor)
3export class SenatorsController {
4  @Get(':id')
5  findOne(@Param('id') id: string) {
6    return this.senatorsService.findOne(id);
7  }
8}

C'est le genre d'erreur qui ne donne aucun signal. Le code compile, les tests unitaires du service passent, et les mots de passe filent vers le client - faute d'avoir branché l'interceptor. La vérification prend une demi-minute : appelle l'endpoint et lis la réponse.

L'interceptor peut aussi s'enregistrer globalement par app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))) - et avec des données sensibles, c'est le choix le plus sûr, car oublier le décorateur sur un contrôleur devient impossible.

Le trajet des données, de la base au client

Toute la transformation se déroule en quatre étapes, toujours dans cet ordre :

  1. Le service récupère les données de la base de données.
  2. Le contrôleur renvoie un objet du service.
  3. ClassSerializerInterceptor applique @Exclude/@Expose.
  4. Le client reçoit un JSON filtré.

Remarque où se place la troisième étape : après le contrôleur, non avant. L'interceptor agit sur ce que la méthode a renvoyé - et c'est pourquoi le service et le contrôleur manipulent l'objet complet, tous champs compris. Le filtrage est le dernier geste avant l'envoi, non une condition du travail antérieur.

D'où une conclusion pratique : un mot de passe consigné dans un log du service y figurera, quoiqu'il ne figure plus dans la réponse. @Exclude() protège la sortie de l'API, non tes propres journaux.

La conversion manuelle

Il arrive qu'il faille changer un objet ordinaire en instance de classe sans interceptor - dans un test, par exemple, ou avec des données venues d'une file :

1const dto = plainToInstance(CreateLegionaryDto, rawData);

L'ordre des arguments est fixe : plainToInstance( → CreateLegionaryDto → , rawData → ). D'abord la classe cible, ensuite les données brutes - le sens se confond aisément, et les intervertir donne un objet qui paraît correct et ne porte aucun de tes décorateurs.

La fonction inverse, instanceToPlain, change une instance de classe en objet ordinaire, en appliquant au passage @Exclude et @Expose. C'est exactement l'opération que ClassSerializerInterceptor accomplit pour toi.

Récapitulatif

Une fuite n'exige aucun bogue ; l'absence de décision suffit :

  • @Exclude() masque un champ lors de la conversion de l'objet en réponse JSON - il ne le supprime pas de la base, ne désactive pas la validation, ne bloque pas l'accès en TypeScript,
  • @Expose() marque un champ comme visible ; il compte avec excludeExtraneousValues: true,
  • @Transform() définit une logique de transformation personnalisée pour la valeur d'un champ - il n'anime pas, ne sauvegarde pas, ne change pas de type,
  • pour que @Exclude et @Expose agissent, le contrôleur a besoin d'@UseInterceptors(ClassSerializerInterceptor) - non d'une bibliothèque class-serializer, non d'un middleware dans app.module.ts, non d'un décorateur @Serialize(),
  • un interceptor manquant ne produit aucune erreur - les données sortent simplement en entier,
  • le trajet des données : le service récupère en base → le contrôleur renvoie un objet → ClassSerializerInterceptor applique @Exclude/@Expose → le client reçoit un JSON filtré,
  • le filtrage survient après le contrôleur : les champs exclus figurent donc encore dans les journaux du service,
  • plainToInstance( → CreateLegionaryDto → , rawData → ) : la classe d'abord, les données ensuite,
  • instanceToPlain opère en sens inverse - la même opération que celle de l'interceptor.

Dans la prochaine leçon, nous descendrons vers la validation imbriquée. En attendant, retiens ceci : ce qui sort de ton API est décidé soit par toi, soit par le hasard. Il n'y a pas de troisième possibilité.

Code de cette leçon : src/class-transformer.ts
1// class-transformer - Transformation des données
2import {
3  Exclude,
4  Expose,
5  Transform,
6  Type,
7  plainToInstance,
8  instanceToPlain,
9} from 'class-transformer';
10import { IsString, IsNumber } from 'class-validator';
11
12// TODO : Compléter les décorateurs de transformation
13
14export class LegionaryResponseDto {
15  // TODO : Ajouter @Expose()
16  name: string;
17
18  // TODO : Ajouter @Expose()
19  rank: string;
20
21  // TODO : Ajouter @Expose() et @Transform
22  // Transform : si value > 10, renvoyer 'Veteranus', sinon 'Tiro'
23  experienceYears: number;
24
25  // TODO : Ajouter @Exclude() - masquer le code secret
26  secretMissionCode: string;
27
28  // TODO : Ajouter @Exclude() - masquer le salaire
29  salary: number;
30}
31
32export class CreateLegionaryDto {
33  @IsString()
34  @Transform(({ value }) => value.trim())
35  name: string;
36
37  @IsString()
38  rank: string;
39
40  @IsNumber()
41  age: number;
42}
43
44// Test de transformation
45const rawData = {
46  name: '  Marcus Aurelius  ',
47  rank: 'centurio',
48  age: 35,
49  secretMissionCode: 'ROMA-X-42',
50  salary: 5000,
51  experienceYears: 15,
52};
53
54console.log('Données brutes :', rawData);
55console.log('Après transformation, les champs secrets seront masqués');
56

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. Que fait le décorateur @Exclude() de la bibliothèque class-transformer ?

  2. 2. À quoi sert le décorateur @Transform() dans class-transformer ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Ajoute les décorateurs @Expose, @Exclude et @Transform à SenatorResponseDto

  • Tri horizontal

    Range les éléments de l'appel de la fonction plainToInstance dans le bon ordre :

  • Clique dans l'ordre

    Range les étapes de la transformation des données de la base de données vers la réponse de l'API :

  • Éditeur de code

    Complète les décorateurs manquants dans CreateSoldierDto, UpdateSoldierDto et SoldierResponseDto