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

  1. @RestController: què és exactament
  2. @RequestMapping i les dreceres per verb
  3. @PathVariable: variables de plantilla
  4. @RequestParam: paràmetres de consulta
  5. @RequestBody, @RequestHeader i companyia
  6. Serialització JSON amb Jackson
  7. Configuració global de Jackson en YAML
  8. ResponseEntity davant de retornar l'objecte
  9. L'EstacioController complet
  10. Provar l'API: curl i fitxers .http
  11. CORS i @CrossOrigin
  12. Errors Comuns i Consells
  13. Exercicis

  1. @RestController: què és exactament

Obrim 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 @Component especialitzat que a més fa que RequestMappingHandlerMapping inspeccioni la classe buscant mètodes amb @RequestMapping.
  • @ResponseBody canvia la interpretació del valor retornat. Sense ella, un mètode que retorna el String "estacions" s'interpreta com el nom d'una vista i Spring busca una plantilla estacions.html. Amb ella, aquest String é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.

  1. @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).

  1. @PathVariable: variables de plantilla

Una 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í.

  1. @RequestParam: paràmetres de consulta

Els 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 signatura

Un 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

  1. @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.

  1. 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: si estacioId és null perquè la bicicleta està en ús, la propietat no apareix al JSON. Compte: obliga el client a distingir absència de null, un matís que reprendrem amb PATCH a 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: el shape = STRING evita 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 @JsonIgnore funciona 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).

  1. 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 0

Les decisions importants d'aquesta configuració:

  • write-dates-as-timestamps: false és probablement la propietat de Jackson més rellevant d'un projecte. Sense ella, un Instant es serialitza com 1756645500.000000000: il·legible i fràgil. Spring Boot ja la posa a false, però convé declarar-la. Requereix jackson-datatype-jsr310, que spring-boot-starter-web inclou i Spring Boot registra sol.
  • default-property-inclusion: non_null aplica a tota l'aplicació el que @JsonInclude feia 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: true sí que canvia el valor per defecte. Sense ell, {"capacitat": null} es converteix silenciosament en capacitat = 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.

  1. ResponseEntity davant de retornar l'objecte

Un 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 mida

Existeix 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.

  1. L'EstacioController complet

Ho 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 a Long.
  • 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ó: amb concat, el String es construeix encara que DEBUG estigui desactivat. I el 404 va a log.info, no a log.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.

  1. Provar l'API: curl i fitxers .http

Amb 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 estat

L'ú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=molt

L'ú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.

  1. CORS i @CrossOrigin

Els 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: 3600

Dos 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ó:

  1. Que capacitat es digui capacitatTotal al JSON, sense reanomenar el camp Java.
  2. Que s'afegeixi un camp coordenades amb el format "41.3851,2.1734" i que latitud i longitud deixin d'aparèixer per separat.
  3. 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:

  1. 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 demana latitud i longitud separades, no hi ha manera de satisfer-los tots dos amb una sola classe.
  2. @JsonIgnore és una llista d'exclusions, i les llistes d'exclusions fallen per omissió. El dia que s'afegeixi un camp codiAcces al record i 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.
  3. Trenca el mòdul 4. Quan Estacio sigui una entitat JPA amb relacions mandroses, serialitzar-la directament provocarà LazyInitializationException o 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

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats