Accueil / Articles / Protéger la frontière Express : Un middleware Zod pour le corps de la requête, les paramètres et l’URL

Protéger la frontière Express : Un middleware Zod pour le corps de la requête, les paramètres et l’URL

Apprenez à valider les corps de requête Express, les paramètres de routage et les chaînes de recherche à l’aide d’un middleware Zod réutilisable, ainsi qu’à comprendre comment il complète la validation des modèles Sequelize.

1982 mots

Rien n’empêche un client d’envoyer un numéro là où votre API attend un nom, ou null là où elle attend un mot de passe. Un code qui fait confiance aveuglément à req.body finit par écrire des enregistrements défectueux ou par générer des erreurs sans rapport avec leur véritable cause. Ce guide montre comment décrire une fois les entrées valides à l’aide de Zod, les appliquer via un seul middleware Express couvrant le corps de la requête, les paramètres de route et la chaîne de recherche, et permettre aux contrôleurs de se concentrer sur la logique métier.

Le problème : les requêtes arrivent sans type défini

Voici un en-tête HTTP parfaitement légal que aucun point de terminaison de registration ne devrait accepter :

{
  "fullName": 123,
  "email": "hello",
  "password": null
}

Chaque champ a la forme incorrecte. Zod est une bibliothèque de schémas pour JavaScript et TypeScript qui vous permet de préciser exactement ce que vous attendez, afin d’obtenir soit des données propres, soit une liste structurée des problèmes.

Décrire les entrées sous forme de schéma

Supposons que l’enregistrement nécessite une chaîne fullName, un email bien formaté, un password d’au moins huit caractères, ainsi qu’un age entier facultatif. Avec Zod, cela ressemble presque aux exigences elles-mêmes :

const { z } = require('zod');

const registerSchema = z.object({
    fullName: z.string().min(2),
    email: z.string().email(),
    password: z.string().min(8),
    age: z.number().int().min(18).optional()
});

Les règles sont regroupées dans un seul objet au lieu d’être dispersées dans des instructions if. La règle concernant l’âge impose également un minimum de 18 ans : un âge manquant est accepté, tandis qu’un âge de 16 ans est rejeté.

Installation et importation

Zod est une dépendance npm classique :

npm install zod

Avec CommonJS, on importe l’espace de noms z à l’aide de require:

const { z } = require('zod');

Avec les modules ES, on utilise une importation nommée :

import { z } from 'zod';

Ajout de messages d’erreur lisible

Chaque validateur accepte un message facultatif, qui sera affiché aux clients :

const registerSchema = z.object({
    fullName: z.string().min(2, 'Full name is required'),
    email: z.string().email('Invalid email'),
    password: z
        .string()
        .min(8, 'Password must be at least 8 characters'),
    age: z
        .number()
        .int()
        .min(18)
        .optional()
});

Un chargement de données qui respecte toutes les règles est transmis tel quel :

{
  "fullName": "John Smith",
  "email": "john@example.com",
  "password": "password123",
  "age": 25
}

Celui-ci présente un nom trop court, une adresse sans domaine et un mot de passe de trois caractères :

{
  "fullName": "J",
  "email": "invalid-email",
  "password": "123"
}

Zod signale les trois problèmes en même temps, permettant ainsi à un formulaire de mettre en évidence tous les champs invalides lors d’une seule requête. Les versions récentes de Zod (v4 et ultérieures) proposent également des validateurs de niveau supérieur tels que z.email(), tout en dépréciant le style en chaîne z.string().email(). Le format en chaîne fonctionne toujours, mais consultez la documentation actuelle correspondant à votre version.

Choix entre parse() et safeParse()

parse() lance une exception

parse() renvoie les données validées ou lance une ZodError:

const data = registerSchema.parse(req.body);

Dans un gestionnaire Express, vous devez alors capturer l’erreur vous-même ou la transmettre avec next(err).

safeParse() renvoie un résultat

safeParse() ne lance jamais d’exception. Il renvoie un objet contenant un indicateur success, ce qui convient mieux au traitement des requêtes car une entrée invalide est un résultat attendu et non une exception :

const result = registerSchema.safeParse(req.body);

En cas d’échec, error.issues liste chaque problème avec son chemin et son message, prêt pour une réponse 400 :

if (!result.success) {
    return res.status(400).json({
        success: false,
        errors: result.error.issues
    });
}

En cas de succès, result.data contient la valeur analysée :

const data = result.data;

Utilisez result.data à partir de maintenant, et non req.body : les clés inconnues sont supprimées par défaut, et les conversions ainsi que les valeurs par défaut ont déjà été appliquées.

Des vérifications en ligne de code à un middleware réutilisable

L’intégration la plus simple appelle safeParse() à l’intérieur du gestionnaire :

app.post('/register', (req, res) => {
  const result = registerSchema.safeParse(req.body);
    if (!result.success) {
        return res.status(400).json({
            success: false,
            message: 'Validation failed',
            errors: result.error.issues
        });
    }
    const data = result.data;
    console.log(data);
    // Continue with registration logic...
    return res.status(201).json({
        success: true,
        data
    });
});

