En 02-08 escribimos un fragmento de openapi.yaml: un solo endpoint, GET /cafes, con sus parámetros y dos respuestas. Desde entonces ese fichero nos ha acompañado en todo el curso —lo lintamos con Spectral en 04-01, lo mencionamos al importar la colección en 05-01— pero sigue describiendo una fracción mínima de una API que hoy tiene seis recursos, una docena de subrecursos, autenticación JWT, OAuth con seis ámbitos, veinticinco códigos de error y cabeceras propias.
Un contrato incompleto es peor que no tener contrato, porque genera confianza injustificada. Quien lee openapi.yaml y no encuentra POST /pedidos concluye que no existe, o —peor— que existe pero funciona como él se imagina.
Esta lección termina el trabajo. Vamos a construir la especificación completa de Tienda Aroma sección a sección, entender la diferencia entre escribirla a mano y generarla desde el código, servir Swagger UI en el propio proyecto, validarla en dos niveles y generar con ella los clientes TypeScript de la SPA y de Aroma Móvil. Al terminar, openapi.yaml dejará de ser documentación para convertirse en la fuente desde la que se producen otras cosas.
Contenido
- OpenAPI y Swagger: dos cosas que la gente confunde
- Versiones: 2.0, 3.0 y 3.1
- La anatomía del documento
openapi,infoyserverstags: la organización que ve el lectorpaths:GET /cafescompletopaths:POST /pedidoscompletocomponents.schemas: los tipos de Tienda Aromacomponents.parametersycomponents.responsesreutilizablessecuritySchemesysecurity: JWT y OAuth 2.0$refy los límites de la reutilizaciónexamplefrente aexamplesoneOf,allOfydiscriminator- Documentar la deprecación y los límites de peticiones
- Dos formas de trabajar: a mano o desde el código
- Generar la especificación con
swagger-jsdoc - Servir Swagger UI en
/docs - Alternativas de renderizado: Redoc, Scalar, Stoplight Elements
- Validar la especificación:
swagger-cliy Spectral - Generar clientes con OpenAPI Generator
- Mantener el contrato sincronizado
- OpenAPI y Swagger: dos cosas que la gente confunde
La confusión es histórica y merece dos minutos, porque afecta a cómo se buscan las herramientas.
- Swagger nació en 2011 como un formato de especificación y un conjunto de herramientas, obra de Tony Tam. En 2015 SmartBear compró el proyecto y donó la especificación a la Linux Foundation.
- La especificación donada pasó a llamarse OpenAPI Specification (OAS) y la gobierna la OpenAPI Initiative. Swagger 2.0 fue renombrada como OpenAPI 2.0; a partir de ahí, las versiones son 3.0 y 3.1.
- Swagger hoy es la marca de la familia de herramientas de SmartBear.
| Nombre | Qué es | Ejemplo de uso |
|---|---|---|
| OpenAPI | La especificación: cómo se escribe el YAML/JSON | El openapi: 3.1.0 de nuestro fichero |
| Swagger UI | Renderizador HTML interactivo de una especificación | Lo serviremos en /docs |
| Swagger Editor | Editor web con validación en vivo | Escribir el YAML con autocompletado |
| Swagger Codegen | Generador de clientes y servidores | Sustituido en la práctica por OpenAPI Generator |
swagger-jsdoc |
Genera OpenAPI desde comentarios en el código | El enfoque code-first del apartado 16 |
swagger-ui-express |
Middleware de Express que sirve Swagger UI | La ruta /docs |
Regla mnemotécnica: el fichero es OpenAPI; lo que lo pinta y lo procesa suele llamarse Swagger. Decir "mi Swagger" refiriéndose al fichero es habitual y todo el mundo te entiende, pero saber la diferencia evita perder tiempo buscando en la documentación equivocada.
- Versiones: 2.0, 3.0 y 3.1
| 2.0 (Swagger) | 3.0 | 3.1 | |
|---|---|---|---|
| Año | 2014 | 2017 | 2021 |
| Servidores | host + basePath + schemes |
servers (lista, con variables) |
Igual que 3.0 |
| Cuerpo de petición | Un parámetro in: body |
requestBody con content por tipo |
Igual que 3.0 |
| Reutilizables | definitions, parameters, responses |
Todo bajo components |
Igual que 3.0 |
| JSON Schema | Subconjunto propio incompatible | Subconjunto ampliado, casi compatible | JSON Schema 2020-12 completo |
nullable |
No existe | nullable: true |
type: [string, "null"] |
| Webhooks | No | No | webhooks como sección de primer nivel |
| Ejemplos | example |
example y examples |
Igual, más examples de JSON Schema |
| Soporte de herramientas | Total (legado) | Total | Bueno, con excepciones |
Qué versión usar. Tienda Aroma usa 3.1 por dos motivos concretos:
- Alineación total con JSON Schema 2020-12. Los esquemas del contrato pueden usarse tal cual en AJV para validar respuestas en las pruebas (05-04) y en el
validar()del servidor, sin traducciones ni sorpresas. En 3.0 los esquemas eran "casi" JSON Schema, y ese "casi" cuesta tardes enteras. webhooksde primer nivel. Nuestra arquitectura envíapedido.pagadoypedido.enviadoa RápidoEnvíos con firma HMAC. En 3.0 no había forma de documentarlos como parte de la API; se colaban en la descripción en prosa.
El precio a pagar: alguna herramienta antigua todavía no digiere 3.1 y hay que degradar a 3.0 para ciertos generadores. Es un problema en retroceso, y openapi.yaml puede convertirse automáticamente cuando haga falta.
- La anatomía del documento
Un documento OpenAPI 3.1 tiene estas secciones de primer nivel:
openapi: 3.1.0 # versión de la ESPECIFICACIÓN (no de tu API)
info: {} # metadatos: título, versión de tu API, contacto, licencia
servers: [] # dónde vive la API: producción, pruebas, local
tags: [] # agrupaciones para la documentación
security: [] # seguridad aplicada por defecto a todas las operaciones
paths: {} # las rutas y sus operaciones — el grueso del fichero
webhooks: {} # (3.1) eventos salientes: los de RápidoEnvíos
components: {} # piezas reutilizables referenciadas con $ref
externalDocs: {} # enlace a documentación complementariaDe estas, openapi, info y una de paths/webhooks/components son obligatorias. El resto es opcional pero, sin servers ni security, la especificación no sirve para generar nada útil.
openapi, info y servers
openapi, info y serversopenapi: 3.1.0
info:
title: API de Tienda Aroma
summary: Catálogo, pedidos y reseñas de café de especialidad.
description: |
API REST de **Tienda Aroma**, tienda en línea de café de especialidad.
## Convenios generales
- Todos los identificadores son **opacos** y con prefijo (`caf_`, `ped_`, `cli_`).
No los interpretes ni construyas: úsalos tal cual los recibes.
- Los importes viajan en **euros con dos decimales** (`precioEuros`, `totalEuros`).
- Las fechas son **ISO-8601 en UTC** con sufijo `Z`.
- Las colecciones devuelven `{ "datos": [...], "total": n }` y están
**siempre paginadas**: sin `limite`, se aplican 20 elementos.
- Los errores siguen el formato `{ "error": { "codigo", "mensaje", "detalles" } }`.
El `codigo` es estable y es lo que debes programar; el `mensaje` puede cambiar.
- Un parámetro de consulta desconocido produce `400`, no se ignora.
## Límites de uso
600 peticiones por minuto para clientes autenticados. Al superarlo se responde
`429` con `Retry-After`. Consulta las cabeceras `Aroma-RateLimit-*` en cada respuesta.
## Compatibilidad
Añadimos campos nuevos sin previo aviso: **ignora los que no conozcas**.
Los cambios rompedores llegan en una versión mayor de la ruta (`/v2`), con un
mínimo de 6 meses de convivencia y cabeceras `Deprecation` y `Sunset`.
version: 1.7.0
termsOfService: https://tiendaaroma.example/terminos-api
contact:
name: Equipo de plataforma de Tienda Aroma
url: https://developers.tiendaaroma.example
email: [email protected]
license:
name: Propietaria
url: https://tiendaaroma.example/licencia-api
servers:
- url: https://api.tiendaaroma.example/v1
description: Producción. Datos reales; los límites de uso se aplican en serio.
- url: https://api.pruebas.tiendaaroma.example/v1
description: Pruebas (sandbox). Datos ficticios, se reinician cada noche.
- url: http://localhost:3000/v1
description: Desarrollo local.Tres advertencias sobre esta cabecera, que parece trivial y no lo es:
info.versiones la versión de tu API, no de OpenAPI. Son campos distintos que la gente confunde constantemente. Usamos SemVer:1.7.0significa que ha habido siete tandas de adiciones compatibles desde la 1.0.0. Un2.0.0implicaría un cambio rompedor y, por tanto, un/v2en la ruta, según 02-07.- La
descriptiondeinfoes la portada de tu documentación. Es el único sitio donde caben los convenios transversales —dinero, fechas, identificadores opacos, paginación, compatibilidad— que no pertenecen a ningún endpoint concreto y que, sin embargo, son lo primero que necesita quien integra. Acepta Markdown y Swagger UI lo renderiza. - El
urlde los servidores incluye/v1. Consecuencia directa de nuestra decisión de versionar en la ruta: las claves depathsquedan como/cafes, sin repetir/v1. Si lo pusieras en ambos sitios, los clientes generados llamarían a/v1/v1/cafes.
info.contact.url apunta al portal de desarrollador que veremos en 05-06.
tags: la organización que ve el lector
tags: la organización que ve el lectorLos tags agrupan operaciones. Sin ellos, Swagger UI muestra una lista plana con cuarenta endpoints y nadie encuentra nada.
tags:
- name: Cafés
description: |
Catálogo de cafés de especialidad. La lectura es pública en cuanto a datos,
pero requiere autenticación; la escritura exige rol `administrador`.
- name: Pedidos
description: |
Ciclo de vida del pedido: creación, pago, envío, factura, anulación y devolución.
Las transiciones de estado se hacen con subrecursos, no cambiando `estado` con PATCH.
- name: Clientes
description: Datos, preferencias y pedidos del cliente.
- name: Reseñas
description: Reseñas de cafés y su moderación.
- name: Carritos
description: Carrito de la compra previo al pedido.
- name: Sesiones
description: Autenticación con credenciales y obtención del token de acceso.
- name: Operación
description: Salud del servicio y metadatos. No forman parte de `/v1`.
x-tagGroups: # extensión que entienden Redoc y algunos portales
- name: Comercio
tags: [Cafés, Carritos, Pedidos]
- name: Comunidad
tags: [Clientes, Reseñas]
- name: Plataforma
tags: [Sesiones, Operación]Dos criterios: un tag por recurso (los recursos son estables, los casos de uso no) y descripciones que contengan la regla de negocio no obvia, como el hecho de que las transiciones de estado sean subrecursos. Esa frase evita media docena de preguntas en el canal de soporte.
Cualquier campo que empiece por x- es una extensión: la especificación permite añadirlos, las herramientas los ignoran si no los entienden, y algunas —como Redoc con x-tagGroups— los aprovechan.
paths: GET /cafes completo
paths: GET /cafes completoRetomamos el fragmento de 02-08 y lo llevamos a su forma final, ya con referencias a components:
paths:
/cafes:
get:
operationId: obtenerCafes # nombre del método en los clientes generados
summary: Lista el catálogo de cafés
description: |
Devuelve los cafés del catálogo filtrados, ordenados y paginados.
La paginación es obligatoria: sin `limite` se aplican 20 elementos y el
máximo es 100. Un parámetro de consulta desconocido produce `400`.
tags: [Cafés]
parameters:
- name: origen
in: query
description: Filtra por país de origen. Varios valores separados por comas.
required: false
schema: { type: string }
example: Colombia,Etiopía
- name: tueste
in: query
description: Filtra por nivel de tueste. Varios valores separados por comas.
schema:
type: string
pattern: '^(claro|medio|oscuro)(,(claro|medio|oscuro))*$'
example: claro,medio
- name: precioMin
in: query
description: Precio mínimo en euros, inclusive.
schema: { type: number, minimum: 0 }
- name: precioMax
in: query
description: Precio máximo en euros, inclusive.
schema: { type: number, minimum: 0 }
- name: disponible
in: query
description: Si es `true`, solo devuelve cafés con `stock` mayor que cero.
schema: { type: boolean }
- name: q
in: query
description: Búsqueda de texto en nombre, origen y notas de cata.
schema: { type: string, minLength: 2, maxLength: 100 }
- name: ordenar
in: query
description: |
Campo de ordenación; el prefijo `-` invierte el orden. Se admiten varios
campos separados por comas. El desempate final es siempre `id` ascendente.
schema:
type: string
default: nombre
example: -precioEuros,nombre
- name: campos
in: query
description: Lista de campos a incluir en cada elemento, separados por comas.
schema: { type: string }
example: id,nombre,precioEuros
- $ref: '#/components/parameters/Limite'
- $ref: '#/components/parameters/Desplazamiento'
responses:
'200':
description: Colección de cafés que cumplen el filtro.
headers:
Link:
$ref: '#/components/headers/Link'
ETag:
$ref: '#/components/headers/ETag'
Aroma-RateLimit-Restantes:
$ref: '#/components/headers/RateLimitRestantes'
content:
application/json:
schema: { $ref: '#/components/schemas/ColeccionCafes' }
examples:
primeraPagina:
summary: Primera página del catálogo
value:
datos:
- id: caf_001
nombre: Etiopía Yirgacheffe
origen: Etiopía
tueste: claro
precioEuros: 14.50
stock: 120
notasCata: [cítrico, floral, té negro]
fechaCreacion: '2026-01-15T08:30:00Z'
version: 3
- id: caf_002
nombre: Colombia Huila
origen: Colombia
tueste: medio
precioEuros: 12.90
stock: 80
notasCata: [caramelo, nuez]
fechaCreacion: '2026-01-16T09:10:00Z'
version: 1
total: 137
sinResultados:
summary: Filtro sin coincidencias — 200 con lista vacía, nunca 404
value: { datos: [], total: 0 }
'304':
description: No modificado. Se devuelve si `If-None-Match` coincide con el `ETag`.
'400': { $ref: '#/components/responses/Error400' }
'401': { $ref: '#/components/responses/Error401' }
'429': { $ref: '#/components/responses/Error429' }
'5XX': { $ref: '#/components/responses/Error500' }
post:
operationId: crearCafe
summary: Crea un café en el catálogo
description: Requiere rol `administrador`.
tags: [Cafés]
security:
- bearerJWT: []
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NuevoCafe' }
responses:
'201':
description: Café creado.
headers:
Location:
description: URI del recurso creado.
schema: { type: string, format: uri-reference }
example: /v1/cafes/caf_017
ETag:
$ref: '#/components/headers/ETag'
content:
application/json:
schema: { $ref: '#/components/schemas/Cafe' }
'400': { $ref: '#/components/responses/Error400' }
'401': { $ref: '#/components/responses/Error401' }
'403': { $ref: '#/components/responses/Error403' }
'409':
description: Ya existe un café con ese nombre y origen.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
parameters: [] # (parámetros comunes a todas las operaciones de esta ruta)Detalles que distinguen una especificación útil de una que solo compila:
operationIdes obligatorio en la práctica. Es el nombre del método en los clientes generados:obtenerCafesproduceapi.obtenerCafes({...}). Debe ser único en todo el documento y estable en el tiempo: cambiarlo rompe el código de todos los consumidores que usen el cliente generado, aunque la API no haya cambiado nada.- El ejemplo
sinResultadosdocumenta una decisión de diseño de 02-04 —un filtro sin coincidencias es200con lista vacía, no404— mejor que tres párrafos. '5XX'es la forma de agrupar toda la familia de errores de servidor sin repetirse. Las comillas son obligatorias en YAML: sin ellas,404se interpreta como número.securitya nivel de operación sobrescribe la global. AquíPOST /cafesexige explícitamentebearerJWTporque no admite el flujo de OAuth de terceros.
paths: POST /pedidos completo
paths: POST /pedidos completoEl caso más rico del contrato: exige Idempotency-Key, tiene ámbitos OAuth, y sus errores son de negocio.
/pedidos:
post:
operationId: crearPedido
summary: Crea un pedido
description: |
Crea un pedido en estado `pendiente_pago` y **reserva el stock** de cada línea.
Esta operación **exige la cabecera `Idempotency-Key`**: repetir la petición con
la misma clave y el mismo cuerpo devuelve la respuesta original sin crear un
pedido nuevo. Repetirla con la misma clave y distinto cuerpo produce `409`.
Guarda la clave antes de enviar y reutilízala en cualquier reintento.
tags: [Pedidos]
security:
- bearerJWT: []
- oauth2: [pedidos.escribir]
parameters:
- name: Idempotency-Key
in: header
required: true
description: UUID v4 generado por el cliente. Se conserva 24 horas.
schema: { type: string, format: uuid }
example: 7f3c1a90-2d64-4e11-9c88-1b2f4a6d0e55
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NuevoPedido' }
examples:
dosLineas:
summary: Pedido de dos cafés
value:
clienteId: cli_842
lineas:
- { cafeId: caf_001, cantidad: 2 }
- { cafeId: caf_002, cantidad: 1 }
responses:
'201':
description: Pedido creado y stock reservado.
headers:
Location:
description: URI del pedido creado.
schema: { type: string, format: uri-reference }
example: /v1/pedidos/ped_5001
content:
application/json:
schema: { $ref: '#/components/schemas/Pedido' }
'400': { $ref: '#/components/responses/Error400' }
'401': { $ref: '#/components/responses/Error401' }
'403': { $ref: '#/components/responses/Error403' }
'409':
description: |
Conflicto de negocio. Consulta `error.codigo` para distinguir:
- `stock_insuficiente`: alguna línea supera el stock disponible.
- `clave_idempotencia_reutilizada`: misma `Idempotency-Key`, distinto cuerpo.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
examples:
stockInsuficiente:
value:
error:
codigo: stock_insuficiente
mensaje: 'No hay stock suficiente de "Etiopía Yirgacheffe".'
detalles:
- { campo: 'lineas[0].cantidad', solicitado: 200, disponible: 120 }
claveReutilizada:
value:
error:
codigo: clave_idempotencia_reutilizada
mensaje: 'La clave de idempotencia ya se usó con otro cuerpo.'
detalles: []
'428':
description: Falta la cabecera `Idempotency-Key`.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example:
error:
codigo: clave_idempotencia_requerida
mensaje: 'La cabecera Idempotency-Key es obligatoria en esta operación.'
detalles: []
'429': { $ref: '#/components/responses/Error429' }Observa cómo el 409 se documenta con dos ejemplos con nombre: el mismo código HTTP significa dos cosas distintas y el consumidor programa contra error.codigo, no contra el estado. Esa es la razón de ser del catálogo de errores de 02-04, y aquí se hace visible.
components.schemas: los tipos de Tienda Aroma
components.schemas: los tipos de Tienda AromaLos esquemas son la parte más reutilizada del documento y la que alimentará las validaciones de 05-04 y los clientes generados del apartado 20.
components:
schemas:
Cafe:
type: object
title: Café
description: Un café del catálogo.
required: [id, nombre, origen, tueste, precioEuros, stock, fechaCreacion, version]
properties:
id:
type: string
pattern: '^caf_[A-Za-z0-9]+$'
description: Identificador opaco. No lo parsees ni lo construyas.
examples: [caf_001]
readOnly: true
nombre: { type: string, minLength: 1, maxLength: 120, examples: [Etiopía Yirgacheffe] }
origen: { type: string, minLength: 2, maxLength: 60, examples: [Etiopía] }
tueste:
type: string
enum: [claro, medio, oscuro]
description: Nivel de tueste. Valores cerrados; pueden añadirse nuevos en el futuro.
precioEuros:
type: number
minimum: 0
multipleOf: 0.01
description: |
Precio de venta en euros con dos decimales. Internamente se almacena en
céntimos enteros: no realices aritmética en coma flotante con este valor
si necesitas exactitud; multiplica por 100 y opera con enteros.
examples: [14.50]
stock: { type: integer, minimum: 0, examples: [120] }
notasCata:
type: array
maxItems: 8
items: { type: string, maxLength: 40 }
examples: [[cítrico, floral, té negro]]
fechaCreacion:
type: string
format: date-time
description: Fecha de alta en ISO-8601 UTC.
examples: ['2026-01-15T08:30:00Z']
readOnly: true
version:
type: integer
minimum: 1
description: |
Versión para concurrencia optimista. Coincide con el `ETag` de la respuesta;
envíalo en `If-Match` al modificar.
readOnly: true
_links:
$ref: '#/components/schemas/Enlaces'
NuevoCafe:
type: object
title: Café nuevo
description: Cuerpo para crear un café. No incluye campos calculados por el servidor.
required: [nombre, origen, tueste, precioEuros, stock]
additionalProperties: false # un campo desconocido produce 400 (03-04)
properties:
nombre: { type: string, minLength: 1, maxLength: 120 }
origen: { type: string, minLength: 2, maxLength: 60 }
tueste: { type: string, enum: [claro, medio, oscuro] }
precioEuros: { type: number, minimum: 0, multipleOf: 0.01 }
stock: { type: integer, minimum: 0, default: 0 }
notasCata:
type: array
maxItems: 8
items: { type: string, maxLength: 40 }
ParcheCafe:
type: object
title: Parche de café (merge-patch)
description: |
Cuerpo de `PATCH` con `Content-Type: application/merge-patch+json`.
Todos los campos son opcionales; `null` borra el campo cuando es admisible.
additionalProperties: false
minProperties: 1 # un parche vacío no tiene sentido: 400
properties:
nombre: { type: string, minLength: 1, maxLength: 120 }
precioEuros: { type: number, minimum: 0, multipleOf: 0.01 }
stock: { type: integer, minimum: 0 }
notasCata:
type: [array, 'null'] # sintaxis 3.1: en 3.0 sería nullable: true
items: { type: string, maxLength: 40 }
ColeccionCafes:
type: object
title: Colección de cafés
required: [datos, total]
properties:
datos:
type: array
items: { $ref: '#/components/schemas/Cafe' }
total:
type: integer
minimum: 0
description: Total de elementos que cumplen el filtro, no de la página actual.
LineaPedido:
type: object
required: [cafeId, cantidad]
properties:
cafeId: { type: string, pattern: '^caf_[A-Za-z0-9]+$' }
cantidad: { type: integer, minimum: 1, maximum: 99 }
precioUnitarioEuros: { type: number, readOnly: true }
subtotalEuros: { type: number, readOnly: true }
NuevoPedido:
type: object
required: [clienteId, lineas]
additionalProperties: false
properties:
clienteId: { type: string, pattern: '^cli_[A-Za-z0-9]+$' }
lineas:
type: array
minItems: 1
maxItems: 50
items: { $ref: '#/components/schemas/LineaPedido' }
Pedido:
type: object
required: [id, clienteId, lineas, totalEuros, estado, fechaCreacion]
properties:
id: { type: string, pattern: '^ped_[A-Za-z0-9]+$', readOnly: true }
clienteId: { type: string, pattern: '^cli_[A-Za-z0-9]+$' }
lineas:
type: array
items: { $ref: '#/components/schemas/LineaPedido' }
totalEuros: { type: number, minimum: 0, readOnly: true, examples: [29.00] }
estado:
type: string
enum: [pendiente_pago, pagado, enviado]
description: |
El estado **no se modifica con PATCH**: se cambia invocando los subrecursos
`/pedidos/{id}/pago`, `/pedidos/{id}/envio` o `/pedidos/{id}/anulacion`.
readOnly: true
fechaCreacion: { type: string, format: date-time, readOnly: true }
_links: { $ref: '#/components/schemas/Enlaces' }
Enlaces:
type: object
description: Enlaces de navegación del recurso (HATEOAS, nivel 3 de Richardson).
additionalProperties:
type: object
required: [href]
properties:
href: { type: string, format: uri-reference }
method:
type: string
enum: [GET, POST, PUT, PATCH, DELETE]
default: GET
examples:
- self: { href: /v1/pedidos/ped_5001 }
pago: { href: /v1/pedidos/ped_5001/pago, method: POST }
Error:
type: object
title: Error
description: |
Formato único de error de la API. Programa siempre contra `error.codigo`,
que es estable; `error.mensaje` está pensado para humanos y puede cambiar
sin previo aviso, incluso de idioma.
required: [error]
properties:
error:
type: object
required: [codigo, mensaje, detalles]
properties:
codigo:
type: string
description: Código estable del catálogo de errores.
enum:
[cafe_no_encontrado, cliente_no_encontrado, pedido_no_encontrado,
resena_no_encontrada, carrito_no_encontrado, stock_insuficiente,
pedido_ya_pagado, datos_invalidos, parametro_invalido, no_autenticado,
token_caducado, permisos_insuficientes, conflicto_version,
precondicion_requerida, operacion_en_curso, clave_idempotencia_requerida,
clave_idempotencia_reutilizada, limite_peticiones, cuerpo_demasiado_grande,
ruta_no_encontrada, metodo_no_permitido, formato_no_soportado,
error_interno, servicio_no_disponible, version_api_retirada]
mensaje: { type: string, description: Descripción legible en español. }
detalles:
type: array
description: Lista de problemas concretos. Vacía si no aplica.
items:
type: object
properties:
campo: { type: string, examples: ['lineas[0].cantidad'] }
problema: { type: string }
trazaId:
type: string
format: uuid
description: |
Identificador de la traza. **Solo presente en respuestas 5xx.**
Inclúyelo al abrir una incidencia con soporte.Cuatro decisiones que merecen justificación:
readOnly: truemarca los campos que el servidor calcula. Los generadores lo aprovechan: el tipoCafegenerado los incluye, pero el tipo del cuerpo de creación los omite. Es la razón por la queNuevoCafeexiste como esquema aparte en lugar de reutilizarCafe.additionalProperties: falsesolo en las entradas. En los cuerpos que recibimos, un campo desconocido es un error del cliente y devolvemos400(03-04). En las salidas, jamás: cerrarlas convertiría cualquier campo nuevo en un cambio rompedor para los clientes generados, contra la regla de 02-07.- El
enumcompleto del catálogo de errores. Coste alto de mantenimiento, valor alto: el cliente TypeScript generado obtiene un tipo unión con los veinticinco códigos y el compilador avisa si alguien escribecafe_no_encontrada. multipleOf: 0.01documenta formalmente la regla de los dos decimales que venimos arrastrando desde 02-05.
components.parameters y components.responses reutilizables
components.parameters y components.responses reutilizables parameters:
Limite:
name: limite
in: query
description: Número máximo de elementos a devolver.
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
Desplazamiento:
name: desplazamiento
in: query
description: |
Número de elementos a saltar. Máximo 10.000; a partir de ahí usa `cursor`
donde esté disponible, porque el desplazamiento profundo degrada la consulta.
schema: { type: integer, minimum: 0, maximum: 10000, default: 0 }
IdCafe:
name: id
in: path
required: true
description: Identificador opaco del café.
schema: { type: string, pattern: '^caf_[A-Za-z0-9]+$' }
example: caf_001
IfMatch:
name: If-Match
in: header
required: true
description: |
`ETag` de la versión que estás modificando. Obligatorio en `PUT`, `PATCH` y
`DELETE`: sin él se responde `428`; si no coincide, `412`.
schema: { type: string }
example: 'W/"3"'
headers:
Link:
description: Enlaces de paginación (RFC 8288) con `rel` `next`, `prev`, `first` y `last`.
schema: { type: string }
example: '</v1/cafes?limite=20&desplazamiento=20>; rel="next"'
ETag:
description: Validador de la representación. Úsalo en `If-None-Match` e `If-Match`.
schema: { type: string }
example: 'W/"3"'
RetryAfter:
description: Segundos que debes esperar antes de reintentar.
schema: { type: integer }
example: 30
RateLimitRestantes:
description: Peticiones que te quedan en la ventana actual.
schema: { type: integer }
example: 597
responses:
Error400:
description: Petición inválida — datos del cuerpo o parámetros de consulta.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
examples:
parametroInvalido:
value:
error:
codigo: parametro_invalido
mensaje: "El parámetro 'limite' no puede superar 100."
detalles: []
datosInvalidos:
value:
error:
codigo: datos_invalidos
mensaje: 'El cuerpo contiene campos inválidos.'
detalles:
- { campo: precioEuros, problema: 'debe ser mayor o igual que 0' }
Error401:
description: Falta el token, es inválido o ha caducado.
headers:
WWW-Authenticate:
description: Esquema esperado y motivo del rechazo.
schema: { type: string }
example: 'Bearer realm="api", error="invalid_token"'
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
Error403:
description: Autenticado pero sin permiso — rol o ámbito OAuth insuficiente.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
Error404:
description: El recurso no existe o no es visible para ti.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
Error429:
description: Se ha superado el límite de peticiones.
headers:
Retry-After: { $ref: '#/components/headers/RetryAfter' }
Aroma-RateLimit-Restantes: { $ref: '#/components/headers/RateLimitRestantes' }
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example:
error:
codigo: limite_peticiones
mensaje: 'Has superado el límite de 600 peticiones por minuto.'
detalles: []
Error500:
description: |
Error interno. Reintenta con retroceso exponencial y jitter. El cuerpo incluye
`trazaId`: cítalo si abres una incidencia.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }Con esto, cada operación declara sus errores en una línea ('404': { $ref: '#/components/responses/Error404' }) y el día que cambie el formato de error se toca un sitio. Sin components.responses, una API de cuarenta operaciones repite el bloque de error doscientas veces y, garantizado, tres de ellas quedan desactualizadas.
securitySchemes y security: JWT y OAuth 2.0
securitySchemes y security: JWT y OAuth 2.0 securitySchemes:
bearerJWT:
type: http
scheme: bearer
bearerFormat: JWT
description: |
Token JWT obtenido en `POST /v1/sesiones` con email y contraseña.
Caduca en 1 hora; renuévalo con `POST /v1/sesiones/refresco`.
Es el mecanismo de la SPA, del panel y de Aroma Móvil.
oauth2:
type: oauth2
description: |
Para aplicaciones de terceros (como CataBox) que actúan en nombre de un
cliente de Tienda Aroma. Registra tu aplicación en el portal de desarrollador
para obtener `client_id`. Las aplicaciones públicas **deben** usar PKCE.
flows:
authorizationCode:
authorizationUrl: https://auth.tiendaaroma.example/oauth/autorizar
tokenUrl: https://auth.tiendaaroma.example/oauth/token
refreshUrl: https://auth.tiendaaroma.example/oauth/token
scopes:
cafes.leer: Leer el catálogo de cafés y sus reseñas.
pedidos.leer: Leer los pedidos del cliente que autoriza.
pedidos.escribir: Crear y pagar pedidos en nombre del cliente.
resenas.escribir: Publicar reseñas en nombre del cliente.
clientCredentials:
tokenUrl: https://auth.tiendaaroma.example/oauth/token
scopes:
envios.escribir: Actualizar el estado de envío. Reservado a socios logísticos.
resenas.moderar: Aprobar o rechazar reseñas. Reservado a herramientas internas.
# Seguridad por defecto de TODA la API: cualquiera de los dos esquemas sirve.
security:
- bearerJWT: []
- oauth2: []Cómo se leen las dos formas de combinar, que es la parte que más confunde:
| Escrito así | Significa |
|---|---|
security: [{ bearerJWT: [] }, { oauth2: [] }] |
JWT o OAuth: la lista externa es un OR |
security: [{ bearerJWT: [], apiKey: [] }] |
JWT y apiKey a la vez: dentro del mismo objeto es un AND |
security: [] en una operación |
Esa operación es pública: anula la seguridad global |
security: [{ oauth2: [pedidos.escribir] }] |
OAuth con ese ámbito concreto |
Las excepciones a la seguridad global de Tienda Aroma:
/sesiones:
post:
operationId: iniciarSesion
summary: Inicia sesión y obtiene un token
tags: [Sesiones]
security: [] # pública por definición: aquí es donde se consigue el token
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, password]
properties:
email: { type: string, format: email }
password: { type: string, format: password, minLength: 8, writeOnly: true }
responses:
'200':
description: Sesión iniciada.
content:
application/json:
schema:
type: object
required: [token, expiraEn, cliente]
properties:
token: { type: string, description: JWT de acceso. }
expiraEn: { type: integer, description: Segundos de validez., examples: [3600] }
cliente: { $ref: '#/components/schemas/Cliente' }
'401':
description: |
Credenciales incorrectas. El mensaje es **deliberadamente genérico**:
no revela si el email existe (04-02, enumeración de usuarios).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }writeOnly: true en password es el espejo de readOnly: se envía pero nunca se devuelve. Los generadores lo omiten en los tipos de respuesta, y Swagger UI no lo muestra en los ejemplos de salida.
$ref y los límites de la reutilización
$ref y los límites de la reutilización$ref es un puntero JSON. Sus tres formas:
# 1. Interna: al propio documento (la más habitual)
schema: { $ref: '#/components/schemas/Cafe' }
# 2. A otro fichero local: permite trocear una especificación grande
schema: { $ref: './esquemas/cafe.yaml' }
responses:
'404': { $ref: './respuestas/comunes.yaml#/Error404' }
# 3. Remota: a una URL. Evítala.
schema: { $ref: 'https://esquemas.tiendaaroma.example/cafe.yaml' }Cuando openapi.yaml pasa de unas mil líneas, trocearlo en ficheros y unirlos antes de publicar es lo razonable:
# Une un documento troceado en un único fichero autocontenido
npx @redocly/cli bundle openapi.yaml -o dist/openapi.yamlDos avisos por experiencia:
$refremota es una dependencia de red en tu proceso de construcción. Si ese host cae o cambia, tu documentación deja de compilar y no sabrás por qué. Si necesitas esquemas compartidos entre APIs, publícalos como paquete y únelos en la construcción.- En OpenAPI 3.0, un objeto que contiene
$refignora sus hermanos. Escribir{ $ref: '#/...', description: 'otra cosa' }descartaba silenciosamente la descripción. En 3.1 esto se arregló ydescriptionysummarysí se respetan junto a$ref, pero no todas las herramientas se han enterado; si necesitas variar algo,allOfsigue siendo lo seguro.
example frente a examples
example frente a examplesexample |
examples |
|
|---|---|---|
| Dónde vive | Dentro de schema, o junto a content |
Junto a content, y en parámetros |
| Cuántos | Uno | Varios, con nombre |
| Estructura | El valor directo | Mapa de nombre: { summary, description, value } |
| Cuándo usarlo | Un campo suelto | Casos alternativos: éxito, vacío, error de negocio |
# Mal: un único ejemplo pierde el matiz de los distintos 409
'409':
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example: { error: { codigo: stock_insuficiente, mensaje: '...', detalles: [] } }
# Bien: cada caso con su nombre; Swagger UI ofrece un desplegable para elegirlo
'409':
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
examples:
stockInsuficiente:
summary: Alguna línea supera el stock disponible
value: { error: { codigo: stock_insuficiente, mensaje: '...', detalles: [] } }
claveReutilizada:
summary: Misma Idempotency-Key con distinto cuerpo
value: { error: { codigo: clave_idempotencia_reutilizada, mensaje: '...', detalles: [] } }Un detalle propio de 3.1: dentro de un schema la palabra correcta es examples en plural y como array (viene de JSON Schema 2020-12), mientras que junto a content es un mapa con nombres. Son dos campos distintos que se escriben igual; los verás en el apartado 8 como examples: [caf_001].
Y la regla que más valor aporta: usa ejemplos realistas y coherentes entre sí. Si el ejemplo de POST /pedidos menciona caf_001 con cantidad: 2 a 14,50 €, el ejemplo de la respuesta debe decir totalEuros: 29.00 y no 99.99. Los ejemplos incoherentes destruyen la confianza en la documentación entera, y además alimentan los mocks de 05-04.
oneOf, allOf y discriminator
oneOf, allOf y discriminatorLos tres combinadores, con el ejemplo real de las notificaciones a RápidoEnvíos:
| Palabra | Significa | Uso típico |
|---|---|---|
allOf |
Cumple todos los esquemas | Herencia: base + extensión |
oneOf |
Cumple exactamente uno | Variantes excluyentes |
anyOf |
Cumple al menos uno | Poco frecuente; suele indicar un diseño confuso |
EventoBase:
type: object
required: [id, tipo, fechaEmision]
properties:
id: { type: string, examples: [evt_9001] }
tipo: { type: string }
fechaEmision: { type: string, format: date-time }
EventoPedidoPagado:
allOf:
- $ref: '#/components/schemas/EventoBase'
- type: object
required: [datos]
properties:
tipo: { const: pedido.pagado }
datos:
type: object
properties:
pedidoId: { type: string, examples: [ped_5001] }
totalEuros: { type: number, examples: [29.00] }
EventoPedidoEnviado:
allOf:
- $ref: '#/components/schemas/EventoBase'
- type: object
required: [datos]
properties:
tipo: { const: pedido.enviado }
datos:
type: object
properties:
pedidoId: { type: string }
seguimiento: { type: string, examples: [RE-4471-XA] }
Evento:
oneOf:
- $ref: '#/components/schemas/EventoPedidoPagado'
- $ref: '#/components/schemas/EventoPedidoEnviado'
discriminator:
propertyName: tipo
mapping:
pedido.pagado: '#/components/schemas/EventoPedidoPagado'
pedido.enviado: '#/components/schemas/EventoPedidoEnviado'discriminator le dice al validador y al generador qué campo mirar para saber cuál de las variantes es. Sin él, un validador debe probar todas y un generador produce un tipo unión sin forma de estrecharlo. Con él, el cliente TypeScript generado obtiene una unión discriminada y un switch (evento.tipo) con comprobación exhaustiva.
Y, como el documento es 3.1, estos eventos se declaran como webhooks de primer nivel:
webhooks:
pedidoPagado:
post:
operationId: recibirPedidoPagado
summary: Notificación de pedido pagado
description: |
Tienda Aroma envía esta petición **a la URL que hayas registrado** cuando un
pedido se paga. Verifica la firma antes de procesar el cuerpo: la cabecera
`Aroma-Firma` contiene el HMAC-SHA256 del cuerpo crudo con tu secreto
compartido. Responde `2xx` en menos de 5 segundos; reintentamos con retroceso
exponencial durante 24 horas.
parameters:
- name: Aroma-Firma
in: header
required: true
schema: { type: string, examples: ['sha256=9f2a...'] }
- name: Aroma-Evento-Id
in: header
required: true
description: Identificador único del evento. Úsalo para descartar duplicados.
schema: { type: string }
requestBody:
content:
application/json:
schema: { $ref: '#/components/schemas/EventoPedidoPagado' }
responses:
'200': { description: Notificación aceptada. }
- Documentar la deprecación y los límites de peticiones
La deprecación de 02-07 tiene una expresión formal en OpenAPI:
/cafes/{id}/valoraciones:
get:
operationId: obtenerValoracionesCafe
summary: '[Obsoleto] Valoraciones de un café'
deprecated: true
description: |
> **Obsoleto desde la 1.5.0. Se retirará el 30 de junio de 2027.**
>
> Usa `GET /cafes/{id}/resenas`, que devuelve el mismo dato con `puntuacion`
> y `comentario` en un solo recurso. Guía de migración:
> https://developers.tiendaaroma.example/migracion/resenas
Las respuestas incluyen las cabeceras `Deprecation` y `Sunset`.
tags: [Cafés]
parameters:
- $ref: '#/components/parameters/IdCafe'
responses:
'200':
description: Valoraciones del café.
headers:
Deprecation:
description: Fecha en que la operación quedó obsoleta (RFC 9745).
schema: { type: string }
example: '@1767225600'
Sunset:
description: Fecha de retirada definitiva (RFC 8594).
schema: { type: string }
example: 'Tue, 30 Jun 2027 23:59:59 GMT'
Link:
description: Enlace a la alternativa, con rel="successor-version".
schema: { type: string }deprecated: true hace que Swagger UI tache la operación y que los clientes generados marquen el método como obsoleto: en TypeScript, con @deprecated, el editor lo tacha; en Java, con @Deprecated, el compilador avisa. Es la forma más eficaz de avisar: aparece donde el desarrollador está mirando.
El campo también existe en las propiedades de un esquema y en los parámetros:
precioCentimos:
type: integer
deprecated: true
description: 'Obsoleto: usa `precioEuros`. Se eliminará en la v2.'Los límites de peticiones se documentan en tres lugares complementarios, porque ninguno basta por sí solo: la description global de info (la política general), la respuesta reutilizable Error429 con sus cabeceras, y la description de las operaciones que tengan un límite específico, como POST /sesiones con su limiteLogin más estricto de 04-04.
- Dos formas de trabajar: a mano o desde el código
| Especificación primero (a mano) | Código primero (anotaciones) | |
|---|---|---|
| Quién escribe el contrato | El equipo, antes de implementar | Se deduce del código ya escrito |
| Herramienta | Editor de YAML, Swagger Editor, Stoplight | swagger-jsdoc, decoradores de NestJS, springdoc |
| Contrato como acuerdo previo | Sí: se puede revisar y mockear antes | No: existe cuando el código existe |
| Riesgo de deriva | Alto si nadie lo comprueba | Bajo para la forma, alto para el significado |
| Calidad de la documentación | Alta: descripciones y ejemplos pensados | Suele ser pobre: tipos sin explicación |
| Trabajo en paralelo | El front empieza el día 1 con un mock | El front espera a que exista la API |
| Coste inicial | Alto | Bajo |
| Coste de mantenimiento | Medio y constante | Bajo, pero engañoso |
| Encaja con | API pública, varios consumidores, equipos separados | Servicio interno, un equipo, iteración rápida |
Tienda Aroma sigue el enfoque de especificación primero, y esa decisión ya está tomada desde 02-01. El motivo es concreto: tenemos cinco consumidores —SPA, Aroma Móvil, panel, RápidoEnvíos y CataBox— y tres de ellos los desarrollan personas que no son nosotros. El contrato tiene que existir antes que el código porque es lo que permite trabajar en paralelo.
El matiz importante, y donde mucha gente se engaña: generar la especificación desde el código elimina la deriva estructural, no la semántica. El generador sabe que el endpoint devuelve un objeto con un campo estado de tipo string; no sabe que pendiente_pago solo pasa a pagado a través del subrecurso /pago, ni que el precio no debe usarse en aritmética de coma flotante. Toda la información valiosa de nuestro openapi.yaml la ha escrito una persona pensando en quien va a integrarse.
Y el enfoque a mano tiene su propia deriva: nada garantiza que el YAML describa lo que el servidor hace realmente. Contra eso hay dos remedios, y ambos están en el curso: las reglas de Spectral de 04-01 y, sobre todo, las pruebas de contrato de 05-04, que validan las respuestas reales contra el esquema.
- Generar la especificación con
swagger-jsdoc
swagger-jsdocAunque no sea nuestro enfoque, conviene saber cómo es, porque te lo encontrarás. Con swagger-jsdoc la especificación se escribe en comentarios JSDoc junto a las rutas:
// src/rutas/cafes.js — ejemplo del enfoque "código primero" (NO es el de Tienda Aroma)
/**
* @openapi
* /cafes/{id}:
* get:
* operationId: obtenerCafePorId
* summary: Obtiene un café por su identificador
* tags: [Cafés]
* parameters:
* - $ref: '#/components/parameters/IdCafe'
* responses:
* '200':
* description: El café solicitado.
* content:
* application/json:
* schema: { $ref: '#/components/schemas/Cafe' }
* '404':
* $ref: '#/components/responses/Error404'
*/
router.get('/:id', autenticar, asincrono(obtenerCafePorId));Y se ensambla en un módulo de configuración:
// src/config/openapi.js
import swaggerJsdoc from 'swagger-jsdoc';
export const especificacion = swaggerJsdoc({
definition: {
openapi: '3.1.0',
info: { title: 'API de Tienda Aroma', version: '1.7.0' },
servers: [{ url: 'http://localhost:3000/v1' }],
},
// Ficheros donde buscar los comentarios @openapi
apis: ['./src/rutas/*.js', './src/esquemas/*.js'],
});Ventaja real: el comentario está a un centímetro del código, así que quien cambia la ruta ve la documentación. Inconveniente real: es YAML dentro de comentarios, sin autocompletado ni validación mientras escribes, y un error de indentación aparece en tiempo de ejecución. Además, sigue habiendo que escribirlo a mano; lo único que se automatiza es el ensamblado.
Un enfoque intermedio que gana terreno en el ecosistema Node y que merece mención: derivar la especificación de los esquemas de validación que ya tienes. Nuestros esquemas de Zod de src/esquemas/ ya describen la forma exacta de las entradas; con zod-to-json-schema pueden convertirse en los components.schemas del documento, de modo que validación y documentación no puedan divergir:
// Herramienta auxiliar: exporta los esquemas Zod como JSON Schema
import { zodToJsonSchema } from 'zod-to-json-schema';
import { esquemaNuevoCafe } from '../src/esquemas/cafes.js';
const jsonSchema = zodToJsonSchema(esquemaNuevoCafe, { target: 'jsonSchema2020-12' });
console.log(JSON.stringify({ components: { schemas: { NuevoCafe: jsonSchema } } }, null, 2));Es la mejor herramienta contra la deriva de la parte estructural, sin renunciar a escribir a mano las descripciones y los ejemplos. Frameworks como Fastify y NestJS lo hacen de serie, como veremos en 05-03.
- Servir Swagger UI en
/docs
/docsAhora servimos la documentación desde el propio proyecto.
Fichero nuevo src/config/openapi.js:
// src/config/openapi.js
// Carga y expone la especificación OpenAPI del proyecto.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import YAML from 'yaml';
const aqui = dirname(fileURLToPath(import.meta.url));
const rutaEspecificacion = join(aqui, '..', '..', 'openapi.yaml');
// Se lee UNA vez al arrancar: es un fichero inmutable durante la vida del proceso.
// Si falla, que falle aquí y no en la primera petición a /docs.
export const especificacion = YAML.parse(readFileSync(rutaEspecificacion, 'utf8'));
export const versionApi = especificacion.info.version;Fichero nuevo src/rutas/documentacion.js:
// src/rutas/documentacion.js
import { Router } from 'express';
import swaggerUi from 'swagger-ui-express';
import { especificacion } from '../config/openapi.js';
import { entorno } from '../config/entorno.js';
export const rutasDocumentacion = Router();
// El documento crudo: es lo que consumen los generadores de clientes,
// Prism (05-04), el gateway (05-06) y la importación de Postman (05-01).
rutasDocumentacion.get('/openapi.json', (req, res) => {
res.type('application/json').send(especificacion);
});
const opcionesUi = {
customSiteTitle: 'API de Tienda Aroma — documentación',
swaggerOptions: {
// En local apuntamos el "Try it out" al servidor local; en otros entornos,
// al que corresponda. Sin esto, el botón dispara contra producción.
urls: undefined,
persistAuthorization: true, // conserva el token entre recargas: muy cómodo
displayRequestDuration: true,
docExpansion: 'list', // lista las operaciones plegadas, no expandidas
filter: true, // caja de búsqueda por tag
tryItOutEnabled: entorno.nombre !== 'produccion',
},
};
rutasDocumentacion.use('/', swaggerUi.serve, swaggerUi.setup(especificacion, opcionesUi));Y su registro en src/app.js. La posición importa: antes de app.use('/v1', rutasV1) y, sobre todo, fuera de /v1, porque la documentación no es un recurso versionado de la API.
// src/app.js — fragmento, entre las posiciones 13 y 14 de la cadena
import { rutasDocumentacion } from './rutas/documentacion.js';
// ... 13. etagCondicional
// 13-bis. Documentación. Fuera de /v1 y con su propia política de acceso.
if (entorno.docsPublicas || entorno.nombre !== 'produccion') {
app.use('/docs', rutasDocumentacion);
} else {
// En producción exigimos autenticación de empleado para ver el contrato interno.
app.use('/docs', autenticar, exigirRol('empleado', 'administrador'), rutasDocumentacion);
}
// 14. app.use('/v1', rutasV1)Cuatro consideraciones sobre exponer la documentación:
helmety Swagger UI chocan. LaContent-Security-Policypor defecto de helmet (04-02) bloquea los estilos en línea que usa Swagger UI, y la página aparece en blanco y sin CSS. La solución correcta no es desactivar helmet, sino relajar la política solo en esa ruta:
// Excepción de CSP acotada a /docs; el resto de la API conserva la política estricta
app.use('/docs', helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
scriptSrc: ["'self'", "'unsafe-inline'"],
},
}), rutasDocumentacion);/docsno debe contar contra el límite de peticiones de la API ni ensuciar las métricas de 04-07. SimetricasMiddlewarela etiqueta como ruta, verás una latencia p99 anómala causada por gente leyendo documentación.- ¿Pública o protegida? Si la API es pública, la documentación también: es tu escaparate. Si es interna, el contrato es un mapa detallado de tu superficie de ataque —rutas, parámetros, esquemas— y es información valiosa para quien te ataque. Protegerla no es seguridad de verdad (la seguridad está en la autenticación de los endpoints), pero sí reduce ruido y exposición innecesaria.
- El "Try it out" de Swagger UI ejecuta peticiones reales desde el navegador. En producción conviene desactivarlo, y en cualquier caso recuerda que necesita que el origen de la documentación esté en la lista blanca de CORS de 04-05 si la sirves desde otro dominio.
- Alternativas de renderizado: Redoc, Scalar, Stoplight Elements
Swagger UI no es la única forma de pintar el mismo openapi.yaml.
| Renderizador | Aspecto | Probar en vivo | Fuerte en | Cuándo elegirlo |
|---|---|---|---|---|
| Swagger UI | Clásico, denso | Sí | Ubicuidad; todo el mundo lo reconoce | Documentación interna, desarrollo |
| Redoc | Tres columnas, tipográfico | Solo en la versión de pago | Especificaciones grandes, lectura larga, x-tagGroups |
Documentación pública de referencia |
| Scalar | Moderno, oscuro por defecto | Sí, con cliente integrado | Rapidez, buena experiencia, ejemplos multilenguaje | Portales nuevos |
| Stoplight Elements | Componente web | Sí | Integrarlo en un portal propio | Portal de desarrollador a medida (05-06) |
Cambiar de renderizador es cuestión de minutos porque todos consumen el mismo fichero:
// Alternativa con Redoc servido de forma estática, sin dependencias externas de CDN
rutasDocumentacion.get('/referencia', (req, res) => {
res.type('html').send(`<!doctype html>
<html>
<head><title>API de Tienda Aroma</title><meta charset="utf-8"></head>
<body>
<redoc spec-url="/docs/openapi.json"></redoc>
<script src="/estaticos/redoc.standalone.js"></script>
</body>
</html>`);
});Fíjate en que el script se sirve desde /estaticos y no desde una CDN externa: una CDN en la documentación es una dependencia de terceros que la CSP de 04-02 debería bloquear, y con razón.
- Validar la especificación:
swagger-cli y Spectral
swagger-cli y SpectralHay dos niveles de validación que resuelven problemas distintos y hacen falta los dos.
Nivel 1: ¿es un documento OpenAPI válido? Estructura, referencias resueltas, tipos correctos.
# swagger-cli (paquete @apidevtools/swagger-cli)
npx swagger-cli validate openapi.yaml
# → openapi.yaml is valid
# Alternativa más moderna, con mejor soporte de 3.1
npx @redocly/cli lint openapi.yamlEsto detecta un $ref roto, una indentación equivocada o un type: strng. Sin esta comprobación, un error de una letra rompe la documentación y no te enteras hasta que alguien abre /docs.
Nivel 2: ¿cumple la guía de estilo de Tienda Aroma? Aquí entra Spectral con .spectral.yaml, que ya escribimos en 04-01.
Ahora que el documento está completo, añadimos tres reglas nuevas que solo tienen sentido con components poblado:
# .spectral.yaml — reglas añadidas en 05-02
rules:
aroma-operacion-con-operationid:
description: Toda operación declara operationId; es el nombre del método generado.
severity: error
given: $.paths[*][get,post,put,patch,delete]
then:
field: operationId
function: truthy
aroma-operationid-en-camelcase:
description: Los operationId van en camelCase y en español (obtenerCafes, crearPedido).
severity: error
given: $.paths[*][*].operationId
then:
function: casing
functionOptions: { type: camel }
aroma-errores-usan-el-esquema-comun:
description: Toda respuesta 4xx/5xx referencia el esquema Error del catálogo.
severity: error
given: $.paths[*][*].responses[?(@property.match(/^[45]/))].content['application/json'].schema
then:
function: schema
functionOptions:
schema:
type: object
properties:
$ref: { const: '#/components/schemas/Error' }
aroma-esquemas-con-descripcion:
description: Todo esquema de components tiene descripción; es lo que lee el consumidor.
severity: warn
given: $.components.schemas[*]
then:
field: description
function: truthy
aroma-ejemplos-en-respuestas-200:
description: Las respuestas 200 y 201 incluyen al menos un ejemplo.
severity: warn
given: $.paths[*][*].responses[200,201].content['application/json']
then:
function: schema
functionOptions:
schema:
type: object
anyOf:
- required: [example]
- required: [examples]Ambos niveles se convierten en pasos de la tubería de CI de 05-05:
{
"scripts": {
"contrato:validar": "swagger-cli validate openapi.yaml",
"contrato:lint": "spectral lint openapi.yaml --fail-severity=error",
"contrato": "npm run contrato:validar && npm run contrato:lint"
}
}
- Generar clientes con OpenAPI Generator
Con el contrato completo, el paso siguiente es dejar de escribir a mano el código que llama a la API.
# Cliente TypeScript con fetch para la SPA
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-fetch \
-o ../aroma-spa/src/api-generada \
--additional-properties=supportsES6=true,withInterfaces=true,typescriptThreePlus=true
# Cliente TypeScript con axios para Aroma Móvil (React Native)
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-axios \
-o ../aroma-movil/src/api-generadaEl resultado en la SPA, con tipos deducidos del contrato:
// Código de la SPA que consume el cliente generado
import { Configuration, CafesApi, type Cafe, TuesteEnum } from './api-generada';
const configuracion = new Configuration({
basePath: import.meta.env.VITE_URL_API, // https://api.tiendaaroma.example/v1
accessToken: () => sesion.obtenerToken(),
});
const cafesApi = new CafesApi(configuracion);
// El método se llama como el operationId; los parámetros están tipados
const coleccion = await cafesApi.obtenerCafes({
tueste: TuesteEnum.Claro, // enum generado desde el esquema: no cabe "morado"
precioMax: 15,
limite: 20,
ordenar: '-precioEuros',
});
// coleccion.datos es Cafe[]; coleccion.total es number
coleccion.datos.forEach((cafe: Cafe) => {
// cafe.precioEuros es number, cafe.tueste es TuesteEnum
console.log(`${cafe.nombre}: ${cafe.precioEuros.toFixed(2)} €`);
});Generadores disponibles para nuestros consumidores:
| Consumidor | Generador | Salida |
|---|---|---|
| SPA (React) | typescript-fetch |
Clases y tipos con fetch nativo |
| Aroma Móvil | typescript-axios o kotlin / swift5 |
Cliente por plataforma |
| CataBox (tercero) | El que ellos elijan | Solo consumen el openapi.yaml publicado |
| Herramientas internas | python, go |
Scripts de operación |
| Pruebas de contrato | — | Los esquemas se usan directamente (05-04) |
Qué se gana: tipos siempre alineados con el contrato, cero código repetitivo de fetch, operationId como nombre de método, enums que impiden valores inválidos en tiempo de compilación, y —el efecto más valioso— un cambio rompedor en el contrato se convierte en un error de compilación en el consumidor, no en un fallo en producción.
Qué exige cuidado, y esto no lo cuentan los tutoriales:
- El código generado no se edita nunca. Se regenera. Añade la carpeta a
.gitignoreo, si la versionas para tener trazabilidad, marca los ficheros como generados y prohíbe tocarlos en la revisión. Una corrección manual desaparece en la siguiente generación. - Genera mucho código. Un generador puede producir cientos de ficheros para una API mediana. Revisa lo que produce antes de adoptarlo; algunos generadores arrastran dependencias pesadas.
- La calidad depende del generador.
typescript-fetchygoson sólidos; otros tienen rarezas. Pruébalo antes de comprometerte. - Cambiar un
operationIdrompe a los consumidores aunque la API sea idéntica. Trátalo como parte del contrato. - Envuélvelo. No expongas el cliente generado a toda tu aplicación: pon una capa fina encima que traduzca sus errores a los de tu dominio y centralice la autenticación y los reintentos con jitter de 04-04. Así, cambiar de generador afecta a un fichero.
Para casos más ligeros existen alternativas que generan solo tipos: openapi-typescript produce un fichero de tipos sin cliente, y openapi-fetch los consume con un envoltorio mínimo. Para una SPA moderna suele ser mejor opción que las clases del generador oficial.
- Mantener el contrato sincronizado
Todo lo anterior se derrumba si openapi.yaml describe una API que ya no existe. El ciclo de vida completo del contrato:
graph LR
A[Propuesta de cambio<br/>en openapi.yaml] --> B[Pull request:<br/>revisión de diseño 04-01]
B --> C[swagger-cli validate<br/>+ spectral lint]
C --> D[oasdiff:<br/>¿es rompedor? 05-04]
D --> E[Implementación<br/>módulo 3]
E --> F[Pruebas de contrato:<br/>respuestas reales vs esquema]
F --> G[Publicación en CI:<br/>/docs y portal 05-06]
G --> H[Clientes regenerados<br/>SPA y Aroma Móvil]
Las reglas de equipo que sostienen ese ciclo:
- El cambio del contrato va en el mismo pull request que la implementación. Si van separados, uno de los dos se olvida.
openapi.yamles el primer fichero que se revisa, antes que el código. El diff del contrato es donde se ve si el cambio es una buena idea; el código solo dice si está bien hecha.- La tubería falla si el contrato no valida o no pasa el linting. Sin excepciones (04-01).
- La tubería avisa si el cambio es rompedor, con
oasdiff. Es la puerta que veremos en 05-04. - Las pruebas de integración validan las respuestas reales contra los esquemas. Es lo único que detecta la deriva de verdad, y es el tema central de 05-04.
- La publicación es automática, no un paso manual que alguien recuerda hacer los viernes (05-05).
info.versionsube en cada cambio del contrato, siguiendo SemVer.
Errores Comunes y Consejos
- Confundir
openapi: 3.1.0coninfo.version. El primero es la versión de la especificación; el segundo, la de tu API. Cambiar el primero por error rompe herramientas; olvidar subir el segundo hace inútil el historial. - Repetir
/v1enserversy enpaths. Los clientes generados llaman a/v1/v1/cafes. Con versionado en la ruta, el prefijo va enserversy las claves depathsempiezan por/cafes. additionalProperties: falseen los esquemas de salida. Convierte cualquier campo nuevo en un cambio rompedor para los consumidores estrictos, justo lo contrario de la regla de compatibilidad de 02-07. Ciérralos solo en las entradas.- Documentar solo el camino feliz. Una especificación sin
4xxobliga a cada consumidor a descubrir los errores a base de provocarlos. Las respuestas reutilizables cuestan una línea por operación. - Ejemplos incoherentes.
caf_001a 14,50 €, dos unidades y un total de 99,99 € en el ejemplo del pedido. Además de dar mala impresión, alimenta los mocks de 05-04 con datos falsos y confunde a quien integra. - Olvidar
operationId, o cambiarlo a la ligera. Sin él, los generadores inventan nombres comogetCafesById_1. Cambiarlo rompe a los consumidores sin tocar la API. - Editar el código generado. Desaparece en la regeneración siguiente. Si necesitas cambiarlo, envuélvelo.
- Servir Swagger UI en producción sin pensarlo. Revisa si tu contrato debe ser público, ten en cuenta la CSP de helmet y decide si el "Try it out" debe estar activo.
- Especificación completa pero sin validar nada. El documento más bonito del mundo miente si nadie comprueba que las respuestas reales lo cumplen. Ese es el problema de 05-04.
- Consejo: escribe primero las descripciones y los ejemplos, no los tipos. Los tipos se deducen; el conocimiento —que el precio no se opera en coma flotante, que el estado se cambia con subrecursos— solo está en tu cabeza.
- Consejo: usa
summarycorto ydescriptionlargo. Swagger UI muestra elsummaryen la lista plegada, y es lo único que la mayoría lee. - Consejo: si tu equipo mantiene esquemas Zod, genera desde ellos los
components.schemasen lugar de escribirlos dos veces. La duplicación es la madre de la deriva.
Ejercicios
Ejercicio 1: documentar GET /pedidos/{id} y POST /pedidos/{id}/pago
Escribe el fragmento de paths para estas dos operaciones, reutilizando todo lo que ya existe en components. Requisitos:
GET /pedidos/{id}: parámetro de ruta,expandir=lineas.cafecomo parámetro de consulta opcional, respuestas200,304,401,403(el pedido de otro cliente) y404, conETagen la respuesta.POST /pedidos/{id}/pago: exigeIdempotency-Key, ámbito OAuthpedidos.escribir, cuerpo con el método de pago, y respuestas200,402(pago rechazado),409(pedido_ya_pagado) y428.
Ejercicio 2: la regla de Spectral que faltaba
Escribe una regla de Spectral que obligue a que toda operación que modifica un recurso existente (PUT, PATCH, DELETE) declare el parámetro de cabecera If-Match y documente la respuesta 412. Explica el given, el then y por qué la introducirías como warn antes que como error.
Ejercicio 3: decidir el enfoque para un servicio nuevo
Tienda Aroma va a lanzar un servicio interno de recomendaciones (recomendaciones-api) que solo consumirá la propia API de Tienda Aroma mediante gRPC y, además, expondrá dos endpoints REST para el panel interno. Lo desarrollará un equipo de dos personas en tres semanas.
Decide si aplicar "especificación primero" o "código primero", justifícalo con al menos cuatro criterios de la tabla del apartado 15, y describe qué harías para evitar la deriva en el enfoque que elijas.
Soluciones
Solución 1
/pedidos/{id}:
get:
operationId: obtenerPedido
summary: Obtiene un pedido por su identificador
description: |
Un cliente solo puede consultar sus propios pedidos; los roles `empleado` y
`administrador` pueden consultar cualquiera. Intentar leer el pedido de otro
cliente devuelve `403`, no `404`: la existencia del pedido no es secreta para
quien está autenticado, y devolver `404` complicaría la depuración.
tags: [Pedidos]
security:
- bearerJWT: []
- oauth2: [pedidos.leer]
parameters:
- name: id
in: path
required: true
schema: { type: string, pattern: '^ped_[A-Za-z0-9]+$' }
example: ped_5001
- name: expandir
in: query
description: |
Incrusta recursos relacionados en lugar de devolver solo sus enlaces.
Único valor admitido: `lineas.cafe`.
schema: { type: string, enum: [lineas.cafe] }
- name: If-None-Match
in: header
description: ETag conocido por el cliente; si coincide se responde `304`.
schema: { type: string }
responses:
'200':
description: El pedido solicitado.
headers:
ETag: { $ref: '#/components/headers/ETag' }
Cache-Control:
description: Privado y de vida corta; un pedido cambia de estado.
schema: { type: string }
example: 'private, max-age=0, must-revalidate'
content:
application/json:
schema: { $ref: '#/components/schemas/Pedido' }
examples:
pagado:
summary: Pedido ya pagado, con enlaces a las acciones disponibles
value:
id: ped_5001
clienteId: cli_842
lineas:
- { cafeId: caf_001, cantidad: 2, precioUnitarioEuros: 14.50, subtotalEuros: 29.00 }
totalEuros: 29.00
estado: pagado
fechaCreacion: '2026-03-02T10:15:00Z'
_links:
self: { href: /v1/pedidos/ped_5001 }
factura: { href: /v1/pedidos/ped_5001/factura }
devolucion: { href: /v1/pedidos/ped_5001/devolucion, method: POST }
'304':
description: No modificado.
'401': { $ref: '#/components/responses/Error401' }
'403':
description: El pedido pertenece a otro cliente.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example:
error:
codigo: permisos_insuficientes
mensaje: 'No tienes permiso para consultar este pedido.'
detalles: []
'404': { $ref: '#/components/responses/Error404' }
/pedidos/{id}/pago:
post:
operationId: pagarPedido
summary: Paga un pedido pendiente
description: |
Cobra el pedido y lo pasa al estado `pagado`. Es una **transición de estado
expresada como subrecurso**, no un `PATCH` sobre `estado`.
Exige `Idempotency-Key`: un reintento con la misma clave devuelve la respuesta
original sin cobrar dos veces. Es la garantía más importante de esta operación.
Al completarse, se emite el webhook `pedido.pagado` hacia RápidoEnvíos.
tags: [Pedidos]
security:
- bearerJWT: []
- oauth2: [pedidos.escribir]
parameters:
- name: id
in: path
required: true
schema: { type: string, pattern: '^ped_[A-Za-z0-9]+$' }
- name: Idempotency-Key
in: header
required: true
schema: { type: string, format: uuid }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [metodo]
additionalProperties: false
properties:
metodo: { type: string, enum: [tarjeta, transferencia, monedero] }
tokenTarjeta:
type: string
writeOnly: true
description: |
Token de la pasarela. **Nunca envíes el PAN de la tarjeta a esta
API**: tokenízalo en el cliente con el SDK de la pasarela.
responses:
'200':
description: Pago aceptado; el pedido pasa a `pagado`.
content:
application/json:
schema: { $ref: '#/components/schemas/Pedido' }
'401': { $ref: '#/components/responses/Error401' }
'402':
description: La pasarela ha rechazado el pago. El pedido sigue `pendiente_pago`.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example:
error:
codigo: pago_rechazado
mensaje: 'La entidad emisora ha rechazado el pago.'
detalles: [{ campo: metodo, problema: 'fondos insuficientes' }]
'409':
description: El pedido ya estaba pagado.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
example:
error: { codigo: pedido_ya_pagado, mensaje: 'El pedido ya está pagado.', detalles: [] }
'428':
description: Falta `Idempotency-Key`.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429': { $ref: '#/components/responses/Error429' }Nota: pago_rechazado no estaba en el catálogo de 02-04. Añadir un código exige actualizar también el enum del esquema Error y el fichero src/errores/error-api.js. Es un buen ejemplo de por qué el catálogo cerrado obliga a un cambio consciente en lugar de a inventar un código sobre la marcha.
Solución 2
aroma-modificaciones-exigen-if-match:
description: |
Toda operación que modifica un recurso existente debe declarar el parámetro
If-Match y documentar la respuesta 412, para que la concurrencia optimista de
04-06 sea parte del contrato y no un detalle de implementación.
message: '{{path}} modifica un recurso pero no declara If-Match o no documenta el 412.'
severity: warn
given: $.paths[*][put,patch,delete]
then:
- field: parameters
function: schema
functionOptions:
schema:
type: array
contains:
type: object
properties:
name: { const: If-Match }
required: [name]
- field: responses.412
function: truthyExplicación del given: $.paths[*][put,patch,delete] selecciona el objeto de operación de esos tres métodos en todas las rutas. No usa el sufijo ~ porque aquí nos interesa el valor (el objeto de la operación con sus parameters y responses), no la clave.
Explicación del then: es una lista de dos comprobaciones que se aplican al mismo nodo. La primera usa la función schema con contains de JSON Schema para exigir que el array parameters incluya al menos un elemento con name: If-Match. La segunda usa truthy sobre responses.412, que exige que ese campo exista y no esté vacío.
Por qué warn primero: el contrato actual tiene operaciones DELETE que no exigen If-Match —el borrado de un carrito, por ejemplo—. Si la regla entra directamente como error, la tubería se pone en rojo y nadie puede integrar nada hasta arreglarlo todo, con la consecuencia previsible de que alguien desactive la regla. El procedimiento correcto, el mismo de 04-01: entra como warn, se abre una tarea para limpiar las infracciones, y cuando el linting sale limpio se sube a error en un pull request de una línea. Además, hay excepciones legítimas —DELETE idempotentes sobre recursos sin concurrencia— que conviene documentar antes de endurecer, con x-spectral-ignore o replanteando el given.
Solución 3
Decisión: código primero para recomendaciones-api, con dos salvedades.
Justificación con los criterios de la tabla:
| Criterio | Análisis del caso |
|---|---|
| Consumidores | Uno solo y interno: el panel. No hay equipos externos esperando. El valor principal de "especificación primero" —permitir trabajo en paralelo— no aplica. |
| Trabajo en paralelo | El panel puede esperar; son dos endpoints. No compensa montar un mock ni negociar un contrato previo. |
| Coste inicial frente al plazo | Tres semanas y dos personas. El coste inicial de escribir a mano un contrato completo se come una parte apreciable del presupuesto. |
| Riesgo de deriva semántica | Bajo: el equipo que escribe el servicio es el mismo que consume el endpoint desde el panel. La deriva duele cuando el consumidor es otro. |
| Estabilidad esperada | Un servicio de recomendaciones es experimental por naturaleza: los endpoints cambiarán varias veces en los primeros meses. Un contrato acordado por adelantado se rehará constantemente. |
| Superficie | Dos endpoints REST. El grueso del servicio es gRPC, cuyo contrato son los ficheros .proto, que ya son especificación primero por construcción. |
Salvedad 1: gRPC no es negociable. Los .proto son el contrato y se escriben antes que el código, con revisión. Que el REST sea código primero no cambia eso.
Salvedad 2: el contrato debe existir aunque se genere. "Código primero" no significa "sin contrato". El servicio debe publicar su openapi.json generado en /docs/openapi.json, y ese fichero debe pasar por el mismo spectral lint que Tienda Aroma. Si el equipo no acepta esto, la decisión correcta pasa a ser especificación primero.
Medidas contra la deriva en el enfoque elegido:
- Generar desde los esquemas de validación, no desde anotaciones sueltas. Si el servicio valida con Zod,
zod-to-json-schemaproduce loscomponents.schemas, de modo que la validación real y la documentación son literalmente el mismo objeto y no pueden divergir. - Volcar el
openapi.jsongenerado a un fichero versionado en cada construcción de CI. Así el diff del contrato aparece en el pull request y es revisable, aunque nadie lo haya escrito a mano. Es el truco que da a "código primero" la revisabilidad de "especificación primero". - Pasar
spectral lintsobre el documento generado, con las mismas reglas de la organización. Obliga a poneroperationId, descripciones y respuestas de error, que es justo lo que el enfoque de código primero suele olvidar. - Escribir a mano las descripciones y los ejemplos. Los tipos los deduce el generador; el conocimiento del dominio, no. Una anotación sin descripción produce documentación inútil.
- Revisar la decisión cuando cambie el contexto. El día que un segundo consumidor —Aroma Móvil, o un tercero— dependa de este servicio, el análisis cambia y toca migrar a especificación primero. Conviene dejarlo escrito en un ADR (04-01) para que la decisión y su fecha de caducidad estén documentadas.
Conclusión
openapi.yaml ha dejado de ser un fragmento para convertirse en el contrato completo de Tienda Aroma. Sabes distinguir OpenAPI, la especificación, de Swagger, la familia de herramientas, y por qué la 3.1 —alineada con JSON Schema 2020-12 y con webhooks de primer nivel— es la elección correcta para una API que valida con AJV y notifica a RápidoEnvíos. Has recorrido el documento entero: info con la portada donde viven los convenios de dinero, fechas e identificadores opacos; servers con el /v1 en el sitio correcto; tags por recurso; paths con GET /cafes y POST /pedidos completos, incluidos el 428 por falta de Idempotency-Key y los dos significados distintos del mismo 409; components con esquemas que distinguen Cafe de NuevoCafe mediante readOnly, parámetros y respuestas de error reutilizables, y securitySchemes con el JWT de 03-06 y los flujos y ámbitos OAuth de 04-03. Y sabes documentar lo que casi nadie documenta: la deprecación con deprecated: true junto a Deprecation y Sunset de 02-07, los límites de peticiones de 04-04 y los eventos firmados hacia RápidoEnvíos.
Sobre ese contrato has montado la maquinaria que lo hace útil. La API sirve su propia documentación en /docs con Swagger UI, fuera de /v1, con la excepción de CSP que helmet exige, protegida en producción y con el "Try it out" desactivado allí; los ficheros nuevos son src/config/openapi.js y src/rutas/documentacion.js, con swagger-ui-express y yaml como dependencias, y el registro correspondiente en src/app.js. Conoces las alternativas de renderizado y la diferencia entre las dos validaciones que hacen falta —swagger-cli validate para la estructura y Spectral para la guía de estilo, ahora con cinco reglas más y los scripts contrato:validar y contrato:lint—. Y has generado desde el mismo fichero los clientes TypeScript de la SPA y de Aroma Móvil con OpenAPI Generator, sabiendo que el código generado no se edita, que se envuelve, y que un operationId es parte del contrato. También has visto por qué la discusión entre escribir la especificación a mano o generarla desde el código no tiene un ganador universal: elimina la deriva estructural, nunca la semántica.
Y ahí queda el hueco que esta lección no puede tapar. Tenemos un contrato precioso y validado como documento, pero nada garantiza todavía que el servidor lo cumpla: que GET /cafes devuelva exactamente el esquema ColeccionCafes, que ningún error se salga del catálogo, que un cambio en el YAML no rompa a la SPA sin avisar. En 05-04, Contratos, mocks y pruebas automatizadas, cerramos ese círculo: levantaremos un mock con Prism directamente desde openapi.yaml para que la SPA avance sin esperar al backend, usaremos msw y nock como dobles de prueba, validaremos con AJV las respuestas reales dentro de las pruebas Supertest de 03-08, detectaremos cambios rompedores entre dos versiones del contrato con oasdiff aplicando las reglas de 02-07, veremos cuándo el contract testing dirigido por el consumidor con Pact compensa y cuándo es sobreingeniería, y organizaremos el recorrido de compra completo como prueba de extremo a extremo. Antes, sin embargo, conviene levantar la vista del proyecto: en 05-03, Frameworks populares para APIs RESTful, veremos qué habría cambiado —y qué no— si en 03-01 hubiéramos elegido Fastify, NestJS, FastAPI, Spring Boot o ASP.NET Core en lugar de Express.
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
