L'API de la lliçó anterior té un forat de la mida d'un camió: si algú envia {"nom": "", "preuEuros": "caríssim", "estoc": -5, "color": "vermell"}, el cafè es crea. eurosACentims("caríssim") produeix NaN, l'estoc queda negatiu, el camp color es desa sense que ningú ho hagi previst i a partir d'aquí GET /v1/cafes retorna "preuEuros": null per sempre. Les comprovacions a mà que vam anar deixant pel camí són incompletes, estan repetides i cadascuna s'inventa el seu missatge. Avui les substituïm per esquemes declaratius amb Zod i un únic middleware validar(esquema, origen) que rebutja l'entrada estricta, converteix els tipus dels paràmetres de consulta i retorna totes les fallades alhora a detalls, amb el format exacte que vam fixar a 02-04. És la lliçó que converteix l'API en una cosa que es pot exposar a un consumidor que no siguis tu mateix.

Contingut

  1. Per què no es confia mai en l'entrada
  2. Què es valida: cos, ruta, consulta i capçaleres
  3. Validació manual davant de validació per esquema
  4. Primeres passes amb Zod: parse i safeParse
  5. Els tipus que necessita la Botiga Aroma
  6. z.coerce i el problema dels paràmetres de consulta
  7. Entrada estricta amb .strict()
  8. Regles compostes amb refine i superRefine
  9. Normalització amb transform
  10. Un esquema per operació: partial, extend i merge
  11. src/esquemes/comuns.js
  12. src/esquemes/cafes.js
  13. src/esquemes/comandes.js
  14. El middleware validar(esquema, origen)
  15. Del ZodError al format d'error del contracte
  16. Les rutes amb validació
  17. Peticions invàlides i les seves respostes exactes
  18. Validació de negoci: per què viu al servei
  19. Sanejament, normalització i límit de mida
  20. Alternatives a Zod i la connexió amb OpenAPI

  1. Per què no es confia mai en l'entrada

La regla és tan vella com el desenvolupament web i no admet matisos: tot el que arriba per la xarxa és hostil fins que no es demostri el contrari. No pas perquè tots els consumidors siguin maliciosos —la SPA de la Botiga Aroma no ho és—, sinó perquè:

Motiu Exemple a la Botiga Aroma
Errors honestos L'Aroma Mòbil envia preuEuros com a cadena per un bug del seu formulari
Clients desactualitzats Una versió antiga de l'app envia torrefaccio: "torrat", que ja no existeix
Integracions de tercers El soci RàpidEnviaments envia dates en format català (14/03/2026)
Atacs Algú prova estoc: -999999 o un cos de 500 MB per veure què passa
El client no és de fiar per definició Encara que la SPA validi, qualsevol pot cridar l'API amb curl

Aquest darrer punt és el decisiu. La validació del client és usabilitat; la validació del servidor és correcció. La primera existeix per no fer esperar l'usuari que el servidor li digui que el camp és buit; la segona és l'única que protegeix de debò les dades, perquè l'API és pública i ningú no està obligat a usar la teva SPA per cridar-la.

Les conseqüències de no validar es divideixen en dues famílies:

  • Integritat. Dades corruptes que es propaguen: un NaN desat avui trenca una factura d'aquí a tres mesos, i per llavors l'origen del problema és impossible de rastrejar. A més contaminen els càlculs agregats: un preu nul fa que l'informe de vendes menteixi.
  • Seguretat. Injecció SQL, injecció NoSQL, contaminació de prototips, denegació de servei amb cossos gegants o expressions regulars patològiques. Aquí només veurem com la validació tanca la porta d'entrada; el catàleg complet d'amenaces i les seves defenses és la lliçó 04-02.

Un principi que ordena tota la resta: valida a la vora, confia a dins. Un cop la petició ha passat el middleware de validació, la resta del codi —controlador, servei, repositori— pot donar per fet que dades.preuEuros és un número positiu amb dos decimals. Si cada capa ho torna a comprovar, el codi s'omple de defensa inútil i ningú no sap qui és responsable de què.

  1. Què es valida: cos, ruta, consulta i capçaleres

Un error freqüent és validar només el cos. Hi ha quatre entrades i totes quatre són igual de manipulables:

Origen Què conté Exemple d'atac o error
Cos (req.body) Les dades del recurs preuEuros: -10, camp desconegut esAdmin: true
Ruta (req.params) Identificadors /v1/cafes/../../etc/passwd, /v1/cafes/'; DROP TABLE
Consulta (req.query) Filtres, ordre, paginació limit=999999, ordenar=(select…)
Capçaleres (req.headers) Metadades del protocol Content-Type inesperat, Idempotency-Key absent

A la Botiga Aroma validarem les tres primeres amb esquemes. Les capçaleres es comproven de manera puntual, perquè la seva semàntica és del protocol i no del domini: el Content-Type de PATCH ja es comprova al controlador (03-03) i la Idempotency-Key obligatòria a POST /v1/comandes es comprovarà en un middleware propi.

  1. Validació manual davant de validació per esquema

Així és com va quedar la comprovació manual de POST /v1/cafes a 03-03, i només cobria els camps obligatoris:

const obligatoris = ['nom', 'origen', 'torrefaccio', 'preuEuros', 'estoc'];
const mancants = obligatoris.filter((camp) => dades?.[camp] === undefined);
if (mancants.length > 0) { /* ... 400 ... */ }

Per cobrir el contracte complet caldria afegir-hi: que nom sigui una cadena no buida de longitud raonable, que torrefaccio sigui un de tres valors, que preuEuros sigui un número positiu amb dos decimals, que estoc sigui un enter no negatiu, que notesTast sigui un array de cadenes, que no hi hagi camps desconeguts… i repetir-ho al PUT, i al PATCH amb tot opcional, i un altre cop per a les comandes. Serien dues-centes línies d'if que cal mantenir sincronitzades amb la documentació.

Criteri Manual (if) Esquema declaratiu
Llegibilitat Es perd entre condicionals L'esquema és l'especificació
Tots els errors alhora Cal acumular-los a mà De sèrie
Conversió de tipus Manual i propensa a fallades Integrada
Reutilització POST/PUT/PATCH Copiar i enganxar extend, merge, partial
Documentació Es desincronitza Genera JSON Schema i OpenAPI
Cost d'una regla nova Un if a cada lloc Una línia en un sol lloc

Un esquema declaratiu diu què s'ha de complir, no com comprovar-ho. I com que és un objecte, es pot transformar: d'un esquema Zod en surten un validador, els tipus de TypeScript i un JSON Schema per a OpenAPI. D'un if no en surt res.

  1. Primeres passes amb Zod: parse i safeParse

Zod ja està instal·lat des de 03-01. Els exemples fan servir l'API estable, comuna a les versions 3 i 4.

