A la lliçó anterior vam dissenyar el contracte: tretze endpoints, els seus verbs, els seus codis d'estat i les regles de les URL. Ara escrivim el codi que el compleix. Un controlador de Spring és una classe enganyosament senzilla —rep objectes Java i retorna objectes Java— però entre la petició HTTP i aquest mètode hi ha una maquinària de resolució d'arguments i conversió de missatges que convé dominar, perquè és la que explica el 90% dels "no m'arriba el paràmetre" i "el JSON no surt com esperava". En aquesta lliçó desmuntem @RestController, aprenem a extreure cada peça d'una petició (ruta, cadena de consulta, capçaleres, cos), controlem com Jackson serialitza els nostres objectes, decidim quan fer servir ResponseEntity i acabem amb un EstacioController complet, amb filtres i paginació, provat des del terminal i des de l'IDE.
Contingut
@RestController: què és exactament@RequestMappingi les dreceres per verb@PathVariable: variables de plantilla@RequestParam: paràmetres de consulta@RequestBody,@RequestHeaderi companyia- Serialització JSON amb Jackson
- Configuració global de Jackson en YAML
ResponseEntitydavant de retornar l'objecte- L'
EstacioControllercomplet - Provar l'API: curl i fitxers
.http - CORS i
@CrossOrigin - Errors Comuns i Consells
- Exercicis
@RestController: què és exactament
@RestController: què és exactamentObrim l'anotació, com vam fer amb @SpringBootApplication a la lliçó 02-01:
@Controller // <-- és un estereotip: bean detectat per @ComponentScan
@ResponseBody // <-- el retorn va al cos, no a una vista
public @interface RestController {
@AliasFor(annotation = Controller.class)
String value() default "";
}Dues anotacions combinades, res més:
@Controllerés un dels cinc estereotips de 02-01: un@Componentespecialitzat que a més fa queRequestMappingHandlerMappinginspeccioni la classe buscant mètodes amb@RequestMapping.@ResponseBodycanvia la interpretació del valor retornat. Sense ella, un mètode que retorna elString"estacions"s'interpreta com el nom d'una vista i Spring busca una plantillaestacions.html. Amb ella, aquestStringés el cos de la resposta.
| Anotació | Retorn del mètode | Quan utilitzar-la |
|---|---|---|
@Controller |
Nom de vista (Thymeleaf, JSP) | Web amb HTML generat al servidor |
@Controller + @ResponseBody per mètode |
Cos de la resposta | Classes mixtes (rar) |
@RestController |
Cos de la resposta, sempre | API REST: el nostre cas |
Un error freqüentíssim en qui comença és anotar amb @Controller un controlador d'API i trobar-se amb un error de resolució de vista o un 404 críptic. Si la resposta ha de ser JSON, és @RestController.
@RequestMapping i les dreceres per verb
@RequestMapping i les dreceres per verb@RequestMapping és l'anotació base de mapatge. Els seus atributs:
| Atribut | Què fa | Exemple |
|---|---|---|
path / value |
Patró de ruta | "/api/v1/estacions" |
method |
Verbs acceptats | RequestMethod.GET |
params |
Exigeix o prohibeix paràmetres | "activa", "!esborrany" |
headers |
Exigeix capçaleres | "X-API-Client=mobil" |
consumes |
Content-Type que accepta |
"application/json" |
produces |
Content-Type que retorna |
"application/json" |
Des de Spring 4.3 existeixen dreceres que fixen method: @GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping. @GetMapping("/{id}") és idèntic a @RequestMapping(path = "/{id}", method = RequestMethod.GET), i sempre preferible per llegibilitat.
El patró habitual combina @RequestMapping a nivell de classe —que fixa el prefix comú— amb les dreceres a nivell de mètode:
@RestController
@RequestMapping(path = "/api/v1/estacions", // prefix de la classe
produces = MediaType.APPLICATION_JSON_VALUE)
public class EstacioController {
@GetMapping // GET /api/v1/estacions
public List<Estacio> llistar() { ... }
@GetMapping("/{id}") // GET /api/v1/estacions/1
public Estacio obtenir(@PathVariable Long id) { ... }
}Concentrar el prefix en un sol lloc evita repetir-lo a cada mètode i fa trivial canviar la versió de l'API. Sobre produces i consumes: no són obligatoris —Spring Boot ja negocia JSON per defecte— però declarar-los fa el contracte visible al codi, produeix respostes correctes (415, 406) en lloc d'errors confusos, i springdoc els fa servir per generar la documentació (03-07).
@PathVariable: variables de plantilla
@PathVariable: variables de plantillaUna variable de plantilla és un segment de la ruta que actua com a paràmetre. Es declara entre claus i es captura amb @PathVariable.
@GetMapping("/{id}")
public Estacio obtenirPerId(@PathVariable Long id) {
return estacioService.cercarPerId(id).orElse(null);
}Spring extreu el segment, el converteix al tipus declarat fent servir el seu ConversionService i el passa com a argument. La conversió funciona amb Long, int, UUID, LocalDate, enums i qualsevol tipus per al qual existeixi un convertidor.
El nom ha de coincidir. Si el paràmetre Java es diu diferent de la plantilla, cal dir-ho: @GetMapping("/{idEstacio}") amb @PathVariable("idEstacio") Long id. Quan coincideixen, el nom es pot ometre només si el codi es compila amb informació de paràmetres. Els projectes generats per Spring Initializr ho fan (el spring-boot-maven-plugin hi afegeix -parameters), però si alguna vegada veus un IllegalArgumentException: Name for argument of type [java.lang.Long] not specified, la causa és aquesta. Escriure el nom sempre és un costum barat i robust.
Una ruta pot tenir diverses variables (@GetMapping("/{idEstacio}/bicicletes/{idBicicleta}")), i una variable pot ser opcional declarant-la com a @PathVariable Optional<Integer> any i registrant dos patrons a la mateixa anotació: @GetMapping({"/estadistiques", "/estadistiques/{any}"}).
Patrons i expressions regulars. La sintaxi {nom:regex} restringeix quins valors captura el segment. És molt útil per desambiguar rutes:
@GetMapping("/{id:\\d+}") // només dígits: /estacions/1
public Estacio perId(@PathVariable Long id) { ... }
@GetMapping("/{codi:[A-Z]{3}-\\d{3}}") // /estacions/RIB-001
public Estacio perCodi(@PathVariable String codi) { ... }Sense les expressions regulars, /estacions/RIB-001 intentaria convertir-se a Long i fallaria amb un error de tipus. Amb elles, cada ruta va al seu mètode. Els comodins de rutes de Spring:
| Patró | Coincideix amb | No coincideix amb |
|---|---|---|
/estacions/{id} |
/estacions/1 |
/estacions/1/bicicletes |
/estacions/{id:\\d+} |
/estacions/1 |
/estacions/abc |
/estacions/* |
/estacions/1 |
/estacions/1/bicicletes |
/estacions/** |
/estacions/1/bicicletes/42 |
— |
/est?cio |
/estacio, /estecio |
/estaacio |
Quan diversos patrons encaixen, Spring tria el més específic: un patró literal guanya a un amb variable, i aquest guanya a un amb comodí.
@RequestParam: paràmetres de consulta
@RequestParam: paràmetres de consultaEls paràmetres de la cadena de consulta —el que va després del ?— es capturen amb @RequestParam.
// GET /api/v1/estacions?capacitatMinima=20
@GetMapping
public List<Estacio> llistar(@RequestParam int capacitatMinima) { ... }Per defecte són obligatoris: si en falta un, Spring respon 400 Bad Request amb MissingServletRequestParameterException. Les tres maneres de fer-lo opcional:
@RequestParam(defaultValue = "0") int capacitatMinima // la millor: mai no hi ha null
@RequestParam(required = false) Integer capacitatMinima // arriba null si no ve
@RequestParam Optional<Integer> capacitatMinima // explícit a la signaturaUn error clàssic: @RequestParam(required = false) int capacitat amb el tipus primitiu. Si el paràmetre falta, Spring intenta assignar null a un int i llança una excepció. Amb required = false, el tipus ha de ser sempre un embolcall.
Llistes i valors múltiples. @RequestParam List<Long> ids accepta les dues convencions habituals: ?ids=1,2,3 i ?ids=1&ids=2&ids=3. També pots rebre tots els paràmetres de cop amb @RequestParam Map<String, String> filtres, però perds el tipatge, la validació automàtica i la documentació OpenAPI: fes-ho servir només si els paràmetres són genuïnament dinàmics.
Agrupar paràmetres en un objecte. Quan un endpoint té cinc o sis paràmetres, la signatura es torna il·legible. Spring permet enllaçar-los a un objecte sense cap anotació:
public record FiltreEstacions(String nom, Integer capacitatMinima,
int pagina, int mida) {}
@GetMapping
public List<Estacio> llistar(FiltreEstacions filtre) { ... }Spring fa servir l'enllaç de dades estàndard (nom del paràmetre → component del record). És més net, es valida amb @Valid (03-04) i es documenta bé. El farem servir a l'exercici 2.
| Anotació | Origen de la dada | Exemple de petició |
|---|---|---|
@PathVariable |
Segment de la ruta | /estacions/**1** |
@RequestParam |
Cadena de consulta o formulari | /estacions?**capacitatMinima=20** |
@RequestBody |
Cos de la petició | {"nom":"Mercat Central"} |
@RequestHeader |
Capçalera HTTP | Accept-Language: ca |
@CookieValue |
Galeta | Cookie: preferencia=mapa |
@MatrixVariable |
Parells dins d'un segment | /estacions/1;zona=centre |
@RequestBody, @RequestHeader i companyia
@RequestBody, @RequestHeader i companyia@RequestBody pren el cos de la petició i el deserialitza al tipus declarat fent servir l'HttpMessageConverter adequat segons el Content-Type; per a application/json, aquest convertidor és Jackson. S'escriu public Estacio crear(@RequestBody Estacio estacio). Només hi pot haver un @RequestBody per mètode: el cos és un. Si falta o el JSON està mal format, es llança HttpMessageNotReadableException, que traduirem a una resposta decent a 03-06. La implementació completa de la creació és el tema de la lliçó següent.
@RequestHeader captura capçaleres, amb la mateixa semàntica de defaultValue i required. També admet rebre-les totes: @RequestHeader HttpHeaders capcaleres.
@GetMapping
public List<Estacio> llistar(
@RequestHeader(value = "Accept-Language", defaultValue = "ca") String idioma) { ... }A CicloUrbana el farem servir per a l'idioma dels missatges d'error (03-04) i per a l'If-Match del control de concurrència (03-03).
@MatrixVariable captura parells clau-valor dins d'un segment de ruta, separats per punt i coma: /api/v1/estacions/1;zona=centre. Forma part de l'RFC 3986 i Spring la suporta, però està deshabilitada per defecte i cal activar-la configurant UrlPathHelper. S'esmenta per completesa: CicloUrbana no la fa servir, perquè aquestes mateixes dades van millor a la cadena de consulta.
A més de les anotacions, un mètode pot declarar tipus que Spring injecta directament: HttpServletRequest, Locale, UriComponentsBuilder, Principal (mòdul 5). Acoblen el controlador a l'API de servlets, així que convé reservar-los per quan no hi hagi alternativa.
- Serialització JSON amb Jackson
Quan un mètode de @RestController retorna un objecte, MappingJackson2HttpMessageConverter el converteix a JSON. Amb un record de Java el procés és directe: cada component del record es converteix en una propietat JSON amb el mateix nom.
El nostre record Estacio(Long id, String nom, String adreca, int capacitat, double latitud, double longitud) es converteix, sense cap anotació, en:
{ "id": 1, "nom": "Plaça Major", "adreca": "Plaça Major, 1",
"capacitat": 24, "latitud": 41.3851, "longitud": 2.1734 }Jackson suporta record de manera nativa des de la versió 2.12: fa servir el constructor canònic per deserialitzar i els accessors per serialitzar. No calen getters, ni constructor buit, ni @JsonCreator. Aquesta és una de les raons per les quals el curs fa servir record per a tot el que viatja per l'API.
Les anotacions de Jackson que farem servir:
| Anotació | Efecte | Exemple |
|---|---|---|
@JsonProperty("nom") |
Reanomena la propietat al JSON | capacitat → "capacitat_total" |
@JsonIgnore |
Exclou el camp del JSON | Coordenades internes de manteniment |
@JsonInclude(NON_NULL) |
Omet el camp si és null |
No enviar "operari": null |
@JsonFormat |
Controla el format de dates i nombres | "2026-08-31T14:05:00" |
@JsonPropertyOrder |
Fixa l'ordre de les propietats | {"id", "nom", ...} |
@JsonAlias |
Accepta diversos noms en deserialitzar | Compatibilitat amb clients antics |
@JsonIgnoreProperties(ignoreUnknown) |
Tolera camps desconeguts en llegir | Valor per defecte a Spring Boot |
Un exemple complet aplicat a la representació d'una bicicleta, que introduirem formalment a la lliçó vinent:
package com.ciclourbana.bicicletes;
@JsonInclude(JsonInclude.Include.NON_NULL) // omet les propietats nul·les
public record Bicicleta(
Long id,
String matricula,
@JsonProperty("bateria") // al JSON es diu "bateria"
int nivellBateria,
EstatBicicleta estat,
Long estacioId,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime ultimaRevisio,
@JsonIgnore // ús intern: no surt mai
String codiAncoratgeIntern
) {}Punt per punt:
@JsonInclude(NON_NULL)a nivell de tipus: siestacioIdésnullperquè la bicicleta està en ús, la propietat no apareix al JSON. Compte: obliga el client a distingir absència denull, un matís que reprendrem ambPATCHa 03-03.@JsonProperty("bateria"): el camp Java segueix l'estil català del projecte i el JSON exposa el nom acordat amb l'equip de l'app. Desacoblar tots dos noms permet reanomenar en Java sense trencar el contracte.@JsonFormat: elshape = STRINGevita que la data s'emeti com a array de nombres, que és el comportament de Jackson sense el mòdul JSR-310.@JsonIgnore:codiAncoratgeInternés informació operativa de Ribalta que no ha de sortir a l'exterior. Aquí apunta un problema seriós: el model intern conté dades que l'API no ha d'exposar. Anotar camps amb@JsonIgnorefunciona a petita escala i es converteix en una font de fuites tan bon punt el model creix. La solució estructural són els DTO (03-05).
- Configuració global de Jackson en YAML
Anotar camp a camp no escala. Spring Boot exposa la configuració global de Jackson sota spring.jackson.*, i aquesta és la manera correcta de fixar polítiques de tot el projecte.
# src/main/resources/application.yml
spring:
jackson:
date-format: yyyy-MM-dd'T'HH:mm:ss # afecta java.util.Date
time-zone: Europe/Madrid
default-property-inclusion: non_null # ometre propietats nul·les a tota l'API
serialization:
write-dates-as-timestamps: false # dates ISO-8601, no nombres
fail-on-empty-beans: false
indent-output: false # en producció estalvia amplada de banda
deserialization:
fail-on-unknown-properties: false # tolerar camps que no coneixem
fail-on-null-for-primitives: true # rebutjar null en un int en lloc de posar 0Les decisions importants d'aquesta configuració:
write-dates-as-timestamps: falseés probablement la propietat de Jackson més rellevant d'un projecte. Sense ella, unInstantes serialitza com1756645500.000000000: il·legible i fràgil. Spring Boot ja la posa afalse, però convé declarar-la. Requereixjackson-datatype-jsr310, quespring-boot-starter-webinclou i Spring Boot registra sol.default-property-inclusion: non_nullaplica a tota l'aplicació el que@JsonIncludefeia a una classe, i evita repetir l'anotació a cada DTO.fail-on-unknown-properties: false(valor per defecte) és una decisió de compatibilitat: si un client antic envia un camp retirat, la petició no falla. És justament el comportament que a 03-01 feia compatible afegir i retirar camps.fail-on-null-for-primitives: truesí que canvia el valor per defecte. Sense ell,{"capacitat": null}es converteix silenciosament encapacitat = 0, i una estació amb capacitat zero és un error de dades difícil de rastrejar. Millor un 400 immediat.
Si necessites alguna cosa que les propietats no cobreixen, personalitza amb un Jackson2ObjectMapperBuilderCustomizer a com.ciclourbana.comu:
@Configuration
public class ConfiguracioJackson {
/** S'acumula amb spring.jackson.* en lloc de reemplaçar-la. */
@Bean
Jackson2ObjectMapperBuilderCustomizer personalitzacioCicloUrbana() {
return builder -> builder
.featuresToDisable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS)
.simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
}
}El detall es dedueix del que hem après sobre @ConditionalOnMissingBean: si declares un bean ObjectMapper propi, JacksonAutoConfiguration s'aparta i perds de cop totes les propietats spring.jackson.*, el mòdul de dates i els convertidors registrats. Personalitza sempre amb el customizer, no reemplaçant l'ObjectMapper.
ResponseEntity davant de retornar l'objecte
ResponseEntity davant de retornar l'objecteUn mètode pot retornar l'objecte directament o embolcallar-lo en un ResponseEntity<T>, que representa la resposta HTTP completa: estat, capçaleres i cos.
// Forma directa: sempre 200 OK, sense capçaleres pròpies
public Estacio obtenir(@PathVariable Long id) { ... }
// Amb ResponseEntity: control total de l'estat i de les capçaleres
public ResponseEntity<Estacio> obtenir(@PathVariable Long id) {
return estacioService.cercarPerId(id)
.map(ResponseEntity::ok) // 200 + cos
.orElseGet(() -> ResponseEntity.notFound().build()); // 404 sense cos
}| Criteri | Retornar l'objecte | Retornar ResponseEntity |
|---|---|---|
| Codi d'estat | Sempre 200 (o el de @ResponseStatus) |
Qualsevol, decidit en temps d'execució |
| Capçaleres pròpies | No | Sí (Location, ETag, Cache-Control) |
| Llegibilitat | Màxima | Una mica més de soroll |
| Tipatge del cos | Directe | Embolcallat en un genèric |
| Documentació OpenAPI | S'infereix sola | De vegades necessita @ApiResponse |
| Ús recomanat | GET que sempre té èxit |
POST (201 + Location), 204, respostes condicionals |
La política de CicloUrbana per a tot el mòdul: retornar l'objecte directament quan el cas d'èxit és únic i l'estat és 200 (tots els GET de llistat); fer servir ResponseEntity quan la resposta necessita una capçalera (Location als POST, ETag a les condicionals) o quan l'estat varia; i no fer servir ResponseEntity per retornar errors. Això últim és clau i encara no és evident: escriure ResponseEntity.notFound() a cada mètode escampa la lògica d'error per tots els controladors. Des de 03-06 llançarem RecursNoTrobatException i un gestor global la convertirà en un 404 ben format. En aquesta lliçó encara fem servir ResponseEntity per al 404, amb un TODO per no oblidar-ho.
Formes útils de construir un ResponseEntity:
ResponseEntity.ok(estacio); // 200 amb cos
ResponseEntity.noContent().build(); // 204 sense cos
ResponseEntity.created(uri).body(estacio); // 201 + Location
ResponseEntity.status(HttpStatus.CONFLICT).build(); // qualsevol codi
ResponseEntity.ok()
.header("X-Total-Elements", "42")
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
.body(llista); // capçaleres a midaExisteix a més @ResponseStatus sobre el mètode, que fixa el codi d'èxit sense ResponseEntity; l'aplicarem als POST a la lliçó següent.
- L'
EstacioController complet
EstacioController completHo reunim tot. Primer ampliem EstacioService (paquet com.ciclourbana.estacions) amb els mètodes que el controlador necessita:
@Service
public class EstacioService {
private final EstacioRepositori estacioRepositori;
public EstacioService(EstacioRepositori estacioRepositori) {
this.estacioRepositori = estacioRepositori;
}
/** Llistat filtrat i paginat. Només variables locals: el bean és
* singleton i el comparteixen els fils de Tomcat (vist a 02-03). */
public List<Estacio> cercar(String nom, Integer capacitatMinima,
int pagina, int mida) {
List<Estacio> filtrades = estacioRepositori.cercarTotes().stream()
.filter(e -> nom == null
|| e.nom().toLowerCase().contains(nom.toLowerCase()))
.filter(e -> capacitatMinima == null || e.capacitat() >= capacitatMinima)
.sorted(Comparator.comparing(Estacio::nom))
.toList();
// Paginació en memòria. Al mòdul 4, Spring Data la farà a la
// base de dades amb Pageable, que és el correcte en producció.
int inici = pagina * mida;
if (inici >= filtrades.size()) {
return List.of();
}
return filtrades.subList(inici, Math.min(inici + mida, filtrades.size()));
}
public Optional<Estacio> cercarPerId(Long id) {
return estacioRepositori.cercarPerId(id);
}
}I ara el controlador:
package com.ciclourbana.estacions;
/**
* API d'estacions de CicloUrbana.
*
* Retorna encara l'entitat de domini Estacio; la separació en DTO
* arriba a la lliçó 03-05, i la gestió centralitzada d'errors a 03-06.
*/
@RestController
@RequestMapping(path = "/api/v1/estacions",
produces = MediaType.APPLICATION_JSON_VALUE)
public class EstacioController {
private static final Logger log = LoggerFactory.getLogger(EstacioController.class);
private static final int MIDA_PAGINA_MAXIMA = 100;
private final EstacioService estacioService;
// Injecció per constructor: sense @Autowired, com es va raonar a 02-02
public EstacioController(EstacioService estacioService) {
this.estacioService = estacioService;
}
/** GET /api/v1/estacions?nom=nord&capacitatMinima=20&pagina=0&mida=20
* Retorna la llista directament: èxit únic (200), sense capçaleres a mida. */
@GetMapping
public List<Estacio> llistar(
@RequestParam(required = false) String nom,
@RequestParam(required = false) Integer capacitatMinima,
@RequestParam(defaultValue = "0") int pagina,
@RequestParam(defaultValue = "20") int mida) {
// Defensa provisional: sense això, ?mida=1000000 tomba el servei.
// A 03-04 se substitueix per @Min/@Max declaratius.
int midaSegura = Math.min(Math.max(mida, 1), MIDA_PAGINA_MAXIMA);
int paginaSegura = Math.max(pagina, 0);
log.debug("Llistat d'estacions: nom={}, capacitatMinima={}, pagina={}",
nom, capacitatMinima, paginaSegura);
return estacioService.cercar(nom, capacitatMinima, paginaSegura, midaSegura);
}
/** GET /api/v1/estacions/1 — ResponseEntity perquè l'estat varia.
* TODO (03-06): substituir per llançar RecursNoTrobatException. */
@GetMapping("/{id:\\d+}")
public ResponseEntity<Estacio> obtenirPerId(@PathVariable("id") Long id) {
return estacioService.cercarPerId(id)
.map(ResponseEntity::ok)
.orElseGet(() -> {
log.info("Estació no trobada: id={}", id);
return ResponseEntity.notFound().build();
});
}
}Detalls que mereixen comentari:
@GetMapping("/{id:\\d+}"): la restricció a dígits evita que/api/v1/estacions/resum, si algun dia l'afegim, intenti convertir-se aLong.- Els límits de paginació al controlador són provisionals i lletjos a propòsit: 03-04 els substitueix per
@Min(0)i@Max(100), i el contrast fa evident què aporta la validació declarativa. - El logging fa servir el patró de substitució d'SLF4J (
{}), no concatenació: ambconcat, elStringes construeix encara queDEBUGestigui desactivat. I el 404 va alog.info, no alog.error: és informació operativa, no una fallada del sistema (es detalla a 03-06).
Arrenquem amb ./mvnw spring-boot:run i CarregadorEstacionsDemo deixa les quatre estacions de Ribalta al repositori en memòria.
- Provar l'API: curl i fitxers
.http
.httpAmb curl, l'eina universal:
curl -s http://localhost:8080/api/v1/estacions | jq
curl -s "http://localhost:8080/api/v1/estacions?nom=nord" | jq
curl -s "http://localhost:8080/api/v1/estacions?capacitatMinima=24&pagina=0&mida=2" | jq
curl -s http://localhost:8080/api/v1/estacions/1 | jq
curl -i http://localhost:8080/api/v1/estacions/999 # veure capçaleres i estatL'última crida retorna HTTP/1.1 404 amb Content-Length: 0. I el filtre per capacitat mínima 24:
[ { "id": 2, "nom": "Estació Nord", "capacitat": 30, "latitud": 41.4012, "longitud": 2.1698 },
{ "id": 1, "nom": "Plaça Major", "capacitat": 24, "latitud": 41.3851, "longitud": 2.1734 } ]Apareixen ordenades per nom, com imposa el Comparator del servei; "Estació Nord" (30), "Plaça Major" (24) i "Universitat" (36) superen el filtre, però amb mida=2 només n'arriben les dues primeres.
Per a la feina diària és més còmode un fitxer .http a src/test/http/estacions.http, que IntelliJ IDEA i l'extensió REST Client de VS Code executen directament. Es versiona amb el codi, així que l'API queda documentada i provable des del mateix repositori:
@base = http://localhost:8080/api/v1
### Llistar totes les estacions
GET {{base}}/estacions
Accept: application/json
### Filtrar per nom
GET {{base}}/estacions?nom=nord
### Filtrar per capacitat mínima i paginar
GET {{base}}/estacions?capacitatMinima=24&pagina=0&mida=2
### Detall de la Plaça Major
GET {{base}}/estacions/1
### Estació inexistent: ha de respondre 404
GET {{base}}/estacions/999
### Paràmetre de tipus incorrecte: respon 400
GET {{base}}/estacions?capacitatMinima=moltL'última petició és interessant. Spring intenta convertir "molt" a Integer, falla i llança MethodArgumentTypeMismatchException, que es tradueix en un 400 Bad Request amb una resposta per defecte poc informativa. Desa-la al fitxer: la millorarem a 03-06.
- CORS i
@CrossOrigin
@CrossOriginEls navegadors apliquen la política del mateix origen: una pàgina servida des de https://panel.ribalta.example no pot cridar per JavaScript a https://api.ciclourbana.example llevat que el servidor ho autoritzi. Aquest permís és CORS (Cross-Origin Resource Sharing). El mecanisme, en curt: abans d'una petició "no simple" (amb Content-Type: application/json, o amb verb PUT/DELETE, o amb capçaleres pròpies), el navegador envia un preflight i el servidor l'ha d'autoritzar:
OPTIONS /api/v1/estacions HTTP/1.1
Origin: https://panel.ribalta.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://panel.ribalta.example
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 3600Dos aclariments que estalvien hores de depuració:
- CORS és cosa del navegador. curl, Postman i les apps mòbils natives l'ignoren completament. Si el teu curl funciona i el frontend no, és CORS.
- CORS no és seguretat del servidor. No protegeix l'API de ningú: només impedeix que el navegador lliuri la resposta a un script d'un altre origen. La protecció real és l'autenticació (mòdul 5).
A Spring, la manera ràpida és @CrossOrigin(origins = "https://panel.ribalta.example") sobre la classe o el mètode. I la manera correcta per a un projecte, centralitzada a com.ciclourbana.comu:
@Configuration
public class ConfiguracioCors implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registre) {
registre.addMapping("/api/**")
.allowedOrigins("https://panel.ribalta.example", "http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.allowedHeaders("*")
.exposedHeaders("Location", "ETag") // visibles per al JS del client
.maxAge(3600);
}
}exposedHeaders mereix atenció: per defecte, el JavaScript del navegador només pot llegir un grapat de capçaleres de la resposta. Si el frontend necessita el Location d'un POST o l'ETag per a una petició condicional, cal exposar-les aquí explícitament.
No facis servir mai allowedOrigins("*") juntament amb credencials: l'especificació ho prohibeix i Spring llançarà un error de configuració. L'enduriment de CORS juntament amb Spring Security es reprèn a la lliçó 05-05.
Errors Comuns i Consells
Fer servir @Controller en lloc de @RestController. El mètode retorna "Plaça Major" i Spring busca una plantilla amb aquest nom: error de resolució de vista o un 404 desconcertant.
@RequestParam(required = false) amb un tipus primitiu. int no admet null. Fes servir Integer, Optional<Integer> o, millor, defaultValue.
Oblidar el nom a @PathVariable. Si el projecte es compila sense -parameters, falla en temps d'execució amb un missatge sobre el nom de l'argument. Escriu-lo sempre.
Rutes ambigües. @GetMapping("/{id}") amb id de tipus Long i una crida a /estacions/abc produeix un 400 confús. La restricció {id:\\d+} ho evita.
Definir un bean ObjectMapper propi. Anul·la JacksonAutoConfiguration i amb ella totes les propietats spring.jackson.* i el mòdul de dates. Fes servir Jackson2ObjectMapperBuilderCustomizer. Símptoma relacionat: si veus dates com [2026,8,31,14,5] o 1756645500.000000000, falta write-dates-as-timestamps: false o el mòdul JSR-310.
Posar lògica de negoci al controlador. El controlador tradueix HTTP a crides Java i res més. Si apareix un if sobre regles del negoci de Ribalta, aquest codi pertany a EstacioService. La prova: quan arribi un consumidor de missatges, podria reutilitzar la lògica sense passar per HTTP?
Consell: un controlador per agregat. EstacioController, BicicletaController, LloguerController. No un ApiController amb vint mètodes.
Consell: desa les peticions en un .http versionat. Cada cas rar que descobreixis —un tipus incorrecte, un filtre buit— afegeix-l'hi. Aquest fitxer és documentació viva i l'esborrany de les proves d'integració del mòdul 6.
Exercicis
Exercici 1: Endpoint de cerca per proximitat
Afegeix a EstacioController l'endpoint GET /api/v1/estacions/properes?lat=41.38&lon=2.17&radiMetres=800, que retorna les estacions dins del radi indicat, ordenades per distància. radiMetres és opcional amb valor per defecte 500; lat i lon són obligatoris. Implementa el càlcul a EstacioService i assegura't que la ruta no entri en conflicte amb /{id}.
Exercici 2: Agrupar els filtres en un objecte i retornar metadades de paginació
La signatura de llistar ja té quatre paràmetres i creixerà. Refactoritza-la per (a) agrupar els filtres en un record FiltreEstacions i (b) retornar, a més de la llista, el total d'elements i el nombre de pàgines, fent servir capçaleres HTTP en lloc d'embolcallar el cos.
Exercici 3: Controlar la representació JSON
L'equip de l'app mòbil de Ribalta demana tres canvis a la resposta d'estació:
- Que
capacitates diguicapacitatTotalal JSON, sense reanomenar el camp Java. - Que s'afegeixi un camp
coordenadesamb el format"41.3851,2.1734"i quelatitudilongituddeixin d'aparèixer per separat. - Que les propietats surtin sempre en l'ordre
id,nom,capacitatTotal,coordenades,adreca.
Resol-ho només amb anotacions de Jackson i explica per què aquesta solució no escala.
Solucions
Solució 1.
A EstacioService:
private static final double RADI_TERRA_METRES = 6_371_000;
public List<Estacio> cercarProperes(double latitud, double longitud, int radiMetres) {
return estacioRepositori.cercarTotes().stream()
.map(e -> Map.entry(e, distanciaMetres(latitud, longitud, e.latitud(), e.longitud())))
.filter(parell -> parell.getValue() <= radiMetres)
.sorted(Map.Entry.comparingByValue()) // la més propera primer
.map(Map.Entry::getKey)
.toList();
}
/** Fórmula de l'haversine: distància sobre la superfície terrestre. */
private double distanciaMetres(double lat1, double lon1, double lat2, double lon2) {
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
return RADI_TERRA_METRES * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}Al controlador:
@GetMapping("/properes")
public List<Estacio> properes(@RequestParam double lat,
@RequestParam double lon,
@RequestParam(defaultValue = "500") int radiMetres) {
return estacioService.cercarProperes(lat, lon, radiMetres);
}Sobre el conflicte de rutes. /properes és un patró literal i /{id} un amb variable; Spring dona prioritat al literal, així que no hi ha ambigüitat. Tot i així, la restricció {id:\\d+} fa la intenció explícita i protegeix de descuits futurs.
Nota de disseny. Si falten lat o lon, Spring respon 400 automàticament. Però una latitud de 200 graus passaria sense problema, i radiMetres=-50 també. És exactament el buit que cobreix la lliçó 03-04 amb @DecimalMin/@DecimalMax i @Positive.
Solució 2.
El record de filtres, a com.ciclourbana.estacions:
public record FiltreEstacions(String nom, Integer capacitatMinima,
Integer pagina, Integer mida) {
// Constructor compacte: normalitza els valors nuls i acota la mida
public FiltreEstacions {
pagina = (pagina == null || pagina < 0) ? 0 : pagina;
mida = (mida == null || mida < 1) ? 20 : Math.min(mida, 100);
}
}El constructor compacte d'un record és el lloc ideal per normalitzar: s'executa sempre, vingui l'objecte d'on vingui, i el resultat és immutable.
El controlador:
@GetMapping
public ResponseEntity<List<Estacio>> llistar(FiltreEstacions filtre) {
List<Estacio> pagina = estacioService.cercar(filtre.nom(),
filtre.capacitatMinima(), filtre.pagina(), filtre.mida());
long total = estacioService.comptarAmbFiltre(filtre.nom(), filtre.capacitatMinima());
int totalPagines = (int) Math.ceil((double) total / filtre.mida());
return ResponseEntity.ok()
.header("X-Total-Elements", String.valueOf(total))
.header("X-Total-Pagines", String.valueOf(totalPagines))
.header("X-Pagina-Actual", String.valueOf(filtre.pagina()))
.body(pagina);
}Spring enllaça els paràmetres de consulta al record sense cap anotació, per coincidència de noms: la petició continua sent ?nom=nord&pagina=0&mida=10.
Per què capçaleres i no un cos embolcallant. Totes dues són legítimes. Les capçaleres mantenen el cos com un array pur d'estacions, que és el que el recurs "col·lecció" representa; és l'elecció de GitHub. L'alternativa —{"contingut": [...], "totalElements": 42}— és la que produeix Spring Data amb Page<T> i la que adoptarem al mòdul 4, perquè arriba de franc. Advertiment: si tries capçaleres, declara-les a exposedHeaders de la configuració CORS, o el JavaScript del navegador no les podrà llegir.
Solució 3.
@JsonPropertyOrder({"id", "nom", "capacitatTotal", "coordenades", "adreca"})
public record Estacio(
Long id,
String nom,
String adreca,
@JsonProperty("capacitatTotal")
int capacitat,
@JsonIgnore double latitud,
@JsonIgnore double longitud
) {
/**
* Propietat calculada: Jackson serialitza qualsevol mètode sense arguments
* anotat amb @JsonProperty, encara que no sigui un component del record.
*/
@JsonProperty("coordenades")
public String coordenades() {
return latitud + "," + longitud;
}
}Resultat:
{ "id": 1, "nom": "Plaça Major", "capacitatTotal": 24,
"coordenades": "41.3851,2.1734", "adreca": "Plaça Major, 1" }Per què aquesta solució no escala, que és el fons de l'exercici:
- Contamina el domini amb el contracte.
Estacioés el model intern i ara carrega amb les preferències de format de l'app mòbil. Si demà el portal de dades obertes de l'ajuntament demanalatitudilongitudseparades, no hi ha manera de satisfer-los tots dos amb una sola classe. @JsonIgnoreés una llista d'exclusions, i les llistes d'exclusions fallen per omissió. El dia que s'afegeixi un campcodiAccesalrecordi ningú no recordi anotar-lo, es publica sense voler. Una llista d'inclusions —un DTO que enumera el que sí que surt— falla al revés: com a molt oblides exposar alguna cosa, i això es detecta a l'instant.- Trenca el mòdul 4. Quan
Estaciosigui una entitat JPA amb relacions mandroses, serialitzar-la directament provocaràLazyInitializationExceptiono consultes en cascada inesperades.
Tot això es resol amb un EstacioResponse diferent d'Estacio: el contingut de la lliçó 03-05.
Conclusió
Ja saps escriure controladors REST de veritat. Has vist que @RestController no és més que @Controller + @ResponseBody, i per què confondre'ls produeix errors de vista incomprensibles. Domines el mapatge de rutes amb @RequestMapping a nivell de classe i les dreceres per verb, incloses les restriccions amb expressions regulars que eviten rutes ambigües. Saps extreure cada part d'una petició —@PathVariable, @RequestParam amb obligatorietat, valors per defecte, llistes i objectes de filtre, @RequestBody, @RequestHeader— i coneixes els paranys de cadascun, començant pel required = false sobre un primitiu. Controles com Jackson converteix un record en JSON, camp a camp amb @JsonProperty, @JsonIgnore, @JsonFormat i @JsonInclude, i globalment des de spring.jackson.*, amb la regla d'or de personalitzar sempre amb un Jackson2ObjectMapperBuilderCustomizer en lloc de reemplaçar l'ObjectMapper. Tens criteri per decidir entre retornar l'objecte i embolcallar-lo en un ResponseEntity. I entens què és CORS, per què no és un mecanisme de seguretat i com configurar-lo de manera centralitzada.
Sobretot, CicloUrbana ja té un EstacioController que s'assembla a un de real: llistat amb filtre per nom i per capacitat, paginació, consulta per identificador amb el seu 404, logging amb nivells apropiats, i un fitxer .http versionat amb els casos de prova, inclosos els que encara responen malament. L'exercici 3 ha deixat assenyalat, amb nom i cognoms, el deute tècnic que arrosseguem: estem exposant la classe de domini directament.
La lliçó 03-03, Gestió de Mètodes HTTP, completa el CRUD. Implementarem POST amb el seu 201 Created i la capçalera Location construïda amb ServletUriComponentsBuilder, PUT com a reemplaçament total, PATCH amb el problema —més subtil del que sembla— de distingir un camp absent d'un camp posat a nul, i DELETE amb la seva idempotència. Afegirem el recurs bicicleta i el subrecurs /estacions/{id}/bicicletes, modelarem les accions que no encaixen al CRUD pur (POST /api/v1/lloguers/{id}/finalitzar) sense trencar el disseny REST, i resoldrem el problema de les actualitzacions perdudes amb ETag i If-Match. L'API de Ribalta deixa de ser de només lectura.
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
