Cerrábamos 06-03 diciendo que la calidad de una API no se mide el día del lanzamiento, sino en la facilidad con la que un integrador sigue trabajando con ella cuando ya nadie del equipo original queda en la empresa. Ese es el listón. Durante seis módulos hemos leído, analizado y criticado el trabajo de otros —incluido el nuestro, en la memoria técnica de 06-01 y en los tres años de producción de 06-03—. Ahora te toca a ti: vas a diseñar y construir una API completa desde cero, por fases, con criterios de aceptación verificables y una rúbrica con la que puedas evaluarte sin que nadie te corrija. Todo lo que hemos hecho hasta aquí estaba preparando este encargo.
No es un ejercicio de escribir rutas de Express. Es un ejercicio de decidir: qué es un recurso, qué garantiza tu contrato, qué pasa cuando dos personas compiten por la última plaza y qué le dirás dentro de dos años a quien dependa de ti.
Contenido
- El encargo: la API de Aula Aroma
- Dominios alternativos
- Entrega por fases y criterios de aceptación
- Rúbrica de autoevaluación
- Guía de arranque: el primer día
- Plan de trabajo por semanas
- Pistas para los puntos donde casi todo el mundo se atasca
- Solución de referencia de la Fase 2
- Errores comunes y consejos
- Ejercicios
- Conclusión del curso
- El encargo: la API de Aula Aroma
Tienda Aroma quiere abrir una línea de negocio nueva: Aula Aroma, una plataforma de cursos y catas presenciales de café de especialidad. Tú diseñas y construyes su API pública desde cero, con el mismo stack y las mismas convenciones de contrato que hemos usado en todo el curso (ids con prefijo, camelCase, dinero en céntimos por dentro y euros por fuera, fechas ISO-8601 UTC, colecciones envueltas en {"datos": [...], "total": n}, errores con catálogo de códigos, versionado en la ruta).
1.1 El dominio
| Concepto | Descripción |
|---|---|
| Curso | Producto formativo repetible: "Cata sensorial de orígenes africanos", "Latte art nivel 1". Tiene título, descripción, duración, nivel y precio base. |
| Sesión | Celebración concreta de un curso: fecha y hora, sede, aforo, instructor asignado. Un curso tiene muchas sesiones. |
| Inscripción | Una persona ocupa una plaza de una sesión. Tiene estado, precio pagado y fecha. |
| Lista de espera | Personas que quisieron inscribirse con el aforo lleno y esperan una vacante. |
| Asistente | Persona identificada (cuenta con correo y contraseña) que se inscribe. Puede ser cliente de la tienda o no. |
| Valoración | Puntuación de 1 a 5 y comentario que un asistente deja sobre una sesión a la que asistió. |
| Instructor | Quien imparte sesiones. Tiene biografía, especialidades y una valoración media derivada. |
1.2 Reglas de negocio
Estas reglas no son decorado: son la razón por la que el proyecto es interesante. Cada una obliga a una decisión de diseño.
- Aforo. Cada sesión tiene un número máximo de plazas. Nunca puede haber más inscripciones confirmadas que plazas, ni siquiera con peticiones simultáneas.
- Cierre de inscripciones. No se admiten inscripciones nuevas a partir de
Xhoras antes del comienzo (parametrizable por curso; por defecto 24). - Lista de espera. Si el aforo está lleno, la persona puede entrar en lista de espera. Cuando alguien cancela, la primera de la lista pasa a tener una ventana de tiempo para confirmar.
- Cancelación y reembolso. Cancelar con más de 72 horas de antelación reembolsa el 100 %; entre 72 y 24 horas, el 50 %; con menos de 24 horas, nada. La cancelación por parte de Aula Aroma reembolsa siempre el 100 %.
- Valoración condicionada. Un asistente solo puede valorar una sesión si su inscripción figura como asistida, y solo una vez.
- Descuentos. Los clientes de Tienda Aroma con compras en los últimos 12 meses tienen un 15 % de descuento; hay códigos promocionales con importe fijo o porcentaje; los descuentos no se acumulan salvo indicación explícita.
1.3 Los cuatro retos de verdad
- Plazas limitadas y concurrencia. Dos peticiones a la vez para la última plaza. La comprobación "¿queda sitio?" y la reserva tienen que ser atómicas. Ya viste el patrón en
06-01: la condición viaja dentro delUPDATE, no en unSELECTprevio. - Cancelaciones y listas de espera. Cancelar dispara un efecto en cadena (liberar plaza, promocionar de la lista, notificar, calcular reembolso). ¿Es eso un
DELETE? ¿Es un recurso? - Fechas y zonas horarias. Las sesiones son presenciales y ocurren en una hora local concreta. El contrato dice UTC, pero "las sesiones del sábado" depende del huso de quien pregunta. Los cambios de horario de verano existen y te van a morder.
- Precios y descuentos. Céntimos por dentro, euros por fuera (
02-05). El precio final depende de quién pregunta, cuándo y con qué código. ¿Se calcula en la representación del curso o solo al inscribirse?
- Dominios alternativos
Si Aula Aroma no te motiva, elige otro. El listón de exigencia es el mismo: debe tener al menos un recurso con restricción de concurrencia, un flujo de cambio de estado y una relación no trivial.
| Dominio | Qué reto añade |
|---|---|
| Gestión de biblioteca | Ejemplares frente a obras: el mismo libro tiene varias copias físicas, y el préstamo se hace sobre una copia, no sobre el título. Modelar esa diferencia sin filtrarla al consumidor es el ejercicio. |
| Helpdesk de incidencias | La máquina de estados es el corazón del dominio (abierta, asignada, en espera del cliente, resuelta, cerrada) con transiciones no libres, y la autorización depende del rol y de la pertenencia. |
| Reserva de salas | Solapamiento temporal: la restricción no es un contador, es un intervalo que no puede cruzarse con otro, y las reservas periódicas multiplican el problema. |
- Entrega por fases y criterios de aceptación
Una fase por módulo. No pases a la siguiente sin cumplir los criterios: la mitad del valor del proyecto está en resistir la tentación de escribir código en la fase 1.
Fase 1 — Análisis (módulo 1)
Entregable: docs/analisis.md.
| # | Criterio de aceptación |
|---|---|
| 1.1 | Lista de consumidores identificados (web pública, panel de administración, app de instructores, integración con la tienda) con lo que necesita cada uno. |
| 1.2 | Al menos 12 casos de uso redactados como "como rol, quiero acción para fin". |
| 1.3 | Requisitos no funcionales cuantificados: latencia objetivo, volumen esperado, disponibilidad, retención de datos. |
| 1.4 | Glosario del dominio con 15+ términos y su nombre exacto en el contrato (en español, coherente). |
| 1.5 | Justificación de por qué REST y no otra opción, con referencia a 01-07. |
| 1.6 | Nivel objetivo en el modelo de Richardson (01-05) declarado y razonado. |
Fase 2 — Contrato (módulo 2)
Entregable: openapi.yaml + docs/contrato.md.
| # | Criterio de aceptación |
|---|---|
| 2.1 | Mapa de recursos y URIs completo, con sustantivos en plural y sin verbos (02-02). |
| 2.2 | Tabla método × recurso con el código de estado de éxito y los de error de cada combinación (02-03, 02-04). |
| 2.3 | Esquemas de representación de cada recurso, con tipos, obligatoriedad y ejemplo. |
| 2.4 | Catálogo de errores propio, con código snake_case, estado HTTP y mensaje. |
| 2.5 | Paginación y filtros definidos por colección, indicando cuál usa limite/desplazamiento y cuál cursor (02-06). |
| 2.6 | Estrategia de versionado escrita, con qué cuenta como cambio compatible (02-07). |
| 2.7 | openapi.yaml en OpenAPI 3.1 que pasa spectral lint sin errores (05-02). |
| 2.8 | Al menos un ejemplo de petición y respuesta por operación. |
Fase 3 — Implementación (módulo 3)
Entregable: código en src/, migraciones/, pruebas/.
| # | Criterio de aceptación |
|---|---|
| 3.1 | Estructura por capas respetada: las rutas no tocan la base de datos y los repositorios no conocen HTTP (03-05). |
| 3.2 | Validación con Zod de cuerpo, parámetros de ruta y query, con errores traducidos al formato del catálogo (03-04). |
| 3.3 | Migraciones versionadas y reproducibles desde cero con un solo comando. |
| 3.4 | La reserva de plaza se hace en una transacción y es correcta bajo concurrencia (con prueba que lo demuestre). |
| 3.5 | Autenticación JWT + bcrypt y autorización por rol y pertenencia (03-06). |
| 3.6 | Manejo de errores unificado: ningún 500 sin registrar y ninguna traza filtrada al cliente (03-07). |
| 3.7 | Pruebas con node:test + Supertest, cobertura ≥ 70 % en servicios, con casos de éxito y de error (03-08). |
Fase 4 — Endurecimiento (módulo 4)
Entregable: middleware, configuración y docs/seguridad.md.
| # | Criterio de aceptación |
|---|---|
| 4.1 | helmet activo y cabeceras revisadas una a una, no por defecto ciego (04-02). |
| 4.2 | Rate limiting con límites distintos para escritura y lectura, y 429 con Retry-After (04-04). |
| 4.3 | CORS con lista blanca explícita de orígenes, no * en producción (04-05). |
| 4.4 | ETag + If-None-Match en al menos dos colecciones de lectura frecuente, con 304 verificado en prueba (04-06). |
| 4.5 | Logs estructurados con pino, id de correlación por petición y sin datos personales en claro (04-07). |
| 4.6 | Métricas con prom-client: contador de peticiones, histograma de latencia y al menos una métrica de negocio (plazas ocupadas, por ejemplo). |
Fase 5 — Herramientas (módulo 5)
Entregable: postman/, Dockerfile, .github/workflows/ci.yml.
| # | Criterio de aceptación |
|---|---|
| 5.1 | Colección de Postman que recorre el flujo completo (registro → inscripción → cancelación → valoración) y pasa con newman run (05-01). |
| 5.2 | Swagger UI servido desde la propia API en /v1/docs, alimentado por openapi.yaml. |
| 5.3 | Validación de contrato en las pruebas: al menos una prueba comprueba que la respuesta real encaja con el esquema declarado (05-04). |
| 5.4 | Dockerfile multi-etapa que arranca la API con un solo docker run. |
| 5.5 | CI en GitHub Actions que ejecuta lint, Spectral, pruebas y Newman, y falla si algo falla (05-05). |
Fase 6 — Memoria (módulo 6)
Entregable: docs/memoria.md + docs/decisiones/*.md (ADR).
| # | Criterio de aceptación |
|---|---|
| 6.1 | Al menos 6 ADR con decisión, contexto, alternativas descartadas y consecuencias, al estilo de 06-01. |
| 6.2 | Autocrítica honesta: tres cosas que rehiarías y por qué. |
| 6.3 | Plan de evolución a un año, con qué añadirías sin romper y qué obligaría a una v2 (06-03). |
| 6.4 | Política de deprecación escrita: cabeceras, plazos y comunicación. |
| 6.5 | Comparación breve con el caso de CafeSocial de 06-02: qué decisiones tuyas no serían válidas en otro dominio. |
- Rúbrica de autoevaluación
Puntúa cada criterio con 0 (insuficiente), 0,6 (correcto) o 1 (excelente) y multiplica por el peso. Por debajo de 60 puntos, vuelve a la fase más floja antes de seguir.
| Criterio | Peso | Insuficiente | Correcto | Excelente |
|---|---|---|---|---|
| Diseño del contrato | 20 | URIs con verbos, códigos inventados, errores inconsistentes | Recursos y métodos correctos, errores catalogados | Contrato que se entiende sin leer el código; casos límite previstos |
| Fidelidad al dominio | 15 | Faltan reglas de negocio o se contradicen | Todas las reglas implementadas | Reglas implementadas y expresadas en el contrato, no ocultas |
| Corrección bajo concurrencia | 15 | Se puede sobrevender el aforo | Reserva atómica en transacción | Además probada con peticiones simultáneas reales |
| Calidad de implementación | 10 | Todo en las rutas, sin capas | Capas respetadas, validación completa | Código legible, servicios reutilizables, sin repetición |
| Seguridad | 10 | Sin autenticación o con secretos en el repositorio | JWT, roles, helmet, CORS, rate limiting | Autorización por pertenencia y superficie mínima expuesta |
| Pruebas | 10 | Anecdóticas o inexistentes | ≥ 70 % en servicios, casos de error | Unitarias + integración + e2e + contrato |
| Documentación | 10 | README escueto | OpenAPI válido y Swagger UI | Ejemplos ejecutables y guía de primeros pasos para el integrador |
| Observabilidad | 5 | console.log |
Logs estructurados y métricas básicas | Correlación de peticiones y métricas de negocio útiles |
| Memoria y autocrítica | 5 | Descripción de lo hecho | Decisiones justificadas | Alternativas descartadas y plan de evolución creíble |
| Total | 100 |
- Guía de arranque: el primer día
# 1. Proyecto y dependencias
mkdir aula-aroma && cd aula-aroma
npm init -y
npm pkg set type="module"
npm pkg set engines.node=">=20"
npm install express@4 zod better-sqlite3 jsonwebtoken bcrypt \
helmet cors express-rate-limit pino pino-http prom-client \
swagger-ui-express yaml
npm install --save-dev supertest @stoplight/spectral-cli newman c8
# 2. Estructura por capas (la misma de todo el curso)
mkdir -p src/{config,rutas,controladores,servicios,repositorios,esquemas,middleware,errores,observabilidad}
mkdir -p migraciones pruebas/{unitarias,integracion,e2e,ayudas} docs/decisiones postman
touch src/app.js src/servidor.js openapi.yaml docs/analisis.md
# 3. Scripts mínimos
npm pkg set scripts.dev="node --watch src/servidor.js"
npm pkg set scripts.migrar="node migraciones/ejecutar.js"
npm pkg set scripts.prueba="node --test pruebas/"
npm pkg set scripts.contrato="spectral lint openapi.yaml"
# 4. Higiene desde el minuto uno
printf "node_modules\n*.db\n.env\n" > .gitignore
git init && git add -A && git commit -m "Estructura inicial de Aula Aroma"Antes de escribir la primera ruta, escribe el primer ADR. docs/decisiones/0001-versionado-en-la-ruta.md te costará diez minutos y te ahorrará una discusión contigo mismo dentro de tres semanas.
- Plan de trabajo por semanas
Estimado para unas 8-10 horas semanales. Ajusta el calendario, no el orden.
| Semana | Foco | Al terminar deberías tener |
|---|---|---|
| 1 | Fase 1 completa | Análisis, glosario y casos de uso cerrados. Cero código. |
| 2 | Fase 2: recursos, métodos, errores | Mapa de URIs y catálogo de errores revisados dos veces |
| 3 | Fase 2: openapi.yaml |
Contrato que pasa Spectral y ejemplos de cada operación |
| 4 | Fase 3: esqueleto, migraciones, CRUD de cursos y sesiones | GET/POST funcionando con validación y errores unificados |
| 5 | Fase 3: inscripciones, aforo, cancelación, lista de espera | La lógica difícil resuelta y probada bajo concurrencia |
| 6 | Fase 3: autenticación, autorización, cobertura | Pruebas verdes y ≥ 70 % en servicios |
| 7 | Fase 4 completa | Cabeceras, límites, CORS, ETag, logs y métricas |
| 8 | Fase 5 completa | Postman + Newman, Swagger UI, Docker y CI en verde |
| 9 | Fase 6 y repaso con la rúbrica | Memoria, ADR y puntuación honesta |
- Pistas para los puntos donde casi todo el mundo se atasca
7.1 ¿La inscripción es un recurso propio o un subrecurso de la sesión?
Las dos cosas, y no es una trampa. Se crea donde vive la restricción (POST /v1/sesiones/{id}/inscripciones: la plaza pertenece a la sesión) y se consulta y manipula por su identidad propia (GET /v1/inscripciones/{id}), porque una inscripción tiene ciclo de vida, aparece en "mis inscripciones" y se referencia desde valoraciones y facturas. La regla práctica: si algo se lista de forma independiente de su padre o se enlaza desde otros sitios, necesita URI canónica propia (02-02).
7.2 Plazas y concurrencia: la condición va dentro del UPDATE
El error clásico es leer, comprobar en JavaScript y escribir. Entre la lectura y la escritura cabe otra petición entera. La comprobación tiene que ser parte de la escritura, como en 06-01:
-- Correcto: la condición vive en el UPDATE. Si devuelve 0 filas, no había plaza.
UPDATE sesiones
SET plazas_ocupadas = plazas_ocupadas + 1
WHERE id = ?
AND plazas_ocupadas < aforo
AND estado = 'abierta';// servicios/inscripciones.js
export function inscribir(sesionId, asistenteId) {
return db.transaction(() => {
const res = repoSesiones.ocuparPlaza(sesionId); // el UPDATE de arriba
if (res.changes === 0) {
// No sabemos si es aforo lleno o sesión cerrada: preguntamos ahora, ya sin carrera
const sesion = repoSesiones.buscarPorId(sesionId);
if (!sesion) throw new ErrorNoEncontrado('sesion_no_encontrada');
if (sesion.estado !== 'abierta') throw new ErrorConflicto('inscripciones_cerradas');
throw new ErrorConflicto('aforo_completo'); // 409, con enlace a lista de espera
}
return repoInscripciones.crear({ sesionId, asistenteId, estado: 'confirmada' });
})();
}Y añade la red de seguridad en el esquema, para que la base de datos no dependa de que tu código sea perfecto:
CREATE TABLE inscripciones (
id TEXT PRIMARY KEY,
sesion_id TEXT NOT NULL REFERENCES sesiones(id),
asistente_id TEXT NOT NULL REFERENCES asistentes(id),
estado TEXT NOT NULL CHECK (estado IN ('confirmada','cancelada','asistida','ausente')),
precio_cent INTEGER NOT NULL CHECK (precio_cent >= 0),
creada_en TEXT NOT NULL
);
-- Una persona no puede tener dos inscripciones vivas en la misma sesión
CREATE UNIQUE INDEX ux_inscripcion_viva
ON inscripciones (sesion_id, asistente_id)
WHERE estado <> 'cancelada';7.3 ¿400 o 409?
La pregunta correcta es: ¿reenviar la misma petición más tarde podría funcionar?
| Situación | Código | Por qué |
|---|---|---|
puntuacion: 9 en una valoración de 1 a 5 |
400 |
La petición está mal formada; nunca será válida |
Falta sesionId |
400 |
Sintaxis del cuerpo |
| Aforo completo | 409 |
La petición es válida; el estado del servidor lo impide, y puede cambiar |
| Inscripciones ya cerradas por la antelación | 409 |
Válida, pero incompatible con el estado actual |
| Valorar una sesión a la que no asististe | 403 |
Es una cuestión de permiso sobre el recurso, no de forma |
| Sesión inexistente | 404 |
No hay recurso al que aplicar la operación |
Un truco extra: 422 es legítimo cuando el cuerpo es sintácticamente válido pero semánticamente imposible (fecha de fin anterior a la de inicio). Elige una de las dos convenciones (400 para todo o 400/422 separados) y aplícala sin excepciones; lo que rompe a los integradores es la incoherencia, no la elección (02-04).
7.4 Fechas y zonas horarias
Guarda siempre en UTC y emite ISO-8601 con Z. Pero añade en la sesión el huso de la sede ("zonaHoraria": "Europe/Madrid") y la hora local ya formateada si tus clientes la van a pintar: no obligues a cada consumidor a resolverlo. Para filtrar, acepta un rango explícito y no un concepto ambiguo:
Evita ?fecha=2027-03-28, porque "ese día" depende del huso de quien pregunta y el 28 de marzo tiene 23 horas en Madrid. Si aun así quieres ofrecerlo por comodidad, documenta explícitamente que se interpreta en la zona de la sede.
7.5 La lista de espera es un estado, no otra colección
Es tentador crear una tabla y un recurso paralelos. Pero una persona en lista de espera es una inscripción con estado: "en_espera" y una posicion. Así, promocionar a alguien es un cambio de estado y no un traslado entre colecciones, "mis inscripciones" devuelve todo con un filtro y no duplicas las reglas de unicidad. Expón /v1/sesiones/{id}/lista-espera como vista filtrada de las inscripciones de esa sesión, no como almacén distinto.
7.6 No acoples el JSON a las tablas
Tu tabla tiene plazas_ocupadas, aforo y precio_cent. Tu representación debería ofrecer lo que el consumidor necesita: plazasDisponibles (derivado), precio en euros con dos decimales, estado calculado ("abierta", "completa", "cerrada"). Si más adelante cambias el cálculo de plazas, el contrato no se entera. Esta es la línea que separa una API de un formulario sobre una base de datos (02-01, 03-05).
- Solución de referencia de la Fase 2
Te doy resuelto el contrato para que tengas una vara de medir. No copies sin entender: cada fila tiene un porqué, y hay decisiones discutibles a propósito. El resto de fases es cosa tuya.
8.1 Mapa de URIs
| Método y URI | Qué hace | Éxito | Errores frecuentes | Auth |
|---|---|---|---|---|
GET /v1/cursos |
Lista cursos, con nivel, duracionMax, q, limite/desplazamiento |
200 |
400 |
No |
GET /v1/cursos/{id} |
Detalle con _links a sus sesiones |
200 |
404 |
No |
POST /v1/cursos |
Crea curso | 201 + Location |
400, 403 |
Admin |
PATCH /v1/cursos/{id} |
Modifica campos sueltos | 200 |
400, 404, 409 |
Admin |
GET /v1/cursos/{id}/sesiones |
Sesiones de un curso, filtrables por desde/hasta/sede |
200 |
400, 404 |
No |
GET /v1/sesiones |
Todas las sesiones, mismo filtrado | 200 |
400 |
No |
GET /v1/sesiones/{id} |
Detalle con plazasDisponibles y estado |
200 |
404 |
No |
POST /v1/sesiones |
Programa una sesión de un curso | 201 |
400, 404, 409 |
Admin |
POST /v1/sesiones/{id}/inscripciones |
Ocupa plaza (acepta Idempotency-Key) |
201 |
400, 401, 404, 409 |
Asistente |
GET /v1/sesiones/{id}/inscripciones |
Inscritos de la sesión | 200 |
403, 404 |
Instructor/Admin |
GET /v1/sesiones/{id}/lista-espera |
Vista de inscripciones en_espera, ordenadas por posición |
200 |
404 |
Instructor/Admin |
POST /v1/sesiones/{id}/lista-espera |
Entra en lista de espera cuando hay aforo completo | 201 |
401, 404, 409 |
Asistente |
GET /v1/inscripciones |
Mis inscripciones, filtro por estado |
200 |
401 |
Asistente |
GET /v1/inscripciones/{id} |
Detalle canónico | 200 |
401, 403, 404 |
Propietario/Admin |
PUT /v1/inscripciones/{id}/cancelacion |
Cancela y devuelve el reembolso calculado | 200 |
401, 403, 404, 409 |
Propietario/Admin |
POST /v1/valoraciones |
Valora una sesión asistida | 201 |
400, 401, 403, 409 |
Asistente |
GET /v1/valoraciones |
Lista por sesionId, cursoId o instructorId, con cursor |
200 |
400 |
No |
DELETE /v1/valoraciones/{id} |
Retira la propia valoración | 204 |
401, 403, 404 |
Propietario/Admin |
GET /v1/instructores |
Lista con especialidades | 200 |
400 |
No |
GET /v1/instructores/{id} |
Detalle con valoracionMedia y totalSesiones |
200 |
404 |
No |
Dos decisiones que merecen comentario. La cancelación es un subrecurso con PUT, no un DELETE /inscripciones/{id}: cancelar no borra nada (la inscripción sigue existiendo, con historial y reembolso), tiene resultado propio que devolver y es idempotente —cancelar dos veces deja el mismo estado—. Y POST /v1/valoraciones no cuelga de la sesión porque la valoración se lista y consulta de forma transversal (por instructor, por curso, por autor) y el enlace natural es la inscripción, que ya identifica sesión y persona.
8.2 Catálogo de errores
| Código | HTTP | Cuándo |
|---|---|---|
datos_invalidos |
400 | Fallo de validación; detalles lleva campo y motivo |
rango_fechas_invalido |
400 | desde posterior a hasta, o formato no ISO-8601 |
cursor_invalido |
400 | Cursor mal formado o caducado |
credenciales_invalidas |
401 | Usuario o contraseña incorrectos |
token_expirado |
401 | JWT caducado; el cliente debe renovar |
permiso_insuficiente |
403 | Rol sin acceso a la operación |
no_asististe_a_la_sesion |
403 | Intento de valorar sin inscripción asistida |
curso_no_encontrado |
404 | Id inexistente |
sesion_no_encontrada |
404 | Id inexistente |
inscripcion_no_encontrada |
404 | Id inexistente o de otra persona sin privilegios |
aforo_completo |
409 | No quedan plazas; _links.listaEspera indica la salida |
inscripciones_cerradas |
409 | Se superó la antelación mínima |
ya_inscrito |
409 | Ya existe inscripción viva en esa sesión |
ya_valorada |
409 | Solo se admite una valoración por inscripción |
sesion_ya_cancelada |
409 | Operación sobre una sesión anulada |
codigo_promocional_no_aplicable |
409 | Caducado, agotado o incompatible |
demasiadas_peticiones |
429 | Límite superado; ver Retry-After |
error_interno |
500 | Fallo no previsto; se registra con id de correlación |
Ejemplo de cuerpo de error, en el formato del curso:
{
"error": {
"codigo": "aforo_completo",
"mensaje": "La sesión ses_0412 no tiene plazas disponibles.",
"detalles": [
{ "campo": "sesionId", "motivo": "aforo 12 de 12 ocupado" }
]
},
"_links": {
"listaEspera": "/v1/sesiones/ses_0412/lista-espera",
"sesionesAlternativas": "/v1/cursos/cur_007/sesiones?desde=2027-04-01T00:00:00Z"
}
}Fíjate en el detalle: el error no solo dice que no, dice qué hacer a continuación. Eso es HATEOAS aplicado con criterio (01-05), sin ceremonia inútil.
8.3 Fragmento de openapi.yaml
openapi: 3.1.0
info:
title: API de Aula Aroma
version: 1.0.0
description: Cursos, sesiones presenciales e inscripciones de Tienda Aroma.
servers:
- url: https://api.aula-aroma.example/v1
paths:
/sesiones/{sesionId}/inscripciones:
post:
summary: Inscribe al asistente autenticado en una sesión
operationId: crearInscripcion
tags: [Inscripciones]
security: [{ bearerAuth: [] }]
parameters:
- name: sesionId
in: path
required: true
schema: { type: string, pattern: '^ses_[0-9]{4}$' }
- name: Idempotency-Key
in: header
required: false
description: Repetir la petición con la misma clave no crea una segunda inscripción.
schema: { type: string, maxLength: 64 }
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
codigoPromocional: { type: string, maxLength: 24 }
additionalProperties: false
responses:
'201':
description: Inscripción confirmada
headers:
Location:
schema: { type: string }
example: /v1/inscripciones/ins_10233
content:
application/json:
schema: { $ref: '#/components/schemas/Inscripcion' }
'409':
description: Aforo completo o inscripciones cerradas
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
schemas:
Inscripcion:
type: object
required: [id, sesionId, asistenteId, estado, precio, creadaEn]
properties:
id: { type: string, example: ins_10233 }
sesionId: { type: string, example: ses_0412 }
asistenteId: { type: string, example: asi_0091 }
estado:
type: string
enum: [confirmada, en_espera, cancelada, asistida, ausente]
posicionEspera:
type: [integer, 'null']
description: Posición en la lista de espera; null si está confirmada.
precio: { type: string, example: '45.00', description: Euros con dos decimales }
descuentoAplicado: { type: string, example: '15%' }
creadaEn: { type: string, format: date-time }
_links:
type: object
properties:
self: { type: string }
sesion: { type: string }
cancelacion: { type: string }
Error:
type: object
required: [error]
properties:
error:
type: object
required: [codigo, mensaje]
properties:
codigo: { type: string, example: aforo_completo }
mensaje: { type: string }
detalles:
type: array
items:
type: object
properties:
campo: { type: string }
motivo: { type: string }8.4 El flujo que tienes que hacer funcionar
sequenceDiagram
participant C as Cliente
participant A as API Aula Aroma
participant D as SQLite
C->>A: POST /v1/sesiones/ses_0412/inscripciones
A->>A: Valida JWT y cuerpo (Zod)
A->>D: BEGIN + UPDATE sesiones ... WHERE ocupadas < aforo
alt Quedaba plaza
D-->>A: 1 fila modificada
A->>D: INSERT inscripcion (confirmada) + COMMIT
A-->>C: 201 Created + Location
else Aforo completo
D-->>A: 0 filas modificadas
A->>D: ROLLBACK
A-->>C: 409 aforo_completo + _links.listaEspera
end
Errores Comunes y Consejos
- Empezar por el código. El síntoma es un
openapi.yamlescrito al final para documentar lo que ya existe. El contrato se diseña antes; si no, acabas exponiendo tu esquema de tablas y descubriendo en la fase 5 que la paginación no encaja. - Comprobar el aforo con un
SELECTprevio. Funciona en desarrollo, donde nunca hay dos peticiones a la vez, y sobrevende el primer día real. Escribe una prueba que lance 20 inscripciones simultáneas contra una sesión de 5 plazas y exija exactamente 5 confirmadas. - Usar
DELETEpara cancelar. Pierdes historial, no puedes devolver el reembolso calculado y te quedas sin sitio donde poner el motivo. Modela la cancelación como transición de estado. - Duplicar la lista de espera en otra tabla y otro recurso. Multiplica reglas, invita a incoherencias (alguien confirmado y en espera) y complica "mis inscripciones".
- Devolver céntimos en unos sitios y euros en otros. Elige, documéntalo en el contrato y ponlo en el conversor de la capa de representación, no en cada controlador (
02-05). - Mensajes de error como única información. Los clientes programan contra el
codigo, no contra el texto. Si cambias un mensaje no pasa nada; si cambias un código, rompes integraciones (06-03). - Autorizar solo por rol. "Es asistente" no basta: hay que comprobar que esa inscripción es suya. La pertenencia se verifica en el servicio, con el id del token, nunca con un id que venga del cuerpo.
- Dejar la observabilidad para el final. Añadir el id de correlación cuando ya hay 40 ficheros cuesta cinco veces más que ponerlo el primer día.
- Consejo de ritmo: si una fase se te atasca más de dos días, entrega la versión mínima que cumpla los criterios y sigue. Volver con el proyecto entero montado es más fácil que perfeccionar en el vacío.
- Consejo final: guarda cada decisión dudosa en un ADR en el momento de dudar. La memoria de la fase 6 se escribirá casi sola.
Ejercicios
Ejercicio 1 — Clasificar decisiones de modelado
Para cada elemento de Aula Aroma, decide si debe ser (a) un recurso con URI propia, (b) un subrecurso, (c) un campo de otro recurso, o (d) un parámetro de consulta. Justifica en una frase.
- La lista de espera de una sesión.
- El descuento del 15 % para clientes de la tienda.
- La valoración media de un instructor.
- La cancelación de una inscripción.
- Las sesiones de un curso en un rango de fechas.
Ejercicio 2 — Elegir el código de estado
Indica el código HTTP y el código de error del catálogo para cada situación:
POST /v1/valoracionescon"puntuacion": 0.POST /v1/sesiones/ses_0412/inscripcionesen una sesión con 12 de 12 plazas.PUT /v1/inscripciones/ins_555/cancelacioncuandoins_555pertenece a otra persona.POST /v1/sesiones/ses_9999/inscripcionessin que existases_9999.POST /v1/sesiones/ses_0412/inscripciones6 horas antes de una sesión con antelación mínima de 24 horas.
Ejercicio 3 — Esquema Zod de la inscripción
Escribe el esquema Zod que valida el cuerpo de POST /v1/sesiones/{sesionId}/inscripciones y el de PUT /v1/inscripciones/{id}/cancelacion, sabiendo que en la cancelación se admite un motivo opcional de hasta 200 caracteres y que ningún campo extra puede colarse.
Soluciones
Ejercicio 1
- (b) Subrecurso de la sesión, pero como vista filtrada de sus inscripciones:
GET /v1/sesiones/{id}/lista-espera. No es una colección independiente; por dentro esestado: "en_espera". - (c) Campo derivado del precio en la representación (
precioBase,precio,descuentoAplicado). No tiene identidad propia ni se consulta por sí mismo; se calcula al representar y al inscribir. - (c) Campo calculado del instructor (
valoracionMedia,totalValoraciones). El consumidor la quiere junto al instructor, y obligarle a una segunda petición para un número es mal diseño. - (b) Subrecurso de la inscripción con
PUT: tiene efectos propios (reembolso), resultado que devolver y es idempotente. UnDELETEperdería la información. - (d) Parámetros de consulta
desdeyhastasobreGET /v1/cursos/{id}/sesiones. Un filtro no crea un recurso nuevo (02-06).
Ejercicio 2
| # | Código | Error | Motivo |
|---|---|---|---|
| 1 | 400 |
datos_invalidos |
Fuera del rango 1-5: la petición nunca será válida |
| 2 | 409 |
aforo_completo |
Petición válida, estado del servidor incompatible y cambiante |
| 3 | 403 |
permiso_insuficiente |
Recurso existente sobre el que no se tiene permiso |
| 4 | 404 |
sesion_no_encontrada |
El recurso destino no existe |
| 5 | 409 |
inscripciones_cerradas |
Válida, pero la ventana temporal ya se cerró |
En el caso 3, si prefieres no revelar la existencia de inscripciones ajenas, un 404 es defendible: es una decisión de seguridad, no de semántica, y debe documentarse (04-02).
Ejercicio 3
// esquemas/inscripciones.js
import { z } from 'zod';
// Cuerpo de POST /v1/sesiones/{sesionId}/inscripciones
export const esquemaCrearInscripcion = z.object({
codigoPromocional: z.string().trim().min(3).max(24).regex(/^[A-Z0-9-]+$/, {
message: 'Solo mayúsculas, dígitos y guiones'
}).optional()
}).strict(); // strict() rechaza campos extra: nada de colar "estado" o "precio"
// Parámetro de ruta, validado aparte para no mezclar responsabilidades
export const esquemaSesionId = z.object({
sesionId: z.string().regex(/^ses_[0-9]{4}$/, { message: 'Identificador de sesión no válido' })
});
// Cuerpo de PUT /v1/inscripciones/{id}/cancelacion
export const esquemaCancelacion = z.object({
motivo: z.string().trim().max(200).optional()
}).strict();El detalle importante: el cliente no envía asistenteId. Sale del JWT. Si lo aceptaras del cuerpo, cualquiera podría inscribir a otra persona; es el fallo de autorización más común en proyectos de este tipo (03-06).
Conclusión
Hemos recorrido un camino largo. Empezamos en el módulo 1 preguntándonos qué es realmente una API y por qué HTTP, nacido para servir documentos, terminó siendo la mejor base para conectar sistemas; pasamos por Richardson, por HATEOAS y por la comparación honesta con SOAP, GraphQL y gRPC. En el módulo 2 dejamos de programar para diseñar: recursos, URIs, métodos, códigos, representaciones, paginación, versionado y documentación. El módulo 3 convirtió ese contrato en código con capas, validación, persistencia, autenticación, errores y pruebas. El módulo 4 lo endureció para el mundo real: seguridad, OAuth, límites, CORS, caché y observabilidad. El módulo 5 nos dio las herramientas que hacen sostenible el trabajo diario. Y el módulo 6 nos enseñó, con tres casos, que una API se juzga por cómo envejece.
Si dentro de unos años olvidas los detalles —y los olvidarás, porque las versiones de Express cambian y las bibliotecas se sustituyen—, quédate con lo que no caduca:
- El contrato es el producto. El código es reemplazable; la promesa que has hecho a quien te consume, no.
- Se diseña para el consumidor, no para la base de datos. La estructura de tus tablas es un asunto tuyo. Que se filtre al JSON es el origen de la mitad de las malas APIs.
- HTTP resuelve más de lo que parece. Códigos de estado, cabeceras condicionales, caché, negociación de contenido, idempotencia: casi siempre que quieras inventar un mecanismo, comprueba antes si ya existe.
- La compatibilidad es un compromiso. Añadir sin romper no es una limitación técnica, es una forma de respeto hacia gente que confió en ti.
- La seguridad y la observabilidad no son opcionales. Una API sin autorización correcta es una brecha esperando fecha; una API sin trazas es una caja negra el día del incidente.
- No hay un diseño REST universal. Lo vimos en
06-02: lo que era obvio para una tienda dejaba de serlo para una red social. Las reglas son herramientas de pensamiento, no dogmas.
¿Cómo seguir? Lee las fuentes de primera mano: las RFC de HTTP (9110 a 9114), la de Problem Details (9457), la especificación de OpenAPI, la de OAuth 2.1 y OIDC. Estudia APIs públicas que se toman en serio su contrato —Stripe es una clase magistral de versionado y errores; GitHub, de paginación, condicionalidad y evolución a lo largo de más de una década—. Coge cualquier API con la que trabajes y critícala con lo que ahora sabes: mira sus códigos de estado, su paginación, sus cabeceras de caché, su política de deprecación. Y si puedes, contribuye: revisa contratos ajenos, abre incidencias en las especificaciones de tu equipo, escribe la documentación que a ti te habría gustado encontrar.
Empezaste este curso con nociones de JavaScript. Terminas sabiendo diseñar un contrato antes de escribir una línea, construir una API por capas con validación, persistencia transaccional y autenticación, protegerla, cachearla, medirla, documentarla, probarla, empaquetarla y desplegarla, y —lo más difícil— sostenerla mientras cambia sin dejar tirado a quien depende de ella. Eso no es poco: es el trabajo completo.
Ahora ve a construir Aula Aroma. Y cuando termines, léela como si fueras el integrador que llegará dentro de tres años.
Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful
Módulo 1: Introducción a las APIs RESTful
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
