El servidor de la lliçó anterior respon, però tota la seva lògica viu dins del router i només sap llegir. Avui el convertim en una implementació seriosa del contracte: exprimirem els objectes req i res per saber exactament quina informació porta una petició i què controlem de la resposta, partirem el codi en tres capes —rutes, controladors i serveis— amb una frontera clara entre elles, escriurem el mapejador que tradueix el model intern amb cèntims a la representació pública amb euros i _links, i completarem el cicle de vida dels cafès: POST amb 201 i Location, PUT, PATCH amb application/merge-patch+json i el seu 415 quan no ho és, i DELETE lògic amb 204. A més implementarem els paràmetres de col·lecció de 02-06 —filtres, cerca, ordenació i paginació— amb la capçalera Link completa. És la lliçó més llarga del mòdul i la que més contracte converteix en codi.

Contingut

  1. Què es refactoritza avui i per què
  2. L'objecte req a fons
  3. L'objecte res a fons
  4. Les tres capes: rutes, controladors i serveis
  5. El mapejador de representació
  6. El repositori en memòria, ampliat
  7. El servei de cafès
  8. El controlador de cafès
  9. Les rutes, ja sense lògica
  10. Els paràmetres de col·lecció: filtres i cerca
  11. Ordenació amb - i desempat per id
  12. Paginació i la capçalera Link
  13. Selecció de camps amb camps
  14. Crear: POST amb 201 i Location
  15. Reemplaçar i modificar: PUT i PATCH
  16. Esborrar: DELETE lògic amb 204
  17. Comandes i els enllaços d'acció segons l'estat
  18. async/await i el parany del throw a Express 4

  1. Què es refactoritza avui i per què

Estat de partida: src/rutes/cafes.js consulta el repositori, transforma les dades i respon, tot al mateix lloc. Funciona amb dues rutes de lectura. Amb vint-i-quatre URIs, filtres, validació i permisos, es converteix en un fitxer de mil línies impossible de provar.

Fitxers que es creen avui:

Fitxer Contingut
src/serveis/mapejadors.js Traducció de model intern a representació pública
src/serveis/cafes.js Lògica de negoci de cafès
src/serveis/comandes.js Lògica de negoci de comandes
src/controladors/cafes.js Traducció HTTP ↔ domini per als cafès
src/controladors/comandes.js Ídem per a les comandes
src/controladors/paginacio.js Construcció de la capçalera Link
src/repositoris/comandes-memoria.js Magatzem de comandes en memòria
src/rutes/comandes.js Router de /v1/comandes

Fitxers que es modifiquen: src/rutes/cafes.js (queda reduït a declaracions), src/rutes/index.js (munta les comandes) i src/repositoris/cafes-memoria.js (guanya mètodes d'escriptura i de cerca).

Dos avisos sobre el que no fem avui, perquè el codi no et sorprengui:

  • No hi ha validació real. Comprovarem l'imprescindible a mà i amb missatges provisionals. Els esquemes Zod i el middleware validar arriben a 03-04.
  • No hi ha gestió d'errors centralitzada. Els serveis retornaran null quan alguna cosa no existeixi i el controlador respondrà el 404 a mà. A 03-07 els serveis llançaran ErrorApi i un únic middleware se n'encarregarà de tot.

  1. L'objecte req a fons

req és la petició HTTP convertida en objecte JavaScript. Aquestes són les propietats que farem servir:

Propietat Què conté Exemple amb GET /v1/cafes/caf_001?camps=id,nom
req.method Mètode HTTP en majúscules 'GET'
req.params Paràmetres de ruta (:id). Sempre cadenes { id: 'caf_001' }
req.query Paràmetres de consulta ja analitzats. Sempre cadenes { camps: 'id,nom' }
req.body Cos analitzat per express.json() {} (no hi ha cos en un GET)
req.headers Capçaleres, amb els noms en minúscules { host: 'localhost:3000', accept: '*/*' }
req.get(nom) Una capçalera, sense importar les majúscules req.get('Content-Type')
req.path Ruta sense query string, relativa al muntatge '/caf_001' dins del router
req.originalUrl URL completa tal com va arribar, amb query '/v1/cafes/caf_001?camps=id,nom'
req.baseUrl Prefix sota el qual està muntat el router '/v1/cafes'
req.ip IP del client '::1'
req.protocol http o https 'http'

Quatre coses que convé gravar a foc:

Tot arriba com a text. ?limit=20 produeix req.query.limit === '20', la cadena. '20' + 1 és '201'. Convertir és obligatori, i a 03-04 ho farà Zod amb z.coerce.

req.query et pot donar un array sense avisar. Si el client repeteix un paràmetre:

GET /v1/cafes?torrefaccio=clar&torrefaccio=mitja   →   req.query.torrefaccio === ['clar', 'mitja']

Un codi que assumeixi una cadena farà torrefaccio.toLowerCase() i petarà. El contracte de 02-06 diu que els filtres de la Botiga Aroma són de valor únic, així que un array ha de produir 400 parametre_invalid.

Les capçaleres es llegeixen en minúscules a req.headers, o amb req.get() que ignora les majúscules. req.headers['Content-Type'] és undefined; req.headers['content-type'] funciona.

req.path no és req.originalUrl. Dins d'un router muntat, req.path és relatiu. Per construir la capçalera Link necessitarem la URL completa, i allà originalUrl és la bona.

Un middleware de diagnòstic que pots enganxar temporalment a app.js per veure-ho tot en directe:

app.use((req, res, next) => {
  console.log({
    method: req.method,
    originalUrl: req.originalUrl,
    path: req.path,
    params: req.params,
    query: req.query,
    contentType: req.get('Content-Type'),
    body: req.body,
  });
  next();
});

  1. L'objecte res a fons

res és la resposta en construcció. Ja coneixem status, json, set i end; hi afegim les que necessita el contracte:

Mètode Per a què Ús al contracte
res.status(n) Fixa el codi 201, 204, 404
res.json(obj) Serialitza i envia Totes les respostes amb cos
res.set(n, v) Una capçalera Location, Link, Allow, Accept-Patch
res.set({...}) Diverses capçaleres alhora Còmode per a Link + Allow
res.location(url) Drecera per a la capçalera Location Després d'un POST
res.links({...}) Construeix la capçalera Link RFC 8288 Paginació
res.vary(capcalera) Afegeix a Vary Negociació de contingut (02-05)
res.end() Tanca sense cos Respostes 204

res.links() mereix una demostració, perquè fa per nosaltres el format del RFC 8288:

res.links({
  next: 'http://localhost:3000/v1/cafes?limit=20&desplacament=40',
  prev: 'http://localhost:3000/v1/cafes?limit=20&desplacament=0',
});
Link: <http://localhost:3000/v1/cafes?limit=20&desplacament=40>; rel="next", <http://localhost:3000/v1/cafes?limit=20&desplacament=0>; rel="prev"

Exactament el format que vam fixar a 02-06, amb els angles, el rel entre cometes i les comes. Un detall: res.links() acumula si es crida diverses vegades, així que n'hi ha prou amb una crida que inclogui totes les relacions.

  1. Les tres capes: rutes, controladors i serveis

Capa Fitxer Responsabilitat Pot tocar
Ruta rutes/cafes.js Dir quin mètode i URI invoquen quina funció Res més
Controlador controladors/cafes.js Llegir req, cridar el servei, escriure res amb estat i capçaleres req, res, serveis, mapejadors
Servei serveis/cafes.js Regles de negoci i orquestració Repositoris. Mai req ni res
Repositori repositoris/cafes-*.js Desar i recuperar dades La font de dades

La regla que ho resumeix: el servei no ha de saber que existeix HTTP. Si en un servei apareix un req, un res, un codi d'estat o una capçalera, la frontera està trencada. Els tres avantatges concrets, tots dins d'aquest mòdul:

  1. Es pot provar sense servidor (03-08): un test del servei li passa dades i comprova el resultat, sense Supertest ni ports.
  2. Es pot reutilitzar: un script que importi comandes des d'un CSV crida el mateix servei que l'API. Si la lògica visqués al controlador, s'hauria de duplicar.
  3. Es pot canviar l'emmagatzematge (03-05) sense que el controlador se n'assabenti.

  1. El mapejador de representació

La conversió de model intern a representació pública és una responsabilitat pròpia i mereix el seu fitxer. És on es compleixen, en un sol lloc, totes les decisions de 02-05.

// src/serveis/mapejadors.js

/** Converteix cèntims enters a euros amb dos decimals. 1450 → 14.5 */
export function centimsAEuros(centims) {
  return Number((centims / 100).toFixed(2));
}

/** Converteix euros a cèntims enters. 14.5 → 1450 */
export function eurosACentims(euros) {
  // Math.round evita que 14.5 * 100 doni 1449.9999999999998 per coma flotant.
  return Math.round(euros * 100);
}

/**
 * Model intern de cafè → representació pública.
 * Aquí es concentren les regles de 02-05:
 *  - camelCase a tots els camps
 *  - euros per fora, cèntims per dins
 *  - camps sempre presents, amb null si no hi ha valor
 *  - arrays buits com a [], mai null
 *  - _links amb self (els cafès no tenen enllaços d'acció)
 */
export function cafeARepresentacio(cafe) {
  return {
    id: cafe.id,
    nom: cafe.nom,
    origen: cafe.origen,
    torrefaccio: cafe.torrefaccio,
    preuEuros: centimsAEuros(cafe.preuCentims),
    estoc: cafe.estoc,
    notesTast: cafe.notesTast ?? [],
    descripcio: cafe.descripcio ?? null,
    dataCreacio: cafe.dataCreacio,
    _links: {
      self: { href: `/v1/cafes/${cafe.id}` },
      ressenyes: { href: `/v1/cafes/${cafe.id}/ressenyes` },
    },
  };
}

/**
 * Enllaços d'acció d'una comanda SEGONS EL SEU ESTAT (02-05, hipermèdia
 * selectiva). És la traducció literal de la taula del contracte:
 *   pendent_pagament → self, client, pagar, anullar
 *   pagat            → self, client, factura, enviament, anullar
 *   enviat           → self, client, factura, enviament, retornar
 */
function enllacosDeComanda(comanda) {
  const base = `/v1/comandes/${comanda.id}`;
  const enllacos = {
    self: { href: base },
    client: { href: `/v1/clients/${comanda.clientId}` },
  };

  if (comanda.estat === 'pendent_pagament') {
    enllacos.pagar = { href: `${base}/pagament`, method: 'POST' };
    enllacos.anullar = { href: `${base}/anullacio`, method: 'POST' };
  }

  if (comanda.estat === 'pagat') {
    enllacos.factura = { href: `${base}/factura` };
    enllacos.enviament = { href: `${base}/enviament` };
    enllacos.anullar = { href: `${base}/anullacio`, method: 'POST' };
  }

  if (comanda.estat === 'enviat') {
    enllacos.factura = { href: `${base}/factura` };
    enllacos.enviament = { href: `${base}/enviament` };
    enllacos.retornar = { href: `${base}/devolucio`, method: 'POST' };
  }

  return enllacos;
}

/** Model intern de comanda → representació pública. */
export function comandaARepresentacio(comanda) {
  return {
    id: comanda.id,
    clientId: comanda.clientId,
    estat: comanda.estat,
    // El preu de la línia va CONGELAT: és el que hi havia el dia de la
    // compra, no l'actual del catàleg (02-05, secció d'incrustació).
    linies: comanda.linies.map((linia) => ({
      cafeId: linia.cafeId,
      nom: linia.nom,
      quantitat: linia.quantitat,
      preuEuros: centimsAEuros(linia.preuCentims),
    })),
    totalEuros: centimsAEuros(comanda.totalCentims),
    dataCreacio: comanda.dataCreacio,
    dataPagament: comanda.dataPagament ?? null,
    dataEnviament: comanda.dataEnviament ?? null,
    _links: enllacosDeComanda(comanda),
  };
}

/**
 * Versió reduïda per a les col·leccions: en una llista, cada element només
 * porta 'self' (02-05: "els elements dins d'una col·lecció no porten
 * _links complets, per no multiplicar el pes de la resposta").
 */
export function aResumDeColeccio(representacio) {
  return { ...representacio, _links: { self: representacio._links.self } };
}

Ara tenim un únic punt on canviar si demà el contracte afegeix un camp. Abans, aquella lògica estava repartida pel router i no hi havia res que impedís que GET /v1/cafes i GET /v1/cafes/caf_001 retornessin formes diferents del mateix cafè. Aquesta és la fallada de consistència més freqüent a les APIs reals, i un mapejador compartit la fa impossible.

  1. El repositori en memòria, ampliat

El repositori guanya la capacitat de cercar amb criteris i d'escriure. La signatura de buscar() està pensada mirant ja cap a 03-05: rep criteris en unitats internes (cèntims) i retorna elements i total, perquè amb SQL seran dues consultes.

// src/repositoris/cafes-memoria.js  (versió ampliada)

const cafes = [
  /* ... caf_001 i caf_002 com a 03-02 ... */
];

/** Comptador per generar ids nous. A 03-05 ho farà la base de dades. */
let seguentId = 3;

function generarId() {
  return `caf_${String(seguentId++).padStart(3, '0')}`; // caf_003, caf_004...
}

export const repositoriCafes = {
  /**
   * Cerca amb filtres, ordenació i paginació.
   * @returns {{elements: object[], total: number}}
   *   'total' és el nombre de coincidències ABANS de paginar: el client
   *   necessita saber quantes n'hi ha en total, no quantes caben a la pàgina.
   */
  buscar(criteris = {}) {
    const {
      origen,
      torrefaccio,
      preuMinCentims,
      preuMaxCentims,
      disponible,
      q,
      ordenar = [{ camp: 'id', descendent: false }],
      limit = 20,
      desplacament = 0,
    } = criteris;

    let resultat = cafes.filter((cafe) => cafe.actiu);

    // --- Filtres (I lògic entre tots, com vam fixar a 02-06) ---
    if (origen !== undefined) {
      resultat = resultat.filter((c) => c.origen.toLowerCase() === origen.toLowerCase());
    }
    if (torrefaccio !== undefined) {
      resultat = resultat.filter((c) => c.torrefaccio === torrefaccio);
    }
    if (preuMinCentims !== undefined) {
      resultat = resultat.filter((c) => c.preuCentims >= preuMinCentims);
    }
    if (preuMaxCentims !== undefined) {
      resultat = resultat.filter((c) => c.preuCentims <= preuMaxCentims);
    }
    if (disponible !== undefined) {
      resultat = resultat.filter((c) => (disponible ? c.estoc > 0 : c.estoc === 0));
    }
    if (q !== undefined) {
      const terme = q.toLowerCase();
      resultat = resultat.filter(
        (c) =>
          c.nom.toLowerCase().includes(terme) ||
          c.origen.toLowerCase().includes(terme) ||
          c.notesTast.some((nota) => nota.toLowerCase().includes(terme))
      );
    }

    const total = resultat.length; // ← abans de paginar

    // --- Ordenació multicamp ---
    resultat = [...resultat].sort((a, b) => {
      for (const { camp, descendent } of ordenar) {
        const va = a[camp];
        const vb = b[camp];
        if (va === vb) continue;
        const signe = va > vb ? 1 : -1;
        return descendent ? -signe : signe;
      }
      return 0;
    });

    // --- Paginació ---
    const elements = resultat.slice(desplacament, desplacament + limit);

    return { elements: elements.map((c) => ({ ...c })), total };
  },

  buscarPerId(id) {
    const cafe = cafes.find((c) => c.id === id && c.actiu);
    return cafe ? { ...cafe } : undefined;
  },

  crear(dades) {
    const cafe = {
      id: generarId(),
      ...dades,
      dataCreacio: new Date().toISOString(),
      actiu: true,
    };
    cafes.push(cafe);
    return { ...cafe };
  },

  /** Aplica canvis parcials sobre un cafè existent. */
  actualitzar(id, canvis) {
    const index = cafes.findIndex((c) => c.id === id && c.actiu);
    if (index === -1) return undefined;
    cafes[index] = { ...cafes[index], ...canvis };
    return { ...cafes[index] };
  },

  /** Esborrat LÒGIC (02-03): el registre es conserva, deixa d'estar actiu. */
  esborrar(id) {
    const cafe = cafes.find((c) => c.id === id && c.actiu);
    if (!cafe) return false;
    cafe.actiu = false;
    return true;
  },
};

Fixa't que el repositori parla en cèntims (preuMinCentims) i no coneix els euros: la traducció és responsabilitat del mapejador i del controlador. La frontera d'unitats és tan clara com la de capes.

  1. El servei de cafès

// src/serveis/cafes.js
import { repositoriCafes } from '../repositoris/cafes-memoria.js';
import { eurosACentims } from './mapejadors.js';

export const serveiCafes = {
  /** Retorna una pàgina de cafès i el total de coincidències. */
  llistar(criteris) {
    return repositoriCafes.buscar(criteris);
  },

  /** Retorna un cafè o undefined si no existeix. */
  obtenir(id) {
    return repositoriCafes.buscarPerId(id);
  },

  /** Crea un cafè. Rep dades ja validades i en euros. */
  crear(dades) {
    return repositoriCafes.crear({
      nom: dades.nom.trim(),
      origen: dades.origen.trim(),
      torrefaccio: dades.torrefaccio,
      preuCentims: eurosACentims(dades.preuEuros),
      estoc: dades.estoc,
      notesTast: dades.notesTast ?? [],
      descripcio: dades.descripcio ?? null,
    });
  },

  /** Reemplaça completament un cafè (PUT). undefined si no existeix. */
  reemplacar(id, dades) {
    if (!repositoriCafes.buscarPerId(id)) return undefined;
    return repositoriCafes.actualitzar(id, {
      nom: dades.nom.trim(),
      origen: dades.origen.trim(),
      torrefaccio: dades.torrefaccio,
      preuCentims: eurosACentims(dades.preuEuros),
      estoc: dades.estoc,
      notesTast: dades.notesTast ?? [],
      descripcio: dades.descripcio ?? null,
    });
  },

  /** Modifica parcialment un cafè (PATCH). Només toca el que arriba. */
  modificar(id, canvis) {
    if (!repositoriCafes.buscarPerId(id)) return undefined;

    const parcials = {};
    if (canvis.nom !== undefined) parcials.nom = canvis.nom.trim();
    if (canvis.origen !== undefined) parcials.origen = canvis.origen.trim();
    if (canvis.torrefaccio !== undefined) parcials.torrefaccio = canvis.torrefaccio;
    if (canvis.preuEuros !== undefined) {
      parcials.preuCentims = eurosACentims(canvis.preuEuros);
    }
    if (canvis.estoc !== undefined) parcials.estoc = canvis.estoc;
    if (canvis.notesTast !== undefined) parcials.notesTast = canvis.notesTast;
    // A Merge Patch, null significa "esborra aquest camp" (02-03).
    if (canvis.descripcio !== undefined) parcials.descripcio = canvis.descripcio;

    return repositoriCafes.actualitzar(id, parcials);
  },

  /** Esborrat lògic. Retorna true si ha esborrat alguna cosa. */
  esborrar(id) {
    return repositoriCafes.esborrar(id);
  },
};

Fixa't en el que no hi ha en aquest fitxer: ni un res, ni un 404, ni una capçalera. Només objectes de domini. I en la diferència de fons entre reemplacar i modificar: el primer rep el recurs complet i el substitueix sencer; el segon només toca els camps presents, distingint "absent" (no tocar) de null (esborrar el valor). És exactament la semàntica de Merge Patch de 02-03.

  1. El controlador de cafès

El controlador és la duana entre HTTP i el domini. Comencem per la lectura, que és on viu la feina de 02-06.

// src/controladors/cafes.js
import { serveiCafes } from '../serveis/cafes.js';
import { cafeARepresentacio, aResumDeColeccio, eurosACentims } from '../serveis/mapejadors.js';
import { construirCapcaleraLink } from './paginacio.js';

/** Camps pels quals es permet ordenar. Llista blanca: res més s'admet. */
const CAMPS_ORDENABLES = new Set(['nom', 'preuEuros', 'estoc', 'dataCreacio', 'id']);

/** Traducció de camp públic a camp intern per ordenar. */
const CAMP_INTERN = { preuEuros: 'preuCentims' };

/**
 * Interpreta el paràmetre 'ordenar' de 02-06:
 *   ?ordenar=-preuEuros,nom  →  preu descendent, després nom ascendent.
 * Sempre s'hi afegeix 'id' al final com a desempat, perquè la paginació
 * sigui estable: sense desempat, dos cafès al mateix preu poden canviar
 * d'ordre entre la pàgina 1 i la 2, i el client en veu un de repetit i un de perdut.
 */
function interpretarOrdenar(text) {
  if (!text) return [{ camp: 'id', descendent: false }];

  const criteris = [];
  for (const part of text.split(',')) {
    const descendent = part.startsWith('-');
    const campPublic = descendent ? part.slice(1) : part;

    if (!CAMPS_ORDENABLES.has(campPublic)) {
      return { error: `El camp d'ordenació '${campPublic}' no existeix.` };
    }
    criteris.push({ camp: CAMP_INTERN[campPublic] ?? campPublic, descendent });
  }

  criteris.push({ camp: 'id', descendent: false });
  return criteris;
}

/** Aplica ?camps=id,nom sobre una representació ja construïda. */
function projectar(representacio, camps) {
  if (!camps) return representacio;
  const demanats = new Set(camps.split(','));
  demanats.add('id'); // l'id es retorna sempre: sense ell la resposta és inútil
  return Object.fromEntries(
    Object.entries(representacio).filter(([clau]) => demanats.has(clau))
  );
}

export const controladorCafes = {
  /** GET /v1/cafes */
  llistar(req, res) {
    const { origen, torrefaccio, preuMin, preuMax, disponible, q, ordenar, camps } = req.query;

    // Paginació amb els seus valors per defecte i màxims (02-06).
    const limit = req.query.limit === undefined ? 20 : Number(req.query.limit);
    const desplacament =
      req.query.desplacament === undefined ? 0 : Number(req.query.desplacament);

    // Comprovacions mínimes: a 03-04 les farà el middleware de validació.
    if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
      return res.status(400).json({
        error: {
          codi: 'parametre_invalid',
          missatge: "El paràmetre 'limit' ha de ser un enter entre 1 i 100.",
          detalls: [{ camp: 'limit', codi: 'fora_de_rang', missatge: 'Màxim 100.' }],
        },
      });
    }

    const criterisOrdre = interpretarOrdenar(ordenar);
    if (criterisOrdre.error) {
      return res.status(400).json({
        error: {
          codi: 'parametre_invalid',
          missatge: criterisOrdre.error,
          detalls: [{ camp: 'ordenar', codi: 'valor_desconegut', missatge: criterisOrdre.error }],
        },
      });
    }

    const { elements, total } = serveiCafes.llistar({
      origen,
      torrefaccio,
      // Els preus arriben en euros i el repositori parla en cèntims.
      preuMinCentims: preuMin === undefined ? undefined : eurosACentims(Number(preuMin)),
      preuMaxCentims: preuMax === undefined ? undefined : eurosACentims(Number(preuMax)),
      disponible: disponible === undefined ? undefined : disponible === 'true',
      q,
      ordenar: criterisOrdre,
      limit,
      desplacament,
    });

    const enllacos = construirCapcaleraLink({ req, limit, desplacament, total });
    if (Object.keys(enllacos).length > 0) res.links(enllacos);

    res.status(200).json({
      dades: elements
        .map(cafeARepresentacio)
        .map(aResumDeColeccio)
        .map((r) => projectar(r, camps)),
      total,
    });
  },

  /** GET /v1/cafes/:id */
  obtenir(req, res) {
    const cafe = serveiCafes.obtenir(req.params.id);
    if (!cafe) return noTrobat(res, req.params.id);
    res.status(200).json(projectar(cafeARepresentacio(cafe), req.query.camps));
  },
};

/** Resposta 404 del catàleg. Provisional: a 03-07 la centralitza ErrorApi. */
function noTrobat(res, id) {
  return res.status(404).json({
    error: {
      codi: 'cafe_no_trobat',
      missatge: `No existeix cap cafè amb l'identificador '${id}'.`,
      detalls: [],
    },
  });
}

Aquí apareixen dues decisions de seguretat que convé subratllar. La primera és la llista blanca de camps ordenables: si acceptéssim qualsevol nom, amb SQL al darrere (03-05) estaríem concatenant text de l'usuari dins d'un ORDER BY, que és injecció SQL de manual. La segona és que limit no es retalla silenciosament a 100, sinó que retorna 400: el contracte de 02-06 ho va decidir així perquè retallar en silenci fa creure al client que ha rebut tot el que demanava.

  1. Les rutes, ja sense lògica

// src/rutes/cafes.js
import { Router } from 'express';
import { controladorCafes } from '../controladors/cafes.js';

export const rutesCafes = Router();

rutesCafes.get('/', controladorCafes.llistar);
rutesCafes.post('/', controladorCafes.crear);
rutesCafes.get('/:id', controladorCafes.obtenir);
rutesCafes.put('/:id', controladorCafes.reemplacar);
rutesCafes.patch('/:id', controladorCafes.modificar);
rutesCafes.delete('/:id', controladorCafes.esborrar);