import { z } from 'zod';

// Un esquema és un objecte que descriu una forma de dada.
const esquemaNom = z.string().min(3).max(120);

// parse() retorna el valor validat o LLANÇA un ZodError.
esquemaNom.parse('Etiòpia Yirgacheffe'); // → 'Etiòpia Yirgacheffe'
esquemaNom.parse('ab');                  // → llança ZodError

// safeParse() no llança mai: retorna un resultat discriminat.
const resultat = esquemaNom.safeParse('ab');
console.log(resultat.success);  // false
console.log(resultat.error.issues);
// [{ code: 'too_small', minimum: 3, path: [], message: 'String must contain at least 3 character(s)' }]
Mètode Retorna Quan fer-lo servir
parse(v) El valor validat, o llança Dins d'un try, o quan la fallada és un bug
safeParse(v) { success: true, data } o { success: false, error } Al middleware: la fallada és esperable

A la Botiga Aroma farem servir safeParse, perquè un cos invàlid no és una excepció: és el cas normal d'un client que s'equivoca, i la seva resposta és un 400 ben format, no un error de programa.

L'essencial de l'objecte error: la propietat issues és un array amb tots els problemes trobats, no només el primer. Cada issue té:

Propietat Què és Exemple
path Camí al camp, com a array ['linies', 0, 'quantitat']
code Tipus de problema invalid_type, too_small, unrecognized_keys
message Missatge llegible 'Expected number, received string'

Aquest issues complet és justament el que necessita el contracte: la validació retorna totes les fallades alhora (02-04), perquè el consumidor corregeixi la seva petició d'un sol cop i no en cinc intents.

  1. Els tipus que necessita la Botiga Aroma

Un recorregut pels constructors que farem servir, cadascun amb el seu cas real:

import { z } from 'zod';

// --- Cadenes ---
z.string();                       // ha de ser cadena
z.string().min(1, 'No pot estar buit');
z.string().max(120);
z.string().trim();                // retalla espais ABANS de validar
z.string().email();               // correu del client
z.string().regex(/^caf_\d{3}$/, "Format d'id de cafè invàlid");

// --- Números ---
z.number();                       // ha de ser número (no cadena)
z.number().int('Ha de ser un enter');
z.number().positive();            // > 0
z.number().nonnegative();         // >= 0, el correcte per a 'estoc'
z.number().max(10000);

// --- Booleans i enumerats ---
z.boolean();
z.enum(['clar', 'mitja', 'fosc']);                       // torrefaccio
z.enum(['pendent_pagament', 'pagat', 'enviat']);         // estat de comanda

// --- Dates ISO-8601 en UTC, com vam fixar a 02-05 ---
z.string().datetime({ message: 'Ha de ser una data ISO-8601 en UTC amb Z' });

// --- Arrays ---
z.array(z.string()).max(10);      // notesTast: com a molt 10 notes
z.array(esquemaLinia).min(1, 'La comanda ha de tenir com a mínim una línia');

// --- Objectes ---
z.object({ nom: z.string(), estoc: z.number() });

// --- Modificadors ---
z.string().optional();            // pot faltar (undefined)
z.string().nullable();            // pot ser null
z.string().default('');           // si falta, s'omple

Mereix una aturada el cas de preuEuros. El contracte de 02-05 diu "euros amb dos decimals per fora, cèntims per dins". Zod no té un tipus "decimal amb dues xifres", així que es compon:

const preuEuros = z
  .number()
  .positive('El preu ha de ser més gran que zero')
  .max(1000, 'El preu no pot superar els 1000 €')
  .refine((valor) => Number.isInteger(Math.round(valor * 100)) && (valor * 100) % 1 < 1e-9, {
    message: 'El preu admet com a màxim dos decimals',
  });

Una manera més llegible i robusta d'expressar el mateix, evitant el soroll de la coma flotant, és comprovar la representació textual:

const preuEuros = z
  .number()
  .positive('El preu ha de ser més gran que zero')
  .max(1000, 'El preu no pot superar els 1000 €')
  .refine((valor) => /^\d+(\.\d{1,2})?$/.test(String(valor)), {
    message: 'El preu admet com a màxim dos decimals',
  });

Així 14.5 i 14.50 passen (són el mateix número), i 14.567 es rebutja amb 400. Acceptar tres decimals seria acceptar fraccions de cèntim que es perdrien en convertir, i amb elles la quadratura comptable.

  1. z.coerce i el problema dels paràmetres de consulta

Com vam veure a 03-03, tot el que arriba a la URL és text. ?limit=20&disponible=true produeix { limit: '20', disponible: 'true' }. Un z.number() sobre '20' falla, i amb raó.

z.coerce converteix abans de validar:

z.coerce.number().int().min(1).max(100).parse('20');   // → 20 (número)
z.coerce.number().parse('abc');                        // → falla: NaN no és número
z.coerce.boolean().parse('true');                      // → true

Compte amb z.coerce.boolean(): aplica la conversió de JavaScript, on qualsevol cadena no buida és certa. Així, ?disponible=false es convertiria en true, que és exactament el bug que vam corregir a l'exercici 1 de 03-03. Per als booleans a la URL cal ser explícit:

// Correcte: només 'true' i 'false', qualsevol altra cosa és 400.
const booleaDeConsulta = z
  .enum(['true', 'false'], { message: "Només s'admet 'true' o 'false'" })
  .transform((v) => v === 'true');

Aquesta és la mena de detall que separa una API que sembla que funciona d'una que funciona.

  1. Entrada estricta amb .strict()

Per defecte, z.object() descarta en silenci les claus que no són a l'esquema. El contracte de 02-05 va decidir el contrari: entrada estricta, camp desconegut → 400.

const lax = z.object({ nom: z.string() });
lax.parse({ nom: 'Kenya', color: 'vermell' });          // → { nom: 'Kenya' }, el color es perd

const estricte = z.object({ nom: z.string() }).strict();
estricte.parse({ nom: 'Kenya', color: 'vermell' });     // → falla: unrecognized_keys

Les tres raons de la decisió, que convé tenir a mà perquè el debat reapareix sempre:

  1. Detecta errades del client. Qui envia preuEuro (sense s) amb la versió laxa rep un 201 alegre i un cafè amb preu per defecte. Amb l'estricta, rep un 400 que li diu exactament quin camp no existeix.
  2. Impedeix l'assignació massiva. Si demà el model intern tingués un camp destacat o rolClient, un cos que l'inclogués podria acabar desant-lo si el codi fa {...dades}. L'entrada estricta tanca aquella porta des de la vora.
  3. Fa explícita l'evolució. Afegir un camp a l'API passa a ser una decisió conscient que es reflecteix a l'esquema i a openapi.yaml.

