Fem inventari del deute acumulat. A controladors/cafes.js hi ha un res.status(404).json({error: {...}}) repetit tres vegades; a controladors/comandes.js, un altre de gairebé idèntic; el middleware de validació construeix el seu 400; el d'autenticació, dues variants de 401 i un 403; el servei d'autenticació retorna {error: 'email_ja_registrat'} i el de comandes {noTrobat: true}, dos convenis diferents per al mateix; i des que bcrypt va portar controladors async, una excepció inesperada deixa la petició penjada sense resposta. Avui tot això se substitueix per dues peces: una classe d'error de domini, ErrorApi, que els serveis llancen sense saber que existeix HTTP, i un únic middleware d'errors que la tradueix al format del contracte. En acabar, cap fitxer fora de src/middleware/errors.js no construirà una resposta d'error.
Contingut
- Per què centralitzar
- La classe
ErrorApi - Les fàbriques d'errors del catàleg
- Serveis que llancen en comptes de retornar
- L'embolcall
asincron(fn) - El middleware d'errors d'Express
ErrorApidavant d'error inesperat- El
tracaIdi la seva correlació amb els logs - Traduir el
ZodError - Traduir els errors de SQLite
- Traduir els errors d'
express.json() - El 404 de rutes i el 405 amb
Allow - L'ordre definitiu de
src/app.js - Què no exposar mai en un error
- Errors no capturats del procés i apagada ordenada
- Taula de referència: situació → excepció → resposta
problem+jsoni per què mantenim el format propi
- Per què centralitzar
Els quatre problemes de l'enfocament dispers, per ordre de gravetat:
| Problema | Conseqüència |
|---|---|
| Format inconsistent | Un 404 amb detalls i un altre sense; els clients han de programar defensivament |
| Canvis impossibles | Afegir tracaId a tots els 5xx obliga a tocar vint fitxers |
| Fuites accidentals | Un res.json({ error: err.message }) publica un camí del sistema o una consulta SQL |
| Capes contaminades | El servei decideix codis HTTP i deixa de ser reutilitzable fora de l'API |
La solució té dues meitats que cal entendre juntes:
- Els serveis llancen errors de domini. Diuen què ha passat (
el cafè no existeix), no com es respon. No esmenten codis HTTP. - Un middleware tradueix. És l'únic que coneix el format de la resposta d'error, i per tant l'únic que pot garantir que sempre és el mateix.
graph TD S[Servei: throw ErrorApi] --> C[Controlador] C -->|no captura| E[Middleware d'errors] V[Zod: ZodError] --> E B[SQLite: SqliteError] --> E J[express.json: SyntaxError] --> E X[Bug inesperat: TypeError] --> E E --> R[Resposta única del contracte]
- La classe
ErrorApi
ErrorApi// src/errors/error-api.js
/**
* Error de domini de l'API.
*
* Porta la informació necessària per construir la resposta, però es llança
* des dels serveis sense que aquests sàpiguen res d'Express: 'estat' és un
* número, no una crida a res.status().
*/
export class ErrorApi extends Error {
/**
* @param {number} estat Codi HTTP (404, 409, ...)
* @param {string} codi Codi del catàleg de 02-04 ('cafe_no_trobat')
* @param {string} missatge Missatge llegible per al consumidor
* @param {object[]} detalls Llista de problemes concrets; SEMPRE present
*/
constructor(estat, codi, missatge, detalls = []) {
super(missatge);
// Sense això, error.name seria 'Error' i els logs perdrien informació.
this.name = 'ErrorApi';
this.estat = estat;
this.codi = codi;
this.detalls = detalls;
// Marca explícita: el middleware la fa servir per distingir un error
// previst del catàleg d'una fallada inesperada del programa.
this.esErrorApi = true;
// Retalla la traça perquè comenci on es va llançar, no al constructor.
Error.captureStackTrace?.(this, ErrorApi);
}
}Dues decisions. Estendre Error conserva la traça de la pila i fa que la classe funcioni amb throw, try/catch i les eines de depuració. I la marca esErrorApi en comptes de fiar-se només d'instanceof: si per qualsevol motiu hi hagués dues còpies del mòdul carregades —cosa que passa amb certes configuracions de proves o amb dependències duplicades—, instanceof fallaria mentre que la propietat continua allà.
- Les fàbriques d'errors del catàleg
Escriure new ErrorApi(404, 'cafe_no_trobat', '...') a vint llocs reintrodueix el problema que volem resoldre: res no garanteix que el codi i l'estat lliguin. Les fàbriques ho garanteixen.
// src/errors/error-api.js (continuació)
export const errors = {
// --- 400 ---
dadesInvalides(detalls = []) {
return new ErrorApi(
400,
'dades_invalides',
'El cos de la petició conté errors de validació.',
detalls
);
},
parametreInvalid(detalls = []) {
return new ErrorApi(
400,
'parametre_invalid',
'Els paràmetres de la petició contenen errors.',
detalls
);
},
// --- 401 ---
noAutenticat(missatge = "Aquesta operació requereix autenticació.") {
return new ErrorApi(401, 'no_autenticat', missatge);
},
tokenCaducat() {
return new ErrorApi(401, 'token_caducat', 'El token ha caducat. Torna a iniciar sessió.');
},
// --- 403 ---
permisDenegat(rolsPermesos = []) {
const detall =
rolsPermesos.length > 0
? ` Es requereix un d'aquests rols: ${rolsPermesos.join(', ')}.`
: '';
return new ErrorApi(403, 'permisos_insuficients', `No tens permís.${detall}`);
},
// --- 404 ---
noTrobat(tipus, id) {
// Mapa entitat → codi del catàleg. Un tipus nou s'afegeix aquí.
const codis = {
cafe: 'cafe_no_trobat',
client: 'client_no_trobat',
comanda: 'comanda_no_trobada',
ressenya: 'ressenya_no_trobada',
cistella: 'cistella_no_trobada',
};
return new ErrorApi(
404,
codis[tipus] ?? 'ruta_no_trobada',
`No existeix cap ${tipus} amb l'identificador '${id}'.`
);
},
rutaNoTrobada(metode, url) {
return new ErrorApi(404, 'ruta_no_trobada', `No existeix el recurs ${metode} ${url}.`);
},
// --- 405 / 409 / 413 / 415 ---
metodeNoPermes(metode, ruta) {
return new ErrorApi(405, 'metode_no_permes', `${metode} no està permès sobre ${ruta}.`);
},
conflicte(codi, missatge, detalls = []) {
return new ErrorApi(409, codi, missatge, detalls);
},
cosMassaGran() {
return new ErrorApi(413, 'cos_massa_gran', 'El cos supera la mida màxima (100 kB).');
},
formatNoSuportat(tipus) {
return new ErrorApi(
415,
'format_no_suportat',
`El tipus de contingut '${tipus}' no s'admet en aquesta operació.`
);
},
// --- 500 ---
errorIntern() {
return new ErrorApi(500, 'error_intern', "S'ha produït un error inesperat.");
},
};Fixa't en conflicte(codi, ...): els 409 comparteixen estat però no codi —estoc_insuficient, comanda_ja_pagada, conflicte_versio, cistella_buida—, així que la fàbrica rep el codi i garanteix només el 409. L'important és que el catàleg de 02-04 i aquest fitxer són la mateixa cosa; si un codi no és aquí, no existeix.
- Serveis que llancen en comptes de retornar
Ara els serveis es netegen. Abans:
// ABANS: convenis ad hoc que el controlador havia de conèixer
obtenir(id) {
return repositoriCafes.buscarPerId(id); // undefined si no existeix
},Després:
// src/serveis/cafes.js (modificat)
import { errors } from '../errors/error-api.js';
export const serveiCafes = {
/** Retorna un cafè. Llança si no existeix. */
obtenir(id) {
const cafe = repositoriCafes.buscarPerId(id);
if (!cafe) throw errors.noTrobat('cafe', id);
return cafe;
},
reemplacar(id, dades) {
this.obtenir(id); // llança 404 si no existeix: sense duplicar el missatge
return repositoriCafes.actualitzar(id, { /* ...camps... */ });
},
esborrar(id) {
if (!repositoriCafes.esborrar(id)) throw errors.noTrobat('cafe', id);
},
};I el servei de comandes, amb els seus errors de negoci:
// src/serveis/comandes.js (modificat)
import { errors } from '../errors/error-api.js';
import { crearComandaAtomica } from '../repositoris/comandes-sqlite.js';
export const serveiComandes = {
obtenirPer(id, usuari) {
const comanda = repositoriComandes.buscarPerId(id);
const esPersonal = ['empleat', 'administrador'].includes(usuari.rol);
// Comanda inexistent i comanda aliena donen EXACTAMENT el mateix error,
// per no filtrar-ne l'existència (03-06, secció 14).
if (!comanda || (comanda.clientId !== usuari.id && !esPersonal)) {
throw errors.noTrobat('comanda', id);
}
return comanda;
},
crear({ clientId, linies }) {
try {
const id = crearComandaAtomica({ clientId, linies });
return repositoriComandes.buscarPerId(id);
} catch (error) {
// El repositori llança errors amb 'codiDomini' (03-05); aquí es
// converteixen en ErrorApi. La traducció viu al servei perquè
// és ell qui decideix que un estoc insuficient és un 409.
if (error.codiDomini === 'estoc_insuficient') {
throw errors.conflicte('estoc_insuficient', error.message);
}
if (error.codiDomini === 'cafe_no_trobat') {
throw errors.noTrobat('cafe', "d'alguna línia de la comanda");
}
throw error; // no és nostre: que el middleware el tracti com un 500
}
},
pagar(id, usuari) {
const comanda = this.obtenirPer(id, usuari);
if (comanda.estat === 'pagat') {
throw errors.conflicte('comanda_ja_pagada', `La comanda '${id}' ja està pagada.`);
}
// ...marcar com a pagada...
},
};Aquell darrer throw error és important: el que no sabem traduir, es propaga. Empassar-se un error desconegut per "no trencar res" és la manera més eficaç que una fallada greu passi desapercebuda.
I el controlador queda reduït al seu paper real:
// src/controladors/cafes.js (modificat)
obtenir(req, res) {
const cafe = serveiCafes.obtenir(req.params.id); // si falla, llança
res.status(200).json(projectar(cafeARepresentacio(cafe), req.query.camps));
},Una línia de feina i una de resposta. No hi ha if (!cafe), no hi ha 404 escrit a mà, no hi ha missatge duplicat.
- L'embolcall
asincron(fn)
asincron(fn)Amb controladors síncrons, Express 4 captura el throw i el porta al middleware d'errors. Amb controladors async —els de clients i sessions ja ho són, i tots ho serien amb PostgreSQL— no, tal com vam diagnosticar a 03-03: la funció retorna una promesa rebutjada que ningú no observa, i la petició es queda penjada.
// src/middleware/asincron.js
/**
* Embolcalla un gestor perquè qualsevol rebuig de promesa acabi a
* next(error) i, per tant, al middleware d'errors.
*
* Promise.resolve() funciona igual si fn és síncrona (retorna una promesa
* ja resolta) o asíncrona, així que es pot embolcallar TOT sense pensar-hi.
*/
export function asincron(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}Ús a les rutes:
// src/rutes/cafes.js (modificat)
import { asincron } from '../middleware/asincron.js';
rutesCafes.get('/', validar(esquemaConsultaCafes, 'query'), asincron(controladorCafes.llistar));
rutesCafes.get('/:id', validar(esquemaIdCafe, 'params'), asincron(controladorCafes.obtenir));
rutesCafes.post(
'/',
autenticar,
exigirRol('empleat', 'administrador'),
validar(esquemaCrearCafe),
asincron(controladorCafes.crear)
);
// ...i així a totesEmbolcalla tots els gestors, els síncrons inclosos. El cost és nul i elimina la classe de bug més difícil de detectar: el dia que algú converteixi un controlador en async sense recordar-se de l'embolcall, la ruta deixaria de respondre davant de qualsevol error i ningú no ho notaria fins a producció.
Hi ha paquets que fan això (express-async-errors, que apedaça Express en importar-lo) i Express 5 ja ho fa de sèrie: un gestor async que rebutja va directe al middleware d'errors. És la raó més pràctica per migrar. El nostre embolcall és explícit, són quatre línies i no depèn de res.
- El middleware d'errors d'Express
// src/middleware/errors.js
import { ZodError } from 'zod';
import { ErrorApi, errors } from '../errors/error-api.js';
import { entorn } from '../config/entorn.js';
/**
* Middleware d'errors.
*
* LA SIGNATURA DE QUATRE ARGUMENTS ÉS OBLIGATÒRIA. Express distingeix un
* middleware d'errors d'un de normal comptant els paràmetres de la
* funció: amb tres és normal, amb quatre és d'errors. Per això 'next'
* s'ha de declarar encara que no es faci servir, i per això ESLint
* necessitava la regla argsIgnorePattern: '^_' que vam configurar a 03-01.
*/
// eslint-disable-next-line no-unused-vars
export function gestorErrors(err, req, res, next) {
const errorApi = traduir(err);
// --- Registre per a l'equip ---
if (errorApi.estat >= 500) {
// Els 5xx són fallades NOSTRES: es registren senceres, amb traça.
console.error(
JSON.stringify({
nivell: 'error',
tracaId: req.tracaId,
metode: req.method,
ruta: req.originalUrl,
usuari: req.usuari?.id ?? null,
missatge: err.message,
pila: err.stack,
})
);
} else {
// Els 4xx són fallades del client: n'hi ha prou amb una línia informativa.
console.warn(
JSON.stringify({
nivell: 'avis',
tracaId: req.tracaId,
metode: req.method,
ruta: req.originalUrl,
codi: errorApi.codi,
})
);
}
// --- Capçaleres que exigeix el contracte segons el cas ---
if (errorApi.estat === 401) {
res.set('WWW-Authenticate', 'Bearer realm="api.botigaaroma.example"');
}
if (errorApi.estat === 405 && err.metodesPermesos) {
res.set('Allow', err.metodesPermesos.join(', '));
}
if (errorApi.codi === 'format_no_suportat' && req.method === 'PATCH') {
res.set('Accept-Patch', 'application/merge-patch+json');
}
// --- Cos del contracte (02-04) ---
const cos = {
error: {
codi: errorApi.codi,
missatge: errorApi.missatge ?? errorApi.message,
detalls: errorApi.detalls ?? [],
},
};
// tracaId NOMÉS als 5xx, com vam decidir a 02-04.
if (errorApi.estat >= 500) {
cos.error.tracaId = req.tracaId;
// En desenvolupament ajuda veure la causa; en producció, MAI.
if (entorn.nodeEnv !== 'produccio') {
cos.error.depuracio = err.message;
}
}
res.status(errorApi.estat).json(cos);
}I la funció que decideix què és cada error:
// src/middleware/errors.js (continuació)
/** Converteix qualsevol excepció en un ErrorApi del catàleg. */
function traduir(err) {
// 1. Ja és nostre: es fa servir tal qual.
if (err?.esErrorApi) return err;
// 2. Validació de Zod que s'ha escapat del middleware de validació.
if (err instanceof ZodError) {
return errors.dadesInvalides(
err.issues.map((i) => ({
camp: i.path.join('.') || '(cos)',
codi: 'valor_invalid',
missatge: i.message,
}))
);
}
// 3. JSON mal format, detectat per express.json().
if (err instanceof SyntaxError && 'body' in err) {
return new ErrorApi(400, 'dades_invalides', 'El cos no és JSON vàlid.', [
{ camp: '(cos)', codi: 'json_mal_format', missatge: err.message },
]);
}
// 4. Cos massa gran (limit d'express.json).
if (err?.type === 'entity.too.large') return errors.cosMassaGran();
// 5. Errors de SQLite.
const deSqlite = traduirSqlite(err);
if (deSqlite) return deSqlite;
// 6. Qualsevol altra cosa és un bug nostre: 500 sense filtrar res.
return errors.errorIntern();
}
ErrorApi davant d'error inesperat
ErrorApi davant d'error inesperatLa distinció del pas 6 és el cor del middleware:
ErrorApi (previst) |
Error inesperat (bug) | |
|---|---|---|
| Origen | Llançat a propòsit per un servei | TypeError, ReferenceError, fallada de llibreria |
| Estat | El que diu l'error: 400, 404, 409… | Sempre 500 |
codi |
Del catàleg | Sempre error_intern |
| Missatge al client | Específic i útil | Genèric: "S'ha produït un error inesperat" |
tracaId |
No | Sí |
| Log | Avís d'una línia | Error complet amb pila |
| De qui és la culpa? | Del client | Nostra |
Un exemple del segon cas. Si un dia un bug produeix TypeError: Cannot read properties of undefined (reading 'preuCentims'), el client rep:
{
"error": {
"codi": "error_intern",
"missatge": "S'ha produït un error inesperat.",
"detalls": [],
"tracaId": "trz_8f4a1c92"
}
}I als logs del servidor queda el detall complet, amb el fitxer, la línia, l'usuari i la ruta. El consumidor rep el que necessita per demanar ajuda; l'equip, el que necessita per arreglar-ho. Aquesta asimetria és intencionada i és una mesura de seguretat, no només d'estètica.
- El
tracaId i la seva correlació amb els logs
tracaId i la seva correlació amb els logs// src/middleware/traca.js
import { randomBytes } from 'node:crypto';
/**
* Assigna un identificador únic a cada petició.
* Si ve d'un proxy o d'una passarel·la que ja el va generar, es respecta:
* així la traça és contínua al llarg de tot el sistema (04-07).
*/
export function assignarTracaId(req, res, next) {
const heretat = req.get('Aroma-Traca-Id');
req.tracaId = heretat ?? `trz_${randomBytes(4).toString('hex')}`;
// Es retorna sempre, no només als errors: permet al client
// referenciar qualsevol petició en obrir una incidència.
res.set('Aroma-Traca-Id', req.tracaId);
next();
}El flux quan alguna cosa falla és aquest: l'usuari veu tracaId: "trz_8f4a1c92", obre una incidència amb aquell identificador, i l'equip busca aquella cadena als logs i troba la petició exacta, amb el seu mètode, la seva ruta, el seu usuari i la pila de l'error. Sense tracaId, la investigació comença per "a quina hora va ser, més o menys?".
Fixa't en el prefix: Aroma-Traca-Id, no X-Traca-Id, per la decisió de 02-05 i el RFC 6648.
Això és el primer graó de l'observabilitat. Els logs estructurats amb nivells i pino, les mètriques, les traces distribuïdes amb OpenTelemetry i la correlació entre serveis són la lliçó 04-07; aquí n'hi ha prou que cada petició tingui nom i que aquell nom aparegui a les dues puntes.
- Traduir el
ZodError
ZodErrorEl middleware validar de 03-04 construïa la seva resposta. Ara llança i deixa de saber d'HTTP:
// src/middleware/validacio.js (modificat)
import { errors } from '../errors/error-api.js';
export function validar(esquema, origen = 'body') {
return (req, res, next) => {
const resultat = esquema.safeParse(req[origen]);
if (!resultat.success) {
const detalls = aDetalls(resultat.error);
// El middleware d'errors s'encarrega del format i del registre.
return next(
origen === 'body' ? errors.dadesInvalides(detalls) : errors.parametreInvalid(detalls)
);
}
req[origen] = resultat.data;
next();
};
}next(error) amb un argument salta tots els middleware normals i va directe al primer middleware d'errors. És el mecanisme estàndard d'Express per propagar fallades des d'un middleware, i la contrapartida de throw als gestors.
Les funcions aDetalls i traduirCodi es queden on eren: continuen sent responsabilitat de la validació, perquè coneixen l'estructura de Zod. El que canvia és que ja no decideixen el format de la resposta.
El pas 2 de traduir() és una xarxa de seguretat per als ZodError que es llancin fora del middleware —per exemple, un parse() dins d'un servei—. No hauria de passar, i si passa, el resultat continua sent correcte.
- Traduir els errors de SQLite
better-sqlite3 llança errors amb un camp code molt informatiu:
// src/middleware/errors.js (continuació)
/** Errors de SQLite → errors del catàleg. Retorna null si no ho és. */
function traduirSqlite(err) {
const codi = err?.code;
if (typeof codi !== 'string' || !codi.startsWith('SQLITE_')) return null;
switch (codi) {
case 'SQLITE_CONSTRAINT_UNIQUE':
case 'SQLITE_CONSTRAINT_PRIMARYKEY':
// Algú ha intentat duplicar un valor únic (un email, per exemple).
return new ErrorApi(409, 'recurs_duplicat', 'Ja existeix un recurs amb aquell valor únic.');
case 'SQLITE_CONSTRAINT_FOREIGNKEY':
// Es referencia alguna cosa que no existeix: una comanda d'un client inexistent.
return new ErrorApi(
409,
'referencia_invalida',
"L'operació referencia un recurs que no existeix."
);
case 'SQLITE_CONSTRAINT_CHECK':
// Un CHECK de l'esquema (torrefacció invàlida, estoc negatiu). Si arriba
// aquí és que la validació de 03-04 té un forat: es registra
// com a 5xx perquè l'equip ho vegi, encara que la culpa sembli del client.
return new ErrorApi(400, 'dades_invalides', 'Les dades no compleixen una restricció del model.');
case 'SQLITE_BUSY':
return new ErrorApi(503, 'servei_no_disponible', 'La base de dades està ocupada. Reintenta-ho.');
default:
return null; // desconegut: que sigui un 500 amb el seu log complet
}
}Dos advertiments. El primer: el missatge que es retorna no és el de SQLite. L'original diria una cosa com UNIQUE constraint failed: clients.email, revelant el nom de la taula i de la columna; és just el tipus de dada que un atacant fa servir per mapar el teu esquema. El segon: aquesta traducció és una xarxa de seguretat, no la primera línia. L'email duplicat ja es comprova al servei de registre (03-06) i retorna un 409 email_ja_registrat amb un missatge molt més útil. Que a més existeixi la traducció cobreix la cursa entre dos registres simultanis amb el mateix correu, en què la comprovació prèvia pot passar i la base de dades ser l'única que se n'assabenti.
Un apunt per a PostgreSQL: els codis són diferents —23505 per a la unicitat, 23503 per a la clau forana— però l'estructura de la funció seria idèntica.
- Traduir els errors d'
express.json()
express.json()Dos casos freqüents que avui produeixen respostes HTML d'Express:
# JSON mal format: falta una cometa
curl -s -X POST http://localhost:3000/v1/cafes \
-H "Content-Type: application/json" \
-d '{"nom": "Kenya, "origen": "Kenya"}' | jq{
"error": {
"codi": "dades_invalides",
"missatge": "El cos no és JSON vàlid.",
"detalls": [
{
"camp": "(cos)",
"codi": "json_mal_format",
"missatge": "Unexpected token o in JSON at position 20"
}
]
}
}# Cos de 2 MB contra un límit de 100 kB
curl -s -X POST http://localhost:3000/v1/cafes \
-H "Content-Type: application/json" \
--data-binary @gegant.json | jq .error.codiLa comprovació err instanceof SyntaxError && 'body' in err mereix explicació: SyntaxError és una classe estàndard de JavaScript i pot venir de qualsevol lloc, però el paquet body-parser que fa servir Express hi afegeix una propietat body amb el text rebut. Aquella propietat és el que distingeix "el client ha enviat JSON trencat" de "hi ha un eval mal escrit al codi".
Sobre el missatge: exposar "Unexpected token o in JSON at position 20" és acceptable perquè descriu l'entrada del client mateix, no el nostre sistema, i li diu exactament on ha de mirar. És l'excepció que confirma la regla de la secció 14.
- El 404 de rutes i el 405 amb
Allow
AllowEl calaix de sastre de 03-02 ara delega:
// src/middleware/no-trobat.js
import { errors } from '../errors/error-api.js';
/** Cap ruta no ha coincidit: 404 ruta_no_trobada. */
export function gestorNoTrobat(req, res, next) {
next(errors.rutaNoTrobada(req.method, req.originalUrl));
}El 405 és diferent i més subtil: la URI existeix, però no per a aquell mètode. DELETE /v1/cafes (sobre la col·lecció) no és al mapa d'URIs, però GET i POST sí. Retornar 404 seria mentir; el correcte és 405 amb la capçalera Allow, obligatòria segons el RFC 9110.
// src/middleware/no-trobat.js (continuació)
import { ErrorApi } from '../errors/error-api.js';
/**
* Retorna un gestor que respon 405 amb Allow.
* Es registra amb router.all() al final de cada grup de rutes, així que
* només s'assoleix si la URI ha coincidit però cap mètode no ho ha fet.
*/
export function metodeNoPermes(...metodesPermesos) {
return (req, res, next) => {
const error = new ErrorApi(
405,
'metode_no_permes',
`${req.method} no està permès sobre ${req.baseUrl}${req.path}.`
);
// El middleware d'errors llegirà aquesta propietat per posar Allow.
error.metodesPermesos = [...metodesPermesos, 'OPTIONS'];
next(error);
};
}// src/rutes/cafes.js (al final del fitxer, després de totes les rutes)
import { metodeNoPermes } from '../middleware/no-trobat.js';
rutesCafes.all('/', metodeNoPermes('GET', 'POST'));
rutesCafes.all('/:id', metodeNoPermes('GET', 'PUT', 'PATCH', 'DELETE'));HTTP/1.1 405 Method Not Allowed
Allow: GET, POST, OPTIONS
Content-Type: application/json; charset=utf-8
{"error":{"codi":"metode_no_permes","missatge":"DELETE no està permès sobre /v1/cafes.","detalls":[]}}router.all() intercepta qualsevol mètode sobre aquella ruta, i s'ha de declarar després de les rutes específiques: si fos abans, es menjaria també els GET legítims. És la mateixa regla d'ordre de 03-02 aplicada al final de la llista.
- L'ordre definitiu de
src/app.js
src/app.js// src/app.js (versió final del mòdul)
import express from 'express';
import { rutesV1 } from './rutes/index.js';
import { assignarTracaId } from './middleware/traca.js';
import { gestorNoTrobat } from './middleware/no-trobat.js';
import { gestorErrors } from './middleware/errors.js';
export const app = express();
app.disable('x-powered-by');
// --- 1. Identitat de la petició: el PRIMER, perquè tot la pugui fer servir ---
app.use(assignarTracaId);
// --- 2. Registre de peticions (04-07 el substituirà per logs estructurats) ---
app.use((req, res, next) => {
const inici = Date.now();
res.on('finish', () => {
console.log(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - inici} ms)`);
});
next();
});
// --- 3. Analitzadors del cos ---
app.use(
express.json({
limit: '100kb',
type: ['application/json', 'application/merge-patch+json'],
})
);
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
// --- 4. Salut (fora de /v1: no forma part del contracte) ---
app.get('/salut', (req, res) => {
res.status(200).json({ estat: 'ok', versio: '1.0.0', moment: new Date().toISOString() });
});
// --- 5. API versionada ---
app.use('/v1', rutesV1);
// --- 6. Cap ruta no ha coincidit ---
app.use(gestorNoTrobat);
// --- 7. Middleware d'errors: SEMPRE L'ÚLTIM ---
app.use(gestorErrors);Per què el middleware d'errors va l'últim, sense excepcions. Express recorre la cadena en ordre; quan algú crida next(error), busca cap endavant el següent middleware amb quatre arguments. Si el registressis abans de les rutes, un error llançat en una ruta no trobaria cap gestor al davant i cauria al gestor per defecte d'Express: una pàgina HTML amb la pila completa en desenvolupament. Una fallada d'ordre aquí filtra la traça del teu codi a Internet.
- Què no exposar mai en un error
| No exposis | Exemple | Per què |
|---|---|---|
| Traces de pila | at /home/aroma/src/serveis/comandes.js:42 |
Revela camins del sistema, estructura del projecte i el teu nom d'usuari |
| Consultes SQL | SELECT * FROM clients WHERE email = ... |
Mapa de l'esquema servit a l'atacant |
| Noms de taula o columna | UNIQUE constraint failed: clients.email |
Ídem |
| Versions | Express 4.18.2, SQLite 3.45 |
Permet buscar vulnerabilitats conegudes d'aquella versió exacta |
| Missatges interns de llibreries | ECONNREFUSED 10.0.3.14:5432 |
Revela topologia de xarxa i IPs internes |
| Existència de recursos aliens | 403 en comptes de 404 |
Permet enumerar (03-06) |
| Distingir usuari de contrasenya | "Aquell correu no existeix" | Permet enumerar comptes |
La regla operativa és l'asimetria entre les dues audiències:
| Resposta al consumidor | Log per a l'equip | |
|---|---|---|
| Audiència | Qualsevol, inclòs un atacant | Persones amb accés al sistema |
| Contingut | Codi, missatge, detalls, tracaId |
Tot: pila, SQL, usuari, capçaleres |
| Objectiu | Que sàpiga què fer | Que l'equip ho pugui arreglar |
El tracaId és el que fa possible que les dues vistes siguin tan diferents sense perdre'n la connexió.
I un avís sobre el camp depuracio que afegim en desenvolupament: està condicionat a entorn.nodeEnv !== 'produccio'. Que aquella condició estigui ben escrita és crític; si NODE_ENV no està definit en producció, es filtrarien els missatges interns. És un argument més per a la validació de configuració en arrencar que vam muntar a 03-01.
- Errors no capturats del procés i apagada ordenada
El middleware només veu el que passa dins d'una petició. Una fallada en un setTimeout, en un gestor d'esdeveniments o en una promesa solta escapa d'Express i arriba al procés:
// src/servidor.js (versió final del mòdul)
import { app } from './app.js';
import { entorn } from './config/entorn.js';
import { tancarBaseDades } from './config/base-dades.js';
const servidor = app.listen(entorn.port, () => {
console.log(`API de la Botiga Aroma escoltant a ${entorn.baseUrl}/v1`);
});
let tancant = false;
function tancarOrdenadament(motiu, codiSortida = 0) {
if (tancant) return; // dos senyals seguits no han de duplicar el tancament
tancant = true;
console.log(`Tancant per: ${motiu}`);
servidor.close(() => {
tancarBaseDades();
console.log('Servidor i base de dades tancats.');
process.exit(codiSortida);
});
// Xarxa de seguretat: si en 10 s no ha acabat, es força la sortida.
// Sense això, una connexió oberta pot impedir l'apagada per sempre.
setTimeout(() => {
console.error("Tancament forçat després del temps d'espera.");
process.exit(1);
}, 10000).unref();
}
process.on('SIGINT', () => tancarOrdenadament('SIGINT'));
process.on('SIGTERM', () => tancarOrdenadament('SIGTERM'));
/**
* Excepció no capturada: el procés està en un estat DESCONEGUT.
* Es registra i se surt. Intentar continuar és pitjor: pot haver-hi
* transaccions a mitges, fitxers oberts i estat corrupte.
*/
process.on('uncaughtException', (error) => {
console.error(
JSON.stringify({ nivell: 'fatal', tipus: 'uncaughtException', missatge: error.message, pila: error.stack })
);
tancarOrdenadament('uncaughtException', 1);
});
/** Promesa rebutjada sense catch. Des de Node 15, també acaba el procés. */
process.on('unhandledRejection', (rao) => {
console.error(
JSON.stringify({ nivell: 'fatal', tipus: 'unhandledRejection', missatge: String(rao) })
);
tancarOrdenadament('unhandledRejection', 1);
});Per què sortir en comptes de continuar. És contraintuïtiu: sembla més robust "aguantar". No ho és. Una excepció no capturada significa que el codi ha arribat a un punt que ningú no va preveure, i a partir d'aquí no es pot raonar sobre l'estat del procés: pot haver-hi una transacció sense tancar, un fitxer bloquejat o una variable corrupta que produeixi respostes incorrectes —pitjor que cap resposta—. El correcte és registrar-ho tot, tancar ordenadament i deixar que el supervisor aixequi un procés net. Aquell supervisor (systemd, Docker amb restart: always, Kubernetes) és part del desplegament i es tracta a 05-05.
I per això l'apagada ordenada importa tant: entre SIGTERM i la mort del procés, servidor.close() deixa d'acceptar connexions noves però acaba les que són en curs. Sense això, cada desplegament tallaria a mitja resposta els clients que estiguessin esperant.
- Taula de referència: situació → excepció → resposta
| Situació | Es llança | HTTP | codi |
Capçalera extra |
|---|---|---|---|---|
| Cos amb camps invàlids | errors.dadesInvalides(detalls) |
400 | dades_invalides |
— |
| Paràmetre de consulta desconegut o fora de rang | errors.parametreInvalid(detalls) |
400 | parametre_invalid |
— |
| JSON mal format | SyntaxError de body-parser |
400 | dades_invalides |
— |
Falta Authorization |
errors.noAutenticat() |
401 | no_autenticat |
WWW-Authenticate |
| Token caducat | errors.tokenCaducat() |
401 | token_caducat |
WWW-Authenticate |
| Rol insuficient | errors.permisDenegat([...]) |
403 | permisos_insuficients |
— |
| Cafè inexistent | errors.noTrobat('cafe', id) |
404 | cafe_no_trobat |
— |
| Comanda aliena | errors.noTrobat('comanda', id) |
404 | comanda_no_trobada |
— |
| URI inexistent | errors.rutaNoTrobada(...) |
404 | ruta_no_trobada |
— |
| Mètode no admès en aquella URI | errors.metodeNoPermes(...) |
405 | metode_no_permes |
Allow |
| Sense estoc | errors.conflicte('estoc_insuficient', ...) |
409 | estoc_insuficient |
— |
| Segon pagament | errors.conflicte('comanda_ja_pagada', ...) |
409 | comanda_ja_pagada |
— |
| Versió desfasada | errors.conflicte('conflicte_versio', ...) |
409 | conflicte_versio |
— |
| Cos de 2 MB | entity.too.large de body-parser |
413 | cos_massa_gran |
— |
| PATCH amb JSON Patch | errors.formatNoSuportat(tipus) |
415 | format_no_suportat |
Accept-Patch |
| Bug del programa | TypeError i similars |
500 | error_intern |
— (i tracaId al cos) |
| Base de dades ocupada | SQLITE_BUSY |
503 | servei_no_disponible |
Retry-After |
Aquesta taula és el resum executable del catàleg de 02-04 i la referència que es consulta en afegir un endpoint nou. La columna de la dreta és la que més s'oblida.
problem+json i per què mantenim el format propi
problem+json i per què mantenim el format propiA 02-04 vam comparar el nostre format amb l'estàndard RFC 9457 (application/problem+json), que es veuria així:
{
"type": "https://api.botigaaroma.example/errors/estoc-insuficient",
"title": "Estoc insuficient",
"status": 409,
"detail": "Només queden 3 unitats d'‘Etiòpia Yirgacheffe’ i se'n demanen 5.",
"instance": "/v1/comandes"
}problem+json |
Format de la Botiga Aroma | |
|---|---|---|
| Estàndard | Sí, RFC 9457 | No |
| Identificador de màquina | URI a type |
codi en snake_case |
| Llista de fallades de validació | Extensió pròpia | detalls de sèrie |
Content-Type |
application/problem+json |
application/json |
| Suport als clients | Creixent | Requereix llegir la documentació |
La Botiga Aroma manté el seu format, i les raons continuen sent les de 02-04: codi en snake_case és més còmode per a un switch que comparar URIs llargues; detalls com a camp de primera classe encaixa amb la decisió de retornar totes les fallades de validació alhora; i fer servir application/json evita que els clients hagin de negociar un tipus diferent. Ara s'hi afegeix un argument d'implementació: canviar de format seria trivial —es toca únicament src/middleware/errors.js—, i aquesta és precisament la prova que centralitzar va valer la pena. Si demà un soci exigeix problem+json, es pot fins i tot emetre segons l'Accept, amb Vary: Accept, sense tocar ni un servei.
El que no és opcional, facis servir el format que facis servir: codi HTTP correcte, un identificador estable per a màquines, un missatge útil per a persones, i mai filtrar l'interior del sistema.
Errors Comuns i Consells
1. Declarar el middleware d'errors amb tres arguments. Express el tracta com un middleware normal i mai no s'executa. Els quatre paràmetres són obligatoris, encara que next no es faci servir.
2. Registrar-lo abans de les rutes. No captura res i els errors cauen al gestor per defecte d'Express, que en desenvolupament retorna la pila en HTML.
3. Oblidar asincron() en un controlador async. La petició es queda penjada sense resposta ni error visible. Embolcalla tots els gestors.
4. Fer servir throw dins d'un setTimeout o d'un callback. No el captura ni Express ni l'embolcall: acaba a uncaughtException. Dins de callbacks, propaga amb next(error).
5. Retornar err.message sense filtrar. Publica camins, SQL, IPs i versions. Només els ErrorApi porten missatge propi; la resta, missatge genèric.
6. Respondre i a més cridar next(). ERR_HTTP_HEADERS_SENT. Un camí o l'altre, mai tots dos.
7. Empassar-se errors desconeguts. Un catch que retorna null converteix una fallada greu en dades absents. El que no sàpigues traduir, torna'l a llançar.
8. Posar tracaId als 4xx. El contracte el reserva per als 5xx, que són els únics que requereixen investigació per part nostra.
9. No tancar el procés després d'un uncaughtException. L'estat és desconegut; continuar servint peticions pot produir respostes incorrectes, cosa que és pitjor que no respondre.
Consell: provoca un error a propòsit de tant en tant —un throw new Error('prova') temporal en un controlador— i comprova que la resposta és un 500 net amb tracaId i que el log conté la pila completa. És l'única manera de saber que el camí d'error funciona; ningú no el prova fins que el necessita.
Exercicis
Exercici 1
Implementa el middleware exigirClauIdempotencia que el contracte de 02-03 exigeix a POST /v1/comandes i POST /v1/comandes/{id}/pagament: si falta la capçalera Idempotency-Key, ha de produir 400 clau_idempotencia_requerida; si la clau ja es va fer servir amb un cos diferent, 422 clau_idempotencia_reutilitzada. Fes servir ErrorApi i explica per què el segon cas és 422 i no 409.
Exercici 2
Un company escriu aquest controlador i en producció apareixen peticions que no reben mai resposta. Diagnostica'n els tres problemes i escriu la versió correcta.
rutesComandes.post('/', autenticar, async (req, res) => {
try {
const comanda = await serveiComandes.crear(req.body);
res.status(201).json(comandaARepresentacio(comanda));
} catch (error) {
res.status(500).json({ error: error.message });
}
});Exercici 3
Dissenya la resposta completa —codi, capçaleres i cos— per a aquestes quatre situacions, indicant quina fàbrica d'errors la produeix i què es registra al log:
PATCH /v1/cafes/caf_001ambContent-Type: application/json-patch+json.GET /v1/comandes/com_5001amb el token d'un client que no n'és el propietari.POST /v1/cafesamb un token vàlid de rolclient.- Un
TypeErrordins decafeARepresentacioperquè un cafè sembrat ténotes_tastaNULL.
Solucions
Solució 1
// src/middleware/idempotencia.js
import { createHash } from 'node:crypto';
import { ErrorApi, errors } from '../errors/error-api.js';
import { baseDades } from '../config/base-dades.js';
// Taula necessària (migració 003):
// CREATE TABLE claus_idempotencia (
// clau TEXT PRIMARY KEY, empremta TEXT NOT NULL,
// resposta TEXT, data TEXT NOT NULL
// );
const buscarClau = baseDades.prepare('SELECT * FROM claus_idempotencia WHERE clau = ?');
const desarClau = baseDades.prepare(
'INSERT INTO claus_idempotencia (clau, empremta, data) VALUES (?, ?, ?)'
);
export function exigirClauIdempotencia(req, res, next) {
const clau = req.get('Idempotency-Key');
if (!clau) {
return next(
new ErrorApi(
400,
'clau_idempotencia_requerida',
'Aquesta operació requereix la capçalera Idempotency-Key.'
)
);
}
// Empremta del cos: identifica "la mateixa petició".
const empremta = createHash('sha256').update(JSON.stringify(req.body ?? {})).digest('hex');
const registre = buscarClau.get(clau);
if (registre) {
if (registre.empremta !== empremta) {
// Mateixa clau, cos diferent: contradicció del client.
return next(
new ErrorApi(
422,
'clau_idempotencia_reutilitzada',
"Aquesta Idempotency-Key ja es va fer servir amb un cos diferent."
)
);
}
if (registre.resposta) {
// Reintent legítim: es retorna la resposta original.
return res.status(200).json(JSON.parse(registre.resposta));
}
return next(
new ErrorApi(409, 'operacio_en_curs', "Una petició idèntica s'està processant.")
);
}
desarClau.run(clau, empremta, new Date().toISOString());
req.clauIdempotencia = clau;
next();
}rutesComandes.post(
'/',
autenticar,
exigirRol('client', 'empleat', 'administrador'),
exigirClauIdempotencia,
validar(esquemaCrearComanda),
asincron(controladorComandes.crear)
);Per què 422 i no 409: tots dos codis indiquen que la petició no es pot processar, però assenyalen coses diferents. El 409 Conflict diu que la petició era correcta i xoca amb l'estat actual del recurs: no hi ha estoc, la comanda ja estava pagada. El 422 Unprocessable Content diu que la petició està sintàcticament ben formada però és semànticament contradictòria en si mateixa: el client afirma amb la clau "aquesta és la mateixa petició que abans" i alhora envia un cos diferent. No hi ha cap recurs en conflicte; la contradicció és dins de la petició mateixa. Per això el contracte de 02-04 va reservar el 422 exclusivament per a aquest cas, i tota la resta va per 400 o 409.
Solució 2
| # | Problema | Conseqüència |
|---|---|---|
| 1 | Tot error es converteix en 500 |
Un estoc insuficient, que és 409 estoc_insuficient, es reporta com a fallada del servidor. El client ho reintenta creient que és temporal, la monitorització s'omple de falsos 5xx i l'ErrorApi que el servei va llançar amb cura es perd |
| 2 | Es retorna error.message en cru |
Fuita d'informació: pot contenir camins del sistema o missatges de SQLite. A més el format és {error: "text"}, no {error: {codi, missatge, detalls}}, així que trenca el contracte |
| 3 | Sense asincron() i amb un catch que pot fallar |
Si comandaARepresentacio llança després de l'await, l'error passa dins del try i entra al catch… però si el mateix res.json ja ha enviat capçaleres, el catch intentarà respondre un altre cop i llançarà ERR_HTTP_HEADERS_SENT fora de qualsevol captura. Aquella segona excepció no la veu ningú: promesa rebutjada, petició penjada |
Versió correcta:
// src/controladors/comandes.js
crear: async (req, res) => {
const comanda = await serveiComandes.crear({
clientId: req.usuari.id, // del token, MAI del cos (03-06)
linies: req.body.linies,
});
res.set('Location', `/v1/comandes/${comanda.id}`);
res.status(201).json(comandaARepresentacio(comanda));
},// src/rutes/comandes.js
rutesComandes.post(
'/',
autenticar,
exigirClauIdempotencia,
validar(esquemaCrearComanda),
asincron(controladorComandes.crear)
);Sense try/catch. El controlador s'ocupa del camí feliç; asincron() encamina qualsevol rebuig cap a gestorErrors, que ja sap distingir un ErrorApi d'un bug, posar el codi correcte, no filtrar res i registrar el que correspongui. Un try/catch en un controlador només es justifica quan cal traduir un error a un altre de més específic —com fa serveiComandes.crear amb els errors del repositori—, mai per "que no es trenqui".
Solució 3
1. PATCH amb JSON Patch
HTTP/1.1 415 Unsupported Media Type
Accept-Patch: application/merge-patch+json
Aroma-Traca-Id: trz_1a2b3c4d
{"error":{"codi":"format_no_suportat","missatge":"El tipus de contingut 'application/json-patch+json' no s'admet en aquesta operació.","detalls":[]}}Fàbrica: errors.formatNoSuportat(tipus), llançada des del controlador de PATCH. La capçalera Accept-Patch l'afegeix el middleware d'errors en veure codi === 'format_no_suportat' en un PATCH. Log: avís d'una línia, és un error del client.
2. Comanda aliena
HTTP/1.1 404 Not Found
Aroma-Traca-Id: trz_5e6f7a8b
{"error":{"codi":"comanda_no_trobada","missatge":"No existeix cap comanda amb l'identificador 'com_5001'.","detalls":[]}}Fàbrica: errors.noTrobat('comanda', id) des de serveiComandes.obtenirPer. 404 i no 403, i amb exactament el mateix cos que si la comanda no existís, per no confirmar-ne l'existència (03-06). Log: avís; convé registrar usuari i l'id demanat, perquè una ratxa d'aquests és un indici d'enumeració i a 04-07 serà una alerta.
3. POST /v1/cafes amb rol client
HTTP/1.1 403 Forbidden
Aroma-Traca-Id: trz_9c0d1e2f
{"error":{"codi":"permisos_insuficients","missatge":"No tens permís. Es requereix un d'aquests rols: empleat, administrador.","detalls":[]}}Fàbrica: errors.permisDenegat(['empleat', 'administrador']) des d'exigirRol. 403 i no 404, perquè /v1/cafes és públic i la seva existència no és cap secret: aquí ser explícit ajuda l'integrador sense filtrar res. Log: avís.
4. TypeError al mapejador
HTTP/1.1 500 Internal Server Error
Aroma-Traca-Id: trz_3a4b5c6d
{"error":{"codi":"error_intern","missatge":"S'ha produït un error inesperat.","detalls":[],"tracaId":"trz_3a4b5c6d"}}Fàbrica: cap; és el cas per defecte de traduir(), que retorna errors.errorIntern(). El client no veu el TypeError, ni el nom de la funció, ni la línia. Al log queda tot:
{"nivell":"error","tracaId":"trz_3a4b5c6d","metode":"GET","ruta":"/v1/cafes/caf_007","usuari":null,
"missatge":"Cannot read properties of null (reading 'map')",
"pila":"TypeError: ... at cafeARepresentacio (/app/src/serveis/mapejadors.js:31:29) ..."}I el diagnòstic de fons: la causa real és que la columna notes_tast va admetre NULL malgrat estar declarada NOT NULL DEFAULT '[]', probablement per una inserció manual. La correcció duradora no és un ?? [] al mapejador —això és tapar el símptoma—, sinó assegurar la dada al repositori i comprovar per què es va saltar la restricció. El tracaId és el que permet arribar des de la queixa de l'usuari fins a aquella conclusió.
Conclusió
Ja no hi ha respostes d'error escrites a mà repartides pel projecte. Els serveis llancen ErrorApi dient què ha passat amb un codi del catàleg de 02-04, sense esmentar HTTP ni tocar res; les fàbriques garanteixen que estat i codi lliguen sempre; i un únic middleware de quatre arguments, registrat l'últim, tradueix tot el que hi arriba —ErrorApi, ZodError, errors de SQLite, JSON mal format, cossos massa grans i bugs inesperats— al mateix format {"error": {"codi", "missatge", "detalls"}}, amb tracaId només als 5xx, WWW-Authenticate als 401, Allow als 405 i Accept-Patch als 415. L'embolcall asincron() tanca per fi el forat de les promeses rebutjades que arrossegàvem des de 03-03, i el procés sap morir bé: tancament ordenat davant de SIGTERM, registre complet i sortida davant d'uncaughtException.
El més valuós d'aquesta lliçó no és el codi, sinó l'asimetria que estableix: al consumidor se li dona un codi estable, un missatge útil i un identificador amb què reclamar; a l'equip se li dona la pila completa, la consulta, l'usuari i la ruta. Mai a l'inrevés. I com que tot passa per un sol fitxer, demà es pot afegir un camp a tots els errors, emetre problem+json segons l'Accept o enviar els 5xx a un sistema d'alertes canviant un sol lloc.
Amb això, l'API de la Botiga Aroma està completa: rutes, representacions, validació, persistència, autenticació i errors. Falta l'única cosa que converteix "funciona a la meva màquina" en "compleix el contracte": comprovar-ho. A 03-08, Proves i validació, tanquem el mòdul amb la piràmide de proves aplicada a aquesta API: proves unitàries dels serveis i del mapejador de cèntims a euros fent servir el repositori en memòria com a doble —possible gràcies a la separació per capes de 03-03—, proves d'integració amb Supertest sobre l'objecte app sense obrir port —possible gràcies a la separació de 03-02—, verificació de codis, capçaleres Location, Link i Allow i forma exacta del cos, generació de tokens vàlids a les mateixes proves, aïllament amb una base SQLite temporal, cobertura amb el runner natiu, i una llista de verificació del contracte abans de donar l'API per bona.
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
