Menu
CoddyTech

Esquemas con zod

Parte de la sección API REST del Journey de Node.js de Coddy. Lección 11 de 25.

Las comprobaciones manuales aumentan con cada campo y cada ruta. Un esquema de zod describe la estructura una sola vez, y safeParse comprueba un valor con respecto a ella:

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 contiene el cuerpo validado, con name sin espacios al principio ni al final

.strict() rechaza los campos que el esquema no enumera. Event.partial() hace que todos los campos sean opcionales, que es lo que necesita una solicitud PATCH. Event.partial({ seats: true }) hace que solo seats sea opcional, y const { seats = 1 } = result.data usa 1 cuando falta. z.flattenError(result.error) convierte los problemas en { formErrors, fieldErrors }: formErrors contiene los problemas de todo el cuerpo, como un campo desconocido, y fieldErrors contiene los problemas de cada campo. Event.parse hace la misma comprobación, pero lanza una excepción si el valor no es válido; una excepción en una ruta se convierte en un error 500.

challenge icon

Desafío

Fácil

Este paso abre el proyecto de la API de seguimiento de tareas preparado para el curso, en lugar de los archivos de tu proyecto de Validating Input (incluido routes/tasks.js). Tu trabajo anterior permanece en su propia lección. Lee los archivos proporcionados antes de editar routes/tasks.js. Esta copia contiene rutas CRUD para las tareas y validación de entrada escrita a mano.

Tus comprobaciones escritas a mano aumentan con cada campo y cada ruta. Describe una tarea una sola vez con un esquema de zod y haz que las tres rutas que reciben un cuerpo lo usen.

  1. En routes/tasks.js, importa z de zod y escribe un esquema llamado Task:
    • title: una cadena, recortada, de 1 a 100 caracteres después de recortarla.
    • done: true o false (z.boolean()).
    • El esquema es estricto, así que se rechaza cualquier campo que no incluya, como color.

    Elimina la función auxiliar proporcionada titleError (la versión del curso de la validación de Validating Input).

  2. Crea dos versiones de Task con .partial(). La versión de POST hace que solo done sea opcional. La versión de PATCH hace que ambos campos sean opcionales. PUT usa Task directamente, así que necesita ambos campos.
  3. En POST, PATCH y PUT, comprueba req.body con safeParse. Si la comprobación falla, responde con el estado 400 y la lista de problemas de zod bajo error, generada con z.flattenError(result.error). Para { "title": 123 } se ve así:
    { "error": { "formErrors": [], "fieldErrors": { "title": ["Invalid input: expected string, received number"] } } }
  4. Si la comprobación se supera, guarda los datos de result.data, no de req.body: ahí está el título recortado. En POST, un cuerpo sin done crea la tarea con "done": false (un campo ausente es undefined en result.data, y ?? proporciona un valor alternativo). PUT sigue conservando id y createdAt de la tarea almacenada.

Pruébalo: envía por POST { "title": " Order new chairs ", "color": "red" } a /api/tasks. Obtendrás 400, y formErrors indica Unrecognized key: "color". Vuelve a enviarlo sin color: obtendrás 201 con "title": "Order new chairs" (sin espacios) y "done": false. PATCH /api/tasks/2 con { "done": "yes" } devuelve 400 con una entrada done en fieldErrors. PUT /api/tasks/2 con { "title": "Book the big room", "done": false } devuelve 200 y conserva el createdAt anterior de la tarea 2.

Pruébalo tú mismo

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

const router = Router();

// Por qué no se puede almacenar un title, o null cuando sí se puede.
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;

Todas las lecciones de API REST

Practica por tu cuenta: Playground de Express