La contrapartida honesta: l'entrada estricta redueix la tolerància. Un client que reenvia tal qual una representació que li vam retornar —inclosos _links o dataCreacio— rebrà un 400. És un cas real i freqüent al PUT. Es resol documentant-ho amb claredat i, si cal, ignorant explícitament els camps de només lectura a l'esquema en lloc de rebutjar-los.

  1. Regles compostes amb refine i superRefine

Hi ha regles que no són d'un camp sinó de la relació entre diversos. refine afegeix una comprovació arbitrària:

// Rang de preus coherent als filtres
const esquemaRang = z
  .object({
    preuMin: z.coerce.number().nonnegative().optional(),
    preuMax: z.coerce.number().nonnegative().optional(),
  })
  .refine(
    (dades) =>
      dades.preuMin === undefined ||
      dades.preuMax === undefined ||
      dades.preuMin <= dades.preuMax,
    { message: "'preuMin' no pot ser més gran que 'preuMax'", path: ['preuMin'] }
  );

El path importa: sense ell, la fallada no s'associa a cap camp i el detalls de l'error queda sense camp.

superRefine permet emetre diversos problemes i triar-ne el codi:

const esquemaDates = z
  .object({
    dataDes: z.string().datetime().optional(),
    dataFins: z.string().datetime().optional(),
  })
  .superRefine((dades, ctx) => {
    if (dades.dataDes && dades.dataFins && dades.dataDes > dades.dataFins) {
      ctx.addIssue({
        code: 'custom',
        path: ['dataDes'],
        message: "'dataDes' ha de ser anterior a 'dataFins'",
      });
    }
  });

On és el límit. A l'esquema hi van les regles que es poden comprovar mirant només la petició: formats, rangs, coherència entre camps. Tot el que necessiti consultar l'estat del sistema —existeix aquell cafè?, hi ha estoc?, aquesta comanda ja està pagada?— no hi va. Hi tornarem a la secció 18, perquè és la confusió més comuna d'aquesta lliçó.

  1. Normalització amb transform

transform canvia el valor després de validar-lo. Ens serveix per a dues coses:

// 1. Netejar l'entrada: retallar espais, normalitzar majúscules
const nom = z.string().trim().min(1).max(120);
const origen = z
  .string()
  .trim()
  .min(1)
  .transform((v) => v.charAt(0).toUpperCase() + v.slice(1).toLowerCase()); // 'COLÒMBIA' → 'Colòmbia'

// 2. Convertir a la unitat interna: euros → cèntims
const preu = preuEuros.transform((euros) => Math.round(euros * 100));

La segona temptació és forta i cal resistir-la en part. Si l'esquema retornés preuCentims, el controlador rebria un objecte que ja no s'assembla al contracte públic, i la traducció d'unitats quedaria repartida entre l'esquema i el mapejador. A la Botiga Aroma mantenim la conversió al servei (eurosACentims, 03-03) i fem servir transform només per normalitzar: retallar espais, unificar majúscules de l'origen, eliminar notes de tast duplicades. Una regla útil: transform neteja el que el client va escriure; no tradueix entre el món públic i l'intern.

  1. Un esquema per operació: partial, extend i merge

POST, PUT i PATCH no demanen el mateix, així que no comparteixen esquema; comparteixen peces.

Operació Esquema Regla
POST /v1/cafes esquemaCrearCafe Tots els camps obligatoris llevat dels opcionals del contracte
PUT /v1/cafes/:id esquemaReemplacarCafe Igual que crear: PUT reemplaça el recurs sencer
PATCH /v1/cafes/:id esquemaModificarCafe Tot opcional, però almenys un camp

I les eines de composició:

const base = z.object({ nom: z.string(), origen: z.string() });

base.partial();                                   // tots els camps opcionals
base.extend({ estoc: z.number() });               // afegeix camps
base.merge(altreEsquema);                         // fusiona dos objectes
base.pick({ nom: true });                         // només alguns
base.omit({ origen: true });                      // tots menys alguns

Un detall d'ordre que costa una tarda si es descobreix per les males: .strict() s'aplica al final. base.strict().partial() funciona, però si encadenes extend després de strict, convé tornar-lo a tancar. Per això als nostres fitxers el .strict() és sempre la darrera crida.

  1. src/esquemes/comuns.js

Comencem per les peces compartides, per no repetir-les a cada recurs:

// src/esquemes/comuns.js
import { z } from 'zod';

/** Identificadors amb prefix, tal com els vam fixar a 02-02. */
export const idCafe = z.string().regex(/^caf_\d{3}$/, "L'id ha de tenir la forma 'caf_001'");
export const idClient = z.string().regex(/^cli_\d+$/, "L'id ha de tenir la forma 'cli_842'");
export const idComanda = z.string().regex(/^com_\d+$/, "L'id ha de tenir la forma 'com_5001'");

/** Boolean de query string: només 'true' o 'false'. */
export const booleaDeConsulta = z
  .enum(['true', 'false'], { message: "Només s'admet 'true' o 'false'" })
  .transform((v) => v === 'true');

/** Import en euros amb dos decimals com a màxim (02-05). */
export const preuEuros = z
  .number()
  .positive('El preu ha de ser més gran que zero')
  .max(1000, 'El preu no pot superar els 1000 €')
  .refine((v) => /^\d+(\.\d{1,2})?$/.test(String(v)), {
    message: 'El preu admet com a màxim dos decimals',
  });

/** Data ISO-8601 en UTC amb Z. */
export const dataIso = z.string().datetime({ message: 'Ha de ser ISO-8601 en UTC, amb Z final' });

/**
 * Paginació per desplaçament, amb els valors del contracte de 02-06:
 * limit per defecte 20 i màxim 100; desplaçament màxim 10.000.
 * No es retalla en silenci: fora de rang és 400.
 */
export const paginacio = {
  limit: z.coerce
    .number()
    .int('El límit ha de ser un enter')
    .min(1, 'El límit mínim és 1')
    .max(100, 'El límit màxim és 100')
    .default(20),
  desplacament: z.coerce
    .number()
    .int('El desplaçament ha de ser un enter')
    .min(0)
    .max(10000, 'El desplaçament màxim és 10.000; fes servir filtres més concrets')
    .default(0),
};

/** Llista de camps separats per comes: 'id,nom,preuEuros'. */
export const llistaDeCamps = z
  .string()
  .regex(/^[a-zA-Z]+(,[a-zA-Z]+)*$/, 'Ha de ser una llista de camps separats per comes');

Els default() són importants: gràcies a ells, el controlador rep limit i desplacament sempre amb un número, i desapareixen els req.query.limit === undefined ? 20 : ... de 03-03.

  1. src/esquemes/cafes.js