Cela fonctionne, mais avec 20 ou 50 points d’entrée, les mêmes lignes sont collées dans chaque contrôleur et finissent par s’éloigner progressivement. Notez également que cet exemple renvoie à l’utilisateur l’objet validé, y compris le mot de passe ; un véritable point d’entrée ne devrait renvoyer que les champs non sensibles.

Une usine validate()

L’usine présentée ci-dessous prend un schéma et renvoie un gestionnaire Express. Elle valide le corps de la requête, les paramètres et l’URL en même temps, renvoie 400 en cas d’échec, et sinon stocke le résultat analysé dans req.validated avant d’appeler next():

const validate = (schema) => {
    return (req, res, next) => {
      const result = schema.safeParse({
                  body: req.body,
                  params: req.params,
                  query: req.query
              });
              if (!result.success) {
                  return res.status(400).json({
                      success: false,
                      message: 'Validation failed',
                      errors: result.error.issues
                  });
              }
              req.validated = result.data;
              next();
          };
      };

 module.exports = validate;

Deux points sont importants. Écrire dans une propriété séparée req.validated évite des problèmes dans Express 5, où req.query est un accesseur et ne peut pas être simplement réaffecté. De plus, comme le middleware enveloppe les données d’entrée sous la forme { body, params, query }, les schémas doivent respecter cette structure. Un registerSchema plat chercherait fullName au niveau le plus élevé et rejetterait toutes les requêtes ; il faut donc l’envelopper dans z.object({ body: registerSchema }), ou faire en sorte que le middleware ne valide que req.body.

L’intégration dans une route

Le middleware se situe entre le chemin et le contrôleur :

router.post(
    '/register',
    validate(registerSchema),
    register
);

Le pipeline de requêtes devient alors :

Request
   ↓
Express Router
   ↓
Zod Validation Middleware
   ↓
Controller
   ↓
Service
   ↓
Database

Les données d’entrée invalides sont arrêtées au niveau du middleware, et le contrôleur n’est jamais exécuté ; les données valides continuent leur traitement, avec la garantie que celles-ci correspondent au schéma.

Réserver les contrôleurs à la logique métier

En l’absence d’une couche de validation, un contrôleur accumule toutes les préoccupations en même temps :

const register = async (req, res) => {
    // validation
    // check email
    // validate password
    // validate name
    // business logic
    // database operation
};

Avec le middleware en place, il ne fait que lire des valeurs vérifiées :

const register = async (req, res) => {
 const {
        fullName,
        email,
        password
    } = req.validated.body;
    // Business logic
};

En plus, les schémas peuvent être testés unitairement avec des objets simples, et les tests de contrôleur n’ont plus besoin d’un cas pour chaque charge utile mal formatée.

Valider les paramètres de route par coercition

La même approche s’applique aux segments URL. Prenons une requête pour un utilisateur :

GET /users/123

Un schéma pour le paramètre id :

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

Attaché comme avant (sous une clé params lorsqu’on utilise le middleware ci-dessus) :

router.get(
    '/users/:id',
    validate(userParamsSchema),
    getUser
);

Le point clé est la coercition :

z.coerce.number()

Tout dans une URL est du texte. La valeur de

req.params.id

arrive sous forme de chaîne

"123"

et non en tant que nombre

123

Une simple z.number() rejetterait toutes les requêtes. z.coerce.number() fait d’abord passer l’entrée par Number(), puis applique .int() et .positive(). Un cas particulier : Number('') vaut 0, donc une valeur vide devient zéro. Ici, .positive() le détecte, mais un schéma sans limite inférieure le laisserait passer.

Vérification des chaînes de requête avec des valeurs par défaut

La pagination est le cas classique de chaîne de requête :

GET /users?page=1&limit=10

La coercition combinée aux valeurs par défaut permet d’obtenir des nombres sécurisés même lorsque le client les omet :

const userQuerySchema = z.object({
    page: z.coerce.number().int().positive().default(1),
    limit: z.coerce.number().int().positive().max(100).default(10)
});

La limite .max(100) empêche également un client de demander un million de lignes en une seule requête.

Blocs de construction courants de Zod

La plupart des schémas combinent un petit ensemble d’éléments :

  • z.string(), z.number(), z.boolean() vérifient les types primitifs.
  • z.object() décrit la structure d’un objet ; z.array() valide un tableau et ses éléments.
  • z.enum() limite une valeur à une liste fixe d’options.
  • .min() et .max() définissent la valeur d’un nombre ou la longueur d’une chaîne ou d’un tableau.
  • .email() vérifie le format d’une adresse e-mail ; .int() exige un nombre entier ; .positive() exige une valeur supérieure à zéro.
  • .optional() autorise l’absence d’un champ ; .nullable() permet la valeur explicite null ; .default() remplit les valeurs manquantes.
  • z.coerce est un espace de noms plutôt qu’une fonction : z.coerce.number() et ses variantes convertissent les entrées avant validation.
  • .refine() ajoute des règles personnalisées ; .transform() redéfinit une valeur après son passage.
  • .parse() lance une exception en cas d’échec ; .safeParse() renvoie un résultat indiquant le succès ou l’erreur.
  • Exemple : un enregistrement d’utilisateur avec des rôles

    const userSchema = z.object({
        fullName: z.string().min(2),
        email: z.string().email(),
        role: z.enum([
            'admin',
            'teacher',
            'parent'
        ]),
        isActive: z.boolean().default(true)
    });
    

    z.enum() rejette tout autre rôle, et isActive prend la valeur par défaut de true lorsqu’il n’est pas spécifié. Le schéma sert également de documentation.

    Zod et Sequelize valident des niveaux différents

    Zod protège la frontière de l’API

    HTTP Request
          ↓
         Zod
          ↓
     Controller
    

    Sequelize protège la couche de données

    Ses validateurs s’exécutent lors du sauvegarde d’un modèle, au cœur de la couche de service :

    Controller
         ↓
     Service
         ↓
     Sequelize
         ↓
     MySQL
    

    Utilisation des deux

    Ils forment ensemble deux couches indépendantes :

    Client
       ↓
    Express
       ↓
    Zod
       ↓
    Controller
       ↓
    Service
       ↓
    Sequelize
       ↓
    MySQL
    

    Zod génère des réponses 400 rapides et adaptées aux clients ; Sequelize détecte les erreurs provenant de l’intérieur de l’application, comme un travail en arrière-plan créant un enregistrement incorrect. Les contraintes de base de données telles que NOT NULL et les index uniques restent la dernière ligne de défense.

    Organisation des schémas dans une base de code plus grande

    Dans un projet basé sur des modules, chaque module dispose d’un fichier de validation à côté de ses routes, contrôleurs et services, avec le middleware partagé dans un dossier distinct :

    src/
    ├── modules/
    │   └── users/
    │       ├── user.controller.js
    │       ├── user.service.js
    │       ├── user.routes.js
    │       └── user.validation.js
    │
    ├── middleware/
    │   └── validate.js
    │
    └── app.js
    

    user.validation.js exporte les schémas du module :

    const { z } = require('zod');
    
    const createUserSchema = z.object({
        fullName: z.string().min(2),
        email: z.string().email(),
        password: z.string().min(8)
    });
    
    module.exports = {
        createUserSchema
    };
    

    et le fichier des routes reste concis :

    router.post(
        '/users',
        validate(createUserSchema),
        createUser
    );
    

    Lorsqu’un champ change, le contrôleur et ses règles sont modifiés ensemble. Pour réutiliser les mêmes schémas dans le navigateur, consultez partager un schéma Zod entre React et Node.

    Pourquoi une seule source de vérité est avantageuse

    Sans schéma, la validation se retrouve dans les contrôleurs sous forme de vérifications ad hoc :

    if (!email) {
        // ...
    }
    if (!password) {
        // ...
    }
    if (password.length < 8) {
        // ...
    }
    if (!['admin', 'teacher'].includes(role)) {
        // ...
    }
    

    Chaque point d’entrée répète une version légèrement différente, et personne ne peut voir en un coup d’œil le contrat complet. Le schéma équivalent l’énonce en quelques lignes :

    const userSchema = z.object({
        email: z.string().email(),
        password: z.string().min(8),
        role: z.enum(['admin', 'teacher'])
    });
    

    C’est là l’accord entre l’API et les clients, appliqué en un seul endroit. Pour une comparaison avec une autre approche populaire, consultez Zod versus express-validator.

    Points clés

    La véritable valeur réside dans l’ordre des responsabilités imposé par Zod :

    Request
       ↓
    Validation
       ↓
    Controller
       ↓
    Business Logic
       ↓
    Database
    
    • Vérifier aux limites avec safeParse() et ne laisser parvenir aux traitements que result.data.
    • Centraliser la validation dans un seul middleware, et faire en sorte que chaque schéma corresponde à la structure qu’il analyse.
    • Utiliser z.coerce pour les paramètres et les chaînes de requête, ainsi que limiter des valeurs comme la taille de page.
    • Considérer les validateurs ORM et les contraintes de base de données comme une couche supplémentaire, et non comme un remplacement.
    • Placer les schémas à côté de leurs modules afin que les contrats évoluent avec le code.

    Lectures complémentaires

  • Identifier vs former : Choisir les paramètres de route ou les chaînes de recherche dans Express — Apprenez quand une valeur doit figurer dans un paramètre de route d’Express plutôt que dans une chaîne de recherche, comment lire req.params et req.query, ainsi que comment gérer les valeurs par défaut et les types en toute sécurité.
  • La méthode HTTP QUERY pour les équipes frontend : Lues sécurisées avec un corps — Découvrez quand la méthode HTTP QUERY est préférable à GET et POST pour des filtres complexes, comment l’utiliser avec fetch, et quels sont les besoins en matière de CORS, de mise en cache et de support infrastructurel.