A 02-08 vam escriure un fragment d'openapi.yaml: un sol endpoint, GET /cafes, amb els seus paràmetres i dues respostes. Des d'aleshores aquell fitxer ens ha acompanyat durant tot el curs —el vam passar per Spectral a 04-01, el vam esmentar en importar la col·lecció a 05-01— però continua descrivint una fracció mínima d'una API que avui té sis recursos, una dotzena de subrecursos, autenticació JWT, OAuth amb sis àmbits, vint-i-cinc codis d'error i capçaleres pròpies.

Un contracte incomplet és pitjor que no tenir contracte, perquè genera confiança injustificada. Qui llegeix openapi.yaml i no hi troba POST /comandes conclou que no existeix o —pitjor— que existeix però funciona com ell s'imagina.

Aquesta lliçó acaba la feina. Construirem l'especificació completa de la Botiga Aroma secció a secció, entendrem la diferència entre escriure-la a mà i generar-la des del codi, servirem Swagger UI en el mateix projecte, la validarem en dos nivells i generarem amb ella els clients TypeScript de la SPA i de l'Aroma Mòbil. En acabar, openapi.yaml deixarà de ser documentació per convertir-se en la font des de la qual es produeixen altres coses.

Contingut

  1. OpenAPI i Swagger: dues coses que la gent confon
  2. Versions: 2.0, 3.0 i 3.1
  3. L'anatomia del document
  4. openapi, info i servers
  5. tags: l'organització que veu el lector
  6. paths: GET /cafes complet
  7. paths: POST /comandes complet
  8. components.schemas: els tipus de la Botiga Aroma
  9. components.parameters i components.responses reutilitzables
  10. securitySchemes i security: JWT i OAuth 2.0
  11. $ref i els límits de la reutilització
  12. example davant d'examples
  13. oneOf, allOf i discriminator
  14. Documentar la deprecació i els límits de peticions
  15. Dues maneres de treballar: a mà o des del codi
  16. Generar l'especificació amb swagger-jsdoc
  17. Servir Swagger UI a /docs
  18. Alternatives de renderització: Redoc, Scalar, Stoplight Elements
  19. Validar l'especificació: swagger-cli i Spectral
  20. Generar clients amb OpenAPI Generator
  21. Mantenir el contracte sincronitzat

  1. OpenAPI i Swagger: dues coses que la gent confon

La confusió és històrica i mereix dos minuts, perquè afecta com es busquen les eines.

  • Swagger va néixer el 2011 com un format d'especificació i un conjunt d'eines, obra de Tony Tam. El 2015 SmartBear va comprar el projecte i va donar l'especificació a la Linux Foundation.
  • L'especificació donada va passar a dir-se OpenAPI Specification (OAS) i la governa la OpenAPI Initiative. Swagger 2.0 va ser reanomenada OpenAPI 2.0; a partir d'aquí, les versions són 3.0 i 3.1.
  • Swagger avui és la marca de la família d'eines de SmartBear.
Nom Què és Exemple d'ús
OpenAPI L'especificació: com s'escriu el YAML/JSON L'openapi: 3.1.0 del nostre fitxer
Swagger UI Renderitzador HTML interactiu d'una especificació El servirem a /docs
Swagger Editor Editor web amb validació en viu Escriure el YAML amb autocompletat
Swagger Codegen Generador de clients i servidors Substituït a la pràctica per OpenAPI Generator
swagger-jsdoc Genera OpenAPI des de comentaris al codi L'enfocament code-first de l'apartat 16
swagger-ui-express Middleware d'Express que serveix Swagger UI La ruta /docs

Regla mnemotècnica: el fitxer és OpenAPI; el que el pinta i el processa se sol dir Swagger. Dir «el meu Swagger» referint-se al fitxer és habitual i tothom t'entén, però saber la diferència evita perdre temps buscant a la documentació equivocada.

  1. Versions: 2.0, 3.0 i 3.1

2.0 (Swagger) 3.0 3.1
Any 2014 2017 2021
Servidors host + basePath + schemes servers (llista, amb variables) Igual que 3.0
Cos de petició Un paràmetre in: body requestBody amb content per tipus Igual que 3.0
Reutilitzables definitions, parameters, responses Tot sota components Igual que 3.0
JSON Schema Subconjunt propi incompatible Subconjunt ampliat, gairebé compatible JSON Schema 2020-12 complet
nullable No existeix nullable: true type: [string, "null"]
Webhooks No No webhooks com a secció de primer nivell
Exemples example example i examples Igual, més examples de JSON Schema
Suport d'eines Total (llegat) Total Bo, amb excepcions

Quina versió fer servir. La Botiga Aroma fa servir la 3.1 per dos motius concrets:

  1. Alineació total amb JSON Schema 2020-12. Els esquemes del contracte es poden fer servir tal qual a AJV per validar respostes a les proves (05-04) i al validar() del servidor, sense traduccions ni sorpreses. A la 3.0 els esquemes eren «gairebé» JSON Schema, i aquell «gairebé» costa tardes senceres.
  2. webhooks de primer nivell. La nostra arquitectura envia comanda.pagada i comanda.enviada a RàpidEnviaments amb signatura HMAC. A la 3.0 no hi havia manera de documentar-los com a part de l'API; s'escolaven a la descripció en prosa.

El preu a pagar: alguna eina antiga encara no digereix la 3.1 i cal degradar a 3.0 per a certs generadors. És un problema en retrocés, i openapi.yaml es pot convertir automàticament quan calgui.

  1. L'anatomia del document

Un document OpenAPI 3.1 té aquestes seccions de primer nivell:

openapi: 3.1.0        # versió de l'ESPECIFICACIÓ (no de la teva API)
info: {}              # metadades: títol, versió de la teva API, contacte, llicència
servers: []           # on viu l'API: producció, proves, local
tags: []              # agrupacions per a la documentació
security: []          # seguretat aplicada per defecte a totes les operacions
paths: {}             # les rutes i les seves operacions — el gruix del fitxer
webhooks: {}          # (3.1) esdeveniments sortints: els de RàpidEnviaments
components: {}        # peces reutilitzables referenciades amb $ref
externalDocs: {}      # enllaç a documentació complementària

D'aquestes, openapi, info i una de paths/webhooks/components són obligatòries. La resta és opcional però, sense servers ni security, l'especificació no serveix per generar res útil.

  1. openapi, info i servers

openapi: 3.1.0

info:
  title: API de la Botiga Aroma
  summary: Catàleg, comandes i ressenyes de cafè d'especialitat.
  description: |
    API REST de la **Botiga Aroma**, botiga en línia de cafè d'especialitat.

    ## Convenis generals

    - Tots els identificadors són **opacs** i amb prefix (`caf_`, `com_`, `cli_`).
      No els interpretis ni els construeixis: fes-los servir tal com els reps.
    - Els imports viatgen en **euros amb dos decimals** (`preuEuros`, `totalEuros`).
    - Les dates són **ISO-8601 en UTC** amb sufix `Z`.
    - Les col·leccions retornen `{ "dades": [...], "total": n }` i estan
      **sempre paginades**: sense `limit`, s'apliquen 20 elements.
    - Els errors segueixen el format `{ "error": { "codi", "missatge", "detalls" } }`.
      El `codi` és estable i és el que has de programar; el `missatge` pot canviar.
    - Un paràmetre de consulta desconegut produeix `400`, no s'ignora.

    ## Límits d'ús

    600 peticions per minut per a clients autenticats. En superar-lo es respon
    `429` amb `Retry-After`. Consulta les capçaleres `Aroma-RateLimit-*` a cada resposta.

    ## Compatibilitat

    Afegim camps nous sense avís previ: **ignora els que no coneguis**.
    Els canvis trencadors arriben en una versió major de la ruta (`/v2`), amb un
    mínim de 6 mesos de convivència i capçaleres `Deprecation` i `Sunset`.
  version: 1.7.0
  termsOfService: https://botigaaroma.example/termes-api
  contact:
    name: Equip de plataforma de la Botiga Aroma
    url: https://developers.botigaaroma.example
    email: [email protected]
  license:
    name: Propietària
    url: https://botigaaroma.example/llicencia-api

servers:
  - url: https://api.botigaaroma.example/v1
    description: Producció. Dades reals; els límits d'ús s'apliquen de debò.
  - url: https://api-proves.botigaaroma.example/v1
    description: Proves (sandbox). Dades fictícies, es reinicien cada nit.
  - url: http://localhost:3000/v1
    description: Desenvolupament local.

Tres advertiments sobre aquesta capçalera, que sembla trivial i no ho és:

  • info.version és la versió de la teva API, no d'OpenAPI. Són camps diferents que la gent confon constantment. Fem servir SemVer: 1.7.0 significa que hi ha hagut set tandes d'addicions compatibles des de la 1.0.0. Un 2.0.0 implicaria un canvi trencador i, per tant, un /v2 a la ruta, segons 02-07.
  • La description d'info és la portada de la teva documentació. És l'únic lloc on caben els convenis transversals —diners, dates, identificadors opacs, paginació, compatibilitat— que no pertanyen a cap endpoint concret i que, tanmateix, són el primer que necessita qui s'hi integra. Accepta Markdown i Swagger UI el renderitza.
  • L'url dels servidors inclou /v1. Conseqüència directa de la nostra decisió de versionar a la ruta: les claus de paths queden com /cafes, sense repetir /v1. Si el posessis als dos llocs, els clients generats cridarien /v1/v1/cafes.

info.contact.url apunta al portal de desenvolupador que veurem a 05-06.

  1. tags: l'organització que veu el lector

Els tags agrupen operacions. Sense ells, Swagger UI mostra una llista plana amb quaranta endpoints i ningú no hi troba res.

tags:
  - name: Cafès
    description: |
      Catàleg de cafès d'especialitat. La lectura és pública quant a dades,
      però requereix autenticació; l'escriptura exigeix rol `administrador`.
  - name: Comandes
    description: |
      Cicle de vida de la comanda: creació, pagament, enviament, factura, anul·lació i devolució.
      Les transicions d'estat es fan amb subrecursos, no canviant `estat` amb PATCH.
  - name: Clients
    description: Dades, preferències i comandes del client.
  - name: Ressenyes
    description: Ressenyes de cafès i la seva moderació.
  - name: Cistelles
    description: Cistella de la compra prèvia a la comanda.
  - name: Sessions
    description: Autenticació amb credencials i obtenció del token d'accés.
  - name: Operació
    description: Salut del servei i metadades. No formen part de `/v1`.

x-tagGroups:            # extensió que entenen Redoc i alguns portals
  - name: Comerç
    tags: [Cafès, Cistelles, Comandes]
  - name: Comunitat
    tags: [Clients, Ressenyes]
  - name: Plataforma
    tags: [Sessions, Operació]

Dos criteris: un tag per recurs (els recursos són estables, els casos d'ús no) i descripcions que continguin la regla de negoci no òbvia, com el fet que les transicions d'estat siguin subrecursos. Aquella frase evita mitja dotzena de preguntes al canal de suport.

Qualsevol camp que comenci per x- és una extensió: l'especificació permet afegir-ne, les eines els ignoren si no els entenen, i algunes —com Redoc amb x-tagGroups— els aprofiten.

  1. paths: GET /cafes complet

Reprenem el fragment de 02-08 i el portem a la seva forma final, ja amb referències a components:

paths:
  /cafes:
    get:
      operationId: obtenirCafes          # nom del mètode als clients generats
      summary: Llista el catàleg de cafès
      description: |
        Retorna els cafès del catàleg filtrats, ordenats i paginats.
        La paginació és obligatòria: sense `limit` s'apliquen 20 elements i el
        màxim és 100. Un paràmetre de consulta desconegut produeix `400`.
      tags: [Cafès]
      parameters:
        - name: origen
          in: query
          description: Filtra per país d'origen. Diversos valors separats per comes.
          required: false
          schema: { type: string }
          example: Colòmbia,Etiòpia
        - name: torrefaccio
          in: query
          description: Filtra per nivell de torrefacció. Diversos valors separats per comes.
          schema:
            type: string
            pattern: '^(clar|mitja|fosc)(,(clar|mitja|fosc))*$'
          example: clar,mitja
        - name: preuMin
          in: query
          description: Preu mínim en euros, inclusivament.
          schema: { type: number, minimum: 0 }
        - name: preuMax
          in: query
          description: Preu màxim en euros, inclusivament.
          schema: { type: number, minimum: 0 }
        - name: disponible
          in: query
          description: Si és `true`, només retorna cafès amb `estoc` més gran que zero.
          schema: { type: boolean }
        - name: q
          in: query
          description: Cerca de text a nom, origen i notes de tast.
          schema: { type: string, minLength: 2, maxLength: 100 }
        - name: ordenar
          in: query
          description: |
            Camp d'ordenació; el prefix `-` inverteix l'ordre. S'admeten diversos
            camps separats per comes. El desempat final és sempre `id` ascendent.
          schema:
            type: string
            default: nom
            example: -preuEuros,nom
        - name: camps
          in: query
          description: Llista de camps a incloure a cada element, separats per comes.
          schema: { type: string }
          example: id,nom,preuEuros
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/desplacament'
      responses:
        '200':
          description: Col·lecció de cafès que compleixen el filtre.
          headers:
            Link:
              $ref: '#/components/headers/Link'
            ETag:
              $ref: '#/components/headers/ETag'
            Aroma-RateLimit-Restants:
              $ref: '#/components/headers/RateLimitRestants'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ColleccioCafes' }
              examples:
                primeraPagina:
                  summary: Primera pàgina del catàleg
                  value:
                    dades:
                      - id: caf_001
                        nom: Etiòpia Yirgacheffe
                        origen: Etiòpia
                        torrefaccio: clar
                        preuEuros: 14.50
                        estoc: 120
                        notesTast: [cítric, floral, te negre]
                        dataCreacio: '2026-01-15T08:30:00Z'
                        versio: 3
                      - id: caf_002
                        nom: Colòmbia Huila
                        origen: Colòmbia
                        torrefaccio: mitja
                        preuEuros: 12.90
                        estoc: 80
                        notesTast: [caramel, nou]
                        dataCreacio: '2026-01-16T09:10:00Z'
                        versio: 1
                    total: 137
                senseResultats:
                  summary: Filtre sense coincidències — 200 amb llista buida, mai 404
                  value: { dades: [], total: 0 }
        '304':
          description: No modificat. Es retorna si `If-None-Match` coincideix amb l'`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è al catàleg
      description: Requereix rol `administrador`.
      tags: [Cafès]
      security:
        - bearerJWT: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NouCafe' }
      responses:
        '201':
          description: Cafè creat.
          headers:
            Location:
              description: URI del recurs creat.
              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: Ja existeix un cafè amb aquell nom i origen.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

    parameters: []   # (paràmetres comuns a totes les operacions d'aquesta ruta)

Detalls que distingeixen una especificació útil d'una que només compila:

  • operationId és obligatori a la pràctica. És el nom del mètode als clients generats: obtenirCafes produeix api.obtenirCafes({...}). Ha de ser únic a tot el document i estable en el temps: canviar-lo trenca el codi de tots els consumidors que facin servir el client generat, encara que l'API no hagi canviat res.
  • L'exemple senseResultats documenta una decisió de disseny de 02-04 —un filtre sense coincidències és 200 amb llista buida, no 404— millor que tres paràgrafs.
  • '5XX' és la manera d'agrupar tota la família d'errors de servidor sense repetir-se. Les cometes són obligatòries en YAML: sense elles, 404 s'interpreta com a número.
  • security a nivell d'operació sobreescriu la global. Aquí POST /cafes exigeix explícitament bearerJWT perquè no admet el flux d'OAuth de tercers.

  1. paths: POST /comandes complet

El cas més ric del contracte: exigeix Idempotency-Key, té àmbits OAuth, i els seus errors són de negoci.

  /comandes:
    post:
      operationId: crearComanda
      summary: Crea una comanda
      description: |
        Crea una comanda en estat `pendent_pagament` i **reserva l'estoc** de cada línia.

        Aquesta operació **exigeix la capçalera `Idempotency-Key`**: repetir la petició amb
        la mateixa clau i el mateix cos retorna la resposta original sense crear una
        comanda nova. Repetir-la amb la mateixa clau i un cos diferent produeix `409`.
        Desa la clau abans d'enviar i reutilitza-la en qualsevol reintent.
      tags: [Comandes]
      security:
        - bearerJWT: []
        - oauth2: [comandes.escriure]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: UUID v4 generat pel client. Es conserva 24 hores.
          schema: { type: string, format: uuid }
          example: 7f3c1a90-2d64-4e11-9c88-1b2f4a6d0e55
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NovaComanda' }
            examples:
              duesLinies:
                summary: Comanda de dos cafès
                value:
                  clientId: cli_842
                  linies:
                    - { cafeId: caf_001, quantitat: 2 }
                    - { cafeId: caf_002, quantitat: 1 }
      responses:
        '201':
          description: Comanda creada i estoc reservat.
          headers:
            Location:
              description: URI de la comanda creada.
              schema: { type: string, format: uri-reference }
              example: /v1/comandes/com_5001
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Comanda' }
        '400': { $ref: '#/components/responses/Error400' }
        '401': { $ref: '#/components/responses/Error401' }
        '403': { $ref: '#/components/responses/Error403' }
        '409':
          description: |
            Conflicte de negoci. Consulta `error.codi` per distingir-lo:
            - `estoc_insuficient`: alguna línia supera l'estoc disponible.
            - `clau_idempotencia_reutilitzada`: mateixa `Idempotency-Key`, cos diferent.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                estocInsuficient:
                  value:
                    error:
                      codi: estoc_insuficient
                      missatge: 'No hi ha estoc suficient d''"Etiòpia Yirgacheffe".'
                      detalls:
                        - { camp: 'linies[0].quantitat', sollicitat: 200, disponible: 120 }
                clauReutilitzada:
                  value:
                    error:
                      codi: clau_idempotencia_reutilitzada
                      missatge: 'La clau d''idempotència ja s''ha fet servir amb un altre cos.'
                      detalls: []
        '428':
          description: Falta la capçalera `Idempotency-Key`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  codi: clau_idempotencia_requerida
                  missatge: 'La capçalera Idempotency-Key és obligatòria en aquesta operació.'
                  detalls: []
        '429': { $ref: '#/components/responses/Error429' }

Observa com el 409 es documenta amb dos exemples amb nom: el mateix codi HTTP significa dues coses diferents i el consumidor programa contra error.codi, no contra l'estat. Aquesta és la raó de ser del catàleg d'errors de 02-04, i aquí es fa visible.

  1. components.schemas: els tipus de la Botiga Aroma

Els esquemes són la part més reutilitzada del document i la que alimentarà les validacions de 05-04 i els clients generats de l'apartat 20.

components:
  schemas:
    Cafe:
      type: object
      title: Cafè
      description: Un cafè del catàleg.
      required: [id, nom, origen, torrefaccio, preuEuros, estoc, dataCreacio, versio]
      properties:
        id:
          type: string
          pattern: '^caf_[A-Za-z0-9]+$'
          description: Identificador opac. No el parsegis ni el construeixis.
          examples: [caf_001]
          readOnly: true
        nom: { type: string, minLength: 1, maxLength: 120, examples: [Etiòpia Yirgacheffe] }
        origen: { type: string, minLength: 2, maxLength: 60, examples: [Etiòpia] }
        torrefaccio:
          type: string
          enum: [clar, mitja, fosc]
          description: Nivell de torrefacció. Valors tancats; se'n poden afegir de nous en el futur.
        preuEuros:
          type: number
          minimum: 0
          multipleOf: 0.01
          description: |
            Preu de venda en euros amb dos decimals. Internament s'emmagatzema en
            cèntims enters: no facis aritmètica en coma flotant amb aquest valor
            si necessites exactitud; multiplica per 100 i opera amb enters.
          examples: [14.50]
        estoc: { type: integer, minimum: 0, examples: [120] }
        notesTast:
          type: array
          maxItems: 8
          items: { type: string, maxLength: 40 }
          examples: [[cítric, floral, te negre]]
        dataCreacio:
          type: string
          format: date-time
          description: Data d'alta en ISO-8601 UTC.
          examples: ['2026-01-15T08:30:00Z']
          readOnly: true
        versio:
          type: integer
          minimum: 1
          description: |
            Versió per a concurrència optimista. Coincideix amb l'`ETag` de la resposta;
            envia'l a `If-Match` en modificar.
          readOnly: true
        _links:
          $ref: '#/components/schemas/Enllacos'

    NouCafe:
      type: object
      title: Cafè nou
      description: Cos per crear un cafè. No inclou camps calculats pel servidor.
      required: [nom, origen, torrefaccio, preuEuros, estoc]
      additionalProperties: false      # un camp desconegut produeix 400 (03-04)
      properties:
        nom: { type: string, minLength: 1, maxLength: 120 }
        origen: { type: string, minLength: 2, maxLength: 60 }
        torrefaccio: { type: string, enum: [clar, mitja, fosc] }
        preuEuros: { type: number, minimum: 0, multipleOf: 0.01 }
        estoc: { type: integer, minimum: 0, default: 0 }
        notesTast:
          type: array
          maxItems: 8
          items: { type: string, maxLength: 40 }

    PedacCafe:
      type: object
      title: Pedaç de cafè (merge-patch)
      description: |
        Cos de `PATCH` amb `Content-Type: application/merge-patch+json`.
        Tots els camps són opcionals; `null` esborra el camp quan és admissible.
      additionalProperties: false
      minProperties: 1                 # un pedaç buit no té sentit: 400
      properties:
        nom: { type: string, minLength: 1, maxLength: 120 }
        preuEuros: { type: number, minimum: 0, multipleOf: 0.01 }
        estoc: { type: integer, minimum: 0 }
        notesTast:
          type: [array, 'null']        # sintaxi 3.1: a la 3.0 seria nullable: true
          items: { type: string, maxLength: 40 }

    ColleccioCafes:
      type: object
      title: Col·lecció de cafès
      required: [dades, total]
      properties:
        dades:
          type: array
          items: { $ref: '#/components/schemas/Cafe' }
        total:
          type: integer
          minimum: 0
          description: Total d'elements que compleixen el filtre, no de la pàgina actual.

    LiniaComanda:
      type: object
      required: [cafeId, quantitat]
      properties:
        cafeId: { type: string, pattern: '^caf_[A-Za-z0-9]+$' }
        quantitat: { type: integer, minimum: 1, maximum: 99 }
        preuUnitariEuros: { type: number, readOnly: true }
        subtotalEuros: { type: number, readOnly: true }

    NovaComanda:
      type: object
      required: [clientId, linies]
      additionalProperties: false
      properties:
        clientId: { type: string, pattern: '^cli_[A-Za-z0-9]+$' }
        linies:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/LiniaComanda' }

    Comanda:
      type: object
      required: [id, clientId, linies, totalEuros, estat, dataCreacio]
      properties:
        id: { type: string, pattern: '^com_[A-Za-z0-9]+$', readOnly: true }
        clientId: { type: string, pattern: '^cli_[A-Za-z0-9]+$' }
        linies:
          type: array
          items: { $ref: '#/components/schemas/LiniaComanda' }
        totalEuros: { type: number, minimum: 0, readOnly: true, examples: [29.00] }
        estat:
          type: string
          enum: [pendent_pagament, pagat, enviat]
          description: |
            L'estat **no es modifica amb PATCH**: es canvia invocant els subrecursos
            `/comandes/{id}/pagament`, `/comandes/{id}/enviament` o `/comandes/{id}/anullacio`.
          readOnly: true
        dataCreacio: { type: string, format: date-time, readOnly: true }
        _links: { $ref: '#/components/schemas/Enllacos' }

    Enllacos:
      type: object
      description: Enllaços de navegació del recurs (HATEOAS, nivell 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/comandes/com_5001 }
          pagament: { href: /v1/comandes/com_5001/pagament, method: POST }

    Error:
      type: object
      title: Error
      description: |
        Format únic d'error de l'API. Programa sempre contra `error.codi`,
        que és estable; `error.missatge` està pensat per a humans i pot canviar
        sense avís previ, fins i tot d'idioma.
      required: [error]
      properties:
        error:
          type: object
          required: [codi, missatge, detalls]
          properties:
            codi:
              type: string
              description: Codi estable del catàleg d'errors.
              enum:
                [cafe_no_trobat, client_no_trobat, comanda_no_trobada,
                 ressenya_no_trobada, cistella_no_trobada, estoc_insuficient,
                 comanda_ja_pagada, dades_invalides, parametre_invalid, no_autenticat,
                 token_caducat, permisos_insuficients, conflicte_versio,
                 precondicio_requerida, operacio_en_curs, clau_idempotencia_requerida,
                 clau_idempotencia_reutilitzada, limit_peticions, cos_massa_gran,
                 ruta_no_trobada, metode_no_permes, format_no_suportat,
                 error_intern, servei_no_disponible, versio_api_retirada]
            missatge: { type: string, description: Descripció llegible en català. }
            detalls:
              type: array
              description: Llista de problemes concrets. Buida si no s'aplica.
              items:
                type: object
                properties:
                  camp: { type: string, examples: ['linies[0].quantitat'] }
                  problema: { type: string }
            tracaId:
              type: string
              format: uuid
              description: |
                Identificador de la traça. **Només present en respostes 5xx.**
                Inclou-lo en obrir una incidència amb suport.

Quatre decisions que mereixen justificació:

  • readOnly: true marca els camps que el servidor calcula. Els generadors ho aprofiten: el tipus Cafe generat els inclou, però el tipus del cos de creació els omet. És la raó per la qual NouCafe existeix com a esquema a part en lloc de reutilitzar Cafe.
  • additionalProperties: false només a les entrades. Als cossos que rebem, un camp desconegut és un error del client i retornem 400 (03-04). A les sortides, mai: tancar-les convertiria qualsevol camp nou en un canvi trencador per als clients generats, contra la regla de 02-07.
  • L'enum complet del catàleg d'errors. Cost alt de manteniment, valor alt: el client TypeScript generat obté un tipus unió amb els vint-i-cinc codis i el compilador avisa si algú escriu cafe_no_trobada.
  • multipleOf: 0.01 documenta formalment la regla dels dos decimals que arrosseguem des de 02-05.

  1. components.parameters i components.responses reutilitzables

  parameters:
    limit:
      name: limit
      in: query
      description: Nombre màxim d'elements a retornar.
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    desplacament:
      name: desplacament
      in: query
      description: |
        Nombre d'elements a saltar. Màxim 10.000; a partir d'aquí fes servir `cursor`
        allà on estigui disponible, perquè el desplaçament profund degrada la consulta.
      schema: { type: integer, minimum: 0, maximum: 10000, default: 0 }
    IdCafe:
      name: id
      in: path
      required: true
      description: Identificador opac 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ó que estàs modificant. Obligatori a `PUT`, `PATCH` i
        `DELETE`: sense ell es respon `428`; si no coincideix, `412`.
      schema: { type: string }
      example: 'W/"3"'

  headers:
    Link:
      description: Enllaços de paginació (RFC 8288) amb `rel` `next`, `prev`, `first` i `last`.
      schema: { type: string }
      example: '</v1/cafes?limit=20&desplacament=20>; rel="next"'
    ETag:
      description: Validador de la representació. Fes-lo servir a `If-None-Match` i `If-Match`.
      schema: { type: string }
      example: 'W/"3"'
    RetryAfter:
      description: Segons que has d'esperar abans de reintentar-ho.
      schema: { type: integer }
      example: 30
    RateLimitRestants:
      description: Peticions que et queden a la finestra actual.
      schema: { type: integer }
      example: 597

  responses:
    Error400:
      description: Petició invàlida — dades del cos o paràmetres de consulta.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            parametreInvalid:
              value:
                error:
                  codi: parametre_invalid
                  missatge: "El paràmetre 'limit' no pot superar 100."
                  detalls: []
            dadesInvalides:
              value:
                error:
                  codi: dades_invalides
                  missatge: 'El cos conté camps invàlids.'
                  detalls:
                    - { camp: preuEuros, problema: 'ha de ser més gran o igual que 0' }

    Error401:
      description: Falta el token, no és vàlid o ha caducat.
      headers:
        WWW-Authenticate:
          description: Esquema esperat i motiu del rebuig.
          schema: { type: string }
          example: 'Bearer realm="api", error="invalid_token"'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    Error403:
      description: Autenticat però sense permís — rol o àmbit OAuth insuficient.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    Error404:
      description: El recurs no existeix o no és visible per a tu.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    Error429:
      description: S'ha superat el límit de peticions.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        Aroma-RateLimit-Restants: { $ref: '#/components/headers/RateLimitRestants' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              codi: limit_peticions
              missatge: 'Has superat el límit de 600 peticions per minut.'
              detalls: []

    Error500:
      description: |
        Error intern. Reintenta-ho amb retrocés exponencial i jitter. El cos inclou
        `tracaId`: cita'l si obres una incidència.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

Amb això, cada operació declara els seus errors en una línia ('404': { $ref: '#/components/responses/Error404' }) i el dia que canviï el format d'error es toca un lloc. Sense components.responses, una API de quaranta operacions repeteix el bloc d'error dues-centes vegades i, garantit, tres d'elles queden desactualitzades.

  1. securitySchemes i security: JWT i OAuth 2.0

  securitySchemes:
    bearerJWT:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Token JWT obtingut a `POST /v1/sessions` amb correu i contrasenya.
        Caduca en 1 hora; renova'l amb `POST /v1/sessions/renovacio`.
        És el mecanisme de la SPA, del panell i de l'Aroma Mòbil.

    oauth2:
      type: oauth2
      description: |
        Per a aplicacions de tercers (com CataBox) que actuen en nom d'un
        client de la Botiga Aroma. Registra la teva aplicació al portal de desenvolupador
        per obtenir el `client_id`. Les aplicacions públiques **han** de fer servir PKCE.
      flows:
        authorizationCode:
          authorizationUrl: https://auth.botigaaroma.example/oauth/autoritzar
          tokenUrl: https://auth.botigaaroma.example/oauth/token
          refreshUrl: https://auth.botigaaroma.example/oauth/token
          scopes:
            cafes.llegir: Llegir el catàleg de cafès i les seves ressenyes.
            comandes.llegir: Llegir les comandes del client que autoritza.
            comandes.escriure: Crear i pagar comandes en nom del client.
            ressenyes.escriure: Publicar ressenyes en nom del client.
        clientCredentials:
          tokenUrl: https://auth.botigaaroma.example/oauth/token
          scopes:
            enviaments.escriure: Actualitzar l'estat d'enviament. Reservat a socis logístics.
            ressenyes.moderar: Aprovar o rebutjar ressenyes. Reservat a eines internes.

# Seguretat per defecte de TOTA l'API: qualsevol dels dos esquemes serveix.
security:
  - bearerJWT: []
  - oauth2: []

Com es llegeixen les dues formes de combinar, que és la part que més confon:

Escrit així Significa
security: [{ bearerJWT: [] }, { oauth2: [] }] JWT o OAuth: la llista externa és un OR
security: [{ bearerJWT: [], apiKey: [] }] JWT i apiKey alhora: dins del mateix objecte és un AND
security: [] en una operació Aquella operació és pública: anul·la la seguretat global
security: [{ oauth2: [comandes.escriure] }] OAuth amb aquell àmbit concret

Les excepcions a la seguretat global de la Botiga Aroma:

  /sessions:
    post:
      operationId: iniciarSessio
      summary: Inicia sessió i obté un token
      tags: [Sessions]
      security: []          # pública per definició: aquí és on s'aconsegueix el token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, contrasenya]
              properties:
                email: { type: string, format: email }
                contrasenya: { type: string, format: password, minLength: 8, writeOnly: true }
      responses:
        '200':
          description: Sessió iniciada.
          content:
            application/json:
              schema:
                type: object
                required: [token, caducaEn, client]
                properties:
                  token: { type: string, description: JWT d'accés. }
                  caducaEn: { type: integer, description: Segons de validesa., examples: [3600] }
                  client: { $ref: '#/components/schemas/Client' }
        '401':
          description: |
            Credencials incorrectes. El missatge és **deliberadament genèric**:
            no revela si el correu existeix (04-02, enumeració d'usuaris).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

writeOnly: true a contrasenya és el mirall de readOnly: s'envia però no es retorna mai. Els generadors l'ometen als tipus de resposta, i Swagger UI no el mostra als exemples de sortida.

  1. $ref i els límits de la reutilització

$ref és un punter JSON. Les seves tres formes:

# 1. Interna: al mateix document (la més habitual)
schema: { $ref: '#/components/schemas/Cafe' }

# 2. A un altre fitxer local: permet trossejar una especificació gran
schema: { $ref: './esquemes/cafe.yaml' }
responses:
  '404': { $ref: './respostes/comunes.yaml#/Error404' }

# 3. Remota: a una URL. Evita-la.
schema: { $ref: 'https://esquemes.botigaaroma.example/cafe.yaml' }

Quan openapi.yaml passa d'unes mil línies, trossejar-lo en fitxers i unir-los abans de publicar és el raonable:

# Uneix un document trossejat en un únic fitxer autocontingut
npx @redocly/cli bundle openapi.yaml -o dist/openapi.yaml

Dos avisos per experiència:

  • Un $ref remot és una dependència de xarxa al teu procés de construcció. Si aquell host cau o canvia, la teva documentació deixa de compilar i no sabràs per què. Si necessites esquemes compartits entre APIs, publica'ls com a paquet i uneix-los a la construcció.
  • A OpenAPI 3.0, un objecte que conté $ref ignora els seus germans. Escriure { $ref: '#/...', description: 'una altra cosa' } descartava silenciosament la descripció. A la 3.1 això es va arreglar i description i summary sí que es respecten al costat de $ref, però no totes les eines se n'han assabentat; si necessites variar alguna cosa, allOf continua sent el segur.

  1. example davant d'examples

example examples
On viu Dins de schema, o al costat de content Al costat de content, i als paràmetres
Quants Un Diversos, amb nom
Estructura El valor directe Mapa de nom: { summary, description, value }
Quan fer-lo servir Un camp solt Casos alternatius: èxit, buit, error de negoci
# Malament: un únic exemple perd el matís dels diferents 409
'409':
  content:
    application/json:
      schema: { $ref: '#/components/schemas/Error' }
      example: { error: { codi: estoc_insuficient, missatge: '...', detalls: [] } }

# Bé: cada cas amb el seu nom; Swagger UI ofereix un desplegable per triar-lo
'409':
  content:
    application/json:
      schema: { $ref: '#/components/schemas/Error' }
      examples:
        estocInsuficient:
          summary: Alguna línia supera l'estoc disponible
          value: { error: { codi: estoc_insuficient, missatge: '...', detalls: [] } }
        clauReutilitzada:
          summary: Mateixa Idempotency-Key amb un cos diferent
          value: { error: { codi: clau_idempotencia_reutilitzada, missatge: '...', detalls: [] } }

Un detall propi de la 3.1: dins d'un schema la paraula correcta és examples en plural i com a array (ve de JSON Schema 2020-12), mentre que al costat de content és un mapa amb noms. Són dos camps diferents que s'escriuen igual; els veuràs a l'apartat 8 com a examples: [caf_001].

I la regla que més valor aporta: fes servir exemples realistes i coherents entre si. Si l'exemple de POST /comandes esmenta caf_001 amb quantitat: 2 a 14,50 €, l'exemple de la resposta ha de dir totalEuros: 29.00 i no 99.99. Els exemples incoherents destrueixen la confiança en tota la documentació, i a més alimenten els mocks de 05-04.

  1. oneOf, allOf i discriminator

Els tres combinadors, amb l'exemple real de les notificacions a RàpidEnviaments:

Paraula Significa Ús típic
allOf Compleix tots els esquemes Herència: base + extensió
oneOf Compleix exactament un Variants excloents
anyOf Compleix almenys un Poc freqüent; sol indicar un disseny confús
    EsdevenimentBase:
      type: object
      required: [id, tipus, dataEmissio]
      properties:
        id: { type: string, examples: [evt_9001] }
        tipus: { type: string }
        dataEmissio: { type: string, format: date-time }

    EsdevenimentComandaPagada:
      allOf:
        - $ref: '#/components/schemas/EsdevenimentBase'
        - type: object
          required: [dades]
          properties:
            tipus: { const: comanda.pagada }
            dades:
              type: object
              properties:
                comandaId: { type: string, examples: [com_5001] }
                totalEuros: { type: number, examples: [29.00] }

    EsdevenimentComandaEnviada:
      allOf:
        - $ref: '#/components/schemas/EsdevenimentBase'
        - type: object
          required: [dades]
          properties:
            tipus: { const: comanda.enviada }
            dades:
              type: object
              properties:
                comandaId: { type: string }
                seguiment: { type: string, examples: [RE-4471-XA] }

    Esdeveniment:
      oneOf:
        - $ref: '#/components/schemas/EsdevenimentComandaPagada'
        - $ref: '#/components/schemas/EsdevenimentComandaEnviada'
      discriminator:
        propertyName: tipus
        mapping:
          comanda.pagada: '#/components/schemas/EsdevenimentComandaPagada'
          comanda.enviada: '#/components/schemas/EsdevenimentComandaEnviada'

discriminator diu al validador i al generador quin camp mirar per saber quina de les variants és. Sense ell, un validador ha de provar-les totes i un generador produeix un tipus unió sense manera d'estrènyer-lo. Amb ell, el client TypeScript generat obté una unió discriminada i un switch (esdeveniment.tipus) amb comprovació exhaustiva.

I, com que el document és 3.1, aquests esdeveniments es declaren com a webhooks de primer nivell:

webhooks:
  comandaPagada:
    post:
      operationId: rebreComandaPagada
      summary: Notificació de comanda pagada
      description: |
        La Botiga Aroma envia aquesta petició **a la URL que hagis registrat** quan una
        comanda es paga. Verifica la signatura abans de processar el cos: la capçalera
        `Aroma-Signatura` conté l'HMAC-SHA256 del cos cru amb el teu secret
        compartit. Respon `2xx` en menys de 5 segons; ho reintentem amb retrocés
        exponencial durant 24 hores.
      parameters:
        - name: Aroma-Signatura
          in: header
          required: true
          schema: { type: string, examples: ['sha256=9f2a...'] }
        - name: Aroma-Esdeveniment-Id
          in: header
          required: true
          description: Identificador únic de l'esdeveniment. Fes-lo servir per descartar duplicats.
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EsdevenimentComandaPagada' }
      responses:
        '200': { description: Notificació acceptada. }

  1. Documentar la deprecació i els límits de peticions

La deprecació de 02-07 té una expressió formal a OpenAPI:

  /cafes/{id}/valoracions:
    get:
      operationId: obtenirValoracionsCafe
      summary: '[Obsolet] Valoracions d''un cafè'
      deprecated: true
      description: |
        > **Obsolet des de la 1.5.0. Es retirarà el 30 de juny de 2027.**
        >
        > Fes servir `GET /cafes/{id}/ressenyes`, que retorna la mateixa dada amb `puntuacio`
        > i `comentari` en un sol recurs. Guia de migració:
        > https://developers.botigaaroma.example/migracio/ressenyes

        Les respostes inclouen les capçaleres `Deprecation` i `Sunset`.
      tags: [Cafès]
      parameters:
        - $ref: '#/components/parameters/IdCafe'
      responses:
        '200':
          description: Valoracions del cafè.
          headers:
            Deprecation:
              description: Data en què l'operació va quedar obsoleta (RFC 9745).
              schema: { type: string }
              example: '@1767225600'
            Sunset:
              description: Data de retirada definitiva (RFC 8594).
              schema: { type: string }
              example: 'Tue, 30 Jun 2027 23:59:59 GMT'
            Link:
              description: Enllaç a l'alternativa, amb rel="successor-version".
              schema: { type: string }

deprecated: true fa que Swagger UI ratlli l'operació i que els clients generats marquin el mètode com a obsolet: en TypeScript, amb @deprecated, l'editor el ratlla; en Java, amb @Deprecated, el compilador avisa. És la manera més eficaç d'avisar: apareix on la persona desenvolupadora està mirant.

El camp també existeix a les propietats d'un esquema i als paràmetres:

        preuCentims:
          type: integer
          deprecated: true
          description: 'Obsolet: fes servir `preuEuros`. S''eliminarà a la v2.'

Els límits de peticions es documenten en tres llocs complementaris, perquè cap no basta per si sol: la description global d'info (la política general), la resposta reutilitzable Error429 amb les seves capçaleres, i la description de les operacions que tinguin un límit específic, com POST /sessions amb el seu limitLogin més estricte de 04-04.

  1. Dues maneres de treballar: a mà o des del codi

Especificació primer (a mà) Codi primer (anotacions)
Qui escriu el contracte L'equip, abans d'implementar Es dedueix del codi ja escrit
Eina Editor de YAML, Swagger Editor, Stoplight swagger-jsdoc, decoradors de NestJS, springdoc
Contracte com a acord previ Sí: es pot revisar i fer-ne mock abans No: existeix quan el codi existeix
Risc de deriva Alt si ningú no ho comprova Baix per a la forma, alt per al significat
Qualitat de la documentació Alta: descripcions i exemples pensats Sol ser pobra: tipus sense explicació
Treball en paral·lel El front comença el dia 1 amb un mock El front espera que existeixi l'API
Cost inicial Alt Baix
Cost de manteniment Mitjà i constant Baix, però enganyós
Encaixa amb API pública, diversos consumidors, equips separats Servei intern, un equip, iteració ràpida

La Botiga Aroma segueix l'enfocament d'especificació primer, i aquella decisió ja està presa des de 02-01. El motiu és concret: tenim cinc consumidors —SPA, Aroma Mòbil, panell, RàpidEnviaments i CataBox— i tres d'ells els desenvolupen persones que no som nosaltres. El contracte ha d'existir abans que el codi perquè és el que permet treballar en paral·lel.

El matís important, i on molta gent s'enganya: generar l'especificació des del codi elimina la deriva estructural, no la semàntica. El generador sap que l'endpoint retorna un objecte amb un camp estat de tipus string; no sap que pendent_pagament només passa a pagat a través del subrecurs /pagament, ni que el preu no s'ha de fer servir en aritmètica de coma flotant. Tota la informació valuosa del nostre openapi.yaml l'ha escrita una persona pensant en qui s'hi integrarà.

I l'enfocament a mà té la seva pròpia deriva: res no garanteix que el YAML descrigui el que el servidor fa realment. Contra això hi ha dos remeis, i tots dos són al curs: les regles de Spectral de 04-01 i, sobretot, les proves de contracte de 05-04, que validen les respostes reals contra l'esquema.

  1. Generar l'especificació amb swagger-jsdoc

Encara que no sigui el nostre enfocament, convé saber com és, perquè te'l trobaràs. Amb swagger-jsdoc l'especificació s'escriu en comentaris JSDoc al costat de les rutes:

// src/rutes/cafes.js — exemple de l'enfocament "codi primer" (NO és el de la Botiga Aroma)

/**
 * @openapi
 * /cafes/{id}:
 *   get:
 *     operationId: obtenirCafePerId
 *     summary: Obté un cafè pel seu identificador
 *     tags: [Cafès]
 *     parameters:
 *       - $ref: '#/components/parameters/IdCafe'
 *     responses:
 *       '200':
 *         description: El cafè sol·licitat.
 *         content:
 *           application/json:
 *             schema: { $ref: '#/components/schemas/Cafe' }
 *       '404':
 *         $ref: '#/components/responses/Error404'
 */
router.get('/:id', autenticar, asincron(obtenirCafePerId));

I s'assembla en un mòdul de configuració:

// src/config/openapi.js
import swaggerJsdoc from 'swagger-jsdoc';

export const especificacio = swaggerJsdoc({
  definition: {
    openapi: '3.1.0',
    info: { title: 'API de la Botiga Aroma', version: '1.7.0' },
    servers: [{ url: 'http://localhost:3000/v1' }],
  },
  // Fitxers on cercar els comentaris @openapi
  apis: ['./src/rutes/*.js', './src/esquemes/*.js'],
});

Avantatge real: el comentari és a un centímetre del codi, així que qui canvia la ruta veu la documentació. Inconvenient real: és YAML dins de comentaris, sense autocompletat ni validació mentre escrius, i un error d'indentació apareix en temps d'execució. A més, encara s'ha d'escriure a mà; l'única cosa que s'automatitza és l'assemblatge.

Un enfocament intermedi que guanya terreny a l'ecosistema Node i que mereix menció: derivar l'especificació dels esquemes de validació que ja tens. Els nostres esquemes de Zod de src/esquemes/ ja descriuen la forma exacta de les entrades; amb zod-to-json-schema es poden convertir en els components.schemas del document, de manera que validació i documentació no puguin divergir:

// Eina auxiliar: exporta els esquemes Zod com a JSON Schema
import { zodToJsonSchema } from 'zod-to-json-schema';
import { esquemaNouCafe } from '../src/esquemes/cafes.js';

const jsonSchema = zodToJsonSchema(esquemaNouCafe, { target: 'jsonSchema2020-12' });
console.log(JSON.stringify({ components: { schemas: { NouCafe: jsonSchema } } }, null, 2));

És la millor eina contra la deriva de la part estructural, sense renunciar a escriure a mà les descripcions i els exemples. Frameworks com Fastify i NestJS ho fan de sèrie, com veurem a 05-03.

  1. Servir Swagger UI a /docs

Ara servim la documentació des del mateix projecte.

npm install swagger-ui-express yaml

Fitxer nou src/config/openapi.js:

// src/config/openapi.js
// Carrega i exposa l'especificació OpenAPI del projecte.
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 rutaEspecificacio = join(aqui, '..', '..', 'openapi.yaml');

// Es llegeix UNA vegada en arrencar: és un fitxer immutable durant la vida del procés.
// Si falla, que falli aquí i no a la primera petició a /docs.
export const especificacio = YAML.parse(readFileSync(rutaEspecificacio, 'utf8'));

export const versioApi = especificacio.info.version;

Fitxer nou src/rutes/documentacio.js:

// src/rutes/documentacio.js
import { Router } from 'express';
import swaggerUi from 'swagger-ui-express';
import { especificacio } from '../config/openapi.js';
import { entorn } from '../config/entorn.js';

export const rutesDocumentacio = Router();

// El document cru: és el que consumeixen els generadors de clients,
// Prism (05-04), el gateway (05-06) i la importació de Postman (05-01).
rutesDocumentacio.get('/openapi.json', (req, res) => {
  res.type('application/json').send(especificacio);
});

const opcionsUi = {
  customSiteTitle: 'API de la Botiga Aroma — documentació',
  swaggerOptions: {
    // En local apuntem el "Try it out" al servidor local; en altres entorns,
    // al que correspongui. Sense això, el botó dispara contra producció.
    urls: undefined,
    persistAuthorization: true,   // conserva el token entre recàrregues: molt còmode
    displayRequestDuration: true,
    docExpansion: 'list',         // llista les operacions plegades, no desplegades
    filter: true,                 // caixa de cerca per tag
    tryItOutEnabled: entorn.nom !== 'produccio',
  },
};

rutesDocumentacio.use('/', swaggerUi.serve, swaggerUi.setup(especificacio, opcionsUi));

I el seu registre a src/app.js. La posició importa: abans d'app.use('/v1', rutesV1) i, sobretot, fora de /v1, perquè la documentació no és un recurs versionat de l'API.

// src/app.js — fragment, entre les posicions 13 i 14 de la cadena
import { rutesDocumentacio } from './rutes/documentacio.js';

// ... 13. etagCondicional

// 13-bis. Documentació. Fora de /v1 i amb la seva pròpia política d'accés.
if (entorn.docsPubliques || entorn.nom !== 'produccio') {
  app.use('/docs', rutesDocumentacio);
} else {
  // En producció exigim autenticació d'empleat per veure el contracte intern.
  app.use('/docs', autenticar, exigirRol('empleat', 'administrador'), rutesDocumentacio);
}

// 14. app.use('/v1', rutesV1)

Quatre consideracions sobre exposar la documentació:

  • helmet i Swagger UI xoquen. La Content-Security-Policy per defecte de helmet (04-02) bloqueja els estils en línia que fa servir Swagger UI, i la pàgina apareix en blanc i sense CSS. La solució correcta no és desactivar helmet, sinó relaxar la política només en aquella ruta:
// Excepció de CSP acotada a /docs; la resta de l'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'"],
  },
}), rutesDocumentacio);
  • /docs no ha de comptar contra el límit de peticions de l'API ni embrutar les mètriques de 04-07. Si metriquesMiddleware l'etiqueta com a ruta, hi veuràs una latència p99 anòmala causada per gent llegint documentació.
  • Pública o protegida? Si l'API és pública, la documentació també: és el teu aparador. Si és interna, el contracte és un mapa detallat de la teva superfície d'atac —rutes, paràmetres, esquemes— i és informació valuosa per a qui t'ataqui. Protegir-la no és seguretat de debò (la seguretat és a l'autenticació dels endpoints), però sí que redueix soroll i exposició innecessària.
  • El "Try it out" de Swagger UI executa peticions reals des del navegador. En producció convé desactivar-lo, i en qualsevol cas recorda que necessita que l'origen de la documentació estigui a la llista blanca de CORS de 04-05 si la serveixes des d'un altre domini.

  1. Alternatives de renderització: Redoc, Scalar, Stoplight Elements

Swagger UI no és l'única manera de pintar el mateix openapi.yaml.

Renderitzador Aspecte Provar en viu Fort en Quan triar-lo
Swagger UI Clàssic, dens Ubiqüitat; tothom el reconeix Documentació interna, desenvolupament
Redoc Tres columnes, tipogràfic Només a la versió de pagament Especificacions grans, lectura llarga, x-tagGroups Documentació pública de referència
Scalar Modern, fosc per defecte Sí, amb client integrat Rapidesa, bona experiència, exemples multillenguatge Portals nous
Stoplight Elements Component web Integrar-lo en un portal propi Portal de desenvolupador a mida (05-06)

Canviar de renderitzador és qüestió de minuts perquè tots consumeixen el mateix fitxer:

// Alternativa amb Redoc servit de manera estàtica, sense dependències externes de CDN
rutesDocumentacio.get('/referencia', (req, res) => {
  res.type('html').send(`<!doctype html>
<html>
  <head><title>API de la Botiga Aroma</title><meta charset="utf-8"></head>
  <body>
    <redoc spec-url="/docs/openapi.json"></redoc>
    <script src="/estatics/redoc.standalone.js"></script>
  </body>
</html>`);
});

Fixa't que l'script se serveix des de /estatics i no des d'una CDN externa: una CDN a la documentació és una dependència de tercers que la CSP de 04-02 hauria de bloquejar, i amb raó.

  1. Validar l'especificació: swagger-cli i Spectral

Hi ha dos nivells de validació que resolen problemes diferents i calen tots dos.

Nivell 1: és un document OpenAPI vàlid? Estructura, referències resoltes, tipus correctes.

# swagger-cli (paquet @apidevtools/swagger-cli)
npx swagger-cli validate openapi.yaml
# → openapi.yaml is valid

# Alternativa més moderna, amb millor suport de 3.1
npx @redocly/cli lint openapi.yaml

Això detecta un $ref trencat, una indentació equivocada o un type: strng. Sense aquesta comprovació, un error d'una lletra trenca la documentació i no te n'assabentes fins que algú obre /docs.

Nivell 2: compleix la guia d'estil de la Botiga Aroma? Aquí entra Spectral amb .spectral.yaml, que ja vam escriure a 04-01.

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

Ara que el document està complet, hi afegim tres regles noves que només tenen sentit amb components poblat:

# .spectral.yaml — regles afegides a 05-02
rules:
  aroma-operacio-amb-operationid:
    description: Tota operació declara operationId; és el nom del mètode generat.
    severity: error
    given: $.paths[*][get,post,put,patch,delete]
    then:
      field: operationId
      function: truthy

  aroma-operationid-en-camelcase:
    description: Els operationId van en camelCase i en català (obtenirCafes, crearComanda).
    severity: error
    given: $.paths[*][*].operationId
    then:
      function: casing
      functionOptions: { type: camel }

  aroma-errors-usen-esquema-comu:
    description: Tota resposta 4xx/5xx referencia l'esquema Error del catàleg.
    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-esquemes-amb-descripcio:
    description: Tot esquema de components té descripció; és el que llegeix el consumidor.
    severity: warn
    given: $.components.schemas[*]
    then:
      field: description
      function: truthy

  aroma-exemples-en-respostes-200:
    description: Les respostes 200 i 201 inclouen almenys un exemple.
    severity: warn
    given: $.paths[*][*].responses[200,201].content['application/json']
    then:
      function: schema
      functionOptions:
        schema:
          type: object
          anyOf:
            - required: [example]
            - required: [examples]

