Ja està dissenyat el sobre: sabem a quina URI s'adreça la petició, amb quin mètode i amb quin codi respon el servidor. Falta allò que va a dins. Aquesta lliçó dissenya el cos de les respostes de la Botiga Aroma —els noms dels camps, els seus tipus, com es representen dates i imports, què s'incrusta i què s'enllaça— i les capçaleres que governen aquest cos. És una lliçó de decisions petites i molt duradores: el nom d'un camp publicat és contracte durant anys, i equivocar-se amb els imports monetaris es paga en cèntims perduts. En acabar tindràs la representació canònica de cada recurs i les regles de negociació de contingut que el mòdul 3 implementarà.

Contingut

  1. Què és una representació
  2. Convencions de noms i tipus
  3. Nuls davant de camps absents
  4. Dates, hores i zones horàries
  5. Imports monetaris
  6. Enumerats i booleans
  7. Embolcall o objecte nu
  8. Relacions: incrustar o enllaçar
  9. Hipermèdia selectiva: el disseny de _links
  10. Expansió i selecció de camps
  11. Negociació de contingut
  12. Respostes que no són JSON

  1. Què és una representació

Recordem la distinció de 01-04: el recurs és l'entitat conceptual (el cafè caf_001), i la representació és una de les seves formes concretes en un moment donat. El mateix recurs es pot representar com a JSON en català, JSON en castellà, PDF o imatge JPEG, i totes comparteixen URI.

graph LR
    R["Recurs<br/><b>/v1/comandes/com_5001/factura</b>"] --> A["Accept: application/json<br/>→ JSON amb els imports"]
    R --> B["Accept: application/pdf<br/>→ PDF amb capçalera impresa"]
    R --> C["Accept-Language: es<br/>→ mateix contingut en castellà"]

D'aquí la regla, ja enunciada a 02-02: el format no va a la URL, es negocia amb capçaleres. I d'aquí també que les decisions d'aquesta lliçó siguin tan importants: la representació és allò que el consumidor veu realment i contra el qual programa.

  1. Convencions de noms i tipus

2.1. camelCase

La Botiga Aroma fa servir camelCase a tots els noms de camp JSON: preuEuros, dataCreacio, notesTast, clientId.

Estil Exemple Qui el fa servir Comentari
camelCase preuEuros Google, Stripe (parcial), la majoria Natural en JavaScript, que és el consumidor principal
snake_case preu_euros Stripe, Twitter/X, Slack Natural en Python/Ruby, llegible
PascalCase PreuEuros APIs .NET antigues Poc freqüent avui
kebab-case preu-euros Pràcticament ningú Incòmode: obliga a obj["preu-euros"]

Cap no és millor en abstracte. La Botiga Aroma tria camelCase perquè els seus consumidors principals són JavaScript (SPA, Aroma Mòbil amb React Native, servidor Node.js) i així l'objecte JSON es fa servir sense traducció. El que sí que és obligatori és no barrejar: {"preuEuros": 14.50, "data_creacio": "..."} és el tipus de detall que enverina una API.

2.2. Regles d'anomenament de camps

  • Sense prefixos tècnics ni abreviatures críptiques: nom, no strNom ni nm.
  • Sufix Id per a referències: clientId, cafeId. Deixa clar que és una referència i no l'objecte.
  • Sufix d'unitat quan n'hi hagi: preuEuros, pesGrams, duracioSegons. Elimina d'un cop la pregunta "això en quina unitat està?".
  • Plural per als arrays: notesTast, linies, dades.
  • Sense is/has en català: el booleà es diu actiu, esgotat, regal.
  • Noms de domini, no de taula: origen, no fkOrigen.

2.3. Tipus

Tipus JSON Ús a la Botiga Aroma Exemple
string Text, identificadors, dates, enumerats "caf_001"
number Quantitats i imports 14.50, 120
boolean Banderes true
array Col·leccions i llistes de valors ["cítric", "floral"]
object Estructures imbricades {"cafeId": "...", "quantitat": 2}
null Absència amb significat "dataEnviament": null

Dues regles dures:

  1. El tipus d'un camp no canvia mai. Si estoc és un nombre, no pot tornar-se "120" en una altra resposta. És un canvi trencador (02-07) i una font inexhaurible de bugs, perquè "0" és cert en JavaScript i 0 és fals.
  2. Els identificadors són sempre string. Encara que 5001 sembli un nombre, com_5001 és una cadena opaca, i així queda protegit el dia que canviï el format.

  1. Nuls davant de camps absents

Tres estats possibles per a un camp, i cal triar què significa cadascun:

Forma Significat que li donem Exemple
Camp present amb valor Hi ha dada "dataEnviament": "2026-03-16T09:00:00Z"
Camp present amb null La dada existeix conceptualment però encara no té valor "dataEnviament": null (comanda sense enviar)
Camp absent El camp no aplica a aquest recurs o no s'ha demanat Sense dataEnviament en una comanda anul·lada

Regla de la Botiga Aroma: la representació d'un recurs inclou sempre els mateixos camps, fent servir null per a allò que encara no té valor. Les úniques absències legítimes són (a) els camps que el client ha exclòs amb camps= i (b) els objectes que no s'han demanat amb expandir=.

Per què val la pena aquesta regla:

// Amb la regla: el client escriu això i funciona sempre
const data = comanda.dataEnviament ?? "Pendent d'enviament";

// Sense la regla, el client s'ha de defensar de tres casos
const data = ("dataEnviament" in comanda)
  ? (comanda.dataEnviament === null ? "Pendent" : comanda.dataEnviament)
  : "Desconegut";

Dos matisos:

  • En arrays no es fa servir null: una llista sense elements és [], mai null. Així el client la pot recórrer sense comprovar res.
  • Al PATCH amb Merge Patch, null significa "esborra" (02-03). És una asimetria deliberada entre entrada i sortida, i cal documentar-la.

  1. Dates, hores i zones horàries

Totes les dates i hores van en ISO-8601 amb zona horària explícita, en UTC (Z).

{
  "dataCreacio": "2026-03-14T10:30:00Z",
  "dataPagament": "2026-03-14T10:32:15Z",
  "dataEnviament": null,
  "dataCaducitat": "2027-01-31"
}
Format Exemple Veredicte
ISO-8601 amb Z 2026-03-14T10:30:00Z ✅ L'estàndard de la Botiga Aroma
ISO-8601 amb desplaçament 2026-03-14T11:30:00+01:00 ✅ S'accepta a l'entrada, es normalitza a UTC
ISO-8601 sense zona 2026-03-14T10:30:00 ❌ Ambigu: l'hora de qui?
Només data 2027-01-31 ✅ Només quan l'hora no aplica
Epoch en segons 1773484200 ❌ Il·legible; ambigüitat segons/mil·lisegons
Format local 14/03/2026 11:30 ❌ Ambigu (març o el dia 3?) i dependent de l'idioma

Punts importants:

  • UTC a l'emmagatzematge i al transport; hora local només a la presentació. El client formata segons la zona de l'usuari; el servidor mai no suposa Barcelona.
  • Compte amb l'horari d'estiu. Aquí es canvia de +01:00 a +02:00; si guardes hora local, dues comandes de la matinada del canvi poden aparèixer desordenades o duplicades.
  • Dates sense hora (2027-01-31) per a allò que és un dia natural, com la caducitat d'un lot. Posar-hi T00:00:00Z convida a errors d'un dia per desplaçament de zona.
  • Noms coherents: data<Alguna cosa> per a instants, <alguna cosa>Dies per a durades. La Botiga Aroma evita timestamp a seques.

  1. Imports monetaris

L'apartat on més diners es perden per un descuit tècnic. Mai no facis servir coma flotant binària per a diners al servidor.

// El clàssic que sorprèn tothom
0.1 + 0.2                      // 0.30000000000000004
14.50 * 3                      // 43.5 (bé)
0.07 * 100                     // 7.000000000000001
(29.00 * 0.21).toFixed(2)      // "6.09" ... de vegades

El number de JSON, quan es processa com a double d'IEEE 754, no pot representar exactament 0.1. Sumar cent línies de comanda acumula error, i en comptabilitat un cèntim de descompensació és un problema real.

Opcions de representació:

Opció Exemple A favor En contra
Nombre decimal "preuEuros": 14.50 Llegible, còmode per al client Risc de coma flotant si el client calcula
Enter en cèntims "preuCentims": 1450 Exacte, sense decimals Tothom ha de saber l'escala; lleig de llegir
Cadena decimal "preuEuros": "14.50" Exacte i sense ambigüitat Obliga a parsejar; incòmode per ordenar
Objecte import {"import": "14.50", "moneda": "EUR"} Explícit, multidivisa Verbós

Decisió de la Botiga Aroma:

  • A la representació: nombre amb exactament dos decimals i sufix Euros (14.50, 29.00). És llegible i directe per als clients, que només el mostren.
  • Al servidor i a la base de dades: enters de cèntims o tipus decimal exacte. La conversió passa a la vora (03-05).
  • Els totals els calcula sempre el servidor. El client mai no suma imports per mostrar-los com a oficials: per això la comanda inclou totalEuros ja calculat.
  • Moneda: la v1 és només euros i així consta a la documentació. Si algun dia hi ha més divises, s'hi afegeix un camp moneda opcional amb valor per defecte "EUR" —canvi retrocompatible (02-07)— en lloc de reestructurar els imports.

Detall que sorprèn: 29.00 en JSON es pot serialitzar com a 29 segons la biblioteca, perquè JSON no distingeix enters de decimals. És acceptable —numèricament són iguals— i el client formata amb dos decimals en mostrar-ho. Si et molesta, l'alternativa és la cadena decimal, amb el seu cost.

  1. Enumerats i booleans

6.1. Enumerats

Els valors enumerats de la Botiga Aroma van en snake_case en minúscules, amb valors estables i ampliables:

Camp Valors v1
torrefaccio clar, mitja, fosc
estat (comanda) pendent_pagament, pagat, enviat
estat (ressenya) pendent_moderacio, publicada, rebutjada
estat (enviament) pendent_recollida, en_repartiment, lliurat

Regles del contracte:

  • El valor no es tradueix mai. estat: "pagat" és un identificador de màquina; l'etiqueta que veu l'usuari la posa el client. Si demà cal un text llegible, s'hi afegeix un camp a part (estatText), no es canvia el valor.
  • La llista pot créixer. La documentació adverteix des del primer dia: tracta un valor desconegut amb elegància (principi de robustesa, 02-01). Afegir estat: "retornat" no ha de trencar res a ningú.
  • La llista no encongeix i els valors no es reanomenen. Això sí que és trencador.
  • Res de codis numèrics. torrefaccio: 1 obliga a mantenir una taula de correspondències fora de banda i és il·legible en un log.

6.2. Booleans

Un booleà només s'ha de fer servir quan el concepte sigui genuïnament binari i per sempre. Molts "booleans" acaben convertits en enumerats: aprovada: true/false no cobreix "pendent de moderació", que és exactament per què les ressenyes tenen estat i no aprovada. Davant del dubte, enumerat: ampliar un enumerat és retrocompatible; convertir un booleà en enumerat, no.

  1. Embolcall o objecte nu

Per a una col·lecció, què es retorna?

Opció A, array nu:

[
  { "id": "caf_001", "nom": "Etiòpia Yirgacheffe" },
  { "id": "caf_002", "nom": "Colòmbia Huila" }
]

Opció B, embolcall (Botiga Aroma):

{
  "dades": [
    { "id": "caf_001", "nom": "Etiòpia Yirgacheffe" },
    { "id": "caf_002", "nom": "Colòmbia Huila" }
  ],
  "total": 2
}
Criteri Array nu Embolcall dades/total
Simplicitat per al client Màxima: s'itera directament Un nivell més (resposta.dades)
Afegir metadades després Canvi trencador Additiu, sense trencar res
Total d'elements No hi cap (o va en capçalera) total
Coherència amb les respostes d'element Dues formes diferents Dues formes diferents igualment
Risc històric de JSON hijacking Existia en navegadors antics Mitigat

Decisió de la Botiga Aroma: embolcall per a col·leccions, objecte nu per a elements.

GET /v1/cafes/caf_001  →  { "id": "caf_001", "nom": "...", ... }
GET /v1/cafes          →  { "dades": [...], "total": 137 }

La raó principal és la tolerància a l'evolució: si demà cal afegir total, _links o avisos de depreciació a una col·lecció, amb l'embolcall és additiu i amb l'array nu caldria trencar tots els clients. I no emboliquem els elements individuals ({"dades": {...}}) perquè afegeix soroll sense aportar res: un element ja és un objecte extensible.

Conseqüència pràctica per al client, que cal documentar bé:

// Col·lecció
const resposta = await fetch("/v1/cafes").then(r => r.json());
resposta.dades.forEach(cafe => console.log(cafe.nom));
console.log(`Hi ha ${resposta.total} cafès en total`);

// Element
const cafe = await fetch("/v1/cafes/caf_001").then(r => r.json());
console.log(cafe.nom);

Les metadades de paginació que acompanyen total es decideixen a 02-06.

  1. Relacions: incrustar o enllaçar

Una comanda té un client i línies que apunten a cafès. Quant d'això viatja a la resposta?

Enllaçar (linking):

{
  "id": "com_5001",
  "clientId": "cli_842",
  "totalEuros": 29.00,
  "_links": {
    "self": { "href": "/v1/comandes/com_5001" },
    "client": { "href": "/v1/clients/cli_842" }
  }
}

Incrustar (embedding):

{
  "id": "com_5001",
  "client": {
    "id": "cli_842",
    "nom": "Marta Garcia",
    "email": "[email protected]"
  },
  "totalEuros": 29.00
}
Criteri Enllaçar Incrustar
Mida de la resposta Mínima Més gran
Nombre de crides del client Més (chattiness) Menys
Frescor de la dada Sempre actual en demanar-la Còpia de l'instant de la resposta
Memòria cau Cada recurs es guarda a part Invalida tot el conjunt
Risc d'exposar de més Baix Alt (dades personals, permisos)
Acoblament Baix Alt

Criteri de la Botiga Aroma:

  1. S'incrusta allò que gairebé sempre es necessita i és petit i estable. El nom del cafè i el preuEuros de cada línia de comanda s'incrusten, i a més el preu s'incrusta congelat: el preu de la comanda és el que hi havia el dia de la compra, no l'actual. Aquí incrustar no és una optimització, és correcció de negoci.
  2. S'enllaça allò gran, canviant o sensible. El client complet, les ressenyes d'un cafè, la factura.
  3. Mai no s'incrusta una col·lecció sense límit. Un cafè amb 4.000 ressenyes no les pot portar a dins: van enllaçades i paginades.
  4. La resta, sota demanda amb expandir (secció 10).

Així queda una comanda de la Botiga Aroma:

{
  "id": "com_5001",
  "clientId": "cli_842",
  "estat": "pendent_pagament",
  "totalEuros": 29.00,
  "dataCreacio": "2026-03-14T10:30:00Z",
  "dataPagament": null,
  "dataEnviament": null,
  "linies": [
    { "cafeId": "caf_001", "nom": "Etiòpia Yirgacheffe", "quantitat": 2, "preuEuros": 14.50 }
  ],
  "_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" }
  }
}

  1. Hipermèdia selectiva: el disseny de _links

A 01-05 vam fixar el nivell objectiu: Richardson 2 sòlid amb hipermèdia selectiva. Concretem què significa exactament al contracte, perquè "selectiva" sense regles es converteix en caos.

Què SÍ que porta enllaços:

Element Enllaços Motiu
Tot recurs individual self URI canònica, imprescindible amb vistes imbricades (02-02)
Tot recurs amb relacions Enllaços als recursos relacionats Evita que el client construeixi URLs
Només les comandes Enllaços d'acció segons l'estat La màquina d'estats és real i canvia
Col·leccions Paginació mitjançant la capçalera Link Es decideix a 02-06

Què NO porta enllaços: els elements dins d'una col·lecció no porten _links complets (només self), per no multiplicar el pes de la resposta per vint; els cafès no porten enllaços d'acció, perquè no tenen màquina d'estats.

Format de l'enllaç. Objecte amb href i, a les accions, method:

"_links": {
  "self":   { "href": "/v1/comandes/com_5001" },
  "pagar":  { "href": "/v1/comandes/com_5001/pagament", "method": "POST" }
}

És la forma de HAL simplificada (01-05), però sense adoptar application/hal+json ni _embedded: continuem amb application/json, perquè no volem obligar els clients a entendre un format hipermèdia complet.

Els enllaços d'acció segons l'estat de la comanda són el cor de la hipermèdia selectiva:

Estat _links presents
pendent_pagament self, client, pagar, anullar
pagat self, client, factura, enviament, anullar
enviat self, client, factura, enviament, retornar

I la regla que fa que això serveixi d'alguna cosa, escrita a la documentació: "si un enllaç d'acció no hi és present, aquesta acció no és possible ara; no la construeixis a mà". La SPA pinta els botons a partir de _links en comptes de replicar la màquina d'estats, que és exactament el que buscàvem.

Les URIs dels enllaços són relatives a l'amfitrió (/v1/comandes/com_5001). Es documenta així perquè funcioni igual en producció, en preproducció i en local.

  1. Expansió i selecció de camps

Les dues vàlvules anunciades a 02-01 per governar la granularitat.

10.1. Expansió (expandir)

Incrusta sota demanda un recurs relacionat, estalviant crides:

curl "https://api.botigaaroma.example/v1/comandes/com_5001?expandir=client"
{
  "id": "com_5001",
  "clientId": "cli_842",
  "client": {
    "id": "cli_842",
    "nom": "Marta Garcia",
    "email": "[email protected]"
  },
  "totalEuros": 29.00
}

Regles de la Botiga Aroma per a expandir:

  • Llista separada per comes: ?expandir=client,linies.cafe.
  • S'admet un sol nivell de profunditat (linies.cafe sí; linies.cafe.ressenyes no) per evitar consultes incontrolables.
  • El camp original es manté: clientId no desapareix en afegir-se client. Així el client no ha d'escriure dues rutes d'accés diferents.
  • Només es poden expandir les relacions documentades; un valor desconegut retorna 400 amb parametre_invalid.
  • Màxim tres expansions per petició.

10.2. Selecció de camps (camps)

També anomenada sparse fieldsets: el client demana només allò que farà servir.

curl "https://api.botigaaroma.example/v1/cafes?camps=id,nom,preuEuros&limit=3"
{
  "dades": [
    { "id": "caf_001", "nom": "Etiòpia Yirgacheffe", "preuEuros": 14.50 },
    { "id": "caf_002", "nom": "Colòmbia Huila", "preuEuros": 12.90 }
  ],
  "total": 137
}

Regles:

  • id s'inclou sempre, es demani o no: sense ell la resposta és inservible.
  • Els camps no demanats s'ometen (única excepció legítima a la regla de la secció 3).
  • Un camp desconegut retorna 400 amb parametre_invalid, en lloc d'ignorar-se en silenci: així es detecten les errades.
  • No es combina amb expandir sobre la mateixa relació a la v1, per no multiplicar casos.

Això connecta directament amb l'over-fetching que vam discutir a 01-07 en comparar REST amb GraphQL: camps i expandir cobreixen el 90 % de la necessitat real sense renunciar a la memòria cau HTTP ni assumir la complexitat d'un llenguatge de consultes. És la resposta d'una API REST ben dissenyada a aquest argument.

I el cost, que cal assumir amb els ulls oberts: cada combinació de paràmetres és una URL diferent i, per tant, una entrada de memòria cau diferent. Multiplicar variants redueix la taxa d'encerts de la memòria cau (04-06).

  1. Negociació de contingut

És el mecanisme pel qual client i servidor acorden la representació. El client proposa amb capçaleres Accept-*, el servidor tria i ho declara amb Content-*.

Capçalera del client Què negocia Capçalera de resposta Error si no hi ha acord
Accept Format Content-Type 406 Not Acceptable
Accept-Language Idioma Content-Language 406 (o idioma per defecte)
Accept-Encoding Compressió Content-Encoding Se serveix sense comprimir
Accept-Charset Joc de caràcters (dins de Content-Type) En desús: avui tot és UTF-8
Content-Type (petició) Format d'allò que envia 415 Unsupported Media Type

11.1. Factors de qualitat (q=)

El client pot expressar preferències ponderades, de 0 a 1 (per defecte, 1):

