A la lliçó anterior vam deixar un projecte amb carpetes buides, dependències instal·lades i configuració validada, però incapaç de respondre res. Avui això canvia: en acabar tindràs un servidor Express escoltant a http://localhost:3000 que serveix GET /v1/cafes i GET /v1/cafes/caf_001 amb el format exacte que vam fixar al mòdul 2, un endpoint de salut, i un 404 que ja parla l'idioma del contracte. Pel camí entendrem l'única idea que cal entendre de debò per treballar amb Express —el middleware—, veurem per què separar app.js de servidor.js és una decisió d'arquitectura i no un caprici, i muntarem el Router sota /v1, que és on el versionat a la ruta decidit a 02-07 deixa de ser un diagrama i es converteix en codi.
Contingut
- Què és Express i què no és
- El concepte central: middleware
- El cicle de vida d'una petició
src/app.jsisrc/servidor.js: per què van separats- Primera arrencada i endpoint de salut
- Middleware integrat:
express.jsoniexpress.urlencoded - El
Routerd'Express i el muntatge de/v1 - Dades en memòria:
src/repositoris/cafes-memoria.js - Les primeres rutes de cafès
- Rutes amb paràmetres i
req.params - Enviar respostes:
res.status,res.json,res.set,res.sendStatus - El 404 genèric amb el format d'error del contracte
- Provar-ho tot amb
curl - Registre de peticions:
morgani fins on arribem avui
- Què és Express i què no és
Node inclou un mòdul http amb el qual ja es pot muntar un servidor:
// Servidor amb el mòdul http de Node, sense Express. Només per veure la diferència.
import { createServer } from 'node:http';
const servidor = createServer((req, res) => {
if (req.url === '/v1/cafes' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ dades: [], total: 0 }));
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'no trobat' }));
}
});
servidor.listen(3000);Funciona, i ensenya una cosa important: Express no fa res que tu no poguessis fer. Però aquell if/else creix fins a l'inmanejable tan bon punt hi ha vint rutes, paràmetres de ruta, cossos JSON per analitzar i comportaments comuns a totes les peticions.
Express és una capa fina sobre node:http que aporta exactament quatre coses:
| Aporta | Sense Express | Amb Express |
|---|---|---|
| Encaminament | if (req.url === ...) amb expressions regulars a mà |
app.get('/v1/cafes/:id', gestor) |
| Middleware | Encadenar funcions a mà | Una cadena ordenada amb app.use() |
Utilitats a req |
Analitzar la query i el cos tu mateix | req.query, req.params, req.body |
Utilitats a res |
writeHead + end + JSON.stringify |
res.status(200).json(objecte) |
I és igual d'important saber què no és: Express no és un framework "amb bateries incloses". No porta ORM, ni validació, ni autenticació, ni estructura de carpetes obligatòria. Tot això ho poses tu —i per això el mòdul té vuit lliçons—. A canvi, no hi ha màgia: cada cosa que passa en una petició està escrita en algun fitxer teu. Per aprendre com funciona de debò una API és la millor elecció possible. A 05-03 compararem Express amb Fastify, NestJS i altres.
- El concepte central: middleware
Un middleware és una funció que rep la petició, hi pot fer alguna cosa i decideix si la passa a la funció següent de la cadena. Tota la seva definició cap en una signatura:
function elMeuMiddleware(req, res, next) {
// 1. Pot llegir o modificar req
// 2. Pot llegir o escriure res
// 3. Crida next() per cedir el torn... o respon i acaba
next();
}| Paràmetre | Què és |
|---|---|
req |
Objecte de petició: URL, mètode, capçaleres, paràmetres, cos |
res |
Objecte de resposta: estat, capçaleres, cos a retornar |
next |
Funció que cedeix el control al middleware següent de la cadena |
I tres regles que expliquen el 90 % dels problemes d'un principiant amb Express:
- Els middleware s'executen en l'ordre en què es registren. No hi ha prioritats ni màgia: és una llista, de dalt a baix.
- Si un middleware no crida
next()ni respon, la petició es queda penjada fins que el client se'n cansa. És l'error més freqüent. - Si un middleware respon (
res.json(...)), la cadena acaba aquí. Cridarnext()després provoca el famósERR_HTTP_HEADERS_SENT.
Un exemple amb tres middleware encadenats per veure l'ordre en directe:
app.use((req, res, next) => {
console.log('1: entra la petició');
next(); // cedeix el torn
});
app.use((req, res, next) => {
console.log('2: continuo jo');
req.moment = Date.now(); // puc enriquir req per als següents
next();
});
app.get('/v1/cafes', (req, res) => {
console.log('3: gestor final');
res.json({ dades: [], total: 0 }); // responc: la cadena acaba aquí
});Un gestor de ruta (app.get, app.post) també és un middleware; simplement està condicionat a un mètode i una ruta. Aquesta uniformitat és la clau del disseny d'Express: tot és la mateixa cosa. La validació de 03-04, l'autenticació de 03-06 i la gestió d'errors de 03-07 seran middleware amb aquesta mateixa signatura.
- El cicle de vida d'una petició
Aquest és el recorregut complet que tindrà una petició a la Botiga Aroma al final del mòdul. Avui muntem les caixes grises; les altres arriben a les lliçons indicades.
graph TD
A[Client: curl / SPA] --> B[express.json analitza el cos]
B --> C[Router muntat a /v1]
C --> D{Coincideix alguna ruta?}
D -->|No| E[404 ruta_no_trobada]
D -->|Sí| F[Validació 03-04 i autenticació 03-06]
F --> G[Controlador 03-03]
G --> H[Servei i repositori]
H --> I[res.status.json]
F -->|Error| J[Middleware d'errors 03-07]
E --> J
J --> K[Resposta amb el format del contracte]
El que cal retenir: una petició travessa la cadena de dalt a baix i surt per un de dos llocs, una resposta normal o el middleware d'errors. Res més.
src/app.js i src/servidor.js: per què van separats
src/app.js i src/servidor.js: per què van separatsGairebé tots els tutorials d'Express fiquen en un únic fitxer la creació de l'app i la crida a listen(). Nosaltres no, i el motiu és concret:
| Fitxer | Responsabilitat | Sap de… |
|---|---|---|
src/app.js |
Construir l'aplicació: middleware i rutes | Express |
src/servidor.js |
Arrencar el procés: port, senyals, tancament | Sistema operatiu, xarxa |
L'avantatge apareix a 03-08. Supertest, l'eina amb què provarem l'API, accepta l'objecte app d'Express i li llança peticions sense obrir cap port TCP. Si app.js cridés listen(), cada fitxer de proves ocuparia el port 3000 i dues suites en paral·lel xocarien amb EADDRINUSE. Separant-ho, l'app és un objecte reutilitzable i el port és un detall de desplegament.
Comencem per src/app.js:
// src/app.js
import express from 'express';
// Creem la instància de l'aplicació. Encara no escolta en cap port.
export const app = express();
// Express afegeix per defecte la capçalera 'X-Powered-By: Express', que revela
// la tecnologia del servidor sense aportar res. Es treu sempre (vegeu 04-02).
app.disable('x-powered-by');
// Endpoint de salut: comprova que el procés és viu i respon.
app.get('/salut', (req, res) => {
res.status(200).json({
estat: 'ok',
versio: '1.0.0',
moment: new Date().toISOString(),
});
});I src/servidor.js:
// src/servidor.js
import { app } from './app.js';
import { entorn } from './config/entorn.js';
// listen() obre el sòcol TCP i deixa el procés escoltant.
const servidor = app.listen(entorn.port, () => {
console.log(`API de la Botiga Aroma escoltant a ${entorn.baseUrl}/v1`);
console.log(`Entorn: ${entorn.nodeEnv}`);
});
// Tancament ordenat: quan el sistema demana que parem (Ctrl+C o
// l'orquestrador en desplegar), deixem d'acceptar connexions noves i
// esperem que acabin les peticions en curs abans de sortir.
function tancarOrdenadament(senyal) {
console.log(`\nRebut el senyal ${senyal}. Tancant el servidor...`);
servidor.close(() => {
console.log('Servidor tancat. Fins aviat.');
process.exit(0);
});
}
process.on('SIGINT', () => tancarOrdenadament('SIGINT')); // Ctrl+C
process.on('SIGTERM', () => tancarOrdenadament('SIGTERM')); // docker stop, kubernetesSobre el tancament ordenat: sense ell, Ctrl+C talla de cop i una petició a mig respondre mor sense resposta. Amb servidor.close(), el sòcol deixa d'acceptar clients nous però les peticions vives acaben. A 03-05 hi afegirem el tancament de la base de dades i a 03-07 la captura d'errors no controlats del procés.
Fixa't en la direcció dels import: servidor.js importa app.js, mai a l'inrevés.
- Primera arrencada i endpoint de salut
En un altre terminal:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 68
ETag: W/"44-mQb..."
Date: Sat, 14 Mar 2026 10:30:00 GMT
Connection: keep-alive
{"estat":"ok","versio":"1.0.0","moment":"2026-03-14T10:30:00.000Z"}Val la pena aturar-se en tres detalls d'aquesta resposta:
Content-Type: application/json; charset=utf-8l'ha posatres.json()automàticament. És el que vam declarar al contracte.ETagtambé ve de sèrie. És la base de la memòria cau condicional, que tractarem a 04-06.- No hi ha
X-Powered-By, gràcies aapp.disable.
Per què /salut és fora de /v1? Perquè no és part de l'API pública: no la consumeixen la SPA ni l'Aroma Mòbil, sinó el balancejador de càrrega i la monitoratge. No forma part del contracte versionat, així que no s'ha de versionar. És l'excepció que confirma la regla de 02-07.
- Middleware integrat:
express.json i express.urlencoded
express.json i express.urlencodedQuan arriba un POST amb cos JSON, el cos viatja com un flux de bytes. Sense ajuda, req.body és undefined. express.json() és un middleware que llegeix aquell flux, l'analitza i deixa l'objecte a req.body.
Afegeix-lo a src/app.js, abans de les rutes:
// src/app.js (afegir després d'app.disable)
// Analitza els cossos JSON i els deixa a req.body.
app.use(
express.json({
// Límit de mida: per sobre, respon 413. Sense límit, qualsevol
// pot tombar el procés enviant un cos de 2 GB (04-02).
limit: '100kb',
// Quin Content-Type accepta com a JSON. Hi afegim merge-patch perquè el
// contracte de 02-03 exigeix PATCH amb application/merge-patch+json.
type: ['application/json', 'application/merge-patch+json'],
})
);
// Analitza formularis (Content-Type: application/x-www-form-urlencoded).
// La nostra API és JSON, però ho deixem per si un formulari HTML crida
// un endpoint: millor un 400 clar que un req.body buit i inexplicable.
app.use(express.urlencoded({ extended: false, limit: '10kb' }));| Opció | Què controla | Valor triat |
|---|---|---|
limit |
Mida màxima del cos | 100kb (de sobres per a una comanda de 50 línies) |
type |
Quin Content-Type processa |
JSON i Merge Patch |
strict (per defecte true) |
Només accepta objectes i arrays a l'arrel | Es manté |
extended (a urlencoded) |
Permetre objectes imbricats al formulari | false, no els necessitem |
Dues conseqüències que cal conèixer des d'ara. La primera: si el client envia JSON mal format, express.json() llança un SyntaxError que avui produeix una pàgina HTML d'error lletja; a 03-07 el convertirem en un 400 dades_invalides del contracte. La segona: si el client no envia Content-Type: application/json, el middleware no analitza res i req.body queda com {}; és la causa número u del "el meu POST arriba buit".
- El
Router d'Express i el muntatge de /v1
Router d'Express i el muntatge de /v1Posar totes les rutes a app.js funciona amb dues i és insostenible amb vint-i-quatre. Un Router és una miniaplicació d'Express: té les seves pròpies rutes i els seus propis middleware, i es munta sota un prefix.
Crearem dos fitxers. Primer, el router de cafès (src/rutes/cafes.js), que de moment només declara les seves rutes relatives:
// src/rutes/cafes.js
import { Router } from 'express';
export const rutesCafes = Router();
// Les rutes es declaren RELATIVES al punt de muntatge.
// '/' aquí acabarà sent '/v1/cafes' quan es munti.
rutesCafes.get('/', (req, res) => {
res.json({ dades: [], total: 0 });
});I ara l'agregador (src/rutes/index.js), que aplega tots els routers de la versió 1:
// src/rutes/index.js
import { Router } from 'express';
import { rutesCafes } from './cafes.js';
export const rutesV1 = Router();
// Cada col·lecció del mapa d'URIs de 02-02 es munta sota el seu prefix.
rutesV1.use('/cafes', rutesCafes);
// A mesura que avanci el mòdul s'hi aniran afegint:
// rutesV1.use('/clients', rutesClients); → 03-06
// rutesV1.use('/comandes', rutesComandes); → 03-03
// rutesV1.use('/sessions', rutesSessions); → 03-06I es munta a src/app.js:
// src/app.js (afegir després dels analitzadors)
import { rutesV1 } from './rutes/index.js';
// AQUÍ viu el versionat a la ruta decidit a 02-07: tot el que
// pengi de rutesV1 respon sota /v1 i només sota /v1.
app.use('/v1', rutesV1);El resultat és una composició de prefixos en tres nivells:
| Nivell | Fitxer | Prefix aportat |
|---|---|---|
| Aplicació | app.js |
/v1 |
| Agregador | rutes/index.js |
/cafes |
| Router de recurs | rutes/cafes.js |
/ o /:id |
| Resultat | /v1/cafes, /v1/cafes/:id |
I aquí hi ha el guany real de 02-07 fet codi: el dia que existeixi una v2, es crea src/rutes/v2/ i s'afegeix app.use('/v2', rutesV2). Les dues versions conviuen al mateix procés, compartint serveis on el comportament no canviï i divergint on sí. Sense aquest muntatge, "convivència de versions" seria una frase bonica impossible d'implementar.
- Dades en memòria:
src/repositoris/cafes-memoria.js
src/repositoris/cafes-memoria.jsEncara no tenim base de dades —això és 03-05—, així que els cafès viuran en un array. Però el col·loquem ja a la carpeta de repositoris i darrere d'una interfície, perquè l'objectiu declarat és substituir-lo per SQLite sense que la resta del codi se n'assabenti.
// src/repositoris/cafes-memoria.js
/**
* Magatzem en memòria de cafès.
*
* IMPORTANT: aquest és el MODEL INTERN, no la representació pública.
* Els diners es desen en CÈNTIMS ENTERS (preuCentims), com vam decidir
* a 02-05: 14,50 € són 1450 cèntims. Mai un float per als diners, perquè
* 0.1 + 0.2 !== 0.3 en coma flotant i un cèntim perdut per comanda és
* una discrepància comptable a final de mes.
*/
const cafes = [
{
id: 'caf_001',
nom: 'Etiòpia Yirgacheffe',
origen: 'Etiòpia',
torrefaccio: 'clar',
preuCentims: 1450,
estoc: 120,
notesTast: ['cítric', 'floral', 'te negre'],
descripcio: null,
dataCreacio: '2026-01-15T09:00:00Z',
actiu: true,
},
{
id: 'caf_002',
nom: 'Colòmbia Huila',
origen: 'Colòmbia',
torrefaccio: 'mitja',
preuCentims: 1290,
estoc: 80,
notesTast: ['xocolata', 'caramel', 'nou'],
descripcio: null,
dataCreacio: '2026-01-20T11:15:00Z',
actiu: true,
},
];
export const repositoriCafes = {
/** Retorna tots els cafès actius. Còpia defensiva: ningú no muta l'array. */
buscarTots() {
return cafes.filter((cafe) => cafe.actiu).map((cafe) => ({ ...cafe }));
},
/** Retorna un cafè pel seu id, o undefined si no existeix. */
buscarPerId(id) {
const cafe = cafes.find((c) => c.id === id && c.actiu);
return cafe ? { ...cafe } : undefined;
},
};Dues decisions que semblen menors i no ho són:
- Els noms dels mètodes (
buscarTots,buscarPerId) són la interfície del repositori. A 03-05 escriuremcafes-sqlite.jsamb exactament els mateixos noms, i canviar d'un a l'altre serà canviar unimport. - Es retornen còpies (
{ ...cafe }), no les referències de l'array. Si retornéssim la referència, qualsevol capa superior podria modificar el "magatzem" per accident. Amb una base de dades real això és impossible per construcció; en memòria cal imposar-ho a mà.
- Les primeres rutes de cafès
Ara connectem el repositori amb el router. Substitueix el contingut de src/rutes/cafes.js:
// src/rutes/cafes.js
import { Router } from 'express';
import { repositoriCafes } from '../repositoris/cafes-memoria.js';
export const rutesCafes = Router();
/**
* Converteix el model intern en la representació pública del contracte.
*
* PROVISIONAL: a 03-03 aquesta funció es muda a src/serveis/mapejadors.js,
* que és el seu lloc. Aquí serveix per no avançar capes abans d'hora.
*/
function aRepresentacio(cafe) {
return {
id: cafe.id,
nom: cafe.nom,
origen: cafe.origen,
torrefaccio: cafe.torrefaccio,
// Cèntims → euros amb dos decimals. Number() el retorna a número
// perquè el JSON tingui 14.5 i no la cadena "14.50".
preuEuros: Number((cafe.preuCentims / 100).toFixed(2)),
estoc: cafe.estoc,
notesTast: cafe.notesTast,
// Camps sempre presents, amb null si no hi ha valor (02-05).
descripcio: cafe.descripcio ?? null,
dataCreacio: cafe.dataCreacio,
_links: {
self: { href: `/v1/cafes/${cafe.id}` },
},
};
}
// GET /v1/cafes → col·lecció amb l'embolcall del contracte
rutesCafes.get('/', (req, res) => {
const cafes = repositoriCafes.buscarTots();
res.status(200).json({
dades: cafes.map(aRepresentacio),
total: cafes.length,
});
});
// GET /v1/cafes/:id → element solt, sense embolcall
rutesCafes.get('/:id', (req, res) => {
const cafe = repositoriCafes.buscarPerId(req.params.id);
if (!cafe) {
// Comprovació mínima i resposta a mà. A 03-07 això serà un
// throw d'ErrorApi i ho formatarà un únic middleware d'errors.
return res.status(404).json({
error: {
codi: 'cafe_no_trobat',
missatge: `No existeix cap cafè amb l'identificador '${req.params.id}'.`,
detalls: [],
},
});
}
res.status(200).json(aRepresentacio(cafe));
});Detalls importants d'aquest fitxer:
- L'embolcall només és a la col·lecció.
GET /v1/cafesretorna{dades, total};GET /v1/cafes/:idretorna l'objecte nu. És exactament el que vam decidir a 02-05. return res.status(404)...: elreturnno retorna res d'útil a Express, però talla l'execució de la funció. Sense ell, continuaria fins alres.status(200)i provocariaERR_HTTP_HEADERS_SENT.totalés avui la longitud de l'array. Quan a 03-03 hi hagi filtres i paginació,totalserà el nombre d'elements que compleixen el filtre, no els retornats a la pàgina. Aquesta distinció es cobra bugs.?? null(fusió de nuls) retorna el valor de l'esquerra llevat que siguinulloundefined. No s'ha de confondre amb||, que també substituiria el0o la cadena buida.
- Rutes amb paràmetres i
req.params
req.params'/:id' declara un segment variable. Express captura el que hi hagi en aquella posició i ho deixa a req.params.id, sempre com a cadena de text.
| Ruta declarada | URL rebuda | req.params |
|---|---|---|
/:id |
/v1/cafes/caf_001 |
{ id: 'caf_001' } |
/:id/ressenyes |
/v1/cafes/caf_001/ressenyes |
{ id: 'caf_001' } |
/:id/ressenyes/:ressenyaId |
/v1/cafes/caf_001/ressenyes/res_101 |
{ id: 'caf_001', ressenyaId: 'res_101' } |
Que els nostres identificadors siguin cadenes amb prefix (caf_001) juga a favor: no cal convertir res ni preocupar-se d'un parseInt que retorni NaN. Va ser una decisió de 02-02 i aquí es cobra el primer dividend.
Un avís sobre l'ordre, que és el parany clàssic:
// MALAMENT: '/destacats' no s'assoleix mai. La ruta '/:id' coincideix abans
// i el gestor rep req.params.id === 'destacats'.
rutesCafes.get('/:id', gestorPerId);
rutesCafes.get('/destacats', gestorDestacats);
// BÉ: l'específic primer, el genèric després.
rutesCafes.get('/destacats', gestorDestacats);
rutesCafes.get('/:id', gestorPerId);Express recorre les rutes en ordre de declaració i es queda amb la primera que coincideix. El que és concret va abans que el que és variable.
Un router germà molt útil és router.param(), que executa codi cada vegada que apareix un paràmetre concret —per exemple, carregar el cafè i deixar-lo a req.cafe—. L'esmentem perquè el coneguis; en aquest curs preferim fer-ho explícit al servei.
- Enviar respostes:
res.status, res.json, res.set, res.sendStatus
res.status, res.json, res.set, res.sendStatus| Mètode | Què fa | Exemple |
|---|---|---|
res.status(code) |
Fixa el codi. No envia res: és encadenable | res.status(201) |
res.json(obj) |
Serialitza a JSON, posa el Content-Type i envia |
res.json({ id: 'caf_001' }) |
res.send(x) |
Envia text, HTML o buffer endevinant el tipus | res.send('ok') |
res.set(n, v) |
Afegeix una capçalera de resposta | res.set('Location', '/v1/cafes/caf_003') |
res.sendStatus(code) |
Fixa el codi i envia el seu text estàndard com a cos | res.sendStatus(204) |
res.end() |
Acaba la resposta sense cos | res.status(204).end() |
Tres avisos pràctics:
// 1. res.status() tot sol NO respon. Això deixa la petició penjada:
res.status(204);
// Correcte per a un 204 (sense cos, com el DELETE de 02-03):
res.status(204).end();
// 2. res.sendStatus(204) envia el cos "No Content" com a TEXT PLA.
// Per a un 204 és inofensiu, però acostumar-s'hi porta a errors
// com res.sendStatus(404), que retorna "Not Found" en text/plain
// en comptes del format d'error del contracte. Evita'l en aquesta API.
// 3. Les capçaleres es fixen ABANS d'enviar el cos. Després, ja és tard.
res.set('Location', '/v1/cafes/caf_003');
res.status(201).json(representacio);En aquest mòdul farem servir gairebé sempre el mateix patró: res.set(...) per a les capçaleres del contracte i res.status(...).json(...) per al cos.
- El 404 genèric amb el format d'error del contracte
Si demanes GET /v1/inexistent, cap ruta no coincideix i Express respon amb el seu 404 per defecte: una pàgina HTML amb el text Cannot GET /v1/inexistent. Per a un consumidor que espera JSON, això trenca el contracte en tres fronts: Content-Type equivocat, forma del cos desconeguda i filtració del framework.
Afegim un middleware final a src/app.js, després de totes les rutes:
// src/app.js (al final, després d'app.use('/v1', rutesV1))
// Si la petició arriba fins aquí, cap ruta no ha coincidit.
// En no portar ruta, aquest app.use() s'executa per a qualsevol petició
// que no hagi estat atesa abans: és el "calaix de sastre".
app.use((req, res) => {
res.status(404).json({
error: {
codi: 'ruta_no_trobada',
missatge: `No existeix el recurs ${req.method} ${req.originalUrl}.`,
detalls: [],
},
});
});Dues precisions sobre el catàleg. El codi ruta_no_trobada no era al catàleg de 02-04, que només tenia els 404 específics (cafe_no_trobat, comanda_no_trobada…). L'afegim ara per al cas genèric —una URI que no existeix en absolut—, i això és perfectament legítim: la regla que vam fixar és que el catàleg només creix; afegir un codi és un canvi additiu, retirar-lo seria trencador. I cal documentar-ho a openapi.yaml, perquè un codi d'error sense documentar no forma part del contracte.
La diferència entre els dos 404 convé tenir-la clara:
| Situació | Codi | Significat per al consumidor |
|---|---|---|
GET /v1/cafes/caf_999 |
cafe_no_trobat |
La ruta existeix; aquell cafè, no |
GET /v1/cafesos |
ruta_no_trobada |
Aquella URI no existeix a l'API. Revisa la documentació |
L'ordre és crític. Aquest middleware s'ha de registrar l'últim dels normals; si estigués abans d'app.use('/v1', rutesV1), respondria 404 a absolutament tot. A 03-07 hi afegirem al darrere el middleware d'errors, que és l'únic que va després.
L'src/app.js complet queda així:
// src/app.js
import express from 'express';
import { rutesV1 } from './rutes/index.js';
export const app = express();
app.disable('x-powered-by');
// --- 1. Analitzadors del cos ---
app.use(
express.json({
limit: '100kb',
type: ['application/json', 'application/merge-patch+json'],
})
);
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
// --- 2. Endpoint de salut (fora de /v1: no és part del contracte) ---
app.get('/salut', (req, res) => {
res.status(200).json({
estat: 'ok',
versio: '1.0.0',
moment: new Date().toISOString(),
});
});
// --- 3. API versionada ---
app.use('/v1', rutesV1);
// --- 4. Calaix de sastre: cap ruta no ha coincidit ---
app.use((req, res) => {
res.status(404).json({
error: {
codi: 'ruta_no_trobada',
missatge: `No existeix el recurs ${req.method} ${req.originalUrl}.`,
detalls: [],
},
});
});
// --- 5. (03-07) Aquí anirà el middleware d'errors, sempre l'últim ---Aquests cinc blocs numerats són l'ordre definitiu de l'aplicació. Durant la resta del mòdul només hi inserirem peces entremig, mai canviarem la seqüència.
- Provar-ho tot amb
curl
curlAmb npm run dev en marxa, en un altre terminal:
{"dades":[{"id":"caf_001","nom":"Etiòpia Yirgacheffe","origen":"Etiòpia","torrefaccio":"clar","preuEuros":14.5,"estoc":120,"notesTast":["cítric","floral","te negre"],"descripcio":null,"dataCreacio":"2026-01-15T09:00:00Z","_links":{"self":{"href":"/v1/cafes/caf_001"}}},{"id":"caf_002",...}],"total":2}Si tens jq instal·lat, la lectura millora molt:
# 2. Un element concret: sense embolcall
curl -s http://localhost:3000/v1/cafes/caf_001 | jq '.id, .preuEuros'Atenció al 14.5. El contracte de 02-05 diu "euros amb dos decimals", però JSON no distingeix 14.50 de 14.5: són el mateix número i així ho serialitza JSON.stringify. Els dos decimals són una qüestió de format de presentació, responsabilitat del client, no del transport. L'important —i el que sí que controlem— és que el valor sigui exacte, i ho és perquè per dins són 1450 cèntims enters. Si un consumidor necessités literalment la cadena "14.50", caldria serialitzar el preu com a text, i aquesta és una decisió de contracte que ja vam descartar a 02-05.
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"error":{"codi":"cafe_no_trobat","missatge":"No existeix cap cafè amb l'identificador 'caf_999'.","detalls":[]}}Cinc comprovacions, cinc respostes conformes al contracte. A 03-08 convertirem exactament aquestes crides en proves automàtiques amb Supertest, perquè ningú no les hagi d'executar a mà mai més.
- Registre de peticions:
morgan i fins on arribem avui
morgan i fins on arribem avuiAra mateix el terminal no diu res quan arriba una petició, i això fa incòmode depurar. La solució mínima és un middleware de cinc línies, que a més serveix d'exemple perfecte de la signatura que hem après:
// src/app.js (just després d'app.disable, abans dels analitzadors)
app.use((req, res, next) => {
const inici = Date.now();
// 'finish' es dispara quan la resposta s'ha enviat del tot,
// així que aquí ja coneixem el codi d'estat i la durada.
res.on('finish', () => {
console.log(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - inici} ms)`);
});
next();
});L'alternativa habitual és morgan (npm install morgan), un middleware de registre amb formats predefinits:
import morgan from 'morgan';
app.use(morgan('dev')); // format compacte i acolorit
app.use(morgan('combined')); // format estàndard d'Apache, per a produccióQualsevol dels dos serveix per desenvolupar. Però que quedi clar que això no és observabilitat: no hi ha nivells de log, ni format estructurat en JSON, ni identificador de correlació, ni mètriques, ni traces. console.log en producció és un log que ningú no pot consultar ni agregar. L'observabilitat seriosa —logs estructurats amb pino, mètriques, traces distribuïdes i el tracaId que emetrem als 500— és la lliçó 04-07. A 03-07 farem el primer pas generant aquest tracaId per correlacionar un error amb el seu log.
Errors Comuns i Consells
1. La petició es queda penjada per sempre. Un middleware ni ha cridat next() ni ha respost. Recorre la cadena de dalt a baix buscant el que no tanca cap camí.
2. Error: Can't set headers after they are sent. S'ha respost dues vegades: un res.json() sense return seguit d'un altre, o un next() després de respondre. Fes servir sempre return res.json(...) quan la funció pugui continuar.
3. Les rutes retornen 404 encara que el fitxer existeixi. Gairebé sempre és el muntatge: recorda que les rutes del router són relatives. Si escrius rutesCafes.get('/cafes', ...) i el muntes a /v1/cafes, la URL real és /v1/cafes/cafes.
4. req.body és undefined. Falta express.json() o està registrat després de les rutes. L'ordre mana.
5. req.body arriba buit encara que express.json() hi sigui. El client no ha enviat Content-Type: application/json. A curl, l'opció és -H "Content-Type: application/json".
6. /:id declarada abans que una ruta fixa. /destacats deixa d'existir. L'específic, primer.
7. EADDRINUSE: address already in use :::3000. Hi ha un altre procés al port, normalment un npm run dev anterior que no ha mort. lsof -i :3000 l'identifica; kill <pid> el tanca.
8. Ficar la lògica de negoci dins de la ruta. Avui hem consultat el repositori directament des del router perquè no hi ha res més. És l'últim dia: a 03-03 se separa en controlador i servei, i no tornarà a passar.
Consell: defineix l'ordre dels middleware una vegada, comenta'l amb números com hem fet a app.js i respecta'l. Les fallades més difícils de diagnosticar a Express són sempre d'ordre.
Exercicis
Exercici 1
Afegeix al router de cafès la ruta GET /v1/cafes/:id/ressenyes, que forma part del mapa d'URIs de 02-02. De moment no hi ha ressenyes, així que ha de retornar una col·lecció buida amb l'embolcall del contracte, però només si el cafè existeix; si no existeix, ha de retornar 404 cafe_no_trobat. Explica per què una col·lecció buida és 200 i no 404.
Exercici 2
Escriu un middleware anomenat capcaleraServidor que afegeixi a totes les respostes la capçalera Aroma-Versio: 1.0.0. Registra'l a src/app.js a la posició correcta i justifica-la. Comprova el resultat amb curl -i. Per què Aroma- i no X-Aroma-?
Exercici 3
Aquest codi té tres errors. Troba'ls, explica què li passa a la petició amb cadascun i escriu la versió corregida.
rutesCafes.get('/:id', (req, res, next) => {
const cafe = repositoriCafes.buscarPerId(req.params.id);
if (!cafe) {
res.status(404).json({ error: 'no trobat' });
}
res.status(200);
res.json(aRepresentacio(cafe));
next();
});Solucions
Solució 1
// src/rutes/cafes.js (afegir-la ABANS de la ruta '/:id' no cal aquí,
// perquè '/:id/ressenyes' té dos segments i no col·lisiona amb '/:id')
rutesCafes.get('/:id/ressenyes', (req, res) => {
const cafe = repositoriCafes.buscarPerId(req.params.id);
if (!cafe) {
return res.status(404).json({
error: {
codi: 'cafe_no_trobat',
missatge: `No existeix cap cafè amb l'identificador '${req.params.id}'.`,
detalls: [],
},
});
}
// La col·lecció existeix (el cafè existeix) però no té elements.
res.status(200).json({ dades: [], total: 0 });
});Per què 200 i no 404: el recurs sol·licitat és la col·lecció de ressenyes del cafè caf_001, i aquella col·lecció existeix; el que passa és que és buida. Un 404 significaria "aquesta URI no identifica cap recurs", i obligaria el client a tractar el cas normal "encara no hi ha ressenyes" com un error. La regla, que ja vam veure a 02-04: col·lecció buida és 200 amb {"dades": [], "total": 0}; recurs inexistent és 404. Coherent amb la decisió de 02-05 que els arrays buits es representen com [] i mai com null ni absents.
Solució 2
// src/app.js (just després d'app.disable('x-powered-by'))
app.use((req, res, next) => {
res.set('Aroma-Versio', '1.0.0');
next();
});Per què aquella posició: ha d'anar abans de qualsevol cosa que pugui respondre —rutes, 404, errors—, perquè les capçaleres només es poden fixar mentre la resposta no s'hagi enviat. Col·locada la primera, la capçalera acompanya totes les respostes, inclosos els 404 i els 500.
Per què Aroma- i no X-Aroma-: el prefix X- per a capçaleres no estàndard va quedar desaconsellat pel RFC 6648 el 2012. El problema històric és que moltes capçaleres X- acabaven estandarditzant-se i llavors convivien dos noms per a la mateixa cosa (X-Forwarded-For n'és l'exemple canònic). La recomanació actual és fer servir un prefix propi del proveïdor sense X-, i per això el contracte de 02-05 va fixar Aroma-.
Solució 3
| # | Error | Què li passa a la petició |
|---|---|---|
| 1 | Falta return abans del res.status(404) |
Amb un id inexistent respon 404 i continua executant; en arribar a res.json(aRepresentacio(cafe)) amb cafe a undefined, llança un TypeError després d'haver enviat ja les capçaleres → ERR_HTTP_HEADERS_SENT |
| 2 | El cos de l'error no segueix el contracte | Retorna {"error": "no trobat"}, una cadena, en comptes de l'objecte {error: {codi, missatge, detalls}} fixat a 02-04. Qualsevol client que llegeixi resposta.error.codi obté undefined |
| 3 | next() després de respondre |
Cedeix el control al middleware següent —el calaix de sastre del 404— que intentarà respondre un altre cop sobre una resposta ja enviada |
Versió corregida:
rutesCafes.get('/:id', (req, res) => {
const cafe = repositoriCafes.buscarPerId(req.params.id);
if (!cafe) {
return res.status(404).json({
error: {
codi: 'cafe_no_trobat',
missatge: `No existeix cap cafè amb l'identificador '${req.params.id}'.`,
detalls: [],
},
});
}
res.status(200).json(aRepresentacio(cafe));
});Nota sobre next: s'ha eliminat del tot de la signatura. Un gestor final de ruta no necessita next perquè sempre respon; declarar-lo convida a cridar-lo per error. En aquest mòdul només el farem servir als middleware intermedis i, a partir de 03-07, per propagar errors amb next(error).
Conclusió
Ja hi ha un servidor. I més enllà que respongui, l'important és el que n'has entès: que Express és una cadena ordenada de middleware amb la signatura (req, res, next), que cada peça decideix si cedeix el torn o respon, i que l'ordre de registre és l'ordre d'execució, sense excepcions. Aquesta idea és el 90 % d'Express, i amb ella hi encaixaran sense sorpreses la validació de 03-04, l'autenticació de 03-06 i la gestió d'errors de 03-07.
A més, has pres tres decisions estructurals que sostenen la resta del mòdul. app.js separat de servidor.js, perquè l'aplicació sigui un objecte que les proves de 03-08 puguin fer servir sense obrir un port. El Router muntat a /v1, que converteix el versionat a la ruta de 02-07 en un prefix compost en tres nivells i fa que una futura v2 sigui una línia de codi, no una migració. I el magatzem darrere d'una interfície de repositori amb buscarTots i buscarPerId, els noms dels quals reapareixeran idèntics a 03-05 quan al darrere hi hagi SQLite. A més, les respostes ja compleixen el contracte: embolcall {dades, total} a la col·lecció, objecte nu a l'element, cèntims per dins i euros per fora, i un 404 amb codi, missatge i detalls.
El que avui grinyola és que tota la lògica viu dins del router, que la conversió a la representació pública és una funció solta amb un comentari de "provisional", i que només sabem llegir. A 03-03, Gestió de peticions i respostes, ho arreglem: exprimirem els objectes req i res, partirem el codi en rutes → controladors → serveis, escriurem el mapejador de representació al seu lloc, i implementarem el contracte complet d'escriptura —POST amb 201 i Location, PUT, PATCH amb merge-patch+json i el seu 415, DELETE lògic— juntament amb els filtres, l'ordenació, la paginació i la capçalera Link que vam dissenyar a 02-06.
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
