POST /api/pedidos sigue haciendo registrarPedido(peticion.body) con lo que llegue. Helmet, CORS y el limitador de peticiones no miran el contenido: un cuerpo con cantidad: -5, un sesionId que es un array o un correo de 40 000 caracteres pasa por todos ellos sin despeinarse. Este es el agujero que cerramos ahora. La validación es una de las cosas que Express no hace por ti (lo dijimos en 06-01, tabla incluida) y es también una de las que más consecuencias tiene cuando falta. En esta lección decidimos dónde vive, elegimos una herramienta, escribimos los esquemas de Escena Viva y construimos un middleware genérico que se coloca en la ruta y deja los datos ya limpios y convertidos.
Contenido
- Por qué nunca se confía en el cliente
- Qué se valida y dónde debe vivir la validación
- Validación a mano y por qué se vuelve inmantenible
- zod: el validador elegido
- Los esquemas de Escena Viva
- El middleware genérico
validar(esquema, origen) - Coerción y normalización
- La respuesta de error útil
- Usos menos obvios: respuestas y configuración
- Sanitización frente a validación
- Por qué nunca se confía en el cliente
publico/app.js valida el formulario antes de enviar: comprueba que la cantidad esté entre 1 y 6, que el correo tenga arroba y que haya una sesión seleccionada. Y aun así, el servidor debe volver a validarlo todo. Motivos, ordenados de más evidente a menos:
- El cliente no es tu formulario. Cualquiera puede llamar a la API con
curl, con Postman o con un script: tu HTML es una sugerencia, no un control. - El cliente se puede modificar. Las herramientas de desarrollo del navegador permiten cambiar el
maxde uninputen dos segundos. - Puede haber más clientes. Una app móvil, un panel de taquilla, una integración con un promotor. Cada uno con sus propios fallos.
- Los intermediarios alteran datos. Proxies, redirecciones, reintentos con cuerpo truncado.
- El cliente valida para la experiencia; el servidor valida para la integridad. El primero evita frustración, el segundo evita corrupción.
La formulación clásica: la validación en el cliente es cortesía; la validación en el servidor es obligación. Y no se trata solo de atacantes: un despliegue del front-end que envíe cantidad como cadena en lugar de número por un error de tipado provoca exactamente el mismo destrozo, sin mala intención.
- Qué se valida y dónde debe vivir la validación
| Origen | Qué llega | Riesgo típico |
|---|---|---|
req.body |
Cuerpo JSON del pedido | Tipos incorrectos, campos ausentes, campos de más |
req.params |
:id, :idSesion |
Formato arbitrario, cadenas enormes, caracteres de control |
req.query |
Filtros y paginación | porPagina=999999 que tumba la respuesta |
req.headers |
X-Canal-Venta, Accept |
Inyección en registros, valores desmesurados |
Y la pregunta más importante: ¿dónde se coloca esa validación? El recorrido es Cliente → validar() → Controlador → Servicio → Dominio, y hay dos puntos de rechazo bien distintos: el middleware validar() devuelve 400 cuando la forma es inválida, y el dominio devuelve 409 cuando el aforo no da. Son dos niveles con responsabilidades distintas, y confundirlos es un error de diseño frecuente:
| Validación de entrada (el borde) | Invariantes del dominio | |
|---|---|---|
| Pregunta que responde | ¿Tienen los datos la forma correcta? | ¿Es esta operación válida ahora? |
| Ejemplo | cantidad es un entero entre 1 y 6 |
La sesión ses-001-1 tiene 3 entradas libres |
| Dónde vive | src/esquemas/ + middleware validar |
src/dominio/sesion.js, gestor-ventas.js |
| Depende del estado / conoce HTTP | No / sí, es el borde | Sí / no, nunca |
| Estado HTTP resultante | 400 o 422 | 409 |
La regla: el borde garantiza la forma; el dominio garantiza las reglas. Sesion.vender() no debe comprobar si cantidad es un número —eso ya está garantizado cuando llega— pero sí debe comprobar si hay aforo, porque eso depende del estado y puede cambiar entre la validación y la venta. Y al revés: si borras el middleware de validación, el dominio no debe romperse de forma catastrófica, pero tampoco es su trabajo generar mensajes amables para un cliente HTTP.
- Validación a mano y por qué se vuelve inmantenible
Recuerda cómo quedó rutas-pedidos.js en el módulo 4:
// Extracto del modulo 4. Funcionaba. Y creceria sin control.
function validarPedido(datos) {
if (typeof datos !== 'object' || datos === null || Array.isArray(datos)) {
throw Object.assign(new Error('El cuerpo debe ser un objeto'), { codigo: 'JSON_INVALIDO' });
}
if (typeof datos.sesionId !== 'string' || !/^ses-\d{3}-\d$/.test(datos.sesionId)) {
throw Object.assign(new Error('sesionId invalido'), { codigo: 'PARAMETRO_INVALIDO' });
}
if (!Number.isInteger(datos.cantidad) || datos.cantidad < 1) {
throw Object.assign(new Error('cantidad invalida'), { codigo: 'CANTIDAD_INVALIDA' });
}
if (datos.cantidad > LIMITE_ENTRADAS_POR_PEDIDO) {
throw Object.assign(new Error('maximo 6 entradas'), { codigo: 'LIMITE_POR_PEDIDO' });
}
// ...y ahora el email, y el canal, y los campos opcionales, y el descuento...
}Cinco problemas concretos, y todos empeoran con el tiempo:
- Se detiene en el primer error. El cliente arregla
sesionId, reenvía, y descubre que también fallabacantidad: tres viajes para tres errores. - No devuelve el resultado convertido. Validas
datos.cantidadpero sigues usando el objeto original sin normalizar. - Los campos de más pasan. Si el cliente envía
{ cantidad: 2, esAdministrador: true }, ese campo llega intacto al dominio. - Es imposible de reutilizar. El mismo pedido validado desde una cola de mensajes o un script de importación necesita copiar y pegar.
- No se puede documentar automáticamente. No hay descripción de la forma esperada; solo código imperativo que hay que leer entero.
Cincuenta líneas para cuatro campos: con veinte endpoints, esto es la mitad del proyecto.
- zod: el validador elegido
zod invierte el enfoque: en lugar de escribir comprobaciones, describes la forma de los datos, y la biblioteca deriva de ahí la validación, la conversión y los mensajes. Se instala con npm install zod, y npm view zod license dependencies confirma lo que buscabas en el módulo 5: MIT y cero dependencias.
Comparativa breve y honesta:
| zod | express-validator | Joi | |
|---|---|---|---|
| Acoplamiento a Express | Ninguno | Alto: es middleware | Ninguno |
| Estilo | Esquema declarativo | Cadena de comprobaciones por campo | Esquema declarativo |
| Salida y reutilización fuera de HTTP | Objeto convertido y podado; sí | Modifica req y expone validationResult; no |
Objeto convertido; sí |
| Inferencia de tipos | Excelente (con TypeScript, gratis) | Escasa | Buena |
| Dependencias y curva | Cero, curva suave | Varias, curva suave | Varias, curva media |
Elegimos zod por tres razones que encajan con el curso: cero dependencias (criterio del módulo 5), independencia de Express (el mismo esquema sirve para la API, para un script de importación y para las pruebas del módulo 9) y porque el esquema es una fuente única de verdad que documenta la entrada mejor que cualquier comentario. Lo esencial de su API: se declara un objeto con z.object({ nombre: z.string().min(1).max(80), edad: z.number().int().positive(), correo: z.email(), canal: z.enum([...]), notas: z.string().optional(), activo: z.boolean().default(true) }) y se ejecuta con esquema.safeParse(datos) —que devuelve { success, data } o { success, error }— o con esquema.parse(datos), que devuelve los datos o lanza un ZodError.
Detalle clave: z.object() descarta por defecto las claves no declaradas. { cantidad: 2, esAdministrador: true } sale del validador como { cantidad: 2 }, así que el problema 3 del apartado anterior queda resuelto de oficio. Y si prefieres rechazar en vez de descartar, .strict() hace que la clave sobrante sea un error.
- Los esquemas de Escena Viva
// src/esquemas/pedido.js
const { z } = require('zod');
const LIMITE_ENTRADAS_POR_PEDIDO = 6;
// Piezas reutilizables. Identificador de sesion: ses-NNN-M (ses-001-1...).
const idSesion = z
.string({ error: 'sesionId debe ser una cadena' })
.trim()
.regex(/^ses-\d{3}-\d$/, 'sesionId debe tener el formato ses-NNN-M');
const idEvento = z.string().trim().regex(/^evt-\d{3}$/, 'El evento debe ser evt-NNN');
const cantidadEntradas = z.coerce
.number({ error: 'cantidad debe ser un numero' })
.int('cantidad debe ser un entero')
.min(1, 'Debes pedir al menos 1 entrada')
.max(LIMITE_ENTRADAS_POR_PEDIDO, `Maximo ${LIMITE_ENTRADAS_POR_PEDIDO} entradas por pedido`);
const correoComprador = z
.email('El correo del comprador no es valido')
.max(254, 'El correo es demasiado largo')
.transform((valor) => valor.trim().toLowerCase());
const canalVenta = z.enum(['web', 'taquilla', 'telefono'], { error: 'canal no valido' });
/** Cuerpo de POST /api/pedidos */
const esquemaCrearPedido = z.object({
sesionId: idSesion,
cantidad: cantidadEntradas,
email: correoComprador,
canal: canalVenta.default('web'),
comentario: z.string().trim().max(280).optional(),
});
/** Parametros de /api/eventos/:id y query de GET /api/eventos */
const esquemaParametrosEvento = z.object({ id: idEvento });
const esquemaConsultaEventos = z.object({
sala: z.enum(['Teatro Almendra', 'Sala Boveda', 'Auditorio Ribera']).optional(),
agotado: z.enum(['true', 'false']).transform((v) => v === 'true').optional(),
pagina: z.coerce.number().int().min(1).default(1),
porPagina: z.coerce.number().int().min(1).max(50).default(10),
});
module.exports = { esquemaCrearPedido, esquemaParametrosEvento, esquemaConsultaEventos };Compáralo con las cincuenta líneas imperativas del apartado 3: aquí hay más reglas, en menos espacio, y se leen como una especificación. Y LIMITE_ENTRADAS_POR_PEDIDO sigue siendo la misma constante del módulo 4: la regla no ha cambiado, solo el sitio donde se expresa.
- El middleware genérico
validar(esquema, origen)
validar(esquema, origen)Un solo middleware configurable que se coloca en cualquier ruta. Es una factoría, como las de 06-04.
// src/middleware/validar.js
const { ErrorDeValidacion } = require('../errores.js'); // 06-07
const ORIGENES_VALIDOS = ['body', 'params', 'query', 'headers'];
// Devuelve un middleware que valida una parte de la peticion contra un esquema.
// El resultado ya convertido y podado se deja en req.datosValidados[origen].
function validar(esquema, origen = 'body') {
// Error de programacion: se detecta al arrancar, no en produccion.
if (!ORIGENES_VALIDOS.includes(origen)) {
throw new Error(`Origen de validacion no soportado: ${origen}`);
}
return function validarPeticion(peticion, respuesta, siguiente) {
const resultado = esquema.safeParse(peticion[origen]);
if (!resultado.success) {
// Convertimos los problemas de zod a nuestro formato de detalles.
const detalles = resultado.error.issues.map((p) => ({
campo: p.path.join('.') || origen, motivo: p.message, tipo: p.code,
}));
siguiente(new ErrorDeValidacion('Los datos enviados no son validos', detalles));
return;
}
// IMPORTANTE: no reasignamos peticion.query (solo lectura en Express 5)
// ni peticion.params. Guardamos el resultado en una propiedad propia.
peticion.datosValidados = peticion.datosValidados ?? {};
peticion.datosValidados[origen] = resultado.data;
siguiente();
};
}
module.exports = { validar, ORIGENES_VALIDOS };Uso en las rutas, y controlador ya sin comprobaciones:
rutasPedidos.post('/', validar(esquemaCrearPedido, 'body'), crearPedido);
rutasEventos.get('/', validar(esquemaConsultaEventos, 'query'), listarEventos);
rutasEventos.get('/:id', validar(esquemaParametrosEvento, 'params'), obtenerEvento);
// src/controladores/pedidos.js — los datos ya tienen la forma correcta:
// enteros son enteros, el correo esta en minusculas y no hay campos de mas.
async function crearPedido(peticion, respuesta) {
const pedido = await registrarPedido(peticion.datosValidados.body);
respuesta.status(201).location(`/api/pedidos/${pedido.id}`).json(pedido.toJSON());
}Por qué no se reasigna req.query
En Express 4 el patrón habitual era req.query = resultado.data, y funcionaba. En Express 5 req.query es un getter sin setter, y esa línea lanza TypeError: Cannot set property query of #<IncomingMessage> which has only a getter; si migras código antiguo, es uno de los primeros errores que verás. Guardar en req.datosValidados no es solo una forma de esquivar la limitación: es mejor diseño, porque deja explícito en el controlador que los datos que usa han pasado por un esquema y conserva intacto lo que llegó del cliente por si el registro necesita compararlo.
- Coerción y normalización
Todo lo que llega por HTTP es texto. req.params y req.query son siempre cadenas —en GET /api/eventos?pagina=2, req.query.pagina es '2', no 2—; solo req.body con Content-Type: application/json conserva tipos, y solo si el cliente los envió bien. Por eso convertir es parte de validar, no un paso posterior:
// Coercion: acepta '2' y 2, devuelve siempre el numero 2.
pagina: z.coerce.number().int().min(1).default(1),
// Normalizacion: recorta espacios y baja a minusculas.
email: z.email().transform((valor) => valor.trim().toLowerCase()),
// Coercion de booleano desde la query, que nunca trae booleanos.
agotado: z.enum(['true', 'false']).transform((valor) => valor === 'true').optional(),| Operación | Qué hace | Ejemplo en Escena Viva |
|---|---|---|
| Coerción | Convierte el tipo | '2' → 2 en cantidad |
| Normalización | Unifica representaciones equivalentes | ' [email protected] ' → '[email protected]' |
| Valor por defecto y poda | Rellena lo ausente y descarta lo no declarado | canal ausente → 'web'; esAdministrador desaparece |
Sin bajar el correo a minúsculas, [email protected] y [email protected] son dos compradores distintos: los duplicados de datos maestros casi siempre nacen de una normalización que faltó.
Cuidado con la coerción excesiva.
z.coerce.number()acepta''como0ytruecomo1, porque usaNumber()por debajo. Si eso no te vale, sé explícito:z.string().regex(/^\d+$/).transform(Number). Coerción amable en la query (donde todo es texto por fuerza), coerción estricta en el cuerpo JSON (donde el cliente pudo enviar el tipo correcto).
- La respuesta de error útil
Mantenemos el formato fijado en el módulo 4 y le añadimos detalles:
{
"error": {
"codigo": "DATOS_INVALIDOS",
"mensaje": "Los datos enviados no son validos",
"estado": 400,
"detalles": [
{ "campo": "cantidad", "motivo": "Maximo 6 entradas", "tipo": "too_big" },
{ "campo": "email", "motivo": "El correo no es valido", "tipo": "invalid_format" }
],
"idPeticion": "9f2a1c48-3c7e-4a1b-9c62-1d0f8b4a77e1"
}
}Qué hace útil a esta respuesta: da todos los errores a la vez (un viaje en lugar de tres), el campo exacto con ruta completa en estructuras anidadas (entradas.0.cantidad), un motivo legible escrito por ti en el esquema, un tipo estable (too_big, invalid_type) que un cliente puede tratar por programa sin depender del idioma, y el identificador de petición de 06-04 para cruzarlo con los registros del servidor.
Y qué no debe aparecer nunca:
| No reveles | Por qué |
|---|---|
| La traza de la excepción | Delata rutas del sistema de ficheros y versiones de bibliotecas |
| El valor recibido tal cual | Puede contener contraseñas o datos personales, y acaba en los registros |
| Nombres de tablas, columnas, ficheros o consultas internas | Mapa gratis de tu infraestructura y ayuda para el siguiente intento |
| Si un correo existe o no en el sistema | Permite enumerar usuarios (crítico en el módulo 8) |
400 frente a 422
Ambos son legítimos y hay debate. Nuestra política, coherente con la tabla ESTADO_POR_CODIGO del módulo 4:
| Situación | Código de dominio | Estado |
|---|---|---|
| JSON malformado (ni siquiera se puede parsear) | JSON_INVALIDO |
400 |
| Falta un campo o tiene tipo incorrecto | DATOS_INVALIDOS |
400 |
| Formato correcto pero regla de entrada violada (más de 6 entradas) | LIMITE_POR_PEDIDO |
422 |
| Formato correcto pero el estado no lo permite (aforo insuficiente) | AFORO_INSUFICIENTE |
409 |
Lo importante no es cuál eliges, sino que sea coherente en toda la API y esté documentado.
- Usos menos obvios: respuestas y configuración
Los esquemas no son solo para lo que entra.
Validar la configuración de arranque
En 06-02 escribiste funciones a mano (obligatoria, entero, lista); un esquema hace lo mismo más corto y con mejores mensajes:
// src/config/esquema.js
const esquemaConfiguracion = z.object({
NODE_ENV: z.enum(['desarrollo', 'pruebas', 'produccion']).default('desarrollo'),
PUERTO: z.coerce.number().int().min(1).max(65535).default(3000),
LIMITE_CUERPO: z.string().regex(/^\d+(kb|mb)$/i).default('100kb'),
CONFIAR_EN_PROXY: z.coerce.number().int().min(0).max(10).default(0),
ORIGENES_PERMITIDOS: z.string().default('')
.transform((v) => v.split(',').map((o) => o.trim()).filter(Boolean)),
});El mismo principio de "fallar rápido", con un único sitio que describe todo lo que la aplicación necesita para arrancar.
Validar la respuesta de un servicio externo
src/servicios/cambio-divisas.js hace fetch a una API que no controlas: que hoy devuelva { rates: { USD: 1.08 } } no garantiza que mañana no devuelva { data: [...] } tras un cambio de versión.
// src/servicios/cambio-divisas.js (fragmento)
const esquemaRespuestaTipos = z.object({
base: z.literal('EUR'),
rates: z.record(z.string().length(3), z.number().positive()),
});
async function obtenerTipoCambio(moneda) {
const respuesta = await fetch(URL_TIPOS, { signal: AbortSignal.timeout(3000) });
const resultado = esquemaRespuestaTipos.safeParse(await respuesta.json());
if (!resultado.success) {
// Mejor fallar con un codigo claro que propagar undefined por todo el sistema.
const mensaje = 'La API de divisas devolvio un formato inesperado';
throw Object.assign(new Error(mensaje), { codigo: 'SERVICIO_EXTERNO_CAIDO' });
}
return resultado.data.rates[moneda];
}El coste es mínimo y evita el peor tipo de fallo: el que no explota donde ocurre, sino tres capas más abajo con un undefined incomprensible.
- Sanitización frente a validación
Se confunden constantemente, pero son cosas distintas:
| Validación | Sanitización | |
|---|---|---|
| Pregunta | ¿Es aceptable este dato? | ¿Cómo lo hago inofensivo en este contexto? |
| Momento y resultado | Al entrar; rechazo con 400 | Al salir hacia un destino; transformación del valor |
| Ejemplo | comentario de máximo 280 caracteres |
Escapar < y > al pintarlo en HTML |
La regla que evita la mayoría de los errores: valida al entrar, escapa al salir. ¿Por qué no escapar al guardar? Porque el escapado depende del destino y guardando escapado pierdes el dato original: si guardas <script> y lo envías por JSON a una app móvil, esa app muestra literalmente <script>; si el mismo comentario va a un HTML, a un CSV y a un correo, cada destino necesita un escapado distinto; y si cambias de sistema de plantillas, tus datos ya están contaminados con el escapado del anterior.
Guarda el dato tal como es (validado, normalizado) y escapa en el punto de renderizado. En Escena Viva, el publico/app.js que pinta comentarios debe usar textContent en lugar de innerHTML: ese es el escapado correcto para ese destino.
La mención honesta sobre inyección
Verás repetido que "validar previene la inyección SQL". Es falso como defensa principal: una lista negra de palabras (DROP, --, ;) se esquiva de mil maneras y rechaza comentarios legítimos de compradores llamados O'Brien. La protección real son las consultas parametrizadas, que separan el código de los datos para que el motor nunca interprete un valor como instrucción; eso llega en el módulo 7, con Mongoose y Sequelize.
Mientras tanto, en Escena Viva la persistencia es un JSON y el riesgo equivalente es distinto pero real:
- Recorrido de directorios si un identificador acaba formando parte de una ruta de fichero. Por eso
resolverDentroDesigue siendo obligatorio (módulo 3). - Contaminación de prototipo si haces
Object.assign({}, req.body)con una clave__proto__. zod te protege porquez.object()descarta lo no declarado. - Denegación de servicio con cuerpos enormes o regex catastróficas. Por eso
limitenexpress.json()y patrones acotados en los esquemas.
Validar reduce superficie, pero no sustituye a la defensa específica de cada destino.
Errores Comunes y Consejos
- Confiar en la validación del formulario o reasignar
req.query/req.params(TypeErroren Express 5): el HTML es cortesía, el servidor es la frontera real, y el resultado va areq.datosValidados. - Validar dentro del dominio.
Sesion.vender()no debe comprobar tipos: cuando llega ahí ya son correctos. Su trabajo es el aforo. - Devolver solo el primer error (obliga al cliente a un viaje por campo;
safeParselos da todos) o filtrar el error interno: nunca envíes trazas ni valores recibidos, regístralos en el servidor y devuelve lo justo. - Coerción indiscriminada (
z.coerce.number()convierte''en0ytrueen1) u olvidar.trim()(un' ses-001-1 'falla contra la regex con un mensaje desconcertante). - Duplicar los esquemas entre rutas. Extrae los fragmentos comunes (
idSesion,correoComprador) y compónlos. - Consejo: el esquema es documentación ejecutable. Cuando alguien pregunte qué acepta
POST /api/pedidos, la respuesta essrc/esquemas/pedido.js, y no puede estar desactualizada porque es el código que se ejecuta.
Ejercicios
Ejercicio 1: esquema de pedido con varias sesiones
Escena Viva quiere permitir pedidos con entradas para varias sesiones a la vez. Escribe esquemaCrearPedidoMultiple con email, canal y entradas, un array de 1 a 4 elementos con sesionId y cantidad. Añade dos reglas que un esquema de campo aislado no puede expresar: el total de entradas no puede pasar de 6, y no puede repetirse la misma sesionId. Pista: .superRefine() sobre el objeto completo.
Ejercicio 2: validar la query de eventos de punta a punta
Aplica validar(esquemaConsultaEventos, 'query') a GET /api/eventos y adapta el controlador para leer de req.datosValidados.query. Comprueba con curl que ?porPagina=999 y ?pagina=abc devuelven 400 con el campo y el motivo, que ?agotado=true llega como booleano y que sin query los valores por defecto son pagina: 1, porPagina: 10.
Ejercicio 3: el campo de más
Envía a POST /api/pedidos un cuerpo con un campo no declarado, por ejemplo {"sesionId":"ses-002-1","cantidad":2,"email":"[email protected]","precioCentimos":1}. Comprueba que el pedido se crea y que precioCentimos no llega al servicio. Después modifica el esquema con .strict() y comprueba que ahora devuelve 400 indicando la clave sobrante. Razona en qué casos preferirías cada comportamiento.
Soluciones
Solución 1
// src/esquemas/pedido.js (ampliacion)
const esquemaLineaPedido = z.object({ sesionId: idSesion, cantidad: cantidadEntradas });
const esquemaCrearPedidoMultiple = z
.object({
email: correoComprador,
canal: canalVenta.default('web'),
entradas: z.array(esquemaLineaPedido).min(1, 'Al menos una linea').max(4, 'Maximo 4 sesiones'),
})
.superRefine((pedido, contexto) => {
// Regla 1: total de entradas del pedido.
const total = pedido.entradas.reduce((suma, linea) => suma + linea.cantidad, 0);
if (total > LIMITE_ENTRADAS_POR_PEDIDO) {
contexto.addIssue({
code: 'custom', path: ['entradas'],
message: `El pedido suma ${total} entradas y el maximo es ${LIMITE_ENTRADAS_POR_PEDIDO}`,
});
}
// Regla 2: sin sesiones repetidas.
const vistas = new Set();
pedido.entradas.forEach((linea, i) => {
if (vistas.has(linea.sesionId)) {
contexto.addIssue({
code: 'custom', path: ['entradas', i, 'sesionId'],
message: `La sesion ${linea.sesionId} se repite; agrupa las cantidades`,
});
}
vistas.add(linea.sesionId);
});
});superRefine es el sitio de las reglas que cruzan campos. Ojo: siguen siendo reglas de forma, no de estado. "¿Hay aforo para estas 6 entradas?" sigue siendo del dominio, porque depende de las ventas del momento.
Solución 2
// src/controladores/eventos.js
async function listarEventos(peticion, respuesta) {
const { sala, agotado, pagina, porPagina } = peticion.datosValidados.query;
let eventos = await obtenerCatalogo();
if (sala) eventos = eventos.filter((e) => e.sala === sala);
if (agotado !== undefined) eventos = eventos.filter((e) => e.agotado === agotado);
const total = eventos.length;
const desde = (pagina - 1) * porPagina;
respuesta.json({
pagina, porPagina, total,
totalPaginas: Math.max(1, Math.ceil(total / porPagina)),
eventos: eventos.slice(desde, desde + porPagina).map((e) => e.toJSON()),
});
}# ?porPagina=999 y ?pagina=abc devuelven 400 DATOS_INVALIDOS con el campo y el motivo:
curl -s 'localhost:3000/api/eventos?porPagina=999' | head -c 200
# {"error":{"codigo":"DATOS_INVALIDOS",...,"detalles":[{"campo":"porPagina",...,"tipo":"too_big"}]}}
curl -s 'localhost:3000/api/eventos' | head -c 60 # sin query, valores por defecto
# {"pagina":1,"porPagina":10,"total":3,"totalPaginas":1,...Compara el controlador con la versión del ejercicio de 06-03: han desaparecido los parseInt, los ?? y los topes manuales. Esa lógica no se ha perdido, se ha mudado al esquema.
Solución 3
# Con z.object() normal: el campo de mas se descarta en silencio.
curl -s -X POST localhost:3000/api/pedidos -H 'Content-Type: application/json' \
-d '{"sesionId":"ses-002-1","cantidad":2,"email":"[email protected]","precioCentimos":1}'
# 201: el pedido se crea con el precio real de la sesion, nunca con el enviado.
# Con .strict() en el esquema, se rechaza con 400 DATOS_INVALIDOS y detalle
# {"campo":"precioCentimos","tipo":"unrecognized_keys"}.Cuándo preferir cada uno: podar (el comportamiento por defecto) encaja en una API pública con clientes variados, porque un cliente antiguo que envía campos obsoletos sigue funcionando; .strict() encaja en una API interna o con campos sensibles, porque un campo inesperado suele ser un error del cliente y conviene avisar pronto. Lo crucial en ambos casos: precioCentimos jamás debe salir del cuerpo del cliente; el precio lo pone el servidor leyendo la sesión. Si un campo del cuerpo puede alterar el dinero, tienes un problema mucho más grave que un esquema mal configurado.
Conclusión
La validación es la frontera de tu aplicación, y ahora Escena Viva la tiene bien puesta. Sabes por qué el formulario del navegador no cuenta, qué se valida (cuerpo, parámetros, query, cabeceras) y —lo más importante conceptualmente— dónde vive cada cosa: el borde garantiza la forma, el dominio garantiza las reglas que dependen del estado; el primero devuelve 400, el segundo 409. Has cambiado las cincuenta líneas imperativas del módulo 4 por esquemas declarativos de zod que son a la vez validación, conversión, normalización, poda de campos sobrantes y documentación ejecutable. El middleware validar(esquema, origen) se coloca en cualquier ruta, deja el resultado en req.datosValidados respetando que req.query sea de solo lectura en Express 5, y libera a los controladores de toda comprobación. Y has visto los usos menos obvios —validar la configuración de arranque y las respuestas de servicios externos— además de la distinción entre validar al entrar y escapar al salir, con la advertencia honesta de que la defensa real contra la inyección son las consultas parametrizadas del módulo 7.
Queda un detalle sin resolver, y es el que cierra el módulo: el middleware validar no responde, llama a siguiente(new ErrorDeValidacion(...)), y ese ErrorDeValidacion no existe todavía ni nadie lo está recogiendo. Lo mismo pasa con el EVENTO_NO_ENCONTRADO que propaga cargarEvento, con el AFORO_INSUFICIENTE que lanza el dominio, con el JSON malformado que rechaza express.json() y con el 404 de las rutas inexistentes.
En la siguiente lección, Manejo de Errores, construimos el destino de todos ellos: el middleware de error de cuatro argumentos, una jerarquía propia en src/errores.js, el sitio definitivo de la tabla ESTADO_POR_CODIGO que escribiste en el módulo 4, la distinción entre errores operativos y errores de programación, y la política de qué hacer cuando algo falla fuera del alcance de Express.
Curso de Node.js: De Principiante a Avanzado
Módulo 1: Introducción a Node.js
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