// src/esquemes/cafes.js
import { z } from 'zod';
import {
  idCafe,
  preuEuros,
  paginacio,
  booleaDeConsulta,
  llistaDeCamps,
} from './comuns.js';

/** Camps del recurs cafè que el client pot escriure. */
const campsCafe = {
  nom: z.string().trim().min(3, 'El nom necessita almenys 3 caràcters').max(120),
  origen: z.string().trim().min(2).max(80),
  torrefaccio: z.enum(['clar', 'mitja', 'fosc'], {
    message: "La torrefacció ha de ser 'clar', 'mitja' o 'fosc'",
  }),
  preuEuros,
  estoc: z.number().int("L'estoc ha de ser un enter").nonnegative("L'estoc no pot ser negatiu"),
  notesTast: z
    .array(z.string().trim().min(1).max(40))
    .max(10, 'Com a màxim 10 notes de tast')
    .default([]),
  descripcio: z.string().trim().max(2000).nullable().default(null),
};

/** POST /v1/cafes — tots els camps llevat dels que tenen default. */
export const esquemaCrearCafe = z.object(campsCafe).strict();

/** PUT /v1/cafes/:id — reemplaçament complet: mateixes exigències que crear. */
export const esquemaReemplacarCafe = esquemaCrearCafe;

/** PATCH /v1/cafes/:id — tot opcional, però almenys un camp. */
export const esquemaModificarCafe = z
  .object(campsCafe)
  .partial()
  .strict()
  .refine((dades) => Object.keys(dades).length > 0, {
    message: 'El cos del PATCH no pot estar buit',
  });

/** Paràmetres de ruta de /v1/cafes/:id */
export const esquemaIdCafe = z.object({ id: idCafe }).strict();

/** Paràmetres de consulta de GET /v1/cafes, segons el contracte de 02-06. */
export const esquemaConsultaCafes = z
  .object({
    origen: z.string().trim().min(1).optional(),
    torrefaccio: z.enum(['clar', 'mitja', 'fosc']).optional(),
    preuMin: z.coerce.number().nonnegative().optional(),
    preuMax: z.coerce.number().nonnegative().optional(),
    disponible: booleaDeConsulta.optional(),
    q: z.string().trim().min(2, 'La cerca necessita almenys 2 caràcters').max(80).optional(),
    ordenar: z.string().optional(),
    camps: llistaDeCamps.optional(),
    limit: paginacio.limit,
    desplacament: paginacio.desplacament,
  })
  .strict()
  .refine(
    (d) => d.preuMin === undefined || d.preuMax === undefined || d.preuMin <= d.preuMax,
    { message: "'preuMin' no pot ser més gran que 'preuMax'", path: ['preuMin'] }
  );

El .strict() de l'esquema de consulta és el que compleix la promesa de 02-06 que un paràmetre desconegut produeix 400. ?limite=20 (en castellà, l'errada més freqüent) deixa de ser un filtre ignorat en silenci i passa a ser un error explícit que l'integrador veu a la seva primera prova.

  1. src/esquemes/comandes.js

// src/esquemes/comandes.js
import { z } from 'zod';
import { idCafe, idClient, paginacio, dataIso } from './comuns.js';

/** Una línia de comanda tal com l'envia el client. */
const esquemaLinia = z
  .object({
    cafeId: idCafe,
    quantitat: z
      .number()
      .int('La quantitat ha de ser un enter')
      .min(1, 'La quantitat mínima és 1')
      .max(99, 'La quantitat màxima per línia és 99'),
  })
  .strict();

/**
 * POST /v1/comandes
 * El client NO envia preus ni total: els posa el servidor a partir del
 * catàleg. Si els acceptéssim, qualsevol podria comprar a 0,01 €.
 */
export const esquemaCrearComanda = z
  .object({
    clientId: idClient,
    linies: z
      .array(esquemaLinia)
      .min(1, 'La comanda ha de tenir com a mínim una línia')
      .max(50, 'Com a màxim 50 línies per comanda'),
  })
  .strict()
  .superRefine((dades, ctx) => {
    // Un mateix cafè no pot aparèixer en dues línies: seria ambigu en
    // descomptar estoc i en calcular el total.
    const vistos = new Set();
    dades.linies.forEach((linia, index) => {
      if (vistos.has(linia.cafeId)) {
        ctx.addIssue({
          code: 'custom',
          path: ['linies', index, 'cafeId'],
          message: `El cafè '${linia.cafeId}' apareix repetit; agrupa les quantitats en una línia`,
        });
      }
      vistos.add(linia.cafeId);
    });
  });

/** Paràmetres de consulta de GET /v1/comandes (02-06). */
export const esquemaConsultaComandes = z
  .object({
    clientId: idClient.optional(),
    estat: z.enum(['pendent_pagament', 'pagat', 'enviat']).optional(),
    dataDes: dataIso.optional(),
    dataFins: dataIso.optional(),
    ordenar: z.string().optional(),
    cursor: z.string().optional(), // opac: s'interpreta a 03-05
    limit: paginacio.limit,
    desplacament: paginacio.desplacament,
  })
  .strict()
  .refine((d) => !d.dataDes || !d.dataFins || d.dataDes <= d.dataFins, {
    message: "'dataDes' ha de ser anterior o igual a 'dataFins'",
    path: ['dataDes'],
  });

La nota més important d'aquest fitxer és la d'esquemaCrearComanda: el client no envia preus. És un cas perfecte de per què la validació és també disseny. Acceptar preuEuros a la línia d'una comanda seria una vulnerabilitat de negoci de manual; rebutjar-lo amb l'entrada estricta l'elimina d'arrel.

  1. El middleware validar(esquema, origen)

Una sola peça per a les tres entrades:

// src/middleware/validacio.js

/**
 * Middleware genèric de validació.
 *
 * @param {import('zod').ZodTypeAny} esquema  Esquema Zod a aplicar.
 * @param {'body'|'query'|'params'} origen    Part de la petició a validar.
 *
 * Si la validació passa, SUBSTITUEIX req[origen] pel valor ja analitzat,
 * amb els tipus convertits i els valors per defecte aplicats. A partir
 * d'aquí, el controlador treballa amb dades netes i no torna a comprovar.
 */
export function validar(esquema, origen = 'body') {
  return (req, res, next) => {
    const resultat = esquema.safeParse(req[origen]);

    if (!resultat.success) {
      // El codi depèn de l'origen: el contracte de 02-04 distingeix
      // 'dades_invalides' (cos) de 'parametre_invalid' (query/ruta).
      const codi = origen === 'body' ? 'dades_invalides' : 'parametre_invalid';

      return res.status(400).json({
        error: {
          codi,
          missatge:
            origen === 'body'
              ? 'El cos de la petició conté errors de validació.'
              : 'Els paràmetres de la petició contenen errors.',
          detalls: aDetalls(resultat.error),
        },
      });
    }

    req[origen] = resultat.data;
    next();
  };
}

