Parte VIII · Ampliaciones

25. Diseño de API: HTTP, REST, OpenAPI y contratos

Una API es lo único de tu sistema que otros programadores van a leer todos los días. El esquema de la base de datos lo cambias con una migración, la implementación de un servicio la reescribes una tarde, pero la forma de tus URL, tus códigos de estado y tus cuerpos JSON se convierte en un contrato que vive en el código de terceros, en aplicaciones móviles que no puedes actualizar y en integraciones que nadie recuerda haber montado. Diseñar mal ese contrato no produce un error visible el primer día: produce una deuda que se cobra durante años, en forma de clientes que reintentan un cobro dos veces, cachés que no se pueden invalidar y versiones que no se pueden retirar.

Este capítulo trata del diseño del contrato, no de la mecánica interna de NestJS. El capítulo 10 explicó cómo una petición atraviesa middleware, guards, interceptores, pipes y filtros; aquí damos por sabido ese recorrido y nos preguntamos otra cosa: dado un caso de uso de TaskFlow, ¿qué método, qué URL, qué código de estado, qué cabeceras y qué cuerpo son los correctos, y por qué? Empezamos por el protocolo que casi nadie estudia y todo el mundo usa, seguimos por semántica de métodos, códigos, colecciones, errores, caché, concurrencia e idempotencia, y terminamos convirtiendo todo eso en un documento OpenAPI que genera el cliente tipado de Angular y que rompe la construcción cuando alguien introduce un cambio incompatible.

CORE NEST Angular Tiempo de lectura: ~130 min Prerrequisitos: capítulos 9 y 10

25.1 Qué vas a poder hacer al terminar

Qué no vas a encontrar aquí La mecánica interna de NestJS. Cómo se ejecuta un guard, en qué orden corren los interceptores o por qué un pipe no ve el cuerpo antes que un guard es materia del capítulo 10, y darlo por sabido nos permite dedicar todo el espacio al diseño. Cuando en este capítulo aparezca un interceptor o un filtro, será para implementar una decisión de contrato ya tomada, no para explicar la pieza.

25.2 HTTP en profundidad, que casi nadie estudia y todo el mundo usa

HTTP es el protocolo sobre el que se apoya todo lo que escribes, y sin embargo la mayoría de los desarrolladores lo conoce por acumulación de anécdotas: que 404 es «no encontrado», que hay una cosa llamada CORS que da problemas y que las cabeceras se ponen en un objeto. Esa forma de saber es suficiente para copiar un ejemplo y radicalmente insuficiente para diseñar. Dedicar una hora a entender el protocolo con precisión cambia la calidad de todas las decisiones posteriores, porque casi todas las buenas prácticas de diseño de API no son convenciones arbitrarias: son consecuencias de cómo funciona HTTP.

La definición formal actual está en la RFC 9110, publicada en junio de 2022, que reemplazó y reorganizó la vieja familia RFC 7230–7235 (que a su vez había sustituido a la RFC 2616 de 1999). El cambio importante de esa reorganización es conceptual: la semántica de HTTP —métodos, códigos de estado, cabeceras, el significado de las cosas— se separó de la sintaxis de transporte, que es distinta en HTTP/1.1, HTTP/2 y HTTP/3. Dicho de otro modo: lo que significa un PUT o un 409 es idéntico en las tres versiones; lo que cambia entre versiones es cómo viajan los bytes por el cable.

25.2.1 Anatomía de una petición y de una respuesta

Aunque HTTP/2 y HTTP/3 ya no envían texto plano, el modelo mental sigue siendo el de HTTP/1.1, y todas las herramientas te lo muestran así. Una petición tiene cuatro partes: la línea de petición, las cabeceras, una línea en blanco y el cuerpo opcional. Verlo literalmente, con los bytes que viajan, es el ejercicio que más rápido despeja confusiones:

peticion-cruda.http
PATCH /api/v1/projects/9/tasks/42 HTTP/1.1          <- línea de petición: método, destino, versión
Host: api.taskflow.dev                              <- obligatoria desde HTTP/1.1: hosting virtual
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6…
Content-Type: application/merge-patch+json          <- qué formato tiene el cuerpo QUE ENVÍO
Accept: application/json                            <- qué formatos ACEPTO en la respuesta
Accept-Language: es-ES,es;q=0.9,en;q=0.7            <- q = factor de calidad, de 0 a 1
Accept-Encoding: br, gzip
If-Match: "v7-3f21ac"                               <- precondición: solo si sigue en esta versión
Idempotency-Key: 0f1b1a6c-3a2e-4f0b-9d84-2c1c9a0f77e1
Content-Length: 31
                                                    <- línea EN BLANCO: fin de cabeceras
{"estado":"HECHA","horas":3.5}                      <- cuerpo

La respuesta tiene la misma estructura, con una línea de estado en lugar de una línea de petición:

respuesta-cruda.http
HTTP/1.1 200 OK                                     <- versión, código, frase (decorativa)
Content-Type: application/json; charset=utf-8
Content-Length: 214
ETag: "v8-91cd02"                                   <- versión NUEVA del recurso
Cache-Control: private, no-cache                    <- se puede guardar, pero hay que revalidar
Vary: Accept, Accept-Language, Authorization        <- de qué cabeceras depende esta respuesta
X-Request-Id: 8f2c1d3e-77aa-4b19-9e0c-51a2d0b3e6f4  <- correlación con tus logs
Date: Fri, 31 Jul 2026 17:04:22 GMT