Tots dos nivells es converteixen en passos de la canalització de CI de 05-05:

{
  "scripts": {
    "contracte:validar": "swagger-cli validate openapi.yaml",
    "contracte:lint": "spectral lint openapi.yaml --fail-severity=error",
    "contracte": "npm run contracte:validar && npm run contracte:lint"
  }
}

  1. Generar clients amb OpenAPI Generator

Amb el contracte complet, el pas següent és deixar d'escriure a mà el codi que crida l'API.

# Client TypeScript amb fetch per a 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

# Client TypeScript amb axios per a l'Aroma Mòbil (React Native)
npx @openapitools/openapi-generator-cli generate \
  -i openapi.yaml \
  -g typescript-axios \
  -o ../aroma-mobil/src/api-generada

El resultat a la SPA, amb tipus deduïts del contracte:

// Codi de la SPA que consumeix el client generat
import { Configuration, CafesApi, type Cafe, TorrefaccioEnum } from './api-generada';

const configuracio = new Configuration({
  basePath: import.meta.env.VITE_URL_API,          // https://api.botigaaroma.example/v1
  accessToken: () => sessio.obtenirToken(),
});

const cafesApi = new CafesApi(configuracio);

// El mètode es diu com l'operationId; els paràmetres estan tipats
const colleccio = await cafesApi.obtenirCafes({
  torrefaccio: TorrefaccioEnum.Clar,   // enum generat des de l'esquema: no hi cap "morat"
  preuMax: 15,
  limit: 20,
  ordenar: '-preuEuros',
});

// colleccio.dades és Cafe[]; colleccio.total és number
colleccio.dades.forEach((cafe: Cafe) => {
  // cafe.preuEuros és number, cafe.torrefaccio és TorrefaccioEnum
  console.log(`${cafe.nom}: ${cafe.preuEuros.toFixed(2)} €`);
});

Generadors disponibles per als nostres consumidors:

Consumidor Generador Sortida
SPA (React) typescript-fetch Classes i tipus amb fetch natiu
Aroma Mòbil typescript-axios o kotlin / swift5 Client per plataforma
CataBox (tercer) El que ells triïn Només consumeixen l'openapi.yaml publicat
Eines internes python, go Scripts d'operació
Proves de contracte Els esquemes es fan servir directament (05-04)

Què s'hi guanya: tipus sempre alineats amb el contracte, zero codi repetitiu de fetch, operationId com a nom de mètode, enums que impedeixen valors invàlids en temps de compilació, i —l'efecte més valuós— un canvi trencador al contracte es converteix en un error de compilació al consumidor, no en una fallada en producció.

Què exigeix cura, i això no ho expliquen els tutorials:

  • El codi generat no s'edita mai. Es regenera. Afegeix la carpeta al .gitignore o, si la versiones per tenir traçabilitat, marca els fitxers com a generats i prohibeix tocar-los a la revisió. Una correcció manual desapareix a la generació següent.
  • Genera molt codi. Un generador pot produir centenars de fitxers per a una API mitjana. Revisa el que produeix abans d'adoptar-lo; alguns generadors arrosseguen dependències pesades.
  • La qualitat depèn del generador. typescript-fetch i go són sòlids; d'altres tenen raresses. Prova'l abans de comprometre-t'hi.
  • Canviar un operationId trenca els consumidors encara que l'API sigui idèntica. Tracta'l com a part del contracte.
  • Embolcalla'l. No exposis el client generat a tota la teva aplicació: posa-hi una capa fina al damunt que tradueixi els seus errors als del teu domini i centralitzi l'autenticació i els reintents amb jitter de 04-04. Així, canviar de generador afecta un fitxer.

Per a casos més lleugers existeixen alternatives que generen només tipus: openapi-typescript produeix un fitxer de tipus sense client, i openapi-fetch els consumeix amb un embolcall mínim. Per a una SPA moderna sol ser millor opció que les classes del generador oficial.

  1. Mantenir el contracte sincronitzat

Tot l'anterior s'ensorra si openapi.yaml descriu una API que ja no existeix. El cicle de vida complet del contracte:

graph LR
    A[Proposta de canvi<br/>a openapi.yaml] --> B[Pull request:<br/>revisió de disseny 04-01]
    B --> C[swagger-cli validate<br/>+ spectral lint]
    C --> D[oasdiff:<br/>és trencador? 05-04]
    D --> E[Implementació<br/>mòdul 3]
    E --> F[Proves de contracte:<br/>respostes reals vs esquema]
    F --> G[Publicació a CI:<br/>/docs i portal 05-06]
    G --> H[Clients regenerats<br/>SPA i Aroma Mòbil]

Les regles d'equip que sostenen aquell cicle:

  1. El canvi del contracte va al mateix pull request que la implementació. Si van separats, un dels dos s'oblida.
  2. openapi.yaml és el primer fitxer que es revisa, abans que el codi. El diff del contracte és on es veu si el canvi és una bona idea; el codi només diu si està ben fet.
  3. La canalització falla si el contracte no valida o no passa el linting. Sense excepcions (04-01).
  4. La canalització avisa si el canvi és trencador, amb oasdiff. És la porta que veurem a 05-04.
  5. Les proves d'integració validen les respostes reals contra els esquemes. És l'única cosa que detecta la deriva de debò, i és el tema central de 05-04.
  6. La publicació és automàtica, no un pas manual que algú recorda fer els divendres (05-05).
  7. info.version puja a cada canvi del contracte, seguint SemVer.

Errors Comuns i Consells

  • Confondre openapi: 3.1.0 amb info.version. El primer és la versió de l'especificació; el segon, la de la teva API. Canviar el primer per error trenca eines; oblidar pujar el segon fa inútil l'historial.
  • Repetir /v1 a servers i a paths. Els clients generats criden /v1/v1/cafes. Amb versionat a la ruta, el prefix va a servers i les claus de paths comencen per /cafes.
  • additionalProperties: false als esquemes de sortida. Converteix qualsevol camp nou en un canvi trencador per als consumidors estrictes, just el contrari de la regla de compatibilitat de 02-07. Tanca'ls només a les entrades.
  • Documentar només el camí feliç. Una especificació sense 4xx obliga cada consumidor a descobrir els errors a base de provocar-los. Les respostes reutilitzables costen una línia per operació.
  • Exemples incoherents. caf_001 a 14,50 €, dues unitats i un total de 99,99 € a l'exemple de la comanda. A més de fer mala impressió, alimenta els mocks de 05-04 amb dades falses i confon qui s'hi integra.
  • Oblidar operationId, o canviar-lo a la lleugera. Sense ell, els generadors inventen noms com getCafesById_1. Canviar-lo trenca els consumidors sense tocar l'API.
  • Editar el codi generat. Desapareix a la regeneració següent. Si necessites canviar-lo, embolcalla'l.
  • Servir Swagger UI en producció sense pensar-hi. Revisa si el teu contracte ha de ser públic, tingues en compte la CSP de helmet i decideix si el "Try it out" ha d'estar actiu.
  • Especificació completa però sense validar res. El document més bonic del món menteix si ningú no comprova que les respostes reals el compleixen. Aquest és el problema de 05-04.
  • Consell: escriu primer les descripcions i els exemples, no els tipus. Els tipus es dedueixen; el coneixement —que el preu no s'opera en coma flotant, que l'estat es canvia amb subrecursos— només és al teu cap.
  • Consell: fes servir un summary curt i una description llarga. Swagger UI mostra el summary a la llista plegada, i és l'única cosa que la majoria llegeix.
  • Consell: si el teu equip manté esquemes Zod, genera'n els components.schemas en lloc d'escriure'ls dues vegades. La duplicació és la mare de la deriva.

Exercicis

Exercici 1: documentar GET /comandes/{id} i POST /comandes/{id}/pagament

Escriu el fragment de paths per a aquestes dues operacions, reutilitzant tot el que ja existeix a components. Requisits:

  • GET /comandes/{id}: paràmetre de ruta, expandir=linies.cafe com a paràmetre de consulta opcional, respostes 200, 304, 401, 403 (la comanda d'un altre client) i 404, amb ETag a la resposta.
  • POST /comandes/{id}/pagament: exigeix Idempotency-Key, àmbit OAuth comandes.escriure, cos amb el mètode de pagament, i respostes 200, 402 (pagament rebutjat), 409 (comanda_ja_pagada) i 428.

Exercici 2: la regla de Spectral que faltava

Escriu una regla de Spectral que obligui que tota operació que modifica un recurs existent (PUT, PATCH, DELETE) declari el paràmetre de capçalera If-Match i documenti la resposta 412. Explica el given, el then i per què la introduiries com a warn abans que com a error.

Exercici 3: decidir l'enfocament per a un servei nou

La Botiga Aroma llançarà un servei intern de recomanacions (recomanacions-api) que només consumirà la mateixa API de la Botiga Aroma mitjançant gRPC i, a més, exposarà dos endpoints REST per al panell intern. El desenvoluparà un equip de dues persones en tres setmanes.

Decideix si aplicar «especificació primer» o «codi primer», justifica-ho amb almenys quatre criteris de la taula de l'apartat 15, i descriu què faries per evitar la deriva en l'enfocament que triïs.

Solucions

Solució 1

  /comandes/{id}:
    get:
      operationId: obtenirComanda
      summary: Obté una comanda pel seu identificador
      description: |
        Un client només pot consultar les seves pròpies comandes; els rols `empleat` i
        `administrador` poden consultar-ne qualsevol. Intentar llegir la comanda d'un altre
        client retorna `403`, no `404`: l'existència de la comanda no és secreta per a
        qui està autenticat, i retornar `404` complicaria la depuració.
      tags: [Comandes]
      security:
        - bearerJWT: []
        - oauth2: [comandes.llegir]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^com_[A-Za-z0-9]+$' }
          example: com_5001
        - name: expandir
          in: query
          description: |
            Incrusta recursos relacionats en lloc de retornar només els seus enllaços.
            Únic valor admès: `linies.cafe`.
          schema: { type: string, enum: [linies.cafe] }
        - name: If-None-Match
          in: header
          description: ETag conegut pel client; si coincideix es respon `304`.
          schema: { type: string }
      responses:
        '200':
          description: La comanda sol·licitada.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
            Cache-Control:
              description: Privat i de vida curta; una comanda canvia d'estat.
              schema: { type: string }
              example: 'private, max-age=0, must-revalidate'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Comanda' }
              examples:
                pagada:
                  summary: Comanda ja pagada, amb enllaços a les accions disponibles
                  value:
                    id: com_5001
                    clientId: cli_842
                    linies:
                      - { cafeId: caf_001, quantitat: 2, preuUnitariEuros: 14.50, subtotalEuros: 29.00 }
                    totalEuros: 29.00
                    estat: pagat
                    dataCreacio: '2026-03-02T10:15:00Z'
                    _links:
                      self: { href: /v1/comandes/com_5001 }
                      factura: { href: /v1/comandes/com_5001/factura }
                      devolucio: { href: /v1/comandes/com_5001/devolucio, method: POST }
        '304':
          description: No modificat.
        '401': { $ref: '#/components/responses/Error401' }
        '403':
          description: La comanda pertany a un altre client.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  codi: permisos_insuficients
                  missatge: 'No tens permís per consultar aquesta comanda.'
                  detalls: []
        '404': { $ref: '#/components/responses/Error404' }

  /comandes/{id}/pagament:
    post:
      operationId: pagarComanda
      summary: Paga una comanda pendent
      description: |
        Cobra la comanda i la passa a l'estat `pagat`. És una **transició d'estat
        expressada com a subrecurs**, no un `PATCH` sobre `estat`.

        Exigeix `Idempotency-Key`: un reintent amb la mateixa clau retorna la resposta
        original sense cobrar dues vegades. És la garantia més important d'aquesta operació.
        En completar-se, s'emet el webhook `comanda.pagada` cap a RàpidEnviaments.
      tags: [Comandes]
      security:
        - bearerJWT: []
        - oauth2: [comandes.escriure]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^com_[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: [metode]
              additionalProperties: false
              properties:
                metode: { type: string, enum: [targeta, transferencia, moneder] }
                tokenTargeta:
                  type: string
                  writeOnly: true
                  description: |
                    Token de la passarel·la. **No enviïs mai el PAN de la targeta a
                    aquesta API**: tokenitza'l al client amb l'SDK de la passarel·la.
      responses:
        '200':
          description: Pagament acceptat; la comanda passa a `pagat`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Comanda' }
        '401': { $ref: '#/components/responses/Error401' }
        '402':
          description: La passarel·la ha rebutjat el pagament. La comanda continua `pendent_pagament`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  codi: pagament_rebutjat
                  missatge: 'L''entitat emissora ha rebutjat el pagament.'
                  detalls: [{ camp: metode, problema: 'fons insuficients' }]
        '409':
          description: La comanda ja estava pagada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: { codi: comanda_ja_pagada, missatge: 'La comanda ja està pagada.', detalls: [] }
        '428':
          description: Falta `Idempotency-Key`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/Error429' }

Nota: pagament_rebutjat no era al catàleg de 02-04. Afegir un codi exigeix actualitzar també l'enum de l'esquema Error i el fitxer src/errors/error-api.js. És un bon exemple de per què el catàleg tancat obliga a un canvi conscient en lloc d'inventar un codi sobre la marxa.

Solució 2

  aroma-modificacions-exigeixen-if-match:
    description: |
      Tota operació que modifica un recurs existent ha de declarar el paràmetre
      If-Match i documentar la resposta 412, perquè la concurrència optimista de
      04-06 sigui part del contracte i no un detall d'implementació.
    message: '{{path}} modifica un recurs però 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ó del given: $.paths[*][put,patch,delete] selecciona l'objecte d'operació d'aquests tres mètodes a totes les rutes. No fa servir el sufix ~ perquè aquí ens interessa el valor (l'objecte de l'operació amb els seus parameters i responses), no la clau.

Explicació del then: és una llista de dues comprovacions que s'apliquen al mateix node. La primera fa servir la funció schema amb contains de JSON Schema per exigir que l'array parameters inclogui almenys un element amb name: If-Match. La segona fa servir truthy sobre responses.412, que exigeix que aquell camp existeixi i no estigui buit.

Per què warn primer: el contracte actual té operacions DELETE que no exigeixen If-Match —l'esborrat d'una cistella, per exemple—. Si la regla entra directament com a error, la canalització es posa en vermell i ningú no pot integrar res fins que estigui tot arreglat, amb la conseqüència previsible que algú desactivi la regla. El procediment correcte, el mateix de 04-01: entra com a warn, s'obre una tasca per netejar les infraccions, i quan el linting surt net es puja a error en un pull request d'una línia. A més, hi ha excepcions legítimes —DELETE idempotents sobre recursos sense concurrència— que convé documentar abans d'endurir-la, amb x-spectral-ignore o replantejant el given.

Solució 3

Decisió: codi primer per a recomanacions-api, amb dues excepcions.

Justificació amb els criteris de la taula:

Criteri Anàlisi del cas
Consumidors Un de sol i intern: el panell. No hi ha equips externs esperant. El valor principal d'«especificació primer» —permetre treball en paral·lel— no s'aplica.
Treball en paral·lel El panell pot esperar; són dos endpoints. No compensa muntar un mock ni negociar un contracte previ.
Cost inicial davant del termini Tres setmanes i dues persones. El cost inicial d'escriure a mà un contracte complet es menja una part apreciable del pressupost.
Risc de deriva semàntica Baix: l'equip que escriu el servei és el mateix que consumeix l'endpoint des del panell. La deriva fa mal quan el consumidor és un altre.
Estabilitat esperada Un servei de recomanacions és experimental per naturalesa: els endpoints canviaran diverses vegades els primers mesos. Un contracte acordat per endavant es refarà constantment.
Superfície Dos endpoints REST. El gruix del servei és gRPC, el contracte del qual són els fitxers .proto, que ja són especificació primer per construcció.

Excepció 1: gRPC no és negociable. Els .proto són el contracte i s'escriuen abans que el codi, amb revisió. Que el REST sigui codi primer no canvia això.

Excepció 2: el contracte ha d'existir encara que es generi. «Codi primer» no vol dir «sense contracte». El servei ha de publicar el seu openapi.json generat a /docs/openapi.json, i aquell fitxer ha de passar pel mateix spectral lint que la Botiga Aroma. Si l'equip no ho accepta, la decisió correcta passa a ser especificació primer.

Mesures contra la deriva en l'enfocament triat:

  1. Generar des dels esquemes de validació, no des d'anotacions soltes. Si el servei valida amb Zod, zod-to-json-schema produeix els components.schemas, de manera que la validació real i la documentació són literalment el mateix objecte i no poden divergir.
  2. Bolcar l'openapi.json generat a un fitxer versionat a cada construcció de CI. Així el diff del contracte apareix al pull request i és revisable, encara que ningú no l'hagi escrit a mà. És el truc que dona a «codi primer» la revisabilitat d'«especificació primer».
  3. Passar spectral lint sobre el document generat, amb les mateixes regles de l'organització. Obliga a posar operationId, descripcions i respostes d'error, que és justament el que l'enfocament de codi primer sol oblidar.
  4. Escriure a mà les descripcions i els exemples. Els tipus els dedueix el generador; el coneixement del domini, no. Una anotació sense descripció produeix documentació inútil.
  5. Revisar la decisió quan canviï el context. El dia que un segon consumidor —l'Aroma Mòbil, o un tercer— depengui d'aquest servei, l'anàlisi canvia i toca migrar a especificació primer. Convé deixar-ho escrit en un ADR (04-01) perquè la decisió i la seva data de caducitat estiguin documentades.

Conclusió

openapi.yaml ha deixat de ser un fragment per convertir-se en el contracte complet de la Botiga Aroma. Saps distingir OpenAPI, l'especificació, de Swagger, la família d'eines, i per què la 3.1 —alineada amb JSON Schema 2020-12 i amb webhooks de primer nivell— és l'elecció correcta per a una API que valida amb AJV i notifica RàpidEnviaments. Has recorregut el document sencer: info amb la portada on viuen els convenis de diners, dates i identificadors opacs; servers amb el /v1 al lloc correcte; tags per recurs; paths amb GET /cafes i POST /comandes complets, inclosos el 428 per falta d'Idempotency-Key i els dos significats diferents del mateix 409; components amb esquemes que distingeixen Cafe de NouCafe mitjançant readOnly, paràmetres i respostes d'error reutilitzables, i securitySchemes amb el JWT de 03-06 i els fluxos i àmbits OAuth de 04-03. I saps documentar el que gairebé ningú documenta: la deprecació amb deprecated: true juntament amb Deprecation i Sunset de 02-07, els límits de peticions de 04-04 i els esdeveniments signats cap a RàpidEnviaments.

Sobre aquell contracte hi has muntat la maquinària que el fa útil. L'API serveix la seva pròpia documentació a /docs amb Swagger UI, fora de /v1, amb l'excepció de CSP que helmet exigeix, protegida en producció i amb el "Try it out" desactivat allà; els fitxers nous són src/config/openapi.js i src/rutes/documentacio.js, amb swagger-ui-express i yaml com a dependències, i el registre corresponent a src/app.js. Coneixes les alternatives de renderització i la diferència entre les dues validacions que calen —swagger-cli validate per a l'estructura i Spectral per a la guia d'estil, ara amb cinc regles més i els scripts contracte:validar i contracte:lint—. I has generat des del mateix fitxer els clients TypeScript de la SPA i de l'Aroma Mòbil amb OpenAPI Generator, sabent que el codi generat no s'edita, que s'embolcalla, i que un operationId és part del contracte. També has vist per què la discussió entre escriure l'especificació a mà o generar-la des del codi no té un guanyador universal: elimina la deriva estructural, mai la semàntica.

I aquí queda el buit que aquesta lliçó no pot tapar. Tenim un contracte preciós i validat com a document, però encara res no garanteix que el servidor el compleixi: que GET /cafes retorni exactament l'esquema ColleccioCafes, que cap error no se surti del catàleg, que un canvi al YAML no trenqui la SPA sense avisar. A 05-04, Contractes, mocks i proves automatitzades, tanquem aquell cercle: aixecarem un mock amb Prism directament des d'openapi.yaml perquè la SPA avanci sense esperar el backend, farem servir msw i nock com a dobles de prova, validarem amb AJV les respostes reals dins de les proves Supertest de 03-08, detectarem canvis trencadors entre dues versions del contracte amb oasdiff aplicant les regles de 02-07, veurem quan el contract testing dirigit pel consumidor amb Pact compensa i quan és sobreenginyeria, i organitzarem el recorregut de compra complet com a prova d'extrem a extrem. Abans, però, convé aixecar la vista del projecte: a 05-03, Frameworks populars per a APIs RESTful, veurem què hauria canviat —i què no— si a 03-01 haguéssim triat Fastify, NestJS, FastAPI, Spring Boot o ASP.NET Core en lloc d'Express.

Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful

Mòdul 1: Introducció a les APIs RESTful

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats