Tot el que hem dissenyat fins ara funciona igual de bé amb 5 cafès que amb 5.000… fins que arriba el primer GET /v1/cafes sobre un catàleg real i la resposta pesa 40 MB. Les col·leccions són el punt on una API ben dissenyada es distingeix d'una que aguanta només a l'entorn de desenvolupament. Aquesta lliçó dissenya les col·leccions grans de la Botiga Aroma: com es filtren, com s'ordenen, com es parteixen en pàgines —amb els tres models existents i les seves conseqüències reals— i com s'hi cerca text. És disseny de contracte, no d'implementació: aquí es decideix quins paràmetres existeixen i què prometen, i el mòdul 3 els implementarà tal qual.

Contingut

  1. Per què una col·lecció sense límits és un problema
  2. Filtratge: convenis de query params
  3. Filtres per rang i per múltiples valors
  4. Per què no cal inventar un llenguatge de consulta a la URL
  5. Ordenació
  6. Paginació per offset
  7. Paginació per pàgina
  8. Paginació per cursor
  9. Comparativa i deep paging
  10. On viatgen les metadades de paginació
  11. Cerca de text
  12. Valors per defecte, límits i documentació

  1. Per què una col·lecció sense límits és un problema

GET /v1/cafes sense restriccions sembla inofensiu. Amb 137 cafès ho és. Amb 50.000 referències, o amb GET /v1/comandes sobre l'històric complet, deixa de ser-ho per quatre motius simultanis:

Problema Què passa
Base de dades Un SELECT sense LIMIT recorre i materialitza la taula sencera
Memòria del servidor Serialitzar 50.000 objectes a JSON pot consumir centenars de MB; amb diverses peticions alhora, el procés mor
Xarxa i client 40 MB per una pantalla que mostra 20 files; al mòbil, inacceptable
Disponibilitat Qualsevol pot tombar l'API repetint aquesta crida: és una denegació de servei gratuïta

L'últim punt és l'important i el que se sol passar per alt: una col·lecció sense límit màxim és un vector d'atac, i no cal mala intenció —n'hi ha prou amb un script d'un soci amb un bucle mal escrit—. Per això la primera decisió no és "com pagino", sinó "la paginació és obligatòria i el servidor la imposa encara que el client no la demani".

  1. Filtratge: convenis de query params

Com vam fixar a 02-02, els criteris de selecció van a la query string. El conveni base de la Botiga Aroma és el més simple possible: un paràmetre per camp, igualtat exacta.

# Cafès de Colòmbia
curl "https://api.botigaaroma.example/v1/cafes?origen=Col%C3%B2mbia"

# Cafès de Colòmbia amb torrefacció mitjana (els filtres es combinen amb I lògic)
curl "https://api.botigaaroma.example/v1/cafes?origen=Col%C3%B2mbia&torrefaccio=mitja"

# Comandes pagades d'un client
curl "https://api.botigaaroma.example/v1/comandes?clientId=cli_842&estat=pagat"

Regles del contracte:

  • El nom del paràmetre és el nom del camp a la representació (origen, torrefaccio, estat, clientId). Previsibilitat: qui ha vist el JSON ja sap filtrar.
  • Diversos filtres es combinen amb I lògic. Mai amb O; per a això hi ha el multivalor de la secció 3.
  • Un filtre desconegut retorna 400 amb parametre_invalid. Ignorar-lo en silenci és pitjor: el client creu que ha filtrat i rep tot el catàleg.
  • Un valor invàlid retorna 400: ?torrefaccio=torradissima no és un enumerat vàlid.
  • Sense resultats és 200 amb {"dades": [], "total": 0}, mai 404 (02-03).
  • Els valors es codifiquen a la URL: ?origen=Eti%C3%B2pia.

Filtres publicats a la v1 de la Botiga Aroma:

Col·lecció Filtres
/cafes origen, torrefaccio, preuMin, preuMax, disponible, q
/comandes clientId, estat, dataDes, dataFins
/ressenyes cafeId, clientId, estat, puntuacioMin
/clients q (nom o correu)

La llista de filtres és tancada i forma part del contracte. No es filtra per qualsevol camp "perquè l'ORM ho permet": cada filtre publicat s'ha de documentar, validar, provar, indexar i mantenir per sempre.

  1. Filtres per rang i per múltiples valors

3.1. Rangs

El conveni de la Botiga Aroma són dos paràmetres amb sufix, tots dos inclusius:

# Cafès entre 10 i 15 euros, tots dos inclosos
curl "https://api.botigaaroma.example/v1/cafes?preuMin=10&preuMax=15"

# Comandes de març del 2026
curl "https://api.botigaaroma.example/v1/comandes?dataDes=2026-03-01&dataFins=2026-03-31"

# Ressenyes de 4 estrelles o més
curl "https://api.botigaaroma.example/v1/ressenyes?puntuacioMin=4"

Convenció de sufixos: Min/Max per a nombres, Des/Fins per a dates. Els dos extrems són opcionals i independents: ?preuMin=10 significa "de 10 € en amunt".

Alternatives que existeixen i que la Botiga Aroma no fa servir, perquè les reconeguis:

Estil Exemple Comentari
Sufixos (Botiga Aroma) ?preuMin=10&preuMax=15 Llegible, fàcil de validar i de documentar
Operadors al valor ?preu=gte:10,lte:15 Compacte, però cal parsejar el valor
Claudàtors ?preu[gte]=10&preu[lte]=15 Estil JSON:API; lleig de codificar a la URL
Rang amb guionet ?preu=10-15 Ambigu amb negatius i amb decimals

3.2. Múltiples valors

Per a "això o allò" sobre el mateix camp, llista separada per comes:

# Cafès de torrefacció clara o mitjana
curl "https://api.botigaaroma.example/v1/cafes?torrefaccio=clar,mitja"

# Comandes pagades o enviades
curl "https://api.botigaaroma.example/v1/comandes?estat=pagat,enviat"

L'alternativa —repetir el paràmetre, ?torrefaccio=clar&torrefaccio=mitja— és igual de vàlida i la fan servir moltes APIs, però el seu comportament depèn del framework i de la biblioteca del client (alguns es queden amb el darrer valor). La coma és explícita i no admet interpretacions. Limitació assumida: els valors no poden contenir comes; a la Botiga Aroma només s'admet multivalor en enumerats i identificadors, on això no passa.

Resumint la semàntica completa, que cal documentar de manera explícita:

?torrefaccio=clar,mitja&origen=Colòmbia
   →  (torrefaccio = clar O torrefaccio = mitja)  I  (origen = Colòmbia)

  1. Per què no cal inventar un llenguatge de consulta a la URL

Tard o d'hora algú proposarà una cosa així:

GET /v1/cafes?filtre=(origen eq 'Colòmbia' and preu gt 10) or torrefaccio eq 'clar'
GET /v1/cafes?where={"$or":[{"preu":{"$gt":10}},{"torrefaccio":"clar"}]}

És temptador: resol qualsevol consulta futura sense tocar l'API. I gairebé sempre és un error:

  • Cal escriure un parser i un avaluador, amb els seus errors de sintaxi, els seus missatges i els seus casos límit. És un projecte, no un paràmetre.
  • Risc d'injecció: passar l'expressió al motor de dades sense traduir-la amb cura és una via directa a NoSQL injection o SQL injection (04-02).
  • Impossible d'acotar: el client pot construir consultes amb un cost arbitrari. Adeu als índexs i a les previsions de capacitat.
  • Impossible de documentar bé a OpenAPI: el paràmetre és una cadena lliure, així que no hi ha validació automàtica, ni autocompletat, ni mocks útils (02-08).
  • La memòria cau en pateix: infinites combinacions d'URL, cap de reutilitzable.

Hi ha estàndards seriosos per a això —OData i GraphQL (01-07)— i la lliçó és que, si de debò necessites consultes arbitràries, n'adoptes un amb els ulls oberts, no t'inventes un dialecte. La Botiga Aroma es queda amb filtres explícits: cobreixen els casos d'ús reals, es documenten sols i són predictibles en cost. Si apareix una consulta legítima que no hi encaixa, s'hi afegeix un filtre nou (canvi retrocompatible, 02-07) o es crea un recurs específic.

  1. Ordenació

Un sol paràmetre, ordenar, amb el nom del camp i un guionet al davant per a l'ordre descendent:

# Del més barat al més car
curl "https://api.botigaaroma.example/v1/cafes?ordenar=preuEuros"

# Del més car al més barat
curl "https://api.botigaaroma.example/v1/cafes?ordenar=-preuEuros"

# Comandes més recents primer
curl "https://api.botigaaroma.example/v1/comandes?ordenar=-dataCreacio"

# Ordre múltiple: per torrefacció ascendent i, dins de cada torrefacció, per preu descendent
curl "https://api.botigaaroma.example/v1/cafes?ordenar=torrefaccio,-preuEuros"

Detalls del contracte:

  • Camps ordenables tancats i documentats per col·lecció. /cafes: nom, preuEuros, estoc, dataCreacio, puntuacioMitjana. Un camp no permès retorna 400 amb parametre_invalid. La raó és de rendiment: cada camp ordenable necessita el seu índex.
  • Ordre per defecte, també documentat: /cafes per nom ascendent, /comandes per -dataCreacio, /ressenyes per -dataCreacio.
  • Ordre múltiple amb comes, de major a menor prioritat.

L'ordre estable, o per què això importa més del que sembla

Un ordre és estable quan dues peticions idèntiques retornen els elements en el mateix ordre. Sona obvi, però no ho és si ordenes per un camp amb valors repetits: la base de dades pot retornar els empats en qualsevol ordre entre una consulta i la següent.

Conseqüència directa i molt real en paginar:

# 40 cafès costen exactament 12,90 €
curl "https://api.botigaaroma.example/v1/cafes?ordenar=preuEuros&limit=20&desplacament=0"
curl "https://api.botigaaroma.example/v1/cafes?ordenar=preuEuros&limit=20&desplacament=20"

Si el motor resol els empats de manera diferent a cada consulta, hi ha cafès que apareixen a les dues pàgines i cafès que no apareixen a cap. L'usuari veu duplicats i perd elements, i el bug és intermitent i impossible de reproduir en desenvolupament amb 10 files.

Solució, i és contracte: el servidor sempre afegeix un criteri de desempat únic al final de l'ordre sol·licitat, típicament l'id.

?ordenar=preuEuros   →   ORDER BY preuEuros ASC, id ASC   (implícit)

No apareix a la URL, però es documenta: "l'ordre es desempata sempre per id ascendent". Sense això, cap paginació no és fiable.

  1. Paginació per offset

El model més estès: "salta N elements i dona-me'n M".

# Primera pàgina
curl "https://api.botigaaroma.example/v1/cafes?limit=20&desplacament=0"

# Segona pàgina
curl "https://api.botigaaroma.example/v1/cafes?limit=20&desplacament=20"

# Pàgina 7
curl "https://api.botigaaroma.example/v1/cafes?limit=20&desplacament=120"
{
  "dades": [ { "id": "caf_001", "nom": "Etiòpia Yirgacheffe", "preuEuros": 14.50 } ],
  "total": 137
}

A favor: és trivial d'entendre, permet saltar a qualsevol pàgina (desplacament = (pagina - 1) * limit) i dona el total, amb la qual cosa el client pot pintar "137 resultats, pàgina 3 de 7".

En contra, dos problemes seriosos que veurem a la secció 9: es degrada amb desplaçaments grans i no és estable davant d'insercions.

  1. Paginació per pàgina

És el mateix model amb una altra aritmètica, més còmoda per a qui pinta paginadors:

curl "https://api.botigaaroma.example/v1/cafes?pagina=3&perPagina=20"
{
  "dades": [ ],
  "total": 137,
  "pagina": 3,
  "perPagina": 20,
  "totalPagines": 7
}

Avantatge: el client no calcula res. Inconvenient: és exactament igual de fràgil que l'offset, perquè per sota es tradueix a un desplaçament; només canvia la manera de demanar-ho. I hi afegeix una ambigüitat clàssica: la primera pàgina és la 1 o la 0? Qualsevol de les dues val mentre estigui documentada; equivocar-se costa un bug de 20 elements que ningú no veu.

Decisió de la Botiga Aroma: no s'ofereixen pagina/perPagina. Un únic mecanisme (limit/desplacament) és més consistent que dos d'equivalents, i el càlcul de la pàgina el fa el client en una línia.

  1. Paginació per cursor

En lloc de "salta 5.000", el servidor lliura un punter opac a la posició on es va quedar.