{"id":42,"titulo":"Revisar contrato","estado":"HECHA","horas":3.5,"version":8}
La frase de estado no significa nada El texto que acompaña al código («OK», «Not Found», «I'm a teapot») es puramente informativo y HTTP/2 y HTTP/3 lo eliminaron por completo del protocolo. Ningún cliente debe tomar decisiones leyéndolo, y ninguna API debe intentar comunicar información por ahí. Lo que se procesa es el número de tres dígitos.

Merece la pena fijarse en tres detalles que producen errores reales. Primero, Content-Type describe el cuerpo de ese mensaje: en la petición describe lo que envías, en la respuesta lo que recibes; no son la misma cabecera repetida. Segundo, Accept es una preferencia con factores de calidad, no una orden: el servidor puede responder con otro tipo o rechazar con 406. Y tercero, las cabeceras que empiezan por If- son precondiciones: le dices al servidor «ejecuta esto solo si se cumple X», y son la base de la caché condicional y del bloqueo optimista que veremos en 25.8 y 25.9.

Analogía Piensa en una petición como en un sobre enviado a una ventanilla. La línea de petición es el impreso que rellenas («quiero modificar el expediente 42»). Las cabeceras son las anotaciones del margen: quién eres, en qué idioma quieres la respuesta, y condiciones del tipo «tramita solo si el expediente sigue en la versión que yo tengo». El cuerpo es la documentación adjunta. Y el código de estado de la respuesta es el sello que el funcionario estampa: no es una conversación, es una clasificación normalizada que cualquier otra oficina entiende sin leer el texto.

25.2.2 HTTP/1.1, HTTP/2 y HTTP/3: qué cambia en la práctica

Las tres versiones comparten la misma semántica y difieren en el transporte. El problema que las tres generaciones intentan resolver es siempre el mismo: una página moderna necesita decenas o cientos de recursos, y abrir una conexión cuesta tiempo.

HTTP/1.1 (1997) introdujo las conexiones persistentes: en lugar de abrir y cerrar una conexión TCP por recurso, la conexión se reutiliza (Connection: keep-alive, que es el comportamiento por defecto). Pero mantuvo una limitación grave: en una conexión solo puede haber una petición en curso a la vez. Si la primera respuesta tarda, las siguientes esperan. Es el head-of-line blocking a nivel de aplicación. Los navegadores lo mitigaron abriendo unas seis conexiones por dominio, y de ahí nació el famoso domain sharding: repartir imágenes entre img1.midominio.com, img2… para multiplicar el número de conexiones paralelas.

HTTP/2 (2015, RFC 7540, hoy RFC 9113) cambió la sintaxis a un protocolo binario con multiplexación: sobre una única conexión TCP viajan muchos streams simultáneos, entrelazados en marcos. Añadió HPACK, una compresión de cabeceras con tabla dinámica que evita reenviar en cada petición las mismas cookies y el mismo User-Agent, y priorización de streams. La consecuencia práctica es directa y a menudo se ignora: el domain sharding pasó de optimización a penalización, porque cada dominio adicional obliga a una resolución DNS, un saludo TCP y un saludo TLS nuevos, y fragmenta la tabla de compresión. Lo mismo ocurre con la concatenación agresiva de ficheros y los sprites de imágenes: con multiplexación, muchos ficheros pequeños con caché granular suelen ganar.

HTTP/3 (2022, RFC 9114) sustituye TCP por QUIC sobre UDP. Resuelve el head-of-line blocking que quedaba: en HTTP/2 los streams son independientes en la capa de aplicación, pero comparten una sola conexión TCP, así que la pérdida de un paquete detiene todos los streams hasta la retransmisión. En QUIC cada stream se recupera por separado. Además, TLS 1.3 está integrado en el propio establecimiento de la conexión, lo que reduce el saludo a un solo viaje de ida y vuelta (o cero con reanudación), y la conexión sobrevive a un cambio de red gracias al identificador de conexión: el móvil pasa de wifi a datos sin que la descarga se corte.

AspectoHTTP/1.1HTTP/2HTTP/3
TransporteTCP, textoTCP, binario en marcosQUIC sobre UDP
Peticiones en paraleloUna por conexión (~6 conexiones por dominio)Multiplexadas en una conexiónMultiplexadas, sin bloqueo por pérdida
CabecerasTexto, repetidas íntegrasComprimidas con HPACKComprimidas con QPACK
CifradoOpcional (TLS aparte)En la práctica obligatorio en navegadoresIntegrado (TLS 1.3)
Coste de establecer conexiónTCP + TLS: 2-3 viajesTCP + TLS: 2-3 viajes1 viaje, o 0 al reanudar
Cambio de red (wifi a datos)Rompe la conexiónRompe la conexiónLa conexión sobrevive
Domain shardingAyudaPerjudicaPerjudica
Concatenar todo en un ficheroAyudaCasi nunca ayudaCasi nunca ayuda
Contexto histórico HTTP/2 nació de SPDY, un experimento de Google desplegado en Chrome y en sus servidores desde 2009; el grupo de trabajo del IETF lo tomó como base en 2012. HTTP/3 siguió el mismo camino con gQUIC. Es un patrón recurrente en la evolución de la web: un actor con suficiente cuota despliega una solución propietaria a gran escala, demuestra que funciona con datos reales y después se estandariza. También explica por qué ambos protocolos están tan orientados al caso «navegador cargando una página compleja» y aportan menos, comparativamente, a una API que devuelve un único JSON.
¿Qué significa esto para tu API? Menos de lo que parece y más de lo que crees. Menos, porque la semántica que diseñas es idéntica en las tres versiones y no debes escribir código distinto. Más, porque HTTP/2 hace barato lo que en HTTP/1.1 era caro: si tus clientes hablan HTTP/2, dejar de embutir treinta objetos anidados en una respuesta monolítica y ofrecer recursos pequeños y cacheables por separado deja de ser una decisión costosa. La regla operativa es simple: termina TLS y HTTP/2 en el proxy inverso (Nginx, Traefik, Caddy o el balanceador de tu proveedor) y deja que hable HTTP/1.1 con Node por la red interna. Node no gana nada sirviendo HTTP/2 directamente en la mayoría de despliegues, y sí complica la configuración.

25.2.3 Cómo viaja realmente una petición hasta tu controlador

Entre el this.http.patch(...) de un servicio de Angular y la primera línea de tu método de NestJS hay más infraestructura de la que se suele imaginar. Conocer esas etapas es lo que te permite diagnosticar un problema en producción cuando el síntoma es «a veces tarda dos segundos» o «solo falla desde el móvil»:

  NAVEGADOR (Angular)
  taskService.actualizar(42, {estado:'HECHA'})
       │  1 · HttpClient construye la petición y pasa por los interceptores
       │      (auth, reintentos, cabecera de correlación, indicador de carga)
       ▼
  ┌────────────────────────────────────────────────────────────────────────┐
  │ 2 · ¿ES UNA PETICIÓN DE OTRO ORIGEN?                                   │
  │     app.taskflow.dev  ->  api.taskflow.dev  =  SÍ                      │
  │     Si no es "simple", el navegador envía primero OPTIONS (preflight)  │
  │     y NO envía la petición real hasta recibir permiso. Ver 25.17.      │
  └────────────────────────────────────────────────────────────────────────┘
       ▼
  3 · RESOLUCIÓN DNS         api.taskflow.dev -> 203.0.113.10
      caché del navegador -> caché del sistema -> resolutor -> autoritativo
      coste típico: 0 ms si está en caché, 20-120 ms si no
       ▼
  4 · CONEXIÓN TCP (o QUIC)  saludo de 3 vías: SYN, SYN-ACK, ACK
      coste: 1 viaje de ida y vuelta (RTT). Madrid-Fráncfort ~ 30 ms
       ▼
  5 · SALUDO TLS             ClientHello / ServerHello, certificado, claves
      TLS 1.3: 1 RTT (0 con reanudación). TLS 1.2: 2 RTT
      aquí se negocia también el protocolo con ALPN: h2, http/1.1
       ▼
  ┌────────────────────────────────────────────────────────────────────────┐
  │ 6 · CDN / WAF (si existe)                                              │
  │     ¿Hay respuesta cacheada y fresca para esta clave?                  │
  │        SÍ  -> devuelve sin tocar tu servidor (coste: ~10 ms)           │
  │        NO  -> sigue hacia el origen, y quizá guarde la respuesta       │
  │     También: reglas de filtrado, límite de peticiones, bloqueo de IP   │
  └────────────────────────────────────────────────────────────────────────┘
       ▼
  7 · BALANCEADOR DE CARGA   elige una instancia sana
      comprueba /health periódicamente; retira instancias que fallan
      añade X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host
       ▼
  8 · PROXY INVERSO          Nginx / Traefik / Caddy
      termina TLS, aplica gzip o brotli, límites de tamaño y de tasa,
      cabeceras de seguridad, y habla HTTP/1.1 con la aplicación
       ▼
  9 · NODE.JS                el bucle de eventos acepta el socket
      http.Server emite 'request'; Express o Fastify enruta
       ▼
 10 · NESTJS                 middleware, guards, interceptores, pipes  (cap. 10)
       ▼
 11 · TU CONTROLADOR         @Patch(':id') actualizar(@Param() … )
       ▼
 12 · SERVICIO -> MIKROORM -> POSTGRESQL
      y la respuesta deshace todo el camino en sentido inverso

De este recorrido se extraen cuatro lecciones de diseño que van a reaparecer en todo el capítulo. La primera es que los pasos 3, 4 y 5 se pagan una vez por conexión, no por petición: por eso las conexiones persistentes importan tanto, y por eso una API que obliga al cliente a hacer diez llamadas para pintar una pantalla es cara aunque cada llamada sea rápida. La segunda es que hay al menos dos cachés antes de tu código (la del navegador y la del CDN), y si no emites cabeceras de caché correctas las estás desaprovechando por completo; esto es 25.8. La tercera es que tu aplicación no ve la IP real del cliente, sino la del último salto, salvo que confíes explícitamente en las cabeceras X-Forwarded-*, lo cual solo debes hacer si controlas el proxy. Y la cuarta es que el navegador puede negarse a enviar tu petición antes de que exista, por CORS, que es un mecanismo del navegador y no de tu servidor.

apps/api/src/main.ts
// Confiar en el proxy no es opcional cuando hay uno delante: sin esto,
// req.ip devuelve la IP del proxy y todo tu límite de peticiones por IP
// se aplica, en realidad, a una sola IP. Y req.protocol dice "http",
// con lo que las redirecciones y las cookies "secure" se rompen.
const app = await NestFactory.create<NestExpressApplication>(AppModule);

// Número de saltos de confianza, NO "true": confiar en todos permite
// a cualquier cliente falsificar su IP con una cabecera X-Forwarded-For.
app.set('trust proxy', 1);

// Límite de tamaño del cuerpo: la primera línea de defensa contra
// peticiones diseñadas para agotar la memoria del proceso (ver 25.17).
app.use(json({ limit: '256kb' }));

await app.listen(3000);
Error clásico de producción app.set('trust proxy', true) combinado con un límite de peticiones por IP es una puerta abierta: cualquiera puede enviar X-Forwarded-For: 1.2.3.4 con un valor distinto en cada petición y saltarse el límite por completo, además de envenenar tus logs de auditoría. Configura el número exacto de proxies de confianza que tienes delante, o la subred concreta. Es un error que no da ningún síntoma hasta el día en que alguien lo aprovecha.

25.2.4 Conexiones persistentes y por qué te afectan como diseñador

Una conexión persistente es simplemente una conexión TCP que no se cierra al terminar una petición. Suena a detalle de infraestructura, pero tiene dos consecuencias que sí son tuyas. La primera es el agrupamiento de conexiones salientes: cuando tu API de NestJS llama a otro servicio, si no reutilizas conexiones estás pagando DNS, TCP y TLS en cada llamada. En Node, esto se controla con un Agent con keepAlive, y desde Node 19 fetch ya lo hace por defecto con undici.

La segunda es el tiempo de inactividad. Los balanceadores cierran conexiones ociosas pasado un umbral; si el de tu proveedor cierra a los 60 segundos y tu servidor Node cierra a los 5 (el valor por defecto histórico de Node era 5 segundos, hoy son 5 en keepAliveTimeout y 60 en headersTimeout), aparecen errores 502 intermitentes e inexplicables: el balanceador reutiliza una conexión que el servidor acaba de cerrar. La regla es que el tiempo de inactividad del servidor debe ser mayor que el del balanceador.

integraciones/cliente.tsINCORRECTO
// Un agente nuevo por llamada: DNS + TCP + TLS
// en CADA petición. Con 200 llamadas por minuto
// a un servicio externo, esto son 200 saludos
// TLS por minuto y una latencia añadida de
// 100-200 ms que nadie sabe de dónde sale.
async function notificar(evento: Evento) {
  const agente = new https.Agent();          // nuevo cada vez
  return axios.post(URL, evento, {
    httpsAgent: agente,
  });
}
integraciones/cliente.tsCORRECTO
// Un único agente compartido, con conexiones
// reutilizadas. El primer envío paga el saludo;
// los siguientes reutilizan el socket abierto.
const agente = new https.Agent({
  keepAlive: true,
  maxSockets: 50,        // techo por destino
  maxFreeSockets: 10,    // ociosas que se guardan
  timeout: 30_000,
});

async function notificar(evento: Evento) {
  return axios.post(URL, evento, { httpsAgent: agente });
}
Cómo comprobarlo tú mismo curl -v --http2 https://api.taskflow.dev/health muestra la versión negociada por ALPN y el detalle del saludo TLS. Para medir el reparto del tiempo, curl -w "dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" -o /dev/null -s URL te dice exactamente cuánto se va en cada etapa del diagrama anterior. Es la primera herramienta que hay que sacar cuando alguien dice «la API va lenta»: muchas veces el tiempo no está en tu código.

25.3 Métodos y su semántica exacta

El método HTTP no es una etiqueta decorativa: es una promesa sobre el comportamiento de la operación, y hay infraestructura real que actúa según esa promesa. Los navegadores precargan enlaces con GET, los proxies cachean GET, las bibliotecas de cliente reintentan automáticamente los métodos idempotentes, los rastreadores siguen enlaces suponiendo que no cambian nada y los balanceadores reenvían a otra instancia cuando una petición idempotente falla. Si tu GET /tareas/42/borrar borra una tarea, no estás cometiendo un error de estilo: estás mintiendo a toda esa infraestructura, y tarde o temprano alguien pagará el precio.

25.3.1 Seguro, idempotente y cacheable

Son tres propiedades distintas que se confunden constantemente. Conviene definirlas con la precisión de la RFC 9110:

Idempotente no significa «devuelve lo mismo» Es el malentendido número uno en entrevistas. DELETE /tareas/42 ejecutado dos veces devuelve 204 y después 404: respuestas distintas, pero estado final idéntico (la tarea no existe). Es idempotente. En cambio POST /tareas ejecutado dos veces crea dos tareas distintas: estados finales distintos. No es idempotente, y por eso necesita claves de idempotencia (25.10). Y PATCH con el cuerpo {"horas": 3} es idempotente en la práctica, mientras que {"op":"incrementar","valor":1} no lo es: la idempotencia de PATCH depende de lo que escribas dentro, y por eso la especificación no lo declara idempotente.
MétodoSeguroIdempotenteCacheableCuerpo en la peticiónUso en TaskFlow
GETNo (ignorado)GET /projects/9/tasks?estado=ABIERTA
HEADNoComprobar existencia y ETag sin descargar el informe
POSTNoNoEn la práctica noPOST /projects/9/tasks crea una tarea
PUTNoNoPUT /tasks/42/assignee fija el responsable
PATCHNoDepende del cuerpoNoPATCH /tasks/42 cambia solo el estado
DELETENoNoDesaconsejadoDELETE /tasks/42
OPTIONSNoNoComprobación previa de CORS (la envía el navegador)

Con estas propiedades en la mano, casi todas las dudas de diseño se resuelven con dos preguntas, en este orden:

  ¿QUÉ MÉTODO USO?

  Pregunta 1 · ¿La operación cambia el estado del servidor?
      NO  ->  GET      (o HEAD si solo quieres las cabeceras)
              y ya has terminado: nunca uses POST para leer.
      SÍ  ->  sigue.

  Pregunta 2 · ¿Repetirla N veces deja el mismo estado que hacerla 1 vez?
      NO  ->  POST     y protégelo con Idempotency-Key si el cliente
                       puede reintentar (pagos, envíos, creación).
      SÍ  ->  ¿estás enviando el recurso ENTERO?
                  SÍ -> PUT      (reemplazo total; ausente = borrado)
                  NO -> PATCH    (modificación parcial)
              ¿la operación es "que deje de existir"?
                  SÍ -> DELETE

  Caso especial · una acción que no encaja en CRUD ("archivar")
      -> conviértela en un recurso y usa POST sobre él.  Ver 25.5.4.

25.3.2 PUT frente a PATCH, y el cuerpo de un PATCH

PUT significa «que el estado de este recurso pase a ser exactamente esto». Es un reemplazo total: los campos que no envíes se consideran ausentes, no «sin cambios». PATCH significa «aplica estas modificaciones al recurso». La diferencia no es cosmética, tiene consecuencias directas sobre los datos:

PATCH mal entendidoINCORRECTO
# El cliente quiere cambiar solo el estado y
# usa PUT enviando únicamente ese campo.
PUT /api/v1/tasks/42
Content-Type: application/json

{"estado": "HECHA"}

# Interpretación correcta de PUT: el recurso
# pasa a ser SOLO eso. titulo, descripcion,
# horas, responsable y etiquetas quedan
# borrados. Si el servidor "es amable" y los
# conserva, está implementando un PATCH bajo
# el nombre PUT, y ahora nadie sabe qué hace
# realmente el endpoint.
PATCH bien entendidoCORRECTO
# Modificación parcial explícita, con el tipo
# de medio que dice cómo interpretarla.
PATCH /api/v1/tasks/42
Content-Type: application/merge-patch+json
If-Match: "v7-3f21ac"

{"estado": "HECHA"}

# Y si de verdad quieres reemplazo total,
# PUT con el recurso COMPLETO:
PUT /api/v1/tasks/42
Content-Type: application/json

{"titulo":"Revisar contrato","descripcion":"…",
 "estado":"HECHA","horas":3.5,"responsableId":7}

Ahora la pregunta que casi nadie se hace: ¿qué formato tiene el cuerpo de un PATCH? La especificación de PATCH (RFC 5789) no define ninguno; solo dice que el cuerpo es un «conjunto de instrucciones de modificación» y que el Content-Type debe indicar cuál es. Hay dos estándares reales:

JSON Merge Patch · RFC 7386
// Content-Type: application/merge-patch+json
// Un objeto con la MISMA forma que el recurso.
// Los campos presentes se sustituyen.
// null significa BORRAR el campo.
// Los campos ausentes no se tocan.
{
  "estado": "HECHA",
  "horas": 3.5,
  "responsableId": null
}

// Limitación importante: como null significa
// "borrar", NO puedes asignar el valor null a
// un campo. Y no puedes modificar un elemento
// concreto de un array: hay que enviarlo entero.
JSON Patch · RFC 6902
// Content-Type: application/json-patch+json
// Un ARRAY de operaciones aplicadas en orden,
// con rutas JSON Pointer (RFC 6901).
[
  { "op": "replace", "path": "/estado",
    "value": "HECHA" },
  { "op": "add", "path": "/etiquetas/-",
    "value": "urgente" },
  { "op": "remove", "path": "/responsableId" },
  { "op": "test", "path": "/version",
    "value": 7 }
]

// Más expresivo: opera sobre elementos concretos
// de un array y "test" permite precondiciones.
// Más complejo de validar y de documentar.
Recomendación práctica Para una API de negocio como TaskFlow, usa JSON Merge Patch: es intuitivo, se documenta bien en OpenAPI y encaja de forma natural con un DTO derivado con PartialType. Reserva JSON Patch para casos donde de verdad necesites operar sobre posiciones de listas o aplicar operaciones condicionales, como un editor colaborativo. Sea cual sea tu elección, documéntala y publica el tipo de medio: un PATCH con Content-Type: application/json es ambiguo, y la ambigüedad en un contrato siempre acaba en un error de integración.
apps/api/src/tasks/dto/patch-task.dto.ts
import { PartialType } from '@nestjs/swagger';
import { IsIn, IsInt, IsOptional, Min, ValidateIf } from 'class-validator';

// El truco del Merge Patch en TypeScript: hay que distinguir
// "campo ausente" (no tocar) de "campo con null" (borrar). Como
// undefined y null son cosas distintas, el DTO debe permitir null
// explícitamente en los campos que sean anulables, y el servicio
// debe recorrer las CLAVES PRESENTES, no los valores definidos.
export class PatchTaskDto extends PartialType(CreateTaskDto) {
  @IsOptional()
  @ValidateIf((_, valor) => valor !== null)   // null es válido: borra
  @IsInt()
  @Min(1)
  responsableId?: number | null;

  @IsOptional()
  @IsIn(['ABIERTA', 'EN_CURSO', 'HECHA', 'CANCELADA'])
  estado?: EstadoTarea;
}
apps/api/src/tasks/tasks.service.ts
async aplicarMergePatch(id: number, parche: PatchTaskDto): Promise<Task> {
  const tarea = await this.em.findOneOrFail(Task, id);

  // Recorremos las claves PRESENTES en el cuerpo recibido. Usar
  // Object.entries del DTO ya validado evita el fallo clásico de
  // hacer `tarea.horas = parche.horas ?? tarea.horas`, que hace
  // IMPOSIBLE borrar un campo poniéndolo a null.
  for (const [clave, valor] of Object.entries(parche)) {
    if (valor === null && !CAMPOS_ANULABLES.has(clave)) {
      throw new UnprocessableEntityException(
        `El campo ${clave} no admite null`);
    }
    (tarea as Record<string, unknown>)[clave] = valor;
  }

  await this.em.flush();   // el Unit of Work calcula el UPDATE mínimo
  return tarea;
}
El fallo silencioso de whitelist con Merge Patch Si tu ValidationPipe tiene whitelist: true (y debe tenerlo, ver capítulo 10) y un campo del DTO no lleva ningún decorador de validación, ese campo desaparece del cuerpo sin aviso. El síntoma es desconcertante: el cliente envía {"horas": 3}, el servidor responde 200 y el valor no cambia. Activa siempre forbidNonWhitelisted: true para que el fallo sea ruidoso, y escribe un test por cada campo parcheable.

25.3.3 Los métodos que se olvidan: HEAD y OPTIONS

HEAD devuelve exactamente las mismas cabeceras que un GET pero sin cuerpo. Sirve para comprobar si un recurso existe, conocer su tamaño (Content-Length) o leer su ETag antes de decidir si merece la pena descargarlo. Es especialmente útil con ficheros grandes: en TaskFlow, un cliente que quiera saber si el informe mensual en PDF ha cambiado puede hacer HEAD /reports/2026-07.pdf y comparar el ETag sin transferir cinco megabytes. En NestJS lo tienes gratis: Express responde automáticamente a HEAD ejecutando el manejador de GET y descartando el cuerpo, aunque conviene comprobarlo porque un interceptor que escriba en la respuesta puede estropearlo.

OPTIONS lo envía el navegador solo, como comprobación previa de CORS, y normalmente lo responde el middleware de CORS sin que tú escribas nada. Su otro uso —preguntar qué métodos admite un recurso y recibir la cabecera Allow— es raro en la práctica y no conviene invertir esfuerzo en él.

25.4 Códigos de estado usados con criterio

El código de estado es la parte de la respuesta que las máquinas leen primero, y a menudo la única que leen. Un cliente HTTP decide con él si reintentar, si redirigir, si invalidar su caché, si pedir credenciales o si registrar una alerta. Elegirlo mal no es un detalle estético: es romper el comportamiento automático de bibliotecas, proxies y monitorización.

Las cinco familias tienen un significado que conviene recordar como una frase: 1xx «sigo trabajando», 2xx «hecho», 3xx «mira en otro sitio», 4xx «el problema está en tu petición, no la repitas igual» y 5xx «el problema es mío, quizá funcione si lo intentas de nuevo». Esa última distinción es la más importante de todo el capítulo desde el punto de vista operativo: los 4xx no deben disparar alertas y los 5xx sí, porque un 4xx es el sistema funcionando (rechazando algo que debía rechazar) y un 5xx es el sistema fallando.

25.4.1 Tabla de referencia con el caso de TaskFlow

CódigoNombreCuándo exactamenteCaso concreto en TaskFlow
200OKÉxito con cuerpo. Lectura, o escritura que devuelve el recurso actualizadoGET /tasks/42; PATCH /tasks/42 devolviendo la tarea con su nueva versión
201CreatedSe ha creado un recurso nuevo. Obligatorio devolver Location con su URLPOST /projects/9/tasksLocation: /api/v1/tasks/42
202AcceptedAceptado para procesar más tarde. El resultado aún no existePOST /projects/9/exports encola la generación del informe
204No ContentÉxito sin nada que devolver. El cuerpo debe estar vacíoDELETE /tasks/42; PUT /tasks/42/read-state
206Partial ContentRespuesta a un Range: descargas reanudables, vídeoDescarga parcial de un adjunto grande
301Moved PermanentlyLa URL cambió para siempre. Los clientes deben actualizarlaMigración de /api/tareas a /api/v1/tasks
303See Other«El resultado está en otro sitio, ve con GET»Un trabajo asíncrono terminado apunta al recurso producido
304Not ModifiedRespuesta a una petición condicional: tu copia sirve. Sin cuerpoGET /tasks/42 con If-None-Match coincidente
400Bad RequestLa petición está malformada: JSON inválido, tipo imposible, parámetro obligatorio ausenteCuerpo que no es JSON; ?limit=abc
401UnauthorizedNo sé quién eres: falta el token, ha caducado o es inválido. Debe incluir WWW-AuthenticatePetición sin Authorization o con JWT expirado
403ForbiddenSé quién eres y no puedes. Reintentar con las mismas credenciales no serviráUn miembro intenta borrar un proyecto que solo puede borrar el propietario
404Not FoundEl recurso no existe… o no quieres revelar que existeGET /tasks/99999; también una tarea de otra organización
405Method Not AllowedLa ruta existe pero no admite ese método. Debe incluir AllowDELETE /projects/9/stats
406Not AcceptableNo puedes producir ninguno de los tipos del AcceptAccept: application/xml en una API solo JSON
409ConflictChoque con el estado actual: duplicado, transición inválida, edición concurrenteDos usuarios editan la misma tarea; correo ya registrado
410GoneExistió y se eliminó deliberadamente y para siempreVersión v1 retirada; proyecto purgado tras el periodo de retención
412Precondition FailedUna cabecera If-* no se cumpleIf-Match con un ETag antiguo: bloqueo optimista (25.9)
413Content Too LargeEl cuerpo supera el límite configuradoAdjunto de 200 MB con un límite de 25 MB
415Unsupported Media TypeEl Content-Type enviado no se admitePATCH con text/plain
422Unprocessable ContentSintaxis correcta, semántica inválida: el JSON se entiende pero viola una reglafechaFin anterior a fechaInicio; transición CANCELADA → HECHA
428Precondition RequiredExiges una precondición y el cliente no la envióPATCH sin If-Match en un recurso con edición concurrente
429Too Many RequestsSe superó el límite de peticiones. Debe incluir Retry-AfterUn cliente que sondea el estado de un informe cada 100 ms
500Internal Server ErrorFallo no previsto en tu código. Siempre es un error tuyoExcepción no capturada; consulta SQL malformada
502Bad GatewayUn servicio del que dependes respondió algo inválidoLa pasarela de correo devuelve HTML de error
503Service UnavailableNo disponible temporalmente: mantenimiento, sobrecarga. Admite Retry-AfterDespliegue en curso; pool de conexiones agotado
504Gateway TimeoutUna dependencia no respondió a tiempoLa base de datos tarda más que el timeout del proxy

25.4.2 Los que casi todo el mundo usa mal

200 con un error dentro. Es la peor práctica de este capítulo y, con diferencia, la más extendida. Consiste en responder siempre 200 y meter el resultado real en el cuerpo: {"ok": false, "error": "…"}. Rompe absolutamente todo lo que hay entre el cliente y tú: la monitorización cuenta cero errores mientras los usuarios se quejan, las bibliotecas de cliente no lanzan excepción, la lógica de reintento no se activa, los proxies pueden cachear la respuesta de error, y en Angular el error entra por el camino de éxito del Observable, con lo que cada consumidor debe recordar comprobar un campo que el tipo no le obliga a comprobar.

tasks.controller.tsINCORRECTO
@Get(':id')
async buscar(@Param('id') id: string) {
  const tarea = await this.tasks.buscar(+id);
  if (!tarea) {
    // 200 OK con un error dentro. El cliente,
    // los proxies y el panel de monitorización
    // creen que todo ha ido bien.
    return { ok: false, error: 'No encontrada' };
  }
  return { ok: true, data: tarea };
}
// Y en Angular, esto obliga a escribir en CADA
// consumidor:  if (!r.ok) { ... }
// Un olvido = un undefined en la plantilla.
tasks.controller.tsCORRECTO
@Get(':id')
async buscar(@Param('id', ParseIntPipe) id: number) {
  // El servicio lanza un error de DOMINIO y el
  // filtro global lo traduce a 404 con Problem
  // Details. El controlador no sabe de HTTP más
  // de lo imprescindible.
  return this.tasks.obtenerOFallar(id);
}

// El código de estado ES el canal de error:
//   200 -> hay tarea, y el tipo lo garantiza
//   404 -> no hay, y el cliente HTTP lanza
// Angular lo recibe por el camino de error del
// Observable, sin comprobaciones repetidas.

201 sin Location. Un 201 dice «he creado algo»; la pregunta inmediata del cliente es «¿dónde está?». La cabecera Location con la URL canónica del recurso nuevo es parte del contrato, y es lo que permite que un cliente genérico siga trabajando sin conocer tu convención de URL. Devolver además el recurso en el cuerpo es buena idea: ahorra una petición.

apps/api/src/tasks/tasks.controller.ts
@Post()
@HttpCode(HttpStatus.CREATED)                      // 201, no el 201 por defecto de Nest sin más
@ApiCreatedResponse({ type: TaskDto, headers: {
  Location: { description: 'URL canónica de la tarea creada', schema: { type: 'string' } },
} })
async crear(
  @Param('projectId', ParseIntPipe) projectId: number,
  @Body() dto: CreateTaskDto,
  @Res({ passthrough: true }) res: Response,       // passthrough: Nest sigue serializando
): Promise<TaskDto> {
  const tarea = await this.tasks.crear(projectId, dto);

  // Location con ruta absoluta desde la raíz. Evita construirla a mano
  // concatenando cadenas: si mañana cambia el prefijo global, esto se
  // rompe en silencio. Un helper centralizado es preferible.
  res.setHeader('Location', this.urls.tarea(tarea.id));

  return TaskDto.desde(tarea);                     // cuerpo: ahorra un GET al cliente
}

202 para trabajos asíncronos. Cuando aceptas una petición pero el trabajo aún no ha ocurrido —generar un informe, importar un CSV de mil filas, enviar cien correos—, responder 200 es mentir: sugiere que la operación terminó. El 202 dice explícitamente «lo he aceptado, aún no está hecho», y debe acompañarse de una forma de consultar el progreso, que es el patrón completo de 25.11.

204 sin cuerpo. El 204 significa literalmente «no hay contenido», y la RFC 9110 prohíbe enviar cuerpo. Enviarlo igualmente provoca fallos raros: algunos clientes intentan parsear un JSON vacío y lanzan, y en Angular HttpClient con responseType: 'json' devuelve null, lo cual está bien si lo esperas y produce un TypeError si no. Un DELETE exitoso es el caso canónico de 204.

400 frente a 422. Es la distinción que más discusiones genera. La regla operativa que mejor funciona: 400 si no has podido llegar a entender la petición (JSON malformado, un parámetro que debía ser número y llega como texto, falta un campo obligatorio, el tipo es incorrecto) y 422 si la has entendido perfectamente pero viola una regla de negocio (la fecha de fin es anterior a la de inicio, la transición de estado no está permitida, el descuento supera el máximo de la organización). La diferencia importa porque cada uno indica algo distinto al cliente: un 400 casi siempre es un error de programación en el cliente, y un 422 casi siempre es algo que el usuario puede corregir. Dicho esto, muchas APIs excelentes usan solo 400 y detallan en el cuerpo; lo intolerable es mezclarlos al azar.

Nota sobre NestJS El ValidationPipe de NestJS devuelve 400 por defecto para los fallos de class-validator. Si adoptas el criterio 400/422, configúralo con errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY para las reglas de negocio, o —mejor— deja el 400 para el formato y lanza UnprocessableEntityException desde el caso de uso para las reglas semánticas. Lo importante es que la decisión esté escrita en la documentación y sea la misma en toda la API.

401 frente a 403. El nombre de 401 es históricamente desafortunado: «Unauthorized» debería ser «Unauthenticated». 401 significa «no sé quién eres»: no hay credenciales, están caducadas o son inválidas; reintentar tras autenticarse puede funcionar, y por eso la respuesta debe incluir WWW-Authenticate. 403 significa «sé quién eres y no tienes permiso»: reintentar con las mismas credenciales nunca funcionará. La consecuencia práctica en Angular es directa: ante un 401 el interceptor intenta refrescar el token y, si falla, lleva al usuario al login; ante un 403 muestra un mensaje de permisos y no intenta refrescar nada. Confundirlos produce el bucle infinito de refresco de token, un clásico.

apps/web/src/app/core/auth.interceptor.ts
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      // 401: problema de IDENTIDAD. Merece la pena refrescar el token.
      if (error.status === 401 && !req.url.includes('/auth/refresh')) {
        return auth.refrescar().pipe(
          switchMap(() => next(req)),          // reintento con el token nuevo
          catchError(() => {
            auth.cerrarSesion();
            router.navigate(['/login']);
            return throwError(() => error);
          }),
        );
      }

      // 403: problema de PERMISOS. Refrescar el token no cambia nada;
      // insistir solo produce ruido y, si hay bloqueo por intentos,
      // puede acabar bloqueando la cuenta del usuario.
      if (error.status === 403) {
        router.navigate(['/sin-permiso']);
      }

      return throwError(() => error);
    }),
  );
};

404 frente a 403 para no revelar existencia. Hay un caso en el que 404 es preferible aunque el recurso exista: cuando el hecho mismo de que exista es información sensible. Si un usuario pide GET /projects/731 y ese proyecto pertenece a otra organización, responder 403 le confirma que el proyecto 731 existe. Recorriendo identificadores puede deducir cuántos proyectos tiene la competencia, cuándo se crean y a qué ritmo crece el sistema. Es una fuga por enumeración. La regla: 403 cuando el usuario sabe legítimamente que el recurso existe pero no puede hacer esa operación concreta; 404 cuando ni siquiera debería saber que existe. Y por encima de todo, identificadores no adivinables (UUID o ULID) para los recursos sensibles, que resuelven el problema de raíz.

409 para conflictos. El 409 cubre tres situaciones distintas que conviene diferenciar en el cuerpo del error: un duplicado (violación de índice único), una transición de estado inválida y una edición concurrente. Para esta última, si estás usando If-Match, el código correcto es 412, no 409; el 409 queda para cuando detectas el conflicto sin precondición explícita, típicamente al capturar la excepción de bloqueo optimista de MikroORM.

