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

  1. OpenAPI y Swagger: dos cosas que la gente confunde
  2. Versiones: 2.0, 3.0 y 3.1
  3. La anatomía del documento
  4. openapi, info y servers
  5. tags: la organización que ve el lector
  6. paths: GET /cafes completo
  7. paths: POST /pedidos completo
  8. components.schemas: los tipos de Tienda Aroma
  9. components.parameters y components.responses reutilizables
  10. securitySchemes y security: JWT y OAuth 2.0
  11. $ref y los límites de la reutilización
  12. example frente a examples
  13. oneOf, allOf y discriminator
  14. Documentar la deprecación y los límites de peticiones
  15. Dos formas de trabajar: a mano o desde el código
  16. Generar la especificación con swagger-jsdoc
  17. Servir Swagger UI en /docs
  18. Alternativas de renderizado: Redoc, Scalar, Stoplight Elements
  19. Validar la especificación: swagger-cli y Spectral
  20. Generar clientes con OpenAPI Generator
  21. Mantener el contrato sincronizado

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

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

  1. 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.
  2. webhooks de primer nivel. Nuestra arquitectura envía pedido.pagado y pedido.enviado a 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.

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

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

  1. openapi, info y servers

openapi: 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.version es la versión de tu API, no de OpenAPI. Son campos distintos que la gente confunde constantemente. Usamos SemVer: 1.7.0 significa que ha habido siete tandas de adiciones compatibles desde la 1.0.0. Un 2.0.0 implicaría un cambio rompedor y, por tanto, un /v2 en la ruta, según 02-07.
  • La description de info es 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 url de los servidores incluye /v1. Consecuencia directa de nuestra decisión de versionar en la ruta: las claves de paths quedan 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.

  1. tags: la organización que ve el lector

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

  1. paths: GET /cafes completo

Retomamos 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:

  • operationId es obligatorio en la práctica. Es el nombre del método en los clientes generados: obtenerCafes produce api.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 sinResultados documenta una decisión de diseño de 02-04 —un filtro sin coincidencias es 200 con lista vacía, no 404— 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, 404 se interpreta como número.
  • security a nivel de operación sobrescribe la global. Aquí POST /cafes exige explícitamente bearerJWT porque no admite el flujo de OAuth de terceros.

  1. paths: POST /pedidos completo

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

  1. components.schemas: los tipos de Tienda Aroma

Los 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: true marca los campos que el servidor calcula. Los generadores lo aprovechan: el tipo Cafe generado los incluye, pero el tipo del cuerpo de creación los omite. Es la razón por la que NuevoCafe existe como esquema aparte en lugar de reutilizar Cafe.
  • additionalProperties: false solo en las entradas. En los cuerpos que recibimos, un campo desconocido es un error del cliente y devolvemos 400 (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 enum completo 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 escribe cafe_no_encontrada.
  • multipleOf: 0.01 documenta formalmente la regla de los dos decimales que venimos arrastrando desde 02-05.

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

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

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

Dos avisos por experiencia:

  • $ref remota 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 $ref ignora sus hermanos. Escribir { $ref: '#/...', description: 'otra cosa' } descartaba silenciosamente la descripción. En 3.1 esto se arregló y description y summary sí se respetan junto a $ref, pero no todas las herramientas se han enterado; si necesitas variar algo, allOf sigue siendo lo seguro.

  1. example frente a examples

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

  1. oneOf, allOf y discriminator

Los 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. }

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

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

  1. Generar la especificación con swagger-jsdoc

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

  1. Servir Swagger UI en /docs

Ahora servimos la documentación desde el propio proyecto.

npm install swagger-ui-express yaml

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:

  • helmet y Swagger UI chocan. La Content-Security-Policy por 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);
  • /docs no debe contar contra el límite de peticiones de la API ni ensuciar las métricas de 04-07. Si metricasMiddleware la 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.

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

  1. Validar la especificación: swagger-cli y Spectral

Hay 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.yaml

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

npx spectral lint openapi.yaml --fail-severity=error

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"
  }
}

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

El 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 .gitignore o, 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-fetch y go son sólidos; otros tienen rarezas. Pruébalo antes de comprometerte.
  • Cambiar un operationId rompe 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.

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

  1. El cambio del contrato va en el mismo pull request que la implementación. Si van separados, uno de los dos se olvida.
  2. openapi.yaml es 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.
  3. La tubería falla si el contrato no valida o no pasa el linting. Sin excepciones (04-01).
  4. La tubería avisa si el cambio es rompedor, con oasdiff. Es la puerta que veremos en 05-04.
  5. 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.
  6. La publicación es automática, no un paso manual que alguien recuerda hacer los viernes (05-05).
  7. info.version sube en cada cambio del contrato, siguiendo SemVer.

Errores Comunes y Consejos

  • Confundir openapi: 3.1.0 con info.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 /v1 en servers y en paths. Los clientes generados llaman a /v1/v1/cafes. Con versionado en la ruta, el prefijo va en servers y las claves de paths empiezan por /cafes.
  • additionalProperties: false en 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 4xx obliga a cada consumidor a descubrir los errores a base de provocarlos. Las respuestas reutilizables cuestan una línea por operación.
  • Ejemplos incoherentes. caf_001 a 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 como getCafesById_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 summary corto y description largo. Swagger UI muestra el summary en la lista plegada, y es lo único que la mayoría lee.
  • Consejo: si tu equipo mantiene esquemas Zod, genera desde ellos los components.schemas en 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.cafe como parámetro de consulta opcional, respuestas 200, 304, 401, 403 (el pedido de otro cliente) y 404, con ETag en la respuesta.
  • POST /pedidos/{id}/pago: exige Idempotency-Key, ámbito OAuth pedidos.escribir, cuerpo con el método de pago, y respuestas 200, 402 (pago rechazado), 409 (pedido_ya_pagado) y 428.

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: truthy

Explicació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:

  1. Generar desde los esquemas de validación, no desde anotaciones sueltas. Si el servicio valida con Zod, zod-to-json-schema produce los components.schemas, de modo que la validación real y la documentación son literalmente el mismo objeto y no pueden divergir.
  2. Volcar el openapi.json generado 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".
  3. Pasar spectral lint sobre el documento generado, con las mismas reglas de la organización. Obliga a poner operationId, descripciones y respuestas de error, que es justo lo que el enfoque de código primero suele olvidar.
  4. 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.
  5. 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

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