Sis línies que es llegeixen com el mapa d'URIs de 02-02. Quan a 03-04 arribi la validació i a 03-06 l'autenticació, s'inseriran aquí com a middleware abans del controlador, i el fitxer continuarà sent llegible d'una ullada:

// Així quedarà al final del mòdul (avançament):
rutesCafes.post('/', autenticar, exigirRol('empleat'), validar(esquemaCrearCafe), controladorCafes.crear);

  1. Els paràmetres de col·lecció: filtres i cerca

Amb tot això en marxa, els filtres de 02-06 ja funcionen:

# Filtre simple
curl -s "http://localhost:3000/v1/cafes?origen=Colòmbia" | jq '.total'
1
# Filtres combinats (I lògic) i rang de preu
curl -s "http://localhost:3000/v1/cafes?torrefaccio=clar&preuMax=15" | jq '.dades[].nom'
"Etiòpia Yirgacheffe"
# Cerca de text: mira nom, origen i notes de tast
curl -s "http://localhost:3000/v1/cafes?q=xocolata" | jq '.dades[].nom'
"Colòmbia Huila"

Una precisió sobre ?q=: la nostra implementació en memòria és un includes() sense accents ni tolerància a errades. Amb SQLite (03-05) farem servir LIKE, i en una botiga real això acabaria en un motor de cerca dedicat. El contracte de 02-06 va ser deliberadament prudent i només promet "coincidència parcial sobre nom, origen i notes de tast", que és el que podem complir.

  1. Ordenació amb - i desempat per id

curl -s "http://localhost:3000/v1/cafes?ordenar=-preuEuros" | jq '.dades[] | {nom, preuEuros}'
{ "nom": "Etiòpia Yirgacheffe", "preuEuros": 14.5 }
{ "nom": "Colòmbia Huila", "preuEuros": 12.9 }
# Camp inexistent → 400 del catàleg, no s'ignora en silenci
curl -s "http://localhost:3000/v1/cafes?ordenar=color" | jq .error
{
  "codi": "parametre_invalid",
  "missatge": "El camp d'ordenació 'color' no existeix.",
  "detalls": [
    { "camp": "ordenar", "codi": "valor_desconegut", "missatge": "El camp d'ordenació 'color' no existeix." }
  ]
}

El desempat per id que afegeix interpretarOrdenar no és una mania: sense ell, l'ordenació per un camp amb valors repetits no està definida, i l'ordre pot variar entre dues consultes idèntiques. Com que la paginació trosseja aquella llista, el client veuria elements duplicats en una pàgina i absents en una altra. Tota ordenació paginada necessita un desempat per una clau única.

  1. Paginació i la capçalera Link

// src/controladors/paginacio.js

/**
 * Construeix les relacions de la capçalera Link (RFC 8288) per a una
 * col·lecció paginada per desplaçament, CONSERVANT tots els filtres de la
 * petició original: sense això, el client que segueix 'next' perd el
 * filtre i rep resultats que no havia demanat.
 */
export function construirCapcaleraLink({ req, limit, desplacament, total }) {
  // Reconstruïm la URL absoluta de la petició actual.
  const url = new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`);

  /** Retorna la URL actual amb un altre desplaçament. */
  const ambDesplacament = (valor) => {
    const copia = new URL(url);
    copia.searchParams.set('limit', String(limit));
    copia.searchParams.set('desplacament', String(valor));
    return copia.toString();
  };

  const enllacos = {};
  const ultimDesplacament = Math.max(0, Math.floor((total - 1) / limit) * limit);

  if (desplacament + limit < total) {
    enllacos.next = ambDesplacament(desplacament + limit);
  }
  if (desplacament > 0) {
    enllacos.prev = ambDesplacament(Math.max(0, desplacament - limit));
  }
  if (total > limit) {
    enllacos.first = ambDesplacament(0);
    enllacos.last = ambDesplacament(ultimDesplacament);
  }

  return enllacos;
}

Punts fins d'aquesta funció:

  • new URL(...) amb searchParams fa la codificació per nosaltres. Concatenar cadenes a mà es trenca tan bon punt un filtre porta un espai o una ç.
  • next només existeix si hi ha més elements i prev només si no som a la primera pàgina. L'absència d'un enllaç és informació: significa "no n'hi ha més".
  • first i last només si hi ha més d'una pàgina, per no embrutar la resposta quan no calen.

Prova-ho amb limit=1 per forçar diverses pàgines:

curl -i -s "http://localhost:3000/v1/cafes?limit=1&ordenar=nom" | grep -i "^link"
Link: <http://localhost:3000/v1/cafes?limit=1&ordenar=nom&desplacament=1>; rel="next", <http://localhost:3000/v1/cafes?limit=1&ordenar=nom&desplacament=0>; rel="first", <http://localhost:3000/v1/cafes?limit=1&ordenar=nom&desplacament=1>; rel="last"

Fixa't que ordenar=nom sobreviu als tres enllaços. I al cos, total continua sent 2 encara que dades porti un sol element: és el nombre de coincidències, no el de la pàgina.

  1. Selecció de camps amb camps

curl -s "http://localhost:3000/v1/cafes?camps=nom,preuEuros" | jq '.dades[0]'
{ "id": "caf_001", "nom": "Etiòpia Yirgacheffe", "preuEuros": 14.5 }

L'id apareix encara que no s'hagi demanat, perquè una resposta sense identificador no permet navegar enlloc. És la decisió de 02-05: camps retalla, però mai per sota del mínim utilitzable.

  1. Crear: POST amb 201 i Location

// src/controladors/cafes.js  (afegir a l'objecte controladorCafes)

/** POST /v1/cafes */
crear(req, res) {
  const dades = req.body;

  // Comprovació provisional. A 03-04 la substitueix validar(esquemaCrearCafe).
  const obligatoris = ['nom', 'origen', 'torrefaccio', 'preuEuros', 'estoc'];
  const mancants = obligatoris.filter((camp) => dades?.[camp] === undefined);
  if (mancants.length > 0) {
    return res.status(400).json({
      error: {
        codi: 'dades_invalides',
        missatge: 'Falten camps obligatoris.',
        detalls: mancants.map((camp) => ({
          camp,
          codi: 'requerit',
          missatge: `El camp '${camp}' és obligatori.`,
        })),
      },
    });
  }

  const cafe = serveiCafes.crear(dades);
  const representacio = cafeARepresentacio(cafe);

  // La capçalera Location és OBLIGATÒRIA en tota creació (02-04).
  res.set('Location', `/v1/cafes/${cafe.id}`);
  res.status(201).json(representacio);
},
curl -i -s -X POST http://localhost:3000/v1/cafes \
  -H "Content-Type: application/json" \
  -d '{
    "nom": "Kenya Nyeri",
    "origen": "Kenya",
    "torrefaccio": "mitja",
    "preuEuros": 16.75,
    "estoc": 40,
    "notesTast": ["grosella", "tomàquet", "cítric"]
  }'
HTTP/1.1 201 Created
Location: /v1/cafes/caf_003
Content-Type: application/json; charset=utf-8

{"id":"caf_003","nom":"Kenya Nyeri","origen":"Kenya","torrefaccio":"mitja","preuEuros":16.75,"estoc":40,"notesTast":["grosella","tomàquet","cítric"],"descripcio":null,"dataCreacio":"2026-03-14T10:41:22.113Z","_links":{"self":{"href":"/v1/cafes/caf_003"},"ressenyes":{"href":"/v1/cafes/caf_003/ressenyes"}}}

Comprova l'anada i tornada dels cèntims: hem enviat 16.75, el servei ha desat 1675 i el mapejador retorna 16.75. I observa que descripcio surt com a null encara que no l'hàgim enviada: camps sempre presents, com vam decidir a 02-05.

Per què el cos del 201 porta la representació completa en comptes d'estar buit: estalvia al client un GET immediat per conèixer l'id i la dataCreacio que ha generat el servidor. Location és obligatòria; el cos és una cortesia molt rendible.

  1. Reemplaçar i modificar: PUT i PATCH

// src/controladors/cafes.js  (continuació)

/** PUT /v1/cafes/:id — reemplaçament complet */
reemplacar(req, res) {
  const cafe = serveiCafes.reemplacar(req.params.id, req.body);
  if (!cafe) return noTrobat(res, req.params.id);
  res.status(200).json(cafeARepresentacio(cafe));
},

/** PATCH /v1/cafes/:id — modificació parcial amb Merge Patch */
modificar(req, res) {
  const tipus = req.get('Content-Type') ?? '';

  // El contracte de 02-03 admet NOMÉS merge-patch (i application/json per
  // comoditat). JSON Patch (application/json-patch+json) es rebutja amb
  // 415 i la capçalera Accept-Patch que diu què SÍ que s'admet.
  const admesos = ['application/merge-patch+json', 'application/json'];
  const esAdmes = admesos.some((t) => tipus.startsWith(t));

  if (!esAdmes) {
    res.set('Accept-Patch', 'application/merge-patch+json');
    return res.status(415).json({
      error: {
        codi: 'format_no_suportat',
        missatge: `El tipus '${tipus}' no s'admet a PATCH. Fes servir application/merge-patch+json.`,
        detalls: [],
      },
    });
  }

  const cafe = serveiCafes.modificar(req.params.id, req.body);
  if (!cafe) return noTrobat(res, req.params.id);
  res.status(200).json(cafeARepresentacio(cafe));
},
# PATCH correcte: només es toca l'estoc
curl -s -X PATCH http://localhost:3000/v1/cafes/caf_001 \
  -H "Content-Type: application/merge-patch+json" \
  -d '{"estoc": 95}' | jq '{nom, estoc, preuEuros}'
{ "nom": "Etiòpia Yirgacheffe", "estoc": 95, "preuEuros": 14.5 }

El nom i el preu continuen intactes: això és exactament el que promet PATCH i el que el distingeix de PUT, que hauria exigit reenviar el recurs sencer.

# PATCH amb JSON Patch: 415 + Accept-Patch
curl -i -s -X PATCH http://localhost:3000/v1/cafes/caf_001 \
  -H "Content-Type: application/json-patch+json" \
  -d '[{"op":"replace","path":"/estoc","value":95}]' | head -5
HTTP/1.1 415 Unsupported Media Type
Accept-Patch: application/merge-patch+json
Content-Type: application/json; charset=utf-8

La capçalera Accept-Patch és la que converteix un rebuig en una resposta útil: no només diu "això no", diu "això sí". És la mateixa filosofia que l'Allow en un 405.

  1. Esborrar: DELETE lògic amb 204

// src/controladors/cafes.js  (continuació)

/** DELETE /v1/cafes/:id — esborrat lògic */
esborrar(req, res) {
  const esborrat = serveiCafes.esborrar(req.params.id);
  if (!esborrat) return noTrobat(res, req.params.id);

  // 204 No Content: sense cos. Ni {} ni null: RES.
  res.status(204).end();
},
curl -i -s -X DELETE http://localhost:3000/v1/cafes/caf_003 | head -2
curl -s http://localhost:3000/v1/cafes/caf_003 | jq .error.codi
HTTP/1.1 204 No Content
"cafe_no_trobat"

El registre continua a l'array amb actiu: false —això és l'esborrat lògic de 02-03—, però l'API es comporta com si no existís. Es conserva perquè les comandes històriques referencien aquell cafè i esborrar-lo de debò deixaria factures òrfenes.

Un detall sobre idempotència: el segon DELETE sobre el mateix id retorna 404. Això no trenca la idempotència de 02-03: l'estat del servidor és idèntic després d'un o de cent DELETE, que és el que exigeix la definició. El codi de resposta pot diferir.

  1. Comandes i els enllaços d'acció segons l'estat

Afegim el magatzem de comandes i la seva lectura, que és on llueix la hipermèdia selectiva.

// src/repositoris/comandes-memoria.js

const comandes = [
  {
    id: 'com_5001',
    clientId: 'cli_842',
    estat: 'pendent_pagament',
    linies: [
      { cafeId: 'caf_001', nom: 'Etiòpia Yirgacheffe', quantitat: 2, preuCentims: 1450 },
    ],
    totalCentims: 2900, // 29,00 € — la suma de les línies, en cèntims
    dataCreacio: '2026-03-14T10:30:00Z',
    dataPagament: null,
    dataEnviament: null,
  },
];

export const repositoriComandes = {
  buscar({ clientId, estat, limit = 20, desplacament = 0 } = {}) {
    let resultat = [...comandes];
    if (clientId !== undefined) resultat = resultat.filter((c) => c.clientId === clientId);
    if (estat !== undefined) resultat = resultat.filter((c) => c.estat === estat);

    const total = resultat.length;
    // Ordre per defecte de 02-06: les més recents primer.
    resultat.sort((a, b) => b.dataCreacio.localeCompare(a.dataCreacio) || a.id.localeCompare(b.id));

    return {
      elements: resultat.slice(desplacament, desplacament + limit).map((c) => ({ ...c })),
      total,
    };
  },

  buscarPerId(id) {
    const comanda = comandes.find((c) => c.id === id);
    return comanda ? { ...comanda } : undefined;
  },
};
// src/serveis/comandes.js
import { repositoriComandes } from '../repositoris/comandes-memoria.js';

export const serveiComandes = {
  llistar(criteris) {
    return repositoriComandes.buscar(criteris);
  },
  obtenir(id) {
    return repositoriComandes.buscarPerId(id);
  },
};
// src/controladors/comandes.js
import { serveiComandes } from '../serveis/comandes.js';
import { comandaARepresentacio, aResumDeColeccio } from '../serveis/mapejadors.js';
import { construirCapcaleraLink } from './paginacio.js';

export const controladorComandes = {
  /** GET /v1/comandes */
  llistar(req, res) {
    const limit = req.query.limit === undefined ? 20 : Number(req.query.limit);
    const desplacament =
      req.query.desplacament === undefined ? 0 : Number(req.query.desplacament);

    const { elements, total } = serveiComandes.llistar({
      clientId: req.query.clientId,
      estat: req.query.estat,
      limit,
      desplacament,
    });

    const enllacos = construirCapcaleraLink({ req, limit, desplacament, total });
    if (Object.keys(enllacos).length > 0) res.links(enllacos);

    res.status(200).json({
      dades: elements.map(comandaARepresentacio).map(aResumDeColeccio),
      total,
    });
  },

  /** GET /v1/comandes/:id */
  obtenir(req, res) {
    const comanda = serveiComandes.obtenir(req.params.id);
    if (!comanda) {
      return res.status(404).json({
        error: {
          codi: 'comanda_no_trobada',
          missatge: `No existeix cap comanda amb l'identificador '${req.params.id}'.`,
          detalls: [],
        },
      });
    }
    res.status(200).json(comandaARepresentacio(comanda));
  },
};
// src/rutes/comandes.js
import { Router } from 'express';
import { controladorComandes } from '../controladors/comandes.js';

export const rutesComandes = Router();

rutesComandes.get('/', controladorComandes.llistar);
rutesComandes.get('/:id', controladorComandes.obtenir);
// POST /v1/comandes arriba a 03-04 (validació) i 03-05 (transacció amb estoc).

I a src/rutes/index.js, una línia més:

import { rutesComandes } from './comandes.js';
rutesV1.use('/comandes', rutesComandes);

Ara la prova interessant:

curl -s http://localhost:3000/v1/comandes/com_5001 | jq '{estat, totalEuros, _links}'
{
  "estat": "pendent_pagament",
  "totalEuros": 29,
  "_links": {
    "self": { "href": "/v1/comandes/com_5001" },
    "client": { "href": "/v1/clients/cli_842" },
    "pagar": { "href": "/v1/comandes/com_5001/pagament", "method": "POST" },
    "anullar": { "href": "/v1/comandes/com_5001/anullacio", "method": "POST" }
  }
}

Això és la hipermèdia selectiva de 01-05 i 02-05 funcionant: la comanda està pendent_pagament, així que ofereix pagar i anullar, i no ofereix factura ni retornar, perquè ara mateix no són possibles. Canvia a mà l'estat a 'enviat' al repositori, reinicia i veuràs aparèixer factura, enviament i retornar, i desaparèixer pagar. La SPA de la Botiga Aroma pinta els seus botons a partir d'aquest objecte i no necessita replicar la màquina d'estats.

  1. async/await i el parany del throw a Express 4

Avui tot és síncron perquè el magatzem és un array. A 03-05, amb base de dades, i a 03-06, amb bcrypt, els gestors seran async. I allà hi ha un parany que convé conèixer abans d'ensopegar-hi.

// Gestor asíncron que falla. Express 4 NO captura aquest error.
rutesCafes.get('/:id', async (req, res) => {
  const cafe = await serveiCafes.obtenir(req.params.id);
  if (!cafe) throw new Error('no trobat'); // ← es perd
  res.json(cafeARepresentacio(cafe));
});

Què passa exactament: una funció async no llança, retorna una promesa rebutjada. Express 4 crida el gestor i descarta el valor retornat, així que ningú no està mirant aquella promesa. El resultat és el pitjor possible: UnhandledPromiseRejection a la consola i la petició penjada fins que el client esgota el seu temps d'espera. No hi ha 500, no hi ha resposta: silenci.

Amb codi síncron, en canvi, Express sí que captura:

rutesCafes.get('/:id', (req, res) => {
  throw new Error('això sí que ho captura Express'); // → 500 amb la pàgina HTML per defecte
});

La solució és un embolcall de cinc línies:

/** Embolcalla un gestor async i encamina qualsevol rebuig cap a next(). */
export function asincron(fn) {
  return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}

// Ús:
rutesCafes.get('/:id', asincron(controladorCafes.obtenir));

Promise.resolve(...) funciona tant si fn és async com si no, i .catch(next) reenvia l'error al middleware d'errors. Escriurem aquest fitxer de debò a 03-07, juntament amb la classe ErrorApi i el middleware que li dona sentit; avui queda't amb el diagnòstic, que és el que estalvia una tarda de depuració.

Com a nota de futur: Express 5 ja captura els rebuigs de promeses dels gestors automàticament i fa innecessari l'embolcall. És una de les raons de pes per migrar quan el projecte ho permeti (02-07 sobre canvis i migracions).

Errors Comuns i Consells

1. Ficar lògica de negoci al controlador. Si el controlador calcula descomptes o comprova l'estoc, aquella regla no es pot provar sense HTTP ni reutilitzar des d'un script. El controlador només tradueix.

2. Que el servei retorni codis d'estat. return { estat: 404, missatge: '...' } des d'un servei és la frontera trencada disfressada. El servei retorna dades o llança un error de domini (03-07); el controlador decideix el codi.

3. Confondre total amb la longitud de la pàgina. total és el nombre de coincidències del filtre; dades.length és el que cap en aquesta pàgina. Retornar dades.length com a total trenca el càlcul del nombre de pàgines a tots els clients.

4. Perdre els filtres a la capçalera Link. Construir next com a /v1/cafes?desplacament=20 a seques és una fallada clàssica: el client segueix l'enllaç i rep la col·lecció sense filtrar. Parteix sempre de req.originalUrl.

5. Oblidar Location al 201. És obligatòria al contracte. Un client que crea un recurs i no sap on és ha d'endevinar la URL.

6. Retornar cos en un 204. res.status(204).json({}) és contradictori: 204 significa "no hi ha contingut". Fes servir .end().

7. Convertir euros a cèntims amb euros * 100. 16.75 * 100 pot donar 1674.9999999999998, i en truncar perds un cèntim. Fes servir Math.round, com fa eurosACentims.

8. Acceptar qualsevol camp a ordenar. Sense llista blanca, amb SQL al darrere és injecció directa. La llista blanca és innegociable.

Consell: quan dubtis d'en quina capa posar una cosa, fes-te la pregunta "això continuaria tenint sentit si l'API fos una aplicació d'escriptori?". Si la resposta és sí, és del servei. Si parla de capçaleres, codis o URLs, és del controlador.

Exercicis

Exercici 1

Implementa el filtre disponible de 02-06 amb el seu comportament complet: ?disponible=true retorna només cafès amb estoc > 0, ?disponible=false només els exhaurits, i qualsevol altre valor (?disponible=si, ?disponible=1) ha de produir 400 parametre_invalid en comptes de tractar-se com a false. Explica per què el silenci és pitjor que l'error.

Exercici 2

L'equip de la SPA informa d'un bug: en recórrer /v1/cafes?ordenar=torrefaccio&limit=1 pàgina a pàgina, el cafè caf_002 apareix dues vegades i un altre no apareix mai. Amb 40 cafès al catàleg i només tres valors de torrefaccio, diagnostica'n la causa, explica per què l'ordre pot canviar entre dues consultes i digues quina línia del codi ho resol.

Exercici 3

Escriu el controlador de GET /v1/clients/:id/comandes (subrecurs del mapa d'URIs de 02-02). Ha de retornar les comandes d'aquell client amb l'embolcall i la paginació habituals, i 404 client_no_trobat si el client no existeix. Suposa un serveiClients.obtenir(id) ja disponible. Quina diferència hi ha entre aquesta ruta i GET /v1/comandes?clientId=cli_842, i per què el contracte té les dues?

Solucions

Solució 1

A controladors/cafes.js, dins de llistar, abans de cridar el servei:

let disponible;
if (req.query.disponible !== undefined) {
  if (req.query.disponible !== 'true' && req.query.disponible !== 'false') {
    return res.status(400).json({
      error: {
        codi: 'parametre_invalid',
        missatge: "El paràmetre 'disponible' només admet 'true' o 'false'.",
        detalls: [
          {
            camp: 'disponible',
            codi: 'valor_invalid',
            missatge: `S'ha rebut '${req.query.disponible}'.`,
          },
        ],
      },
    });
  }
  disponible = req.query.disponible === 'true';
}

I es passa disponible al servei en lloc de la conversió en línia.

Per què el silenci és pitjor: amb la conversió ingènua disponible === 'true', la petició ?disponible=si s'interpreta com a false i retorna els cafès exhaurits, just el contrari del que el client demanava. El client rep un 200 OK amb dades incorrectes i no té manera de detectar-ho; el bug es descobreix setmanes després amb clients reals veient un catàleg buit. Un 400 immediat assenyala l'error a la primera prova de l'integrador. És el principi de 02-06: paràmetre o valor desconegut, error explícit, mai interpretació creativa.

Solució 2

Causa: torrefaccio només té tres valors possibles (clar, mitja, fosc), així que amb 40 cafès hi ha grups enormes d'empatats. En ordenar només per torrefaccio, l'ordre dins de cada grup no està definit: Array.prototype.sort no garanteix estabilitat respecte d'un ordre previ si l'array de partida canvia, i amb SQL (03-05) el motor pot retornar les files empatades en l'ordre que li resulti més barat, que depèn del pla d'execució, de la memòria cau i de l'estat dels índexs.

Per què falla la paginació: cada pàgina és una consulta independent. Si a la consulta de la pàgina 1 caf_002 queda a la posició 10 i a la de la pàgina 2 el motor el col·loca a la 25, aquell cafè apareix a totes dues pàgines i un altre cau al forat. És la manifestació clàssica del problema, i per això és tan desconcertant: la fallada depèn del repartiment de dades i no es reprodueix amb dos cafès de prova.

Què ho resol: l'última línia d'interpretarOrdenar:

criteris.push({ camp: 'id', descendent: false });

En afegir l'id —únic— com a darrer criteri, l'ordre total queda completament determinat i és idèntic a totes les consultes. La lliçó general: qualsevol ordenació que s'hagi de paginar ha d'acabar en una clau única.

Solució 3

// src/controladors/clients.js
import { serveiClients } from '../serveis/clients.js';
import { serveiComandes } from '../serveis/comandes.js';
import { comandaARepresentacio, aResumDeColeccio } from '../serveis/mapejadors.js';
import { construirCapcaleraLink } from './paginacio.js';

export const controladorClients = {
  /** GET /v1/clients/:id/comandes */
  llistarComandes(req, res) {
    // Primer, que el client existeixi: si no, 404 del client, no llista buida.
    if (!serveiClients.obtenir(req.params.id)) {
      return res.status(404).json({
        error: {
          codi: 'client_no_trobat',
          missatge: `No existeix cap client amb l'identificador '${req.params.id}'.`,
          detalls: [],
        },
      });
    }

    const limit = req.query.limit === undefined ? 20 : Number(req.query.limit);
    const desplacament =
      req.query.desplacament === undefined ? 0 : Number(req.query.desplacament);

    const { elements, total } = serveiComandes.llistar({
      clientId: req.params.id, // ← l'id ve de la RUTA, no de la query
      estat: req.query.estat,
      limit,
      desplacament,
    });

    const enllacos = construirCapcaleraLink({ req, limit, desplacament, total });
    if (Object.keys(enllacos).length > 0) res.links(enllacos);

    res.status(200).json({
      dades: elements.map(comandaARepresentacio).map(aResumDeColeccio),
      total,
    });
  },
};

Diferència amb GET /v1/comandes?clientId=cli_842:

/v1/clients/cli_842/comandes /v1/comandes?clientId=cli_842
Client inexistent 404 client_no_trobat 200 amb llista buida
Semàntica "les comandes d'aquest client" "totes les comandes, filtrades"
Consumidor típic L'àrea de client de la SPA El panell intern
Permisos (03-06) El client mateix Rol empleat

Per què existeixen les dues: són punts de vista diferents sobre les mateixes dades. El subrecurs expressa una relació de pertinença, és el que navega la SPA des de la fitxa del client i permet una regla d'autorització natural ("només tu veus el que és teu"). La col·lecció amb filtre és l'eina del panell intern, que combina clientId amb estat i dataDes. L'important, i per això aquest exercici, és que la implementació no es duplica: tots dos controladors criden el mateix serveiComandes.llistar. La duplicació seria el problema; dues rutes cap a un únic servei, no.

Conclusió

L'API ja implementa el contracte de cafès de principi a fi. Saps què porta req —i que tot arriba com a text, que un paràmetre repetit es converteix en array i que les capçaleres es llegeixen en minúscules— i què controles de res, inclòs res.links() per a la capçalera Link del RFC 8288. Els paràmetres de col·lecció de 02-06 funcionen de debò: filtres combinats amb I lògic, cerca ?q=, ordenació amb - i desempat obligatori per id, paginació amb els seus valors per defecte i el seu màxim que retorna 400 en comptes de retallar en silenci, i camps per retallar la representació. El cicle d'escriptura és complet: 201 amb Location, PUT de reemplaçament, PATCH amb Merge Patch i el seu 415 acompanyat d'Accept-Patch, i DELETE lògic amb 204 sense cos.

Però el que més rendirà a partir d'ara és la separació en capes. Les rutes són sis línies llegibles; els controladors tradueixen HTTP i no saben de negoci; els serveis no han vist un req a la vida; el mapejador concentra en un sol fitxer les regles de representació de 02-05, inclosos els _links d'acció que apareixen i desapareixen segons l'estat de la comanda. Gràcies a aquesta separació, a 03-05 podrem canviar l'array per SQLite tocant un import, i a 03-08 podrem provar els serveis sense aixecar un servidor.

Queden dues coses evidentment coixes, i totes dues tenen lliçó pròpia. La primera són aquelles comprovacions a mà —camps obligatoris, limit, ordenar— repetides, incompletes i amb missatges escrits un a un: un cos amb preuEuros: "caríssim" entra sense resistència i acaba desat com a NaN. A 03-04, Validació de dades d'entrada, escriurem els esquemes Zod de cafès i comandes i un únic middleware validar(esquema, origen) que rebutgi l'entrada estricta, converteixi els tipus dels paràmetres de consulta i retorni totes les fallades alhora a detalls, exactament com vam fixar a 02-04.

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

Mòdul 1: Introducció a les APIs RESTful

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats