Menu
CoddyTech

Schémas avec zod

Fait partie de la section API REST du Journey Node.js de Coddy. Leçon 11 sur 25.

Les vérifications manuelles se multiplient avec chaque champ et chaque route. Un schéma zod décrit la structure une seule fois, et safeParse teste une valeur par rapport à ce schéma :

import { z } from 'zod';

const Event = z.object({
    name: z.string().trim().min(1).max(80),
    seats: z.number().int().min(1),
}).strict();

const result = Event.safeParse(req.body);
if (!result.success) {
    return res.status(400).json({ error: z.flattenError(result.error) });
}
// result.data contient le corps de la requête validé, avec name sans espaces en début ni en fin

.strict() refuse les champs que le schéma ne répertorie pas. Event.partial() rend chaque champ facultatif, ce dont une requête PATCH a besoin. Event.partial({ seats: true }) rend uniquement seats facultatif, et const { seats = 1 } = result.data utilise 1 si le champ est absent. z.flattenError(result.error) transforme les problèmes en { formErrors, fieldErrors } : formErrors contient les problèmes concernant l’ensemble du corps, comme un champ inconnu, et fieldErrors contient les problèmes de chaque champ. Event.parse effectue la même vérification, mais lève une exception si la valeur est invalide ; une exception dans une route entraîne une réponse 500.

challenge icon

Défi

Facile

Cette étape ouvre le projet d’API de suivi des tâches préparé pour le cours, plutôt que les fichiers de votre projet de Validation des entrées (y compris routes/tasks.js). Votre travail précédent reste dans sa propre leçon. Lisez les fichiers fournis avant de modifier routes/tasks.js. Cette copie contient des routes CRUD pour les tâches et une validation des entrées écrite à la main.

Vos vérifications écrites à la main se multiplient avec chaque champ et chaque route. Décrivez une fois une tâche avec un schéma zod, puis utilisez-le dans les trois routes qui acceptent un corps de requête.

  1. Dans routes/tasks.js, importez z depuis zod et écrivez un schéma appelé Task :
    • title : une chaîne, débarrassée des espaces de début et de fin, de 1 à 100 caractères après suppression des espaces.
    • done : true ou false (z.boolean()).
    • Le schéma est strict : tout champ qui n’y figure pas, comme color, est refusé.

    Supprimez l’assistant fourni titleError (la version du cours de la validation de Validation des entrées).

  2. Créez deux versions de Task avec .partial(). La version de POST rend uniquement done facultatif. La version de PATCH rend les deux champs facultatifs. PUT utilise Task lui-même ; il lui faut donc les deux champs.
  3. Dans POST, PATCH et PUT, vérifiez req.body avec safeParse. Si la vérification échoue, renvoyez le statut 400 et la liste des problèmes de zod sous error, créée avec z.flattenError(result.error). Pour { "title": 123 }, le résultat ressemble à ceci :
    { "error": { "formErrors": [], "fieldErrors": { "title": ["Invalid input: expected string, received number"] } } }
  4. Si la vérification réussit, enregistrez les données à partir de result.data, et non de req.body : elles contiennent le titre sans espaces superflus. Dans POST, un corps sans done crée la tâche avec "done": false (un champ manquant est undefined dans result.data, et ?? fournit une valeur de repli). PUT conserve toujours id et createdAt de la tâche stockée.

À essayer : envoyez { "title": " Order new chairs ", "color": "red" } à /api/tasks avec POST. Vous obtenez 400, et formErrors indique Unrecognized key: "color". Renvoyez la requête sans color : vous obtenez 201 avec "title": "Order new chairs" (sans espaces) et "done": false. PATCH /api/tasks/2 avec { "done": "yes" } renvoie 400 avec une entrée done dans fieldErrors. PUT /api/tasks/2 avec { "title": "Book the big room", "done": false } renvoie 200, avec l’ancien createdAt de la tâche 2.

Essayez vous-même

import { Router } from 'express';
import { nextId, tasks } from '../store.js';

const router = Router();

// La raison pour laquelle title ne peut pas être enregistré, ou null s'il peut l'être.
const titleError = (title) => {
    if (typeof title !== 'string' || title.trim() === '' || title.length > 100) {
        return 'title must be a string of 1 to 100 characters';
    }
    return null;
};

router.get('/', (req, res) => {
    res.json(tasks);
});

router.get('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return res.status(404).json({ error: 'Task not found' });
    }
    res.json(task);
});

router.post('/', (req, res) => {
    const { title } = req.body ?? {};
    const problem = titleError(title);
    if (problem) {
        return res.status(400).json({ error: problem });
    }
    const task = { id: nextId(), title, done: false, createdAt: new Date().toISOString() };
    tasks.push(task);
    res.status(201).location(`/api/tasks/${task.id}`).json(task);
});

router.patch('/:id', (req, res) => {
    const task = tasks.find((t) => t.id === Number(req.params.id));
    if (!task) {
        return res.status(404).json({ error: 'Task not found' });
    }
    const { title, done } = req.body ?? {};
    if (title !== undefined && titleError(title)) {
        return res.status(400).json({ error: titleError(title) });
    }
    if (done !== undefined && typeof done !== 'boolean') {
        return res.status(400).json({ error: 'done must be true or false' });
    }
    if (title !== undefined) task.title = title;
    if (done !== undefined) task.done = done;
    res.json(task);
});

router.put('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return res.status(404).json({ error: 'Task not found' });
    }
    const { title, done } = req.body ?? {};
    if (title === undefined || done === undefined) {
        return res.status(400).json({ error: 'PUT needs every field: title and done' });
    }
    const { id, createdAt } = tasks[index];
    tasks[index] = { id, title, done, createdAt };
    res.json(tasks[index]);
});

router.delete('/:id', (req, res) => {
    const index = tasks.findIndex((t) => t.id === Number(req.params.id));
    if (index === -1) {
        return res.status(404).json({ error: 'Task not found' });
    }
    tasks.splice(index, 1);
    res.status(204).end();
});

export default router;

Toutes les leçons de API REST

Entraînez-vous par vous-même : Playground Express