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

  1. Por qué nunca se confía en el cliente
  2. Qué se valida y dónde debe vivir la validación
  3. Validación a mano y por qué se vuelve inmantenible
  4. zod: el validador elegido
  5. Los esquemas de Escena Viva
  6. El middleware genérico validar(esquema, origen)
  7. Coerción y normalización
  8. La respuesta de error útil
  9. Usos menos obvios: respuestas y configuración
  10. Sanitización frente a validación

  1. 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:

  1. 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.
  2. El cliente se puede modificar. Las herramientas de desarrollo del navegador permiten cambiar el max de un input en dos segundos.
  3. 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.
  4. Los intermediarios alteran datos. Proxies, redirecciones, reintentos con cuerpo truncado.
  5. 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.

  1. 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.

  1. 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:

  1. Se detiene en el primer error. El cliente arregla sesionId, reenvía, y descubre que también fallaba cantidad: tres viajes para tres errores.
  2. No devuelve el resultado convertido. Validas datos.cantidad pero sigues usando el objeto original sin normalizar.
  3. Los campos de más pasan. Si el cliente envía { cantidad: 2, esAdministrador: true }, ese campo llega intacto al dominio.
  4. Es imposible de reutilizar. El mismo pedido validado desde una cola de mensajes o un script de importación necesita copiar y pegar.
  5. 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.

  1. 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.

  1. 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.

  1. El middleware genérico 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.

  1. 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 '' como 0 y true como 1, porque usa Number() 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).

  1. 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.

  1. 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.

  1. 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 &lt;script&gt; y lo envías por JSON a una app móvil, esa app muestra literalmente &lt;script&gt;; 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 resolverDentroDe sigue siendo obligatorio (módulo 3).
  • Contaminación de prototipo si haces Object.assign({}, req.body) con una clave __proto__. zod te protege porque z.object() descarta lo no declarado.
  • Denegación de servicio con cuerpos enormes o regex catastróficas. Por eso limit en express.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 (TypeError en Express 5): el HTML es cortesía, el servidor es la frontera real, y el resultado va a req.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; safeParse los 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 '' en 0 y true en 1) 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 es src/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

Módulo 2: Conceptos Básicos

Módulo 3: Sistema de Archivos y E/S

Módulo 4: HTTP y Servidores Web

Módulo 5: NPM y Gestión de Paquetes

Módulo 6: Framework Express.js

Módulo 7: Bases de Datos y ORMs

Módulo 8: Autenticación y Autorización

Módulo 9: Pruebas y Depuración

Módulo 10: Temas Avanzados

Módulo 11: Despliegue y DevOps

Módulo 12: Proyectos del Mundo Real

© Copyright 2026. Todos los derechos reservados