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
- OpenAPI i Swagger: dues coses que la gent confon
- Versions: 2.0, 3.0 i 3.1
- L'anatomia del document
openapi,infoiserverstags: l'organització que veu el lectorpaths:GET /cafescompletpaths:POST /comandescompletcomponents.schemas: els tipus de la Botiga Aromacomponents.parametersicomponents.responsesreutilitzablessecuritySchemesisecurity: JWT i OAuth 2.0$refi els límits de la reutilitzacióexampledavant d'examplesoneOf,allOfidiscriminator- Documentar la deprecació i els límits de peticions
- Dues maneres de treballar: a mà o des del codi
- Generar l'especificació amb
swagger-jsdoc - Servir Swagger UI a
/docs - Alternatives de renderització: Redoc, Scalar, Stoplight Elements
- Validar l'especificació:
swagger-clii Spectral - Generar clients amb OpenAPI Generator
- Mantenir el contracte sincronitzat
- 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.
- 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:
- 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. webhooksde primer nivell. La nostra arquitectura enviacomanda.pagadaicomanda.enviadaa 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.
- 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àriaD'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.
openapi, info i servers
openapi, info i serversopenapi: 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.0significa que hi ha hagut set tandes d'addicions compatibles des de la 1.0.0. Un2.0.0implicaria un canvi trencador i, per tant, un/v2a la ruta, segons 02-07.- La
descriptiond'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'
urldels servidors inclou/v1. Conseqüència directa de la nostra decisió de versionar a la ruta: les claus depathsqueden 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.
tags: l'organització que veu el lector
tags: l'organització que veu el lectorEls 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.
paths: GET /cafes complet
paths: GET /cafes completReprenem 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:obtenirCafesprodueixapi.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
senseResultatsdocumenta una decisió de disseny de 02-04 —un filtre sense coincidències és200amb llista buida, no404— 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,404s'interpreta com a número.securitya nivell d'operació sobreescriu la global. AquíPOST /cafesexigeix explícitamentbearerJWTperquè no admet el flux d'OAuth de tercers.
paths: POST /comandes complet
paths: POST /comandes completEl 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.
components.schemas: els tipus de la Botiga Aroma
components.schemas: els tipus de la Botiga AromaEls 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: truemarca els camps que el servidor calcula. Els generadors ho aprofiten: el tipusCafegenerat els inclou, però el tipus del cos de creació els omet. És la raó per la qualNouCafeexisteix com a esquema a part en lloc de reutilitzarCafe.additionalProperties: falsenomés a les entrades. Als cossos que rebem, un camp desconegut és un error del client i retornem400(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'
enumcomplet 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ú escriucafe_no_trobada. multipleOf: 0.01documenta formalment la regla dels dos decimals que arrosseguem des de 02-05.
components.parameters i components.responses reutilitzables
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.
securitySchemes i security: JWT i OAuth 2.0
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.
$ref i els límits de la reutilització
$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.yamlDos avisos per experiència:
- Un
$refremot é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é
$refignora els seus germans. Escriure{ $ref: '#/...', description: 'una altra cosa' }descartava silenciosament la descripció. A la 3.1 això es va arreglar idescriptionisummarysí que es respecten al costat de$ref, però no totes les eines se n'han assabentat; si necessites variar alguna cosa,allOfcontinua sent el segur.
example davant d'examples
example davant d'examplesexample |
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.
oneOf, allOf i discriminator
oneOf, allOf i discriminatorEls 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. }
- 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.
- 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.
- Generar l'especificació amb
swagger-jsdoc
swagger-jsdocEncara 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.
- Servir Swagger UI a
/docs
/docsAra servim la documentació des del mateix projecte.
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ó:
helmeti Swagger UI xoquen. LaContent-Security-Policyper 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);/docsno ha de comptar contra el límit de peticions de l'API ni embrutar les mètriques de 04-07. SimetriquesMiddlewarel'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.
- 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 | Sí | 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 | Sí | 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ó.
- Validar l'especificació:
swagger-cli i Spectral
swagger-cli i SpectralHi 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.yamlAixò 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.
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"
}
}
- 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-generadaEl 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
.gitignoreo, 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-fetchigosón sòlids; d'altres tenen raresses. Prova'l abans de comprometre-t'hi. - Canviar un
operationIdtrenca 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.
- 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:
- El canvi del contracte va al mateix pull request que la implementació. Si van separats, un dels dos s'oblida.
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.- La canalització falla si el contracte no valida o no passa el linting. Sense excepcions (04-01).
- La canalització avisa si el canvi és trencador, amb
oasdiff. És la porta que veurem a 05-04. - 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.
- La publicació és automàtica, no un pas manual que algú recorda fer els divendres (05-05).
info.versionpuja a cada canvi del contracte, seguint SemVer.
Errors Comuns i Consells
- Confondre
openapi: 3.1.0ambinfo.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
/v1aserversi apaths. Els clients generats criden/v1/v1/cafes. Amb versionat a la ruta, el prefix va aserversi les claus depathscomencen per/cafes. additionalProperties: falseals 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
4xxobliga cada consumidor a descobrir els errors a base de provocar-los. Les respostes reutilitzables costen una línia per operació. - Exemples incoherents.
caf_001a 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 comgetCafesById_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
summarycurt i unadescriptionllarga. Swagger UI mostra elsummarya la llista plegada, i és l'única cosa que la majoria llegeix. - Consell: si el teu equip manté esquemes Zod, genera'n els
components.schemasen 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.cafecom a paràmetre de consulta opcional, respostes200,304,401,403(la comanda d'un altre client) i404, ambETaga la resposta.POST /comandes/{id}/pagament: exigeixIdempotency-Key, àmbit OAuthcomandes.escriure, cos amb el mètode de pagament, i respostes200,402(pagament rebutjat),409(comanda_ja_pagada) i428.
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: truthyExplicació 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:
- Generar des dels esquemes de validació, no des d'anotacions soltes. Si el servei valida amb Zod,
zod-to-json-schemaprodueix elscomponents.schemas, de manera que la validació real i la documentació són literalment el mateix objecte i no poden divergir. - Bolcar l'
openapi.jsongenerat 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». - Passar
spectral lintsobre el document generat, amb les mateixes regles de l'organització. Obliga a posaroperationId, descripcions i respostes d'error, que és justament el que l'enfocament de codi primer sol oblidar. - 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.
- 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
- Què és una API?
- Història i evolució de les APIs
- Fonaments d'HTTP per a APIs
- Principis bàsics de REST
- Model de maduresa de Richardson i HATEOAS
- REST vs. SOAP
- REST davant de GraphQL, gRPC i webhooks
Mòdul 2: Disseny d'APIs RESTful
- Principis de disseny d'APIs RESTful
- Recursos i URIs
- Mètodes HTTP
- Codis d'estat HTTP
- Representacions, capçaleres i negociació de contingut
- Filtratge, ordenació, paginació i cerca
- Versionat d'APIs
- Documentació d'APIs
Mòdul 3: Desenvolupament d'APIs RESTful
- Configuració de l'entorn de desenvolupament
- Creació d'un servidor bàsic
- Gestió de peticions i respostes
- Validació de dades d'entrada
- Persistència i capa d'accés a dades
- Autenticació i autorització
- Gestió d'errors
- Proves i validació
Mòdul 4: Bones Pràctiques i Seguretat
- Bones pràctiques en el disseny d'APIs
- Seguretat en APIs RESTful
- OAuth 2.0 i OpenID Connect a la pràctica
- Rate limiting i throttling
- CORS i polítiques de seguretat
- Memòria cau HTTP i rendiment
- Observabilitat: logs, mètriques i traces
Mòdul 5: Eines i Frameworks
- Postman per a proves d'APIs
- Swagger i OpenAPI per a documentació
- Frameworks populars per a APIs RESTful
- Contractes, mocks i proves automatitzades d'API
- Integració contínua i desplegament
- API gateways i portals de desenvolupador