Accept: application/json;q=1.0, application/xml;q=0.8, */*;q=0.1
Accept-Language: ca;q=1.0, es;q=0.8, en;q=0.5

Es llegeix: "prefereixo JSON; si no, XML; en darrer cas, qualsevol cosa" i "prefereixo català; si no, castellà; si no, anglès". El servidor recorre les opcions per q descendent i serveix la primera que pot produir.

11.2. Què negocia la Botiga Aroma

GET /v1/cafes/caf_001 HTTP/1.1
Accept: application/json
Accept-Language: ca, es;q=0.8
Accept-Encoding: gzip, br
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: ca
Content-Encoding: br
Vary: Accept, Accept-Language, Accept-Encoding

{
  "id": "caf_001",
  "nom": "Etiòpia Yirgacheffe",
  "origen": "Etiòpia",
  "torrefaccio": "clar",
  "preuEuros": 14.50,
  "notesTast": ["cítric", "floral", "te negre"]
}

Fixa't en tres coses:

  • notesTast ve traduït, perquè és text de màrqueting pensat per a l'usuari final. torrefaccio no es tradueix: és un enumerat, un identificador de màquina (secció 6).
  • Content-Language: ca declara què s'ha servit; és imprescindible, perquè el client va demanar dos idiomes i necessita saber quin ha rebut.
  • Vary diu a les memòries cau intermèdies que la resposta depèn d'aquestes capçaleres i que no han de servir la versió catalana a qui demani castellà. Oblidar Vary és una fallada greu i subtil: es detalla a 04-06.

Contracte d'idiomes de la Botiga Aroma: se serveixen es, ca i en; l'idioma per defecte és ca; si es demana un idioma no disponible, no es retorna 406, se serveix ca i es declara Content-Language: ca. És una decisió pragmàtica: per al contingut, un idioma alternatiu és millor que un error.

11.3. Compressió

Accept-Encoding: gzip, br permet comprimir. Un catàleg de 137 cafès en JSON pot passar de 180 KB a uns 15 KB amb gzip: és l'optimització amb millor relació cost/benefici de tota l'API. S'activa al servidor o a la passarel·la i no canvia el contracte. Detalls de rendiment, a 04-06.

11.4. Media types específics i versionat per media type

A més d'application/json, es poden definir tipus propis que identifiquin la forma exacta de la representació:

Accept: application/vnd.botigaaroma.cafe+json
Accept: application/vnd.botigaaroma.v2+json

El prefix vnd. marca els tipus de proveïdor. El segon exemple és el versionat per media type, una de les estratègies que compararem a 02-07. La Botiga Aroma no el fa servir —versiona a la ruta—, però convé reconèixer-lo: en demanar contingut a una API que versiona així, Accept: application/json et donarà la versió que el servidor consideri per defecte, que pot no ser la que esperes.

  1. Respostes que no són JSON

No tot és JSON, i la negociació de contingut és justament el que permet conviure sense embrutar les URIs.

12.1. La factura en PDF

El mateix recurs, dues representacions:

# Representació JSON: dades estructurades
curl -H "Accept: application/json" \
  https://api.botigaaroma.example/v1/comandes/com_5001/factura
{
  "id": "fac_88",
  "comandaId": "com_5001",
  "numeroFactura": "2026/000188",
  "dataEmissio": "2026-03-14T10:33:00Z",
  "baseImposableEuros": 23.97,
  "ivaEuros": 5.03,
  "totalEuros": 29.00,
  "_links": { "self": { "href": "/v1/comandes/com_5001/factura" } }
}
# Representació PDF: document per imprimir o arxivar
curl -H "Accept: application/pdf" -o factura.pdf \
  https://api.botigaaroma.example/v1/comandes/com_5001/factura
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="factura-2026-000188.pdf"
Content-Length: 48213
Accept-Ranges: bytes

Content-Disposition suggereix el nom del fitxer en descarregar, i Accept-Ranges anuncia que s'admeten descàrregues parcials, que és el que habilita el 206 de 02-04. I no hi ha cap URL amb .pdf: és el mateix recurs.

12.2. Pujada de la imatge d'un cafè

Aquí qui envia una cosa que no és JSON és el client. Dos enfocaments:

a) Binari directe amb PUT sobre el singleton /cafes/{id}/imatge:

curl -i -X PUT "https://api.botigaaroma.example/v1/cafes/caf_001/imatge" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: image/jpeg" \
  --data-binary @yirgacheffe.jpg
HTTP/1.1 200 OK
Content-Type: application/json
Location: https://api.botigaaroma.example/v1/cafes/caf_001/imatge

{
  "url": "https://cdn.botigaaroma.example/cafes/caf_001.jpg",
  "amplePx": 1200, "altPx": 1200, "bytes": 184320, "format": "image/jpeg"
}

És net, idempotent i no necessita cap format addicional.

b) multipart/form-data quan cal enviar fitxer i metadades alhora:

curl -i -X POST "https://api.botigaaroma.example/v1/cafes/caf_001/imatges" \
  -H "Authorization: Bearer <token>" \
  -F "[email protected];type=image/jpeg" \
  -F "descripcio=Gra torrat, pla zenital"

Decisió de la Botiga Aroma: l'opció (a), perquè a la v1 només hi ha una imatge principal per cafè i la descripció és un camp del mateix cafè. Contracte de la pujada:

  • Formats admesos: image/jpeg, image/png, image/webp. Qualsevol altre → 415.
  • Mida màxima: 5 MB. Si se supera → 413 amb cos_massa_gran.
  • La resposta és JSON, encara que la petició fos binària: la resposta descriu el recurs creat, no el retorna.
  • La imatge se serveix des de la CDN, no des de l'API. L'API la desa i en retorna la URL.

12.3. Altres formats que apareixeran

  • CSV per a exportacions del panell intern: Accept: text/csv sobre /v1/comandes.
  • text/event-stream per al SSE del panell en directe que vam decidir a 01-07: és un altre tipus de resposta negociada, amb connexió persistent.

En tots dos casos la regla és la mateixa: el format es negocia, la URI no canvia.

Errors Comuns i Consells

  • Barrejar convencions de noms. Un camelCase amb dos camps en snake_case cola a la revisió i després no es pot treure sense trencar clients.
  • Retornar dates sense zona horària. "2026-03-14T10:30:00" és ambigu; el bug apareix al març i a l'octubre, amb el canvi d'hora.
  • Calcular diners amb coma flotant. Guarda cèntims o decimals exactes i converteix a la vora.
  • Traduir els enumerats. Que estat valgui "pagado" o "paid" segons l'idioma obliga els clients a mantenir taules per idioma. Els valors de màquina no es tradueixen.
  • Retornar un array nu a les col·leccions. Et quedes sense lloc per posar metadades sense trencar el contracte.
  • Incrustar objectes grans "perquè és còmode". La resposta es dispara, la memòria cau es degrada i acabes exposant dades personals on no tocava.
  • Oblidar Vary. Amb memòria cau intermèdia, un usuari pot rebre la resposta en l'idioma d'un altre. És la fallada més difícil de reproduir d'aquesta lliçó.
  • Posar el format a la URL. .json/.pdf dupliquen identitats; per a això hi ha Accept.
  • Consell: escriu primer el JSON ideal a mà. Abans de mirar el model de dades, escriu la resposta que voldries rebre. És la millor defensa contra l'abocament de taules.
  • Consell: revisa cada camp preguntant "qui el consumeix?". Si no hi ha resposta, treu-lo: cada camp publicat s'ha de mantenir per sempre.

Exercicis

Exercici 1: corregir una representació

Aquest és el JSON que proposa un equip per a una ressenya. Reescriu-lo segons les convencions de la Botiga Aroma i justifica cada canvi.

{
  "Id": 101,
  "cafe_id": 1,
  "user": { "id": 842, "nom": "Marta Garcia", "password_hash": "$2b$10$..." },
  "puntuacio": "5",
  "comentari": "Un cafè espectacular",
  "aprovada": true,
  "data": "14/03/2026 11:30",
  "preu_pagat": 14.5,
  "respostes": null
}

Exercici 2: dissenyar la negociació de contingut

Aroma Mòbil, amb l'app en català i en una xarxa lenta, vol el detall de la comanda com_5001 amb les dades del client incloses, però només els camps que pinta a la pantalla (id, estat, totalEuros, dataCreacio).

a) Escriu la petició curl completa amb totes les capçaleres pertinents. b) Escriu la resposta del servidor amb les seves capçaleres. c) Explica per què cal Vary i què passaria exactament si s'ometés.

Exercici 3: incrustar o enllaçar

Per a cada relació, decideix si s'incrusta, s'enllaça o s'ofereix amb expandir, i justifica-ho amb els criteris de la secció 8:

  1. El nom del cafè dins d'una línia de comanda.
  2. Les 4.000 ressenyes de caf_001 a la fitxa del cafè.
  3. El client complet dins d'una comanda, al panell intern.
  4. L'adreça d'enviament dins d'una comanda.
  5. La puntuació mitjana d'un cafè al llistat del catàleg.
  6. L'historial de pagaments d'un client a la seva fitxa.

Solucions

Solució 1

{
  "id": "res_101",
  "cafeId": "caf_001",
  "clientId": "cli_842",
  "puntuacio": 5,
  "comentari": "Un cafè espectacular",
  "estat": "publicada",
  "dataCreacio": "2026-03-14T10:30:00Z",
  "respostes": [],
  "_links": {
    "self": { "href": "/v1/ressenyes/res_101" },
    "cafe": { "href": "/v1/cafes/caf_001" },
    "respostes": { "href": "/v1/ressenyes/res_101/respostes" }
  }
}
Problema Correcció
"Id": 101 "id": "res_101": minúscula, cadena i amb prefix de tipus
cafe_id cafeId: camelCase coherent amb la resta
user incrustat Se substitueix per clientId + enllaç: l'objecte complet no fa falta aquí
password_hash S'elimina. Fuita gravíssima: mai no es serialitza un camp sensible per incrustar un objecte sencer
"puntuacio": "5" Nombre, no cadena: és una quantitat i s'ordena numèricament
aprovada: true estat: "publicada": el booleà no cobreix pendent_moderacio ni rebutjada
"data": "14/03/2026 11:30" dataCreacio en ISO-8601 UTC; el format local és ambigu i dependent de l'idioma
preu_pagat S'elimina: no pertany a una ressenya; aquesta dada viu a la línia de la comanda
"respostes": null []: els arrays buits no són null, perquè el client els pugui recórrer sempre

Solució 2

a)

curl -i "https://api.botigaaroma.example/v1/comandes/com_5001?expandir=client&camps=id,estat,totalEuros,dataCreacio" \
  -H "Authorization: Bearer <token>" \
  -H "Accept: application/json" \
  -H "Accept-Language: ca, es;q=0.8" \
  -H "Accept-Encoding: gzip, br"

b)

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: ca
Content-Encoding: br
Vary: Accept, Accept-Language, Accept-Encoding

{
  "id": "com_5001",
  "estat": "pendent_pagament",
  "totalEuros": 29.00,
  "dataCreacio": "2026-03-14T10:30:00Z",
  "client": { "id": "cli_842", "nom": "Marta Garcia", "email": "[email protected]" }
}

Nota: estat continua sent un identificador de màquina, no text traduïble; l'app el converteix en "Pendent de pagament" en pintar-lo, i seria el mateix valor demanant la resposta en qualsevol altre idioma. I client hi apareix encara que no consti a camps perquè l'expansió és explícita: es documenta així perquè no sorprengui.

c) Vary indica a les memòries cau intermèdies (CDN, proxy corporatiu, passarel·la) quines capçaleres de la petició influeixen en la resposta. Sense ella, la memòria cau desaria aquesta resposta sota la clau "URL" a seques. Conseqüència concreta: el següent usuari que demanés la mateixa comanda amb Accept-Language: es rebria la versió en català emmagatzemada; i un client que no admetés Brotli podria rebre un cos comprimit amb br que no sap descomprimir, amb la qual cosa la resposta seria brossa il·legible.

Solució 3

# Decisió Justificació
1 Incrustar Petit, sempre necessari i, sobretot, és una còpia històrica: la comanda ha de mostrar el nom i el preu del dia de la compra
2 Enllaçar Col·lecció sense límit: mai no s'incrusta. _links.ressenyes apunta a /v1/cafes/caf_001/ressenyes, paginada (02-06)
3 expandir=client El panell el necessita sovint, però incrustar-lo sempre exposaria dades personals a tots els consumidors i engreixaria cada resposta
4 Incrustar És part de la comanda i també una dada congelada: l'adreça a la qual es va enviar, encara que el client la canviï després
5 Incrustar un camp calculat (puntuacioMitjana, nombreRessenyes) Són dos nombres, es mostren a cada targeta del catàleg i eviten una crida per cafè: enllaçar aquí seria chattiness pura
6 Enllaçar Col·lecció que creix sense límit, amb dades sensibles i d'ús ocasional: /v1/clients/cli_842/pagaments

Conclusió

La representació és allò que el consumidor veu realment, i ara està dissenyada de dalt a baix: camelCase, identificadors com a cadenes opaques, camps sempre presents amb null per a allò que encara no té valor i [] per a les llistes buides, dates ISO-8601 en UTC, imports en euros amb dos decimals i cèntims exactes per dins, enumerats en snake_case ampliables i sense traduir, i l'embolcall {"dades": [...], "total": n} per a les col·leccions davant de l'objecte nu per als elements. Hem fixat què s'incrusta —allò petit, estable i congelat, com el nom i el preu d'una línia— i què s'enllaça, com són exactament els _links de la hipermèdia selectiva de nivell 2, i com expandir i camps donen a cada consumidor la granularitat que necessita sense renunciar a REST. I hem tancat la negociació de contingut amb Accept, Accept-Language i Accept-Encoding, amb Vary com a peça imprescindible, incloses les representacions que no són JSON: la factura en PDF i la pujada d'imatges.

Queda una peça que hem anat ajornant i que es nota així que el catàleg creix: què passa quan una col·lecció té 4.000 elements. A la lliçó següent, 02-06 Filtratge, ordenació, paginació i cerca, dissenyarem les col·leccions grans de la Botiga Aroma: els convenis de filtres i rangs, l'ordenació estable, els tres models de paginació amb la seva taula comparativa i el problema del deep paging, on viatgen les metadades de paginació —total i capçalera Link—, la cerca de text i els límits per defecte que protegeixen l'API.

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