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çue | Conséquence sans validation |
|---|---|
"montant": "12.20" (chaîne) | montant + 5 donne "12.205" ; ou erreur SQL, donc 500 |
"montant": -500 | Un remboursement négatif accepté, si la base n'a pas de CHECK |
?limite=1000000 | Toute la table renvoyée en une requête |
?etat=CL&etat=VA | req.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 Mo | Mémoire et base saturées |
Zod en cinq minutes
npm install zod
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 Zod | Vérifie | Exemple |
|---|---|---|
z.string(), .min(), .max(), .trim(), .regex() | Chaînes | z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'Format attendu : AAAA-MM') |
z.number(), .int(), .positive(), .min(), .max(), .multipleOf() | Nombres | z.number().multipleOf(0.01) : deux décimales au plus |
z.enum([…]) | Une valeur parmi une liste | z.enum(['CR', 'CL', 'VA', 'MP']) |
z.iso.date(), z.email(), z.uuid() | Formats courants | z.email() |
.optional(), .default(v), .nullable() | Présence | page: z.number().default(1) |
z.object({…}), z.strictObject({…}) | Objets | voir plus bas |
z.array(schéma) | Tableaux | z.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 :
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.
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 :
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
// 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 :
// 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 lisentreq.valide.body, jamaisreq.body: ils ne peuvent manipuler que des données vérifiées.req.queryn'est pas modifiable en Express 5, d'où cet objet à part ; req[partie] ?? {}: si le client n'envoie pas de corps,req.bodyvautundefined; Zod signale alors chaque champ obligatoire manquant.
Les routes déclarent ce qu'elles vérifient :
// 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
curl -s 'http://localhost:3000/v1/fiches?etat=XX&limite=500' -H "Authorization: Bearer $JETON"
{
"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.
npm install multer
// 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);
});
}
// 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);
});
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 :
| Risque | Protection |
|---|---|
| Saturer le disque ou la mémoire | limits.fileSize et files : 2 Mo, un seul fichier |
| Déposer un script exécutable, ou une page HTML qui sera servie | Liste 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.js | Le nom d'origine n'est jamais utilisé : un UUID aléatoire |
| Accéder au justificatif d'un autre | Le 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
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.
schemaFiltresFiches.safeParse({ page: '3' })schemaFiltresFiches.safeParse({ limite: '0', etat: 'cl' })schemaFraisHorsForfait.safeParse({ date: '2026-10-21', libelle: ' Taxi ', montant: 18 })schemaFraisHorsForfait.safeParse({ date: '2026-10-21', libelle: 'Taxi', montant: '18' })schemaCreationFiche.safeParse({ mois: '2026-11', visiteurId: 8 })
Voir la réponseMasquer la réponse de l'exercice 2
- Succès :
{ page: 3, limite: 20 }(la chaîne est convertie, la limite prend sa valeur par défaut). - Échec :
limite(trop petit, minimum 1) etetat(clen minuscules n'est pas dans l'énumération : la comparaison respecte la casse). - Succès :
{ date: '2026-10-21', libelle: 'Taxi', montant: 18 }, le libellé est nettoyé. - Échec sur
montant: « nombre attendu, chaîne de caractères reçu ». Dans un corps JSON, on n'utilise pascoerce. - Échec : clé non reconnue
visiteurId, chemin vide (le middleware l'affiche sousbody). 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.
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
// 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é ?