# Primera pàgina: sense cursor
curl "https://api.botigaaroma.example/v1/comandes?limit=20&ordenar=-dataCreacio"
HTTP/1.1 200 OK
Link: <https://api.botigaaroma.example/v1/comandes?limit=20&cursor=eyJmIjoiMjAyNi0wMy0xNCIsImkiOiJjb21fNTAwMSJ9>; rel="next"
Content-Type: application/json

{
  "dades": [ ],
  "cursorSeguent": "eyJmIjoiMjAyNi0wMy0xNCIsImkiOiJjb21fNTAwMSJ9"
}
# Pàgina següent: s'envia el cursor rebut
curl "https://api.botigaaroma.example/v1/comandes?limit=20&cursor=eyJmIjoiMjAyNi0wMy0xNCIsImkiOiJjb21fNTAwMSJ9"

Com funciona per dins: el cursor codifica (normalment en Base64) els valors de la darrera fila lliurada segons l'ordre vigent —aquí, dataCreacio i id—. La consulta següent no diu "salta 5.000 files", diu "dona'm les files posteriors a (2026-03-14, com_5001)", que l'índex resol a l'instant sigui quina sigui la profunditat.

Regles de la Botiga Aroma per als cursors:

  • Són opacs. La documentació prohibeix expressament descodificar-los o construir-los: el seu contingut pot canviar sense previ avís.
  • Inclouen l'ordre. Un cursor obtingut amb ?ordenar=-dataCreacio no val per a ?ordenar=preuEuros: en barrejar-los, 400 amb parametre_invalid.
  • No hi ha total a les col·leccions paginades per cursor: calcular-lo exigeix comptar tota la taula, que és justament el cost que volíem evitar. Se'n documenta l'absència.
  • No es pot saltar a la pàgina N: només hi ha "següent" i "anterior". És el preu del model.

  1. Comparativa i deep paging

Criteri Offset (desplacament) Per pàgina (pagina) Cursor
Facilitat per al client Alta Molt alta Mitjana
Saltar a la pàgina N No
Total d'elements No (car)
Cost a la base de dades Creix amb la profunditat Creix amb la profunditat Constant
Estable davant d'insercions No No
Escalabilitat Baixa en col·leccions grans Baixa Alta
Cacheabilitat Bona (URLs estables) Bona Mitjana
Ús típic Catàlegs, back-office Webs amb paginador clàssic Feeds, històric, exportacions

9.1. El deep paging

Demanar la pàgina 5.000 amb offset obliga el motor a llegir i descartar 100.000 files abans de retornar-ne 20:

-- El que fa realment ?limit=20&desplacament=100000
SELECT * FROM cafes ORDER BY nom ASC, id ASC LIMIT 20 OFFSET 100000;

El cost creix linealment amb el desplaçament: la pàgina 1 triga mil·lisegons i la 5.000 pot trigar segons i castigar tota la base de dades. Amb cursor, el cost és el mateix a la pàgina 1 que a la 5.000.

Mitigació de la Botiga Aroma: desplacament màxim de 10.000. Superar-lo retorna 400 amb parametre_invalid i un missatge que remet al mecanisme adequat: "per recórrer la col·lecció completa, fes servir la paginació per cursor".

9.2. Elements duplicats i omesos

L'altre problema de l'offset, il·lustrat amb /comandes ordenat per -dataCreacio:

sequenceDiagram
    participant C as Panell intern
    participant A as API
    C->>A: GET /comandes?limit=3&desplacament=0
    A-->>C: [com_5010, com_5009, com_5008]
    Note over A: Entra una comanda nova: com_5011<br/>Tot es desplaça una posició
    C->>A: GET /comandes?limit=3&desplacament=3
    A-->>C: [com_5008, com_5007, com_5006]
    Note over C: com_5008 apareix DUES vegades<br/>i cap comanda no s'ha perdut... aquesta vegada

Amb una inserció, es duplica un element; amb un esborrat, se n'omet un, que és pitjor perquè és invisible. En un catàleg que canvia poc, és tolerable; en una exportació comptable, és inacceptable.

Decisió de la Botiga Aroma:

Col·lecció Model Motiu
/cafes Offset (limit + desplacament) Catàleg petit i estable; el panell necessita saltar a una pàgina i veure el total
/ressenyes Offset Igual, amb volum moderat
/clients Offset Igual
/comandes Cursor, amb offset admès fins a desplacament=10000 Creix sense límit i rep insercions constants
/clients/{id}/comandes Offset Són poques per client

Conviure és legítim sempre que cada col·lecció documenti quin fa servir i els paràmetres no es barregin: enviar cursor i desplacament alhora retorna 400.

  1. On viatgen les metadades de paginació

Tres llocs possibles, i no són excloents.

a) Al cos, aprofitant l'embolcall decidit a 02-05:

{ "dades": [ ], "total": 137 }

b) En capçaleres pròpies, a l'estil de GitHub:

X-Total-Count: 137
X-Pagina: 3

c) A la capçalera Link (RFC 8288), el mecanisme estàndard del web:

Link: <https://api.botigaaroma.example/v1/cafes?limit=20&desplacament=40>; rel="next",
      <https://api.botigaaroma.example/v1/cafes?limit=20&desplacament=0>; rel="first",
      <https://api.botigaaroma.example/v1/cafes?limit=20&desplacament=0>; rel="prev",
      <https://api.botigaaroma.example/v1/cafes?limit=20&desplacament=120>; rel="last"
Criteri Cos Capçaleres X- Link (RFC 8288)
Estàndard No No (X- desaconsellat per la RFC 6648)
Visible amb HEAD No
Còmode en JavaScript Molt Mitjà (cal llegir capçaleres) Mitjà (cal parsejar)
Enllaços ja construïts No No
Funciona amb respostes no JSON No

Decisió de la Botiga Aroma —coherent amb el que es va fixar al mòdul 1—: total al cos i navegació a la capçalera Link.

HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://api.botigaaroma.example/v1/cafes?limit=20&desplacament=60>; rel="next",
      <https://api.botigaaroma.example/v1/cafes?limit=20&desplacament=20>; rel="prev",
      <https://api.botigaaroma.example/v1/cafes?limit=20&desplacament=0>; rel="first",
      <https://api.botigaaroma.example/v1/cafes?limit=20&desplacament=120>; rel="last"

{
  "dades": [ ],
  "total": 137
}

Per què aquesta combinació i no una altra:

  • És exactament la hipermèdia selectiva de nivell 2 que vam decidir a 01-05: enllaços on aporten (navegació), sense convertir el cos en un document hipermèdia.
  • Link és un estàndard amb rel registrats, no una invenció de la casa, i funciona igual per a JSON, CSV o PDF.
  • Els enllaços venen construïts: el client no recalcula desplaçaments ni arrossega els filtres a mà. Fixa't que cada enllaç conserva tots els paràmetres de la petició original —filtres, ordre i camps—, que és justament l'error que més es comet en implementar-ho.
  • total al cos perquè és una dada del resultat, no de la navegació, i el client JavaScript el té a mà sense parsejar capçaleres.
  • X-Total-Count no es fa servir: seria duplicar total amb un nom desaconsellat.

Amb cursor, Link porta només next (i prev si el model ho permet), sense first ni last, i el cos no porta total. I a l'última pàgina no hi ha rel="next": la seva absència és el senyal de fi de col·lecció, i així es documenta.

  1. Cerca de text

Filtrar és igualtat exacta; cercar és una altra cosa: parcial, difusa, amb rellevància. El paràmetre reservat és q:

curl "https://api.botigaaroma.example/v1/cafes?q=yirgacheffe"
curl "https://api.botigaaroma.example/v1/cafes?q=xocolata&torrefaccio=mitja&ordenar=preuEuros"

Contracte de q a la Botiga Aroma:

  • Cerca a nom, origen i notesTast, i així queda documentat (una cerca que no diu on cerca és una caixa negra).
  • Insensible a majúscules i a accents: q=etiopia troba "Etiòpia".
  • Es combina amb els filtres amb I lògic.
  • Mínim 2 caràcters; amb menys, 400 amb parametre_invalid.
  • L'ordre per defecte passa a ser per rellevància quan hi ha q, llevat que s'indiqui ordenar explícitament. I compte: ordenar per rellevància no és estable, així que es desempata per id igual que a la secció 5.

Quan es mereix la cerca un recurs propi?

Quan deixa de ser "filtrar una col·lecció" i es converteix en una funcionalitat amb entitat pròpia:

Senyal Exemple Recurs propi
Cerca en diversos tipus de recurs alhora Cafès, articles del blog i ajuda GET /v1/cerca?q=espresso
Retorna metadades de cerca Puntuació, facetes, suggeriments
Ho resol un altre motor Elasticsearch, OpenSearch
GET /v1/cerca?q=espresso

{
  "dades": [
    { "tipus": "cafe", "id": "caf_002", "titol": "Colòmbia Huila", "rellevancia": 0.91,
      "_links": { "self": { "href": "/v1/cafes/caf_002" } } },
    { "tipus": "article", "id": "art_12", "titol": "Com preparar un bon espresso", "rellevancia": 0.74 }
  ],
  "total": 2,
  "facetes": { "torrefaccio": { "mitja": 1, "fosc": 1 } }
}

La Botiga Aroma no crea /cerca a la v1: amb ?q= sobre cada col·lecció n'hi ha prou. Queda anotat com a candidat per quan existeixi el blog.

Consultes complexes per POST

Hi ha un cas legítim en què la consulta no cap a una URL: informes del panell intern amb molts criteris, llistes llargues d'identificadors o expressions que superen el límit pràctic de longitud d'una URL (uns 2.000 caràcters a la majoria de servidors i intermediaris).

POST /v1/comandes/consultes HTTP/1.1
Content-Type: application/json

{
  "clientIds": ["cli_842", "cli_843", "…600 més…"],
  "estats": ["pagat", "enviat"],
  "dataDes": "2026-01-01",
  "limit": 100
}

És un compromís conscient: es perd la memòria cau i POST deixa de ser "crear" per significar "processa aquesta consulta", i per això el recurs es diu consultes (un substantiu, 02-02) i retorna 200, no 201. La Botiga Aroma no ho inclou a la v1, però deixa escrit el patró per quan el panell ho necessiti, en lloc d'improvisar-lo.

  1. Valors per defecte, límits i documentació

Aquí es tanca el contracte de col·leccions, i aquesta taula és la que el mòdul 3 implementarà literalment:

Paràmetre Per defecte Màxim Comportament en excedir-lo
limit 20 100 400 amb parametre_invalid
desplacament 0 10.000 400 amb parametre_invalid
camps Tots 30 camps 400
expandir Cap 3 relacions 400
q 100 caràcters 400
ordenar Segons col·lecció 3 criteris 400

Dues decisions que convé raonar:

  • limit per sobre del màxim retorna 400, no es retalla en silenci. Retallar és temptador ("sigues amable amb el client"), però deixa el consumidor creient que ha rebut 1.000 elements quan només en té 100: paginarà malament i perdrà dades sense assabentar-se'n. Fallar de manera visible és més amable a mitjà termini.
  • El servidor pagina encara que el client no ho demani. GET /v1/cafes sense paràmetres retorna 20 elements, total i la capçalera Link amb next. No hi ha manera de demanar "tot".

I així queda documentat cada paràmetre a la referència (02-08): nom, tipus, obligatorietat, valor per defecte, valors admesos, comportament davant de valors invàlids i un exemple executable. A OpenAPI, això s'escriu una sola vegada com a paràmetres reutilitzables i es referencia des de cada col·lecció:

# Fragment del contracte: paràmetres comuns de col·lecció
components:
  parameters:
    limit:
      name: limit
      in: query
      description: Nombre màxim d'elements a retornar.
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
      example: 20
    desplacament:
      name: desplacament
      in: query
      description: Nombre d'elements a ometre des del principi de la col·lecció.
      required: false
      schema:
        type: integer
        minimum: 0
        maximum: 10000
        default: 0
      example: 40

Errors Comuns i Consells

  • No paginar per defecte. L'error més car d'aquesta lliçó: funciona en desenvolupament amb 10 files i tomba producció amb 100.000.
  • Ordenar sense desempat únic. Duplicats i elements perduts en paginar, de manera intermitent i irreproduïble.
  • Perdre els filtres als enllaços de paginació. El rel="next" ha d'arrossegar ordenar, filtres i camps; si no, la pàgina 2 mostra una cosa diferent de la 1.
  • Ignorar paràmetres desconeguts en silenci. Una errada (?torrefacciio=mitja) retorna el catàleg sencer i el client creu que ha filtrat.
  • Permetre ordenar o filtrar per qualsevol camp. Sense índex, cada consulta és un escaneig complet de la taula.
  • Retallar limit sense avisar. El client en demana 1.000, en rep 100 i creu que la col·lecció en té 100.
  • Donar total en paginació per cursor. Comptar la col·lecció sencera anul·la l'avantatge del cursor. És millor no oferir-lo i documentar-ho.
  • Barrejar models de paginació a la mateixa col·lecció sense regles: cursor i desplacament alhora han de donar 400.
  • Consell: prova sempre amb dades que canvien. Insereix files entre la pàgina 1 i la 2 i comprova què passa. Aquest experiment descobreix la meitat dels bugs de paginació.
  • Consell: tria el model segons l'ús, no per moda. El cursor és superior tècnicament, però si el panell necessita "pàgina 7 de 12", l'offset és la resposta correcta.

Exercicis

Exercici 1: construir consultes

Escriu la petició curl completa per a cada necessitat, fent servir només el contracte d'aquesta lliçó:

  1. Cafès d'Etiòpia o Colòmbia, amb torrefacció clara, entre 12 i 18 euros, del més car al més barat, 10 per pàgina, segona pàgina.
  2. Comandes del client cli_842 pagades o enviades el març del 2026, les més recents primer.
  3. Ressenyes pendents de moderació amb 3 estrelles o menys, mostrant només id, puntuacio i comentari.
  4. Cerca de "xocolata" al catàleg, només cafès disponibles, ordenats per preu ascendent.

Exercici 2: diagnosticar una paginació trencada

El panell intern de la Botiga Aroma llista comandes així:

GET /v1/comandes?ordenar=estat&limit=50&desplacament=0
GET /v1/comandes?ordenar=estat&limit=50&desplacament=50
GET /v1/comandes?ordenar=estat&limit=50&desplacament=100

Els usuaris es queixen de dues coses: (a) de vegades veuen la mateixa comanda a dues pàgines, i (b) l'última pàgina triga vuit segons amb 400.000 comandes.

Diagnostica cada símptoma i proposa la solució concreta, indicant què s'hi perd.

Exercici 3: dissenyar la paginació d'una col·lecció nova

La Botiga Aroma afegeix /v1/esdeveniments, el registre d'esdeveniments enviats a RàpidEnviaments (evt_9f2c, comanda.pagada, comanda.enviada…). Característiques: creix a raó de milers d'esdeveniments al dia, mai no es modifica ni s'esborra, i RàpidEnviaments el fa servir per reconciliar el que ha rebut, recorrent-lo sencer des del darrer punt conegut.

Dissenya el contracte d'aquesta col·lecció: model de paginació, paràmetres, filtres, ordre per defecte, metadades i capçaleres. Justifica cada decisió.

Solucions

Solució 1

# 1
curl "https://api.botigaaroma.example/v1/cafes?origen=Eti%C3%B2pia,Col%C3%B2mbia&torrefaccio=clar&preuMin=12&preuMax=18&ordenar=-preuEuros&limit=10&desplacament=10"

# 2
curl "https://api.botigaaroma.example/v1/comandes?clientId=cli_842&estat=pagat,enviat&dataDes=2026-03-01&dataFins=2026-03-31&ordenar=-dataCreacio"

# 3
curl "https://api.botigaaroma.example/v1/ressenyes?estat=pendent_moderacio&puntuacioMax=3&camps=id,puntuacio,comentari"

# 4
curl "https://api.botigaaroma.example/v1/cafes?q=xocolata&disponible=true&ordenar=preuEuros"

Observacions: a la 1, la segona pàgina amb limit=10 és desplacament=10, i el multivalor d'origen fa servir la coma amb els accents codificats. A la 3 cal un filtre puntuacioMax que no és a la taula de la secció 2: la resposta correcta inclou adonar-se'n i proposar afegir-lo com a canvi retrocompatible, en lloc d'inventar-se ?puntuacio=<=3.

Solució 2

(a) Duplicats: falta el desempat. estat només té tres valors, així que hi ha desenes de milers d'empats i el motor els retorna en un ordre diferent a cada consulta. Solució: el servidor afegeix sempre id com a darrer criteri (ORDER BY estat, id), ho documenta i no depèn que el client ho demani. Cost: cap, llevat d'assegurar l'índex adequat. És una fallada del servidor, no del client.

