La API de la lección anterior tiene un agujero del tamaño de un camión: si alguien envía {"nombre": "", "precioEuros": "carísimo", "stock": -5, "color": "rojo"}, el café se crea. eurosACentimos("carísimo") produce NaN, el stock queda negativo, el campo color se guarda sin que nadie lo haya previsto y a partir de ahí GET /v1/cafes devuelve "precioEuros": null para siempre. Las comprobaciones a mano que fuimos dejando por el camino son incompletas, están repetidas y cada una inventa su propio mensaje. Hoy las sustituimos por esquemas declarativos con Zod y un único middleware validar(esquema, origen) que rechaza la entrada estricta, convierte los tipos de los parámetros de consulta y devuelve todos los fallos a la vez en detalles, con el formato exacto que fijamos en 02-04. Es la lección que convierte la API en algo que se puede exponer a un consumidor que no sea tú mismo.
Contenido
- Por qué nunca se confía en la entrada
- Qué se valida: cuerpo, ruta, consulta y cabeceras
- Validación manual frente a validación por esquema
- Primeros pasos con Zod:
parseysafeParse - Los tipos que necesita Tienda Aroma
z.coercey el problema de los parámetros de consulta- Entrada estricta con
.strict() - Reglas compuestas con
refineysuperRefine - Normalización con
transform - Un esquema por operación:
partial,extendymerge src/esquemas/comunes.jssrc/esquemas/cafes.jssrc/esquemas/pedidos.js- El middleware
validar(esquema, origen) - Del
ZodErroral formato de error del contrato - Las rutas con validación
- Peticiones inválidas y sus respuestas exactas
- Validación de negocio: por qué vive en el servicio
- Sanitización, normalización y límite de tamaño
- Alternativas a Zod y la conexión con OpenAPI
- Por qué nunca se confía en la entrada
La regla es tan vieja como el desarrollo web y no admite matices: todo lo que llega por la red es hostil hasta que se demuestre lo contrario. No porque todos los consumidores sean maliciosos —la SPA de Tienda Aroma no lo es—, sino porque:
| Motivo | Ejemplo en Tienda Aroma |
|---|---|
| Errores honestos | Aroma Móvil envía precioEuros como cadena por un bug de su formulario |
| Clientes desactualizados | Una versión antigua de la app manda tueste: "tostado", que ya no existe |
| Integraciones de terceros | El socio RápidoEnvíos envía fechas en formato español (14/03/2026) |
| Ataques | Alguien prueba stock: -999999 o un cuerpo de 500 MB para ver qué pasa |
| El cliente no es de fiar por definición | Aunque la SPA valide, cualquiera puede llamar a la API con curl |
Ese último punto es el decisivo. La validación del cliente es usabilidad; la validación del servidor es corrección. La primera existe para no hacer esperar al usuario a que el servidor le diga que el campo está vacío; la segunda es la única que protege de verdad los datos, porque la API es pública y nadie está obligado a usar tu SPA para llamarla.
Las consecuencias de no validar se dividen en dos familias:
- Integridad. Datos corruptos que se propagan: un
NaNguardado hoy rompe una factura dentro de tres meses, y para entonces el origen del problema es imposible de rastrear. Además contaminan los cálculos agregados: un precio nulo hace que el informe de ventas mienta. - Seguridad. Inyección SQL, inyección NoSQL, contaminación de prototipos, denegación de servicio con cuerpos gigantes o expresiones regulares patológicas. Aquí solo veremos cómo la validación cierra la puerta de entrada; el catálogo completo de amenazas y sus defensas es la lección 04-02.
Un principio que ordena todo lo demás: valida en el borde, confía dentro. Una vez que la petición ha pasado el middleware de validación, el resto del código —controlador, servicio, repositorio— puede dar por hecho que datos.precioEuros es un número positivo con dos decimales. Si cada capa vuelve a comprobarlo, el código se llena de defensa inútil y nadie sabe quién es responsable de qué.
- Qué se valida: cuerpo, ruta, consulta y cabeceras
Un error frecuente es validar solo el cuerpo. Hay cuatro entradas y las cuatro son igual de manipulables:
| Origen | Qué contiene | Ejemplo de ataque o error |
|---|---|---|
Cuerpo (req.body) |
Los datos del recurso | precioEuros: -10, campo desconocido esAdmin: true |
Ruta (req.params) |
Identificadores | /v1/cafes/../../etc/passwd, /v1/cafes/'; DROP TABLE |
Consulta (req.query) |
Filtros, orden, paginación | limite=999999, ordenar=(select…) |
Cabeceras (req.headers) |
Metadatos del protocolo | Content-Type inesperado, Idempotency-Key ausente |
En Tienda Aroma validaremos las tres primeras con esquemas. Las cabeceras se comprueban de forma puntual, porque su semántica es del protocolo y no del dominio: el Content-Type de PATCH ya se comprueba en el controlador (03-03) y la Idempotency-Key obligatoria en POST /v1/pedidos se comprobará en un middleware propio.
- Validación manual frente a validación por esquema
Así es como quedó la comprobación manual de POST /v1/cafes en 03-03, y solo cubría los campos obligatorios:
const obligatorios = ['nombre', 'origen', 'tueste', 'precioEuros', 'stock'];
const faltantes = obligatorios.filter((campo) => datos?.[campo] === undefined);
if (faltantes.length > 0) { /* ... 400 ... */ }Para cubrir el contrato completo habría que añadir: que nombre sea cadena no vacía de longitud razonable, que tueste sea uno de tres valores, que precioEuros sea número positivo con dos decimales, que stock sea entero no negativo, que notasCata sea un array de cadenas, que no haya campos desconocidos… y repetirlo en PUT, y en PATCH con todo opcional, y otra vez para pedidos. Serían doscientas líneas de if que hay que mantener sincronizadas con la documentación.
| Criterio | Manual (if) |
Esquema declarativo |
|---|---|---|
| Legibilidad | Se pierde entre condicionales | El esquema es la especificación |
| Todos los errores a la vez | Hay que acumularlos a mano | De serie |
| Conversión de tipos | Manual y propensa a fallos | Integrada |
| Reutilización POST/PUT/PATCH | Copiar y pegar | extend, merge, partial |
| Documentación | Se desincroniza | Genera JSON Schema y OpenAPI |
| Coste de una regla nueva | Un if en cada sitio |
Una línea en un sitio |
Un esquema declarativo dice qué debe cumplirse, no cómo comprobarlo. Y como es un objeto, se puede transformar: de un esquema Zod salen un validador, los tipos de TypeScript y un JSON Schema para OpenAPI. De un if no sale nada.
- Primeros pasos con Zod:
parse y safeParse
parse y safeParseZod ya está instalado desde 03-01. Los ejemplos usan la API estable, común a las versiones 3 y 4.
import { z } from 'zod';
// Un esquema es un objeto que describe una forma de dato.
const esquemaNombre = z.string().min(3).max(120);
// parse() devuelve el valor validado o LANZA un ZodError.
esquemaNombre.parse('Etiopía Yirgacheffe'); // → 'Etiopía Yirgacheffe'
esquemaNombre.parse('ab'); // → lanza ZodError
// safeParse() nunca lanza: devuelve un resultado discriminado.
const resultado = esquemaNombre.safeParse('ab');
console.log(resultado.success); // false
console.log(resultado.error.issues);
// [{ code: 'too_small', minimum: 3, path: [], message: 'String must contain at least 3 character(s)' }]| Método | Devuelve | Cuándo usarlo |
|---|---|---|
parse(v) |
El valor validado, o lanza | Dentro de un try, o cuando el fallo es un bug |
safeParse(v) |
{ success: true, data } o { success: false, error } |
En el middleware: el fallo es esperable |
En Tienda Aroma usaremos safeParse, porque un cuerpo inválido no es una excepción: es el caso normal de un cliente que se equivoca, y su respuesta es un 400 bien formado, no un error de programa.
Lo esencial del objeto error: la propiedad issues es un array con todos los problemas encontrados, no solo el primero. Cada issue tiene:
| Propiedad | Qué es | Ejemplo |
|---|---|---|
path |
Ruta al campo, como array | ['lineas', 0, 'cantidad'] |
code |
Tipo de problema | invalid_type, too_small, unrecognized_keys |
message |
Mensaje legible | 'Expected number, received string' |
Ese issues completo es justo lo que necesita el contrato: la validación devuelve todos los fallos a la vez (02-04), para que el consumidor corrija su petición de una vez y no en cinco intentos.
- Los tipos que necesita Tienda Aroma
Un recorrido por los constructores que usaremos, cada uno con su caso real:
import { z } from 'zod';
// --- Cadenas ---
z.string(); // debe ser cadena
z.string().min(1, 'No puede estar vacío');
z.string().max(120);
z.string().trim(); // recorta espacios ANTES de validar
z.string().email(); // correo del cliente
z.string().regex(/^caf_\d{3}$/, 'Formato de id de café inválido');
// --- Números ---
z.number(); // debe ser número (no cadena)
z.number().int('Debe ser un entero');
z.number().positive(); // > 0
z.number().nonnegative(); // >= 0, lo correcto para 'stock'
z.number().max(10000);
// --- Booleanos y enumerados ---
z.boolean();
z.enum(['claro', 'medio', 'oscuro']); // tueste
z.enum(['pendiente_pago', 'pagado', 'enviado']); // estado de pedido
// --- Fechas ISO-8601 en UTC, como fijamos en 02-05 ---
z.string().datetime({ message: 'Debe ser una fecha ISO-8601 en UTC con Z' });
// --- Arrays ---
z.array(z.string()).max(10); // notasCata: como mucho 10 notas
z.array(esquemaLinea).min(1, 'El pedido debe tener al menos una línea');
// --- Objetos ---
z.object({ nombre: z.string(), stock: z.number() });
// --- Modificadores ---
z.string().optional(); // puede faltar (undefined)
z.string().nullable(); // puede ser null
z.string().default(''); // si falta, se rellenaMerece una parada el caso de precioEuros. El contrato de 02-05 dice "euros con dos decimales por fuera, céntimos por dentro". Zod no tiene un tipo "decimal con dos cifras", así que se compone:
const precioEuros = z
.number()
.positive('El precio debe ser mayor que cero')
.max(1000, 'El precio no puede superar los 1000 €')
.refine((valor) => Number.isInteger(Math.round(valor * 100)) && (valor * 100) % 1 < 1e-9, {
message: 'El precio admite como máximo dos decimales',
});Una forma más legible y robusta de expresar lo mismo, evitando el ruido de la coma flotante, es comprobar la representación textual:
const precioEuros = z
.number()
.positive('El precio debe ser mayor que cero')
.max(1000, 'El precio no puede superar los 1000 €')
.refine((valor) => /^\d+(\.\d{1,2})?$/.test(String(valor)), {
message: 'El precio admite como máximo dos decimales',
});Así 14.5 y 14.50 pasan (son el mismo número), y 14.567 se rechaza con 400. Aceptar tres decimales sería aceptar fracciones de céntimo que se perderían al convertir, y con ellas la cuadratura contable.
z.coerce y el problema de los parámetros de consulta
z.coerce y el problema de los parámetros de consultaComo vimos en 03-03, todo lo que llega en la URL es texto. ?limite=20&disponible=true produce { limite: '20', disponible: 'true' }. Un z.number() sobre '20' falla, y con razón.
z.coerce convierte antes de validar:
z.coerce.number().int().min(1).max(100).parse('20'); // → 20 (número)
z.coerce.number().parse('abc'); // → falla: NaN no es número
z.coerce.boolean().parse('true'); // → trueCuidado con z.coerce.boolean(): aplica la conversión de JavaScript, donde cualquier cadena no vacía es verdadera. Así, ?disponible=false se convertiría en true, que es exactamente el bug que corregimos en el ejercicio 1 de 03-03. Para booleanos en la URL hay que ser explícito:
// Correcto: solo 'true' y 'false', cualquier otra cosa es 400.
const booleanoDeConsulta = z
.enum(['true', 'false'], { message: "Solo se admite 'true' o 'false'" })
.transform((v) => v === 'true');Esta es la clase de detalle que separa una API que parece funcionar de una que funciona.
- Entrada estricta con
.strict()
.strict()Por defecto, z.object() descarta en silencio las claves que no están en el esquema. El contrato de 02-05 decidió lo contrario: entrada estricta, campo desconocido → 400.
const laxo = z.object({ nombre: z.string() });
laxo.parse({ nombre: 'Kenia', color: 'rojo' }); // → { nombre: 'Kenia' }, el color se pierde
const estricto = z.object({ nombre: z.string() }).strict();
estricto.parse({ nombre: 'Kenia', color: 'rojo' }); // → falla: unrecognized_keysLas tres razones de la decisión, que conviene tener a mano porque el debate reaparece siempre:
- Detecta erratas del cliente. Quien envía
precioEuro(sins) con la versión laxa recibe un201alegre y un café con precio por defecto. Con la estricta, recibe un400que le dice exactamente qué campo no existe. - Impide la asignación masiva. Si mañana el modelo interno tuviera un campo
destacadoorolCliente, un cuerpo que lo incluyera podría acabar guardándolo si el código hace{...datos}. La entrada estricta cierra esa puerta desde el borde. - Hace explícita la evolución. Añadir un campo a la API pasa a ser una decisión consciente que se refleja en el esquema y en
openapi.yaml.
La contrapartida honesta: la entrada estricta reduce la tolerancia. Un cliente que reenvía tal cual una representación que le devolvimos —incluidos _links o fechaCreacion— recibirá un 400. Es un caso real y frecuente en PUT. Se resuelve documentándolo con claridad y, si hace falta, ignorando explícitamente los campos de solo lectura en el esquema en lugar de rechazarlos.
- Reglas compuestas con
refine y superRefine
refine y superRefineHay reglas que no son de un campo sino de la relación entre varios. refine añade una comprobación arbitraria:
// Rango de precios coherente en los filtros
const esquemaRango = z
.object({
precioMin: z.coerce.number().nonnegative().optional(),
precioMax: z.coerce.number().nonnegative().optional(),
})
.refine(
(datos) =>
datos.precioMin === undefined ||
datos.precioMax === undefined ||
datos.precioMin <= datos.precioMax,
{ message: "'precioMin' no puede ser mayor que 'precioMax'", path: ['precioMin'] }
);El path importa: sin él, el fallo no se asocia a ningún campo y el detalles del error queda sin campo.
superRefine permite emitir varios problemas y elegir su código:
const esquemaFechas = z
.object({
fechaDesde: z.string().datetime().optional(),
fechaHasta: z.string().datetime().optional(),
})
.superRefine((datos, ctx) => {
if (datos.fechaDesde && datos.fechaHasta && datos.fechaDesde > datos.fechaHasta) {
ctx.addIssue({
code: 'custom',
path: ['fechaDesde'],
message: "'fechaDesde' debe ser anterior a 'fechaHasta'",
});
}
});Dónde está el límite. En el esquema van las reglas que se pueden comprobar mirando solo la petición: formatos, rangos, coherencia entre campos. Todo lo que necesite consultar el estado del sistema —¿existe ese café?, ¿hay stock?, ¿este pedido ya está pagado?— no va aquí. Volveremos sobre esto en la sección 18, porque es la confusión más común de esta lección.
- Normalización con
transform
transformtransform cambia el valor después de validarlo. Nos sirve para dos cosas:
// 1. Limpiar entrada: recortar espacios, normalizar mayúsculas
const nombre = z.string().trim().min(1).max(120);
const origen = z
.string()
.trim()
.min(1)
.transform((v) => v.charAt(0).toUpperCase() + v.slice(1).toLowerCase()); // 'COLOMBIA' → 'Colombia'
// 2. Convertir a la unidad interna: euros → céntimos
const precio = precioEuros.transform((euros) => Math.round(euros * 100));La segunda tentación es fuerte y hay que resistirla en parte. Si el esquema devolviera precioCentimos, el controlador recibiría un objeto que ya no se parece al contrato público, y la traducción de unidades quedaría repartida entre el esquema y el mapeador. En Tienda Aroma mantenemos la conversión en el servicio (eurosACentimos, 03-03) y usamos transform solo para normalizar: recortar espacios, unificar mayúsculas del origen, eliminar notas de cata duplicadas. Una regla útil: transform limpia lo que el cliente escribió; no traduce entre el mundo público y el interno.
- Un esquema por operación:
partial, extend y merge
partial, extend y mergePOST, PUT y PATCH no piden lo mismo, así que no comparten esquema; comparten piezas.
| Operación | Esquema | Regla |
|---|---|---|
POST /v1/cafes |
esquemaCrearCafe |
Todos los campos obligatorios salvo los opcionales del contrato |
PUT /v1/cafes/:id |
esquemaReemplazarCafe |
Igual que crear: PUT reemplaza el recurso entero |
PATCH /v1/cafes/:id |
esquemaModificarCafe |
Todo opcional, pero al menos un campo |
Y las herramientas de composición:
const base = z.object({ nombre: z.string(), origen: z.string() });
base.partial(); // todos los campos opcionales
base.extend({ stock: z.number() }); // añade campos
base.merge(otroEsquema); // fusiona dos objetos
base.pick({ nombre: true }); // solo algunos
base.omit({ origen: true }); // todos menos algunosUn detalle de orden que cuesta una tarde si se descubre por las malas: .strict() se aplica al final. base.strict().partial() funciona, pero si encadenas extend después de strict, conviene volver a cerrarlo. Por eso en nuestros ficheros el .strict() es siempre la última llamada.
src/esquemas/comunes.js
src/esquemas/comunes.jsEmpezamos por las piezas compartidas, para no repetirlas en cada recurso:
// src/esquemas/comunes.js
import { z } from 'zod';
/** Identificadores con prefijo, tal como los fijamos en 02-02. */
export const idCafe = z.string().regex(/^caf_\d{3}$/, "El id debe tener la forma 'caf_001'");
export const idCliente = z.string().regex(/^cli_\d+$/, "El id debe tener la forma 'cli_842'");
export const idPedido = z.string().regex(/^ped_\d+$/, "El id debe tener la forma 'ped_5001'");
/** Booleano de query string: solo 'true' o 'false'. */
export const booleanoDeConsulta = z
.enum(['true', 'false'], { message: "Solo se admite 'true' o 'false'" })
.transform((v) => v === 'true');
/** Importe en euros con dos decimales como máximo (02-05). */
export const precioEuros = z
.number()
.positive('El precio debe ser mayor que cero')
.max(1000, 'El precio no puede superar los 1000 €')
.refine((v) => /^\d+(\.\d{1,2})?$/.test(String(v)), {
message: 'El precio admite como máximo dos decimales',
});
/** Fecha ISO-8601 en UTC con Z. */
export const fechaIso = z.string().datetime({ message: 'Debe ser ISO-8601 en UTC, con Z final' });
/**
* Paginación por desplazamiento, con los valores del contrato de 02-06:
* limite por defecto 20 y máximo 100; desplazamiento máximo 10.000.
* No se recorta en silencio: fuera de rango es 400.
*/
export const paginacion = {
limite: z.coerce
.number()
.int('El límite debe ser un entero')
.min(1, 'El límite mínimo es 1')
.max(100, 'El límite máximo es 100')
.default(20),
desplazamiento: z.coerce
.number()
.int('El desplazamiento debe ser un entero')
.min(0)
.max(10000, 'El desplazamiento máximo es 10.000; usa filtros más concretos')
.default(0),
};
/** Lista de campos separados por comas: 'id,nombre,precioEuros'. */
export const listaDeCampos = z
.string()
.regex(/^[a-zA-Z]+(,[a-zA-Z]+)*$/, 'Debe ser una lista de campos separados por comas');Los default() son importantes: gracias a ellos, el controlador recibe limite y desplazamiento siempre con un número, y desaparecen los req.query.limite === undefined ? 20 : ... de 03-03.
src/esquemas/cafes.js
src/esquemas/cafes.js// src/esquemas/cafes.js
import { z } from 'zod';
import {
idCafe,
precioEuros,
paginacion,
booleanoDeConsulta,
listaDeCampos,
} from './comunes.js';
/** Campos del recurso café que el cliente puede escribir. */
const camposCafe = {
nombre: z.string().trim().min(3, 'El nombre necesita al menos 3 caracteres').max(120),
origen: z.string().trim().min(2).max(80),
tueste: z.enum(['claro', 'medio', 'oscuro'], {
message: "El tueste debe ser 'claro', 'medio' u 'oscuro'",
}),
precioEuros,
stock: z.number().int('El stock debe ser un entero').nonnegative('El stock no puede ser negativo'),
notasCata: z
.array(z.string().trim().min(1).max(40))
.max(10, 'Como máximo 10 notas de cata')
.default([]),
descripcion: z.string().trim().max(2000).nullable().default(null),
};
/** POST /v1/cafes — todos los campos salvo los que tienen default. */
export const esquemaCrearCafe = z.object(camposCafe).strict();
/** PUT /v1/cafes/:id — reemplazo completo: mismas exigencias que crear. */
export const esquemaReemplazarCafe = esquemaCrearCafe;
/** PATCH /v1/cafes/:id — todo opcional, pero al menos un campo. */
export const esquemaModificarCafe = z
.object(camposCafe)
.partial()
.strict()
.refine((datos) => Object.keys(datos).length > 0, {
message: 'El cuerpo del PATCH no puede estar vacío',
});
/** Parámetros de ruta de /v1/cafes/:id */
export const esquemaIdCafe = z.object({ id: idCafe }).strict();
/** Query params de GET /v1/cafes, según el contrato de 02-06. */
export const esquemaConsultaCafes = z
.object({
origen: z.string().trim().min(1).optional(),
tueste: z.enum(['claro', 'medio', 'oscuro']).optional(),
precioMin: z.coerce.number().nonnegative().optional(),
precioMax: z.coerce.number().nonnegative().optional(),
disponible: booleanoDeConsulta.optional(),
q: z.string().trim().min(2, 'La búsqueda necesita al menos 2 caracteres').max(80).optional(),
ordenar: z.string().optional(),
campos: listaDeCampos.optional(),
limite: paginacion.limite,
desplazamiento: paginacion.desplazamiento,
})
.strict()
.refine(
(d) => d.precioMin === undefined || d.precioMax === undefined || d.precioMin <= d.precioMax,
{ message: "'precioMin' no puede ser mayor que 'precioMax'", path: ['precioMin'] }
);El .strict() del esquema de consulta es el que cumple la promesa de 02-06 de que un parámetro desconocido produce 400. ?limit=20 (en inglés, la errata más frecuente) deja de ser un filtro ignorado en silencio y pasa a ser un error explícito que el integrador ve en su primera prueba.
src/esquemas/pedidos.js
src/esquemas/pedidos.js// src/esquemas/pedidos.js
import { z } from 'zod';
import { idCafe, idCliente, paginacion, fechaIso } from './comunes.js';
/** Una línea de pedido tal como la envía el cliente. */
const esquemaLinea = z
.object({
cafeId: idCafe,
cantidad: z
.number()
.int('La cantidad debe ser un entero')
.min(1, 'La cantidad mínima es 1')
.max(99, 'La cantidad máxima por línea es 99'),
})
.strict();
/**
* POST /v1/pedidos
* El cliente NO envía precios ni total: los pone el servidor a partir del
* catálogo. Si los aceptáramos, cualquiera podría comprar a 0,01 €.
*/
export const esquemaCrearPedido = z
.object({
clienteId: idCliente,
lineas: z
.array(esquemaLinea)
.min(1, 'El pedido debe tener al menos una línea')
.max(50, 'Como máximo 50 líneas por pedido'),
})
.strict()
.superRefine((datos, ctx) => {
// Un mismo café no puede aparecer en dos líneas: sería ambiguo al
// descontar stock y al calcular el total.
const vistos = new Set();
datos.lineas.forEach((linea, indice) => {
if (vistos.has(linea.cafeId)) {
ctx.addIssue({
code: 'custom',
path: ['lineas', indice, 'cafeId'],
message: `El café '${linea.cafeId}' aparece repetido; agrupa las cantidades en una línea`,
});
}
vistos.add(linea.cafeId);
});
});
/** Query params de GET /v1/pedidos (02-06). */
export const esquemaConsultaPedidos = z
.object({
clienteId: idCliente.optional(),
estado: z.enum(['pendiente_pago', 'pagado', 'enviado']).optional(),
fechaDesde: fechaIso.optional(),
fechaHasta: fechaIso.optional(),
ordenar: z.string().optional(),
cursor: z.string().optional(), // opaco: se interpreta en 03-05
limite: paginacion.limite,
desplazamiento: paginacion.desplazamiento,
})
.strict()
.refine((d) => !d.fechaDesde || !d.fechaHasta || d.fechaDesde <= d.fechaHasta, {
message: "'fechaDesde' debe ser anterior o igual a 'fechaHasta'",
path: ['fechaDesde'],
});La nota más importante de este fichero es la de esquemaCrearPedido: el cliente no envía precios. Es un caso perfecto de por qué la validación es también diseño. Aceptar precioEuros en la línea de un pedido sería una vulnerabilidad de negocio de libro; rechazarlo con la entrada estricta la elimina de raíz.
- El middleware
validar(esquema, origen)
validar(esquema, origen)Una sola pieza para las tres entradas:
// src/middleware/validacion.js
/**
* Middleware genérico de validación.
*
* @param {import('zod').ZodTypeAny} esquema Esquema Zod a aplicar.
* @param {'body'|'query'|'params'} origen Parte de la petición a validar.
*
* Si la validación pasa, SUSTITUYE req[origen] por el valor ya parseado,
* con los tipos convertidos y los valores por defecto aplicados. A partir
* de ahí, el controlador trabaja con datos limpios y no vuelve a comprobar.
*/
export function validar(esquema, origen = 'body') {
return (req, res, next) => {
const resultado = esquema.safeParse(req[origen]);
if (!resultado.success) {
// El código depende del origen: el contrato de 02-04 distingue
// 'datos_invalidos' (cuerpo) de 'parametro_invalido' (query/ruta).
const codigo = origen === 'body' ? 'datos_invalidos' : 'parametro_invalido';
return res.status(400).json({
error: {
codigo,
mensaje:
origen === 'body'
? 'El cuerpo de la petición contiene errores de validación.'
: 'Los parámetros de la petición contienen errores.',
detalles: aDetalles(resultado.error),
},
});
}
req[origen] = resultado.data;
next();
};
}
/** Traduce las incidencias de Zod al formato 'detalles' del contrato. */
function aDetalles(error) {
return error.issues.map((incidencia) => ({
campo: incidencia.path.join('.') || '(cuerpo)',
codigo: traducirCodigo(incidencia),
mensaje: incidencia.message,
}));
}
/** Códigos internos de Zod → códigos estables del contrato. */
function traducirCodigo(incidencia) {
switch (incidencia.code) {
case 'invalid_type':
return incidencia.received === 'undefined' ? 'requerido' : 'tipo_invalido';
case 'too_small':
return 'demasiado_pequeno';
case 'too_big':
return 'demasiado_grande';
case 'unrecognized_keys':
return 'campo_desconocido';
case 'invalid_string':
case 'invalid_format':
return 'formato_invalido';
case 'invalid_enum_value':
case 'invalid_value':
return 'valor_no_permitido';
default:
return 'valor_invalido';
}
}Tres decisiones de diseño que merecen justificarse:
Por qué se sustituye req[origen]. Después del middleware, req.query.limite es el número 20, no la cadena '20', y req.body.notasCata es [] aunque el cliente no lo enviara. El controlador se queda sin conversiones ni valores por defecto: todo eso ocurrió en el borde. Es la materialización de "valida en el borde, confía dentro".
Por qué se traducen los códigos de Zod. too_small o unrecognized_keys son detalles de implementación de una librería. Si los expusiéramos, actualizar Zod podría cambiar el contrato de nuestra API, y eso es inaceptable (02-07). La traducción nos aísla: cambiar de librería no cambiaría ni un codigo visible.
Por qué no se filtran los mensajes. Los mensajes de Zod son legibles y en muchos casos los hemos escrito nosotros en español dentro del esquema. Los que no —los mensajes por defecto en inglés— se pueden traducir; el ejercicio 3 lo aborda.
Nota sobre Express 5. En Express 4,
req.queryes una propiedad normal y se puede reasignar. En Express 5 pasó a ser un getter de solo lectura, así que la asignación falla en silencio. La solución portable es guardar el resultado en un campo propio (req.validado = { ...req.validado, [origen]: resultado.data }) y leer de ahí en el controlador. Como este curso usa Express 4, mantenemos la forma directa, que es más legible.
- Del
ZodError al formato de error del contrato
ZodError al formato de error del contratoCon lo anterior, una petición con cuatro fallos produce esta respuesta:
{
"error": {
"codigo": "datos_invalidos",
"mensaje": "El cuerpo de la petición contiene errores de validación.",
"detalles": [
{ "campo": "nombre", "codigo": "demasiado_pequeno", "mensaje": "El nombre necesita al menos 3 caracteres" },
{ "campo": "tueste", "codigo": "valor_no_permitido", "mensaje": "El tueste debe ser 'claro', 'medio' u 'oscuro'" },
{ "campo": "precioEuros", "codigo": "tipo_invalido", "mensaje": "Expected number, received string" },
{ "campo": "stock", "codigo": "demasiado_pequeno", "mensaje": "El stock no puede ser negativo" }
]
}
}Cuatro fallos, una sola respuesta. Esa es la promesa de 02-04, y es lo que separa una API amable de una insufrible: con validación "al primer fallo", el integrador necesitaría cuatro intentos y cuatro despliegues de su cliente para descubrir lo mismo.
Para campos anidados, path.join('.') produce rutas legibles:
path de Zod |
campo en detalles |
|---|---|
['nombre'] |
nombre |
['lineas', 0, 'cantidad'] |
lineas.0.cantidad |
[] (error del objeto entero) |
(cuerpo) |
- Las rutas con validación
Ahora las rutas declaran también el contrato de entrada. src/rutas/cafes.js queda así:
// src/rutas/cafes.js
import { Router } from 'express';
import { controladorCafes } from '../controladores/cafes.js';
import { validar } from '../middleware/validacion.js';
import {
esquemaCrearCafe,
esquemaReemplazarCafe,
esquemaModificarCafe,
esquemaConsultaCafes,
esquemaIdCafe,
} from '../esquemas/cafes.js';
export const rutasCafes = Router();
rutasCafes.get('/', validar(esquemaConsultaCafes, 'query'), controladorCafes.listar);
rutasCafes.post('/', validar(esquemaCrearCafe), controladorCafes.crear);
rutasCafes.get('/:id', validar(esquemaIdCafe, 'params'), controladorCafes.obtener);
rutasCafes.put(
'/:id',
validar(esquemaIdCafe, 'params'),
validar(esquemaReemplazarCafe),
controladorCafes.reemplazar
);
rutasCafes.patch(
'/:id',
validar(esquemaIdCafe, 'params'),
validar(esquemaModificarCafe),
controladorCafes.modificar
);
rutasCafes.delete('/:id', validar(esquemaIdCafe, 'params'), controladorCafes.borrar);Se lee como una especificación: para cada método y URI, qué se valida y quién lo atiende. Y el controlador adelgaza de forma notable, porque desaparecen todas las comprobaciones manuales:
// src/controladores/cafes.js (versión simplificada tras la validación)
listar(req, res) {
// req.query ya viene validado y con los tipos correctos: limite y
// desplazamiento son números, disponible es booleano, y no hay
// parámetros desconocidos. No queda nada que comprobar aquí.
const { origen, tueste, precioMin, precioMax, disponible, q, ordenar, campos } = req.query;
const { limite, desplazamiento } = req.query;
const criteriosOrden = interpretarOrdenar(ordenar);
if (criteriosOrden.error) {
return res.status(400).json({
error: {
codigo: 'parametro_invalido',
mensaje: criteriosOrden.error,
detalles: [{ campo: 'ordenar', codigo: 'valor_no_permitido', mensaje: criteriosOrden.error }],
},
});
}
const { elementos, total } = servicioCafes.listar({
origen,
tueste,
precioMinCentimos: precioMin === undefined ? undefined : eurosACentimos(precioMin),
precioMaxCentimos: precioMax === undefined ? undefined : eurosACentimos(precioMax),
disponible,
q,
ordenar: criteriosOrden,
limite,
desplazamiento,
});
const enlaces = construirCabeceraLink({ req, limite, desplazamiento, total });
if (Object.keys(enlaces).length > 0) res.links(enlaces);
res.status(200).json({
datos: elementos.map(cafeARepresentacion).map(aResumenDeColeccion).map((r) => proyectar(r, campos)),
total,
});
},
crear(req, res) {
// req.body ya está validado: aquí no hay ni un solo 'if'.
const cafe = servicioCafes.crear(req.body);
res.set('Location', `/v1/cafes/${cafe.id}`);
res.status(201).json(cafeARepresentacion(cafe));
},crear ha pasado de veinte líneas a tres. Eso es el beneficio medible de mover la validación al borde.
- Peticiones inválidas y sus respuestas exactas
# 1. Varios fallos a la vez en el cuerpo
curl -s -X POST http://localhost:3000/v1/cafes \
-H "Content-Type: application/json" \
-d '{"nombre":"K","origen":"Kenia","tueste":"tostado","precioEuros":"16.75","stock":-3}' | jq{
"error": {
"codigo": "datos_invalidos",
"mensaje": "El cuerpo de la petición contiene errores de validación.",
"detalles": [
{ "campo": "nombre", "codigo": "demasiado_pequeno", "mensaje": "El nombre necesita al menos 3 caracteres" },
{ "campo": "tueste", "codigo": "valor_no_permitido", "mensaje": "El tueste debe ser 'claro', 'medio' u 'oscuro'" },
{ "campo": "precioEuros", "codigo": "tipo_invalido", "mensaje": "Expected number, received string" },
{ "campo": "stock", "codigo": "demasiado_pequeno", "mensaje": "El stock no puede ser negativo" }
]
}
}# 2. Campo desconocido: entrada estricta
curl -s -X POST http://localhost:3000/v1/cafes \
-H "Content-Type: application/json" \
-d '{"nombre":"Kenia Nyeri","origen":"Kenia","tueste":"medio","precioEuros":16.75,"stock":40,"color":"rojo"}' \
| jq '.error.detalles'[{ "campo": "(cuerpo)", "codigo": "campo_desconocido", "mensaje": "Unrecognized key(s) in object: 'color'" }]# 3. Query param desconocido: 'parametro_invalido', no 'datos_invalidos'
curl -s "http://localhost:3000/v1/cafes?limit=20" | jq '.error.codigo'# 4. Límite fuera de rango: 400, NO se recorta a 100
curl -s "http://localhost:3000/v1/cafes?limite=5000" | jq '.error.detalles[0]'# 5. Id con formato incorrecto: se detecta en params, sin tocar el almacén
curl -s "http://localhost:3000/v1/cafes/1" | jq '.error'{
"codigo": "parametro_invalido",
"mensaje": "Los parámetros de la petición contienen errores.",
"detalles": [{ "campo": "id", "codigo": "formato_invalido", "mensaje": "El id debe tener la forma 'caf_001'" }]
}Este último caso tiene más miga de la que parece. Un id mal formado ahora produce 400 parametro_invalido y nunca llega al repositorio. La alternativa —dejarlo pasar y devolver 404 cafe_no_encontrado— también sería defendible, pero elegimos el 400 porque distingue "te has equivocado escribiendo el identificador" de "ese café no existe", y además ahorra una consulta a la base de datos por cada petición basura. Con SQL detrás (03-05), esa validación previa es además una capa más frente a la inyección.
# 6. PATCH vacío
curl -s -X PATCH http://localhost:3000/v1/cafes/caf_001 \
-H "Content-Type: application/merge-patch+json" -d '{}' | jq '.error.detalles[0].mensaje'
- Validación de negocio: por qué vive en el servicio
Hay reglas que un esquema no puede comprobar, y confundirlas con la validación de formato es el error conceptual más común de esta lección:
| Regla | ¿Esquema o servicio? | Por qué |
|---|---|---|
cantidad es un entero ≥ 1 |
Esquema | Se ve mirando la petición |
cafeId tiene la forma caf_\d{3} |
Esquema | Formato puro |
| Ese café existe | Servicio | Requiere consultar el almacén |
| Hay stock suficiente | Servicio | Depende del estado y cambia entre dos peticiones |
| El pedido no está ya pagado | Servicio | Depende de la máquina de estados |
| El cliente puede ver este pedido | Servicio | Depende de la identidad (03-06) |
Las tres razones de fondo:
- El esquema no tiene acceso a los datos. Meter una consulta dentro de un
refineconvertiría el esquema en algo asíncrono, dependiente de la base de datos e imposible de reutilizar para generar documentación. - El estado cambia. Entre validar "hay stock" y descontarlo puede colarse otro pedido. La comprobación debe ocurrir dentro de la misma transacción que el descuento (03-05); hacerla en el borde da una falsa sensación de seguridad.
- El código de error es distinto. El formato inválido es
400 datos_invalidos; el stock insuficiente es409 stock_insuficiente, un conflicto de estado, no un error de escritura. Son familias diferentes en el catálogo de 02-04.
Así queda esa validación en el servicio de pedidos, escrita hoy con la forma provisional que 03-07 convertirá en throw new ErrorApi(...):
// src/servicios/pedidos.js (añadido)
import { repositorioCafes } from '../repositorios/cafes-memoria.js';
export const servicioPedidos = {
// ...listar y obtener...
/**
* Comprueba las reglas de negocio de un pedido nuevo.
* Devuelve null si todo es correcto, o un objeto de error de dominio.
* En 03-07 esto pasará a ser un throw de ErrorApi.
*/
comprobarLineas(lineas) {
const problemas = [];
for (const linea of lineas) {
const cafe = repositorioCafes.buscarPorId(linea.cafeId);
if (!cafe) {
problemas.push({
codigo: 'cafe_no_encontrado',
campo: `lineas.${linea.cafeId}`,
mensaje: `El café '${linea.cafeId}' no existe o está descatalogado.`,
});
continue;
}
if (cafe.stock < linea.cantidad) {
problemas.push({
codigo: 'stock_insuficiente',
campo: `lineas.${linea.cafeId}`,
mensaje: `Solo quedan ${cafe.stock} unidades de '${cafe.nombre}' y se piden ${linea.cantidad}.`,
});
}
}
return problemas.length > 0 ? problemas : null;
},
};Fíjate en que también aquí se acumulan todos los problemas antes de responder. La coherencia con la validación de esquema es deliberada: si un pedido tiene tres líneas sin stock, el cliente merece enterarse de las tres a la vez.
- Sanitización, normalización y límite de tamaño
Tres conceptos que se confunden y que hacen cosas distintas:
| Concepto | Qué hace | Ejemplo |
|---|---|---|
| Validación | Acepta o rechaza | "tostado" no es un tueste válido → 400 |
| Normalización | Unifica formas equivalentes | " colombia " → "Colombia" |
| Sanitización | Neutraliza contenido peligroso | Escapar HTML antes de mostrarlo |
Tienda Aroma valida y normaliza en el esquema. La sanitización es cuestión de contexto de salida y no se hace aquí: intentar "limpiar" HTML al entrar produce datos mutilados —un cliente que se apellida O'Brien no debería perder el apóstrofo— y una falsa seguridad. Lo correcto es guardar el texto tal cual y escaparlo en el momento de usarlo: parámetros preparados para SQL (03-05), escape de HTML en el cliente que lo pinta. En 04-02 se desarrolla el porqué con detalle.
Sobre el límite de tamaño, ya lo pusimos en 03-02:
Es una defensa que la validación por esquema no puede darte, porque actúa antes: sin límite, un cuerpo de 500 MB se parsea entero en memoria y el proceso muere antes de que Zod vea nada. Con limit, Express responde 413. Ese 413 produce hoy una respuesta fea de Express; en 03-07 lo convertiremos en cuerpo_demasiado_grande del catálogo.
Un último punto: la validación protege contra la contaminación de prototipos. Un cuerpo con {"__proto__": {"esAdmin": true}} podría, combinado con un Object.assign descuidado, modificar el prototipo de todos los objetos del proceso. Con .strict(), esa clave es simplemente un campo desconocido y la petición muere en el borde con un 400.
- Alternativas a Zod y la conexión con OpenAPI
| Librería | Enfoque | Nota |
|---|---|---|
| Zod | Esquemas como código, con inferencia de tipos | La elección del curso: sin dependencias y muy legible |
| Joi | Esquemas como código, veterana | Muy madura; sin inferencia de tipos de TypeScript |
| Yup | Similar a Joi, popular en formularios | Cómoda en el cliente, algo menos en el servidor |
| express-validator | Middleware encadenado sobre req |
Muy integrada en Express; el esquema no es un objeto reutilizable |
| AJV + JSON Schema | Estándar JSON Schema, muy rápida | La única que valida directamente el esquema de OpenAPI |
Esa última fila apunta a un tema importante. En 02-08 escribimos openapi.yaml como fuente de verdad del contrato, y en este módulo hemos escrito esquemas Zod que dicen prácticamente lo mismo. Tenemos dos definiciones de la misma verdad, y dos definiciones acaban divergiendo: alguien añade un campo al esquema Zod y olvida el YAML.
Las tres formas de resolverlo:
- Generar OpenAPI desde Zod, con herramientas como
zod-to-json-schemao@asteasolutions/zod-to-openapi. El código manda. - Generar los validadores desde OpenAPI, con AJV y el JSON Schema del contrato. La especificación manda; es lo más coherente con API-first.
- Mantener las dos y verificar en CI que la implementación cumple la especificación, con pruebas de contrato.
La tercera es la más práctica y la que veremos en 05-04, donde comprobaremos automáticamente que cada respuesta valida contra el esquema publicado. Por ahora basta con ser consciente del riesgo: cada vez que toques un esquema de src/esquemas/, toca también openapi.yaml. Es exactamente la deriva de la que advertíamos en 02-08.
Errores Comunes y Consejos
1. Validar solo el cuerpo. Los query params y los parámetros de ruta son igual de manipulables, y ?limite=999999 es un problema de disponibilidad.
2. Usar z.coerce.boolean() para un parámetro de la URL. Convierte cualquier cadena no vacía en true, incluida 'false'. Usa un z.enum(['true','false']).transform(...).
3. Meter consultas a la base de datos en un refine. El esquema no debe conocer el estado del sistema. Esa comprobación pertenece al servicio y a menudo a la misma transacción que la escritura.
4. Devolver los códigos internos de Zod al cliente. too_small es un detalle de una dependencia; si lo publicas, actualizar la librería te obliga a cambiar el contrato.
5. Olvidar .strict(). Sin él, el objeto se valida pero los campos desconocidos se descartan en silencio y el contrato de entrada estricta deja de cumplirse.
6. Reutilizar el esquema de POST para PATCH. PATCH exige todo opcional; usar el de POST obliga al cliente a reenviar el recurso entero, que es lo que hace PUT.
7. Confiar en que el cliente ya valida. Nunca. La SPA valida por usabilidad; el servidor valida por corrección.
8. Validar después de tocar la base de datos. El orden en la cadena de middleware importa: validar va antes del controlador, siempre.
Consejo: cuando dudes de si una comprobación es de esquema o de negocio, pregúntate si podrías responder mirando solo el texto de la petición. Si necesitas consultar algo, es negocio y va en el servicio.
Ejercicios
Ejercicio 1
Escribe el esquema esquemaCrearResena para POST /v1/cafes/:id/resenas. Según el contrato: puntuacion es un entero de 1 a 5 obligatorio, comentario es texto opcional de entre 10 y 2000 caracteres, y no se admite ningún otro campo (en particular estado, que lo fija el servidor en pendiente_moderacion). Añade la regla de que si hay comentario, no puede ser solo espacios en blanco. Muestra la ruta con su validación.
Ejercicio 2
Un integrador se queja de que POST /v1/pedidos con este cuerpo devuelve 400 y no entiende por qué:
{
"clienteId": "cli_842",
"lineas": [
{ "cafeId": "caf_001", "cantidad": 2, "precioEuros": 14.50 },
{ "cafeId": "caf_001", "cantidad": 1 }
],
"totalEuros": 43.50
}Enumera todos los fallos que detectará esquemaCrearPedido, escribe la respuesta completa que devuelve la API y explica al integrador por qué el rechazo de precioEuros y totalEuros no es una molestia sino una protección.
Ejercicio 3
Los mensajes por defecto de Zod salen en inglés ("Expected number, received string"), lo que rompe la coherencia de una API cuyos mensajes están en español. Propón una solución que traduzca esos mensajes sin tener que escribir un mensaje a mano en cada campo del esquema, e impleméntala en traducirCodigo/aDetalles. Comenta la ventaja de tener el codigo estable además del mensaje.
Soluciones
Solución 1
// src/esquemas/resenas.js
import { z } from 'zod';
export const esquemaCrearResena = z
.object({
puntuacion: z
.number()
.int('La puntuación debe ser un entero')
.min(1, 'La puntuación mínima es 1')
.max(5, 'La puntuación máxima es 5'),
comentario: z
.string()
.trim()
.min(10, 'El comentario necesita al menos 10 caracteres')
.max(2000, 'El comentario no puede superar los 2000 caracteres')
.optional(),
})
.strict();El .trim() antes de .min(10) resuelve por sí solo la regla del comentario en blanco: " " se convierte en cadena vacía y falla el mínimo. Es más elegante que un refine, y demuestra que el orden de los encadenamientos en Zod tiene significado.
// src/rutas/cafes.js
import { esquemaCrearResena } from '../esquemas/resenas.js';
rutasCafes.post(
'/:id/resenas',
validar(esquemaIdCafe, 'params'),
validar(esquemaCrearResena),
controladorResenas.crear
);Sobre estado: no aparece en el esquema a propósito. Con .strict(), un cliente que envíe "estado": "publicada" recibe 400 campo_desconocido y no puede saltarse la moderación. Es el mismo patrón que el precioEuros de las líneas de pedido: los campos que fija el servidor no se aceptan en la entrada, se rechazan.
Solución 2
Fallos detectados, tres en total:
lineas.0.precioEuros— campo desconocido en la línea:esquemaLineaes.strict()y solo admitecafeIdycantidad.totalEuros— campo desconocido en la raíz:esquemaCrearPedidoes.strict()y solo admiteclienteIdylineas.lineas.1.cafeId—caf_001aparece repetido, lo detecta elsuperRefine.
Respuesta de la API:
{
"error": {
"codigo": "datos_invalidos",
"mensaje": "El cuerpo de la petición contiene errores de validación.",
"detalles": [
{ "campo": "lineas.0", "codigo": "campo_desconocido", "mensaje": "Unrecognized key(s) in object: 'precioEuros'" },
{ "campo": "(cuerpo)", "codigo": "campo_desconocido", "mensaje": "Unrecognized key(s) in object: 'totalEuros'" },
{ "campo": "lineas.1.cafeId", "codigo": "valor_invalido", "mensaje": "El café 'caf_001' aparece repetido; agrupa las cantidades en una línea" }
]
}
}Explicación para el integrador: los precios y el total los calcula el servidor a partir del catálogo en el momento de crear el pedido, y por eso la API no los acepta de entrada. Si los aceptara, cualquiera podría enviar precioEuros: 0.01 y comprar café de especialidad a un céntimo; el totalEuros enviado por el cliente, además, podría no cuadrar con la suma de las líneas, y entonces habría que decidir cuál de los dos números es el bueno. Rechazarlos elimina de golpe un fraude y una ambigüedad. La respuesta del 201 sí devuelve precioEuros por línea y totalEuros, ya calculados y congelados, que es lo que el cliente necesita mostrar.
Sobre el café repetido: el contrato prefiere una única línea por café con la cantidad agrupada, porque dos líneas del mismo producto hacen ambiguo el descuento de stock y complican la devolución parcial.
Solución 3
Zod permite un mapa de errores global que se aplica a todos los esquemas, sin tocar campo por campo:
// src/esquemas/mensajes.js
import { z } from 'zod';
/**
* Mapa de errores global: traduce los mensajes por defecto de Zod.
* Se instala una sola vez al arrancar y afecta a todos los esquemas.
* Si un campo define su propio mensaje, ese tiene prioridad.
*/
const mapaEspanol = (incidencia, contexto) => {
switch (incidencia.code) {
case 'invalid_type':
return incidencia.received === 'undefined'
? { message: 'Este campo es obligatorio' }
: { message: `Se esperaba ${incidencia.expected} y se recibió ${incidencia.received}` };
case 'too_small':
return { message: `El valor mínimo admitido es ${incidencia.minimum}` };
case 'too_big':
return { message: `El valor máximo admitido es ${incidencia.maximum}` };
case 'unrecognized_keys':
return { message: `Campos no reconocidos: ${incidencia.keys.join(', ')}` };
default:
return { message: contexto.defaultError };
}
};
export function instalarMensajesEnEspanol() {
// El nombre exacto de esta función varía entre versiones mayores de Zod
// (setErrorMap / config); la idea es la misma: un mapa global.
z.setErrorMap(mapaEspanol);
}Se instala una vez, en src/app.js, antes de montar las rutas:
Ahora el fallo del ejemplo anterior devuelve "Se esperaba number y se recibió string" en lugar del mensaje en inglés, sin haber tocado ni un campo del esquema.
Ventaja de tener codigo además de mensaje: el mensaje es para humanos —puede cambiar, traducirse o reescribirse para que se entienda mejor, y eso no rompe a nadie—. El codigo es para máquinas: un cliente puede escribir if (detalle.codigo === 'requerido') y confiar en que eso no cambiará, porque forma parte del contrato y está sujeto a las reglas de versionado de 02-07. Separar ambos permite mejorar la redacción de los mensajes cualquier martes sin publicar una versión nueva de la API. Es el mismo principio por el que los enumerados van en snake_case y no traducidos (02-05).
Conclusión
La API ya no acepta basura. Los esquemas de src/esquemas/ son ahora la definición ejecutable de qué entra: tipos, rangos, formatos de identificador, enumerados, fechas ISO-8601, precios con dos decimales y listas con longitud máxima; con .strict() para cumplir la entrada estricta de 02-05, z.coerce para convertir los parámetros de consulta que siempre llegan como texto, default() para que el controlador reciba los valores del contrato ya aplicados, y partial() para que PATCH exija lo justo. Un único middleware, validar(esquema, origen), aplica todo eso a body, query y params, sustituye la entrada por el valor ya parseado y traduce el ZodError al formato del contrato: 400 datos_invalidos para el cuerpo, 400 parametro_invalido para los parámetros, y todos los fallos a la vez en detalles, cada uno con su campo, su codigo estable y su mensaje.
Igual de importante es lo que no hemos metido en los esquemas. La existencia de un café, el stock disponible, un pedido ya pagado o el permiso para ver un recurso dependen del estado del sistema, cambian entre dos peticiones y tienen códigos de error de otra familia (409, no 400). Esa validación vive en el servicio, y algunas de sus comprobaciones tendrán que ocurrir dentro de la misma transacción que la escritura.
Justamente ahí vamos ahora. Todo lo construido hasta hoy se apoya en dos arrays en memoria que se vacían con cada reinicio de node --watch, que no admiten consultas serias y que no pueden garantizar que descontar stock de tres líneas sea una operación atómica. En 03-05, Persistencia y capa de acceso a datos, sustituiremos ese almacén por SQLite con better-sqlite3 detrás del patrón repositorio: esquema SQL con precio_centimos entero, migraciones versionadas y datos de siembra, sentencias preparadas que cierran la puerta a la inyección, consultas dinámicas seguras para los filtros de 02-06, transacciones para crear un pedido descontando stock, control de concurrencia optimista con conflicto_version y paginación por cursor de verdad. Y lo haremos sin tocar una sola línea de los controladores ni de los servicios, que es la promesa que hicimos en 03-01 al separar las capas.
Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful
Módulo 1: Introducción a las APIs RESTful
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