410 Gone. Es un 404 con intención: «esto existió y lo eliminamos a propósito». Sirve para dos cosas muy concretas. Una, retirar una versión de la API: GET /api/v1/tasks respondiendo 410 con un mensaje que indique cómo migrar a v2 es infinitamente más útil que un 404. Dos, recursos purgados por política de retención, donde 410 comunica «no lo busques más» y permite a los rastreadores y a las cachés eliminarlo definitivamente.

429 con Retry-After. Un 429 sin indicación de cuándo reintentar convierte al cliente en un adversario: reintentará inmediatamente, empeorando el problema. Retry-After admite segundos (Retry-After: 30) o una fecha HTTP, y acompañado de las cabeceras de límite (25.17) permite a un cliente bien escrito autorregularse.

La familia 5xx. Un 5xx significa «he fallado yo». Por eso nunca debes usar 500 para comunicar un error de validación, y por eso cada 5xx debe generar una entrada de log con traza completa y contar para tu presupuesto de errores. Distinguir 502, 503 y 504 aporta información operativa real: 502 apunta a una dependencia que respondió mal, 503 a una indisponibilidad tuya conocida y 504 a un tiempo de espera agotado. Añadir Retry-After a un 503 durante un despliegue permite que los clientes esperen en lugar de martillear.

Un 5xx no debe contar la verdad completa El cuerpo de un 500 nunca debe incluir la traza, el SQL que falló, la ruta del fichero ni el mensaje original de la excepción. Esa información es un mapa del sistema para quien busca vulnerabilidades. Lo correcto es un mensaje genérico más un identificador de correlación que el usuario pueda dar a soporte, y el detalle completo en el log del servidor. En 25.7.4 está el patrón exacto.

25.5 Diseño de recursos y URL

Una URL es la parte más pública y más permanente de tu API. Puedes reescribir el servicio, cambiar de base de datos y migrar de Express a Fastify sin que ningún cliente se entere; cambiar una URL rompe a todo el mundo. Merece por tanto un rato de diseño consciente en lugar de emerger por acumulación de @Get().

La idea central de REST, tal como la formuló Roy Fielding en su tesis doctoral de 2000, es que el sistema se modela como un conjunto de recursos identificados por URI, sobre los que se aplica un conjunto uniforme y pequeño de operaciones. El giro mental que cuesta hacer es dejar de pensar en «qué funciones expongo» para pensar en «qué cosas existen en mi dominio». No expones crearTarea, listarTareas y borrarTarea: expones el recurso tarea, y HTTP ya trae los verbos.

25.5.1 Sustantivos en plural y consistencia

Las convenciones dominantes en la industria, y las que siguen Google, Microsoft, Stripe y GitHub con pequeñas variantes, son estas:

ReglaMalBienPor qué
Sustantivos, no verbos/getTasks, /crearTareaGET /tasks, POST /tasksEl verbo ya está en el método; repetirlo permite incoherencias como GET /deleteTask
Plural siempre/task/42 y /tasks mezclados/tasks y /tasks/42Una colección y un elemento de esa colección; el plural mantiene la jerarquía coherente
Minúsculas y guion medio/taskComments, /task_comments/task-commentsLas URL distinguen mayúsculas en la ruta; el guion medio es la convención web y la que prefieren los buscadores
Sin extensión de formato/tasks/42.json/tasks/42 + AcceptEl formato se negocia por cabecera; la extensión duplica el mecanismo
Sin barra final/tasks/ y /tasks/tasksDos URL distintas para lo mismo estropean la caché y las métricas
Sin verbos en la ruta de acciónPOST /tasks/42/setEstadoPATCH /tasks/42Si el cambio cabe en el recurso, no hace falta un endpoint nuevo
Un idioma, uno solo/proyectos/9/tasks/projects/9/tasksMezclar idiomas obliga a memorizar caso por caso
¿Inglés o español? Da igual cuál elijas mientras sea uno solo y esté escrito en la guía de estilo del equipo. En este libro las rutas van en inglés (/projects, /tasks) porque es lo habitual en APIs que pueden acabar siendo públicas, y el código y los comentarios en español. Lo que no funciona es «español para lo que escribió Ana e inglés para lo que escribió Luis»: cada llamada obliga a consultar la documentación.

25.5.2 Jerarquía y anidamiento: hasta dónde

El anidamiento expresa pertenencia: /projects/9/tasks son las tareas del proyecto 9. Es útil y comunica bien, pero tiene un techo. La regla empírica, y la recomiendan tanto la guía de Google como la de Microsoft, es no pasar de un nivel de anidamiento. En cuanto llegas a tres, las URL se vuelven frágiles: cualquier reorganización del dominio las invalida, y el cliente necesita conocer toda la cadena de identificadores para acceder a algo que tiene identificador propio.

rutasINCORRECTO
# Anidamiento profundo: para leer un comentario
# necesitas cuatro identificadores, y tres de
# ellos no aportan nada.
GET /organizations/3/projects/9/tasks/42/comments/7

# Peor aún: la misma cosa accesible por dos
# caminos distintos, con dos cachés distintas
# y dos permisos que hay que mantener iguales.
GET /tasks/42/comments/7
GET /comments/7

# Y verbos disfrazados de recurso:
POST /projects/9/tasks/42/markAsDone
GET  /projects/9/getTaskCount
POST /tasks/search-by-user-and-status
rutasCORRECTO
# Un nivel de anidamiento para CREAR y LISTAR,
# donde el padre sí es necesario:
GET  /projects/9/tasks
POST /projects/9/tasks

# Y acceso PLANO al elemento, que ya tiene
# identificador propio y único:
GET    /tasks/42
PATCH  /tasks/42
DELETE /tasks/42
GET    /tasks/42/comments      # un nivel, otra vez

# Filtrar es un parámetro, no una ruta nueva:
GET /tasks?projectId=9&estado=ABIERTA&responsableId=7

La regla que resume todo esto: anida para crear y para listar, aplana para leer, modificar y borrar. Cuando creas una tarea necesitas decir en qué proyecto va, y la URL es el sitio natural para esa información; cuando ya existe y tiene identificador, exigir el proyecto es redundante y además abre una pregunta incómoda: ¿qué haces si /projects/9/tasks/42 se pide y la tarea 42 pertenece al proyecto 5? La respuesta correcta es 404, y tener que implementar y probar esa comprobación en cada endpoint es un coste que se evita aplanando.

25.5.3 Identificadores

La elección del identificador público es una decisión de arquitectura y de seguridad, no un detalle. Tres opciones razonables:

TipoEjemploVentajasInconvenientesCuándo
Entero autoincremental/tasks/42Corto, legible, índice compacto, ordenación naturalEnumerable (revela volumen y permite recorrer), revela información de negocioRecursos internos o no sensibles
UUID v4/tasks/9f8b…No adivinable, generable en el cliente, sin colisiones entre nodos16 bytes, aleatorio: fragmenta índices B-tree y perjudica la localidadRecursos sensibles o multiinquilino
ULID o UUID v701J9Z…No adivinable y ordenable por tiempo: buena localidad en el índiceMenos soporte nativo que UUID v4 en algunas herramientasLa opción por defecto hoy para recursos nuevos
Referencias directas inseguras Exponer el identificador de la base de datos y comprobar los permisos «después» es el patrón que OWASP llama Broken Object Level Authorization, y encabeza su lista de riesgos de API desde 2019. No basta con que la URL sea difícil de adivinar: cada acceso debe verificar la propiedad. La regla operativa en TaskFlow es que ninguna consulta de lectura se hace por identificador a secas, sino siempre acotada por el inquilino: em.findOne(Task, { id, project: { organization: usuario.orgId } }). Un filtro global de MikroORM (capítulo 17) lo hace automático y elimina la posibilidad de olvidarlo.

25.5.4 Acciones que no encajan en CRUD

Antes o después aparece «archivar un proyecto», «reabrir una tarea», «duplicar una plantilla», «enviar un recordatorio». No son creaciones, ni lecturas, ni actualizaciones genéricas, ni borrados. Hay tres soluciones válidas, en orden de preferencia:

Opción 1 · Modelar el estado como un campo y usar PATCH. Si «archivar» es simplemente poner archivado = true, entonces PATCH /projects/9 con {"archivado": true} es la respuesta correcta y no hace falta inventar nada. Es la opción por defecto y la que más gente descarta demasiado rápido.

Opción 2 · Convertir la acción en un subrecurso. Cuando la acción tiene entidad propia —porque se puede consultar, porque tiene fecha y autor, porque hay reglas de negocio detrás—, el subrecurso es más expresivo que un campo booleano. «Archivar» pasa a ser un recurso archive que se crea con PUT y se elimina con DELETE:

rutas de acciones modeladas como recursos
# Archivar y desarchivar: el archivo es un recurso singular
PUT    /projects/9/archive     -> 204   (idempotente: archivar dos veces = archivado)
DELETE /projects/9/archive     -> 204   (desarchivar)
GET    /projects/9/archive     -> 200 {"archivadoEn":"…","porUsuarioId":7}
                               -> 404 si no está archivado

# Otras acciones frecuentes bien modeladas:
PUT    /tasks/42/assignee      {"userId": 7}     # fijar responsable (idempotente)
DELETE /tasks/42/assignee                        # quitar responsable
POST   /tasks/42/comments      {"texto": "…"}    # comentar crea un recurso
POST   /projects/9/exports     {"formato":"xlsx"} -> 202 + Location del trabajo
POST   /invitations            {"email":"…"}     # invitar crea una invitación
PUT    /tasks/42/read-state    -> 204            # marcar como leída

Opción 3 · El verbo explícito, asumido conscientemente. Para operaciones que de verdad no son un recurso —POST /tasks/42/duplicate, POST /auth/login, POST /payments/17/refund— usar un verbo al final con POST es aceptable y lo hacen APIs muy respetadas. Google lo llama «métodos personalizados» y su guía los admite con la sintaxis recurso:accion. Lo importante es que sea la excepción y no la norma, que siempre sea POST (no es seguro ni idempotente) y que esté documentado como tal.

Analogía Piensa en un ayuntamiento. Los recursos son los expedientes; los métodos, los trámites normalizados (consultar, presentar, modificar, retirar). Cuando aparece una gestión que no encaja —«solicitar prórroga»—, un ayuntamiento bien organizado no inventa una ventanilla nueva con su propio procedimiento: crea un tipo de expediente «solicitud de prórroga», que se presenta con el trámite estándar y que además queda registrado, se puede consultar y tiene su propio estado. Eso es exactamente convertir una acción en un recurso, y la ventaja es la misma: todo el mundo sabe ya cómo operar con él.

25.5.5 Qué no poner nunca en una URL

25.6 Colecciones: paginación, filtrado, ordenación y expansión

El endpoint de listado es el que más tráfico recibe, el que más consultas caras genera y el que peor suele estar diseñado. Un GET /tasks que devuelve todas las tareas funciona perfectamente durante seis meses y deja de funcionar el día que un cliente importa cincuenta mil filas, normalmente a las tres de la mañana y con la memoria del proceso agotada.

25.6.1 Paginación: desplazamiento frente a cursor

Hay dos familias, y elegir mal tiene consecuencias medibles. La paginación por desplazamiento (offset) usa ?page=3&limit=20 o ?offset=40&limit=20 y se traduce directamente a LIMIT 20 OFFSET 40. La paginación por cursor usa ?cursor=eyJpZCI6NDJ9&limit=20 y se traduce a un WHERE (creado_en, id) < (…, …) ORDER BY creado_en DESC, id DESC LIMIT 20.

AspectoPor desplazamientoPor cursor
Facilidad de implementaciónTrivialRequiere una clave de orden estable y codificar el cursor
Saltar a la página 57DirectoImposible por diseño
Total de elementosFácil (un COUNT, que también puede ser caro)Normalmente no se ofrece
Coste con desplazamiento grandeTerrible: la base de datos lee y descarta las 100.000 filas anterioresConstante: usa el índice para posicionarse
Datos que cambian mientras paginasElementos duplicados y omitidos al insertarse filasEstable: el cursor apunta a una posición real
Casos de usoTablas de administración con pocos miles de filas y paginador numéricoScroll infinito, exportaciones, listas grandes, API públicas
tasks.service.tsINCORRECTO
// Offset sobre una tabla de 4 millones de filas.
// La página 1 tarda 3 ms; la página 5.000 tarda
// 1,8 s porque PostgreSQL debe leer y descartar
// 100.000 filas antes de empezar a devolver.
// Y si alguien inserta una tarea mientras el
// usuario pasa de página, verá un elemento
// repetido y se perderá otro sin enterarse.
async listar(page = 1, limit = 20) {
  return this.em.findAndCount(Task, {}, {
    offset: (page - 1) * limit,
    limit,
    orderBy: { creadoEn: 'DESC' },   // sin desempate
  });
}
tasks.service.tsCORRECTO
// Cursor con clave compuesta ESTABLE: (creadoEn, id).
// El id desempata las marcas de tiempo idénticas; sin
// él la paginación se salta filas de forma aleatoria.
async listar(cursor?: string, limit = 20) {
  const c = cursor ? decodificarCursor(cursor) : null;

  const where: FilterQuery<Task> = c
    ? { $or: [
        { creadoEn: { $lt: c.creadoEn } },
        { creadoEn: c.creadoEn, id: { $lt: c.id } },
      ] }
    : {};

  // limit + 1: la fila sobrante nos dice si hay más
  // páginas sin necesidad de un COUNT costoso.
  const filas = await this.em.find(Task, where, {
    orderBy: [{ creadoEn: 'DESC' }, { id: 'DESC' }],
    limit: limit + 1,
  });

  const hayMas = filas.length > limit;
  const datos = hayMas ? filas.slice(0, limit) : filas;
  return { datos, siguiente: hayMas
    ? codificarCursor(datos.at(-1)!) : null };
}
El índice tiene que coincidir con el orden La paginación por cursor solo es rápida si existe un índice que cubra exactamente las columnas de ordenación en el mismo orden y sentido: CREATE INDEX idx_task_creado_id ON task (creado_en DESC, id DESC). Sin él, PostgreSQL ordena en memoria todas las filas que cumplen el filtro y has cambiado un problema por otro. Es el punto donde este capítulo se cruza con el 16 y el 19: el contrato de la API y el diseño de índices se diseñan juntos, no por separado.

El cursor debe ser opaco para el cliente. Se codifica en Base64URL y se documenta como «cadena que devuelve el servidor y que el cliente reenvía sin interpretar». Que sea legible es una invitación a que alguien lo construya a mano y quede acoplado a tu esquema; conviene además firmarlo si el orden puede depender de permisos.

apps/api/src/common/cursor.ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export interface Cursor { creadoEn: string; id: number }

export function codificarCursor(t: { creadoEn: Date; id: number }): string {
  const cuerpo = Buffer.from(JSON.stringify({
    creadoEn: t.creadoEn.toISOString(), id: t.id,
  })).toString('base64url');

  // Firma corta: impide que un cliente fabrique cursores arbitrarios
  // para saltarse filtros de visibilidad aplicados en el WHERE.
  const firma = createHmac('sha256', process.env.CURSOR_SECRET!)
    .update(cuerpo).digest('base64url').slice(0, 16);

  return `${cuerpo}.${firma}`;
}

export function decodificarCursor(valor: string): Cursor {
  const [cuerpo, firma] = valor.split('.');
  const esperada = createHmac('sha256', process.env.CURSOR_SECRET!)
    .update(cuerpo ?? '').digest('base64url').slice(0, 16);

  if (!firma || !timingSafeEqual(Buffer.from(firma), Buffer.from(esperada))) {
    throw new BadRequestException('El cursor no es válido');
  }
  return JSON.parse(Buffer.from(cuerpo, 'base64url').toString());
}

25.6.2 Filtrado, ordenación y selección de campos

Los tres se expresan con parámetros de consulta, y los tres tienen la misma trampa: si dejas que el cliente escriba nombres de columna, has expuesto tu esquema y probablemente has abierto una inyección. La defensa es siempre la misma: lista blanca explícita.

ordenaciónINCORRECTO
// El cliente decide el ORDER BY. Dos problemas:
// 1) Acoplas la API a los nombres de columna.
// 2) Según el ORM y cómo se construya, puede
//    acabar concatenado en el SQL.
// Y aunque no haya inyección, ?sort=descripcion
// sobre 4 millones de filas sin índice tumba
// la base de datos: es una denegación de
// servicio que ofreces tú amablemente.
@Get()
listar(@Query('sort') sort = 'id') {
  return this.em.find(Task, {}, {
    orderBy: { [sort]: 'ASC' },
  });
}
ordenaciónCORRECTO
// Lista blanca: solo campos del CONTRATO, con
// nombre público, traducidos a columna, y solo
// los que tienen índice que los soporte.
const ORDENABLES = {
  creadoEn:    'creadoEn',
  vencimiento: 'vencimiento',
  prioridad:   'prioridad',
  titulo:      'titulo',
} as const;

function traducirOrden(sort?: string) {
  // Formato:  ?sort=-vencimiento,titulo
  const partes = (sort ?? '-creadoEn').split(',');
  return partes.map((p) => {
    const desc = p.startsWith('-');
    const clave = desc ? p.slice(1) : p;
    if (!(clave in ORDENABLES)) {
      throw new BadRequestException(
        `No se puede ordenar por "${clave}"`);
    }
    return { [ORDENABLES[clave]]: desc ? 'DESC' : 'ASC' };
  });
}

Para el filtrado hay dos estilos. El plano, ?estado=ABIERTA&responsableId=7, es legible y suficiente para el noventa por ciento de los casos. El estructurado, ?filter[vencimiento][lte]=2026-08-31 o ?vencimiento_lte=2026-08-31, aparece cuando necesitas operadores. Elige uno, documéntalo y no lo mezcles. Y define desde el principio dos cosas que siempre se olvidan: qué ocurre con un parámetro desconocido (recomendación: 400, para que los errores tipográficos del cliente sean visibles en lugar de silenciosos) y cómo se expresan los valores múltiples (?estado=ABIERTA,EN_CURSO o ?estado=ABIERTA&estado=EN_CURSO; el primero es más compacto, el segundo más estándar).

La selección de campos (?fields=id,titulo,estado) reduce el tamaño de la respuesta y es útil en móviles, pero tiene un coste alto: multiplica las variantes de la respuesta, complica la caché (hay que añadir fields a la clave), rompe la tipificación del cliente generado y dificulta la documentación. Ofrécela solo si tienes una necesidad medida. La expansión de relaciones (?expand=responsable,proyecto) resuelve el problema contrario —evitar que el cliente haga N peticiones— y suele aportar más valor, siempre que impongas un límite estricto de qué se puede expandir y a qué profundidad, porque cada expansión es un JOIN o una consulta adicional, y ahí acecha el problema N+1 del capítulo 16.

25.6.3 Contrato completo del listado de tareas

contrato · GET /api/v1/projects/9/tasks
GET /api/v1/projects/9/tasks
      ?estado=ABIERTA,EN_CURSO      # filtro múltiple, valores del enum
      &responsableId=7              # filtro por relación
      &vencimiento_lte=2026-08-31   # filtro con operador
      &q=contrato                   # búsqueda de texto en titulo y descripcion
      &sort=-prioridad,vencimiento  # lista blanca; el guion indica descendente
      &expand=responsable           # expansión permitida, un nivel
      &limit=20                     # 1..100, por defecto 20
      &cursor=eyJjcmVhZG…           # opaco, lo devuelve el servidor
Accept: application/json
Authorization: Bearer …
respuesta · 200 OK
{
  "data": [
    {
      "id": 42,
      "titulo": "Revisar contrato del proveedor",
      "estado": "EN_CURSO",
      "prioridad": "ALTA",
      "vencimiento": "2026-08-14",
      "horas": 3.5,
      "responsable": { "id": 7, "nombre": "Ana Ruiz" },
      "proyectoId": 9,
      "version": 8,
      "creadoEn": "2026-07-02T09:14:00.000Z",
      "actualizadoEn": "2026-07-30T16:22:11.000Z"
    }
  ],
  "meta": {
    "limit": 20,
    "contadoEnPagina": 20,
    "hayMas": true,
    "totalAproximado": 1483
  },
  "links": {
    "self": "/api/v1/projects/9/tasks?limit=20&estado=ABIERTA,EN_CURSO",
    "next": "/api/v1/projects/9/tasks?limit=20&estado=ABIERTA,EN_CURSO&cursor=eyJjcmVhZG…"
  }
}