(b) Lentitud: deep paging. Amb desplacament=399950, el motor llegeix i descarta 399.950 files. Solució: paginació per cursor per a /comandes, més el topall de desplacament=10000. El que s'hi perd: el panell ja no podrà saltar directament a la pàgina 5.000 ni mostrar "pàgina 3 de 8.000", perquè el cursor només ofereix següent i anterior. Mitigació pràctica: gairebé ningú no necessita la pàgina 5.000 —el que necessita és filtrar millor—, així que la solució completa combina cursor i filtres per data i estat perquè el recorregut profund deixi de ser necessari.

Solució 3

Model: paginació per cursor, sense cap dubte. Els tres trets de la col·lecció ho exigeixen: creix sense límit (l'offset es degradaria), és de només escriptura i lectura seqüencial (ningú no necessita "la pàgina 300"), i el cas d'ús és exactament "continua des d'on ho vas deixar", que és la definició d'un cursor.

Contracte proposat:

Aspecte Decisió Justificació
Paginació cursor + limit Cost constant a qualsevol profunditat
limit Per defecte 50, màxim 200 Els esdeveniments són petits; convé un lot més gran que l'estàndard per reconciliar ràpid
Ordre per defecte dataCreacio ascendent, desempatat per id Es recorre cap endavant en el temps: és l'ordre natural d'un registre
Filtres tipus (comanda.pagada, comanda.enviada), comandaId, dataDes, dataFins, estatLliurament Permeten reconciliar per tipus o reintentar els fallits
total No s'ofereix Comptar milions de files anul·laria l'avantatge del cursor; se'n documenta l'absència
Metadades Link amb rel="next"; sense first/last Coherent amb la resta de l'API i amb el model de cursor
Fi de col·lecció Absència de rel="next" Senyal documentat; RàpidEnviaments desa el darrer cursor i torna més tard
Cos {"dades": [...], "cursorSeguent": "..."} El cursor també al cos, per comoditat del client
Immutabilitat Els esdeveniments no es modifiquen Un cursor antic continua sent vàlid indefinidament: avantatge decisiu davant de l'offset

Exemple de resposta:

HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://api.botigaaroma.example/v1/esdeveniments?limit=50&cursor=eyJmIjoiMjAyNi0wMy0xNFQxMDozMjowMFoiLCJpIjoiZXZ0XzlmMmMifQ>; rel="next"

{
  "dades": [
    { "id": "evt_9f2c", "tipus": "comanda.pagada", "comandaId": "com_5001",
      "dataCreacio": "2026-03-14T10:32:00Z", "estatLliurament": "lliurat" }
  ],
  "cursorSeguent": "eyJmIjoiMjAyNi0wMy0xNFQxMDozMjowMFoiLCJpIjoiZXZ0XzlmMmMifQ"
}

Conclusió

Les col·leccions són on una API s'enfronta a la realitat, i ara la Botiga Aroma té un contracte complet per a elles: filtres explícits per camp, rangs amb Min/Max i Des/Fins, multivalor amb comes, i el rebuig conscient a inventar-se un llenguatge de consulta a la URL; ordenació amb ordenar i -camp, amb la regla crítica del desempat per id que fa que la paginació sigui fiable; tres models de paginació entesos a fons, amb offset per a les col·leccions estables com /cafes i cursor per a les que creixen sense fre com /comandes, més el topall de desplacament que evita el deep paging; les metadades repartides entre total al cos i la capçalera Link estàndard amb els seus rel; cerca amb ?q= i el criteri per saber quan es mereix un recurs propi; i una taula de valors per defecte i màxims que protegeix l'API d'ella mateixa.

Amb això, el contracte de la v1 està pràcticament tancat: recursos, mètodes, codis, representacions i col·leccions. I així que un contracte es publica, comença el problema següent: canviar sense trencar res a ningú. A la lliçó següent, 02-07 Versionat d'APIs, distingirem amb precisió quins canvis són retrocompatibles i quins trencadors, compararem les cinc estratègies de versionat —ruta, query, capçalera, media type i data—, justificarem per què la Botiga Aroma versiona a /v1, i dissenyarem el cicle de depreciació complet amb les capçaleres Deprecation i Sunset.

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