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
- Qué es REST de verdad
- El modelo de madurez de Richardson y HATEOAS
- Diseño de recursos y acciones que no son CRUD
- Métodos HTTP y sus garantías
- La clave de idempotencia
- Códigos de estado usados con criterio
- Formato de la respuesta y de los errores
- Paginación, filtrado, ordenación y selección de campos
- Versionado y política de deprecación
- Caché HTTP y peticiones condicionales
- Documentación con OpenAPI
- Rediseño de la API de Escena Viva
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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).
- 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.
- 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.
GETque modifica, o listados sin límite máximo: en el primer caso un precargador puede borrar datos por ti, en el segundo?limite=1000000es 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: publicen 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
POSTque cobran, exigeIdempotency-Keyy 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
- ¿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
