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

  1. El encargo: la API de Aula Aroma
  2. Dominios alternativos
  3. Entrega por fases y criterios de aceptación
  4. Rúbrica de autoevaluación
  5. Guía de arranque: el primer día
  6. Plan de trabajo por semanas
  7. Pistas para los puntos donde casi todo el mundo se atasca
  8. Solución de referencia de la Fase 2
  9. Errores comunes y consejos
  10. Ejercicios
  11. Conclusión del curso

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

  1. 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.
  2. Cierre de inscripciones. No se admiten inscripciones nuevas a partir de X horas antes del comienzo (parametrizable por curso; por defecto 24).
  3. 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.
  4. 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 %.
  5. Valoración condicionada. Un asistente solo puede valorar una sesión si su inscripción figura como asistida, y solo una vez.
  6. 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 del UPDATE, no en un SELECT previo.
  • 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?

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

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

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

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

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

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

GET /v1/sesiones?desde=2027-03-27T22:00:00Z&hasta=2027-03-28T22:00:00Z&sede=mad_centro

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

  1. 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.yaml escrito 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 SELECT previo. 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 DELETE para 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.

  1. La lista de espera de una sesión.
  2. El descuento del 15 % para clientes de la tienda.
  3. La valoración media de un instructor.
  4. La cancelación de una inscripción.
  5. 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:

  1. POST /v1/valoraciones con "puntuacion": 0.
  2. POST /v1/sesiones/ses_0412/inscripciones en una sesión con 12 de 12 plazas.
  3. PUT /v1/inscripciones/ins_555/cancelacion cuando ins_555 pertenece a otra persona.
  4. POST /v1/sesiones/ses_9999/inscripciones sin que exista ses_9999.
  5. POST /v1/sesiones/ses_0412/inscripciones 6 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

  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 es estado: "en_espera".
  2. (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.
  3. (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.
  4. (b) Subrecurso de la inscripción con PUT: tiene efectos propios (reembolso), resultado que devolver y es idempotente. Un DELETE perdería la información.
  5. (d) Parámetros de consulta desde y hasta sobre GET /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

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados