Escena Viva ja és ràpida i està mesurada. El que li queda no és un problema de rendiment, sinó de disseny. La seva API va créixer per acumulació, mòdul a mòdul: primer un servidor artesanal al M4, després rutes d'Express al M6, després autenticació al M8. Pel camí van aparèixer verbs a les URL, codis d'estat triats a ull, llistats sense paginació, cap política de versions, i un dubte que vam deixar explícitament obert al mòdul 4: què passa quan Marc envia dues vegades el mateix POST /api/comandes perquè va perdre la cobertura a l'estrena del Festival de Jazz. Aquesta lliçó converteix aquella API en una API ben dissenyada, amb criteris justificats i no per gust estètic: una API és un contracte que altres programen al damunt i que no pots trencar quan vulguis.
Contingut
- Què és REST de debò
- El model de maduresa de Richardson i HATEOAS
- Disseny de recursos i accions que no són CRUD
- Mètodes HTTP i les seves garanties
- La clau d'idempotència
- Codis d'estat fets servir amb criteri
- Format de la resposta i dels errors
- Paginació, filtratge, ordenació i selecció de camps
- Versionat i política de deprecació
- Memòria cau HTTP i peticions condicionals
- Documentació amb OpenAPI
- Redisseny de l'API d'Escena Viva
- Què és REST de debò
REST (Representational State Transfer) és un estil arquitectònic descrit per Roy Fielding a la seva tesi doctoral de l'any 2000. No és un format, no és JSON i no és "fer servir verbs HTTP": són sis restriccions.
| Restricció | Què exigeix | Com la compleix Escena Viva |
|---|---|---|
| Client-servidor | Separació de responsabilitats i interfícies | L'API no sap res de la interfície web |
| Sense estat (stateless) | Cada petició conté tot el que cal | JWT d'accés (M8); la sessió a Redis és un incompliment conscient |
| Cacheable | Les respostes s'etiqueten com a cacheables o no | Cache-Control i ETag a les lectures |
| Interfície uniforme | URI, representacions, missatges autodescriptius, hipermèdia | És la restricció que més ens falta |
| Sistema per capes | El client no sap si parla amb el servidor final o un proxy | Proxy invers i memòria cau entremig (M11) |
| Codi sota demanda (opcional) | El servidor pot enviar codi executable | No es fa servir, i gairebé ningú no la fa servir |
La restricció sense estat és la que més conseqüències pràctiques té i la que més s'incompleix. És literalment la que fa possible tot el mòdul 10: si cada petició es basta a si mateixa, qualsevol dels set treballadors de la lliçó 10-01 pot atendre-la, i per això el repartiment SCHED_RR funciona. Quan a la 10-03 vam moure les sessions a Redis, el que vam fer va ser externalitzar l'estat perquè el servidor continués sent intercanviable. Un servidor amb estat en memòria no escala horitzontalment: és el mateix aprenentatge vist des de l'altre costat.
- El model de maduresa de Richardson i HATEOAS
Leonard Richardson va proposar una escala de quatre nivells per situar-se amb honestedat:
| Nivell | Què el caracteritza | Exemple |
|---|---|---|
| 0 / 1 | Un sol punt d'entrada com a túnel; o recursos amb URI però tot per POST |
POST /api amb { "accio": ... }; POST /esdeveniments/evt-003/comprar |
| 2 | Mètodes HTTP i codis d'estat amb la seva semàntica | GET /esdeveniments/evt-003, DELETE /comandes/ped-77 → 204 |
| 3 | HATEOAS: la resposta inclou enllaços a les accions possibles | La resposta de la comanda porta els enllaços anullar i entrades |
Gairebé cap API anomenada REST no passa del nivell 2, i convé dir-ho sense dramatisme: el nivell 2, ben fet, és un disseny excel·lent i és l'objectiu realista. HATEOAS (Hypermedia as the Engine of Application State) significa que el client descobreix el que pot fer a partir de la mateixa resposta, en comptes de tenir les URL codificades:
{
"id": "ped-77", "estat": "pagat", "totalCentims": 9000,
"_enllacos": {
"self": { "href": "/api/v1/comandes/ped-77" },
"anullar": { "href": "/api/v1/comandes/ped-77/anullacio", "metode": "POST" }
}
}El seu valor real és que els enllaços expressen l'estat: si la comanda ja està anul·lada, l'enllaç anullar no apareix, i el client no ha de replicar les regles de negoci per saber quin botó ha de mostrar. El motiu pel qual gairebé ningú no arriba al nivell 3 és que els clients reals (una aplicació React, una app mòbil) no naveguen per hipermèdia: tenen les rutes escrites al codi i no hi guanyen res. Recomanació pràctica per a Escena Viva: nivell 2 sòlid, amb enllaços on aporten (accions condicionades per l'estat i navegació de pàgines), sense fingir el nivell 3 amb enllaços self que ningú no fa servir ni renunciar als enllaços quan eliminen lògica duplicada al client.
- Disseny de recursos i accions que no són CRUD
| Regla | Sí | No |
|---|---|---|
| Substantius en plural, no verbs | /esdeveniments/evt-003 |
/obtenirEsdeveniment/evt-003 |
| Minúscules i guions | /sessions-exhaurides |
/sessionsExhaurides |
| Sense extensió de format ni barra final | /esdeveniments + Accept |
/esdeveniments.json, /esdeveniments/ |
| Jerarquia només si hi ha pertinença real | /esdeveniments/evt-003/sessions |
/sales/ribera/esdeveniments/evt-003/sessions/ses-003-1 |
Sobre la jerarquia, el criteri és la dependència d'existència. Una sessió no existeix sense el seu esdeveniment, així que /esdeveniments/evt-003/sessions és correcte per llistar-les; però una sessió concreta té identitat pròpia (ses-003-1) i també ha de ser accessible a /sessions/ses-003-1. La regla habitual: imbricar com a màxim un nivell per al llistat dins del pare, i exposar el recurs fill a la seva pròpia arrel per a l'accés directe, perquè jerarquies de tres o més nivells obliguen el client a conèixer tota la cadena d'identificadors per no res. I ara la pregunta difícil: com es modela anul·lar una comanda o publicar un esdeveniment? No són creacions ni esborrats, són transicions d'estat, i hi ha tres opcions legítimes més una que no ho és:
| Opció | Exemple | Quan |
|---|---|---|
| Subrecurs que representa l'acció | POST /comandes/ped-77/anullacio |
L'acció té dades pròpies o deixa un registre consultable |
PATCH sobre el camp d'estat |
PATCH /esdeveniments/evt-003 → { "estat": "publicat" } |
La transició és simple i sense efectes laterals |
| Col·lecció de transicions | POST /comandes/ped-77/transicions → { "tipus": "anullar" } |
Moltes transicions sobre la mateixa entitat |
| ~~Verb a la URL~~ | ~~POST /comandes/ped-77/anullar~~ |
Mai: trenca la interfície uniforme |
A Escena Viva triem la primera per anul·lar, i el motiu és concret: l'anul·lació és una entitat de negoci, amb motiu, data, import reemborsat i qui la va sol·licitar, que administració voldrà consultar. GET /api/v1/comandes/ped-77/anullacio retorna aquest registre, i això no seria possible amb un PATCH. Per publicar un esdeveniment, en canvi, fem servir PATCH, perquè és un simple canvi de camp sense dades associades.
- Mètodes HTTP i les seves garanties
Cada mètode té tres propietats que l'estàndard defineix i que la infraestructura d'internet dona per bones: proxies, navegadors i biblioteques client reintenten mètodes idempotents automàticament.
| Mètode | Segur | Idempotent | Cacheable | Ús a Escena Viva |
|---|---|---|---|---|
GET / HEAD |
Sí | Sí | Sí | Llegir catàleg, esdeveniment, comanda; comprovar ETag |
OPTIONS |
Sí | Sí | No | CORS (M6) |
POST |
No | No | Poques vegades | Crear comanda, anul·lar, autenticar-se |
PUT / DELETE |
No | Sí | No | Reemplaçar un esdeveniment; esborrar un esborrany |
PATCH |
No | No per defecte | No | Modificació parcial |
Les definicions exactes importen. Segur significa que no modifica l'estat del servidor: un GET que esborra alguna cosa és una fallada greu, perquè qualsevol rastrejador o precarregador del navegador l'activarà. Idempotent significa que executar-lo N vegades deixa el sistema igual que executar-lo una vegada: DELETE /esdeveniments/evt-009 dues vegades deixa l'esdeveniment esborrat i la segona retorna 404, però l'estat és el mateix; idempotència no significa "retorna el mateix". I cacheable significa que la resposta es pot emmagatzemar i reutilitzar. Quant a PUT davant de PATCH, PUT reemplaça el recurs complet (el que no enviïs s'esborra) i PATCH modifica parcialment; el problema és que PATCH no defineix el seu propi format, així que cal dir quin es fa servir:
PATCH /api/v1/esdeveniments/evt-003 HTTP/1.1
Content-Type: application/merge-patch+json
{ "titol": "Festival de Jazz de Primavera 2026", "descripcioCurta": null }application/merge-patch+json (RFC 7386) és el format raonable: els camps presents s'assignen, i null significa "esborra aquest camp". El seu límit és que no pot posar un camp a null de debò ni modificar un element concret d'un array; per a això hi ha application/json-patch+json (RFC 6902), amb operacions explícites, molt més potent i molt més incòmode. Recomanació: merge-patch llevat que necessitis l'altre. I una nota sobre PATCH i idempotència: { "titol": "X" } sí que és idempotent a la pràctica; el que no ho són són les operacions relatives com { "incrementarAforament": 10 }, que convé evitar.
- La clau d'idempotència
Aquí resolem el dubte del mòdul 4. Escenari real: a l'estrena del Festival de Jazz, Marc prem "Comprar 2 entrades per a ses-003-1". La petició arriba al servidor, es processa, es crea la comanda ped-77... i la resposta es perd perquè el mòbil ha canviat d'antena. El client reintenta. Es creen dues comandes i Marc paga dues vegades. Com que POST no és idempotent per definició, la infraestructura no ens pot ajudar, i la solució estàndard de la indústria (Stripe, PayPal, i ara un esborrany de l'IETF) és una capçalera:
POST /api/v1/comandes HTTP/1.1
Content-Type: application/json
Idempotency-Key: 8f14e45f-ea0c-4f2e-9b3d-2c1a7e5d0b91
{ "sessioId": "ses-003-1", "quantitat": 2 }El client genera la clau (un UUID) abans del primer intent i la reutilitza a tots els reintents d'aquella mateixa operació. El servidor la fa servir així:
'use strict';
const crypto = require('node:crypto');
const { obtenirClientRedis } = require('../db/redis.js');
const { ErrorDApi } = require('../errors.js');
const TTL = 24 * 3600;
// Middleware d'idempotencia per a operacions POST no idempotents.
const crearIdempotencia = ({ redis = obtenirClientRedis() } = {}) =>
async function idempotencia(peticio, resposta, seguent) {
const clau = peticio.get('Idempotency-Key');
if (!clau) return seguent(); // opcional; es pot exigir a les compres
// L'empremta del cos evita que la mateixa clau es reutilitzi per a
// una peticio diferent, cosa que seria un error del client.
const empremta = crypto.createHash('sha256')
.update(JSON.stringify(peticio.body || {})).digest('hex');
const clauRedis = `idem:${peticio.usuari?.id || 'anonim'}:${clau}`;
const enCurs = JSON.stringify({ estat: 'en-curs', empremta });
// SET NX atomic: nomes el primer que arriba reserva l'operacio.
if (!(await redis.set(clauRedis, enCurs, 'EX', TTL, 'NX'))) {
const desat = JSON.parse(await redis.get(clauRedis));
if (desat.empremta !== empremta) {
return seguent(new ErrorDApi('CLAU_IDEMPOTENCIA_REUTILITZADA', 422,
[{ camp: 'Idempotency-Key', detall: 'La clau ja es va usar amb un altre cos.' }]));
}
if (desat.estat === 'en-curs') {
// El primer intent encara s'esta processant: 409 i que reintenti.
resposta.set('Retry-After', '2');
return seguent(new ErrorDApi('OPERACIO_EN_CURS', 409, []));
}
// Repetim la resposta original tal qual, sense tornar a cobrar.
resposta.set('Idempotent-Replay', 'true');
return resposta.status(desat.estat).json(desat.cos);
}
// Som els primers. Interceptem json() per desar la resposta.
// Els 5xx no es memoritzen: el reintent ha de poder executar-se.
const jsonOriginal = resposta.json.bind(resposta);
resposta.json = (cos) => {
const memoria = JSON.stringify({ estat: resposta.statusCode, cos, empremta });
(resposta.statusCode < 500
? redis.set(clauRedis, memoria, 'EX', TTL)
: redis.del(clauRedis)).catch(() => {});
return jsonOriginal(cos);
};
return seguent();
};
module.exports = { crearIdempotencia };Quatre decisions que mereixen justificació. L'empremta del cos impedeix que un client amb un error de programació reutilitzi la clau per a una altra compra. L'estat en-curs cobreix la carrera de dues peticions simultànies. Els errors 5xx no es memoritzen, perquè una fallada transitòria ha de poder reintentar-se. I el TTL de 24 hores acota el creixement sense deixar fora reintents raonables. Tot això es combina amb la idempotència del consumidor de la lliçó 10-03: la clau protegeix l'entrada a l'API, i el jobId protegeix l'execució del treball.
- Codis d'estat fets servir amb criteri
| Codi | Quan | A Escena Viva |
|---|---|---|
| 200 OK | Lectura o modificació amb cos | GET /esdeveniments |
| 201 Created | Recurs creat; obligatòria la capçalera Location |
POST /comandes |
| 202 Accepted | Acceptat per processar més tard | POST /comandes/ped-77/entrades (cua, 10-03) |
| 204 No Content | Èxit sense cos | DELETE /esdeveniments/evt-009 |
| 207 Multi-Status | Operació per lots amb resultats mixtos | POST /comandes/lot |
| 304 Not Modified | If-None-Match coincideix |
Catàleg sense canvis (M4) |
| 400 / 401 / 403 | Mal formada / sense credencials / sense permís | JSON trencat; token caducat; Bóveda tocant evt-003 |
| 404 Not Found | No existeix, o no s'ha de saber que existeix | Comanda d'un altre usuari |
| 409 Conflict | Conflicte amb l'estat actual | Aforament insuficient; anul·lar una comanda ja anul·lada |
| 412 Precondition Failed | If-Match no coincideix |
Actualització perduda evitada |
| 422 Unprocessable Content | Sintaxi vàlida, semàntica invàlida | quantitat: 0, data en el passat |
| 429 / 500 / 503 | Límit superat; error intern; no disponible | limitCompra; fallada inesperada; base de dades caiguda |
L'antipatró que cal erradicar és respondre 200 OK amb { "error": ... } a dins. Trenca tot el que hi ha entre el client i tu: els proxies desen un error a la memòria cau com si fos una resposta vàlida, les biblioteques client no llancen excepció, el monitoratge compta 0 % d'errors mentre el sistema crema, i els reintents automàtics no s'activen. El codi d'estat és part del missatge, no decoració.
La distinció 400 / 422 és la que més dubtes genera: 400 si no he pogut entendre la petició (JSON trencat, capçalera absent); 422 si l'he entès perfectament però no la puc acceptar (quantitat: -3 és un número vàlid i una quantitat impossible), de manera que gairebé tots els errors de validació de zod (M6) són 422. El 409, per la seva banda, té un ús molt concret: intentar comprar 5 entrades quan en queden 3 no és un error de validació —5 és una quantitat perfectament vàlida— sinó un conflicte amb l'estat actual del recurs, i a més un error que pot desaparèixer si algú anul·la la seva comanda, cosa que el client ha de saber.
- Format de la resposta i dels errors
Embolcall o no? Retornar el recurs directament ({ "id": "evt-003", ... }) és net i és el que fa la majoria; un embolcall ({ "dades": ..., "meta": ... }) permet afegir metadades sense tocar el recurs. La incoherència és l'única cosa inacceptable. Decisió d'Escena Viva: recurs directe als detalls, embolcall a les col·leccions, perquè una col·lecció necessita metadades de paginació per força.
{
"dades": [{ "id": "evt-003", "titol": "Festival de Jazz de Primavera" }],
"meta": { "total": 3, "limit": 20, "cursorSeguent": "ZXZ0LTAwMw==" },
"enllacos": { "seguent": "/api/v1/esdeveniments?cursor=ZXZ0LTAwMw==&limit=20" }
}Errors. El RFC 9457 (Problem Details for HTTP APIs, que substitueix el 7807) defineix un format estàndard, servit amb Content-Type: application/problem+json:
{
"type": "https://escenaviva.test/errors/aforament-insuficient",
"title": "Aforament insuficient",
"status": 409,
"detail": "Has demanat 5 entrades i en queden 3 per a la sessio ses-003-1.",
"instance": "/api/v1/comandes",
"disponibles": 3
}Comparat amb el format propi d'Escena Viva:
| Aspecte | Format d'Escena Viva | RFC 9457 |
|---|---|---|
| Forma | { error: { codi, missatge, estat, detalls } } |
Objecte pla amb type, title, status, detail |
| Identificador estable | codi (AFORAMENT_INSUFICIENT) |
type (una URI) |
| Errors de camp | detalls: [{ camp, detall }] |
Extensió pròpia (errors) |
| Eines | Cap | Reconegut per biblioteques i passarel·les |
| Documentació | A part | El type és una URL amb l'explicació |
La decisió honesta: mantenim el nostre format perquè hi ha clients que ja en depenen i trencar-lo seria una ruptura de contracte major, però hi afegim Content-Type: application/problem+json amb els àlies type, title i status com a camps addicionals, de manera que les eines estàndard entenguin la resposta i els nostres clients continuïn funcionant. És una convergència progressiva, no una migració de cop; si comencessis avui des de zero, fes servir RFC 9457 directament. I una regla de seguretat heretada del M8: l'error no filtra mai detalls interns —res de piles de crides, noms de taula ni SQL—, sinó que inclou l'idPeticio (del middleware id-peticio.js) perquè l'usuari el pugui citar al suport i tu el puguis correlacionar amb el registre.
- Paginació, filtratge, ordenació i selecció de camps
Per a la paginació hi ha dues escoles:
| Aspecte | Offset (?pagina=3&limit=20) |
Cursor (?cursor=ZXZ0...&limit=20) |
|---|---|---|
| Saltar a la pàgina 47 / total | Sí / fàcil amb COUNT |
No, només seqüencial / car o inexistent |
| Cost a la base de dades | OFFSET 10000 llegeix i descarta 10 000 files |
Índex + WHERE id > cursor: constant |
| Dades que canvien | Es salten i es repeteixen elements | Estable |
| Comprensió del client | Immediata | Requereix explicació |
L'argument decisiu és el de les dades canviants: si Lucía mira la pàgina 1 del catàleg ordenat per vendes, es venen entrades i després demana la pàgina 2, amb offset veurà elements repetits i se'n saltarà d'altres, perquè l'ordre ha canviat sota els seus peus. Amb cursor això no passa, perquè codifica una posició estable dins l'ordre.
'use strict';
// El cursor codifica els camps d'ordenacio, no un numero de fila.
const codificarCursor = ({ dataInici, id }) =>
Buffer.from(JSON.stringify({ dataInici, id })).toString('base64url');
// Un cursor invalid es respon amb 400 (retorna undefined).
const descodificarCursor = (cursor) => {
if (!cursor) return null;
try { return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); }
catch { return undefined; }
};
// El desempat per id es obligatori: sense ell, dos esdeveniments amb la
// mateixa data poden apareixer dues vegades o desapareixer entre pagines.
const construirClausula = (cursor) => (!cursor ? {} : {
where: { [Op.or]: [
{ dataInici: { [Op.gt]: cursor.dataInici } },
{ dataInici: cursor.dataInici, id: { [Op.gt]: cursor.id } },
] },
});
module.exports = { codificarCursor, descodificarCursor, construirClausula };Recomanació pràctica: cursor per al catàleg públic (gran, canviant, navegació seqüencial) i offset per al panell d'administració (on l'organitzador de la Sala Bóveda sí que vol saltar a la pàgina 7 i veure el total). I en tots dos casos, límit màxim imposat pel servidor: limit=100000 no pot ser una manera de tombar l'API. Amb la mateixa coherència es defineix la gramàtica de consulta, un sol criteri per a tota l'API:
| Necessitat | Convenció | Exemple |
|---|---|---|
| Filtre d'igualtat | camp=valor |
?salaId=org-ribera |
| Filtre múltiple (OR) | Llista separada per comes | ?estat=publicat,exhaurit |
| Rang | Sufixos _des / _fins |
?dataInici_des=2026-04-01 |
| Cerca de text | q |
?q=jazz |
| Ordenació | ordre, amb - per a descendent |
?ordre=-dataInici,titol |
| Camps i relacions | camps, incloure |
?camps=id,titol&incloure=sessions |
Tot això es valida amb zod (M6), amb llista blanca de camps ordenables i expandibles: sense ella, ?ordre=columnaSecreta és una fuita d'informació i ?incloure=tot és una denegació de servei. La selecció de camps, a més, és una optimització real, perquè redueix el JSON serialitzat que la lliçó 10-04 va identificar com un consumidor de CPU important.
- Versionat i política de deprecació
Tres formes, amb els seus compromisos:
| Estratègia | Exemple | A favor | En contra |
|---|---|---|---|
| A la ruta | /api/v1/esdeveniments |
Visible, trivial d'enrutar i desar a la memòria cau | "Poc RESTful": el recurs no canvia d'identitat en canviar de versió |
| A la capçalera | X-API-Version: 2 |
URL estables | Invisible; exigeix Vary; s'oblida en depurar |
| Per tipus de mitjà | Accept: application/vnd.escenaviva.v2+json |
El més fidel a REST | Verbós; mal suportat per eines i memòries cau |
Recomanació: a la ruta. És la que fan servir Stripe, GitHub i pràcticament tothom, per una raó pragmàtica: és l'única que un desenvolupador entén sense llegir documentació, i l'única que funciona bé amb proxies i memòries cau. I un advertiment: versionar és car, perquè cada versió viva és codi que cal mantenir i provar. Canvia dins de v1 sempre que el canvi sigui additiu (camps opcionals nous, punts d'entrada nous) i reserva v2 per a ruptures reals: treure un camp, canviar un tipus, canviar el significat d'alguna cosa. Quan toqui deprecar, hi ha capçaleres estàndard:
HTTP/1.1 200 OK
Deprecation: Sun, 01 Nov 2026 00:00:00 GMT
Sunset: Sun, 01 May 2027 00:00:00 GMT
Link: <https://escenaviva.test/docs/migracio-v2>; rel="deprecation"
Warning: 299 - "GET /api/v1/esdeveniments es obsolet. Migra a /api/v2/esdeveniments abans del 2027-05-01."Deprecation indica des de quan és obsolet; Sunset (RFC 8594) quan deixarà de funcionar. Un calendari raonable: anunci, sis mesos de convivència amb avisos, un "dia d'apagada" de prova (unes hores de respostes 410 perquè els clients ressagats se n'assabentin), i retirada. I una cosa que s'oblida: mesura l'ús de cada versió per client, perquè sense aquesta dada apagar v1 és un salt al buit.
- Memòria cau HTTP i peticions condicionals
Al mòdul 4 vam implementar ETag i 304 a mà sobre node:http; ara ho apliquem amb criteri i ho combinem amb la memòria cau de Redis de la lliçó 10-03.
'use strict';
const crypto = require('node:crypto');
// El cataleg es public i igual per a tothom: es pot desar a la memoria
// cau de proxies intermedis a mes de la del navegador.
const crearControladorCataleg = ({ cataleg }) =>
async function llistarEsdeveniments(peticio, resposta, seguent) {
try {
const { dades } = await cataleg.obtenirCataleg(peticio.dadesValidades.query);
const cos = JSON.stringify(dades);
const etiqueta = `"${crypto.createHash('sha1').update(cos).digest('base64url')}"`;
resposta.set('ETag', etiqueta);
// max-age: frescor al client. s-maxage: als proxies.
// stale-while-revalidate: serveix copia caducada mentre refresca.
resposta.set('Cache-Control', 'public, max-age=30, s-maxage=60, stale-while-revalidate=120');
// Sense cos: zero bytes transferits.
if (peticio.get('If-None-Match') === etiqueta) return resposta.status(304).end();
return resposta.type('application/json').send(cos);
} catch (error) {
return seguent(error);
}
};
module.exports = { crearControladorCataleg };Tenim ara tres capes de memòria cau encadenades: Cache-Control al navegador (la petició ni tan sols surt), ETag/304 al servidor (la petició surt però no es transfereix el cos) i Redis (10-03), que evita recalcular i consultar PostgreSQL.
Regla de seguretat crítica: les dades privades porten Cache-Control: private, no-store. Un public a la resposta de GET /api/v1/comandes/ped-77 permetria que un proxy compartit desés les comandes de Lucía i les servís a un altre usuari. I sempre Vary: Authorization a les respostes que depenen de l'usuari. Peticions condicionals per evitar l'actualització perduda. Escenari: dos organitzadors de l'Auditorio Ribera editen evt-003 alhora. Tots dos llegeixen la versió actual, tots dos escriuen; el segon trepitja els canvis del primer sense que ningú se n'assabenti. És l'actualització perduda (lost update), i HTTP la resol amb control de concurrència optimista:
GET /api/v1/esdeveniments/evt-003 → 200 OK, ETag: "v7-a3f2c1"
PATCH /api/v1/esdeveniments/evt-003
If-Match: "v7-a3f2c1"
Content-Type: application/merge-patch+json
{ "aforamentTotal": 520 }
→ 412 si algu ja l'ha canviat a "v8-..."Un middleware crearExigirIfMatch({ obtenirEtiqueta }) ho implementa en tres comprovacions: si falta la capçalera If-Match, respon 428 (Precondition Required), que li diu al client que la seva petició està ben formada però que l'API exigeix una condició; si el recurs no existeix, 404; i si l'etiqueta rebuda no coincideix amb l'actual (i no és *), llança ErrorDApi('CONFLICTE_DE_VERSIO', 412).
- Documentació amb OpenAPI
Una API sense documentació no es pot fer servir; una API amb documentació escrita a mà menteix, perquè ningú no l'actualitza quan canvia el codi. La solució és que el contracte i el codi comparteixin una única font, i a Escena Viva ja tenim aquesta font: els esquemes zod de src/esquemes/ que validen cada petició. Amb zod-to-openapi es converteixen en el document OpenAPI, amb la garantia que el que està documentat és exactament el que es valida.
'use strict';
const { OpenApiGeneratorV31, extendZodWithOpenApi } = require('@asteasolutions/zod-to-openapi');
const { z } = require('zod');
const swaggerUi = require('swagger-ui-express');
const { registreDEsquemes } = require('../esquemes/registre.js');
extendZodWithOpenApi(z);
const generarDocument = () =>
new OpenApiGeneratorV31(registreDEsquemes.definitions).generateDocument({
openapi: '3.1.0',
info: { title: "API d'Escena Viva", version: '1.4.0' },
servers: [{ url: 'https://api.escenaviva.test/api/v1' }],
});
// Es serveix al costat de l'API: la documentacio viatja amb el codi.
function muntarDocumentacio(aplicacio) {
const document = generarDocument();
aplicacio.get('/api/v1/openapi.json', (peticio, resposta) => resposta.json(document));
aplicacio.use('/api/v1/docs', swaggerUi.serve, swaggerUi.setup(document));
}
module.exports = { generarDocument, muntarDocumentacio };Així el contracte esdevé verificable en comptes d'una promesa: a les proves d'integració del mòdul 9 es pot validar cada resposta contra l'esquema OpenAPI, de manera que una resposta que es desvia del contracte trenca la construcció. Aquesta és la diferència entre documentació i contracte.
- Redisseny de l'API d'Escena Viva
| Abans | Després | Decisió |
|---|---|---|
GET /api/esdeveniments |
GET /api/v1/esdeveniments?cursor=&limit=20 |
Versió a la ruta; paginació per cursor obligatòria |
GET /api/esdeveniment/:id |
GET /api/v1/esdeveniments/:id |
Plural coherent a tota l'API |
GET /api/esdeveniments/:id/getSessions |
GET /api/v1/esdeveniments/:id/sessions |
Sense verbs; jerarquia per pertinença real |
| — | GET /api/v1/sessions/:id |
La sessió té identitat pròpia: accés directe |
POST /api/comprar amb 200 |
POST /api/v1/comandes + Idempotency-Key → 201 + Location |
Recurs, no acció; el codi expressa la creació; sense cobraments duplicats |
POST /api/comandes/:id/anular |
POST /api/v1/comandes/:id/anullacio |
L'anul·lació és una entitat consultable |
POST /api/comandes/:id/generarPdf |
POST /api/v1/comandes/:id/entrades → 202, i GET /api/v1/treballs/:id |
Treball encuat (10-03) amb recurs d'estat |
POST /api/esdeveniments/:id/publicar |
PATCH /api/v1/esdeveniments/:id (merge-patch) |
Transició simple sense dades pròpies |
GET /api/esdeveniments?tots=true |
GET /api/v1/esdeveniments?estat=esborrany,publicat |
Gramàtica de filtres coherent |
200 amb { error: ... } |
4xx/5xx reals + application/problem+json |
L'estat forma part del missatge |
Sense ETag al detall |
ETag + If-None-Match + If-Match |
Estalvi de banda i control de concurrència optimista |
| Sense documentació | GET /api/v1/openapi.json i /api/v1/docs |
Contracte generat des dels esquemes zod |
Errors Comuns i Consells
- Verbs a les URL (
/obtenirEsdeveniments,/comprarEntrades): el verb va al mètode HTTP. - 200 amb un error a dins. Trenca proxies, clients, reintents i monitoratge.
GETque modifica, o llistats sense límit màxim: en el primer cas un precarregador pot esborrar dades per tu, en el segon?limit=1000000és una denegació de servei amb paràmetres.- Confondre 401 amb 403. 401 = no sé qui ets. 403 = sé qui ets i no pots.
Cache-Control: publicen dades privades. Un proxy compartit pot servir les comandes de Lucía a un altre usuari.- Versionar per costum, o escriure la documentació a mà: la primera és manteniment evitable, la segona es desincronitza en setmanes.
- Consell: als
POSTque cobren, exigeixIdempotency-Keyi respon 400 si falta. És més segur que fer-la opcional. - Consell: afegeix una prova d'integració per cada codi d'estat documentat. Si documentes un 409, demostra'l.
Exercicis
Exercici 1: idempotència sota reintent
Aplica el middleware d'idempotència a POST /api/v1/comandes. Escriu una prova d'integració (M9) que enviï la mateixa petició dues vegades amb la mateixa Idempotency-Key i verifiqui: una sola comanda creada, mateixa resposta i Idempotent-Replay: true a la segona. Afegeix un cas amb la mateixa clau i cos diferent.
Exercici 2: paginació per cursor estable
Implementa GET /api/v1/esdeveniments amb cursor sobre (dataInici, id). Prova: demana la pàgina 1 amb limit=2, insereix un esdeveniment nou amb data anterior, demana la pàgina 2 i comprova que no es repeteix ni es perd cap element. Repeteix-ho amb offset i compara.
Exercici 3: evitar l'actualització perduda
Afegeix ETag a GET /api/v1/esdeveniments/:id i exigeix If-Match al PATCH. Simula dos organitzadors editant evt-003: tots dos llegeixen, el primer escriu amb èxit, el segon escriu amb l'etiqueta antiga. Verifica el 412 i dissenya la resposta d'error perquè el client sàpiga què ha de fer.
Solucions
Exercici 1. La primera petició retorna 201 amb Location: /api/v1/comandes/ped-77. La segona retorna exactament el mateix cos i el mateix 201, amb Idempotent-Replay: true, i Comanda.count() continua sent 1. El punt subtil és que la resposta repetida és 201, no 200: es repeteix la resposta original tal qual, perquè el client no ha de distingir un reintent d'un primer intent. Amb la mateixa Idempotency-Key i un cos diferent, l'empremta no coincideix i es retorna 422 amb CLAU_IDEMPOTENCIA_REUTILITZADA: és un error del client, no una compra nova. Per a la carrera de peticions simultànies, SET NX garanteix que només una guanya i l'altra rep 409 amb Retry-After: 2.
Exercici 2. Amb cursor, la pàgina 2 continua exactament on va acabar la 1: l'esdeveniment nou amb data anterior no apareix (queda "darrere" del cursor) i cap element no es duplica. Amb offset, l'esdeveniment nou desplaça tot cap endavant i l'últim element de la pàgina 1 reapareix com a primer de la pàgina 2. És una fallada silenciosa que al catàleg del Festival de Jazz significaria mostrar la mateixa sessió dues vegades i amagar-ne una altra. Detall imprescindible: el cursor ha d'incloure el desempat per id; amb només dataInici, dos esdeveniments de la mateixa data provoquen el mateix problema que volies evitar.
Exercici 3. El primer PATCH amb If-Match: "v7-a3f2c1" coincideix i retorna 200 amb un ETag nou. El segon, amb l'etiqueta antiga, rep 412. L'error ha de ser accionable:
{
"error": {
"codi": "CONFLICTE_DE_VERSIO",
"missatge": "L'esdeveniment ha estat modificat per un altre usuari des de la teva ultima lectura.",
"estat": 412,
"detalls": [{ "camp": "If-Match", "detall": "Torna a llegir-lo i reintenta." }]
}
}Sense If-Match, el segon organitzador hauria trepitjat el canvi del primer: l'aforament d'evt-003 quedaria en el valor equivocat i ningú no ho sabria fins a la nit de l'estrena. Nota d'implementació: l'etiqueta ha de derivar de la versió del recurs (un camp versio incrementat a cada escriptura, o updatedAt), no del cos serialitzat, perquè el cos pot variar per selecció de camps o format.
Conclusió
L'API d'Escena Viva ha passat de "funciona" a "està ben dissenyada", i cada decisió té el seu motiu. Sabem què exigeix REST de debò —i que la restricció sense estat és exactament el que fa possible l'escalat de la lliçó 10-01—, on ens situa el model de Richardson i per què el nivell 2 sòlid amb enllaços selectius és l'objectiu realista. Modelem recursos amb substantius plurals i jerarquies justificades, i les accions que no són CRUD com a subrecursos amb entitat pròpia. Coneixem les garanties de cada mètode HTTP, la diferència entre PUT i PATCH amb merge-patch, i hem tancat per fi el dubte del mòdul 4 amb la clau d'idempotència, que impedeix que Marc pagui dues vegades quan perd la cobertura. Fem servir els codis d'estat amb criteri (201 amb Location, 202 per al treball encuat, 409 per a l'aforament, 422 per a la validació) i hem bandejat el 200 amb un error a dins. Convergim cap a problem+json sense trencar els nostres clients. Paginem per cursor on les dades canvien, amb una gramàtica de consulta coherent i límits imposats pel servidor. Versionem a la ruta amb una política de deprecació amb dates. Encadenem tres capes de memòria cau i evitem l'actualització perduda amb If-Match i 412. I generem la documentació des dels esquemes zod, perquè el contracte no pugui mentir.
Amb tot això, queda una pregunta que cap d'aquestes millores no respon: la pantalla d'un esdeveniment a l'aplicació d'Escena Viva continua necessitant tres crides (l'esdeveniment, les seves sessions, la sala), o una sola resposta amb molts camps que aquell client no fa servir. El disseny REST no elimina aquest dilema, només l'administra. A la lliçó següent, GraphQL amb Node.js, veurem una alternativa on el client demana exactament el que necessita, quin preu es paga per això —memòria cau, complexitat, límits de consulta— i per què la resposta correcta gairebé mai no és substituir REST, sinó fer que convisquin.
Curs de Node.js: De Principiant a Avançat
Mòdul 1: Introducció a Node.js
- Què és Node.js?
- Instal·lació i Configuració de l'Entorn
- El Teu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Modern per a Node.js
- El Projecte del Curs: la Plataforma Escena Viva
Mòdul 2: Conceptes Bàsics
- Arquitectura de Node.js
- El Bucle d'Esdeveniments (Event Loop)
- Callbacks i Programació Asíncrona
- Promeses i async/await
- Esdeveniments i EventEmitter
- Mòduls CommonJS i require()
- Mòduls ES i Interoperabilitat
Mòdul 3: Sistema de Fitxers i E/S
- Lectura i Escriptura de Fitxers
- El Mòdul fs a Fons
- Rutes Multiplataforma amb el Mòdul path
- Treballant amb Streams
- Streams de Transformació i pipeline
- Buffers i Dades Binàries
Mòdul 4: HTTP i Servidors Web
- Creant un Servidor HTTP Simple
- Gestió de Sol·licituds i Respostes
- Enrutament Manual
- Servint Fitxers Estàtics
- Rebent Dades: Cossos de Petició i JSON
- Consumint APIs Externes des de Node.js
Mòdul 5: NPM i Gestió de Paquets
- Introducció a NPM i package.json
- Instal·lació i Ús de Paquets
- Versionat Semàntic i package-lock
- Scripts d'npm i Automatització del Projecte
- Creació i Publicació de Paquets
- Seguretat i Manteniment de Dependències
Mòdul 6: Framework Express.js
- Introducció a Express.js
- Configuració d'una Aplicació Express
- Enrutament a Express
- Middleware
- Middleware de Tercers Essencials
- Validació de Dades d'Entrada
- Gestió d'Errors
Mòdul 7: Bases de Dades i ORMs
- Introducció a les Bases de Dades
- Usant MongoDB amb Mongoose
- Operacions CRUD
- Relacions, Poblat i Consultes Avançades
- Usant Bases de Dades SQL amb Sequelize
- Migracions, Transaccions i Dades de Prova
Mòdul 8: Autenticació i Autorització
- Introducció a l'Autenticació
- Registre d'Usuaris i Hash de Contrasenyes
- Sessions i Galetes amb Passport.js
- Autenticació amb JWT
- Control d'Accés Basat en Rols
- Bones Pràctiques de Seguretat en APIs
Mòdul 9: Proves i Depuració
- Introducció a les Proves
- Proves Unitàries amb Mocha i Chai
- Dobles de Prova amb Sinon
- Proves d'Integració
- Cobertura i Automatització de les Proves
- Depuració d'Aplicacions Node.js
Mòdul 10: Temes Avançats
- El Mòdul Cluster
- Fils de Treball (Worker Threads)
- Memòria Cau i Cues de Treball amb Redis
- Optimització del Rendiment
- Construcció d'APIs RESTful
- GraphQL amb Node.js
Mòdul 11: Desplegament i DevOps
- Configuració i Variables d'Entorn
- Registre i Monitoratge en Producció
- Usant PM2 per a la Gestió de Processos
- Empaquetatge amb Docker
- Desplegant a Heroku i Altres PaaS
- Integració i Desplegament Continus
