Escena Viva ya es rápida y está medida. Lo que le queda no es un problema de rendimiento, sino de diseño. Su API creció por acumulación, módulo a módulo: primero un servidor artesanal en el M4, luego rutas de Express en el M6, luego autenticación en el M8. Por el camino aparecieron verbos en las URL, códigos de estado elegidos a ojo, listados sin paginación, ninguna política de versiones, y una duda que dejamos explícitamente abierta en el módulo 4: qué ocurre cuando Marc envía dos veces el mismo POST /api/pedidos porque perdió la cobertura en el estreno del Festival de Jazz. Esta lección convierte esa API en una API bien diseñada, con criterios justificados y no por gusto estético: una API es un contrato que otros programan encima y que no puedes romper cuando quieras.

Contenido

  1. Qué es REST de verdad
  2. El modelo de madurez de Richardson y HATEOAS
  3. Diseño de recursos y acciones que no son CRUD
  4. Métodos HTTP y sus garantías
  5. La clave de idempotencia
  6. Códigos de estado usados con criterio
  7. Formato de la respuesta y de los errores
  8. Paginación, filtrado, ordenación y selección de campos
  9. Versionado y política de deprecación
  10. Caché HTTP y peticiones condicionales
  11. Documentación con OpenAPI
  12. Rediseño de la API de Escena Viva

  1. Qué es REST de verdad

REST (Representational State Transfer) es un estilo arquitectónico descrito por Roy Fielding en su tesis doctoral del año 2000. No es un formato, no es JSON y no es "usar verbos HTTP": son seis restricciones.

Restricción Qué exige Cómo la cumple Escena Viva
Cliente-servidor Separación de responsabilidades e interfaces La API no sabe nada de la interfaz web
Sin estado (stateless) Cada petición contiene todo lo necesario JWT de acceso (M8); la sesión en Redis es un incumplimiento consciente
Cacheable Las respuestas se etiquetan como cacheables o no Cache-Control y ETag en las lecturas
Interfaz uniforme URI, representaciones, mensajes autodescriptivos, hipermedios Es la restricción que más nos falta
Sistema por capas El cliente no sabe si habla con el servidor final o un proxy Proxy inverso y caché entre medias (M11)
Código bajo demanda (opcional) El servidor puede enviar código ejecutable No se usa, y casi nadie la usa

La restricción sin estado es la que más consecuencias prácticas tiene y la que más se incumple. Es literalmente la que hace posible todo el módulo 10: si cada petición se basta a sí misma, cualquiera de los siete trabajadores de la lección 10-01 puede atenderla, y por eso el reparto SCHED_RR funciona. Cuando en la 10-03 movimos las sesiones a Redis, lo que hicimos fue externalizar el estado para que el servidor siguiera siendo intercambiable. Un servidor con estado en memoria no escala horizontalmente: es el mismo aprendizaje visto desde el otro lado.

  1. El modelo de madurez de Richardson y HATEOAS

Leonard Richardson propuso una escala de cuatro niveles para situarse con honestidad:

Nivel Qué caracteriza Ejemplo
0 / 1 Un solo endpoint como túnel; o recursos con URI pero todo por POST POST /api con { "accion": ... }; POST /eventos/evt-003/comprar
2 Métodos HTTP y códigos de estado con su semántica GET /eventos/evt-003, DELETE /pedidos/ped-77 → 204
3 HATEOAS: la respuesta incluye enlaces a las acciones posibles La respuesta del pedido trae los enlaces anular y entradas

Casi ninguna API llamada REST pasa del nivel 2, y conviene decirlo sin dramatismo: el nivel 2, bien hecho, es un diseño excelente y es el objetivo realista. HATEOAS (Hypermedia as the Engine of Application State) significa que el cliente descubre lo que puede hacer a partir de la propia respuesta, en lugar de tener las URL codificadas:

{
  "id": "ped-77", "estado": "pagado", "totalCentimos": 9000,
  "_enlaces": {
    "self":     { "href": "/api/v1/pedidos/ped-77" },
    "anular":   { "href": "/api/v1/pedidos/ped-77/anulacion", "metodo": "POST" }
  }
}

Su valor real es que los enlaces expresan el estado: si el pedido ya está anulado, el enlace anular no aparece, y el cliente no tiene que replicar las reglas de negocio para saber qué botón mostrar. El motivo por el que casi nadie llega al nivel 3 es que los clientes reales (una aplicación React, una app móvil) no navegan hipermedios: tienen las rutas escritas en el código y no ganan nada. Recomendación práctica para Escena Viva: nivel 2 sólido, con enlaces donde aportan (acciones condicionadas por el estado y navegación de páginas), sin fingir el nivel 3 con enlaces self que nadie usa ni renunciar a los enlaces cuando eliminan lógica duplicada en el cliente.

  1. Diseño de recursos y acciones que no son CRUD

Regla Sí No
Sustantivos en plural, no verbos /eventos/evt-003 /obtenerEvento/evt-003
Minúsculas y guiones /sesiones-agotadas /sesionesAgotadas
Sin extensión de formato ni barra final /eventos + Accept /eventos.json, /eventos/
Jerarquía solo si hay pertenencia real /eventos/evt-003/sesiones /salas/ribera/eventos/evt-003/sesiones/ses-003-1

Sobre la jerarquía, el criterio es la dependencia de existencia. Una sesión no existe sin su evento, así que /eventos/evt-003/sesiones es correcto para listarlas; pero una sesión concreta tiene identidad propia (ses-003-1) y también debe ser accesible en /sesiones/ses-003-1. La regla habitual: anidar como máximo un nivel para el listado dentro del padre, y exponer el recurso hijo en su propia raíz para el acceso directo, porque jerarquías de tres o más niveles obligan al cliente a conocer toda la cadena de identificadores para nada. Y ahora la pregunta difícil: ¿cómo se modela anular un pedido o publicar un evento? No son creaciones ni borrados, son transiciones de estado, y hay tres opciones legítimas más una que no lo es:

Opción Ejemplo Cuándo
Sub-recurso que representa la acción POST /pedidos/ped-77/anulacion La acción tiene datos propios o deja un registro consultable
PATCH sobre el campo de estado PATCH /eventos/evt-003 → { "estado": "publicado" } La transición es simple y sin efectos laterales
Colección de transiciones POST /pedidos/ped-77/transiciones → { "tipo": "anular" } Muchas transiciones sobre la misma entidad
~~Verbo en la URL~~ ~~POST /pedidos/ped-77/anular~~ Nunca: rompe la interfaz uniforme

En Escena Viva elegimos la primera para anular, y el motivo es concreto: la anulación es una entidad de negocio, con motivo, fecha, importe reembolsado y quién la solicitó, que administración querrá consultar. GET /api/v1/pedidos/ped-77/anulacion devuelve ese registro, y eso no sería posible con un PATCH. Para publicar un evento, en cambio, usamos PATCH, porque es un simple cambio de campo sin datos asociados.

  1. Métodos HTTP y sus garantías

Cada método tiene tres propiedades que el estándar define y que la infraestructura de internet da por buenas: proxies, navegadores y bibliotecas cliente reintentan métodos idempotentes automáticamente.

Método Seguro Idempotente Cacheable Uso en Escena Viva
GET / HEAD Sí Sí Sí Leer catálogo, evento, pedido; comprobar ETag
OPTIONS Sí Sí No CORS (M6)
POST No No Rara vez Crear pedido, anular, autenticarse
PUT / DELETE No Sí No Reemplazar un evento; borrar un borrador
PATCH No No por defecto No Modificación parcial

Las definiciones exactas importan. Seguro significa que no modifica el estado del servidor: un GET que borra algo es un fallo grave, porque cualquier rastreador o precargador del navegador lo activará. Idempotente significa que ejecutarlo N veces deja el sistema igual que ejecutarlo una vez: DELETE /eventos/evt-009 dos veces deja el evento borrado y la segunda devuelve 404, pero el estado es el mismo; idempotencia no significa "devuelve lo mismo". Y cacheable significa que la respuesta puede almacenarse y reutilizarse. En cuanto a PUT frente a PATCH, PUT reemplaza el recurso completo (lo que no envíes se borra) y PATCH modifica parcialmente; el problema es que PATCH no define su propio formato, así que hay que decir cuál se usa:

PATCH /api/v1/eventos/evt-003 HTTP/1.1
Content-Type: application/merge-patch+json

{ "titulo": "Festival de Jazz de Primavera 2026", "descripcionCorta": null }

application/merge-patch+json (RFC 7386) es el formato razonable: los campos presentes se asignan, y null significa "borra este campo". Su límite es que no puede poner un campo a null de verdad ni modificar un elemento concreto de un array; para eso está application/json-patch+json (RFC 6902), con operaciones explícitas, mucho más potente y mucho más incómodo. Recomendación: merge-patch salvo que necesites lo otro. Y una nota sobre PATCH e idempotencia: { "titulo": "X" } sí es idempotente en la práctica; lo que no lo son son las operaciones relativas como { "incrementarAforo": 10 }, que conviene evitar.

  1. La clave de idempotencia

Aquí resolvemos la duda del módulo 4. Escenario real: en el estreno del Festival de Jazz, Marc pulsa "Comprar 2 entradas para ses-003-1". La petición llega al servidor, se procesa, se crea el pedido ped-77... y la respuesta se pierde porque el móvil cambió de antena. El cliente reintenta. Se crean dos pedidos y Marc paga dos veces. Como POST no es idempotente por definición, la infraestructura no puede ayudarnos, y la solución estándar de la industria (Stripe, PayPal, y ahora un borrador del IETF) es una cabecera:

POST /api/v1/pedidos HTTP/1.1
Content-Type: application/json
Idempotency-Key: 8f14e45f-ea0c-4f2e-9b3d-2c1a7e5d0b91

{ "sesionId": "ses-003-1", "cantidad": 2 }

El cliente genera la clave (un UUID) antes del primer intento y la reutiliza en todos los reintentos de esa misma operación. El servidor la usa así:

'use strict';

const crypto = require('node:crypto');
const { obtenerClienteRedis } = require('../db/redis.js');
const { ErrorDeApi } = require('../errores.js');

const TTL = 24 * 3600;

// Middleware de idempotencia para operaciones POST no idempotentes.
const crearIdempotencia = ({ redis = obtenerClienteRedis() } = {}) =>
  async function idempotencia(peticion, respuesta, siguiente) {
    const clave = peticion.get('Idempotency-Key');
    if (!clave) return siguiente(); // opcional; se puede exigir en compras

    // La huella del cuerpo evita que la misma clave se reutilice para
    // una peticion distinta, que seria un error del cliente.
    const huella = crypto.createHash('sha256')
      .update(JSON.stringify(peticion.body || {})).digest('hex');
    const claveRedis = `idem:${peticion.usuario?.id || 'anonimo'}:${clave}`;
    const enCurso = JSON.stringify({ estado: 'en-curso', huella });

    // SET NX atomico: solo el primero en llegar reserva la operacion.
    if (!(await redis.set(claveRedis, enCurso, 'EX', TTL, 'NX'))) {
      const guardado = JSON.parse(await redis.get(claveRedis));
      if (guardado.huella !== huella) {
        return siguiente(new ErrorDeApi('CLAVE_IDEMPOTENCIA_REUTILIZADA', 422,
          [{ campo: 'Idempotency-Key', detalle: 'La clave ya se uso con otro cuerpo.' }]));
      }
      if (guardado.estado === 'en-curso') {
        // El primer intento sigue procesandose: 409 y que reintente.
        respuesta.set('Retry-After', '2');
        return siguiente(new ErrorDeApi('OPERACION_EN_CURSO', 409, []));
      }
      // Repetimos la respuesta original tal cual, sin volver a cobrar.
      respuesta.set('Idempotent-Replay', 'true');
      return respuesta.status(guardado.estado).json(guardado.cuerpo);
    }

    // Somos el primero. Interceptamos json() para guardar la respuesta.
    // Los 5xx no se memorizan: el reintento debe poder ejecutarse.
    const jsonOriginal = respuesta.json.bind(respuesta);
    respuesta.json = (cuerpo) => {
      const memoria = JSON.stringify({ estado: respuesta.statusCode, cuerpo, huella });
      (respuesta.statusCode < 500
        ? redis.set(claveRedis, memoria, 'EX', TTL)
        : redis.del(claveRedis)).catch(() => {});
      return jsonOriginal(cuerpo);
    };
    return siguiente();
  };

module.exports = { crearIdempotencia };

Cuatro decisiones que merecen justificarse. La huella del cuerpo impide que un cliente con un error de programación reutilice la clave para otra compra. El estado en-curso cubre la carrera de dos peticiones simultáneas. Los errores 5xx no se memorizan, porque un fallo transitorio debe poder reintentarse. Y el TTL de 24 horas acota el crecimiento sin dejar fuera reintentos razonables. Todo esto se combina con la idempotencia del consumidor de la lección 10-03: la clave protege la entrada a la API, y el jobId protege la ejecución del trabajo.

  1. Códigos de estado usados con criterio

Código Cuándo En Escena Viva
200 OK Lectura o modificación con cuerpo GET /eventos
201 Created Recurso creado; obligatoria la cabecera Location POST /pedidos
202 Accepted Aceptado para procesar más tarde POST /pedidos/ped-77/entradas (cola, 10-03)
204 No Content Éxito sin cuerpo DELETE /eventos/evt-009
207 Multi-Status Operación por lotes con resultados mixtos POST /pedidos/lote
304 Not Modified If-None-Match coincide Catálogo sin cambios (M4)
400 / 401 / 403 Mal formada / sin credenciales / sin permiso JSON roto; token caducado; Bóveda tocando evt-003
404 Not Found No existe, o no debe saberse que existe Pedido de otro usuario
409 Conflict Conflicto con el estado actual Aforo insuficiente; anular un pedido ya anulado
412 Precondition Failed If-Match no coincide Actualización perdida evitada
422 Unprocessable Content Sintaxis válida, semántica inválida cantidad: 0, fecha en el pasado
429 / 500 / 503 Límite superado; error interno; no disponible limiteCompra; fallo inesperado; base de datos caída

El antipatrón que hay que erradicar es responder 200 OK con { "error": ... } dentro. Rompe todo lo que hay entre el cliente y tú: los proxies cachean un error como si fuera una respuesta válida, las bibliotecas cliente no lanzan excepción, la monitorización cuenta 0 % de errores mientras el sistema arde, y los reintentos automáticos no se activan. El código de estado es parte del mensaje, no decoración. La distinción 400 / 422 es la que más dudas genera: 400 si no pude entender la petición (JSON roto, cabecera ausente); 422 si la entendí perfectamente pero no puedo aceptarla (cantidad: -3 es un número válido y una cantidad imposible), de modo que casi todos los errores de validación de zod (M6) son 422. El 409, por su parte, tiene un uso muy concreto: intentar comprar 5 entradas cuando quedan 3 no es un error de validación —5 es una cantidad perfectamente válida— sino un conflicto con el estado actual del recurso, y además un error que puede desaparecer si alguien anula su pedido, lo cual el cliente debe saber.

  1. Formato de la respuesta y de los errores

¿Envoltorio o no? Devolver el recurso directamente ({ "id": "evt-003", ... }) es limpio y es lo que hace la mayoría; un envoltorio ({ "datos": ..., "meta": ... }) permite añadir metadatos sin tocar el recurso. La incoherencia es lo único inaceptable. Decisión de Escena Viva: recurso directo en los detalles, envoltorio en las colecciones, porque una colección necesita metadatos de paginación por fuerza.

{
  "datos": [{ "id": "evt-003", "titulo": "Festival de Jazz de Primavera" }],
  "meta": { "total": 3, "limite": 20, "siguienteCursor": "ZXZ0LTAwMw==" },
  "enlaces": { "siguiente": "/api/v1/eventos?cursor=ZXZ0LTAwMw==&limite=20" }
}

Errores. El RFC 9457 (Problem Details for HTTP APIs, que sustituye al 7807) define un formato estándar, servido con Content-Type: application/problem+json:

{
  "type": "https://escenaviva.test/errores/aforo-insuficiente",
  "title": "Aforo insuficiente",
  "status": 409,
  "detail": "Solicitaste 5 entradas y quedan 3 para la sesion ses-003-1.",
  "instance": "/api/v1/pedidos",
  "disponibles": 3
}

Comparado con el formato propio de Escena Viva:

Aspecto Formato de Escena Viva RFC 9457
Forma { error: { codigo, mensaje, estado, detalles } } Objeto plano con type, title, status, detail
Identificador estable codigo (AFORO_INSUFICIENTE) type (una URI)
Errores de campo detalles: [{ campo, detalle }] Extensión propia (errors)
Herramientas Ninguna Reconocido por bibliotecas y pasarelas
Documentación Aparte El type es una URL con la explicación

La decisión honesta: mantenemos nuestro formato porque hay clientes que ya dependen de él y romperlo sería una ruptura de contrato mayor, pero añadimos Content-Type: application/problem+json con los alias type, title y status como campos adicionales, de modo que las herramientas estándar entiendan la respuesta y nuestros clientes sigan funcionando. Es una convergencia progresiva, no una migración de golpe; si empezaras hoy desde cero, usa RFC 9457 directamente. Y una regla de seguridad heredada del M8: el error nunca filtra detalles internos —nada de pilas de llamadas, nombres de tabla ni SQL—, sino que incluye el idPeticion (del middleware id-peticion.js) para que el usuario pueda citarlo en el soporte y tú correlacionarlo con el registro.

  1. Paginación, filtrado, ordenación y selección de campos

Para la paginación hay dos escuelas:

Aspecto Offset (?pagina=3&limite=20) Cursor (?cursor=ZXZ0...&limite=20)
Saltar a la página 47 / total Sí / fácil con COUNT No, solo secuencial / caro o inexistente
Coste en la base de datos OFFSET 10000 lee y descarta 10 000 filas Índice + WHERE id > cursor: constante
Datos que cambian Se saltan y se repiten elementos Estable
Comprensión del cliente Inmediata Requiere explicación

El argumento decisivo es el de los datos cambiantes: si Lucia mira la página 1 del catálogo ordenado por ventas, se venden entradas y luego pide la página 2, con offset verá elementos repetidos y se saltará otros, porque el orden ha cambiado bajo sus pies. Con cursor eso no ocurre, porque codifica una posición estable en el orden.

'use strict';

// El cursor codifica los campos de ordenacion, no un numero de fila.
const codificarCursor = ({ fechaInicio, id }) =>
  Buffer.from(JSON.stringify({ fechaInicio, id })).toString('base64url');

// Un cursor invalido se responde con 400 (devuelve undefined).
const decodificarCursor = (cursor) => {
  if (!cursor) return null;
  try { return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); }
  catch { return undefined; }
};

// El desempate por id es obligatorio: sin el, dos eventos con la misma
// fecha pueden aparecer dos veces o desaparecer entre paginas.
const construirClausula = (cursor) => (!cursor ? {} : {
  where: { [Op.or]: [
    { fechaInicio: { [Op.gt]: cursor.fechaInicio } },
    { fechaInicio: cursor.fechaInicio, id: { [Op.gt]: cursor.id } },
  ] },
});

module.exports = { codificarCursor, decodificarCursor, construirClausula };

Recomendación práctica: cursor para el catálogo público (grande, cambiante, navegación secuencial) y offset para el panel de administración (donde el organizador de la Sala Bóveda sí quiere saltar a la página 7 y ver el total). Y en ambos casos, límite máximo impuesto por el servidor: limite=100000 no puede ser una forma de tumbar la API. Con la misma coherencia se define la gramática de consulta, un solo criterio para toda la API:

Necesidad Convención Ejemplo
Filtro de igualdad campo=valor ?salaId=org-ribera
Filtro múltiple (OR) Lista separada por comas ?estado=publicado,agotado
Rango Sufijos _desde / _hasta ?fechaInicio_desde=2026-04-01
Búsqueda de texto q ?q=jazz
Ordenación orden, con - para descendente ?orden=-fechaInicio,titulo
Campos y relaciones campos, incluir ?campos=id,titulo&incluir=sesiones

Todo esto se valida con zod (M6), con lista blanca de campos ordenables y expandibles: sin ella, ?orden=columnaSecreta es una fuga de información y ?incluir=todo es una denegación de servicio. La selección de campos, además, es una optimización real, porque reduce el JSON serializado que la lección 10-04 identificó como un consumidor de CPU importante.

  1. Versionado y política de deprecación

Tres formas, con sus compromisos:

Estrategia Ejemplo A favor En contra
En la ruta /api/v1/eventos Visible, trivial de enrutar y cachear "Poco RESTful": el recurso no cambia de identidad al cambiar de versión
En la cabecera X-API-Version: 2 URL estables Invisible; exige Vary; se olvida al depurar
Por tipo de medio Accept: application/vnd.escenaviva.v2+json Lo más fiel a REST Verboso; mal soportado por herramientas y cachés

Recomendación: en la ruta. Es la que usan Stripe, GitHub y prácticamente todo el mundo, por una razón pragmática: es la única que un desarrollador entiende sin leer documentación, y la única que funciona bien con proxies y cachés. Y una advertencia: versionar es caro, porque cada versión viva es código que hay que mantener y probar. Cambia dentro de v1 siempre que el cambio sea aditivo (nuevos campos opcionales, nuevos endpoints) y reserva v2 para rupturas reales: quitar un campo, cambiar un tipo, cambiar el significado de algo. Cuando toque deprecar, hay cabeceras estándar:

HTTP/1.1 200 OK
Deprecation: Sun, 01 Nov 2026 00:00:00 GMT
Sunset: Sun, 01 May 2027 00:00:00 GMT
Link: <https://escenaviva.test/docs/migracion-v2>; rel="deprecation"
Warning: 299 - "GET /api/v1/eventos esta obsoleto. Migra a /api/v2/eventos antes del 2027-05-01."

Deprecation indica desde cuándo está obsoleto; Sunset (RFC 8594) cuándo dejará de funcionar. Un calendario razonable: anuncio, seis meses de convivencia con avisos, un "día de apagón" de prueba (unas horas de respuestas 410 para que los clientes rezagados se enteren), y retirada. Y algo que se olvida: mide el uso de cada versión por cliente, porque sin ese dato apagar v1 es un salto al vacío.

  1. Caché HTTP y peticiones condicionales

En el módulo 4 implementamos ETag y 304 a mano sobre node:http; ahora lo aplicamos con criterio y lo combinamos con la caché de Redis de la lección 10-03.

'use strict';

const crypto = require('node:crypto');

// El catalogo es publico e igual para todos: se puede cachear en
// proxies intermedios ademas de en el navegador.
const crearControladorCatalogo = ({ catalogo }) =>
  async function listarEventos(peticion, respuesta, siguiente) {
    try {
      const { datos } = await catalogo.obtenerCatalogo(peticion.datosValidados.query);
      const cuerpo = JSON.stringify(datos);
      const etiqueta = `"${crypto.createHash('sha1').update(cuerpo).digest('base64url')}"`;
      respuesta.set('ETag', etiqueta);
      // max-age: frescura en el cliente. s-maxage: en los proxies.
      // stale-while-revalidate: sirve copia caducada mientras refresca.
      respuesta.set('Cache-Control', 'public, max-age=30, s-maxage=60, stale-while-revalidate=120');
      // Sin cuerpo: cero bytes transferidos.
      if (peticion.get('If-None-Match') === etiqueta) return respuesta.status(304).end();
      return respuesta.type('application/json').send(cuerpo);
    } catch (error) {
      return siguiente(error);
    }
  };

module.exports = { crearControladorCatalogo };

Tenemos ahora tres capas de caché encadenadas: Cache-Control en el navegador (la petición ni siquiera sale), ETag/304 en el servidor (la petición sale pero no se transfiere el cuerpo) y Redis (10-03), que evita recalcular y consultar PostgreSQL. Regla de seguridad crítica: los datos privados llevan Cache-Control: private, no-store. Un public en la respuesta de GET /api/v1/pedidos/ped-77 permitiría que un proxy compartido guardara los pedidos de Lucia y se los sirviera a otro usuario. Y siempre Vary: Authorization en respuestas que dependen del usuario. Peticiones condicionales para evitar la actualización perdida. Escenario: dos organizadores del Auditorio Ribera editan evt-003 a la vez. Ambos leen la versión actual, ambos escriben; el segundo pisa los cambios del primero sin que nadie se entere. Es la actualización perdida (lost update), y HTTP la resuelve con control de concurrencia optimista:

GET /api/v1/eventos/evt-003        → 200 OK, ETag: "v7-a3f2c1"

PATCH /api/v1/eventos/evt-003
If-Match: "v7-a3f2c1"
Content-Type: application/merge-patch+json
{ "aforoTotal": 520 }
                                   → 412 si alguien ya lo cambio a "v8-..."

Un middleware crearExigirIfMatch({ obtenerEtiqueta }) lo implementa en tres comprobaciones: si falta la cabecera If-Match, responde 428 (Precondition Required), que le dice al cliente que su petición está bien formada pero que la API exige una condición; si el recurso no existe, 404; y si la etiqueta recibida no coincide con la actual (y no es *), lanza ErrorDeApi('CONFLICTO_DE_VERSION', 412).

  1. Documentación con OpenAPI

Una API sin documentación no se puede usar; una API con documentación escrita a mano miente, porque nadie la actualiza cuando cambia el código. La solución es que el contrato y el código compartan una única fuente, y en Escena Viva ya tenemos esa fuente: los esquemas zod de src/esquemas/ que validan cada petición. Con zod-to-openapi se convierten en el documento OpenAPI, con la garantía de que lo documentado es exactamente lo validado.

'use strict';

const { OpenApiGeneratorV31, extendZodWithOpenApi } = require('@asteasolutions/zod-to-openapi');
const { z } = require('zod');
const swaggerUi = require('swagger-ui-express');
const { registroDeEsquemas } = require('../esquemas/registro.js');

extendZodWithOpenApi(z);

const generarDocumento = () =>
  new OpenApiGeneratorV31(registroDeEsquemas.definitions).generateDocument({
    openapi: '3.1.0',
    info: { title: 'API de Escena Viva', version: '1.4.0' },
    servers: [{ url: 'https://api.escenaviva.test/api/v1' }],
  });

// Se sirve junto a la API: la documentacion viaja con el codigo.
function montarDocumentacion(aplicacion) {
  const documento = generarDocumento();
  aplicacion.get('/api/v1/openapi.json', (peticion, respuesta) => respuesta.json(documento));
  aplicacion.use('/api/v1/docs', swaggerUi.serve, swaggerUi.setup(documento));
}

module.exports = { generarDocumento, montarDocumentacion };

Así el contrato se vuelve verificable en lugar de una promesa: en las pruebas de integración del módulo 9 se puede validar cada respuesta contra el esquema OpenAPI, de modo que una respuesta que se desvía del contrato rompe la construcción. Esa es la diferencia entre documentación y contrato.

  1. Rediseño de la API de Escena Viva

Antes Después Decisión
GET /api/eventos GET /api/v1/eventos?cursor=&limite=20 Versión en la ruta; paginación por cursor obligatoria
GET /api/evento/:id GET /api/v1/eventos/:id Plural coherente en toda la API
GET /api/eventos/:id/getSesiones GET /api/v1/eventos/:id/sesiones Sin verbos; jerarquía por pertenencia real
— GET /api/v1/sesiones/:id La sesión tiene identidad propia: acceso directo
POST /api/comprar con 200 POST /api/v1/pedidos + Idempotency-Key → 201 + Location Recurso, no acción; el código expresa la creación; sin cobros duplicados
POST /api/pedidos/:id/anular POST /api/v1/pedidos/:id/anulacion La anulación es una entidad consultable
POST /api/pedidos/:id/generarPdf POST /api/v1/pedidos/:id/entradas → 202, y GET /api/v1/trabajos/:id Trabajo encolado (10-03) con recurso de estado
POST /api/eventos/:id/publicar PATCH /api/v1/eventos/:id (merge-patch) Transición simple sin datos propios
GET /api/eventos?todos=true GET /api/v1/eventos?estado=borrador,publicado Gramática de filtros coherente
200 con { error: ... } 4xx/5xx reales + application/problem+json El estado forma parte del mensaje
Sin ETag en el detalle ETag + If-None-Match + If-Match Ahorro de banda y control de concurrencia optimista
Sin documentación GET /api/v1/openapi.json y /api/v1/docs Contrato generado desde los esquemas zod

Errores Comunes y Consejos

  • Verbos en las URL (/obtenerEventos, /comprarEntradas): el verbo va en el método HTTP.
  • 200 con un error dentro. Rompe proxies, clientes, reintentos y monitorización.
  • GET que modifica, o listados sin límite máximo: en el primer caso un precargador puede borrar datos por ti, en el segundo ?limite=1000000 es una denegación de servicio con parámetros.
  • Confundir 401 con 403. 401 = no sé quién eres. 403 = sé quién eres y no puedes.
  • Cache-Control: public en datos privados. Un proxy compartido puede servir los pedidos de Lucia a otro usuario.
  • Versionar por costumbre, o escribir la documentación a mano: la primera es mantenimiento evitable, la segunda se desincroniza en semanas.
  • Consejo: en POST que cobran, exige Idempotency-Key y responde 400 si falta. Es más seguro que hacerla opcional.
  • Consejo: añade una prueba de integración por cada código de estado documentado. Si documentas un 409, demuéstralo.

Ejercicios

Ejercicio 1: idempotencia bajo reintento

Aplica el middleware de idempotencia a POST /api/v1/pedidos. Escribe una prueba de integración (M9) que envíe la misma petición dos veces con la misma Idempotency-Key y verifique: un solo pedido creado, misma respuesta y Idempotent-Replay: true en la segunda. Añade un caso con la misma clave y cuerpo distinto.

Ejercicio 2: paginación por cursor estable

Implementa GET /api/v1/eventos con cursor sobre (fechaInicio, id). Prueba: pide la página 1 con limite=2, inserta un evento nuevo con fecha anterior, pide la página 2 y comprueba que no se repite ni se pierde ningún elemento. Repite con offset y compara.

Ejercicio 3: evitar la actualización perdida

Añade ETag a GET /api/v1/eventos/:id y exige If-Match en PATCH. Simula dos organizadores editando evt-003: ambos leen, el primero escribe con éxito, el segundo escribe con la etiqueta antigua. Verifica el 412 y diseña la respuesta de error para que el cliente sepa qué hacer.

Soluciones

Ejercicio 1. La primera petición devuelve 201 con Location: /api/v1/pedidos/ped-77. La segunda devuelve exactamente el mismo cuerpo y el mismo 201, con Idempotent-Replay: true, y Pedido.count() sigue siendo 1. El punto sutil es que la respuesta repetida es 201, no 200: se repite la respuesta original tal cual, porque el cliente no debe distinguir un reintento de un primer intento. Con el mismo Idempotency-Key y un cuerpo distinto, la huella no coincide y se devuelve 422 con CLAVE_IDEMPOTENCIA_REUTILIZADA: es un error del cliente, no una compra nueva. Para la carrera de peticiones simultáneas, SET NX garantiza que solo una gana y la otra recibe 409 con Retry-After: 2.

Ejercicio 2. Con cursor, la página 2 continúa exactamente donde acabó la 1: el nuevo evento con fecha anterior no aparece (queda "detrás" del cursor) y ningún elemento se duplica. Con offset, el evento nuevo desplaza todo hacia delante y el último elemento de la página 1 reaparece como primero de la página 2. Es un fallo silencioso que en el catálogo del Festival de Jazz significaría mostrar la misma sesión dos veces y ocultar otra. Detalle imprescindible: el cursor debe incluir el desempate por id; con solo fechaInicio, dos eventos de la misma fecha provocan el mismo problema que querías evitar.

Ejercicio 3. El primer PATCH con If-Match: "v7-a3f2c1" coincide y devuelve 200 con un ETag nuevo. El segundo, con la etiqueta antigua, recibe 412. El error debe ser accionable:

{
  "error": {
    "codigo": "CONFLICTO_DE_VERSION",
    "mensaje": "El evento fue modificado por otro usuario desde tu ultima lectura.",
    "estado": 412,
    "detalles": [{ "campo": "If-Match", "detalle": "Vuelve a leerlo y reintenta." }]
  }
}

Sin If-Match, el segundo organizador habría pisado el cambio del primero: el aforo de evt-003 quedaría en el valor equivocado y nadie lo sabría hasta la noche del estreno. Nota de implementación: la etiqueta debe derivar de la versión del recurso (un campo version incrementado en cada escritura, o updatedAt), no del cuerpo serializado, porque el cuerpo puede variar por selección de campos o formato.

Conclusión

La API de Escena Viva ha pasado de "funciona" a "está bien diseñada", y cada decisión tiene su motivo. Sabemos qué exige REST de verdad —y que la restricción sin estado es exactamente lo que hace posible el escalado de la lección 10-01—, dónde nos sitúa el modelo de Richardson y por qué el nivel 2 sólido con enlaces selectivos es el objetivo realista. Modelamos recursos con sustantivos plurales y jerarquías justificadas, y las acciones que no son CRUD como sub-recursos con entidad propia. Conocemos las garantías de cada método HTTP, la diferencia entre PUT y PATCH con merge-patch, y hemos cerrado por fin la duda del módulo 4 con la clave de idempotencia, que impide que Marc pague dos veces cuando pierde la cobertura. Usamos los códigos de estado con criterio (201 con Location, 202 para el trabajo encolado, 409 para el aforo, 422 para la validación) y hemos desterrado el 200 con un error dentro. Convergemos hacia problem+json sin romper a nuestros clientes. Paginamos por cursor donde los datos cambian, con una gramática de consulta coherente y límites impuestos por el servidor. Versionamos en la ruta con una política de deprecación con fechas. Encadenamos tres capas de caché y evitamos la actualización perdida con If-Match y 412. Y generamos la documentación desde los esquemas zod, para que el contrato no pueda mentir. Con todo eso, queda una pregunta que ninguna de estas mejoras responde: la pantalla de un evento en la aplicación de Escena Viva sigue necesitando tres llamadas (el evento, sus sesiones, la sala), o una sola respuesta con muchos campos que ese cliente no usa. El diseño REST no elimina ese dilema, solo lo administra. En la lección siguiente, GraphQL con Node.js, veremos una alternativa donde el cliente pide exactamente lo que necesita, qué precio se paga por ello —caché, complejidad, límites de consulta— y por qué la respuesta correcta casi nunca es sustituir REST, sino hacer que convivan.

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