Fins ara l'API de CicloUrbana és de només lectura: dos endpoints GET i poca cosa més. En aquesta lliçó la convertim en una API completa. Implementarem l'alta, el reemplaçament, la modificació parcial i la baixa d'estacions i bicicletes, amb el codi d'estat i les capçaleres que corresponen a cada operació. Pel camí apareixen problemes que només es manifesten quan una API comença a escriure: com retornar l'URL del recurs acabat de crear, com distingir "no em toquis aquest camp" de "posa aquest camp a nul", què fer quan dos operaris de Ribalta editen la mateixa estació alhora, i com modelar operacions del negoci —iniciar i finalitzar un lloguer— que no encaixen al motlle del CRUD sense trencar el disseny REST.
Contingut
POST: crear recursos@ResponseStatusi la capçaleraLocationPUT: reemplaçament total i idempotènciaPATCH: actualització parcial i el problema del camp absentDELETE: baixa i idempotència- Taula resum: verb, estat i cos
- El recurs bicicleta i el subrecurs d'estació
- Endpoints d'acció: iniciar i finalitzar un lloguer
HEADiOPTIONS- Concurrència:
ETag,If-Matchi peticions condicionals - Errors Comuns i Consells
- Exercicis
POST: crear recursos
POST: crear recursosPOST sobre una col·lecció significa "afegeix un element nou a aquesta col·lecció". El servidor assigna l'identificador i respon 201 Created amb una capçalera Location que apunta al recurs acabat de crear.
Necessitem primer una classe per al cos de la petició. No podem acceptar directament Estacio, perquè el client no ha d'enviar l'id: l'assigna el servidor. Fem servir un record senzill —es convertirà en CrearEstacioRequest, amb validació, a 03-04 i 03-05:
package com.ciclourbana.estacions;
public record NovaEstacio(String nom, String adreca,
int capacitat, double latitud, double longitud) {}Ampliem EstacioRepositori amb tres operacions noves —boolean existeixPerId(Long), boolean eliminarPerId(Long) i boolean existeixPerNom(String)— que EstacioRepositoriEnMemoria implementa sobre el seu ConcurrentHashMap:
@Override
public boolean existeixPerId(Long id) {
return perId.containsKey(id);
}
@Override
public boolean eliminarPerId(Long id) {
return perId.remove(id) != null; // remove retorna el valor anterior o null
}
@Override
public boolean existeixPerNom(String nom) {
return perId.values().stream().anyMatch(e -> e.nom().equalsIgnoreCase(nom));
}A EstacioService, la creació:
public Estacio crear(NovaEstacio nova) {
// Regla de negoci de Ribalta: no hi ha dues estacions amb el mateix nom.
// Provisional: a 03-06 serà EstacioDuplicadaException -> 409.
if (estacioRepositori.existeixPerNom(nova.nom())) {
throw new IllegalStateException("Ja existeix una estació anomenada " + nova.nom());
}
return estacioRepositori.desar(new Estacio(null, nova.nom(), // id nul:
nova.adreca(), nova.capacitat(), // l'assigna
nova.latitud(), nova.longitud())); // el repositori
}Fixa't en on viu la comprovació del nom duplicat: al servei, no al controlador. És una regla de negoci de la xarxa i s'ha d'aplicar cridi qui cridi, no només per HTTP.
@ResponseStatus i la capçalera Location
@ResponseStatus i la capçalera LocationEl controlador:
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Estacio> crear(@RequestBody NovaEstacio nova) {
Estacio creada = estacioService.crear(nova);
// URI absoluta del recurs acabat de crear, a partir de la petició
// actual: http://host/api/v1/estacions + /{id}
URI ubicacio = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(creada.id()).toUri();
return ResponseEntity.created(ubicacio).body(creada);
}ResponseEntity.created(uri) fa dues coses de cop: fixa l'estat en 201 i afegeix la capçalera Location. La resposta:
HTTP/1.1 201 Created
Location: http://localhost:8080/api/v1/estacions/5
Content-Type: application/json
{ "id": 5, "nom": "Mercat Central", "capacitat": 20, ... }Per què la capçalera Location importa. Sense ella, el client que acaba de crear una estació no sap on és: hauria de llegir l'id del cos i construir l'URL a mà, replicant l'esquema de rutes del servidor. Amb Location, el client desa aquesta URL i la fa servir. És l'únic punt en què CicloUrbana toca el nivell 3 de Richardson, i surt de franc.
Sobre ServletUriComponentsBuilder. Construeix l'URI a partir de la petició en curs, respectant host, port i esquema reals. Les seves variants: fromCurrentRequest() fa servir l'URL completa (l'habitual en un POST sobre la col·lecció), fromCurrentContextPath() només el host i el context, i fromCurrentRequestUri() l'URL sense la cadena de consulta. Darrere d'un proxy invers —l'habitual en producció— la petició que veu Tomcat pot ser http://10.0.0.4:8080/... mentre el client feia servir https://api.ciclourbana.example/.... Perquè la Location surti correcta cal activar el tractament de les capçaleres X-Forwarded-*:
És una d'aquelles línies que ningú no recorda fins que un client rep una Location amb http:// i una IP interna. És la restricció de sistema per capes de 03-01: el servidor no ha d'assumir que parla directament amb el client.
@ResponseStatus com a alternativa. Si no necessites la capçalera Location, @ResponseStatus(HttpStatus.CREATED) sobre el mètode fixa el 201 i permet retornar l'objecte directament, sense ResponseEntity. És més llegible, però perd la Location. La política de CicloUrbana: ResponseEntity.created() per a les creacions —la Location forma part d'un 201 ben fet— i @ResponseStatus on l'estat sigui fix i no calgui capçalera.
PUT: reemplaçament total i idempotència
PUT: reemplaçament total i idempotènciaPUT /api/v1/estacions/{id} significa "l'estat d'aquest recurs passa a ser exactament el que t'envio". És un reemplaçament complet, no una fusió: si el cos omet l'adreça, l'adreça queda buida.
// A EstacioService
public Optional<Estacio> reemplacar(Long id, NovaEstacio dades) {
if (!estacioRepositori.existeixPerId(id)) {
return Optional.empty();
}
// Objecte nou amb TOTS els camps del cos: el que el client
// no envia es perd, i aquesta és exactament la semàntica de PUT.
return Optional.of(estacioRepositori.desar(new Estacio(id, dades.nom(),
dades.adreca(), dades.capacitat(), dades.latitud(), dades.longitud())));
}
// A EstacioController
@PutMapping(path = "/{id:\\d+}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Estacio> reemplacar(@PathVariable("id") Long id,
@RequestBody NovaEstacio dades) {
return estacioService.reemplacar(id, dades)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}Per què PUT és idempotent. Enviar tres vegades el mateix cos deixa l'estació igual que enviar-lo una vegada, perquè descriu un estat final absolut i no una operació relativa. És la propietat que permet reintentar sense por quan la xarxa falla.
200 amb cos o 204 sense cos. Totes dues són vàlides: el 200 amb el recurs actualitzat deixa veure al client els camps que calcula o normalitza el servidor, a canvi d'una resposta més pesada; el 204 minimitza el trànsit però obliga a un GET posterior. CicloUrbana retorna 200 amb el recurs.
PUT sobre un recurs inexistent. L'RFC permet que PUT creï el recurs si el client tria l'identificador (upsert). CicloUrbana no ho fa: els identificadors els genera el servidor, així que un PUT sobre l'id 999 respon 404. Crear amb PUT només té sentit quan l'identificador és natural i el coneix el client, com seria un codi oficial RIB-001.
PATCH: actualització parcial i el problema del camp absent
PATCH: actualització parcial i el problema del camp absentPATCH modifica només els camps que s'envien. El tauler d'operaris de Ribalta el necessita per corregir la capacitat d'una estació sense reenviar-ne les coordenades.
I aquí apareix el problema més subtil d'aquesta lliçó. Considera {"capacitat": 28} ("no toquis l'adreça") davant de {"capacitat": 28, "adreca": null} ("esborra l'adreça"). Si el cos es deserialitza a un record amb un camp String adreca, totes dues produeixen exactament el mateix: adreca == null. El camp absent i el camp posat a nul són indistingibles, i tanmateix signifiquen coses oposades. Les tres solucions:
| Estratègia | Com distingeix | Avantatges | Inconvenients |
|---|---|---|---|
Map<String, Object> |
Per la presència de la clau | Sense dependències | Sense tipatge, sense validació, sense OpenAPI |
Camps Optional<T> |
null = absent, Optional.empty() = a nul |
Només Java estàndard | Doble embolcall confús |
JsonNullable<T> |
isPresent() = enviat |
Tipat, valida, documenta | Dependència addicional |
Solució amb Map, la més directa i la que fa servir CicloUrbana de moment:
// A EstacioService. Es parteix de l'estat actual i només se substitueix
// el que ve al mapa.
public Optional<Estacio> actualitzarParcial(Long id, Map<String, Object> canvis) {
return estacioRepositori.cercarPerId(id).map(actual ->
estacioRepositori.desar(new Estacio(id,
canvis.containsKey("nom")
? (String) canvis.get("nom") : actual.nom(),
canvis.containsKey("adreca")
? (String) canvis.get("adreca") : actual.adreca(),
canvis.containsKey("capacitat")
? ((Number) canvis.get("capacitat")).intValue() : actual.capacitat(),
actual.latitud(), actual.longitud())));
}containsKey és la clau literal de l'assumpte: distingeix "la clau no ha vingut" de "la clau ha vingut amb valor null". El Map no està exempt de problemes —els casts són fràgils i una clau mal escrita s'ignora en silenci—, per la qual cosa convé validar que totes les claus rebudes són conegudes i rebutjar la resta amb un 400.
Solució amb JsonNullable, la recomanada per a API públiques. Requereix la dependència org.openapitools:jackson-databind-nullable i registrar-ne el mòdul amb el customizer de 03-02: builder.modulesToInstall(new JsonNullableModule()). El DTO queda tipat i expressiu:
public record PedacEstacio(JsonNullable<String> nom,
JsonNullable<String> adreca,
JsonNullable<Integer> capacitat) {
public PedacEstacio { // constructor compacte: mai camps null
nom = nom == null ? JsonNullable.undefined() : nom;
adreca = adreca == null ? JsonNullable.undefined() : adreca;
capacitat = capacitat == null ? JsonNullable.undefined() : capacitat;
}
/** Aplica el pedaç sobre l'estat actual i retorna una Estacio nova. */
public Estacio aplicarSobre(Estacio actual) {
return new Estacio(actual.id(), nom.orElse(actual.nom()),
adreca.orElse(actual.adreca()), capacitat.orElse(actual.capacitat()),
actual.latitud(), actual.longitud());
}
}JsonNullable.undefined() significa "el client no ha enviat aquesta clau" i JsonNullable.of(null) significa "l'ha enviada amb valor nul". orElse retorna el valor actual quan no està definida: exactament la semàntica de PATCH.
El controlador, amb la variant del Map:
@PatchMapping(path = "/{id:\\d+}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Estacio> actualitzarParcial(@PathVariable("id") Long id,
@RequestBody Map<String, Object> canvis) {
return estacioService.actualitzarParcial(id, canvis)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}Una nota sobre l'estàndard: existeix JSON Patch (RFC 6902), un format amb operacions explícites —[{"op":"replace","path":"/capacitat","value":28}]— que resol el problema d'arrel, però és rigorós i poc amigable per als clients. La variant majoritària és el JSON Merge Patch (RFC 7386): enviar l'objecte parcial, on null significa "esborra aquest camp". CicloUrbana fa servir merge patch.
DELETE: baixa i idempotència
DELETE: baixa i idempotència// A EstacioController; el servei només delega a eliminarPerId
@DeleteMapping("/{id:\\d+}")
public ResponseEntity<Void> eliminar(@PathVariable("id") Long id) {
return estacioService.eliminar(id)
? ResponseEntity.noContent().build() // 204: esborrada
: ResponseEntity.notFound().build(); // 404: no existia
}El debat del 404 al DELETE. Què ha de respondre un DELETE sobre una estació ja esborrada? Hi ha dos arguments: 204 No Content perquè l'estat final és el desitjat —l'estació no existeix—, que és la lectura estricta de la idempotència; o 404 Not Found, que informa el client que el seu model mental està desactualitzat. Totes dues són correctes i hi ha API de primera línia a cada bàndol. La idempotència no exigeix que la resposta sigui idèntica cada vegada, només que l'estat del servidor ho sigui; un 204 seguit d'un 404 és perfectament idempotent. CicloUrbana tria 404, perquè un tauler d'operaris que esborra una estació inexistent probablement té la llista sense refrescar, i silenciar-ho amaga el problema. L'innegociable és que el segon DELETE no provoqui un error del servidor: si un NoSuchElementException es propaga fins a un 500, l'operació deixa de ser reintentable.
Esborrat lògic davant del físic. Esborrar una estació amb lloguers històrics destruiria dades que l'ajuntament necessita. L'habitual en producció és marcar-la com a inactiva i excloure-la dels llistats: l'API no canvia —continua sent DELETE amb 204—, només la implementació. Ho aplicarem amb base de dades, al mòdul 4.
- Taula resum: verb, estat i cos
La referència completa de l'API de CicloUrbana:
| Verb | Ruta | Cos petició | Èxit | Cos resposta | Errors |
|---|---|---|---|---|---|
GET |
/estacions |
— | 200 |
Array | — |
GET |
/estacions/{id} |
— | 200 |
Objecte | 404 |
POST |
/estacions |
Objecte sense id |
201 |
Objecte + Location |
400, 409 |
PUT |
/estacions/{id} |
Objecte complet | 200 |
Objecte actualitzat | 400, 404, 412 |
PATCH |
/estacions/{id} |
Objecte parcial | 200 |
Objecte actualitzat | 400, 404, 412 |
DELETE |
/estacions/{id} |
— | 204 |
Buit | 404, 409 |
HEAD |
/estacions/{id} |
— | 200 |
Només capçaleres | 404 |
OPTIONS |
/estacions |
— | 200 |
Buit + Allow |
— |
I el fitxer .http que l'exercita sencera:
@base = http://localhost:8080/api/v1
### Crear una estació
POST {{base}}/estacions
Content-Type: application/json
{ "nom": "Mercat Central", "adreca": "Carrer del Mercat, 8",
"capacitat": 20, "latitud": 41.3902, "longitud": 2.1655 }
### Reemplaçament total: COMPTE, el que s'ometi es perd
PUT {{base}}/estacions/5
Content-Type: application/json
{ "nom": "Mercat Central", "adreca": "Carrer del Mercat, 8",
"capacitat": 26, "latitud": 41.3902, "longitud": 2.1655 }
### Actualització parcial (merge patch): només la capacitat
PATCH {{base}}/estacions/5
Content-Type: application/json
{ "capacitat": 30 }
### Baixa, i segona baixa: idempotent, retorna 404 però no trenca res
DELETE {{base}}/estacions/5
DELETE {{base}}/estacions/5
- El recurs bicicleta i el subrecurs d'estació
Afegim el paquet com.ciclourbana.bicicletes amb el seu model:
package com.ciclourbana.bicicletes;
public enum EstatBicicleta { DISPONIBLE, EN_US, MANTENIMENT, RETIRADA }
/**
* Bicicleta elèctrica de la xarxa de Ribalta.
* estacioId és null quan la bicicleta està en ús (fora d'ancoratge).
*/
public record Bicicleta(Long id, String matricula, int nivellBateria,
EstatBicicleta estat, Long estacioId) {}El servei, amb les consultes que necessita l'API (ometem el constructor, que injecta BicicletaRepositori, EstacioRepositori i XarxaProperties):
@Service
public class BicicletaService {
/** Bicicletes ancorades en una estació. Optional.empty() = l'estació no existeix. */
public Optional<List<Bicicleta>> cercarPerEstacio(Long estacioId, boolean nomesDisponibles) {
if (!estacioRepositori.existeixPerId(estacioId)) {
return Optional.empty();
}
List<Bicicleta> bicicletes = bicicletaRepositori.cercarPerEstacio(estacioId).stream()
.filter(b -> !nomesDisponibles || esUtilitzable(b))
.toList();
return Optional.of(bicicletes);
}
/** Disponible i amb bateria suficient segons ciclourbana.xarxa.llindar-bateria. */
private boolean esUtilitzable(Bicicleta b) {
return b.estat() == EstatBicicleta.DISPONIBLE
&& b.nivellBateria() >= xarxaProperties.llindarBateria();
}
}Aquí es veu per què vam invertir el mòdul 2 en propietats tipades: el llindar de bateria no està escrit al codi sinó a ciclourbana.xarxa.llindar-bateria, i l'ajuntament pot pujar-lo al 30% a l'hivern sense recompilar.
El subrecurs. /api/v1/estacions/{id}/bicicletes no és el mateix que /api/v1/bicicletes?estacioId={id}: el subrecurs expressa pertinença i respon 404 si l'estació no existeix, mentre que el filtre sobre la col·lecció global respon 200 amb un array buit. Totes dues formes poden coexistir.
// A EstacioController: el subrecurs penja de l'estació
@GetMapping("/{id:\\d+}/bicicletes")
public ResponseEntity<List<Bicicleta>> bicicletesDeEstacio(
@PathVariable("id") Long id,
@RequestParam(defaultValue = "false") boolean nomesDisponibles) {
return bicicletaService.cercarPerEstacio(id, nomesDisponibles)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build()); // l'estació no existeix
}BicicletaController replica el patró d'EstacioController per a GET /api/v1/bicicletes, GET /api/v1/bicicletes/{id} i POST /api/v1/bicicletes. La creació exigeix que la matrícula tingui el format RB-0142 i no estigui repetida; totes dues comprovacions es faran declaratives a 03-04 amb @MatriculaBicicleta.
- Endpoints d'acció: iniciar i finalitzar un lloguer
Aquí el CRUD es queda curt. "Iniciar un lloguer" no és només crear una fila: cal comprovar que la bicicleta està disponible i té bateria, marcar-la com a EN_US, desancorar-la, registrar l'hora amb el bean Clock i publicar l'esdeveniment LloguerIniciat. I "finalitzar" tampoc no és una modificació qualsevol: calcula l'import amb SelectorTarifa, ancora la bicicleta al destí i comprova que hi càpiga. La primera part sí que encaixa a REST sense esforç: iniciar un lloguer és crear un recurs lloguer.
package com.ciclourbana.lloguers;
public record IniciarLloguerRequest(Long usuariId, Long bicicletaId) {}
public record Lloguer(Long id, Long usuariId, Long bicicletaId,
Long estacioOrigenId, Long estacioDestiId,
LocalDateTime inici, LocalDateTime fi,
BigDecimal importTotal, EstatLloguer estat) {}@RestController
@RequestMapping(path = "/api/v1/lloguers", produces = MediaType.APPLICATION_JSON_VALUE)
public class LloguerController {
private final LloguerService lloguerService;
public LloguerController(LloguerService lloguerService) {
this.lloguerService = lloguerService;
}
/** POST /api/v1/lloguers — crear un lloguer és CRUD normal. */
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Lloguer> iniciar(@RequestBody IniciarLloguerRequest peticio) {
Lloguer lloguer = lloguerService.iniciar(peticio.usuariId(), peticio.bicicletaId());
URI ubicacio = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(lloguer.id()).toUri();
return ResponseEntity.created(ubicacio).body(lloguer);
}
}GET /api/v1/lloguers/{id} segueix el mateix patró que EstacioController#obtenirPerId.
Finalitzar és el que no encaixa. Les opcions sobre la taula:
| Disseny | Petició | Valoració |
|---|---|---|
PATCH de l'estat |
PATCH /lloguers/7 amb {"estat":"FINALITZAT"} |
Pur, però el servidor ha d'endevinar que aquest canvi dispara el cobrament; l'estació de destí no encaixa |
PUT del recurs |
PUT /lloguers/7 amb tot l'objecte |
El client enviaria l'import, que només calcula el servidor |
| Subrecurs d'acció | POST /lloguers/7/finalitzar |
Explícit, amb cos i errors propis |
| Subrecurs d'estat | PUT /lloguers/7/estat |
Intermedi; continua sense encaixar el destí |
CicloUrbana tria el subrecurs d'acció:
public record FinalitzarLloguerRequest(Long estacioDestiId) {}
/** POST /api/v1/lloguers/{id}/finalitzar — transició d'estat de la màquina
* del negoci, no CRUD. És POST perquè no és idempotent: finalitzar dues vegades
* és un error que ha de respondre 409, no un no-op. */
@PostMapping(path = "/{id:\\d+}/finalitzar", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Lloguer> finalitzar(@PathVariable("id") Long id,
@RequestBody FinalitzarLloguerRequest peticio) {
return ResponseEntity.ok(lloguerService.finalitzar(id, peticio.estacioDestiId()));
}Quan està justificat un endpoint d'acció. No és una llicència per tornar al nivell 1 de Richardson. Ha de complir les tres condicions: (1) té un nom en el llenguatge del negoci —els operaris diuen "finalitzar un lloguer", no "posar l'estat a finalitzat"—; (2) té efectes més enllà de canviar un camp —calcula l'import, ancora la bicicleta, comprova la capacitat, publica un esdeveniment—; i (3) té contracte d'entrada i errors propis —requereix estacioDestiId i pot fallar amb 409—. Si no compleix les tres, gairebé segur que és un PATCH disfressat: POST /api/v1/estacions/5/reanomenar no en compleix cap.
El servei, on viu la lògica de veritat:
public Lloguer finalitzar(Long lloguerId, Long estacioDestiId) {
Lloguer lloguer = lloguerRepositori.cercarPerId(lloguerId)
.orElseThrow(() -> new IllegalArgumentException("Lloguer no trobat"));
if (lloguer.estat() == EstatLloguer.FINALITZAT) {
throw new IllegalStateException("El lloguer ja estava finalitzat"); // 409 a 03-06
}
Estacio desti = estacioRepositori.cercarPerId(estacioDestiId)
.orElseThrow(() -> new IllegalArgumentException("Estació no trobada"));
if (bicicletaRepositori.comptarPerEstacio(desti.id()) >= desti.capacitat()) {
throw new IllegalStateException("L'estació és plena"); // 409 a 03-06
}
LocalDateTime fi = LocalDateTime.now(clock); // el Clock de ConfiguracioComuna
BigDecimal importTotal = selectorTarifa.per(lloguer.usuariId())
.calcular(Duration.between(lloguer.inici(), fi));
bicicletaService.ancorarA(lloguer.bicicletaId(), desti.id());
return lloguerRepositori.desar(new Lloguer(lloguer.id(), lloguer.usuariId(),
lloguer.bicicletaId(), lloguer.estacioOrigenId(), desti.id(),
lloguer.inici(), fi, importTotal, EstatLloguer.FINALITZAT));
}El Clock injectat no és cap caprici: gràcies a ell, al mòdul 6 provarem el càlcul d'un lloguer de dues hores sense esperar dues hores.
HEAD i OPTIONS
HEAD i OPTIONSHEAD és idèntic a GET però sense cos: només retorna les capçaleres. Serveix per comprovar si un recurs existeix, o per conèixer-ne la mida o l'ETag abans de descarregar-lo. Spring l'implementa automàticament per a tot @GetMapping: no cal escriure res. OPTIONS informa de quins verbs admet una ruta, i també el genera Spring sol a partir dels mapatges declarats:
curl -I http://localhost:8080/api/v1/estacions/1
# HTTP/1.1 200 · Content-Type: application/json · Content-Length: 142
curl -i -X OPTIONS http://localhost:8080/api/v1/estacions/1
# HTTP/1.1 200 · Allow: GET,PUT,PATCH,DELETE,HEAD,OPTIONSAquesta segona és la resposta que el navegador fa servir al preflight de CORS que vam veure a 03-02. Per desactivar la resposta automàtica —rarament— existeix spring.mvc.dispatch-options-request.
- Concurrència:
ETag, If-Match i peticions condicionals
ETag, If-Match i peticions condicionalsL'escenari, amb dos operaris del taller de Ribalta:
sequenceDiagram
participant A as Operària Anna
participant S as CicloUrbana
participant B as Operari Bru
A->>S: GET /estacions/1 (capacitat 24)
B->>S: GET /estacions/1 (capacitat 24)
A->>S: PUT /estacions/1 (capacitat 28)
S-->>A: 200 OK
B->>S: PUT /estacions/1 (capacitat 24, adreça corregida)
S-->>B: 200 OK
Note over S: El canvi de l'Anna s'ha perdut<br/>sense que ningú no se n'assabenti
És el problema de l'actualització perduda. La solució d'HTTP és el bloqueig optimista amb peticions condicionals: el servidor etiqueta cada versió del recurs amb un ETag i el client el retorna a If-Match en modificar.
GET /api/v1/estacions/1
→ 200 OK
ETag: "a3f5c9e1"
PUT /api/v1/estacions/1
If-Match: "a3f5c9e1"
→ 200 OK si l'ETag continua sent aquest
→ 412 Precondition Failed si algú altre l'ha canviat mentrestantEn Bru rebria un 412 i el seu tauler podria recarregar les dades i mostrar el conflicte en lloc de trepitjar la feina de l'Anna.
La manera més barata d'obtenir ETag a Spring Boot és el filtre ShallowEtagHeaderFilter, que calcula un hash MD5 del cos ja serialitzat:
package com.ciclourbana.comu;
@Configuration
public class ConfiguracioEtag {
@Bean
FilterRegistrationBean<ShallowEtagHeaderFilter> filtreEtag() {
var registre = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
registre.addUrlPatterns("/api/v1/estacions/*", "/api/v1/bicicletes/*");
registre.setName("filtreEtag");
return registre;
}
}Amb el filtre actiu, un GET repetit amb If-None-Match: "0a1b2c3d4e..." respon 304 Not Modified sense cos, estalviant la transferència.
| Capçalera | Verb típic | Què comprova | Resposta si falla |
|---|---|---|---|
If-None-Match |
GET |
Ha canviat el recurs? | 304 Not Modified |
If-Match |
PUT, PATCH, DELETE |
Continua sent la versió que vaig llegir? | 412 Precondition Failed |
If-Modified-Since |
GET |
Igual, amb data | 304 Not Modified |
If-Unmodified-Since |
PUT, PATCH |
Igual, amb data | 412 Precondition Failed |
Per a les escriptures, el filtre superficial no n'hi ha prou: cal comprovar l'If-Match al controlador.
@PutMapping(path = "/{id:\\d+}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Estacio> reemplacar(
@PathVariable("id") Long id,
@RequestHeader(value = HttpHeaders.IF_MATCH, required = false) String ifMatch,
@RequestBody NovaEstacio dades) {
Optional<Estacio> actual = estacioService.cercarPerId(id);
if (actual.isEmpty()) {
return ResponseEntity.notFound().build();
}
// Les cometes de l'ETag són obligatòries segons l'RFC 9110
String etagActual = "\"" + estacioService.versioDe(actual.get()) + "\"";
if (ifMatch != null && !ifMatch.equals(etagActual)) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
Estacio reemplacada = estacioService.reemplacar(id, dades).orElseThrow();
return ResponseEntity.ok()
.eTag("\"" + estacioService.versioDe(reemplacada) + "\"")
.body(reemplacada);
}Limitacions de l'ETag superficial: el servidor genera la resposta completa i només llavors calcula el hash, així que estalvia amplada de banda però no feina de servidor; i com que el hash depèn del JSON exacte, un canvi en l'ordre de les propietats produeix un ETag diferent encara que les dades siguin idèntiques. La solució robusta és un camp de versió a l'entitat, que és exactament el que fa @Version de JPA: arriba al mòdul 4 i substituirà aquest filtre.
Errors Comuns i Consells
Retornar 200 en lloc de 201 en crear, o oblidar la capçalera Location. El 201 amb Location és el que distingeix una creació d'una consulta per a qualsevol client genèric, i sense la capçalera el client ha de replicar l'esquema de rutes del servidor.
Fer servir PUT per a actualitzacions parcials. PUT reemplaça: si el client envia {"capacitat": 28}, l'estació es queda sense nom ni adreça. Font clàssica de pèrdua silenciosa de dades.
No distingir el camp absent del nul al PATCH. Deserialitzar a un record normal converteix el que no s'ha enviat en null i esborra camps que el client no volia tocar. Fes servir Map, JsonNullable o Optional.
Fer que el segon DELETE peti. Un NoSuchElementException propagat fins a un 500 trenca la idempotència i fa perillosos els reintents automàtics.
Posar verbs a l'URL sense justificació. POST /estacions/5/reanomenar no compleix cap de les tres condicions de l'apartat 8: és un PATCH. I retornar un 404 des del controlador amb ResponseEntity funciona, però escampa la lògica d'errors per tots els controladors; des de 03-06 es centralitza.
Consell: prova sempre la segona crida. Executa cada PUT i cada DELETE dues vegades seguides; si la segona dona un resultat diferent de l'esperat, la idempotència està trencada. I l'escriptura viu al servei. El controlador tradueix HTTP; les regles —nom duplicat, estació plena, bateria insuficient— pertanyen al servei i s'han d'aplicar vingui la crida d'on vingui.
Exercicis
Exercici 1: Endpoint d'acció per posar una bicicleta en manteniment
Els operaris de Ribalta necessiten retirar temporalment una bicicleta del servei. Dissenya i implementa l'endpoint, justificant el verb i la ruta. Ha d'acceptar un motiu, canviar l'estat a MANTENIMENT, i rebutjar l'operació si la bicicleta està actualment en ús. Implementa també l'operació inversa.
Exercici 2: PATCH segur amb validació de claus
La implementació amb Map<String, Object> accepta en silenci claus desconegudes: PATCH {"capcitat": 30} (amb l'errada) respon 200 sense canviar res, i l'operari es pensa que ha funcionat. Modifica actualitzarParcial per rebutjar amb 400 qualsevol clau no reconeguda, i per impedir que es modifiqui l'id.
Exercici 3: DELETE amb comprovació d'integritat i If-Match
Esborrar una estació que encara té bicicletes ancorades deixaria bicicletes òrfenes. Implementa un DELETE que respongui 409 Conflict en aquest cas, llevat que s'enviï ?forcar=true, cas en què les bicicletes passen a estat RETIRADA. Afegeix-hi a més suport d'If-Match.
Solucions
Solució 1.
Disseny. Compleix les tres condicions de l'apartat 8: nom propi al negoci ("posar en manteniment"), efectes més enllà d'un camp (desancorar, notificar el taller) i contracte i errors propis (el motiu; 409 si està en ús). Per tant, endpoint d'acció amb POST: POST /api/v1/bicicletes/{id}/manteniment i POST /api/v1/bicicletes/{id}/alta-servei. Alternativa igualment defensable: PUT i DELETE sobre /bicicletes/{id}/manteniment, modelant el manteniment com un subrecurs que existeix o no; més elegant i menys llegible.
public record MantenimentRequest(String motiu) {}
// A BicicletaService
public Bicicleta enviarAManteniment(Long id, String motiu) {
Bicicleta bicicleta = bicicletaRepositori.cercarPerId(id)
.orElseThrow(() -> new IllegalArgumentException("Bicicleta no trobada"));
if (bicicleta.estat() == EstatBicicleta.EN_US) {
throw new IllegalStateException("No es pot retirar una bicicleta en ús"); // 409 a 03-06
}
if (bicicleta.estat() == EstatBicicleta.MANTENIMENT) {
return bicicleta; // ja ho està: no és un error, no fem res
}
log.info("Bicicleta {} a manteniment. Motiu: {}", bicicleta.matricula(), motiu);
return bicicletaRepositori.desar(new Bicicleta(bicicleta.id(), bicicleta.matricula(),
bicicleta.nivellBateria(), EstatBicicleta.MANTENIMENT, bicicleta.estacioId()));
}
// A BicicletaController
@PostMapping(path = "/{id:\\d+}/manteniment", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Bicicleta> aManteniment(@PathVariable("id") Long id,
@RequestBody MantenimentRequest peticio) {
return ResponseEntity.ok(bicicletaService.enviarAManteniment(id, peticio.motiu()));
}Detall de disseny. Enviar a manteniment una bicicleta que ja és en manteniment no és un error: es retorna l'estat actual. Això fa l'operació idempotent a la pràctica, encara que el verb POST no ho garanteixi, i permet al tauler d'operaris reintentar sense por després d'una fallada de xarxa.
Solució 2.
private static final Set<String> CAMPS_MODIFICABLES =
Set.of("nom", "adreca", "capacitat", "latitud", "longitud");
public Optional<Estacio> actualitzarParcial(Long id, Map<String, Object> canvis) {
// 1. Rebutjar claus desconegudes ABANS de tocar res. A 03-06 això
// serà una excepció pròpia -> 400 amb la llista de camps erronis.
Set<String> desconegudes = new LinkedHashSet<>(canvis.keySet());
desconegudes.removeAll(CAMPS_MODIFICABLES);
if (!desconegudes.isEmpty()) {
throw new IllegalArgumentException("Camps no reconeguts: " + desconegudes);
}
// 2. L'id no es modifica mai, ni tan sols si ve amb el valor correcte
if (canvis.containsKey("id")) {
throw new IllegalArgumentException("El camp id no és modificable");
}
return estacioRepositori.cercarPerId(id).map(actual -> estacioRepositori.desar(
new Estacio(
id, // l'id el mana sempre la ruta
valorOPerDefecte(canvis, "nom", actual.nom()),
valorOPerDefecte(canvis, "adreca", actual.adreca()),
canvis.containsKey("capacitat")
? ((Number) canvis.get("capacitat")).intValue() : actual.capacitat(),
canvis.containsKey("latitud")
? ((Number) canvis.get("latitud")).doubleValue() : actual.latitud(),
canvis.containsKey("longitud")
? ((Number) canvis.get("longitud")).doubleValue() : actual.longitud())));
}
@SuppressWarnings("unchecked")
private <T> T valorOPerDefecte(Map<String, Object> canvis, String clau, T actual) {
return canvis.containsKey(clau) ? (T) canvis.get(clau) : actual;
}Per què l'id es rebutja fins i tot quan coincideix. Acceptar-lo al cos obre la porta que una implementació futura el faci servir per reassignar el recurs. La regla general: l'identificador de la ruta és l'única font de veritat.
Sobre els casts. ((Number) valor).intValue() és necessari perquè Jackson deserialitza els nombres JSON a Integer, Long o Double segons la seva magnitud i forma; un (Integer) directe peta amb ClassCastException si el client envia 28.0. Aquesta fragilitat és el millor argument per passar-se a JsonNullable amb tipus declarats.
Solució 3.
// A EstacioService. L'enum permet distingir tres desenllaços, no dos.
public enum ResultatBaixa { ELIMINADA, NO_EXISTIA, AMB_BICICLETES }
public ResultatBaixa eliminar(Long id, boolean forcar) {
if (!estacioRepositori.existeixPerId(id)) {
return ResultatBaixa.NO_EXISTIA;
}
List<Bicicleta> ancorades = bicicletaRepositori.cercarPerEstacio(id);
if (!ancorades.isEmpty() && !forcar) {
return ResultatBaixa.AMB_BICICLETES;
}
// Amb forcar=true, les bicicletes es retiren del servei abans d'esborrar
for (Bicicleta b : ancorades) {
bicicletaRepositori.desar(new Bicicleta(b.id(), b.matricula(),
b.nivellBateria(), EstatBicicleta.RETIRADA, null));
log.warn("Bicicleta {} retirada per baixa forçada de l'estació {}", b.matricula(), id);
}
estacioRepositori.eliminarPerId(id);
return ResultatBaixa.ELIMINADA;
}
// A EstacioController
@DeleteMapping("/{id:\\d+}")
public ResponseEntity<Void> eliminar(
@PathVariable("id") Long id,
@RequestParam(defaultValue = "false") boolean forcar,
@RequestHeader(value = HttpHeaders.IF_MATCH, required = false) String ifMatch) {
Optional<Estacio> actual = estacioService.cercarPerId(id);
if (actual.isEmpty()) {
return ResponseEntity.notFound().build();
}
// Comprovació optimista: si el client envia If-Match, ha de coincidir
if (ifMatch != null
&& !ifMatch.equals("\"" + estacioService.versioDe(actual.get()) + "\"")) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
return switch (estacioService.eliminar(id, forcar)) {
case ELIMINADA -> ResponseEntity.noContent().build(); // 204
case NO_EXISTIA -> ResponseEntity.notFound().build(); // 404
case AMB_BICICLETES -> ResponseEntity.status(HttpStatus.CONFLICT).build(); // 409
};
}Notes de disseny. El switch sobre l'enum, exhaustiu gràcies a Java 21, garanteix que si demà s'afegeix un valor a ResultatBaixa el compilador obligui a decidir quin codi HTTP li correspon.
Sobre l'If-Match opcional: si el client no l'envia, l'operació es realitza sense comprovació. Una API amb garanties fortes pot exigir-lo sempre i respondre 428 Precondition Required quan falti; per al tauler de Ribalta, opcional n'hi ha prou. I sobre ?forcar=true: és un paràmetre de consulta i no una ruta diferent perquè modifica com s'executa l'operació, no quin recurs es toca, complint la regla de disseny d'URL de 03-01. Observa també el log.warn: una baixa forçada retira bicicletes del servei i això ha de quedar registrat.
Conclusió
L'API de CicloUrbana ja escriu. Saps crear recursos amb POST retornant 201 Created i una capçalera Location construïda amb ServletUriComponentsBuilder, inclosa la línia server.forward-headers-strategy que fa que aquesta URL sigui correcta darrere d'un proxy. Entens PUT com a reemplaçament total, per què això el fa idempotent i per què CicloUrbana no permet crear amb PUT. Has desmuntat el problema més subtil de PATCH —distingir el camp absent del camp posat a nul— i coneixes les tres solucions, amb Map implementada i JsonNullable com a camí recomanat, a més de la diferència entre JSON Patch i JSON Merge Patch. Saps implementar DELETE sense trencar la idempotència i coneixes el debat del 404 davant del 204 amb arguments de tots dos costats. Tens la taula completa de verb, estat i cos de l'API.
A més, la xarxa de Ribalta ja està sencera: el recurs bicicleta amb el seu estat i el seu nivell de bateria, el subrecurs /estacions/{id}/bicicletes amb la seva diferència semàntica davant del filtre global, i els lloguers amb la creació per POST i la finalització mitjançant un endpoint d'acció, justificat amb tres condicions concretes que eviten que aquesta excepció es converteixi en la porta de tornada al nivell 1 de Richardson. Saps que HEAD i OPTIONS els genera Spring sol. I has resolt el problema de les actualitzacions perdudes amb ETag, If-Match i ShallowEtagHeaderFilter, coneixent-ne les limitacions i sabent que la solució definitiva arribarà amb @Version al mòdul 4.
Però hi ha una esquerda que s'ha anat eixamplant lliçó a lliçó. Res no impedeix crear una estació amb capacitat -5, amb el nom buit, amb una latitud de 200 graus o amb una matrícula que no s'assembla a RB-0142. Les comprovacions que hem escrit estan disperses pels serveis, barrejades amb les regles de negoci, i cap no produeix encara un missatge útil per al client.
La lliçó 03-04, Validació de Dades d'Entrada, tanca aquesta esquerda. Veurem per què es valida a la vora de l'aplicació i quines capes de validació existeixen; farem servir Jakarta Bean Validation amb @NotBlank, @Positive, @Size, @Pattern i la resta del catàleg; aplicarem @Valid als cossos i @Validated als paràmetres de ruta i de consulta; distingirem els grups de validació de l'alta i de la modificació; internacionalitzarem els missatges; i construirem dues restriccions pròpies, @MatriculaBicicleta i @CoordenadesValides, amb els seus validadors. També fixarem la política del projecte sobre quan una fallada és un 400 i quan un 409 o un 422.
Curs de Spring Boot
Mòdul 1: Introducció a Spring Boot
- Què és Spring Boot?
- Configuració del teu entorn de desenvolupament
- Creant la teva primera aplicació Spring Boot
- Entenent l'estructura del projecte
- L'arrencada i el cicle de vida de l'aplicació
Mòdul 2: Conceptes bàsics de Spring Boot
- Anotacions de Spring Boot
- Injecció de dependències a Spring Boot
- Àmbit i cicle de vida dels beans
- Configuració de Spring Boot
- Propietats de Spring Boot
- Autoconfiguració i starters per dins
Mòdul 3: Construint serveis web RESTful
- Introducció als serveis web RESTful
- Creant controladors REST
- Gestió dels mètodes HTTP
- Validació de dades d'entrada
- DTOs i mapatge entre capes
- Gestió d'excepcions a REST
- Documentar l'API amb OpenAPI
Mòdul 4: Accés a dades amb Spring Boot
- Introducció a Spring Data JPA
- Configuració de fonts de dades
- Creació d'entitats JPA
- Relacions entre entitats
- Ús de repositoris de Spring Data
- Mètodes de consulta a Spring Data JPA
- Transaccions i gestió de la persistència
- Migracions d'esquema amb Flyway
Mòdul 5: Seguretat a Spring Boot
- Introducció a Spring Security
- Configuració de Spring Security
- Autenticació i autorització d'usuaris
- Implementació d'autenticació JWT
- Seguretat a nivell de mètode i enduriment de l'API
Mòdul 6: Proves a Spring Boot
- Introducció a les proves
- Proves unitàries amb JUnit
- Simulació amb Mockito
- Proves d'integració
- Proves amb Testcontainers
Mòdul 7: Funcions avançades de Spring Boot
- Spring Boot Actuator
- Perfils de Spring Boot
- Tasques programades i execució asíncrona
- Spring Boot amb Docker
- Spring Boot i microserveis
- Comunicació entre serveis i tolerància a fallades
Mòdul 8: Desplegament d'aplicacions Spring Boot
- Introducció al desplegament
- Desplegant a Heroku
- Desplegant a AWS
- Desplegant a Kubernetes
- Integració i lliurament continus
Mòdul 9: Rendiment i monitoratge
- Ajust de rendiment
- Memòria cau amb Spring Cache
- Monitoratge amb Spring Boot Actuator
- Ús de Prometheus i Grafana
- Gestió de registres i logs
- Traçabilitat distribuïda