Cuatro decisiones de este contrato merecen justificación. Primera: la lista va dentro de data, nunca en la raíz. Devolver un array desnudo impide añadir metadatos más adelante sin romper el contrato, y históricamente fue vector de un ataque de robo de JSON en navegadores antiguos. Segunda: hayMas es booleano en lugar de un total exacto, porque un COUNT(*) con filtros sobre una tabla grande puede costar más que la propia consulta; si el cliente necesita un total, ofrécelo como aproximación o como parámetro opcional. Tercera: links.next viene ya construido, de modo que el cliente no tiene que saber montar el cursor. Y cuarta: los campos de fecha van en ISO 8601 con zona UTC, y las fechas sin hora (vencimiento) sin zona, porque son fechas de calendario y no instantes; confundir ambas cosas es el origen del clásico «la fecha se muestra un día antes» del capítulo 29.

apps/web/src/app/tasks/data/task-api.service.ts
@Injectable({ providedIn: 'root' })
export class TaskApiService {
  private readonly http = inject(HttpClient);
  private readonly base = inject(API_BASE_URL);

  listar(proyectoId: number, filtros: FiltrosTareas): Observable<PaginaTareas> {
    // HttpParams escapa correctamente los valores; concatenar la cadena a
    // mano rompe con acentos, espacios y con el carácter + en las fechas.
    let params = new HttpParams()
      .set('limit', String(filtros.limit ?? 20))
      .set('sort', filtros.orden ?? '-creadoEn');

    if (filtros.estados?.length) params = params.set('estado', filtros.estados.join(','));
    if (filtros.responsableId)   params = params.set('responsableId', filtros.responsableId);
    if (filtros.cursor)          params = params.set('cursor', filtros.cursor);

    return this.http.get<PaginaTareas>(
      `${this.base}/projects/${proyectoId}/tasks`, { params });
  }

  // El scroll infinito consume "links.next" tal cual: el cliente no
  // reconstruye la URL, solo la sigue. Si mañana cambias el formato del
  // cursor o añades un parámetro, el cliente no se entera.
  siguientePagina(url: string): Observable<PaginaTareas> {
    return this.http.get<PaginaTareas>(url);
  }
}

25.7 Errores: el contrato que se diseña el último y se usa el primero

Los errores son la parte de la API que más se usa en el peor momento: cuando algo va mal, cuando hay usuarios esperando y cuando el desarrollador que integra tu API no tiene tiempo. Un error bien diseñado ahorra horas; un {"message": "Error"} con un 500 las consume.

Un buen error responde a cuatro preguntas: qué ha pasado (de forma que una máquina pueda decidir), por qué (de forma que una persona lo entienda), qué campo concreto está mal si aplica, y cómo puedo rastrear este caso concreto cuando escriba al soporte.

25.7.1 Problem Details, RFC 9457

En lugar de inventar un formato por proyecto, existe uno estándar: RFC 9457, «Problem Details for HTTP APIs», que en 2023 sustituyó a la RFC 7807 (los cambios son menores; el campo instance se aclaró y se añadieron los tipos de error registrados). Se identifica con el tipo de medio application/problem+json y define estos campos:

CampoObligatoriedadSignificadoEjemplo en TaskFlow
typeRecomendadoURI que identifica el tipo de problema. Es el campo que leen las máquinas. Si es dereferenciable, debería documentar el errorhttps://api.taskflow.dev/errors/transicion-no-permitida
titleRecomendadoResumen corto y estable del tipo. No cambia entre ocurrenciasTransición de estado no permitida
statusRecomendadoEl código HTTP, repetido en el cuerpo por comodidad de los intermediarios422
detailOpcionalExplicación de esta ocurrencia concreta, legible por una personaNo se puede pasar de CANCELADA a HECHA
instanceOpcionalURI de la ocurrencia: normalmente la ruta que falló, o una URL de rastreo/api/v1/tasks/42
ExtensionesLibreCualquier campo propio: errors, requestId, retryAfter, codeVer el ejemplo siguiente
error inventadoINCORRECTO
// Cada endpoint con su propio formato.
// Imposible de tratar genéricamente.
{ "error": "Algo ha fallado" }

// Otro endpoint, otro formato:
{ "success": false, "msg": "bad state",
  "code": -17 }

// Y el peor de todos: la traza en el cuerpo.
{
  "statusCode": 500,
  "message": "duplicate key value violates
     unique constraint \"task_slug_uq\"",
  "stack": "at TaskRepo.save (/srv/app/dist/
     tasks/task.repository.js:88:15) …",
  "query": "INSERT INTO task (…) VALUES (…)"
}
// Regalo para quien busque vulnerabilidades:
// nombres de tablas, restricciones y rutas.
Problem DetailsCORRECTO
// Content-Type: application/problem+json
{
  "type": "https://api.taskflow.dev/errors/
           transicion-no-permitida",
  "title": "Transición de estado no permitida",
  "status": 422,
  "detail": "Una tarea CANCELADA no puede pasar
             a HECHA. Reábrela primero.",
  "instance": "/api/v1/tasks/42",

  "code": "TASK_TRANSICION_INVALIDA",
  "estadoActual": "CANCELADA",
  "estadoSolicitado": "HECHA",
  "transicionesPermitidas": ["ABIERTA"],
  "requestId": "8f2c1d3e-77aa-4b19-9e0c-51a2…",
  "timestamp": "2026-07-31T17:04:22.812Z"
}
// La máquina lee "type" o "code"; la persona
// lee "detail"; el soporte usa "requestId".
Sobre type y code La RFC dice que type es el identificador de máquina. En la práctica, muchos equipos añaden además un code corto y simbólico (TASK_TRANSICION_INVALIDA) porque es más cómodo en un switch del cliente y más estable que una URL que quizá cambie de dominio. Es una extensión perfectamente legítima. Lo que no debes hacer es que el cliente compare title o detail: son textos legibles que se pueden reescribir, traducir o mejorar sin previo aviso, y comparar cadenas de texto es un acoplamiento que se rompe en la primera corrección ortográfica.

25.7.2 Errores de validación campo a campo

Un formulario de Angular necesita saber qué campo está mal para pintar el mensaje debajo del control correcto. Un error global obliga a la interfaz a adivinar, o a mostrar un aviso genérico que empeora la experiencia. La extensión habitual es un array errors con una entrada por problema:

respuesta · 422 Unprocessable Content
{
  "type": "https://api.taskflow.dev/errors/validacion",
  "title": "La solicitud contiene campos inválidos",
  "status": 422,
  "detail": "Revisa los 3 campos indicados en errors.",
  "instance": "/api/v1/projects/9/tasks",
  "requestId": "8f2c1d3e-77aa-4b19-9e0c-51a2d0b3e6f4",
  "errors": [
    { "campo": "titulo",      "code": "MIN_LENGTH",
      "mensaje": "Debe tener al menos 3 caracteres", "recibido": "ab" },
    { "campo": "vencimiento", "code": "FECHA_ANTERIOR",
      "mensaje": "No puede ser anterior a la fecha de inicio del proyecto" },
    { "campo": "etiquetas[2]", "code": "NO_PERMITIDO",
      "mensaje": "La etiqueta \"urgente!\" contiene caracteres no permitidos" }
  ]
}

Fíjate en tres detalles del diseño. El nombre del campo usa la ruta del cuerpo, con notación de índice para los arrays (etiquetas[2]) y de punto para lo anidado (responsable.email), de modo que el cliente puede localizar el control sin ambigüedad. Cada entrada lleva un code simbólico además del mensaje, para que el cliente pueda traducir a su idioma o mostrar un texto propio. Y recibido aparece solo cuando el valor no es sensible: nunca eches en un mensaje de error la contraseña que el usuario escribió mal.

apps/web/src/app/shared/forms/aplicar-errores-servidor.ts
interface ErrorCampo { campo: string; code: string; mensaje: string }

// Vuelca los errores del servidor sobre el FormGroup para que cada
// mensaje aparezca bajo su control. Es la razón práctica por la que el
// contrato de errores debe incluir la RUTA del campo: sin ella, esta
// función no puede existir y la interfaz muestra un aviso genérico.
export function aplicarErroresServidor(form: FormGroup, errores: ErrorCampo[]): void {
  for (const e of errores) {
    // "etiquetas[2]" -> ["etiquetas", "2"]  para AbstractControl.get()
    const ruta = e.campo.replace(/\[(\d+)\]/g, '.$1');
    const control = form.get(ruta);

    if (control) {
      control.setErrors({ ...(control.errors ?? {}), servidor: e.mensaje });
      control.markAsTouched();
    } else {
      // Campo que no existe en el formulario: no lo perdemos, lo
      // mostramos arriba. Suele indicar que el contrato cambió.
      form.setErrors({ ...(form.errors ?? {}), servidor: e.mensaje });
    }
  }
}

25.7.3 Identificador de correlación

Cuando un usuario llama a soporte y dice «me da error al guardar», el equipo tiene que encontrar esa petición entre millones. El identificador de correlación resuelve exactamente eso: un valor único por petición que aparece en la respuesta, en todos los registros del servidor y, si usas trazado distribuido, en el intervalo de la traza. El usuario copia un código de la pantalla de error y el soporte encuentra la petición en segundos.

La implementación tiene tres piezas. En el servidor, un middleware genera el identificador (o reutiliza el que llegue en X-Request-Id si viene de un proxy de confianza), lo guarda en un almacenamiento de contexto asíncrono y lo devuelve como cabecera. En el filtro de errores, se incluye en el cuerpo del Problem Details. En el cliente, se muestra en la pantalla de error con un botón de copiar. Merece la pena adoptar además la cabecera estándar traceparent del W3C si tienes OpenTelemetry, porque entonces el identificador enlaza con la traza completa.

apps/api/src/common/filters/problem-details.filter.ts
@Catch()
export class ProblemDetailsFilter implements ExceptionFilter {
  private readonly log = new Logger('HTTP');

  constructor(private readonly config: ConfigService) {}

  catch(excepcion: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const res = ctx.getResponse<Response>();
    const req = ctx.getRequest<Request>();
    const requestId = contextoActual()?.requestId ?? 'desconocido';

    const problema = this.traducir(excepcion, req, requestId);

    // 4xx: el sistema funcionando. Nivel warn, sin traza, no alerta.
    // 5xx: el sistema fallando. Nivel error, con traza, sí alerta.
    if (problema.status >= 500) {
      this.log.error({ requestId, ruta: req.url, err: excepcion },
        'Error no controlado');
    } else {
      this.log.warn({ requestId, ruta: req.url, code: problema.code },
        problema.title);
    }

    res.status(problema.status)
       .type('application/problem+json')   // NO application/json
       .json(problema);
  }

  private traducir(e: unknown, req: Request, requestId: string): ProblemaHttp {
    const base = { instance: req.originalUrl, requestId,
                   timestamp: new Date().toISOString() };

    if (e instanceof ErrorDeDominio) {          // capítulo 20: errores propios
      return { ...base, status: e.estadoHttp, type: `${TIPOS}/${e.slug}`,
               title: e.titulo, detail: e.mensajePublico, code: e.code };
    }

    if (e instanceof UniqueConstraintViolationException) {
      // Traducimos el error del ORM SIN filtrar el nombre de la restricción,
      // que revelaría el esquema de la base de datos.
      return { ...base, status: 409, type: `${TIPOS}/recurso-duplicado`,
               title: 'El recurso ya existe', code: 'RECURSO_DUPLICADO',
               detail: 'Ya existe un elemento con esos datos únicos.' };
    }

    if (e instanceof HttpException) {
      const status = e.getStatus();
      return { ...base, status, type: `${TIPOS}/${slugDe(status)}`,
               title: mensajeEstandar(status), detail: detalleSeguro(e) };
    }

    // Desconocido: 500 con mensaje genérico SIEMPRE, en cualquier entorno.
    // El detalle está en el log, indexado por requestId.
    return { ...base, status: 500, type: `${TIPOS}/error-interno`,
             title: 'Error interno del servidor', code: 'ERROR_INTERNO',
             detail: `No hemos podido completar la operación. `
                   + `Si vuelve a ocurrir, indica el código ${requestId}.` };
  }
}

25.7.4 Qué no revelar nunca

25.8 Caché HTTP: la optimización más desaprovechada

La respuesta más rápida es la que no se pide. La segunda más rápida es la que responde un CDN a treinta kilómetros del usuario sin tocar tu servidor. Y sin embargo, la inmensa mayoría de las APIs no emite ninguna cabecera de caché útil, con lo que el navegador, el proxy corporativo y el CDN no tienen más remedio que preguntar siempre. Es dinero y latencia tirados por una línea de configuración que nadie escribió.

Conviene separar dos ideas que se mezclan. La frescura responde a «¿puedo usar mi copia sin preguntar?» y la controla Cache-Control. La validación responde a «mi copia ha caducado, ¿sigue sirviendo?» y la controlan ETag y Last-Modified. Una API bien diseñada usa las dos: frescura corta o nula para datos que cambian, validación siempre.

25.8.1 Cache-Control, directiva a directiva

DirectivaQuién la usaQué significa exactamenteUso en TaskFlow
publicRespuestaPuede almacenarla cualquier caché, incluidas las compartidas (CDN, proxy)Catálogos comunes: países, tipos de tarea
privateRespuestaSolo la caché del navegador del usuario. Nunca una compartidaTodo lo que dependa del usuario autenticado
no-storeAmbosNo guardar en ningún sitio, ni en disco. Es el verdadero «no cachear»Tokens, datos financieros, respuestas de /auth
no-cacheAmbosNo significa «no cachear»: significa «guárdala, pero revalida siempre antes de usarla»Recursos con ETag que cambian con frecuencia
max-age=NRespuestaFresca durante N segundos para cualquier cachémax-age=60 en un panel de métricas
s-maxage=NRespuestaFrescura solo para cachés compartidas; anula max-age en ellasCDN 5 min, navegador 0: max-age=0, s-maxage=300
must-revalidateRespuestaAl caducar, prohíbe servir la copia vieja aunque el origen no respondaDatos donde una copia obsoleta sería un error grave
immutableRespuestaNo revalides nunca durante su vida: el contenido no cambiará jamásFicheros con huella en el nombre: main.9f8b2c.js
stale-while-revalidate=NRespuestaSirve la copia caducada hasta N segundos mientras refresca en segundo planoListados que toleran unos segundos de retraso
stale-if-error=NRespuestaSi el origen falla, sirve la copia caducada durante N segundosRed de seguridad ante un despliegue fallido
no-transformRespuestaProhíbe a los intermediarios recomprimir o recodificarDescargas cuyo hash debe coincidir
no-cache no es «no cachear» Es el nombre peor elegido de todo HTTP y produce fugas de datos reales. no-cache autoriza a guardar la respuesta en disco y solo obliga a revalidarla. Si lo que quieres es que el token de un usuario no quede escrito en el disco del portátil compartido de una oficina, la directiva es no-store. La combinación defensiva para datos sensibles es Cache-Control: no-store, private más Pragma: no-cache por compatibilidad con proxies antiguos.
Vary, o cómo servir los datos de un usuario a otro Una caché compartida indexa por URL. Si dos usuarios piden /api/v1/tasks/42 con tokens distintos y la respuesta es public, el segundo puede recibir la respuesta del primero. Hay dos defensas y conviene aplicar las dos: marcar como private todo lo que dependa del usuario, y declarar Vary: Authorization, Accept, Accept-Language para que la clave de caché incluya esas cabeceras. Una API autenticada que emita Cache-Control: public sin Vary es un incidente de seguridad esperando a que alguien ponga un CDN delante.

25.8.2 ETag, revalidación y el 304

Un ETag es un identificador opaco del estado actual de un recurso. Puede ser fuerte ("v8-91cd02": los bytes son idénticos) o débil (W/"v8": semánticamente equivalente, aunque los bytes difieran en detalles irrelevantes). El cliente lo guarda y, en la siguiente petición, lo envía en If-None-Match. Si coincide, el servidor responde 304 Not Modified sin cuerpo, y el cliente reutiliza su copia.

  FLUJO DE REVALIDACIÓN CON ETag

  PRIMERA PETICIÓN (la caché está vacía)
  ─────────────────────────────────────────────────────────────────────────
  Angular ──── GET /api/v1/tasks/42 ─────────────────────────> NestJS
                                                                  │
                                                     calcula el estado y
                                                     su ETag: "v8-91cd02"
                                                                  │
  Angular <─── 200 OK  (2,4 KB) ─────────────────────────────────┘
               ETag: "v8-91cd02"
               Cache-Control: private, no-cache
               {"id":42,"titulo":"Revisar contrato", …}
       │
       └─ el navegador GUARDA cuerpo + ETag
          ("no-cache" = guarda, pero pregunta antes de usar)


  SEGUNDA PETICIÓN (el usuario vuelve a la pantalla)
  ─────────────────────────────────────────────────────────────────────────
  Angular ──── GET /api/v1/tasks/42 ─────────────────────────> NestJS
               If-None-Match: "v8-91cd02"                          │
                                                     ┌─────────────┴──────┐
                                                     │ ¿el ETag actual    │
                                                     │  sigue siendo      │
                                                     │  "v8-91cd02"?      │
                                                     └──┬──────────────┬──┘
                                                       SÍ             NO
                                                        │              │
  Angular <─── 304 Not Modified (0 bytes) ──────────────┘              │
               ETag: "v8-91cd02"                                       │
       │                                                               │
       └─ usa su copia local. Ahorro: 2,4 KB y toda                    │
          la serialización del servidor.                               │
                                                                       │
  Angular <─── 200 OK  (2,4 KB) ───────────────────────────────────────┘
               ETag: "v9-4ab7de"   <- nuevo estado, nueva etiqueta
       │
       └─ reemplaza su copia y guarda el ETag nuevo


  LO QUE SE AHORRA Y LO QUE NO
  ─────────────────────────────────────────────────────────────────────────
  SE AHORRA   ancho de banda, serialización JSON, tiempo de parseo en el
              cliente, y el repintado si el marco compara referencias.
  NO SE AHORRA  el viaje de ida y vuelta ni, si lo calculas mal, la
              consulta a la base de datos. Para ahorrar la consulta, el
              ETag debe salir de un dato BARATO: una columna de versión
              o un updated_at indexado, nunca del JSON ya construido.

Ese último matiz es el que separa una implementación útil de una decorativa. Si calculas el ETag haciendo un hash del JSON final, has hecho todo el trabajo caro —consultar, cargar relaciones, serializar— y solo te ahorras el envío. Está bien si el cuello de botella es la red, pero el verdadero premio es calcular la etiqueta a partir de un dato que puedas obtener con una consulta mínima.

etag.interceptor.tsINCORRECTO
// Hash del cuerpo ya serializado: hemos hecho
// TODO el trabajo caro antes de descubrir que
// no hacía falta. La base de datos ha sufrido
// exactamente igual que sin caché.
intercept(ctx: ExecutionContext, next: CallHandler) {
  return next.handle().pipe(map((cuerpo) => {
    const etag = createHash('sha1')
      .update(JSON.stringify(cuerpo)).digest('hex');
    const res = ctx.switchToHttp().getResponse();
    res.setHeader('ETag', `"${etag}"`);
    return cuerpo;
  }));
}
tasks.controller.tsCORRECTO
// El ETag sale de la columna de versión, que se
// obtiene con un SELECT de dos columnas. Si el
// cliente ya la tiene, cortamos ANTES de cargar
// relaciones y de serializar nada.
@Get(':id')
async obtener(
  @Param('id', ParseIntPipe) id: number,
  @Headers('if-none-match') ifNoneMatch: string | undefined,
  @Res({ passthrough: true }) res: Response,
) {
  const { version, actualizadoEn } =
    await this.tasks.versionDe(id);      // SELECT barato
  const etag = `"v${version}-${+actualizadoEn}"`;

  res.setHeader('ETag', etag);
  res.setHeader('Cache-Control', 'private, no-cache');

  if (ifNoneMatch === etag) {
    res.status(HttpStatus.NOT_MODIFIED);
    return undefined;                    // 304 SIN cuerpo
  }
  return this.tasks.obtenerCompleta(id); // solo si hace falta
}

Last-Modified e If-Modified-Since son la alternativa basada en tiempo, y funcionan igual salvo por dos limitaciones: su resolución es de un segundo, así que dos cambios en el mismo segundo son indistinguibles, y el formato de fecha HTTP es incómodo de manejar. Se usan sobre todo con ficheros estáticos, donde la fecha de modificación es natural. Cuando el servidor envía las dos cabeceras y el cliente las dos precondiciones, If-None-Match tiene prioridad y If-Modified-Since se ignora.

25.8.3 Caché privada, compartida e invalidación

La diferencia entre la caché del navegador y la del CDN no es solo de ubicación: es de quién puede ver la respuesta. La del navegador es privada por naturaleza; la del CDN sirve a miles de usuarios. De ahí que la decisión más importante sea private frente a public, y que la respuesta por defecto para una API autenticada deba ser private.

La invalidación tiene fama de ser uno de los dos problemas difíciles de la informática, y en HTTP hay cuatro estrategias, en orden de fiabilidad:

El HttpClient de Angular y la caché Conviene tener clara una cosa que sorprende a mucha gente: HttpClient no tiene caché propia. Quien decide es el navegador, aplicando las cabeceras estándar, exactamente igual que con fetch. Por eso las cabeceras que emite tu API funcionan «gratis» en Angular. Lo que sí puedes hacer es una caché en memoria con un interceptor, que es otra cosa: sobrevive solo a la sesión de la pestaña, la controlas tú y sirve para evitar peticiones duplicadas en ráfaga cuando varios componentes piden lo mismo a la vez. Y hay un detalle práctico importante en las peticiones de Angular Universal: durante el renderizado en servidor, HttpClient guarda las respuestas en el TransferState para que el cliente no las repita, lo cual es un mecanismo distinto de la caché HTTP y no obedece a Cache-Control.
apps/web/src/app/core/cache.interceptor.ts
// Caché en memoria de corta vida con deduplicación de peticiones en vuelo.
// Resuelve un problema concreto: tres componentes que piden el mismo
// catálogo al arrancar generan tres peticiones idénticas simultáneas.
const enCurso = new Map<string, Observable<HttpEvent<unknown>>>();
const almacen = new Map<string, { evento: HttpEvent<unknown>; hasta: number }>();

export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
  // Solo GET: cachear una escritura sería un error grave.
  if (req.method !== 'GET' || req.headers.has('x-sin-cache')) return next(req);

  const clave = req.urlWithParams;
  const guardado = almacen.get(clave);
  if (guardado && guardado.hasta > Date.now()) return of(guardado.evento);

  // Deduplicación: si ya hay una petición idéntica en vuelo, nos unimos.
  const vuelo = enCurso.get(clave);
  if (vuelo) return vuelo;

  const peticion = next(req).pipe(
    tap((evento) => {
      if (evento instanceof HttpResponse) {
        almacen.set(clave, { evento, hasta: Date.now() + 30_000 });
      }
    }),
    finalize(() => enCurso.delete(clave)),
    shareReplay({ bufferSize: 1, refCount: true }),
  );

  enCurso.set(clave, peticion);
  return peticion;
};

25.9 Concurrencia: bloqueo optimista sobre HTTP

Ana y Luis abren la misma tarea. Ana cambia la prioridad a ALTA y guarda. Luis, que tenía la pantalla abierta desde antes, cambia el título y guarda treinta segundos después. Con una implementación ingenua, el guardado de Luis escribe el objeto completo tal como él lo tenía y el cambio de Ana desaparece sin dejar rastro. Nadie ve un error. Es la actualización perdida, y es el problema de concurrencia más común en aplicaciones de gestión.

HTTP tiene una solución nativa y elegante: peticiones condicionales de escritura. El mismo ETag que sirve para la caché sirve para el control de concurrencia. El cliente envía If-Match: "v8-91cd02", que significa «aplica este cambio solo si el recurso sigue en la versión que yo leí». Si no coincide, el servidor responde 412 Precondition Failed y no escribe nada.

flujo de bloqueo optimista
# 1 · Ana y Luis leen la misma tarea
GET /api/v1/tasks/42
<- 200 OK   ETag: "v8-91cd02"      (los dos reciben el mismo ETag)

# 2 · Ana guarda primero
PATCH /api/v1/tasks/42        If-Match: "v8-91cd02"
{"prioridad":"ALTA"}
<- 200 OK   ETag: "v9-4ab7de"      (la versión avanza)

# 3 · Luis guarda con el ETag que leyó hace treinta segundos
PATCH /api/v1/tasks/42        If-Match: "v8-91cd02"
{"titulo":"Revisar contrato marco"}
<- 412 Precondition Failed         (no se escribe NADA)
   {
     "type":"https://api.taskflow.dev/errors/conflicto-edicion",
     "title":"La tarea ha cambiado desde que la abriste",
     "status":412,
     "detail":"Ana Ruiz la modificó hace 30 segundos.",
     "versionActual":9,
     "camposCambiados":["prioridad"]
   }

La conexión con MikroORM es directa: la columna anotada con @Property({ version: true }) del capítulo 17 es el estado del que deriva el ETag. El ORM ya incrementa esa columna en cada flush y ya añade WHERE version = ? al UPDATE, lanzando OptimisticLockError si no afecta a ninguna fila. Lo único que falta es traducir esa mecánica de persistencia al lenguaje de HTTP.

apps/api/src/tasks/tasks.controller.ts
@Patch(':id')
async actualizar(
  @Param('id', ParseIntPipe) id: number,
  @Body() dto: PatchTaskDto,
  @Headers('if-match') ifMatch: string | undefined,
  @Res({ passthrough: true }) res: Response,
): Promise<TaskDto> {
  // 428: exigimos la precondición en un recurso con edición concurrente.
  // Es preferible a aceptar la escritura a ciegas: obliga al cliente a
  // participar en el control de concurrencia en lugar de ignorarlo.
  if (!ifMatch) {
    throw new PreconditionRequiredException(
      'Se requiere la cabecera If-Match con el ETag obtenido en el GET');
  }

  const versionEsperada = extraerVersion(ifMatch);   // "v8-…" -> 8

  try {
    // MikroORM comprueba la versión dentro del UPDATE, de forma atómica.
    // Comprobarla antes con un SELECT dejaría una ventana de carrera.
    const tarea = await this.tasks.actualizarConVersion(id, dto, versionEsperada);
    res.setHeader('ETag', etagDe(tarea));
    return TaskDto.desde(tarea);
  } catch (e) {
    if (e instanceof OptimisticLockError) {
      // 412 y no 409: el cliente envió una precondición y no se cumplió.
      throw new PreconditionFailedException(await this.tasks.detalleConflicto(id));
    }
    throw e;
  }
}
apps/api/src/tasks/tasks.service.ts
async actualizarConVersion(id: number, parche: PatchTaskDto, version: number) {
  // lockVersion delega en el mecanismo del ORM: el UPDATE resultante
  // lleva "WHERE id = ? AND version = ?", y si afecta a 0 filas se
  // lanza OptimisticLockError. Es una comprobación ATÓMICA en la base
  // de datos, no una comparación en memoria.
  const tarea = await this.em.findOneOrFail(Task, id, {
    lockMode: LockMode.OPTIMISTIC,
    lockVersion: version,
  });

  this.em.assign(tarea, parche);
  await this.em.flush();
  return tarea;
}
Qué hacer en la interfaz con un 412 Un 412 no es un error del usuario y no debe presentarse como tal. Lo peor que puedes hacer es un «error al guardar» que le haga perder lo escrito. Lo mínimo aceptable es conservar sus cambios en el formulario y ofrecer «recargar y volver a aplicar». Lo bueno es mostrar qué campos cambió la otra persona y permitir fusionar campo a campo: si Ana tocó la prioridad y Luis el título, no hay conflicto real y la interfaz puede resolverlo sola. El campo camposCambiados del cuerpo del error existe precisamente para hacer posible esa fusión.
Bloqueo optimista frente a pesimista El optimista supone que los conflictos son raros: no bloquea nada y detecta el choque al escribir. El pesimista bloquea la fila (SELECT … FOR UPDATE) e impide que nadie más la toque. Para una API HTTP el optimista es casi siempre la respuesta correcta, porque HTTP no tiene sesión: mantener un bloqueo entre el GET y el PATCH significaría bloquear una fila durante el tiempo que el usuario tarde en escribir, que puede ser una hora o para siempre si cierra la pestaña. El pesimista se reserva para transacciones cortas dentro de una única petición, como descontar existencias.

25.10 Idempotencia: por qué las redes obligan a pensarla

El usuario pulsa «Crear tarea». La petición llega al servidor, la tarea se crea, y justo entonces se corta la conexión móvil. El cliente no recibe respuesta y no puede distinguir dos escenarios muy distintos: que la petición nunca llegara o que llegara y se perdiera la respuesta. Si reintenta, puede crear una tarea duplicada; si no reintenta, puede perder el trabajo del usuario. Este problema no tiene solución sin cooperación del servidor: es una propiedad fundamental de los sistemas distribuidos, no un fallo de programación.

La solución estándar, la que usan Stripe desde 2015 y hoy casi toda pasarela de pago, es la clave de idempotencia: el cliente genera un identificador único por intención —no por intento— y lo envía en una cabecera. El servidor recuerda qué hizo con esa clave y, si la vuelve a ver, devuelve el mismo resultado en lugar de ejecutar la operación otra vez. El IETF está normalizando la cabecera Idempotency-Key; hasta que el documento sea RFC, es un estándar de facto.

  FLUJO DE UNA CLAVE DE IDEMPOTENCIA

  El cliente genera la clave UNA VEZ por intención del usuario
  (al abrir el formulario, no al pulsar el botón: si la genera al pulsar,
   dos pulsaciones = dos claves = dos tareas, y no hemos resuelto nada)

  ┌─ INTENTO 1 ─────────────────────────────────────────────────────────┐
  │ POST /api/v1/projects/9/tasks                                       │
  │ Idempotency-Key: 0f1b1a6c-3a2e-4f0b-9d84-2c1c9a0f77e1               │
  │ {"titulo":"Revisar contrato"}                                       │
  └───────────────────────┬─────────────────────────────────────────────┘
                          ▼
        ┌───────────────────────────────────────────┐
        │ ¿Existe la clave en el almacén?           │
        └───┬───────────────────────────────────┬───┘
           NO                                  SÍ
            │                                   │
            ▼                                   ▼
  ┌─────────────────────┐         ┌──────────────────────────────────┐
  │ RESERVA la clave    │         │ ¿Coincide el hash del cuerpo?    │
  │ estado = EN_CURSO   │         └──┬────────────────────────────┬──┘
  │ (INSERT con índice  │           SÍ                           NO
  │  único: si dos      │            │                            │
  │  peticiones corren  │            ▼                            ▼
  │  a la vez, una      │   ┌──────────────────┐   ┌───────────────────────────┐
  │  pierde y va al     │   │ ¿estado?         │   │ 422 Unprocessable Content │
  │  camino de la       │   ├──────────────────┤   │ "Esa clave ya se usó con  │
  │  derecha)           │   │ EN_CURSO -> 409  │   │  un cuerpo distinto"      │
  └──────────┬──────────┘   │  (aún procesando)│   │ NO se ejecuta nada        │
             ▼              │ HECHO -> devuelve│   └───────────────────────────┘
  ┌─────────────────────┐   │  la respuesta    │
  │ EJECUTA la          │   │  GUARDADA tal    │
  │ operación de        │   │  cual: mismo     │
  │ negocio             │   │  código, mismo   │
  │ (crea la tarea)     │   │  cuerpo, misma   │
  └──────────┬──────────┘   │  cabecera        │
             ▼              │  Location        │
  ┌─────────────────────┐   └──────────────────┘
  │ GUARDA la respuesta │
  │ estado = HECHO      │   Todo dentro de la MISMA transacción que la
  │ + código + cuerpo   │   operación de negocio: si el commit falla,
  │ + Location          │   la clave no queda marcada como HECHO.
  │ caduca en 24 h      │
  └──────────┬──────────┘
             ▼
        201 Created
        Location: /api/v1/tasks/42
        Idempotent-Replay: false        <- en el reintento valdrá true
apps/api/src/common/idempotency/idempotency.entity.ts
@Entity({ tableName: 'idempotency_key' })
@Unique({ properties: ['clave', 'usuarioId', 'endpoint'] })
export class ClaveIdempotencia {
  @PrimaryKey() id!: number;

  // La clave se acota por usuario y por endpoint: dos clientes distintos
  // pueden generar el mismo UUID (improbable) y, sobre todo, un usuario no
  // debe poder leer la respuesta guardada de otro reutilizando su clave.
  @Property({ length: 255 }) clave!: string;
  @Property() usuarioId!: number;
  @Property({ length: 255 }) endpoint!: string;

  // Hash del cuerpo: detecta la reutilización de una clave con contenido
  // distinto, que casi siempre indica un error en el cliente.
  @Property({ length: 64 }) hashCuerpo!: string;

  @Enum(() => EstadoClave) estado!: EstadoClave;   // EN_CURSO | HECHO

  @Property({ type: 'int', nullable: true })  codigoRespuesta?: number;
  @Property({ type: 'json', nullable: true }) cuerpoRespuesta?: unknown;
  @Property({ type: 'json', nullable: true }) cabeceras?: Record<string, string>;

  @Property() creadaEn: Date = new Date();

  // Ventana de validez. 24 h es el valor de Stripe y un buen punto de
  // partida: suficiente para cubrir reintentos y cortes de red, y corto
  // para que la tabla no crezca sin control. Un trabajo programado borra
  // las caducadas; sin él, esta tabla acaba siendo la mayor del sistema.
  @Property() caducaEn!: Date;
}
apps/api/src/common/idempotency/idempotency.interceptor.ts
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
  constructor(private readonly em: EntityManager) {}

  async intercept(ctx: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
    const req = ctx.switchToHttp().getRequest<Request>();
    const res = ctx.switchToHttp().getResponse<Response>();

    // Solo tiene sentido en métodos NO idempotentes por naturaleza.
    const clave = req.header('Idempotency-Key');
    if (req.method !== 'POST' || !clave) return next.handle();

    if (!UUID_V4.test(clave)) {
      throw new BadRequestException('Idempotency-Key debe ser un UUID v4');
    }

    const hash = createHash('sha256')
      .update(JSON.stringify(req.body ?? {})).digest('hex');
    const identidad = { clave, usuarioId: req.user!.id, endpoint: rutaDe(req) };

    const previa = await this.em.findOne(ClaveIdempotencia, identidad);

    if (previa) {
      if (previa.hashCuerpo !== hash) {
        // Misma clave, cuerpo distinto: casi siempre un bug del cliente
        // que reutiliza la clave. Rechazamos SIN ejecutar nada.
        throw new UnprocessableEntityException(
          'Esa clave de idempotencia ya se usó con un cuerpo distinto');
      }
      if (previa.estado === EstadoClave.EN_CURSO) {
        // La primera petición sigue procesándose. 409 con Retry-After
        // para que el cliente espere en lugar de martillear.
        res.setHeader('Retry-After', '2');
        throw new ConflictException('La operación original aún se está procesando');
      }
      // Reproducimos la respuesta guardada: mismo código, mismo cuerpo.
      res.status(previa.codigoRespuesta!);
      for (const [k, v] of Object.entries(previa.cabeceras ?? {})) res.setHeader(k, v);
      res.setHeader('Idempotent-Replay', 'true');
      return of(previa.cuerpoRespuesta);
    }

    // Reserva. El índice único hace de árbitro si dos peticiones idénticas
    // llegan a la vez: la que pierde el INSERT recibe 409 y reintentará.
    const registro = this.em.create(ClaveIdempotencia, {
      ...identidad, hashCuerpo: hash, estado: EstadoClave.EN_CURSO,
      caducaEn: new Date(Date.now() + 24 * 3600_000),
    });
    try {
      await this.em.persistAndFlush(registro);
    } catch (e) {
      if (e instanceof UniqueConstraintViolationException) {
        res.setHeader('Retry-After', '2');
        throw new ConflictException('Operación en curso con esa clave');
      }
      throw e;
    }

    return next.handle().pipe(
      tap(async (cuerpo) => {
        registro.estado = EstadoClave.HECHO;
        registro.codigoRespuesta = res.statusCode;
        registro.cuerpoRespuesta = cuerpo;
        const location = res.getHeader('Location');
        if (location) registro.cabeceras = { Location: String(location) };
        await this.em.flush();
      }),
      catchError((error) => {
        // Un fallo NO debe quedar guardado como resultado definitivo:
        // el cliente tiene derecho a reintentar con la misma clave.
        return from(this.em.nativeDelete(ClaveIdempotencia, identidad))
          .pipe(switchMap(() => throwError(() => error)));
      }),
    );
  }
}
Tres detalles que arruinan una implementación de idempotencia El primero: generar la clave al pulsar el botón. Debe generarse cuando nace la intención (al abrir el formulario o al montar el componente) y reutilizarse en todos los reintentos de esa intención; si se genera en el click, dos pulsaciones rápidas producen dos claves y dos tareas. El segundo: guardar el resultado fuera de la transacción de negocio, con lo que puede quedar una tarea creada y una clave sin marcar, o al revés. El tercero: no caducar las claves, y descubrir seis meses después que la tabla de idempotencia ocupa más que el resto de la base de datos junta.
apps/web/src/app/tasks/feature/crear-tarea.component.ts
@Component({ /* … */ })
export class CrearTareaComponent {
  private readonly api = inject(TaskApiService);

  // La clave nace con la INTENCIÓN, no con el clic. Si el usuario pulsa
  // tres veces o la red falla y reintentamos, la clave es la misma y el
  // servidor crea UNA tarea. Se renueva solo al empezar una tarea nueva.
  private claveIdempotencia = crypto.randomUUID();

  readonly form = inject(FormBuilder).nonNullable.group({
    titulo: ['', [Validators.required, Validators.minLength(3)]],
  });

  guardar(): void {
    this.api.crear(this.proyectoId(), this.form.getRawValue(), this.claveIdempotencia)
      .pipe(
        // Reintentos con retroceso exponencial: seguros porque la clave
        // garantiza que no se duplicará nada.
        retry({ count: 3, delay: (_e, i) => timer(500 * 2 ** i) }),
      )
      .subscribe({
        next: () => {
          this.claveIdempotencia = crypto.randomUUID();  // nueva intención
          this.form.reset();
        },
        error: (e: HttpErrorResponse) => this.mostrarProblema(e),
      });
  }
}

25.11 Operaciones largas: el patrón del trabajo asíncrono

Generar el informe anual de un proyecto con cuatro mil tareas puede tardar cuarenta segundos. Mantener una petición HTTP abierta durante ese tiempo es una mala idea por cuatro razones concretas: el proxy inverso probablemente la cortará a los treinta segundos con un 504, el navegador ocupa una de sus conexiones, un reintento del cliente dispara el trabajo dos veces, y si el proceso de Node se reinicia durante un despliegue el trabajo se pierde sin que nadie se entere. La respuesta correcta no es subir los tiempos de espera: es separar la aceptación del trabajo de su ejecución.

El patrón tiene tres piezas. Primera: el POST encola el trabajo y responde 202 Accepted con la URL de un recurso de estado en Location. Segunda: ese recurso de estado es un recurso normal, con GET, que informa del progreso. Tercera: cuando termina, el recurso de estado apunta al resultado, normalmente con un 303 See Other hacia la URL de descarga o con un campo resultado en el cuerpo. Fíjate en que el trabajo es un recurso de primera clase: se puede consultar, cancelar con DELETE y listar.

flujo completo de un trabajo asíncrono
# 1 · Solicitar. La clave de idempotencia evita encolar dos veces
#     el mismo informe si el cliente reintenta.
POST /api/v1/projects/9/exports        Idempotency-Key: 0f1b1a6c-…
{"formato":"xlsx","desde":"2026-01-01","hasta":"2026-07-31"}

<- 202 Accepted
   Location: /api/v1/jobs/7f3a2b
   Retry-After: 5                    # no sondees antes de 5 segundos
   {"jobId":"7f3a2b","estado":"ENCOLADO","progreso":0}

# 2 · Sondear el recurso de estado, respetando SIEMPRE Retry-After
GET /api/v1/jobs/7f3a2b
<- 200 OK   Retry-After: 5
   {"jobId":"7f3a2b","estado":"EN_CURSO","progreso":42,
    "iniciadoEn":"2026-07-31T17:04:22Z"}

# 3 · Terminado. Dos formas válidas de comunicar el resultado:
#     (a) 303 hacia el recurso producido, que es lo más REST
GET /api/v1/jobs/7f3a2b
<- 303 See Other
   Location: /api/v1/exports/7f3a2b.xlsx

#     (b) 200 con el estado y un enlace, más cómodo para un SPA
<- 200 OK
   {"jobId":"7f3a2b","estado":"COMPLETADO","progreso":100,
    "resultado":{"url":"/api/v1/exports/7f3a2b.xlsx","bytes":184320}}

# 4 · Y si falló, el estado lo dice con un Problem Details dentro.
#     Ojo: el GET del estado devuelve 200, porque CONSULTAR el estado
#     ha funcionado. El fallo está en el trabajo, no en la consulta.
<- 200 OK
   {"jobId":"7f3a2b","estado":"FALLIDO",
    "error":{"type":"…/exportacion-fallida","title":"No se pudo generar",
             "detail":"El proyecto no tiene tareas en ese rango."}}

El punto 4 es el que casi todo el mundo hace mal: responder 500 al GET del estado porque el trabajo falló. Son dos cosas distintas. La consulta del estado ha funcionado perfectamente y debe devolver 200; el fracaso del trabajo es información dentro del cuerpo. Devolver 500 hace que el cliente reintente la consulta indefinidamente y que tu monitorización cuente errores que no existen.

El sondeo es la alternativa más simple y funciona en cualquier cliente, pero desperdicia peticiones: hay que respetar Retry-After y aplicar retroceso exponencial con un techo. Las alternativas son los eventos enviados por el servidor (text/event-stream), que son HTTP normal y corriente, unidireccionales, reconectan solos y encajan muy bien con «notifícame cuando termine»; los WebSockets, que aportan bidireccionalidad a cambio de un protocolo distinto con su propia autenticación y su propio escalado (capítulo 11); y los webhooks, que son la opción correcta cuando quien espera el resultado es otro servidor y no un navegador (25.15). La regla práctica: sondeo si el trabajo dura segundos, eventos del servidor si dura minutos y el usuario está mirando, webhook si el consumidor es una máquina.

25.12 Contenido: negociación, ficheros y compresión

La negociación de contenido es el mecanismo por el que cliente y servidor acuerdan el formato. El cliente expresa preferencias con Accept, Accept-Language y Accept-Encoding, con factores de calidad entre 0 y 1; el servidor elige y lo declara en Content-Type, Content-Language y Content-Encoding, y debe añadir Vary con las cabeceras que influyeron en su decisión, o las cachés compartidas servirán la variante equivocada. Si no puede satisfacer el Accept, responde 406; si no entiende el Content-Type que recibe, responde 415.

Sobre tipos de medio, la recomendación es usar los estándar (application/json, application/problem+json, application/merge-patch+json) y resistirse a inventar tipos propios con versión (application/vnd.taskflow.v2+json) salvo que tengas una razón fuerte: son elegantes sobre el papel y en la práctica dificultan las pruebas manuales, la caché y el trabajo de las herramientas.

La subida de ficheros tiene dos enfoques y la elección importa más de lo que parece. Con multipart/form-data el fichero viaja a través de tu API: es simple de implementar y de probar, pero el fichero atraviesa tu proceso de Node consumiendo memoria o disco temporal, ocupa una conexión durante toda la subida, choca con el límite de tamaño del proxy y con el tiempo de espera del balanceador, y te obliga a escalar la aplicación por un motivo que no tiene nada que ver con tu lógica de negocio. Con URL prefirmada, el cliente pide permiso a tu API, recibe una URL temporal y sube el fichero directamente al almacenamiento de objetos (S3, GCS, R2, MinIO), y después confirma. Tu servidor nunca ve los bytes.

subida a través de la APIINCORRECTO para ficheros grandes
// Un adjunto de 80 MB atraviesa Node entero.
// Con 20 subidas simultáneas son 1,6 GB de
// memoria o de disco temporal, el proxy corta
// a los 30 s y el despliegue de esta tarde
// aborta todas las subidas en curso.
@Post(':id/attachments')
@UseInterceptors(FileInterceptor('fichero'))
async subir(@UploadedFile() f: Express.Multer.File) {
  return this.s3.putObject({          // segunda copia
    Bucket: 'taskflow', Key: f.originalname,
    Body: f.buffer,                   // TODO en memoria
  });
}
URL prefirmadaCORRECTO
// La API solo autoriza y firma; los bytes van
// del navegador al almacenamiento. Node no toca
// el fichero, y la subida no depende de tus
// despliegues ni de tu memoria.
@Post(':id/attachments')
async prepararSubida(@Param('id') id: number,
                     @Body() dto: PrepararSubidaDto) {
  await this.tasks.comprobarPermisoEscritura(id);

  const clave = `tasks/${id}/${randomUUID()}`;
  // La firma FIJA tipo y tamaño máximo: sin esto,
  // la URL permite subir cualquier cosa.
  const url = await this.s3.urlDeSubida(clave, {
    contentType: dto.tipoMime,
    maxBytes: 25 * 1024 * 1024,
    expiraEnSegundos: 300,
  });
  return { url, clave, expiraEn: 300 };
}

// Y un POST de confirmación registra el adjunto
// una vez subido, comprobando tamaño y tipo real.

Para las descargas, la cabecera clave es Content-Disposition: attachment; filename="informe.xlsx", y conviene incluir la variante filename*=UTF-8''… para nombres con acentos. Si el fichero es grande, devuélvelo como flujo (StreamableFile en NestJS) en lugar de cargarlo en memoria, y admite Range para permitir descargas reanudables y reproducción de vídeo con respuestas 206. Nunca construyas la ruta del fichero concatenando algo que venga del cliente: es la vía directa al recorrido de directorios.

Sobre compresión: activa gzip o, mejor, brotli, pero hazlo en el proxy inverso y no en Node, porque comprimir es trabajo intensivo de CPU y el bucle de eventos es un recurso demasiado valioso para gastarlo en eso. Un JSON típico se reduce entre un 70 y un 85 por ciento, así que el ahorro es enorme. Dos matices: no comprimas lo que ya está comprimido (imágenes, PDF, ZIP), porque solo gastas CPU; y ten presente que comprimir respuestas que mezclan datos secretos con datos que controla el atacante puede filtrar información por el tamaño, que es la familia de ataques BREACH y CRIME. Para una API JSON autenticada el riesgo es bajo, pero es la razón por la que no debes comprimir respuestas que contengan tokens.

25.13 Versionado y evolución

La pregunta correcta no es «¿cómo versiono mi API?», sino «¿cómo evoluciono sin versionar?». Cada versión activa es una copia del contrato que hay que mantener, probar y documentar; el objetivo es tener las menos posibles. Y para eso hay que saber con precisión qué es un cambio rompedor.

Cambios NO rompedores

  • Añadir un endpoint nuevo.
  • Añadir un campo opcional en una petición.
  • Añadir un campo nuevo en una respuesta (si tus clientes ignoran lo desconocido, y deben hacerlo).
  • Añadir un valor nuevo a un enum de entrada.
  • Añadir una cabecera opcional.
  • Relajar una validación: aceptar más de lo que aceptabas.
  • Añadir un código de error nuevo dentro de una familia ya documentada.

Cambios ROMPEDORES

  • Eliminar o renombrar un campo, un endpoint o un parámetro.
  • Cambiar el tipo de un campo, incluido pasar de número a cadena «porque JavaScript pierde precisión».
  • Hacer obligatorio un campo que era opcional.
  • Añadir un valor a un enum de salida: el cliente tiene un switch que no lo contempla.
  • Cambiar el código de estado de una situación existente.
  • Cambiar el formato de un valor: "2026-07-31" a "31/07/2026".
  • Cambiar el orden por defecto o el tamaño de página por defecto.
  • Endurecer una validación: rechazar lo que antes aceptabas.
Los dos cambios rompedores que nadie ve venir Añadir un valor a un enum de salida rompe a cualquier cliente con un switch exhaustivo, y en TypeScript el compilador del cliente ni siquiera avisa porque su definición del tipo es la vieja. Y endurecer una validación rompe a los clientes que llevaban meses enviando datos que tú aceptabas por descuido. Ambos parecen mejoras inocentes y ambos generan incidencias. Si tienes que añadir un estado nuevo a las tareas, documenta desde el primer día que la lista de estados es abierta y que los clientes deben tener una rama por defecto.
EstrategiaEjemploA favorEn contra
En la ruta/api/v1/tasksExplícita, visible en logs y métricas, trivial de enrutar y de probar con curl; NestJS la soporta de serieTécnicamente «poco REST»: la URI de un recurso debería ser una; duplica rutas
En cabecera propiaX-API-Version: 2La URI del recurso se mantiene estableInvisible en los logs por defecto, incómoda de probar a mano, fácil de olvidar
Por tipo de medioAccept: application/vnd.taskflow.v2+jsonLa más purista; permite versionar recurso a recursoLa más incómoda de todas en la práctica; complica caché y herramientas
Por fechaX-API-Date: 2026-07-31Muy granular; el cliente fija el día en que integró y no le afecta nada posterior (lo usa Stripe)Exige una infraestructura de transformaciones encadenadas: caro de construir
Sin versión/api/tasks, solo cambios compatiblesCero coste de mantenimiento; obliga a la disciplina correctaAntes o después necesitas un cambio rompedor

La recomendación para una aplicación como TaskFlow es versión en la ruta, una sola versión activa casi siempre, y disciplina de compatibilidad para todo lo demás. En NestJS se configura con app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }) y se marca por controlador con @Controller({ path: 'tasks', version: '2' }), lo que permite convivir v1 y v2 solo en los recursos que realmente cambiaron.

La deprecación es un proceso, no un anuncio. Los pasos que funcionan: primero, publicar la versión nueva y documentar la migración con ejemplos concretos, no con una nota genérica. Segundo, emitir en las respuestas de la versión antigua las cabeceras Deprecation: true y Sunset: Sat, 31 Jan 2027 23:59:59 GMT (RFC 8594) más un Link con rel="deprecation" hacia la guía. Tercero —y este es el paso que se salta todo el mundo— medir quién sigue usándola: un contador por versión, por cliente y por endpoint te dice si puedes retirar algo o si vas a romper la integración de tu mayor cliente. Cuarto, avisar activamente a quienes aparecen en esas métricas. Quinto, un «apagón de ensayo» de una hora en una fecha anunciada, que descubre a los integradores que no leyeron ningún aviso. Y sexto, retirar respondiendo 410 Gone con un cuerpo que explique dónde está la versión nueva.

25.14 El modelo de madurez de Richardson y HATEOAS

Leonard Richardson propuso en 2008 una escala de cuatro niveles que se ha convertido en la forma habitual de discutir cuán «REST» es una API. Es una herramienta de análisis útil, no una nota de examen.

NivelNombreQué proponeEjemplo
0El pantano del POXUn único endpoint, un único método, la operación va en el cuerpo. HTTP se usa como túnelPOST /api con {"accion":"listarTareas"}
1RecursosAparecen URI distintas por cosa, pero todo sigue siendo POSTPOST /tasks/42 con {"accion":"borrar"}
2Verbos y códigos HTTPMétodos con su semántica y códigos de estado con criterio. Aquí está el 95% de las APIs buenasDELETE /tasks/42 → 204
3Controles hipermedia (HATEOAS)Las respuestas incluyen los enlaces a las transiciones posibles desde el estado actualLa tarea incluye links con completar y archivar, y no con reabrir

La pregunta honesta es por qué casi nadie llega al nivel 3, cuando en la tesis de Fielding la hipermedia no es un extra sino el rasgo definitorio de REST. Las razones son prácticas. Los clientes reales no descubren: un desarrollador de Angular lee la documentación, escribe la llamada y sigue; nadie programa un cliente que navegue enlaces en tiempo de ejecución, salvo en escenarios muy concretos. La hipermedia añade peso a cada respuesta y complejidad al servidor. Y los generadores de clientes tipados a partir de OpenAPI producen justamente lo contrario: código que conoce las rutas de antemano, que es lo que la gente quiere.

Dicho esto, hay tres casos donde los enlaces sí aportan valor real y merece la pena incluirlos. El primero es la paginación: links.next ya lo usábamos en 25.6 y evita que el cliente sepa construir cursores. El segundo son los flujos con estado, donde las acciones disponibles dependen del estado actual: si la respuesta de una tarea incluye qué transiciones son posibles, la interfaz puede pintar los botones correctos sin duplicar la máquina de estados del servidor, que es una fuente clásica de divergencia entre lo que el botón ofrece y lo que el servidor acepta. El tercero son los recursos relacionados y las descargas temporales, donde una URL firmada con caducidad no puede construirla el cliente.

hipermedia útil, sin ceremonia
{
  "id": 42,
  "titulo": "Revisar contrato del proveedor",
  "estado": "EN_CURSO",
  "version": 8,
  "_links": {
    "self":     { "href": "/api/v1/tasks/42" },
    "completar":{ "href": "/api/v1/tasks/42", "method": "PATCH",
                  "body": { "estado": "HECHA" } },
    "archivar": { "href": "/api/v1/tasks/42/archive", "method": "PUT" },
    "adjuntos": { "href": "/api/v1/tasks/42/attachments" }
  }
}
// No aparece "reabrir" porque la tarea no está cerrada: la interfaz
// dibuja los botones que el servidor ofrece y no puede desincronizarse.
// Esto es HATEOAS pragmático: enlaces donde aportan, no en todas partes.

25.15 Webhooks: cuando la API llama al cliente

Un webhook invierte la dirección de la llamada: en lugar de que el cliente pregunte, tu servidor avisa. Es la forma correcta de notificar a otro sistema («la tarea 42 se ha completado») sin obligarlo a sondear cada diez segundos. Y es también una funcionalidad que parece trivial y no lo es, porque al emitir webhooks te conviertes en cliente HTTP de servidores que no controlas, con toda la incertidumbre que eso implica.

El diseño de la entrega empieza por el cuerpo. Un evento debe llevar un identificador único (para que el receptor pueda descartar duplicados), un tipo (task.completed), una marca de tiempo, un número de versión del esquema del evento y los datos. Sobre los datos hay una decisión importante: enviar el objeto completo es cómodo pero puede quedar obsoleto si llegan dos eventos desordenados, mientras que enviar solo los identificadores obliga al receptor a consultar tu API y garantiza que lea el estado actual. Un punto intermedio razonable es enviar el objeto completo más un número de versión, para que el receptor pueda descartar lo viejo.

La firma es obligatoria, no opcional. Sin ella, cualquiera que descubra la URL del receptor puede inventarse eventos: «el pago se ha confirmado» enviado por un desconocido. El mecanismo estándar es un HMAC-SHA256 sobre el cuerpo en crudo más una marca de tiempo, con un secreto compartido por suscripción. Los dos detalles que se hacen mal son firmar el JSON reserializado en lugar de los bytes recibidos —cualquier diferencia de espacios o de orden de claves invalida la firma— y comparar con === en lugar de con una comparación de tiempo constante.

apps/api/src/webhooks/firma.ts
// EMISOR: la marca de tiempo va DENTRO de lo firmado para que un
// atacante no pueda reenviar un evento antiguo capturado (repetición).
export function firmar(cuerpoCrudo: Buffer, secreto: string): string {
  const t = Math.floor(Date.now() / 1000);
  const firma = createHmac('sha256', secreto)
    .update(`${t}.`).update(cuerpoCrudo).digest('hex');
  return `t=${t},v1=${firma}`;          // cabecera X-TaskFlow-Signature
}

// RECEPTOR: verificación en tiempo constante y ventana temporal.
export function verificar(cuerpoCrudo: Buffer, cabecera: string, secreto: string): boolean {
  const partes = Object.fromEntries(
    cabecera.split(',').map((p) => p.split('=') as [string, string]));
  const t = Number(partes['t']);

  // Fuera de la ventana de 5 minutos: se rechaza aunque la firma sea
  // válida. Sin esto, un evento capturado sirve para siempre.
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const esperada = createHmac('sha256', secreto)
    .update(`${t}.`).update(cuerpoCrudo).digest();
  const recibida = Buffer.from(partes['v1'] ?? '', 'hex');

  // timingSafeEqual exige la misma longitud: comprobarla antes evita
  // que lance. Y NUNCA uses === : filtra información por temporización.
  return recibida.length === esperada.length &&
         timingSafeEqual(recibida, esperada);
}

Para que la verificación sea posible, el receptor necesita el cuerpo sin procesar. En NestJS se consigue creando la aplicación con rawBody: true y leyendo req.rawBody; si el analizador de JSON ya ha transformado el cuerpo, los bytes originales se han perdido y la firma nunca coincidirá. Es la causa número uno de «mi verificación de webhook falla siempre».

Los reintentos son la otra mitad del problema. El receptor estará caído alguna vez, así que hay que reintentar con retroceso exponencial y un poco de aleatoriedad —por ejemplo a los 10 segundos, 1 minuto, 5 minutos, 30 minutos, 2 horas y 12 horas— y desistir después, marcando la suscripción como problemática si acumula fallos. Se reintenta ante 5xx, ante tiempo de espera agotado y ante 429 (respetando Retry-After), pero no ante 4xx: un 400 o un 422 significan que el receptor no va a aceptar ese evento por mucho que insistas. Impón además un tiempo de espera corto, de unos cinco segundos, para que un receptor lento no bloquee tu cola.

Como consecuencia inevitable de los reintentos, el receptor recibirá eventos duplicados: la entrega es «al menos una vez», nunca «exactamente una vez». Por eso el identificador del evento es obligatorio y por eso el receptor debe guardarlo y descartar los repetidos, que es exactamente la misma técnica de 25.10 aplicada del otro lado. El otro consejo práctico para el receptor: responder 2xx de inmediato y procesar en segundo plano; procesar de forma síncrona provoca tiempos de espera agotados que el emisor interpreta como fallo, y acabas reprocesando lo que ya funcionó.

Probar webhooks en local Tu localhost no es accesible desde internet, así que hay tres caminos. El primero, un túnel (ngrok, cloudflared, localtunnel) que expone tu puerto con una URL pública temporal; es lo más cómodo para depurar contra un emisor real. El segundo, la CLI del proveedor cuando existe (stripe listen --forward-to es el ejemplo canónico), que además firma correctamente. Y el tercero, y el que debe estar en tu suite: tests de integración que construyan el cuerpo, lo firmen con el secreto de prueba y llamen al endpoint, incluyendo los casos de firma inválida, marca de tiempo caducada y evento duplicado. Un servicio de captura como webhook.site sirve para inspeccionar lo que envías, pero no sustituye a las pruebas.

25.16 OpenAPI como contrato

Un contrato que solo existe en la cabeza del equipo no es un contrato. OpenAPI (antes Swagger) es la forma estándar de describir una API HTTP en un documento JSON o YAML legible por máquinas, y a partir de ese documento se genera documentación navegable, clientes tipados, servidores simulados, colecciones de pruebas y validaciones automáticas de compatibilidad. La versión 3.1 es la actual y su gran ventaja es que su esquema es JSON Schema completo, lo que elimina las incompatibilidades históricas.

Hay dos filosofías. Contrato primero significa escribir el YAML antes que el código: obliga a diseñar antes de implementar y permite que el equipo de frontend empiece contra un simulador el primer día, a costa de mantener el documento a mano. Código primero significa generarlo desde los decoradores de NestJS: no se desincroniza y cuesta poco, a costa de que el contrato tienda a ser un reflejo de la implementación en lugar de un diseño. Para un equipo full-stack que controla ambos lados, código primero con revisión del documento generado en cada pull request es el equilibrio más práctico.

apps/api/src/tasks/tasks.controller.ts
@ApiTags('Tareas')
@ApiBearerAuth()
@Controller({ path: 'projects/:projectId/tasks', version: '1' })
export class TasksController {
  @Post()
  @ApiOperation({
    summary: 'Crear una tarea en un proyecto',
    description: 'Admite Idempotency-Key para reintentos seguros (ver 25.10).',
  })
  @ApiHeader({ name: 'Idempotency-Key', required: false,
               description: 'UUID v4 generado por el cliente por cada intención' })
  @ApiCreatedResponse({ type: TaskDto, description: 'Tarea creada; Location apunta a ella' })
  @ApiResponse({ status: 422, type: ProblemaValidacionDto,
                 description: 'El cuerpo es válido como JSON pero viola una regla' })
  @ApiResponse({ status: 409, type: ProblemaDto, description: 'Ya existe una tarea igual' })
  crear(/* … */) { /* … */ }
}

// La clave para que el documento sea FIEL sin escribir el doble: el plugin
// de Swagger para la CLI de Nest lee los tipos de TypeScript y los
// decoradores de class-validator, y deriva de ahí los esquemas. Se activa
// en nest-cli.json y evita tener que repetir cada campo con @ApiProperty.
// {"compilerOptions":{"plugins":[{"name":"@nestjs/swagger",
//   "options":{"classValidatorShim":true,"introspectComments":true}}]}}

La fidelidad del documento es el punto crítico: un contrato que miente es peor que no tener contrato, porque la gente confía en él. Tres prácticas la garantizan. Una, generar el documento en el arranque y escribirlo en un fichero versionado con un script, de modo que cualquier cambio en la API aparezca como diferencia en la revisión de código y alguien tenga que aprobarlo conscientemente. Dos, validar en las pruebas de integración que las respuestas reales cumplen el esquema publicado. Y tres, documentar también los errores: una API cuyo OpenAPI solo describe el camino feliz obliga a cada integrador a descubrir los fallos en producción.

package.json · flujo de contrato
# 1 · Generar el documento y guardarlo en el repositorio
npm run api:openapi          # arranca Nest en modo documento y escribe openapi.json

# 2 · Comprobar que no hay cambios rompedores respecto a la rama principal
npx oasdiff breaking origin/main:openapi.json openapi.json --fail-on ERR

# 3 · Generar el cliente tipado de Angular a partir del contrato
npx openapi-generator-cli generate \
    -i openapi.json -g typescript-angular \
    -o libs/api-client/src/generated \
    --additional-properties=providedInRoot=true,fileNaming=kebab-case

# 4 · Verificar que lo generado compila y que nadie lo ha editado a mano
git diff --exit-code libs/api-client/src/generated || \
    (echo "El cliente generado está desactualizado: ejecuta npm run api:client" && exit 1)

El paso 2 es el que convierte el contrato en una red de seguridad real. Herramientas como oasdiff comparan dos documentos OpenAPI y clasifican las diferencias: si alguien elimina un campo, cambia un tipo o hace obligatorio un parámetro opcional, la construcción falla con un mensaje que dice exactamente qué se rompió y para quién. Es infinitamente más barato que descubrirlo cuando la aplicación móvil deja de funcionar.

El paso 3 elimina de un plumazo una clase entera de errores: el cliente de Angular deja de escribirse a mano y pasa a derivarse del contrato, con sus interfaces, sus enumerados y sus servicios inyectables. Si el servidor renombra un campo, el proyecto de Angular no compila. Esa es exactamente la clase de error que quieres tener: ruidoso, temprano y en tu máquina.

Contract testing: el problema que resuelve OpenAPI dice qué puede hacer la API; el contract testing verifica qué necesita de verdad cada consumidor. La idea, popularizada por Pact, es que el consumidor declara en un fichero las interacciones de las que depende («cuando pido GET /tasks/42 espero un 200 con un campo titulo de tipo cadena»), y esas expectativas se ejecutan contra el proveedor en su propia canalización. El problema que resuelve es doble: por un lado, permite retirar un campo con seguridad, porque sabes con certeza si alguien lo usa; por otro, evita los tests de extremo a extremo con todos los servicios levantados, que son lentos, frágiles y difíciles de depurar. En un monorepo con un solo consumidor aporta poco frente al cliente generado y las pruebas de integración; en cuanto hay tres equipos consumiendo tu API, deja de ser opcional.

25.17 Seguridad del contrato

La seguridad del capítulo 12 (autenticación, JWT, OAuth2, autorización) se da por sabida. Aquí interesan las decisiones de seguridad que forman parte del diseño del contrato y que, si se dejan para el final, obligan a cambios rompedores.

Autenticación. El token va en Authorization: Bearer …, nunca en la URL. Un 401 debe incluir WWW-Authenticate: Bearer realm="taskflow", error="invalid_token", que es lo que permite a un cliente genérico saber qué hacer. Y toda la API va por HTTPS: sin cifrado, el token viaja en claro y todo lo demás es decoración.

CORS, explicado de verdad. Es el mecanismo con peor prensa de la web, casi siempre por un malentendido: CORS no protege tu servidor, protege al usuario del navegador. La política del mismo origen impide que una página maliciosa lea las respuestas de otro origen usando las credenciales del usuario; CORS es la forma que tiene tu servidor de decir «para este origen concreto, autorizo la lectura». Nada de esto afecta a curl, a Postman ni a otro servidor: esos clientes no aplican la política porque no hay usuario al que proteger.

Una petición de otro origen es simple —y se envía directamente— si el método es GET, HEAD o POST, si solo lleva cabeceras de una lista muy corta y si su Content-Type es text/plain, multipart/form-data o application/x-www-form-urlencoded. En cuanto envías application/json, o una cabecera Authorization, o usas PATCH, PUT o DELETE, deja de ser simple y el navegador hace una comprobación previa: un OPTIONS con Access-Control-Request-Method y Access-Control-Request-Headers, y solo si la respuesta lo autoriza envía la petición real. Es decir: prácticamente toda llamada de una SPA a una API JSON en otro dominio implica dos viajes, y por eso Access-Control-Max-Age importa tanto: sin él, se paga la comprobación previa en cada petición.

main.tsINCORRECTO
// Combinación prohibida y peligrosa: comodín
// con credenciales. Los navegadores la rechazan,
// y el "arreglo" habitual es peor: reflejar el
// origen recibido, que equivale a permitir a
// CUALQUIER web leer los datos del usuario.
app.enableCors({
  origin: '*',
  credentials: true,
});

// Variante igual de mala:
app.enableCors({
  origin: (o, cb) => cb(null, true),  // refleja todo
  credentials: true,
});
main.tsCORRECTO
// Lista blanca explícita desde configuración, sin
// comodines y sin reflejar lo que llegue.
const permitidos = config.get<string[]>('CORS_ORIGENES');

app.enableCors({
  origin: (origen, cb) =>
    !origen || permitidos.includes(origen)
      ? cb(null, true)
      : cb(new Error('Origen no permitido')),
  credentials: true,
  methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization',
                   'If-Match', 'Idempotency-Key'],
  // Sin esto, el cliente NO puede leer estas
  // cabeceras aunque el servidor las envíe.
  exposedHeaders: ['ETag', 'Location', 'Retry-After',
                   'X-Request-Id', 'RateLimit'],
  maxAge: 86_400,     // cachea la comprobación previa
});
La trampa de exposedHeaders Es el fallo de CORS que más tiempo hace perder. Tu servidor envía ETag, lo ves perfectamente en las herramientas de red del navegador… y response.headers.get('ETag') devuelve null. No es un error tuyo: en peticiones de otro origen, JavaScript solo puede leer siete cabeceras de respuesta salvo que el servidor las autorice expresamente con Access-Control-Expose-Headers. Si tu contrato usa ETag, Location o Retry-After, tienes que exponerlas o el cliente no podrá implementar caché condicional, ni bloqueo optimista, ni reintentos con retroceso.

Límite de peticiones. Protege de abusos y de errores ajenos (un bucle infinito en un cliente puede tumbarte igual que un ataque). El borrador del IETF ya casi estándar define las cabeceras RateLimit-Limit, RateLimit-Remaining y RateLimit-Reset, que junto con el 429 y su Retry-After permiten que un cliente educado se autorregule. Tres consejos de diseño: limita por identidad autenticada y no solo por IP, porque una oficina entera comparte IP; aplica límites distintos por endpoint, porque el login y un listado no merecen el mismo trato; y comprueba el límite antes de tocar la base de datos, o el límite no te está protegiendo de nada.

Tamaño máximo del cuerpo. Un límite explícito y bajo (256 KB para JSON es generoso en una API de gestión) responde 413 y corta la petición antes de acumularla en memoria. Debe estar en dos sitios: en el proxy inverso, que es quien puede rechazarla sin gastar un proceso de Node, y en la aplicación, por si algún día alguien la despliega sin proxy delante. Conviene además limitar la profundidad de anidamiento del JSON y el número de elementos de los arrays de entrada: un array de cien mil identificadores en un POST de operación por lotes es un ataque perfectamente válido si no lo acotas.

Validación estricta. Es la defensa que más rentabilidad ofrece por línea escrita. whitelist: true descarta lo que no está en el DTO y cierra la puerta a la asignación masiva; forbidNonWhitelisted: true convierte el descarte silencioso en un 400 explícito, que es lo que quieres durante el desarrollo; y validar también los parámetros de consulta —con topes en limit, lista blanca en sort y rechazo de parámetros desconocidos— evita que el listado se convierta en una consulta arbitraria contra tu base de datos. Y una regla que resume todas las demás: no expongas entidades de MikroORM directamente; usa DTO de salida, porque el día que alguien añada un campo tokenRecuperacion a la entidad, ese campo aparecerá en la respuesta de tu API sin que nadie lo haya decidido.

25.18 Errores comunes y cómo solucionarlos

SíntomaCausa realSolución
La monitorización dice que todo va bien y los usuarios se quejanLa API responde 200 con el error dentro del cuerpoUsar el código de estado como canal de error; 4xx y 5xx de verdad
Se crean tareas duplicadas cuando la red es malaPOST reintentado sin protección; el cliente no distingue «no llegó» de «se perdió la respuesta»Clave de idempotencia generada por intención, no por clic (25.10)
Un usuario pisa los cambios de otro sin que nadie lo noteActualización perdida: se escribe sin comprobar la versión leídaETag + If-Match, 412 al fallar, y @Property({ version: true }) en la entidad
response.headers.get('ETag') devuelve null en AngularPetición de otro origen: solo se pueden leer siete cabeceras sin autorizaciónAñadir exposedHeaders en la configuración de CORS
Cada petición del SPA aparece duplicada en los logsComprobación previa de CORS en cada llamadamaxAge alto en CORS; ambos sistemas en el mismo origen si es posible
El listado tarda 2 segundos en las páginas altasOFFSET grande: la base de datos lee y descarta las filas anterioresPaginación por cursor con índice sobre las columnas de orden
Al paginar aparecen elementos repetidos y otros desaparecenOrden sin desempate, o inserciones concurrentes con paginación por desplazamientoOrdenar por (campo, id) y usar cursor
El PATCH no borra un campo aunque se envíe nullEl servicio usa valor ?? actual, que trata null como «sin cambios»Recorrer las claves presentes en el cuerpo, distinguiendo ausente de nulo
Un campo del PATCH se ignora en silenciowhitelist: true y el campo no tiene decorador de validaciónAñadir el decorador y activar forbidNonWhitelisted
La verificación de la firma del webhook falla siempreSe firma el JSON reserializado, no los bytes recibidosrawBody: true al crear la aplicación y firmar sobre req.rawBody
El receptor del webhook procesa el mismo evento tres vecesReintentos del emisor: la entrega es «al menos una vez»Guardar el identificador del evento y descartar duplicados
Un usuario ve datos de otro tras poner un CDN delanteCache-Control: public sin Vary en respuestas autenticadasprivate por defecto y Vary: Authorization siempre
Los tokens quedan escritos en el disco del usuarioSe usó no-cache creyendo que significaba «no cachear»no-store para todo lo sensible
El límite de peticiones no limita nadaSe aplica por IP y hay un proxy delante, o trust proxy está en trueLimitar por identidad y configurar el número exacto de proxies de confianza
Un cliente enumera recursos ajenos cambiando el identificadorSe comprueba la autenticación pero no la propiedad del objetoAcotar toda consulta por inquilino, con filtro global de MikroORM; 404 en lugar de 403
El bucle infinito de refresco de tokenEl interceptor de Angular trata el 403 como si fuera 401Refrescar solo ante 401 y excluir la propia ruta de refresco
502 intermitentes sin patrón aparenteEl tiempo de inactividad de Node es menor que el del balanceadorkeepAliveTimeout mayor que el del balanceador, y headersTimeout por encima
El 504 aparece al generar informes grandesOperación larga resuelta de forma síncrona202 con recurso de estado y sondeo con Retry-After (25.11)
La aplicación móvil dejó de funcionar tras un despliegueCambio rompedor no detectado: campo renombrado o enum ampliadoComparación automática del OpenAPI en integración continua con oasdiff
La documentación no coincide con la APIOpenAPI escrito a mano y no actualizadoGenerarlo desde el código, versionarlo y revisar su diferencia en cada cambio
El cliente de Angular se rompe al añadir un estado nuevoEnum de salida ampliado: es un cambio rompedorDocumentar el enum como abierto y exigir rama por defecto en el cliente
Subir un adjunto agota la memoria del procesoEl fichero atraviesa Node en un búferURL prefirmada al almacenamiento de objetos, o flujo con límite de tamaño

25.19 Buenas y malas prácticas

Buenas prácticas

  • Diseñar el contrato antes de escribir el controlador: método, URL, códigos, cabeceras y cuerpo, por escrito.
  • Usar el código de estado como canal de error, con Problem Details (RFC 9457) e identificador de correlación en el cuerpo.
  • Sustantivos en plural, un nivel de anidamiento, aplanar para leer y modificar.
  • Devolver siempre la colección dentro de data, con meta y links.
  • Lista blanca para ordenar, filtrar y expandir; tope máximo en limit.
  • Emitir ETag en las lecturas y aceptarlo en If-None-Match y en If-Match.
  • private y Vary por defecto en toda respuesta autenticada.
  • Admitir Idempotency-Key en las escrituras que importan, con ventana de validez y limpieza programada.
  • 202 con recurso de estado para todo lo que pase de unos segundos.
  • Documentar también los errores en OpenAPI, y generar el cliente de Angular desde el contrato.
  • Comprobar cambios rompedores en la canalización, no en producción.
  • Exponer ETag, Location y Retry-After en CORS si forman parte del contrato.
  • Fechas en ISO 8601 y UTC; fechas de calendario sin zona horaria.
  • Medir el uso por versión y por cliente antes de retirar nada.

Malas prácticas

  • Responder 200 con {"ok": false} y creer que ya hay gestión de errores.
  • Verbos en la URL: /getTasks, /tasks/42/markAsDone.
  • Anidar cuatro niveles para llegar a un recurso que ya tiene identificador propio.
  • Devolver un array desnudo en la raíz de un listado.
  • Paginar por desplazamiento en tablas que crecen sin límite.
  • Permitir que el cliente escriba el nombre de la columna de ordenación.
  • Usar PUT enviando solo unos campos y conservar el resto «por ser amables».
  • Un PATCH sin declarar si es Merge Patch o JSON Patch.
  • Confundir no-cache con no-store en respuestas con datos sensibles.
  • Devolver la traza, el SQL o el nombre de la restricción en el cuerpo de un 500.
  • Confirmar la existencia de una cuenta en el mensaje de error del login.
  • Poner tokens o datos personales en la URL.
  • Reflejar cualquier origen en CORS con credentials: true.
  • Exponer entidades del ORM como respuesta de la API.
  • Crear /api/v2 por cada cambio, y no retirar nunca /api/v1.
  • Emitir webhooks sin firma, sin reintentos y sin identificador de evento.

25.20 Preguntas frecuentes

¿PUT o PATCH? Es la pregunta de entrevista más repetida.
PUT reemplaza el recurso entero: lo que no envías se considera ausente, no «sin cambios». PATCH aplica una modificación parcial. La consecuencia práctica es que PUT es idempotente por definición (enviar el mismo recurso dos veces deja el mismo estado) mientras que PATCH solo lo es si el cuerpo describe valores absolutos; un parche del tipo «incrementa las horas en 1» no lo es. Y una segunda parte que casi nadie responde: el cuerpo de un PATCH necesita un tipo de medio que diga cómo interpretarlo, normalmente application/merge-patch+json (RFC 7386) o application/json-patch+json (RFC 6902).
¿401 o 403?
401 significa «no sé quién eres»: no hay credenciales, están caducadas o son inválidas. Reintentar tras autenticarse puede funcionar, y por eso la respuesta debe incluir WWW-Authenticate. 403 significa «sé quién eres y no puedes»: reintentar con las mismas credenciales nunca funcionará. En el cliente, el 401 justifica intentar refrescar el token y el 403 no; confundirlos produce el bucle infinito de refresco. Y hay un matiz importante: cuando el hecho de que el recurso exista es información sensible, 404 es mejor respuesta que 403, porque un 403 confirma la existencia.
¿Cuándo 422 y cuándo 400?
400 cuando no has podido entender la petición: JSON malformado, un parámetro que debía ser número y llega como texto, falta un campo obligatorio. 422 cuando la has entendido perfectamente pero viola una regla de negocio: la fecha de fin es anterior a la de inicio, la transición de estado no está permitida. La diferencia informa al cliente de algo útil, porque un 400 casi siempre es un error de programación y un 422 casi siempre es algo que el usuario puede corregir. Es legítimo usar solo 400 si lo documentas; lo que no es aceptable es alternarlos sin criterio. En NestJS, el ValidationPipe devuelve 400 por defecto y se puede cambiar con errorHttpStatusCode.
¿Qué significa exactamente que un método sea idempotente?
Que ejecutarlo una vez o N veces seguidas deja el servidor en el mismo estado. Habla del estado final, no de la respuesta: DELETE es idempotente aunque devuelva 204 la primera vez y 404 la segunda. Son idempotentes GET, HEAD, OPTIONS, PUT y DELETE; POST no lo es, y PATCH depende de lo que escribas en el cuerpo. Importa porque hay infraestructura real que actúa según esa promesa: bibliotecas de cliente y balanceadores reintentan automáticamente los métodos idempotentes.
¿Cómo se pagina una API con millones de filas?
Con cursor, no con desplazamiento. LIMIT 20 OFFSET 100000 obliga a la base de datos a leer y descartar cien mil filas, y además produce elementos duplicados y omitidos si alguien inserta mientras el usuario pasa de página. El cursor codifica la posición de la última fila devuelta con una clave de orden estable —típicamente (creado_en, id), donde el id desempata—, se traduce a un WHERE que aprovecha el índice y tiene coste constante. A cambio pierdes el salto a la página 57 y el total exacto, que casi nunca son requisitos reales. Y hay una condición imprescindible: debe existir un índice que cubra exactamente las columnas de ordenación en el mismo orden y sentido.
¿Cómo se invalida una caché HTTP?
En orden de fiabilidad: dejando que expire con una caducidad corta; revalidando con ETag, de modo que no haya nada que invalidar porque la caché siempre pregunta y el servidor decide; cambiando la URL cuando cambia el contenido, que es la única estrategia perfecta y solo sirve para contenido versionable; y purgando activamente contra la API del CDN, que es lo más potente y lo más frágil, porque hay retardo de propagación y hay que acertar con todas las variantes de la clave. Para datos de negocio en una API, la respuesta correcta casi siempre es la segunda.
¿Necesito idempotencia si mi cliente es una aplicación de Angular?
Sí, y por dos motivos distintos. El primero es la red: un móvil en un ascensor produce exactamente el escenario en el que el cliente no puede saber si la petición llegó. El segundo es el usuario: el doble clic en «Guardar» existe y no se arregla del todo deshabilitando el botón, porque entre el clic y el cambio de estado hay milisegundos. Ahora bien, no toda escritura lo necesita: aplícalo donde duplicar tenga consecuencias reales (crear recursos, pagos, envíos de correo) y no en un PATCH que ya es idempotente por su contenido.
¿Por qué mi navegador hace dos peticiones por cada llamada?
Porque tu petición no es «simple» según CORS y el navegador envía primero un OPTIONS de comprobación previa. Basta con enviar Content-Type: application/json, o una cabecera Authorization, o usar PATCH, para dejar de ser simple, así que prácticamente todas las llamadas de un SPA lo son. Las soluciones son responder al OPTIONS con Access-Control-Max-Age alto para que el navegador cachee el permiso, y, cuando sea posible, servir la API y la aplicación bajo el mismo origen a través del proxy inverso, con lo que CORS deja de intervenir.
¿Debo devolver el recurso creado en el cuerpo de un 201?
Es recomendable, junto con la cabecera Location. La cabecera es la parte obligatoria del contrato, porque es lo que permite a un cliente genérico localizar lo que acaba de crear; el cuerpo es una comodidad que ahorra al cliente una petición inmediata, cosa que casi siempre va a hacer. Devuelve el recurso tal como lo devolvería el GET correspondiente, con los campos que el servidor haya calculado (identificador, marcas de tiempo, versión), y no una versión recortada, porque entonces el cliente tiene dos formas distintas del mismo objeto.
¿Versiono en la URL o en una cabecera?
En la URL, salvo que tengas una razón concreta para no hacerlo. Es visible en los logs y en las métricas, trivial de enrutar, fácil de probar con curl y comprensible para cualquiera que llegue nuevo. Los puristas objetan que la URI de un recurso debería ser única; es un argumento correcto en teoría y de poco peso en la práctica. Lo verdaderamente importante no es dónde pones el número, sino tener una sola versión activa la mayor parte del tiempo, y para eso hace falta disciplina de compatibilidad: la mayoría de los cambios que la gente versiona podrían ser aditivos.
¿Qué es HATEOAS y por qué casi nadie lo usa?
Es el nivel 3 del modelo de Richardson: las respuestas incluyen los enlaces a las transiciones posibles desde el estado actual, de modo que el cliente navegue en tiempo de ejecución en lugar de conocer las rutas de antemano. Casi nadie lo implementa porque los clientes reales no descubren nada: un desarrollador lee la documentación, escribe la llamada y sigue. Añade peso a las respuestas y complejidad al servidor a cambio de una flexibilidad que nadie aprovecha. Ahora bien, hay tres casos donde los enlaces sí aportan: la paginación, los flujos con estado donde las acciones disponibles dependen del estado, y las URL firmadas con caducidad que el cliente no puede construir.
¿Cómo hago que una operación de dos minutos no dé 504?
No subiendo los tiempos de espera, sino cambiando el diseño: el POST encola el trabajo y responde 202 con un Location hacia un recurso de estado, y el cliente consulta ese recurso respetando Retry-After hasta que informa de que ha terminado. Un detalle importante: si el trabajo falla, el GET del estado debe devolver 200 con el fallo descrito en el cuerpo, no un 500, porque la consulta del estado sí ha funcionado. Si quien espera es otro servidor y no un navegador, un webhook es mejor que el sondeo.
¿ETag fuerte o débil, y de dónde lo saco?
Débil (W/"…") es suficiente para una API JSON: indica equivalencia semántica y no exige que los bytes sean idénticos, lo cual te libera de que un cambio en el orden de las claves invalide la etiqueta. Sobre el origen del valor, la clave está en calcularlo a partir de algo barato: la columna de versión de la entidad o un updated_at indexado, no el hash del JSON ya serializado. Si haces el hash del cuerpo final, has pagado la consulta y la serialización antes de descubrir que podías responder 304, y solo te ahorras el ancho de banda. Una excepción: para If-Match conviene la etiqueta fuerte, porque ahí la comparación es de identidad exacta.
¿Puedo enviar un cuerpo en un GET o en un DELETE?
Técnicamente la RFC 9110 no lo prohíbe, pero dice que no tiene semántica definida y en la práctica es mala idea: algunos proxies lo descartan, algunas bibliotecas de cliente no lo permiten, las cachés lo ignoran al calcular la clave y las herramientas de depuración no lo muestran. Si necesitas una consulta demasiado compleja para caber en la URL, el patrón habitual es POST /tasks/searches creando un recurso de búsqueda que después se lee con GET, lo que además te devuelve la posibilidad de cachear y de compartir el enlace del resultado.
¿Cómo evito que el cliente y el servidor se desincronicen?
Haciendo que el cliente no se escriba a mano. Generas el documento OpenAPI desde los decoradores de NestJS, lo versionas en el repositorio para que cualquier cambio aparezca en la revisión de código, y de ahí generas el cliente tipado de Angular. A partir de ese momento, si el servidor renombra un campo el proyecto de Angular no compila, que es exactamente donde quieres que aparezca el error. Encima de eso, una comparación automática del OpenAPI contra la rama principal detecta los cambios rompedores antes de fusionar, y si hay varios equipos consumiendo la API, el contract testing te dice qué campos usa cada uno realmente y cuáles puedes retirar.

25.21 Ejercicios

Nivel 1 · básico

25.1 Toma esta lista de URL mal diseñadas y reescríbelas: POST /api/getTaskList, GET /task/42/delete, POST /projects/9/tasks/42/setPriority, GET /api/v1/tasks.json?token=abc123, GET /tasks/estado/ABIERTA/responsable/7. Justifica cada cambio en una frase.

25.2 Para cada situación, elige el código de estado y explica por qué: crear una tarea correctamente; borrar una tarea que ya no existe; pedir una tarea de otra organización; enviar {"vencimiento":"ayer"}; enviar una fecha de fin anterior a la de inicio; superar el límite de peticiones; el servidor de correo no responde.

25.3 Escribe con curl -v las peticiones que demuestren un ciclo completo de ETag: primera lectura con 200, segunda con If-None-Match y 304, modificación, y tercera lectura con 200 y etiqueta nueva. Anota los bytes transferidos en cada caso.

25.4 Configura CORS en NestJS para que la aplicación de Angular en http://localhost:4200 pueda leer las cabeceras ETag y Location. Comprueba en las herramientas de red del navegador que aparece la comprobación previa y mide cuánto la reduce maxAge.

Nivel 2 · intermedio

25.5 Diseña el contrato completo del recurso «comentario de una tarea»: URL, métodos, cuerpos de entrada y salida, códigos de estado de cada operación, cabeceras de caché, paginación del listado y errores posibles con su Problem Details. Entrégalo como fragmento de OpenAPI y contrástalo con la solución comentada.

25.6 Convierte el listado de tareas de paginación por desplazamiento a paginación por cursor. Carga cien mil filas de prueba y compara con EXPLAIN ANALYZE el coste de la página 1 y de la página 2.000 en ambas implementaciones.

25.7 Implementa un filtro global que devuelva Problem Details con identificador de correlación, distinguiendo el registro de 4xx y de 5xx, y traduciendo las excepciones de MikroORM sin filtrar nombres de restricciones. Añade tests que verifiquen que un 500 nunca incluye la traza.

25.8 Implementa ETag e If-Match en PATCH /tasks/:id conectándolo con la columna de versión de MikroORM, con 428 si falta la precondición y 412 si no se cumple. Escribe un test que simule dos ediciones concurrentes.

25.9 Añade caché condicional al listado de proyectos y mide con el panel de red cuántos bytes ahorras en una sesión típica de navegación. Comprueba qué ocurre si olvidas Vary: Authorization con dos usuarios distintos y un proxy de caché delante.

25.10 Implementa el patrón 202 para la exportación de un proyecto: recurso de trabajo, sondeo con Retry-After, y un componente de Angular que muestre el progreso con retroceso exponencial.

Nivel 3 · avanzado

25.11 Implementa claves de idempotencia completas: entidad con índice único, interceptor, reserva y ejecución en la misma transacción, reproducción de la respuesta guardada, rechazo con cuerpo distinto y caducidad. Demuestra con un test que veinte peticiones simultáneas con la misma clave crean exactamente una tarea.

25.12 Monta el flujo completo de contrato: generación del OpenAPI en la construcción, comparación de cambios rompedores contra la rama principal y generación del cliente de Angular. Provoca a propósito un cambio rompedor y comprueba que la canalización falla con un mensaje útil.

25.13 Implementa el emisor de webhooks de TaskFlow con firma HMAC con marca de tiempo, cola de entrega, reintentos con retroceso exponencial y desactivación de suscripciones que fallan de forma sostenida. Escribe el receptor y sus tests, incluidos firma inválida, evento caducado y duplicado.

25.14 Diseña y ejecuta la retirada de la versión v1 de un endpoint: cabeceras Deprecation y Sunset, métrica de uso por cliente, apagón de ensayo y respuesta 410 final. Documenta qué habrías roto si hubieras retirado sin medir.

25.15 Compara para el mismo caso de uso tres diseños —REST con expansión de relaciones, REST con varias peticiones y GraphQL— midiendo número de viajes, bytes transferidos, complejidad del servidor y facilidad de cacheo. Escribe una recomendación argumentada para TaskFlow.

Solución comentada · 25.5 · Contrato completo del recurso comentario
paths:
  /projects/{projectId}/tasks/{taskId}/comments:
    get:
      summary: Listar comentarios de una tarea
      parameters:
        - { name: limit,  in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
        - { name: cursor, in: query, schema: { type: string }, description: Opaco, lo devuelve el servidor }
      responses:
        '200':
          description: Página de comentarios
          headers:
            ETag:          { schema: { type: string } }
            Cache-Control: { schema: { type: string, example: 'private, no-cache' } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaginaComentarios' }
        '304': { description: Sin cambios desde el ETag enviado en If-None-Match }
        '403': { $ref: '#/components/responses/Prohibido' }
        '404': { $ref: '#/components/responses/NoEncontrado' }
    post:
      summary: Crear un comentario
      parameters:
        - { name: Idempotency-Key, in: header, required: false, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [texto]
              properties:
                texto:      { type: string, minLength: 1, maxLength: 5000 }
                respondeA:  { type: integer, nullable: true }
      responses:
        '201':
          description: Comentario creado
          headers:
            Location: { schema: { type: string }, description: URL canónica del comentario }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Comentario' }
        '409': { description: Clave de idempotencia en curso }
        '422': { $ref: '#/components/responses/ErrorValidacion' }

  /comments/{id}:                     # PLANO: ya tiene identificador propio
    patch:
      parameters:
        - { name: If-Match, in: header, required: true, schema: { type: string } }
      requestBody:
        content:
          application/merge-patch+json:      # el tipo de medio DECLARA la semántica
            schema:
              type: object
              properties: { texto: { type: string, minLength: 1, maxLength: 5000 } }
      responses:
        '200': { description: Comentario actualizado }
        '412': { description: El comentario cambió desde que lo leíste }
        '428': { description: Falta la cabecera If-Match }
    delete:
      responses:
        '204': { description: Eliminado, sin cuerpo }
        '404': { description: No existe, o no tienes por qué saber que existe }

Las decisiones que hay que saber defender son cinco. El listado se anida bajo la tarea porque sin ella no tiene sentido, pero el elemento individual es plano porque ya tiene identificador propio, y así se evita tener que comprobar en cada operación que el comentario pertenece a esa tarea y esa tarea a ese proyecto. El POST admite Idempotency-Key porque duplicar un comentario es visible para el usuario y molesto. El PATCH exige If-Match porque editar un comentario ajeno a la vez es un caso real en una herramienta colaborativa, y por eso aparece el 428. El DELETE devuelve 204 sin cuerpo, y devuelve 404 en lugar de 403 cuando el comentario pertenece a otra organización, para no confirmar su existencia. Y el listado publica ETag con private, no-cache, que es la combinación que permite revalidar sin exponer datos de un usuario en una caché compartida.

Solución comentada · 25.8 · ETag e If-Match sobre la versión de MikroORM
// La pieza que evita repetir la lógica en cada controlador: un decorador
// de parámetro que extrae y valida la precondición, más un helper que
// deriva el ETag de la entidad. El ETag NO se inventa: es la versión.
export const etagDe = (e: { version: number }) => `W/"v${e.version}"`;

export const IfMatch = createParamDecorator((_d, ctx: ExecutionContext): number => {
  const req = ctx.switchToHttp().getRequest<Request>();
  const valor = req.header('if-match');

  // 428 y no 400: la petición está bien formada, lo que falta es la
  // precondición que ESTE endpoint exige. El código lo dice con precisión.
  if (!valor) throw new PreconditionRequiredException(
    'Este recurso requiere If-Match con el ETag obtenido en el GET');

  const m = /^W\/"v(\d+)"$/.exec(valor.trim());
  if (!m) throw new BadRequestException('If-Match mal formado');
  return Number(m[1]);
});

@Patch(':id')
async actualizar(
  @Param('id', ParseIntPipe) id: number,
  @Body() dto: PatchTaskDto,
  @IfMatch() version: number,
  @Res({ passthrough: true }) res: Response,
) {
  try {
    // La comprobación es ATÓMICA: MikroORM añade "AND version = ?" al
    // UPDATE. Hacer un SELECT previo y comparar en memoria dejaría una
    // ventana de carrera entre la lectura y la escritura.
    const tarea = await this.em.findOneOrFail(Task, id, {
      lockMode: LockMode.OPTIMISTIC, lockVersion: version,
    });
    this.em.assign(tarea, dto);
    await this.em.flush();

    res.setHeader('ETag', etagDe(tarea));   // versión YA incrementada
    return TaskDto.desde(tarea);
  } catch (e) {
    if (e instanceof OptimisticLockError) {
      // Enriquecemos el 412 con lo que el cliente necesita para fusionar.
      const actual = await this.em.fork().findOneOrFail(Task, id);
      throw new PreconditionFailedException({
        type: `${TIPOS}/conflicto-edicion`,
        title: 'La tarea ha cambiado desde que la abriste',
        status: 412, versionActual: actual.version,
        camposCambiados: this.tasks.diferencias(version, actual),
      });
    }
    throw e;
  }
}

// Test de las dos ediciones concurrentes:
it('rechaza la segunda escritura con el ETag antiguo', async () => {
  const primera = await request(app).get('/api/v1/tasks/42');
  const etag = primera.headers['etag'];

  await request(app).patch('/api/v1/tasks/42')
    .set('If-Match', etag).send({ prioridad: 'ALTA' }).expect(200);

  // Luis usa el ETag que leyó ANTES del cambio de Ana.
  const segunda = await request(app).patch('/api/v1/tasks/42')
    .set('If-Match', etag).send({ titulo: 'Otro título' }).expect(412);

  expect(segunda.body.versionActual).toBe(9);
  // Y lo más importante: no se ha escrito nada.
  const tarea = await orm.em.fork().findOneOrFail(Task, 42);
  expect(tarea.titulo).not.toBe('Otro título');
});

Los dos puntos que se suelen fallar en este ejercicio son la atomicidad y el contenido del 412. Comprobar la versión con un SELECT y después escribir deja una ventana en la que otra petición puede colarse; hay que dejar que la comprobación viaje dentro del UPDATE. Y un 412 con un mensaje genérico obliga a la interfaz a hacer que el usuario pierda su trabajo: incluir la versión actual y los campos que cambiaron es lo que permite ofrecer una fusión en lugar de un «vuelve a empezar». Recuerda además exponer ETag en exposedHeaders de CORS, o el cliente leerá null y nada de esto funcionará desde el navegador.

Solución comentada · 25.11 · Claves de idempotencia a prueba de concurrencia
// La prueba que de verdad valida una implementación de idempotencia no es
// la del reintento secuencial: es la de las peticiones SIMULTÁNEAS, que es
// donde se ven las condiciones de carrera.
describe('claves de idempotencia', () => {
  it('veinte peticiones simultáneas con la misma clave crean una tarea', async () => {
    const clave = randomUUID();
    const cuerpo = { titulo: 'Revisar contrato' };

    const respuestas = await Promise.all(
      Array.from({ length: 20 }, () =>
        request(app.getHttpServer())
          .post('/api/v1/projects/9/tasks')
          .set('Idempotency-Key', clave)
          .set('Authorization', `Bearer ${token}`)
          .send(cuerpo)),
    );

    const creadas  = respuestas.filter((r) => r.status === 201);
    const enCurso  = respuestas.filter((r) => r.status === 409);
    const repetida = respuestas.filter((r) => r.headers['idempotent-replay'] === 'true');

    // Exactamente una ejecuta; el resto o esperan (409) o reciben la
    // respuesta guardada. Ninguna combinación produce dos tareas.
    expect(creadas.length + enCurso.length + repetida.length).toBe(20);
    expect(await orm.em.fork().count(Task, { titulo: 'Revisar contrato' })).toBe(1);

    // Todas las que devuelven 201 apuntan al MISMO recurso.
    const destinos = new Set([...creadas, ...repetida].map((r) => r.headers['location']));
    expect(destinos.size).toBe(1);
  });

  it('rechaza la misma clave con un cuerpo distinto y NO ejecuta nada', async () => {
    const clave = randomUUID();
    await request(app.getHttpServer()).post('/api/v1/projects/9/tasks')
      .set('Idempotency-Key', clave).send({ titulo: 'Primera' }).expect(201);

    await request(app.getHttpServer()).post('/api/v1/projects/9/tasks')
      .set('Idempotency-Key', clave).send({ titulo: 'Segunda' }).expect(422);

    expect(await orm.em.fork().count(Task, { titulo: 'Segunda' })).toBe(0);
  });

  it('permite reintentar con la misma clave si la primera vez falló', async () => {
    const clave = randomUUID();
    jest.spyOn(TasksService.prototype, 'crear')
        .mockRejectedValueOnce(new Error('fallo transitorio'));

    await request(app.getHttpServer()).post('/api/v1/projects/9/tasks')
      .set('Idempotency-Key', clave).send({ titulo: 'Reintento' }).expect(500);

    // La clave NO quedó marcada como HECHO: el cliente puede reintentar.
    await request(app.getHttpServer()).post('/api/v1/projects/9/tasks')
      .set('Idempotency-Key', clave).send({ titulo: 'Reintento' }).expect(201);
  });
});

El árbitro de la concurrencia no es código de aplicación: es el índice único sobre (clave, usuarioId, endpoint). Cuando veinte peticiones intentan reservar a la vez, la base de datos deja pasar una y las otras diecinueve reciben una violación de unicidad que el interceptor traduce a 409 con Retry-After. Intentar resolverlo con un «comprueba si existe y si no créalo» en dos pasos falla exactamente en el escenario que quieres cubrir. El tercer test es el que más gente olvida: si un fallo dejara la clave marcada como completada, el cliente quedaría atrapado, incapaz de reintentar y sin haber creado nada; por eso el camino de error borra la reserva. Y no olvides el trabajo programado que elimina las claves caducadas: sin él, esta tabla crece indefinidamente y acaba siendo la más grande del sistema.

25.22 Resumen del capítulo

  • La semántica de HTTP es la misma en 1.1, 2 y 3; lo que cambia es el transporte. HTTP/2 y HTTP/3 hacen barato lo que antes era caro, y convierten en penalización trucos como repartir recursos entre dominios.
  • Dos preguntas resuelven casi toda duda de método: ¿cambia el estado? y ¿repetirlo deja el mismo estado? De ahí salen GET, POST, PUT, PATCH y DELETE sin discusión.
  • Idempotente habla del estado final, no de la respuesta. DELETE lo es aunque devuelva 204 y luego 404; PATCH lo es o no según lo que escribas en el cuerpo.
  • PUT reemplaza, PATCH modifica, y el cuerpo de un PATCH necesita declarar si es Merge Patch o JSON Patch.
  • El código de estado es el canal de error. Un 200 con un error dentro rompe la monitorización, los reintentos, las cachés y el tipado del cliente.
  • 4xx es el sistema funcionando; 5xx es el sistema fallando. Solo los segundos deben despertar a alguien.
  • Sustantivos en plural, un nivel de anidamiento: anida para crear y listar, aplana para leer, modificar y borrar. Las acciones que no encajan en CRUD se convierten en recursos.
  • Cursor en lugar de desplazamiento en cualquier colección que pueda crecer, con un índice que cubra exactamente las columnas de ordenación.
  • Problem Details (RFC 9457) con identificador de correlación convierte los errores en algo que una máquina automatiza, una persona entiende y el soporte rastrea, sin revelar nada del interior.
  • no-cache no significa «no cachear»: significa «revalida». El que no guarda nada es no-store. Y sin Vary ni private, un CDN puede servir los datos de un usuario a otro.
  • El mismo ETag sirve para caché y para concurrencia: con If-None-Match ahorra ancho de banda con un 304, y con If-Match impide la actualización perdida con un 412, apoyado en la columna de versión del ORM.
  • Las claves de idempotencia no son un lujo: son la única forma de que un reintento sobre una red poco fiable no duplique una operación. Clave por intención, resultado guardado en la misma transacción y caducidad.
  • Lo que tarda, se acepta con 202 y se consulta en un recurso de estado; y el GET de ese estado devuelve 200 aunque el trabajo haya fallado.
  • La mayoría de los cambios pueden ser aditivos. Versiona en la URL, ten una sola versión viva y retira con métricas de uso, cabeceras de deprecación y un 410 final.
  • CORS protege al usuario, no a tu servidor, y sin exposedHeaders el cliente no puede leer ETag ni Location aunque los envíes.
  • OpenAPI generado desde el código, versionado y comparado en cada cambio convierte el contrato en una red de seguridad: el cliente de Angular se genera solo y un cambio rompedor deja de compilar en tu máquina en lugar de fallar en la del usuario.

25.23 Recursos adicionales

Siguiente paso Con el contrato bien diseñado, los capítulos vecinos encajan solos: la autenticación y la autorización que aquí damos por supuestas están en el 12, la columna de versión y las transacciones que sostienen el bloqueo optimista en el 17, las colas que ejecutan los trabajos asíncronos y los webhooks en el 11, y las pruebas de todo ello en el 13. Si tuvieras que quedarte con una sola idea, que sea esta: el contrato es lo único que no puedes refactorizar en silencio, así que es lo único que merece la pena diseñar dos veces.