/** Tradueix les incidències de Zod al format 'detalls' del contracte. */
function aDetalls(error) {
  return error.issues.map((incidencia) => ({
    camp: incidencia.path.join('.') || '(cos)',
    codi: traduirCodi(incidencia),
    missatge: incidencia.message,
  }));
}

/** Codis interns de Zod → codis estables del contracte. */
function traduirCodi(incidencia) {
  switch (incidencia.code) {
    case 'invalid_type':
      return incidencia.received === 'undefined' ? 'requerit' : 'tipus_invalid';
    case 'too_small':
      return 'massa_petit';
    case 'too_big':
      return 'massa_gran';
    case 'unrecognized_keys':
      return 'camp_desconegut';
    case 'invalid_string':
    case 'invalid_format':
      return 'format_invalid';
    case 'invalid_enum_value':
    case 'invalid_value':
      return 'valor_no_permes';
    default:
      return 'valor_invalid';
  }
}

Tres decisions de disseny que mereixen justificació:

Per què se substitueix req[origen]. Després del middleware, req.query.limit és el número 20, no la cadena '20', i req.body.notesTast és [] encara que el client no l'enviés. El controlador es queda sense conversions ni valors per defecte: tot això va passar a la vora. És la materialització de "valida a la vora, confia a dins".

Per què es tradueixen els codis de Zod. too_small o unrecognized_keys són detalls d'implementació d'una llibreria. Si els exposéssim, actualitzar Zod podria canviar el contracte de la nostra API, i això és inacceptable (02-07). La traducció ens aïlla: canviar de llibreria no canviaria ni un codi visible.

Per què no es filtren els missatges. Els missatges de Zod són llegibles i en molts casos els hem escrit nosaltres en català dins de l'esquema. Els que no —els missatges per defecte en anglès— es poden traduir; l'exercici 3 ho aborda.

Nota sobre Express 5. A Express 4, req.query és una propietat normal i es pot reassignar. A Express 5 va passar a ser un getter de només lectura, així que l'assignació falla en silenci. La solució portable és desar el resultat en un camp propi (req.validat = { ...req.validat, [origen]: resultat.data }) i llegir d'allà al controlador. Com que aquest curs fa servir Express 4, mantenim la forma directa, que és més llegible.

  1. Del ZodError al format d'error del contracte

Amb tot això, una petició amb quatre fallades produeix aquesta resposta:

{
  "error": {
    "codi": "dades_invalides",
    "missatge": "El cos de la petició conté errors de validació.",
    "detalls": [
      { "camp": "nom", "codi": "massa_petit", "missatge": "El nom necessita almenys 3 caràcters" },
      { "camp": "torrefaccio", "codi": "valor_no_permes", "missatge": "La torrefacció ha de ser 'clar', 'mitja' o 'fosc'" },
      { "camp": "preuEuros", "codi": "tipus_invalid", "missatge": "Expected number, received string" },
      { "camp": "estoc", "codi": "massa_petit", "missatge": "L'estoc no pot ser negatiu" }
    ]
  }
}

Quatre fallades, una sola resposta. Aquesta és la promesa de 02-04, i és el que separa una API amable d'una d'insofrible: amb validació "a la primera fallada", l'integrador necessitaria quatre intents i quatre desplegaments del seu client per descobrir el mateix.

Per a camps imbricats, path.join('.') produeix camins llegibles:

path de Zod camp a detalls
['nom'] nom
['linies', 0, 'quantitat'] linies.0.quantitat
[] (error de l'objecte sencer) (cos)

  1. Les rutes amb validació

Ara les rutes declaren també el contracte d'entrada. src/rutes/cafes.js queda així:

// src/rutes/cafes.js
import { Router } from 'express';
import { controladorCafes } from '../controladors/cafes.js';
import { validar } from '../middleware/validacio.js';
import {
  esquemaCrearCafe,
  esquemaReemplacarCafe,
  esquemaModificarCafe,
  esquemaConsultaCafes,
  esquemaIdCafe,
} from '../esquemes/cafes.js';

export const rutesCafes = Router();

rutesCafes.get('/', validar(esquemaConsultaCafes, 'query'), controladorCafes.llistar);
rutesCafes.post('/', validar(esquemaCrearCafe), controladorCafes.crear);

rutesCafes.get('/:id', validar(esquemaIdCafe, 'params'), controladorCafes.obtenir);
rutesCafes.put(
  '/:id',
  validar(esquemaIdCafe, 'params'),
  validar(esquemaReemplacarCafe),
  controladorCafes.reemplacar
);
rutesCafes.patch(
  '/:id',
  validar(esquemaIdCafe, 'params'),
  validar(esquemaModificarCafe),
  controladorCafes.modificar
);
rutesCafes.delete('/:id', validar(esquemaIdCafe, 'params'), controladorCafes.esborrar);

Es llegeix com una especificació: per a cada mètode i URI, què es valida i qui ho atén. I el controlador s'aprima de manera notable, perquè desapareixen totes les comprovacions manuals:

// src/controladors/cafes.js  (versió simplificada després de la validació)

llistar(req, res) {
  // req.query ja ve validat i amb els tipus correctes: limit i
  // desplacament són números, disponible és boolean, i no hi ha
  // paràmetres desconeguts. No queda res per comprovar aquí.
  const { origen, torrefaccio, preuMin, preuMax, disponible, q, ordenar, camps } = req.query;
  const { limit, desplacament } = req.query;

  const criterisOrdre = interpretarOrdenar(ordenar);
  if (criterisOrdre.error) {
    return res.status(400).json({
      error: {
        codi: 'parametre_invalid',
        missatge: criterisOrdre.error,
        detalls: [{ camp: 'ordenar', codi: 'valor_no_permes', missatge: criterisOrdre.error }],
      },
    });
  }

  const { elements, total } = serveiCafes.llistar({
    origen,
    torrefaccio,
    preuMinCentims: preuMin === undefined ? undefined : eurosACentims(preuMin),
    preuMaxCentims: preuMax === undefined ? undefined : eurosACentims(preuMax),
    disponible,
    q,
    ordenar: criterisOrdre,
    limit,
    desplacament,
  });

  const enllacos = construirCapcaleraLink({ req, limit, desplacament, total });
  if (Object.keys(enllacos).length > 0) res.links(enllacos);

  res.status(200).json({
    dades: elements.map(cafeARepresentacio).map(aResumDeColeccio).map((r) => projectar(r, camps)),
    total,
  });
},

crear(req, res) {
  // req.body ja està validat: aquí no hi ha ni un sol 'if'.
  const cafe = serveiCafes.crear(req.body);
  res.set('Location', `/v1/cafes/${cafe.id}`);
  res.status(201).json(cafeARepresentacio(cafe));
},

crear ha passat de vint línies a tres. Això és el benefici mesurable de moure la validació a la vora.

  1. Peticions invàlides i les seves respostes exactes

# 1. Diverses fallades alhora al cos
curl -s -X POST http://localhost:3000/v1/cafes \
  -H "Content-Type: application/json" \
  -d '{"nom":"K","origen":"Kenya","torrefaccio":"torrat","preuEuros":"16.75","estoc":-3}' | jq
{
  "error": {
    "codi": "dades_invalides",
    "missatge": "El cos de la petició conté errors de validació.",
    "detalls": [
      { "camp": "nom", "codi": "massa_petit", "missatge": "El nom necessita almenys 3 caràcters" },
      { "camp": "torrefaccio", "codi": "valor_no_permes", "missatge": "La torrefacció ha de ser 'clar', 'mitja' o 'fosc'" },
      { "camp": "preuEuros", "codi": "tipus_invalid", "missatge": "Expected number, received string" },
      { "camp": "estoc", "codi": "massa_petit", "missatge": "L'estoc no pot ser negatiu" }
    ]
  }
}
# 2. Camp desconegut: entrada estricta
curl -s -X POST http://localhost:3000/v1/cafes \
  -H "Content-Type: application/json" \
  -d '{"nom":"Kenya Nyeri","origen":"Kenya","torrefaccio":"mitja","preuEuros":16.75,"estoc":40,"color":"vermell"}' \
  | jq '.error.detalls'
[{ "camp": "(cos)", "codi": "camp_desconegut", "missatge": "Unrecognized key(s) in object: 'color'" }]
# 3. Paràmetre de consulta desconegut: 'parametre_invalid', no 'dades_invalides'
curl -s "http://localhost:3000/v1/cafes?limite=20" | jq '.error.codi'
"parametre_invalid"
# 4. Límit fora de rang: 400, NO es retalla a 100
curl -s "http://localhost:3000/v1/cafes?limit=5000" | jq '.error.detalls[0]'
{ "camp": "limit", "codi": "massa_gran", "missatge": "El límit màxim és 100" }
# 5. Id amb format incorrecte: es detecta a params, sense tocar el magatzem
curl -s "http://localhost:3000/v1/cafes/1" | jq '.error'
{
  "codi": "parametre_invalid",
  "missatge": "Els paràmetres de la petició contenen errors.",
  "detalls": [{ "camp": "id", "codi": "format_invalid", "missatge": "L'id ha de tenir la forma 'caf_001'" }]
}

Aquest darrer cas té més suc del que sembla. Un id mal format ara produeix 400 parametre_invalid i no arriba mai al repositori. L'alternativa —deixar-lo passar i retornar 404 cafe_no_trobat— també seria defensable, però triem el 400 perquè distingeix "t'has equivocat escrivint l'identificador" de "aquell cafè no existeix", i a més estalvia una consulta a la base de dades per cada petició escombraria. Amb SQL al darrere (03-05), aquella validació prèvia és a més una capa més davant de la injecció.

# 6. PATCH buit
curl -s -X PATCH http://localhost:3000/v1/cafes/caf_001 \
  -H "Content-Type: application/merge-patch+json" -d '{}' | jq '.error.detalls[0].missatge'
"El cos del PATCH no pot estar buit"

  1. Validació de negoci: per què viu al servei

Hi ha regles que un esquema no pot comprovar, i confondre-les amb la validació de format és l'error conceptual més comú d'aquesta lliçó:

Regla Esquema o servei? Per què
quantitat és un enter ≥ 1 Esquema Es veu mirant la petició
cafeId té la forma caf_\d{3} Esquema Format pur
Aquell cafè existeix Servei Requereix consultar el magatzem
Hi ha estoc suficient Servei Depèn de l'estat i canvia entre dues peticions
La comanda no està ja pagada Servei Depèn de la màquina d'estats
El client pot veure aquesta comanda Servei Depèn de la identitat (03-06)

Les tres raons de fons:

  1. L'esquema no té accés a les dades. Ficar una consulta dins d'un refine convertiria l'esquema en una cosa asíncrona, dependent de la base de dades i impossible de reutilitzar per generar documentació.
  2. L'estat canvia. Entre validar "hi ha estoc" i descomptar-lo s'hi pot colar una altra comanda. La comprovació ha de passar dins de la mateixa transacció que el descompte (03-05); fer-la a la vora dona una falsa sensació de seguretat.
  3. El codi d'error és diferent. El format invàlid és 400 dades_invalides; l'estoc insuficient és 409 estoc_insuficient, un conflicte d'estat, no un error d'escriptura. Són famílies diferents al catàleg de 02-04.

Així queda aquella validació al servei de comandes, escrita avui amb la forma provisional que 03-07 convertirà en throw new ErrorApi(...):

// src/serveis/comandes.js  (afegit)
import { repositoriCafes } from '../repositoris/cafes-memoria.js';

export const serveiComandes = {
  // ...llistar i obtenir...

  /**
   * Comprova les regles de negoci d'una comanda nova.
   * Retorna null si tot és correcte, o un objecte d'error de domini.
   * A 03-07 això passarà a ser un throw d'ErrorApi.
   */
  comprovarLinies(linies) {
    const problemes = [];

    for (const linia of linies) {
      const cafe = repositoriCafes.buscarPerId(linia.cafeId);

      if (!cafe) {
        problemes.push({
          codi: 'cafe_no_trobat',
          camp: `linies.${linia.cafeId}`,
          missatge: `El cafè '${linia.cafeId}' no existeix o està descatalogat.`,
        });
        continue;
      }

      if (cafe.estoc < linia.quantitat) {
        problemes.push({
          codi: 'estoc_insuficient',
          camp: `linies.${linia.cafeId}`,
          missatge: `Només queden ${cafe.estoc} unitats de '${cafe.nom}' i se'n demanen ${linia.quantitat}.`,
        });
      }
    }

    return problemes.length > 0 ? problemes : null;
  },
};

Fixa't que també aquí s'acumulen tots els problemes abans de respondre. La coherència amb la validació d'esquema és deliberada: si una comanda té tres línies sense estoc, el client mereix assabentar-se de les tres alhora.

  1. Sanejament, normalització i límit de mida

Tres conceptes que es confonen i que fan coses diferents:

Concepte Què fa Exemple
Validació Accepta o rebutja "torrat" no és una torrefacció vàlida → 400
Normalització Unifica formes equivalents " colòmbia ""Colòmbia"
Sanejament Neutralitza contingut perillós Escapar HTML abans de mostrar-lo

La Botiga Aroma valida i normalitza a l'esquema. El sanejament és qüestió de context de sortida i no es fa aquí: intentar "netejar" HTML en entrar produeix dades mutilades —un client que es diu de cognom O'Brien no hauria de perdre l'apòstrof— i una falsa seguretat. El correcte és desar el text tal qual i escapar-lo en el moment de fer-lo servir: paràmetres preparats per a SQL (03-05), escapament d'HTML al client que el pinta. A 04-02 se'n desenvolupa el perquè amb detall.

Sobre el límit de mida, ja el vam posar a 03-02:

app.use(express.json({ limit: '100kb', type: [...] }));

És una defensa que la validació per esquema no et pot donar, perquè actua abans: sense límit, un cos de 500 MB s'analitza sencer en memòria i el procés mor abans que Zod vegi res. Amb limit, Express respon 413. Aquell 413 produeix avui una resposta lletja d'Express; a 03-07 el convertirem en cos_massa_gran del catàleg.

Un darrer punt: la validació protegeix contra la contaminació de prototips. Un cos amb {"__proto__": {"esAdmin": true}} podria, combinat amb un Object.assign descuidat, modificar el prototip de tots els objectes del procés. Amb .strict(), aquella clau és simplement un camp desconegut i la petició mor a la vora amb un 400.

  1. Alternatives a Zod i la connexió amb OpenAPI

Llibreria Enfocament Nota
Zod Esquemes com a codi, amb inferència de tipus L'elecció del curs: sense dependències i molt llegible
Joi Esquemes com a codi, veterana Molt madura; sense inferència de tipus de TypeScript
Yup Semblant a Joi, popular en formularis Còmoda al client, una mica menys al servidor
express-validator Middleware encadenat sobre req Molt integrada a Express; l'esquema no és un objecte reutilitzable
AJV + JSON Schema Estàndard JSON Schema, molt ràpida L'única que valida directament l'esquema d'OpenAPI

Aquella darrera fila apunta a un tema important. A 02-08 vam escriure openapi.yaml com a font de veritat del contracte, i en aquest mòdul hem escrit esquemes Zod que diuen pràcticament el mateix. Tenim dues definicions de la mateixa veritat, i dues definicions acaben divergint: algú afegeix un camp a l'esquema Zod i s'oblida del YAML.

Les tres maneres de resoldre-ho:

  1. Generar OpenAPI des de Zod, amb eines com zod-to-json-schema o @asteasolutions/zod-to-openapi. El codi mana.
  2. Generar els validadors des d'OpenAPI, amb AJV i el JSON Schema del contracte. L'especificació mana; és el més coherent amb API-first.
  3. Mantenir les dues i verificar a CI que la implementació compleix l'especificació, amb proves de contracte.

La tercera és la més pràctica i la que veurem a 05-04, on comprovarem automàticament que cada resposta valida contra l'esquema publicat. De moment n'hi ha prou amb ser conscient del risc: cada vegada que toquis un esquema de src/esquemes/, toca també openapi.yaml. És exactament la deriva de la qual advertíem a 02-08.

Errors Comuns i Consells

1. Validar només el cos. Els paràmetres de consulta i els de ruta són igual de manipulables, i ?limit=999999 és un problema de disponibilitat.

2. Fer servir z.coerce.boolean() per a un paràmetre de la URL. Converteix qualsevol cadena no buida en true, inclosa 'false'. Fes servir un z.enum(['true','false']).transform(...).

3. Ficar consultes a la base de dades dins d'un refine. L'esquema no ha de conèixer l'estat del sistema. Aquella comprovació pertany al servei i sovint a la mateixa transacció que l'escriptura.

4. Retornar els codis interns de Zod al client. too_small és un detall d'una dependència; si el publiques, actualitzar la llibreria t'obliga a canviar el contracte.

5. Oblidar .strict(). Sense ell, l'objecte es valida però els camps desconeguts es descarten en silenci i el contracte d'entrada estricta deixa de complir-se.

6. Reutilitzar l'esquema de POST per al PATCH. PATCH exigeix tot opcional; fer servir el de POST obliga el client a reenviar el recurs sencer, que és el que fa PUT.

7. Confiar que el client ja valida. Mai. La SPA valida per usabilitat; el servidor valida per correcció.

8. Validar després de tocar la base de dades. L'ordre a la cadena de middleware importa: validar va abans del controlador, sempre.

Consell: quan dubtis de si una comprovació és d'esquema o de negoci, pregunta't si podries respondre mirant només el text de la petició. Si necessites consultar alguna cosa, és negoci i va al servei.

Exercicis

Exercici 1

Escriu l'esquema esquemaCrearRessenya per a POST /v1/cafes/:id/ressenyes. Segons el contracte: puntuacio és un enter d'1 a 5 obligatori, comentari és text opcional d'entre 10 i 2000 caràcters, i no s'admet cap altre camp (en particular estat, que el fixa el servidor a pendent_moderacio). Afegeix-hi la regla que si hi ha comentari, no pot ser només espais en blanc. Mostra la ruta amb la seva validació.

Exercici 2

Un integrador es queixa que POST /v1/comandes amb aquest cos retorna 400 i no entén per què:

{
  "clientId": "cli_842",
  "linies": [
    { "cafeId": "caf_001", "quantitat": 2, "preuEuros": 14.50 },
    { "cafeId": "caf_001", "quantitat": 1 }
  ],
  "totalEuros": 43.50
}

Enumera totes les fallades que detectarà esquemaCrearComanda, escriu la resposta completa que retorna l'API i explica a l'integrador per què el rebuig de preuEuros i totalEuros no és una molèstia sinó una protecció.

Exercici 3

Els missatges per defecte de Zod surten en anglès ("Expected number, received string"), cosa que trenca la coherència d'una API els missatges de la qual són en català. Proposa una solució que tradueixi aquests missatges sense haver d'escriure un missatge a mà a cada camp de l'esquema, i implementa-la a traduirCodi/aDetalls. Comenta l'avantatge de tenir el codi estable a més del missatge.

Solucions

Solució 1

// src/esquemes/ressenyes.js
import { z } from 'zod';

export const esquemaCrearRessenya = z
  .object({
    puntuacio: z
      .number()
      .int('La puntuació ha de ser un enter')
      .min(1, 'La puntuació mínima és 1')
      .max(5, 'La puntuació màxima és 5'),
    comentari: z
      .string()
      .trim()
      .min(10, 'El comentari necessita almenys 10 caràcters')
      .max(2000, 'El comentari no pot superar els 2000 caràcters')
      .optional(),
  })
  .strict();

El .trim() abans del .min(10) resol tot sol la regla del comentari en blanc: " " es converteix en cadena buida i falla el mínim. És més elegant que un refine, i demostra que l'ordre dels encadenaments a Zod té significat.

// src/rutes/cafes.js
import { esquemaCrearRessenya } from '../esquemes/ressenyes.js';

rutesCafes.post(
  '/:id/ressenyes',
  validar(esquemaIdCafe, 'params'),
  validar(esquemaCrearRessenya),
  controladorRessenyes.crear
);

Sobre estat: no apareix a l'esquema a propòsit. Amb .strict(), un client que enviï "estat": "publicada" rep 400 camp_desconegut i no es pot saltar la moderació. És el mateix patró que el preuEuros de les línies de comanda: els camps que fixa el servidor no s'accepten a l'entrada, es rebutgen.

Solució 2

Fallades detectades, tres en total:

  1. linies.0.preuEuros — camp desconegut a la línia: esquemaLinia és .strict() i només admet cafeId i quantitat.
  2. totalEuros — camp desconegut a l'arrel: esquemaCrearComanda és .strict() i només admet clientId i linies.
  3. linies.1.cafeIdcaf_001 apareix repetit, ho detecta el superRefine.

Resposta de l'API:

{
  "error": {
    "codi": "dades_invalides",
    "missatge": "El cos de la petició conté errors de validació.",
    "detalls": [
      { "camp": "linies.0", "codi": "camp_desconegut", "missatge": "Unrecognized key(s) in object: 'preuEuros'" },
      { "camp": "(cos)", "codi": "camp_desconegut", "missatge": "Unrecognized key(s) in object: 'totalEuros'" },
      { "camp": "linies.1.cafeId", "codi": "valor_invalid", "missatge": "El cafè 'caf_001' apareix repetit; agrupa les quantitats en una línia" }
    ]
  }
}

Explicació per a l'integrador: els preus i el total els calcula el servidor a partir del catàleg en el moment de crear la comanda, i per això l'API no els accepta d'entrada. Si els acceptés, qualsevol podria enviar preuEuros: 0.01 i comprar cafè d'especialitat a un cèntim; el totalEuros enviat pel client, a més, podria no quadrar amb la suma de les línies, i llavors caldria decidir quin dels dos números és el bo. Rebutjar-los elimina de cop un frau i una ambigüitat. La resposta del 201 sí que retorna preuEuros per línia i totalEuros, ja calculats i congelats, que és el que el client necessita mostrar.

Sobre el cafè repetit: el contracte prefereix una única línia per cafè amb la quantitat agrupada, perquè dues línies del mateix producte fan ambigu el descompte d'estoc i compliquen la devolució parcial.

Solució 3

Zod permet un mapa d'errors global que s'aplica a tots els esquemes, sense tocar camp per camp:

// src/esquemes/missatges.js
import { z } from 'zod';

/**
 * Mapa d'errors global: tradueix els missatges per defecte de Zod.
 * S'instal·la un sol cop en arrencar i afecta tots els esquemes.
 * Si un camp defineix el seu missatge, aquell té prioritat.
 */
const mapaCatala = (incidencia, context) => {
  switch (incidencia.code) {
    case 'invalid_type':
      return incidencia.received === 'undefined'
        ? { message: 'Aquest camp és obligatori' }
        : { message: `S'esperava ${incidencia.expected} i s'ha rebut ${incidencia.received}` };
    case 'too_small':
      return { message: `El valor mínim admès és ${incidencia.minimum}` };
    case 'too_big':
      return { message: `El valor màxim admès és ${incidencia.maximum}` };
    case 'unrecognized_keys':
      return { message: `Camps no reconeguts: ${incidencia.keys.join(', ')}` };
    default:
      return { message: context.defaultError };
  }
};

export function installarMissatgesEnCatala() {
  // El nom exacte d'aquesta funció varia entre versions majors de Zod
  // (setErrorMap / config); la idea és la mateixa: un mapa global.
  z.setErrorMap(mapaCatala);
}

S'instal·la una vegada, a src/app.js, abans de muntar les rutes:

import { installarMissatgesEnCatala } from './esquemes/missatges.js';
installarMissatgesEnCatala();

Ara la fallada de l'exemple anterior retorna "S'esperava number i s'ha rebut string" en lloc del missatge en anglès, sense haver tocat ni un camp de l'esquema.

Avantatge de tenir codi a més de missatge: el missatge és per a humans —pot canviar, traduir-se o reescriure's perquè s'entengui millor, i això no trenca res a ningú—. El codi és per a màquines: un client pot escriure if (detall.codi === 'requerit') i confiar que això no canviarà, perquè forma part del contracte i està subjecte a les regles de versionat de 02-07. Separar-los permet millorar la redacció dels missatges qualsevol dimarts sense publicar una versió nova de l'API. És el mateix principi pel qual els enumerats van en snake_case i no traduïts (02-05).

Conclusió

L'API ja no accepta escombraries. Els esquemes de src/esquemes/ són ara la definició executable de què entra: tipus, rangs, formats d'identificador, enumerats, dates ISO-8601, preus amb dos decimals i llistes amb longitud màxima; amb .strict() per complir l'entrada estricta de 02-05, z.coerce per convertir els paràmetres de consulta que sempre arriben com a text, default() perquè el controlador rebi els valors del contracte ja aplicats, i partial() perquè el PATCH exigeixi el just. Un únic middleware, validar(esquema, origen), aplica tot això a body, query i params, substitueix l'entrada pel valor ja analitzat i tradueix el ZodError al format del contracte: 400 dades_invalides per al cos, 400 parametre_invalid per als paràmetres, i totes les fallades alhora a detalls, cadascuna amb el seu camp, el seu codi estable i el seu missatge.

Igual d'important és el que no hem ficat als esquemes. L'existència d'un cafè, l'estoc disponible, una comanda ja pagada o el permís per veure un recurs depenen de l'estat del sistema, canvien entre dues peticions i tenen codis d'error d'una altra família (409, no 400). Aquella validació viu al servei, i algunes de les seves comprovacions hauran de passar dins de la mateixa transacció que l'escriptura.

Justament cap allà anem ara. Tot el que hem construït fins avui es recolza en dos arrays en memòria que es buiden a cada reinici de node --watch, que no admeten consultes serioses i que no poden garantir que descomptar estoc de tres línies sigui una operació atòmica. A 03-05, Persistència i capa d'accés a dades, substituirem aquell magatzem per SQLite amb better-sqlite3 darrere del patró repositori: esquema SQL amb preu_centims enter, migracions versionades i dades de sembra, sentències preparades que tanquen la porta a la injecció, consultes dinàmiques segures per als filtres de 02-06, transaccions per crear una comanda descomptant estoc, control de concurrència optimista amb conflicte_versio i paginació per cursor de debò. I ho farem sense tocar ni una sola línia dels controladors ni dels serveis, que és la promesa que vam fer a 03-01 en separar les capes.

Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful

Mòdul 1: Introducció a les APIs RESTful

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats