Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 821 minIntermédiaire

Valider les données d'une API avec Zod, et téléverser un fichier

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • écrire des schémas Zod pour le corps, la chaîne de requête et les paramètres ;
  • brancher la validation dans un middleware réutilisable ;
  • renvoyer des erreurs 422 qui disent quel champ corriger ;
  • accepter un fichier envoyé par formulaire sans ouvrir de faille.

4 exercices corrigés

Valider les données d'une API, c'est vérifier chaque valeur reçue (corps, chaîne de requête, paramètres de chemin, fichiers) avant de s'en servir : son type, son format, ses bornes, et l'absence de champs inattendus. Zod est la bibliothèque de validation la plus utilisée en JavaScript : on y décrit la forme attendue des données dans un schéma, et Zod vérifie, convertit et explique ce qui ne va pas. Ce chapitre ajoute la validation à toutes les routes d'AppliFrais avec Zod 4, puis reçoit les justificatifs de frais avec Multer.

Pourquoi valider côté serveur

Le formulaire React vérifie déjà que le montant est positif. Pourquoi recommencer côté serveur ? Parce que le client n'est pas sous ton contrôle. N'importe qui peut envoyer une requête à l'API avec curl ou Bruno, en contournant le front, ou modifier une requête dans les outils de développement du navigateur. La validation du front sert le confort de l'utilisateur ; celle du serveur sert la sécurité et l'intégrité des données.

Sans validation, voici ce qui arrive à l'API :

Donnée reçueConséquence sans validation
"montant": "12.20" (chaîne)montant + 5 donne "12.205" ; ou erreur SQL, donc 500
"montant": -500Un remboursement négatif accepté, si la base n'a pas de CHECK
?limite=1000000Toute la table renvoyée en une requête
?etat=CL&etat=VAreq.query.etat est un tableau, le code plante
{"mois":"2026-11","etat":"VA","visiteurId":8}Si le code passe tout req.body au dépôt : une fiche créée validée, au nom d'un autre (affectation de masse)
Un libellé de 5 MoMémoire et base saturées

Zod en cinq minutes

bash
npm install zod
js
import { z } from 'zod';

const schemaFrais = z.object({
  date: z.iso.date(),                            // "2026-10-14", date réelle
  libelle: z.string().trim().min(3).max(100),    // espaces retirés, 3 à 100 caractères
  montant: z.number().positive().max(5000),      // nombre strictement positif
});

const resultat = schemaFrais.safeParse({ date: '2026-10-14', libelle: ' Péage A43 ', montant: 12.2 });
console.log(resultat);
// { success: true, data: { date: '2026-10-14', libelle: 'Péage A43', montant: 12.2 } }

safeParse ne lève jamais d'exception : il renvoie { success: true, data } ou { success: false, error }. data contient les données nettoyées (ici, le libellé sans ses espaces). parse, lui, renvoie directement les données ou lève une ZodError.

Brique ZodVérifieExemple
z.string(), .min(), .max(), .trim(), .regex()Chaînesz.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'Format attendu : AAAA-MM')
z.number(), .int(), .positive(), .min(), .max(), .multipleOf()Nombresz.number().multipleOf(0.01) : deux décimales au plus
z.enum([…])Une valeur parmi une listez.enum(['CR', 'CL', 'VA', 'MP'])
z.iso.date(), z.email(), z.uuid()Formats courantsz.email()
.optional(), .default(v), .nullable()Présencepage: z.number().default(1)
z.object({…}), z.strictObject({…})Objetsvoir plus bas
z.array(schéma)Tableauxz.array(z.string()).max(10)
z.coerce.number()Convertit puis vérifie"2" devient 2

z.iso.date() vérifie aussi le calendrier : "2026-02-30" est refusé.

Convertir la chaîne de requête avec z.coerce

Tout ce qui arrive dans l'URL est une chaîne : ?page=2 donne "2". z.coerce.number() convertit avant de vérifier :

js
const schemaFiltresFiches = z.object({
  mois: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'Format attendu : AAAA-MM').optional(),
  etat: z.enum(['CR', 'CL', 'VA', 'MP']).optional(),
  visiteur: z.coerce.number().int().positive().optional(),
  page: z.coerce.number().int().min(1).default(1),
  limite: z.coerce.number().int().min(1).max(100).default(20),
});

schemaFiltresFiches.parse({ etat: 'CL', page: '2' });
// { etat: 'CL', page: 2, limite: 20 }

Le plafond de limite (100) est appliqué ici, une fois pour toutes.

La liste blanche : z.strictObject

z.object retire silencieusement les champs qu'il ne connaît pas ; z.strictObject les refuse avec une erreur. Pour un corps de requête, préfère le refus : le client apprend tout de suite qu'il envoie un champ inutile, et une tentative d'affectation de masse est visible au lieu d'être ignorée.

js
const schemaCreationFiche = z.strictObject({ mois });

schemaCreationFiche.safeParse({ mois: '2026-11', etat: 'VA' }).error.issues;
// [{ code: 'unrecognized_keys', keys: ['etat'], path: [], message: 'Clé non reconnue : "etat"' }]

Pour la chaîne de requête, z.object suffit : des paramètres en trop (un ?_=123 ajouté par un outil contre le cache) sont simplement ignorés.

Des messages en français

Zod 4 traduit ses messages. Une ligne au chargement du module suffit :

js
z.config(z.locales.fr()); // messages d'erreur en français

"Trop petit : nombre doit être >=1", "Option invalide : une valeur parmi \"CR\"|\"CL\"|\"VA\"|\"MP\" attendue", "date ISO invalide". Pour un message adapté à ton métier, passe-le en dernier argument : .regex(…, 'Format attendu : AAAA-MM').

Les schémas d'AppliFrais

js
// src/schemas/fiches.schemas.js
import { z } from 'zod';

z.config(z.locales.fr()); // messages d'erreur en français

const mois = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'Format attendu : AAAA-MM');
const quantite = z.number().int().min(0).max(9999);

export const schemaId = z.object({
  id: z.coerce.number().int().positive(),
});

export const schemaFiltresFiches = z.object({
  mois: mois.optional(),
  etat: z.enum(['CR', 'CL', 'VA', 'MP']).optional(),
  visiteur: z.coerce.number().int().positive().optional(),
  page: z.coerce.number().int().min(1).default(1),
  limite: z.coerce.number().int().min(1).max(100).default(20),
});

export const schemaCreationFiche = z.strictObject({ mois });

export const schemaLignesForfait = z.strictObject({
  ETP: quantite,
  KM: quantite,
  NUI: quantite,
  REP: quantite,
});

export const schemaFraisHorsForfait = z.strictObject({
  date: z.iso.date(),
  libelle: z.string().trim().min(3).max(100),
  montant: z.number().positive().max(5000).multipleOf(0.01),
});

export const schemaChangementEtat = z.strictObject({
  etat: z.enum(['CL', 'MP']),
});

export const schemaValidation = z.strictObject({
  montantValide: z.number().min(0).multipleOf(0.01),
});

export const schemaConnexion = z.strictObject({
  login: z.string().trim().min(1).max(30),
  motDePasse: z.string().min(1).max(200),
});

Remarque ce qui n'est pas dans les schémas : aucun visiteurId à la création (il viendra du jeton), aucun etat à la création (une fiche naît toujours CR), aucun id pour un frais (la base le génère). Le schéma est la liste blanche des champs que le client a le droit d'envoyer.

Zod vérifie la forme des données ; les règles qui dépendent de l'état de la base (la fiche est-elle à moi ? est-elle encore en cours ?) restent dans le service.

Un middleware de validation réutilisable

Pour ne pas répéter safeParse dans chaque contrôleur, un middleware reçoit les schémas et vérifie chaque partie de la requête :

js
// src/middlewares/valider.js
import { ErreurApi } from '../erreurs.js';

// valider({ params, query, body }) : vérifie chaque partie avec son schéma Zod.
// Les données propres vont dans req.valide (req.query est en lecture seule en Express 5).
export function valider(schemas) {
  return (req, res, next) => {
    const valide = {};
    const erreurs = [];
    for (const partie of ['params', 'query', 'body']) {
      if (!schemas[partie]) continue;
      const resultat = schemas[partie].safeParse(req[partie] ?? {});
      if (resultat.success) {
        valide[partie] = resultat.data;
      } else {
        for (const probleme of resultat.error.issues) {
          erreurs.push({ champ: probleme.path.join('.') || partie, message: probleme.message });
        }
      }
    }
    if (erreurs.length > 0) {
      throw new ErreurApi(422, 'validation', 'Données invalides', 'Un ou plusieurs champs sont invalides.', { erreurs });
    }
    req.valide = valide;
    next();
  };
}

Trois choix à comprendre :

  • toutes les erreurs de toutes les parties sont rassemblées avant de répondre : le front peut afficher chaque message à côté du bon champ en une seule fois ;
  • les données converties et nettoyées sont rangées dans req.valide. Les contrôleurs lisent req.valide.body, jamais req.body : ils ne peuvent manipuler que des données vérifiées. req.query n'est pas modifiable en Express 5, d'où cet objet à part ;
  • req[partie] ?? {} : si le client n'envoie pas de corps, req.body vaut undefined ; Zod signale alors chaque champ obligatoire manquant.

Les routes déclarent ce qu'elles vérifient :

js
// src/routes/fiches.routes.js
routeurFiches.get('/', valider({ query: schemaFiltresFiches }), fiches.lister);
routeurFiches.post('/', exigerRole('visiteur'), valider({ body: schemaCreationFiche }), fiches.creer);
routeurFiches.get('/:id', valider({ params: schemaId }), fiches.lire);
routeurFiches.post('/:id/frais-hors-forfait', exigerRole('visiteur'),
  valider({ params: schemaId, body: schemaFraisHorsForfait }), fiches.ajouterFraisHorsForfait);

La réponse 422

bash
curl -s 'http://localhost:3000/v1/fiches?etat=XX&limite=500' -H "Authorization: Bearer $JETON"
json
{
  "type": "https://api.gsb.example/problemes/validation",
  "title": "Données invalides",
  "status": 422,
  "detail": "Un ou plusieurs champs sont invalides.",
  "instance": "/v1/fiches?etat=XX&limite=500",
  "erreurs": [
    { "champ": "etat", "message": "Option invalide : une valeur parmi \"CR\"|\"CL\"|\"VA\"|\"MP\" attendue" },
    { "champ": "limite", "message": "Trop grand : nombre doit être <=100" }
  ]
}

Le format suit la RFC 9457, avec l'extension erreurs prévue par le contrat de l'API (erreurs normalisées). Un champ inattendu dans le corps donne { "champ": "body", "message": "Clé non reconnue : \"id\"" }.

Upload de fichier avec Multer : téléverser un justificatif

Un frais hors forfait doit être accompagné d'un justificatif : la photo d'un ticket, une facture en PDF. Un fichier ne voyage pas en JSON : le client l'envoie dans un formulaire multipart/form-data, découpé en parties. express.json() ne le lit pas ; c'est le rôle de Multer.

bash
npm install multer
js
// src/middlewares/televersement.js
import { randomUUID } from 'node:crypto';
import multer from 'multer';
import { config } from '../config.js';
import { ErreurApi } from '../erreurs.js';

const TYPES_ACCEPTES = { 'application/pdf': '.pdf', 'image/jpeg': '.jpg', 'image/png': '.png' };

const stockage = multer.diskStorage({
  destination: config.dossierJustificatifs, // hors du dossier public
  // Jamais le nom envoyé par le client : un nom aléatoire et une extension choisie par le serveur
  filename: (req, fichier, rappel) => rappel(null, randomUUID() + TYPES_ACCEPTES[fichier.mimetype]),
});

const televerser = multer({
  storage: stockage,
  limits: { fileSize: 2 * 1024 * 1024, files: 1 }, // 2 Mo, un seul fichier
  fileFilter: (req, fichier, rappel) => {
    if (TYPES_ACCEPTES[fichier.mimetype]) rappel(null, true);
    else rappel(new ErreurApi(415, 'type-fichier', 'Type de fichier refusé', 'Envoie un PDF, un JPEG ou un PNG.'));
  },
});

// Un seul fichier, dans le champ « fichier » du formulaire ; les erreurs de Multer deviennent des 413 ou des 400.
export function unJustificatif(req, res, next) {
  televerser.single('fichier')(req, res, (erreur) => {
    if (erreur instanceof multer.MulterError) {
      if (erreur.code === 'LIMIT_FILE_SIZE') {
        return next(new ErreurApi(413, 'fichier-trop-gros', 'Fichier trop volumineux', 'Le justificatif dépasse 2 Mo.'));
      }
      return next(new ErreurApi(400, 'televersement', 'Fichier refusé', `Envoi refusé (${erreur.code}).`));
    }
    if (!erreur && !req.file) {
      return next(new ErreurApi(422, 'validation', 'Données invalides', 'Le champ « fichier » est obligatoire.'));
    }
    next(erreur);
  });
}
js
// src/routes/frais.routes.js
routeurFrais.put('/:id/justificatif', valider({ params: schemaId }), unJustificatif, async (req, res) => {
  await service.joindreJustificatif(req.utilisateur, req.valide.params.id, req.file.filename);
  res.sendStatus(204);
});
bash
curl -i -X PUT http://localhost:3000/v1/frais-hors-forfait/58/justificatif \
  -H "Authorization: Bearer $JETON" \
  -F "fichier=@ticket.pdf;type=application/pdf"

-F envoie un formulaire multipart/form-data ; l'API répond 204. Après le passage de Multer, req.file décrit le fichier enregistré (filename, size, mimetype, originalname) ; le service vérifie que le frais appartient au visiteur et que la fiche est en cours, puis enregistre le nom du fichier en base, pas le fichier.

Un fichier envoyé par un utilisateur est la porte d'entrée de plusieurs attaques. Chaque ligne du code ci-dessus en ferme une :

RisqueProtection
Saturer le disque ou la mémoirelimits.fileSize et files : 2 Mo, un seul fichier
Déposer un script exécutable, ou une page HTML qui sera servieListe blanche de types ; extension choisie par le serveur ; dossier hors de tout dossier public servi par express.static
Écraser un fichier ou sortir du dossier avec un nom comme ../../app.jsLe nom d'origine n'est jamais utilisé : un UUID aléatoire
Accéder au justificatif d'un autreLe fichier ne se télécharge que par une route authentifiée qui vérifie le propriétaire

Exercices

Exercice 1 sur 4 Écrire un schéma Réussi À revoir

Une nouvelle route PATCH /v1/frais-hors-forfait/:id permet de corriger un frais. Le client peut envoyer libelle, montant ou les deux, mais au moins un ; aucun autre champ. Écris le schéma Zod.

Voir la réponseMasquer la réponse de l'exercice 1
js
export const schemaCorrectionFrais = z
  .strictObject({
    libelle: z.string().trim().min(3).max(100).optional(),
    montant: z.number().positive().max(5000).multipleOf(0.01).optional(),
  })
  .refine((donnees) => donnees.libelle !== undefined || donnees.montant !== undefined, {
    message: 'Envoie au moins un champ à modifier.',
  });

.refine ajoute une règle qui porte sur plusieurs champs ; son message apparaît avec un chemin vide, donc champ: "body" dans la réponse du middleware.

Tu avais trouvé ?

Exercice 2 sur 4 Que renvoie Zod ? Réussi À revoir

Avec les schémas du chapitre, donne pour chaque appel success et, le cas échéant, data ou la liste des champs en erreur.

  1. schemaFiltresFiches.safeParse({ page: '3' })
  2. schemaFiltresFiches.safeParse({ limite: '0', etat: 'cl' })
  3. schemaFraisHorsForfait.safeParse({ date: '2026-10-21', libelle: ' Taxi ', montant: 18 })
  4. schemaFraisHorsForfait.safeParse({ date: '2026-10-21', libelle: 'Taxi', montant: '18' })
  5. schemaCreationFiche.safeParse({ mois: '2026-11', visiteurId: 8 })
Voir la réponseMasquer la réponse de l'exercice 2
  1. Succès : { page: 3, limite: 20 } (la chaîne est convertie, la limite prend sa valeur par défaut).
  2. Échec : limite (trop petit, minimum 1) et etat (cl en minuscules n'est pas dans l'énumération : la comparaison respecte la casse).
  3. Succès : { date: '2026-10-21', libelle: 'Taxi', montant: 18 }, le libellé est nettoyé.
  4. Échec sur montant : « nombre attendu, chaîne de caractères reçu ». Dans un corps JSON, on n'utilise pas coerce.
  5. Échec : clé non reconnue visiteurId, chemin vide (le middleware l'affiche sous body). C'est précisément une tentative de créer une fiche au nom d'un autre visiteur.

Tu avais trouvé ?

Exercice 3 sur 4 Le booléen piège Réussi À revoir

Un développeur ajoute à schemaFiltresFiches le champ archive: z.coerce.boolean().default(false) pour masquer les fiches mises en paiement. Que vaut archive pour ?archive=false ? Corrige.

Voir la réponseMasquer la réponse de l'exercice 3

true. z.coerce.boolean() applique Boolean("false"), et toute chaîne non vide est vraie en JavaScript. Le filtre fait l'inverse de ce que demande l'utilisateur.

js
archive: z.stringbool().default(false),

z.stringbool() comprend "true" et "false" (et quelques équivalents comme "1"/"0") et refuse toute autre valeur avec une erreur 422.

Tu avais trouvé ?

Exercice 4 sur 4 Télécharger un justificatif Réussi À revoir

Écris la route GET /v1/frais-hors-forfait/:id/justificatif qui renvoie le fichier au visiteur propriétaire, et 404 si le frais n'existe pas, appartient à un autre visiteur ou n'a pas de justificatif. Le dépôt fournit trouverAvecFiche(id), qui renvoie { id, ficheId, justificatif, visiteurId, etatFiche } ou null.

Voir la réponseMasquer la réponse de l'exercice 4
js
// src/services/frais.service.js
export async function nomJustificatif(utilisateur, id) {
  const frais = await depot.trouverAvecFiche(id);
  if (!frais || frais.visiteurId !== utilisateur.id || !frais.justificatif) {
    throw new ErreurApi(404, 'justificatif-introuvable', 'Justificatif introuvable', `Aucun justificatif pour le frais ${id}.`);
  }
  return frais.justificatif;
}

// src/routes/frais.routes.js
routeurFrais.get('/:id/justificatif', valider({ params: schemaId }), async (req, res) => {
  const nom = await service.nomJustificatif(req.utilisateur, req.valide.params.id);
  res.sendFile(nom, { root: config.dossierJustificatifs });
});

sendFile avec l'option root refuse tout chemin qui sortirait du dossier, et fixe le Content-Type d'après l'extension (application/pdf). Le nom vient de la base, jamais de la requête. Pour donner aussi l'accès aux comptables, il faudrait sortir cette route du routeurFrais, qui exige le rôle visiteur pour toutes ses routes.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Authentification JWT, argon2 et rôles dans une API ExpressRevoir le sommaire du cours