La CLI de la lliçó anterior va resoldre l'automatització i la gent tècnica. No resol que la Marta Ruiz consulti el catàleg des del navegador, ni que la futura app mòbil de Nexus Software creï préstecs, ni que el sistema de recursos humans avisi BiblioTech quan algú deixa l'empresa.

Per a això cal una API web: un adaptador d'entrada que parli HTTP, que qualsevol client —navegador, mòbil, un altre servei, un curl en un script— pugui consumir. És el mòdul bibliotech-web, i serà el segon adaptador que s'endolla exactament als mateixos casos d'ús del mòdul bibliotech-aplicacio. Ni una línia de lògica de negoci nova.

Abans de començar, convé dir una cosa que canvia la manera de llegir aquesta lliçó: ja saps com funciona això. Al mòdul 9 vas construir el ServidorCataleg: un ServerSocket que acceptava connexions, llegia bytes, analitzava un protocol de text, decidia què fer, componia una resposta i l'escrivia. Un servidor web és exactament això, amb el protocol ja escrit per altres. Spring MVC no és màgia: és el teu servidor de sockets amb trenta anys de casos límit resolts.

En acabar aquesta lliçó entendràs el cicle petició-resposta i el paper de cada peça, dissenyaràs una API REST amb recursos, verbs i codis d'estat correctes, escriuràs controladors amb validació i gestió global d'errors en format estàndard, paginaràs i filtraràs, documentaràs l'API automàticament, i provaràs la capa web en els dos nivells que tenen sentit.

Dues coses que no hi són: la seguretat (autenticació, autorització, JWT) és la lliçó 12-07, i el desplegament és la 12-06. Aquí construïm l'API; ja la protegirem i la posarem en producció.

Contingut

  1. Com funciona una aplicació web en Java
  2. El servidor incrustat i el DispatcherServlet
  3. El flux intern d'una petició
  4. Del ServidorCataleg de sockets a Spring MVC
  5. REST: recursos i representacions
  6. Verbs HTTP i les seves semàntiques
  7. Disseny d'URI
  8. Codis d'estat per operació
  9. @RestController: el mapatge de peticions
  10. Paràmetres: camí, consulta i cos
  11. ResponseEntity i quan fer-la servir
  12. DTO d'entrada i de sortida
  13. Validació amb jakarta.validation
  14. Un validador propi per a l'ISBN
  15. Gestió global d'errors i Problem Details
  16. Paginació i ordenació
  17. Filtres i cerca
  18. L'API completa de BiblioTech
  19. Documentació automàtica amb OpenAPI
  20. CORS
  21. La capa de servei i les transaccions
  22. Proves de la capa web
  23. Interfície d'usuari: Thymeleaf o front-end separat
  24. Fils virtuals a Spring Boot 3.2
  25. Errors Comuns i Consells
  26. Exercicis
  27. Conclusió

  1. Com funciona una aplicació web en Java

Al seu nucli, tot es redueix a això: un client obre una connexió TCP, envia un text amb un format acordat, el servidor l'interpreta, fa alguna cosa i retorna un altre text. El format acordat és HTTP.

Una petició HTTP crua, tal com viatja pel socket:

GET /api/materials?tipus=LLIBRE&page=0&size=20 HTTP/1.1
Host: bibliotech.nexussoftware.com
Accept: application/json
User-Agent: curl/8.5.0

I la resposta:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 283

{"content":[{"isbn":"978-0000000001","titol":"Java Eficac","tipus":"LLIBRE"}],
 "page":{"size":20,"number":0,"totalElements":3,"totalPages":1}}

Quatre elements a la petició (mètode, camí, versió, capçaleres, i cos opcional) i quatre a la resposta (versió, codi d'estat, capçaleres, cos). Res més. Tot el que fa Spring MVC és estalviar-te manipular aquest text a mà.

La pila completa d'una aplicació Spring Boot:

Capa Què fa Qui la implementa
Socket TCP Accepta connexions, mou bytes El sistema operatiu + la JVM
Servidor HTTP Analitza HTTP, gestiona connexions i fils Tomcat (incrustat)
API Servlet Abstracció estàndard de petició i resposta jakarta.servlet
DispatcherServlet Encamina al teu codi, converteix tipus Spring MVC
Els teus controladors Lògica de l'aplicació Tu

  1. El servidor incrustat i el DispatcherServlet

Servidor incrustat. Fins a Spring Boot, desplegar una aplicació Java web era: construir un .war, instal·lar un Tomcat, copiar el war a webapps/, reiniciar. Spring Boot va invertir el model: el servidor va dins de l'aplicació, i el resultat és un jar que s'executa amb java -jar.

@SpringBootApplication
public class BiblioTechApplication {
    public static void main(String[] args) {
        SpringApplication.run(BiblioTechApplication.class, args);
    }
}

Aquestes tres línies arrenquen un Tomcat al port 8080. Avantatges: una unitat desplegable, la mateixa versió de servidor en desenvolupament i producció, configuració al mateix lloc que la resta, i encaixa perfectament amb contenidors (12-06).

Alternatives, totes intercanviables canviant una dependència:

Servidor Model Quan
Tomcat Un fil per petició Per defecte; el més conegut
Jetty Un fil per petició Menys consum; incrustat en eines
Undertow No bloquejant sota demanda Màxim rendiment amb moltes connexions
Netty Reactiu Només amb Spring WebFlux

El DispatcherServlet és el front controller: un únic servlet mapat a / pel qual passen totes les peticions, i que s'encarrega de decidir qui les atén. És el patró Façana i el patró Ordre (12-02) aplicats al web.

  1. El flux intern d'una petició

Aquest és el recorregut complet de GET /api/materials/978-0000000001:

sequenceDiagram
    autonumber
    participant N as Navegador
    participant T as Tomcat
    participant F as Cadena de filtres
    participant D as DispatcherServlet
    participant HM as HandlerMapping
    participant HA as HandlerAdapter
    participant C as CatalegController
    participant S as CatalegService
    participant R as Repositori JPA
    participant MC as HttpMessageConverter

    N->>T: GET /api/materials/978-0000000001
    T->>T: analitza HTTP, pren un fil del pool
    T->>F: HttpServletRequest
    F->>F: filtres (CORS, MDC, seguretat a 12-07)
    F->>D: request
    D->>HM: qui aten aquest cami?
    HM-->>D: CatalegController.perIsbn
    D->>HA: invoca el metode
    HA->>HA: converteix el PathVariable a Isbn
    HA->>C: perIsbn(Isbn)
    C->>S: cercarPerIsbn(isbn)
    S->>R: findByIsbn(isbn)
    R-->>S: Optional~Material~
    S-->>C: Material
    C-->>HA: MaterialResponse (DTO)
    HA->>MC: serialitza a JSON (Jackson)
    MC-->>D: bytes
    D-->>T: HttpServletResponse 200
    T-->>N: HTTP/1.1 200 OK + JSON

Les peces de Spring MVC que hi intervenen:

Component Responsabilitat
Filter Feina transversal abans i després: CORS, correlació (MDC), seguretat
DispatcherServlet Orquestra tot el procés
HandlerMapping Troba el mètode que atén la URL i el verb
HandlerAdapter Invoca el mètode resolent-ne els arguments
HandlerMethodArgumentResolver Converteix @PathVariable, @RequestParam, @RequestBody
HttpMessageConverter Serialitza i deserialitza el cos (Jackson per a JSON)
HandlerExceptionResolver Converteix excepcions en respostes HTTP

  1. Del ServidorCataleg de sockets a Spring MVC

Val la pena posar les dues coses una al costat de l'altra, perquè això és el mateix, resolt per tu fa tres mòduls.

El teu servidor del mòdul 9:

// ServidorCataleg, modul 9: el vas escriure tu, sencer
public void atendre(Socket client) throws IOException {
    try (var entrada = new BufferedReader(new InputStreamReader(client.getInputStream(), UTF_8));
         var sortida = new PrintWriter(client.getOutputStream(), true)) {

        String peticio = entrada.readLine();           // "CERCAR 978-0000000001"
        String[] parts = peticio.split(" ", 2);        // analisi del protocol

        switch (parts[0]) {                            // encaminament
            case "CERCAR" -> {
                Optional<Material> m = cataleg.perIsbn(parts[1]);
                if (m.isPresent()) {
                    sortida.println("OK " + serialitzar(m.get()));  // serialitzacio
                } else {
                    sortida.println("ERROR 404 No trobat");         // codi d'error
                }
            }
            case "LLISTAR" -> sortida.println("OK " + serialitzar(cataleg.tots()));
            default -> sortida.println("ERROR 400 Ordre desconeguda");
        }
    }
}

El mateix amb Spring MVC:

@RestController
@RequestMapping("/api/materials")
public class CatalegController {

    private final ConsultarCataleg cataleg;

    @GetMapping("/{isbn}")
    public MaterialResponse perIsbn(@PathVariable Isbn isbn) {
        return cataleg.perIsbn(isbn)
                .map(MaterialResponse::desDe)
                .orElseThrow(() -> new MaterialNoTrobatException(isbn));
    }
}

La correspondència, peça a peça:

El que vas fer a mà al mòdul 9 Qui ho fa ara
ServerSocket.accept() en un bucle Tomcat
Un fil per client (ExecutorService) El pool de fils de Tomcat
readLine() i split(" ") L'analitzador HTTP de Tomcat
El switch sobre l'ordre HandlerMapping amb @GetMapping
Integer.parseInt(parts[1]) HandlerMethodArgumentResolver
serialitzar(...) a mà Jackson via HttpMessageConverter
"ERROR 404 ..." @RestControllerAdvice i codis HTTP
Tancar el socket al finally Tomcat
Temps d'espera i connexions a mitges Tomcat
Codificació, Content-Length, keep-alive Tomcat

La conclusió que importa: no estàs aprenent un framework màgic. Estàs delegant en codi provat exactament la feina que ja saps fer, per poder dedicar la teva atenció al que només tu pots escriure: les regles de BiblioTech. I com que saps què hi ha a sota, quan alguna cosa falli —una petició que es queda penjada, una codificació estranya, un Content-Type que no quadra— sabràs on mirar.

  1. REST: recursos i representacions

REST (Representational State Transfer) és un estil arquitectònic definit per Roy Fielding l'any 2000. Les seves idees centrals, aplicades a BiblioTech:

Tot és un recurs, identificat per un URI. Un recurs és un substantiu, no una acció:

Correcte Incorrecte
/api/materials /api/obtenirMaterials
/api/materials/978-0000000001 /api/getMaterialPerIsbn?isbn=…
/api/prestecs/42 /api/veurePrestec/42

Un recurs no és la seva representació. El préstec 42 és un concepte; la seva representació pot ser JSON, XML o HTML segons el que demani el client a Accept.

Sense estat (stateless). Cada petició conté tot el que cal per atendre-la. El servidor no guarda «en quin pas està» un client. Aquesta propietat és la que permet escalar horitzontalment (12-06): qualsevol instància pot atendre qualsevol petició.

Interfície uniforme. Els mateixos verbs amb la mateixa semàntica per a tots els recursos. Qui sap fer servir /api/materials sap fer servir /api/prestecs.

Nota: el model de maduresa de Richardson. Leonard Richardson va classificar les API en quatre nivells. Nivell 0: un únic endpoint que ho rep tot (RPC sobre HTTP). Nivell 1: recursos amb URI propis, però un sol verb. Nivell 2: recursos + verbs HTTP + codis d'estat correctes. Nivell 3: a més, HATEOAS — les respostes inclouen enllaços a les accions possibles. La immensa majoria d'API de la indústria són de nivell 2, i és un objectiu perfectament raonable: el nivell 3 aporta descobribilitat, però afegeix complexitat que pocs clients aprofiten. BiblioTech serà de nivell 2, amb un apunt d'HATEOAS on aporti.

  1. Verbs HTTP i les seves semàntiques

Dues propietats governen l'ús correcte dels verbs:

  • Segur (safe): no modifica l'estat del servidor. Un cercador el pot invocar lliurement.
  • Idempotent: executar-lo N vegades té el mateix efecte que executar-lo una vegada. És el que permet reintentar sense por.
Verb Segur Idempotent Ús A BiblioTech
GET Llegir Consultar catàleg, préstecs
POST No No Crear, o accions no idempotents Crear préstec
PUT No Reemplaçar complet Actualitzar totes les dades d'un material
PATCH No No necessàriament Modificar parcialment Canviar només les unitats
DELETE No Esborrar Cancel·lar una reserva
HEAD Com GET, sense cos Comprovar existència
OPTIONS Verbs permesos Petició prèvia de CORS

Per què la idempotència importa de debò. Un client mòbil envia POST /api/prestecs, la resposta es perd per un tall de xarxa, i el client ho reintenta. Amb POST no idempotent, es creen dos préstecs. Amb PUT, no.

Solucions per fer idempotent una creació:

POST /api/prestecs HTTP/1.1
Idempotency-Key: 3f2504e0-4f89-11d3-9a0c-0305e82c3301
Content-Type: application/json

{"isbn":"978-0000000001","idEmpleat":1,"dies":15}

El servidor desa la clau amb la seva resposta; si arriba repetida, retorna la resposta original sense crear res. És el que fan les passareles de pagament, i per bones raons.

El cas de DELETE i la idempotència. Esborrar dues vegades el mateix recurs: la primera retorna 204, la segona 404. Continua sent idempotent? Sí: l'estat del servidor és el mateix. La idempotència parla de l'efecte, no del codi de resposta.

  1. Disseny d'URI

Regles, amb exemples de BiblioTech:

Regla Malament
Substantius en plural /api/materials /api/material, /api/getMaterials
Minúscules i guions /api/tipus-material /api/tipusMaterial, /api/Tipus_Material
Jerarquia per a relacions /api/empleats/1/prestecs /api/prestecsDeEmpleat?id=1
Sense extensió /api/materials/978-… /api/materials/978-….json
Sense verb al camí POST /api/prestecs POST /api/crearPrestec
Filtres a la consulta /api/materials?tipus=LLIBRE /api/materials/tipus/LLIBRE
Versió explícita /api/v1/materials (sense versió; es reprèn a 12-07)
Sense barra final /api/materials /api/materials/

El cas difícil: les accions que no són CRUD. Com s'expressa «retornar un préstec» en REST? Tres opcions:

# Opcio A: subrecurs que representa el fet. La preferida.
POST /api/prestecs/42/devolucio

# Opcio B: PATCH sobre l'estat
PATCH /api/prestecs/42
{"estat": "RETORNAT"}

# Opcio C: verb al cami. Pragmatica, i acceptable si es fa servir amb moderacio.
POST /api/prestecs/42/retornar

BiblioTech fa servir la A: POST /api/prestecs/42/devolucio crea el fet «devolució» dins del préstec 42. És conceptualment neta i permet que la devolució tingui les seves pròpies dades (data, observacions, estat del material).

Imbricació: màxim dos nivells. /api/empleats/1/prestecs està bé; /api/seus/2/departaments/5/empleats/1/prestecs/42/multes és inmanejable. A partir d'aquí, recurs de primer nivell amb filtres: /api/multes?empleat=1.

  1. Codis d'estat per operació

Fer servir codis correctes no és purisme: és el que permet a un client reaccionar sense analitzar missatges.

Codi Nom Quan, a BiblioTech
200 OK GET amb resultat; PUT/PATCH que retorna el recurs
201 Created POST que crea. Amb capçalera Location
202 Accepted Acceptat per a procés asíncron (importació massiva)
204 No Content DELETE correcte; PUT sense cos de resposta
400 Bad Request JSON mal format, tipus incorrecte, validació fallida
401 Unauthorized Sense credencials o no vàlides (12-07)
403 Forbidden Autenticat, però sense permís (12-07)
404 Not Found El recurs no existeix
405 Method Not Allowed DELETE sobre un recurs que no l'admet
409 Conflict Regla de negoci violada: ja té 3 préstecs; conflicte de versió (@Version)
410 Gone Va existir i s'ha eliminat permanentment
415 Unsupported Media Type Content-Type que no se sap llegir
422 Unprocessable Entity Sintaxi correcta, semàntica no vàlida
429 Too Many Requests Límit de taxa superat (12-07)
500 Internal Server Error Error no previst. Mai per una entrada de l'usuari
503 Service Unavailable Dependència caiguda; amb Retry-After

Errors clàssics que convé no cometre:

Error Per què està malament
Retornar 200 amb {"error": "..."} El client ha d'analitzar el cos per saber si ha funcionat
Retornar 500 perquè falta un camp Un 500 significa «he fallat jo»; que falti un camp és 400
Retornar 404 quan no hi ha resultats en una llista Una llista buida és un resultat vàlid: 200 amb []
Retornar 401 en lloc de 403 401 = «no sé qui ets»; 403 = «sé qui ets i no pots»
Retornar 200 després d'un POST que crea Ha de ser 201 amb Location

  1. @RestController: el mapatge de peticions

package com.nexussoftware.bibliotech.web.cataleg;

@RestController                              // = @Controller + @ResponseBody
@RequestMapping("/api/materials")            // prefix comu de tots els metodes
public class CatalegController {

    private final ConsultarCataleg cataleg;
    private final GestionarCataleg gestio;

    public CatalegController(ConsultarCataleg cataleg, GestionarCataleg gestio) {
        this.cataleg = cataleg;
        this.gestio = gestio;
    }

    @GetMapping
    public PageResponse<MaterialResponse> llistar(@Valid CriteriMaterialsRequest criteri,
                                                  Pageable paginacio) { … }

    @GetMapping("/{isbn}")
    public MaterialResponse perIsbn(@PathVariable Isbn isbn) { … }

    @PostMapping
    public ResponseEntity<MaterialResponse> crear(@Valid @RequestBody CrearMaterialRequest peticio) { … }

    @PutMapping("/{isbn}")
    public MaterialResponse reemplacar(@PathVariable Isbn isbn,
                                       @Valid @RequestBody ActualitzarMaterialRequest peticio) { … }

    @PatchMapping("/{isbn}/unitats")
    public MaterialResponse ajustarUnitats(@PathVariable Isbn isbn,
                                           @Valid @RequestBody AjustUnitatsRequest peticio) { … }

    @DeleteMapping("/{isbn}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void eliminar(@PathVariable Isbn isbn) { … }
}

@RestController equival a @Controller + @ResponseBody a tots els mètodes: el valor retornat és el cos de la resposta, serialitzat per Jackson. Sense ell, Spring interpretaria un String retornat com el nom d'una vista.

Restriccions útils al mapatge:

@GetMapping(value = "/{isbn}", produces = MediaType.APPLICATION_JSON_VALUE)
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@GetMapping(value = "/{isbn}", produces = "text/csv")          // negociacio de contingut
@GetMapping(params = "format=resum")                           // segons parametre
@GetMapping(headers = "X-Api-Version=2")                       // segons capcalera

  1. Paràmetres: camí, consulta i cos

@GetMapping("/{isbn}/prestecs")
public List<PrestecResponse> historial(

        // Part del cami: identifica el recurs
        @PathVariable Isbn isbn,

        // Parametre de consulta obligatori
        @RequestParam EstatPrestec estat,

        // Opcional amb valor per defecte
        @RequestParam(defaultValue = "10") int limit,

        // Opcional de debo: Optional (10-04)
        @RequestParam Optional<LocalDate> desDe,

        // Nom diferent del del parametre Java
        @RequestParam(name = "ordenar_per", defaultValue = "DATA") CriteriOrdre ordre,

        // Repetible: ?etiqueta=java&etiqueta=disseny
        @RequestParam(required = false) List<String> etiqueta,

        // Capcalera
        @RequestHeader(value = "Accept-Language", defaultValue = "ca") Locale idioma) { … }

I per al cos:

@PostMapping
public ResponseEntity<PrestecResponse> crear(@Valid @RequestBody CrearPrestecRequest peticio) { … }

Conversió de tipus propis. Que @PathVariable Isbn isbn funcioni requereix dir-l'hi a Spring, i és exactament el mateix ITypeConverter de Picocli amb una altra interfície:

@Component
public class ConvertidorIsbn implements Converter<String, Isbn> {

    @Override
    public Isbn convert(String text) {
        try {
            return Isbn.de(text);
        } catch (IsbnInvalidException e) {
            // IllegalArgumentException → Spring ho tradueix a 400, no a 500
            throw new IllegalArgumentException("ISBN invalid: " + text, e);
        }
    }
}

El guany és el mateix que a la CLI: el controlador treballa amb el tipus del domini, i les entrades no vàlides es rebutgen abans d'entrar al teu codi.

Un detall sobre els noms de paràmetre: des de Java 21 convé compilar amb -parameters (Spring Boot ho configura per defecte) perquè Spring dedueixi els noms. Sense això, @PathVariable sense nom explícit falla en temps d'execució.

  1. ResponseEntity i quan fer-la servir

Retornar el DTO directament és el més net, i és el que s'ha de fer per defecte:

@GetMapping("/{isbn}")
public MaterialResponse perIsbn(@PathVariable Isbn isbn) { … }    // 200 + JSON

ResponseEntity dona control total sobre estat i capçaleres, i es justifica en tres casos:

Cas 1: creació amb Location (obligatori per a un 201 ben fet).

@PostMapping
public ResponseEntity<PrestecResponse> crear(@Valid @RequestBody CrearPrestecRequest peticio) {
    Prestec creat = gestor.prestar(peticio.isbn(), peticio.idEmpleat(), peticio.dies());

    URI ubicacio = ServletUriComponentsBuilder
            .fromCurrentRequest()          // http://host/api/prestecs
            .path("/{id}")
            .buildAndExpand(creat.getId())
            .toUri();                      // http://host/api/prestecs/42

    return ResponseEntity.created(ubicacio).body(PrestecResponse.desDe(creat));
}

Cas 2: el codi depèn del resultat.

@PutMapping("/{isbn}")
public ResponseEntity<MaterialResponse> reemplacar(@PathVariable Isbn isbn,
                                                   @Valid @RequestBody ActualitzarMaterialRequest p) {
    ResultatActualitzacio resultat = gestio.crearOActualitzar(isbn, p);

    return resultat.haEstatCreat()
            ? ResponseEntity.status(HttpStatus.CREATED).body(MaterialResponse.desDe(resultat.material()))
            : ResponseEntity.ok(MaterialResponse.desDe(resultat.material()));
}

Cas 3: capçaleres específiques, com la memòria cau condicional.

@GetMapping("/{isbn}")
public ResponseEntity<MaterialResponse> perIsbn(@PathVariable Isbn isbn) {
    Material material = cataleg.perIsbn(isbn).orElseThrow(() -> new MaterialNoTrobatException(isbn));

    return ResponseEntity.ok()
            .eTag("\"" + material.getVersion() + "\"")     // el @Version de JPA com a ETag
            .cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
            .body(MaterialResponse.desDe(material));
}

Amb l'ETag, un client que reenvia If-None-Match rep un 304 Not Modified sense cos. És l'optimització d'amplada de banda més barata que existeix.

Alternativa per al cas simple de fixar el codi: @ResponseStatus.

@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)      // 204
public void cancellar(@PathVariable Long id) { reserves.cancellar(id); }

  1. DTO d'entrada i de sortida

Això reprèn 12-01, i ara amb el detall complet. La regla: les entitats JPA no creuen la frontera de l'API, ni d'entrada ni de sortida.

DTO de sortida:

package com.nexussoftware.bibliotech.web.prestecs;

@Schema(description = "Informacio d'un prestec")             // documentacio OpenAPI
public record PrestecResponse(

        @Schema(example = "42") Long id,
        @Schema(example = "978-0000000001") String isbn,
        @Schema(example = "Java Eficac") String titolMaterial,
        @Schema(example = "Marta Ruiz") String nomEmpleat,
        LocalDate dataPrestec,
        LocalDate dataVenciment,

        @Schema(description = "Nulla si el material continua prestat")
        LocalDate dataDevolucio,

        @Schema(example = "ACTIU") String estat,

        @Schema(description = "Negatiu si esta vencut", example = "5")
        long diesRestants,

        @Schema(example = "2.50") BigDecimal multaAcumulada) {

    public static PrestecResponse desDe(Prestec p, LocalDate avui) {
        return new PrestecResponse(
                p.getId(),
                p.getIsbn().valor(),
                p.titolDelMaterial(),
                p.nomDeLempleat(),
                p.getDataPrestec(),
                p.getDataVenciment(),
                p.getDataDevolucio().orElse(null),       // JSON no te Optional
                p.getEstat().name(),
                ChronoUnit.DAYS.between(avui, p.getDataVenciment()),
                p.multaAcumulada(avui).quantitat());
    }
}

DTO d'entrada:

public record CrearPrestecRequest(

        @NotBlank(message = "L'ISBN es obligatori")
        @IsbnValid
        @Schema(example = "978-0000000001")
        String isbn,

        @NotNull(message = "L'identificador de l'empleat es obligatori")
        @Positive
        Long idEmpleat,

        @Positive @Max(value = 90, message = "La durada maxima es de 90 dies")
        @Schema(description = "Dies de prestec. Si s'omet, l'estandard del tipus de material")
        Integer dies) {
}

Fixa't en el que no té: ni id, ni version, ni estat, ni dataDevolucio. Un client maliciós no els pot enviar perquè l'objecte on es deserialitza no té aquests components. La defensa és estructural, no una llista de camps que cal recordar-se d'ignorar.

Un avís concret sobre record i Jackson: els records es deserialitzen sense problema des de Jackson 2.12, però necessiten -parameters en la compilació o @JsonProperty a cada component. Spring Boot ho activa per defecte; si el teu build és propi, verifica-ho.

  1. Validació amb jakarta.validation

Dependència:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Les anotacions més usades:

Anotació Valida Exemple
@NotNull No nul (una cadena buida passa) Long idEmpleat
@NotBlank Cadena no nul·la, no buida, no només espais String titol
@NotEmpty Col·lecció o cadena no buida List<String> autors
@Size(min, max) Longitud @Size(max = 200) String titol
@Min / @Max Rang numèric @Max(90) Integer dies
@Positive / @PositiveOrZero Signe @Positive int unitats
@Email Correu electrònic String correu
@Pattern(regexp) Expressió regular @Pattern(regexp = "97[89]-\\d{10}")
@Past / @Future Dates @PastOrPresent LocalDate data
@Valid Cascada a objectes imbricats @Valid AdrecaRequest adreca

@Valid al paràmetre és el que dispara la validació:

@PostMapping
public ResponseEntity<PrestecResponse> crear(@Valid @RequestBody CrearPrestecRequest peticio) {
    // Si arriba aqui, la peticio es sintacticament valida.
    // Si no, Spring ja ha llancat MethodArgumentNotValidException.
}

Validació als paràmetres de consulta requereix @Validated a la classe:

@RestController
@Validated                                   // habilita la validacio de parametres solts
@RequestMapping("/api/materials")
public class CatalegController {

    @GetMapping
    public List<MaterialResponse> llistar(
            @RequestParam @Size(min = 2, message = "Almenys 2 caracters") String titol,
            @RequestParam @Max(100) int limit) { … }
}

Validació de regles creuades amb una anotació a nivell de classe:

@RangDatesValid          // validador propi: desDe <= finsA
public record ConsultaMultesRequest(
        @NotNull @PastOrPresent LocalDate desDe,
        @NotNull @PastOrPresent LocalDate finsA,
        Long idEmpleat) {
}

Un límit important que cal tenir clar: jakarta.validation valida la forma, no les regles de negoci. Que l'ISBN tingui el format correcte és validació; que aquest ISBN existeixi al catàleg i tingui unitats lliures és una regla de negoci, i viu al domini. Confondre-les porta a posar consultes a la base de dades dins d'un validador, que és exactament on no han d'estar.

  1. Un validador propi per a l'ISBN

Un ISBN-13 no és només un format: porta un dígit de control calculat. Comprovar-lo és validació de forma, i per tant sí que correspon aquí.

package com.nexussoftware.bibliotech.web.validacio;

@Documented
@Constraint(validatedBy = ValidadorIsbn.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface IsbnValid {

    String message() default "ISBN-13 invalid: revisa el format i el digit de control";

    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    /** Si es true, accepta l'ISBN amb guions i espais. */
    boolean permetreSeparadors() default true;
}
public class ValidadorIsbn implements ConstraintValidator<IsbnValid, String> {

    private static final Pattern AMB_SEPARADORS = Pattern.compile("^97[89][- ]?\\d{1,5}[- ]?\\d+[- ]?\\d+[- ]?\\d$");
    private static final Pattern SENSE_SEPARADORS = Pattern.compile("^97[89]\\d{10}$");

    private boolean permetreSeparadors;

    @Override
    public void initialize(IsbnValid anotacio) {
        this.permetreSeparadors = anotacio.permetreSeparadors();
    }

    @Override
    public boolean isValid(String valor, ConstraintValidatorContext context) {
        // Un valor nul es considera valid: l'obligatorietat la imposa @NotNull.
        // Separar responsabilitats entre validadors es el correcte.
        if (valor == null) return true;

        String normalitzat = permetreSeparadors ? valor.replaceAll("[- ]", "") : valor;

        if (!SENSE_SEPARADORS.matcher(normalitzat).matches()) {
            missatge(context, "L'ISBN ha de comencar per 978 o 979 i tenir 13 digits");
            return false;
        }
        if (!digitDeControlCorrecte(normalitzat)) {
            missatge(context, "El digit de control de l'ISBN no es correcte");
            return false;
        }
        return true;
    }

    /**
     * Algoritme oficial de l'ISBN-13: es multipliquen els 12 primers digits
     * alternant 1 i 3, se sumen, i el digit de control es el que falta
     * per al seguent multiple de 10.
     */
    private boolean digitDeControlCorrecte(String isbn) {
        int suma = 0;
        for (int i = 0; i < 12; i++) {
            int digit = isbn.charAt(i) - '0';
            suma += (i % 2 == 0) ? digit : digit * 3;
        }
        int control = (10 - (suma % 10)) % 10;
        return control == (isbn.charAt(12) - '0');
    }

    /** Substitueix el missatge generic per un d'especific de la fallada concreta. */
    private void missatge(ConstraintValidatorContext context, String text) {
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate(text).addConstraintViolation();
    }
}

Aquest missatge específic és la diferència entre «ISBN no vàlid» (que no ajuda) i «el dígit de control no és correcte» (que li diu a qui integra on és la fallada).

  1. Gestió global d'errors i Problem Details

Sense gestió global, una excepció produeix una resposta genèrica amb traça inclosa, que és alhora inútil per al client i una filtració d'informació (12-07).

RFC 7807 (Problem Details) defineix un format estàndard per a errors HTTP, i Spring 6 el suporta de sèrie amb la classe ProblemDetail:

{
  "type": "https://bibliotech.nexussoftware.com/errors/limit-prestecs",
  "title": "Limit de prestecs excedit",
  "status": 409,
  "detail": "L'empleat Diego Alonso ja te 3 prestecs actius (maxim: 3).",
  "instance": "/api/prestecs",
  "timestamp": "2026-08-05T10:23:45Z",
  "traceId": "a7f3e91c4b2d",
  "prestecsActius": [12, 27, 38]
}

Els cinc camps estàndard són type, title, status, detail i instance; la resta són extensions pròpies.

Activació del format per defecte de Spring:

spring:
  mvc:
    problemdetails:
      enabled: true      # les excepcions estandard de Spring ja surten en format RFC 7807

I el gestor per a les nostres excepcions:

package com.nexussoftware.bibliotech.web.error;

@RestControllerAdvice
public class GestorGlobalErrors {

    private static final Logger log = LoggerFactory.getLogger(GestorGlobalErrors.class);
    private static final String BASE_TIPUS = "https://bibliotech.nexussoftware.com/errors/";

    // ---------- 404 ----------
    @ExceptionHandler({MaterialNoTrobatException.class,
                       PrestecNoTrobatException.class,
                       EmpleatNoTrobatException.class})
    public ProblemDetail noTrobat(RecursNoTrobatException e, HttpServletRequest peticio) {
        log.info("Recurs no trobat: {}", e.getMessage());          // INFO: no es una fallada del sistema
        return problema(HttpStatus.NOT_FOUND, "recurs-no-trobat",
                        "Recurs no trobat", e.getMessage(), peticio);
    }

    // ---------- 409: regles de negoci ----------
    @ExceptionHandler(LimitPrestecsExceditException.class)
    public ProblemDetail limitExcedit(LimitPrestecsExceditException e, HttpServletRequest p) {
        ProblemDetail detall = problema(HttpStatus.CONFLICT, "limit-prestecs",
                "Limit de prestecs excedit", e.getMessage(), p);
        detall.setProperty("prestecsActius", e.getIdsPrestecsActius());
        detall.setProperty("maximPermes", e.getMaxim());
        return detall;
    }

    @ExceptionHandler(MaterialNoDisponibleException.class)
    public ProblemDetail noDisponible(MaterialNoDisponibleException e, HttpServletRequest p) {
        ProblemDetail detall = problema(HttpStatus.CONFLICT, "material-no-disponible",
                "Material no disponible", e.getMessage(), p);
        e.getDataPrevistaDisponibilitat()
         .ifPresent(f -> detall.setProperty("disponiblePrevisiblementEl", f.toString()));
        return detall;
    }

    // ---------- 409: conflicte de concurrencia (el @Version d'11-03) ----------
    @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
    public ProblemDetail conflicteDeVersio(ObjectOptimisticLockingFailureException e,
                                           HttpServletRequest p) {
        log.warn("Conflicte de bloqueig optimista a {}", p.getRequestURI());
        return problema(HttpStatus.CONFLICT, "conflicte-concurrencia",
                "Conflicte de concurrencia",
                "Un altre usuari ha modificat aquest recurs mentre l'editaves. Recarrega'l i reintenta.", p);
    }

    // ---------- 400: validacio del cos ----------
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail validacio(MethodArgumentNotValidException e, HttpServletRequest p) {
        List<ErrorCamp> errors = e.getBindingResult().getFieldErrors().stream()
                .map(f -> new ErrorCamp(f.getField(), f.getDefaultMessage(), f.getRejectedValue()))
                .toList();

        ProblemDetail detall = problema(HttpStatus.BAD_REQUEST, "validacio",
                "Error de validacio",
                "La peticio conte %d camp(s) invalid(s).".formatted(errors.size()), p);
        detall.setProperty("errors", errors);        // AIXO es el que fa util un 400
        return detall;
    }

    // ---------- 400: parametres i tipus ----------
    @ExceptionHandler(ConstraintViolationException.class)
    public ProblemDetail parametresInvalids(ConstraintViolationException e, HttpServletRequest p) {
        List<ErrorCamp> errors = e.getConstraintViolations().stream()
                .map(v -> new ErrorCamp(v.getPropertyPath().toString(), v.getMessage(), v.getInvalidValue()))
                .toList();
        ProblemDetail detall = problema(HttpStatus.BAD_REQUEST, "parametres-invalids",
                "Parametres invalids", "Revisa els parametres de la peticio.", p);
        detall.setProperty("errors", errors);
        return detall;
    }

    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ProblemDetail tipusIncorrecte(MethodArgumentTypeMismatchException e, HttpServletRequest p) {
        String esperat = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "valid";
        return problema(HttpStatus.BAD_REQUEST, "tipus-incorrecte", "Tipus de dada incorrecte",
                "El parametre '%s' amb valor '%s' no es un %s."
                        .formatted(e.getName(), e.getValue(), esperat), p);
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ProblemDetail cosIllegible(HttpMessageNotReadableException e, HttpServletRequest p) {
        // NO exposem e.getMessage(): revela l'estructura interna de les classes
        return problema(HttpStatus.BAD_REQUEST, "cos-invalid", "Cos de la peticio invalid",
                "El cos no es un JSON valid o no encaixa amb el format esperat.", p);
    }

    // ---------- 503: dependencia externa ----------
    @ExceptionHandler(ServeiExternNoDisponibleException.class)
    public ResponseEntity<ProblemDetail> serveiCaigut(ServeiExternNoDisponibleException e,
                                                      HttpServletRequest p) {
        log.error("Servei extern no disponible: {}", e.getServei(), e);
        ProblemDetail detall = problema(HttpStatus.SERVICE_UNAVAILABLE, "servei-no-disponible",
                "Servei temporalment no disponible",
                "No s'ha pogut completar l'operacio. Torna-ho a provar d'aqui a uns minuts.", p);
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                .header(HttpHeaders.RETRY_AFTER, "60")     // el client sap quan reintentar
                .body(detall);
    }

    // ---------- 500: la xarxa de seguretat ----------
    @ExceptionHandler(Exception.class)
    public ProblemDetail errorNoPrevist(Exception e, HttpServletRequest p) {
        String idIncidencia = UUID.randomUUID().toString().substring(0, 12);
        // La traca COMPLETA al log; al client, nomes l'identificador (06-07 i 12-07)
        log.error("Error no previst [id={}] a {} {}", idIncidencia, p.getMethod(), p.getRequestURI(), e);

        ProblemDetail detall = problema(HttpStatus.INTERNAL_SERVER_ERROR, "error-intern",
                "Error intern",
                "S'ha produit un error inesperat. Si el problema persisteix, "
                + "contacta amb suport indicant l'identificador d'incidencia.", p);
        detall.setProperty("idIncidencia", idIncidencia);
        return detall;
    }

    // ---------- Constructor comu ----------
    private ProblemDetail problema(HttpStatus estat, String tipus, String titol,
                                   String detallText, HttpServletRequest peticio) {
        ProblemDetail detall = ProblemDetail.forStatusAndDetail(estat, detallText);
        detall.setType(URI.create(BASE_TIPUS + tipus));
        detall.setTitle(titol);
        detall.setInstance(URI.create(peticio.getRequestURI()));
        detall.setProperty("timestamp", Instant.now().toString());
        // L'identificador de correlacio que MDC va posar a 11-07: uneix la resposta amb el log
        detall.setProperty("traceId", MDC.get("traceId"));
        return detall;
    }

    public record ErrorCamp(String camp, String missatge, Object valorRebutjat) { }
}

Taula de traducció completa de la jerarquia del mòdul 6:

Excepció de BiblioTech HTTP Tipus de problema Es registra com
MaterialNoTrobatException 404 recurs-no-trobat INFO
PrestecNoTrobatException 404 recurs-no-trobat INFO
LimitPrestecsExceditException 409 limit-prestecs INFO
MaterialNoDisponibleException 409 material-no-disponible INFO
PrestecJaRetornatException 409 prestec-ja-retornat INFO
ObjectOptimisticLockingFailureException 409 conflicte-concurrencia WARN
MethodArgumentNotValidException 400 validacio DEBUG
IsbnInvalidException 400 isbn-invalid DEBUG
ServeiExternNoDisponibleException 503 servei-no-disponible ERROR
Exception (qualsevol altra) 500 error-intern ERROR

El criteri de nivell de log és important i gairebé ningú l'aplica: un 404 no és un error del sistema, és un client demanant una cosa que no existeix. Si el registres com a ERROR, el teu panell d'alertes s'omplirà de soroll i deixaràs de mirar-lo.

  1. Paginació i ordenació

Retornar List<Material> amb 50.000 elements és un problema de memòria, de xarxa i de temps de resposta. Spring Data resol la paginació de sèrie:

@GetMapping
public PageResponse<MaterialResponse> llistar(
        @PageableDefault(size = 20, sort = "titol", direction = Sort.Direction.ASC)
        Pageable paginacio) {

    Page<Material> pagina = cataleg.cercar(paginacio);
    return PageResponse.desDe(pagina.map(MaterialResponse::desDe));
}
GET /api/materials?page=0&size=20&sort=titol,asc
GET /api/materials?page=2&size=50&sort=anyPublicacio,desc&sort=titol,asc

Limita la mida màxima de pàgina, o algú demanarà size=1000000:

spring:
  data:
    web:
      pageable:
        default-page-size: 20
        max-page-size: 100          # Spring retalla silenciosament per damunt
        one-indexed-parameters: false

I un DTO de resposta paginada propi, per no exposar l'estructura interna de Page (que ha canviat entre versions de Spring Data, trencant clients):

public record PageResponse<T>(List<T> contingut, MetadadesPagina pagina) {

    public static <T> PageResponse<T> desDe(Page<T> page) {
        return new PageResponse<>(page.getContent(), new MetadadesPagina(
                page.getNumber(), page.getSize(), page.getTotalElements(),
                page.getTotalPages(), page.isFirst(), page.isLast()));
    }

    public record MetadadesPagina(int numero, int mida, long totalElements,
                                  int totalPagines, boolean primera, boolean ultima) { }
}
{
  "contingut": [ { "isbn": "978-0000000001", "titol": "Java Eficac", "…": "…" } ],
  "pagina": { "numero": 0, "mida": 20, "totalElements": 3,
              "totalPagines": 1, "primera": true, "ultima": true }
}

Un avís de rendiment. La paginació per desplaçament (OFFSET) es degrada amb pàgines altes: OFFSET 100000 obliga la base de dades a recórrer i descartar cent mil files. Per a catàlegs grans existeix la paginació per cursor (WHERE id > :ultimId ORDER BY id LIMIT 20), que és constant en temps. Per a BiblioTech, amb uns quants milers de materials, el desplaçament fa el fet.

  1. Filtres i cerca

Un objecte de criteris agrupat és millor que vuit @RequestParam solts:

public record CriteriMaterialsRequest(
        @Size(min = 2, max = 100) String titol,
        @Size(min = 2, max = 100) String autor,
        TipusMaterial tipus,
        @Min(1450) Integer anyDesDe,           // any de la impremta: un limit raonable
        @Max(2100) Integer anyFinsA,
        Boolean nomesDisponibles) {

    public CriteriCerca aDomini() {
        return CriteriCerca.builder()          // el Builder de 12-02
                .titol(titol).autor(autor).tipus(tipus)
                .entre(anyDesDe, anyFinsA)
                .nomesDisponibles(Boolean.TRUE.equals(nomesDisponibles))
                .construir();
    }
}
@GetMapping
public PageResponse<MaterialResponse> llistar(@Valid CriteriMaterialsRequest criteri,
                                              @PageableDefault(size = 20) Pageable paginacio) {
    Page<Material> resultats = cataleg.cercar(criteri.aDomini(), paginacio);
    return PageResponse.desDe(resultats.map(MaterialResponse::desDe));
}
GET /api/materials?titol=java&tipus=LLIBRE&nomesDisponibles=true&page=0&size=10

A la implementació, Specification de Spring Data compon els criteris dinàmicament. És el patró Especificació (12-02):

public Page<Material> cercar(CriteriCerca criteri, Pageable paginacio) {
    Specification<Material> spec = Specification.where(null);

    if (criteri.titol() != null) {
        spec = spec.and((arrel, consulta, cb) ->
                cb.like(cb.lower(arrel.get("titol")), "%" + criteri.titol().toLowerCase() + "%"));
    }
    if (criteri.tipus() != null) {
        spec = spec.and((arrel, consulta, cb) -> cb.equal(arrel.get("tipus"), criteri.tipus()));
    }
    if (criteri.nomesDisponibles()) {
        spec = spec.and((arrel, consulta, cb) -> cb.greaterThan(arrel.get("unitatsDisponibles"), 0));
    }
    return repositori.findAll(spec, paginacio);
}

Això no és concatenació de SQL: CriteriaBuilder genera consultes parametritzades, immunes a injecció (es reprèn a 12-07).

  1. L'API completa de BiblioTech

Mètode Camí Descripció Èxit Errors
GET /api/materials Llista paginada amb filtres 200 400
GET /api/materials/{isbn} Detall d'un material 200 400, 404
POST /api/materials Alta de material 201 400, 409
PUT /api/materials/{isbn} Reemplaçament complet 200, 201 400, 404
PATCH /api/materials/{isbn}/unitats Ajust d'unitats 200 400, 404, 409
DELETE /api/materials/{isbn} Baixa de material 204 404, 409
GET /api/prestecs Llista paginada 200 400
GET /api/prestecs/{id} Detall 200 404
POST /api/prestecs Crear préstec 201 400, 404, 409
POST /api/prestecs/{id}/devolucio Registrar devolució 200 404, 409
POST /api/prestecs/{id}/renovacio Renovar 200 404, 409
GET /api/empleats/{id}/prestecs Préstecs d'un empleat 200 404
GET /api/empleats/{id}/multes Multes d'un empleat 200 404
POST /api/reserves Crear reserva 201 400, 404, 409
DELETE /api/reserves/{id} Cancel·lar reserva 204 404, 409
GET /api/estadistiques/us Informe d'ús 200 400
GET /actuator/health Estat del servei 200 503

Exemples amb curl i les seves respostes.

Crear un préstec:

curl -i -X POST http://localhost:8080/api/prestecs \
  -H "Content-Type: application/json" \
  -d '{"isbn":"978-0000000001","idEmpleat":1,"dies":15}'
HTTP/1.1 201 Created
Location: http://localhost:8080/api/prestecs/42
Content-Type: application/json

{
  "id": 42,
  "isbn": "978-0000000001",
  "titolMaterial": "Java Eficac",
  "nomEmpleat": "Marta Ruiz",
  "dataPrestec": "2026-08-05",
  "dataVenciment": "2026-08-20",
  "dataDevolucio": null,
  "estat": "ACTIU",
  "diesRestants": 15,
  "multaAcumulada": 0.00
}

Superar el límit de préstecs:

curl -i -X POST http://localhost:8080/api/prestecs \
  -H "Content-Type: application/json" \
  -d '{"isbn":"978-0000000003","idEmpleat":2,"dies":15}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://bibliotech.nexussoftware.com/errors/limit-prestecs",
  "title": "Limit de prestecs excedit",
  "status": 409,
  "detail": "L'empleat Diego Alonso ja te 3 prestecs actius (maxim: 3).",
  "instance": "/api/prestecs",
  "timestamp": "2026-08-05T10:23:45Z",
  "traceId": "a7f3e91c4b2d",
  "prestecsActius": [12, 27, 38],
  "maximPermes": 3
}

Petició amb diversos errors de validació:

curl -i -X POST http://localhost:8080/api/prestecs \
  -H "Content-Type: application/json" \
  -d '{"isbn":"1234","idEmpleat":null,"dies":365}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://bibliotech.nexussoftware.com/errors/validacio",
  "title": "Error de validacio",
  "status": 400,
  "detail": "La peticio conte 3 camp(s) invalid(s).",
  "instance": "/api/prestecs",
  "errors": [
    {"camp": "isbn", "missatge": "L'ISBN ha de comencar per 978 o 979 i tenir 13 digits",
     "valorRebutjat": "1234"},
    {"camp": "idEmpleat", "missatge": "L'identificador de l'empleat es obligatori",
     "valorRebutjat": null},
    {"camp": "dies", "missatge": "La durada maxima es de 90 dies", "valorRebutjat": 365}
  ]
}

Que es retornin els tres errors alhora —i no el primer— és el que estalvia al client tres viatges d'anada i tornada.

Registrar la devolució:

curl -i -X POST http://localhost:8080/api/prestecs/42/devolucio \
  -H "Content-Type: application/json" -d '{"data":"2026-08-25"}'
HTTP/1.1 200 OK

{
  "idPrestec": 42,
  "dataDevolucio": "2026-08-25",
  "diesDeRetard": 5,
  "multa": 2.50,
  "estat": "RETORNAT"
}

Cerca paginada:

curl "http://localhost:8080/api/materials?titol=diss&tipus=LLIBRE&page=0&size=5&sort=titol,asc"

  1. Documentació automàtica amb OpenAPI

OpenAPI (abans Swagger) descriu una API en un document JSON o YAML llegible per màquines. A partir d'ell es genera documentació navegable, clients en qualsevol llenguatge i proves de contracte.

<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>

Només amb això ja tens:

  • http://localhost:8080/v3/api-docs — el document OpenAPI en JSON
  • http://localhost:8080/swagger-ui.html — la interfície navegable, amb botó «Try it out»

I s'enriqueix amb anotacions:

@RestController
@RequestMapping("/api/prestecs")
@Tag(name = "Prestecs", description = "Gestio de prestecs de materials a empleats")
public class PrestecController {

    @Operation(
        summary = "Crea un prestec",
        description = """
                Presta un material a un empleat. Comprova que hi hagi unitats disponibles
                i que l'empleat no superi el limit de prestecs actius.

                Si no s'indica la durada, es fa servir l'estandard del tipus de material:
                15 dies per a llibres, 7 per a revistes i 3 per a DVD.""")
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "Prestec creat",
            content = @Content(schema = @Schema(implementation = PrestecResponse.class))),
        @ApiResponse(responseCode = "400", description = "Peticio invalida",
            content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
        @ApiResponse(responseCode = "404", description = "Material o empleat inexistent",
            content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
        @ApiResponse(responseCode = "409", description = "Sense unitats lliures o limit excedit",
            content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
    })
    @PostMapping
    public ResponseEntity<PrestecResponse> crear(@Valid @RequestBody CrearPrestecRequest peticio) { … }
}

Informació general de l'API:

@Configuration
public class ConfiguracioOpenApi {

    @Bean
    OpenAPI apiBiblioTech(@Value("${bibliotech.version}") String versio) {
        return new OpenAPI()
                .info(new Info()
                        .title("API de BiblioTech")
                        .version(versio)
                        .description("Gestio de la biblioteca tecnica interna de Nexus Software.")
                        .contact(new Contact().name("Equip de plataforma")
                                              .email("[email protected]")))
                .servers(List.of(
                        new Server().url("http://localhost:8080").description("Desenvolupament"),
                        new Server().url("https://bibliotech.nexussoftware.com").description("Produccio")));
    }
}

I en producció, es desactiva la interfície però es conserva el document (o es protegeix, 12-07):

springdoc:
  swagger-ui:
    enabled: false        # en prod
  api-docs:
    path: /v3/api-docs

El valor real d'OpenAPI apareix en la integració: un client TypeScript, Java o Python es genera des del document amb una ordre, i no cal escriure a mà ni un sol DTO.

  1. CORS

Els navegadors apliquen la política del mateix origen: JavaScript servit des de https://intranet.nexussoftware.com no pot cridar https://bibliotech.nexussoftware.com tret que el servidor ho autoritzi explícitament. CORS (Cross-Origin Resource Sharing) és aquest mecanisme d'autorització.

El flux per a peticions «no simples» (amb Content-Type: application/json, per exemple) inclou una petició prèvia:

OPTIONS /api/prestecs HTTP/1.1
Origin: https://intranet.nexussoftware.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://intranet.nexussoftware.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 3600

Configuració global:

@Configuration
public class ConfiguracioCors implements WebMvcConfigurer {

    private final List<String> origensPermesos;       // des d'application.yml, per entorn

    @Override
    public void addCorsMappings(CorsRegistry registre) {
        registre.addMapping("/api/**")
                .allowedOrigins(origensPermesos.toArray(String[]::new))
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
                .allowedHeaders("Content-Type", "Authorization")
                .exposedHeaders("Location")            // perque el client la pugui llegir despres d'un 201
                .allowCredentials(true)
                .maxAge(3600);                         // desar la resposta previa 1 hora
    }
}
# dev
bibliotech:
  cors:
    origens: ["http://localhost:5173", "http://localhost:3000"]
# prod
bibliotech:
  cors:
    origens: ["https://intranet.nexussoftware.com"]

Avís. allowedOrigins("*") juntament amb allowCredentials(true) és una combinació que l'especificació prohibeix i que Spring rebutja en temps d'execució. I "*" a seques en una API interna és obrir la porta que qualsevol pàgina web del món faci peticions des del navegador dels teus usuaris. Llista explícita d'orígens, sempre.

I un aclariment que estalvia hores de depuració: CORS és una protecció del navegador, no del servidor. Un curl o un client Java ignoren CORS del tot. No és un mecanisme de seguretat de la teva API; la seguretat és 12-07.

  1. La capa de servei i les transaccions

El controlador no porta @Transactional. La transacció pertany al cas d'ús, i això no és una preferència estètica:

// MALAMENT: transaccio al controlador
@RestController
public class PrestecController {
    @PostMapping
    @Transactional                          // ← no
    public PrestecResponse crear(@RequestBody CrearPrestecRequest p) { … }
}

Raons concretes:

  1. La transacció quedaria oberta durant la serialització JSON, allargant-la sense motiu i mantenint ocupada una connexió del pool.
  2. La CLI (12-03) crida el mateix cas d'ús sense passar pel controlador: es quedaria sense transacció.
  3. Barreja una decisió d'infraestructura de dades amb la capa de presentació.
// BE: la transaccio, al cas d'us
@Service
public class GestorPrestecs implements GestionarPrestecs {

    @Override
    @Transactional                                  // escriptura
    public Prestec prestar(Isbn isbn, Long idEmpleat, Integer dies) { … }

    @Override
    @Transactional(readOnly = true)                 // lectura: Hibernate omet el dirty checking
    public Optional<Prestec> cercar(Long id) { … }
}
// I el controlador nomes tradueix HTTP
@PostMapping
public ResponseEntity<PrestecResponse> crear(@Valid @RequestBody CrearPrestecRequest p) {
    Prestec creat = gestor.prestar(p.isbn(), p.idEmpleat(), p.dies());
    return ResponseEntity.created(uriDe(creat)).body(PrestecResponse.desDe(creat, avui()));
}

Recorda de 12-01 la propietat que fa això obligatori:

spring:
  jpa:
    open-in-view: false

Amb open-in-view: true (el valor per defecte de Spring Boot), la sessió d'Hibernate continua oberta durant la representació, cosa que fa que les relacions mandroses es resolguin des del controlador, generant N+1 invisibles. Amb false, si el teu DTO accedeix a una relació no carregada, obtens LazyInitializationException en desenvolupament, que és exactament el que vols: un error sorollós en lloc d'un problema de rendiment silenciós.

  1. Proves de la capa web

Dos nivells, amb propòsits diferents:

Nivell Anotació Què aixeca Velocitat Què prova
Llesca web @WebMvcTest Només la capa MVC ~1 s Mapatge, validació, serialització, codis d'estat
Extrem a extrem @SpringBootTest(RANDOM_PORT) Tot, amb servidor real ~5-15 s El flux complet, inclosa la base de dades

Nivell 1: @WebMvcTest amb MockMvc. No hi ha base de dades, no hi ha servidor: només el controlador i la infraestructura MVC.

@WebMvcTest(PrestecController.class)
class PrestecControllerTest {

    @Autowired MockMvc mvc;
    @Autowired ObjectMapper json;

    @MockitoBean GestionarPrestecs gestor;       // Spring Boot 3.4+; abans era @MockBean

    @Test
    void retorna201ILocationEnCrearUnPrestec() throws Exception {
        var creat = unPrestec(42L, "978-0000000001", "Marta Ruiz");
        when(gestor.prestar(any(), eq(1L), eq(15))).thenReturn(creat);

        mvc.perform(post("/api/prestecs")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"isbn":"978-0000000001","idEmpleat":1,"dies":15}"""))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", endsWith("/api/prestecs/42")))
                .andExpect(jsonPath("$.id").value(42))
                .andExpect(jsonPath("$.titolMaterial").value("Java Eficac"))
                .andExpect(jsonPath("$.estat").value("ACTIU"));
    }

    @Test
    void retorna400AmbElDetallDeCadaCampInvalid() throws Exception {
        mvc.perform(post("/api/prestecs")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"isbn":"1234","idEmpleat":null,"dies":365}"""))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentTypeCompatibleWith("application/problem+json"))
                .andExpect(jsonPath("$.title").value("Error de validacio"))
                .andExpect(jsonPath("$.errors", hasSize(3)))
                .andExpect(jsonPath("$.errors[*].camp",
                        containsInAnyOrder("isbn", "idEmpleat", "dies")));

        // Amb dades invalides, el cas d'us NO ha d'haver-se invocat
        verifyNoInteractions(gestor);
    }

    @Test
    void retorna409AmbDetallQuanSeSuperaElLimit() throws Exception {
        when(gestor.prestar(any(), eq(2L), any()))
                .thenThrow(new LimitPrestecsExceditException(2L, "Diego Alonso", 3,
                                                             List.of(12L, 27L, 38L)));

        mvc.perform(post("/api/prestecs")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"isbn":"978-0000000003","idEmpleat":2,"dies":15}"""))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.type").value(endsWith("/errors/limit-prestecs")))
                .andExpect(jsonPath("$.detail").value(containsString("Diego Alonso")))
                .andExpect(jsonPath("$.prestecsActius", hasSize(3)));
    }

    @Test
    void retorna404QuanElPrestecNoExisteix() throws Exception {
        when(gestor.cercar(9999L)).thenReturn(Optional.empty());

        mvc.perform(get("/api/prestecs/9999"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.status").value(404));
    }

    @Test
    void retorna400QuanLIsbnDelCamiEsInvalid() throws Exception {
        mvc.perform(get("/api/materials/no-es-un-isbn"))
                .andExpect(status().isBadRequest());
    }
}

Nivell 2: @SpringBootTest amb TestRestTemplate. Servidor real en un port aleatori, base de dades real, tot el flux:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PrestecApiIT {

    @Autowired TestRestTemplate client;
    @Autowired PrestecRepository repositori;

    @Test
    void cicleCompletDePrestecIDevolucio() {
        // 1. Crear
        var peticio = new CrearPrestecRequest("978-0000000001", 1L, 15);
        ResponseEntity<PrestecResponse> creacio =
                client.postForEntity("/api/prestecs", peticio, PrestecResponse.class);

        assertThat(creacio.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(creacio.getHeaders().getLocation()).isNotNull();
        Long id = creacio.getBody().id();

        // 2. Consultar per l'URI que va retornar Location
        ResponseEntity<PrestecResponse> consulta =
                client.getForEntity(creacio.getHeaders().getLocation(), PrestecResponse.class);
        assertThat(consulta.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(consulta.getBody().estat()).isEqualTo("ACTIU");

        // 3. Retornar
        ResponseEntity<DevolucioResponse> devolucio = client.postForEntity(
                "/api/prestecs/{id}/devolucio", new DevolucioRequest(null),
                DevolucioResponse.class, id);
        assertThat(devolucio.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(devolucio.getBody().multa()).isEqualByComparingTo("0.00");

        // 4. Verificar que la persistencia reflecteix el canvi
        assertThat(repositori.findById(id))
                .get()
                .extracting(Prestec::getEstat)
                .isEqualTo(EstatPrestec.RETORNAT);
    }

    @Test
    void retornarDuesVegadesRetorna409() {
        Long id = crearIRetornar();

        ResponseEntity<ProblemDetail> segona = client.postForEntity(
                "/api/prestecs/{id}/devolucio", new DevolucioRequest(null),
                ProblemDetail.class, id);

        assertThat(segona.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
        assertThat(segona.getBody().getDetail()).contains("ja ha estat retornat");
    }
}

Què es prova a cada nivell (l'estratègia completa és la lliçó 12-05):

Aspecte @WebMvcTest @SpringBootTest
Camí i verb correctes
Conversió de paràmetres
Validació de l'entrada Sí, aquí Redundant
Codis d'estat i errors Sí, aquí Els principals
Forma del JSON Sí, aquí No cal
Regles de negoci No (simulades)
Persistència real No Sí, aquí
Transaccions i rollback No Sí, aquí

Regla pràctica: moltes proves de llesca (ràpides, exhaustives en casos límit) i poques d'extrem a extrem (lentes, per als fluxos principals). Invertir aquesta proporció és la manera més comuna d'acabar amb una suite de proves que triga vint minuts.

  1. Interfície d'usuari: Thymeleaf o front-end separat

L'API està feta. Falta decidir què veu la Marta Ruiz al seu navegador. Dos camins:

Opció A: HTML servit des de Spring, amb Thymeleaf.

@Controller                                   // NO @RestController!
@RequestMapping("/cataleg")
public class CatalegVistaController {

    @GetMapping
    public String llistar(@RequestParam(required = false) String titol, Model model) {
        model.addAttribute("materials", cataleg.cercar(titol));
        model.addAttribute("filtre", titol);
        return "cataleg/llista";              // resol a templates/cataleg/llista.html
    }
}
<!-- src/main/resources/templates/cataleg/llista.html -->
<table>
  <tr th:each="m : ${materials}">
    <td th:text="${m.isbn}">978-…</td>
    <td th:text="${m.titol}">Titol</td>
    <td th:text="${m.unitatsDisponibles}">0</td>
  </tr>
</table>

Opció B: front-end separat (React, Vue, Angular) que consumeix l'API REST, desplegat com a estàtics en un CDN o servidor web.

Criteri Thymeleaf (HTML servit) Front-end separat
Complexitat de desplegament Una unitat Dos projectes, dos desplegaments
SEO Excel·lent Requereix representació al servidor
Interactivitat rica Limitada Excel·lent
Equip necessari Només Java Java + JavaScript
Reutilització de l'API La vista no fa servir l'API La mateixa API serveix web i mòbil
Temps fins a la primera versió Menor Major
CORS, autenticació per testimoni No calen S'han de resoldre

Recomanació per a BiblioTech: l'API REST és la interfície principal, perquè ha de servir també la CLI, l'app mòbil prevista i la integració amb recursos humans. Per a la interfície dels empleats, Thymeleaf és l'elecció pragmàtica: l'equip de Nexus Software és de Java, la interactivitat requerida és baixa (cercar, llistar, un botó de reservar), i evita el cost de mantenir un projecte de front-end separat amb el seu propi cicle de desplegament.

I no és una decisió irreversible: l'API queda intacta si més endavant s'afegeix un front-end modern. Aquest és precisament l'avantatge de tenir l'API com a peça central.

  1. Fils virtuals a Spring Boot 3.2

Aquí es tanca el cercle amb 10-06.

El model tradicional de Tomcat és un fil de plataforma per petició, amb un pool de 200 per defecte. Cada fil consumeix ~1 MB de pila. Quan una petició espera la base de dades o l'API de metadades, el seu fil està bloquejat sense fer res, i amb 200 peticions lentes concurrents el pool s'esgota: les següents esperen a la cua encara que la CPU estigui al 5 %.

Els fils virtuals de Java 21 (Project Loom) resolen això: són fils gestionats per la JVM, costen uns centenars de bytes, i quan es bloquegen en E/S alliberen el fil de plataforma subjacent.

Activar-los a Spring Boot 3.2+ és una línia:

spring:
  threads:
    virtual:
      enabled: true

Això fa que Tomcat atengui cada petició en un fil virtual, i que @Async i les tasques programades també els facin servir.

Aspecte Fils de plataforma Fils virtuals
Cost de memòria ~1 MB de pila Centenars de bytes
Màxim pràctic Milers Milions
En bloquejar-se en E/S El fil del SO queda ocupat S'allibera el portador
Cost de creació Alt (per això els pools) Molt baix
Canvis al teu codi Cap
Benefici Gran si hi ha molta E/S

El que cal saber abans d'activar-los:

  1. No acceleren la CPU. Si el coll d'ampolla és càlcul, no canvien res. El benefici és en concurrència bloquejada per E/S, que és el cas típic d'una API.
  2. synchronized els ancora. Un bloc synchronized que fa E/S a dins fixa (pins) el fil virtual al seu portador, anul·lant l'avantatge. Se substitueix per ReentrantLock. A Java 24 aquesta limitació s'elimina, però a Java 21 cal vigilar-la.
  3. No els posis mai en un pool. La seva gràcia és crear-ne un per tasca. Un pool de fils virtuals no té sentit.
  4. El pool de connexions continua sent el límit real. Pots tenir un milió de fils virtuals i 20 connexions a la base de dades: el coll d'ampolla es mou, no desapareix. Ajusta HikariCP en conseqüència.
  5. Compte amb els ThreadLocal. Milions de fils virtuals, cadascun amb la seva còpia, consumeixen memòria. Amb MDC (11-07) això està controlat, però convé saber-ho.

Comprovació que estan actius:

@GetMapping("/api/diagnostic/fil")
public Map<String, Object> filActual() {
    Thread fil = Thread.currentThread();
    return Map.of("nom", fil.getName(),
                  "virtual", fil.isVirtual(),
                  "grup", String.valueOf(fil.threadId()));
}
{"nom":"","virtual":true,"grup":"142"}

I així es tanca l'arc que va començar al mòdul 8 amb Thread, va continuar a 08-05 amb ExecutorService, es va generalitzar a 10-06 amb els fils virtuals, i acaba aquí: una línia de configuració que multiplica per mil la concurrència de l'API, sense tocar ni una sola línia de codi de BiblioTech.

Errors Comuns i Consells

1. Exposar entitats JPA a l'API. Fuita de dades sensibles, LazyInitializationException, cicles infinits, i l'esquema de la base de dades convertit en contracte públic. DTO sempre, des del primer endpoint.

2. Retornar 200 amb un cos d'error. El client no pot distingir èxit de fallada sense analitzar el cos. Fes servir el codi d'estat: per a això existeix.

3. Posar @Transactional al controlador. Allarga la transacció fins a la serialització, ocupa una connexió de més i deixa sense transacció els altres adaptadors.

4. Deixar open-in-view a true. És el valor per defecte i genera N+1 invisibles executats des de la capa de vista. Posa'l a false i arregla el que es trenqui.

5. No paginar. Funciona amb 50 materials i tomba el servidor amb 50.000. Pagina des del principi i limita la mida màxima.

6. Retornar 500 per errors del client. Un camp que falta és 400, un recurs inexistent és 404, una regla de negoci violada és 409. El 500 és «he fallat jo», i hauria de disparar una alerta.

7. Filtrar detalls interns als missatges d'error. Traces de pila, noms de classe, consultes SQL o versions de biblioteca a la resposta HTTP són informació de franc per a un atacant. Al client, missatge i identificador; al log, tota la resta.

8. allowedOrigins("*") en producció. Qualsevol pàgina del món podria cridar la teva API des del navegador dels teus usuaris. Llista explícita.

9. Verbs als camins. POST /api/crearPrestec no és REST, és RPC amb una altra sintaxi. POST /api/prestecs.

10. No documentar l'API. Sense OpenAPI, cada integració comença amb una cadena de correus. El cost és una dependència i unes anotacions.

11. Provar només amb @SpringBootTest. Suites de vint minuts que ningú executa abans de pujar codi. Moltes proves de llesca, poques d'extrem a extrem.

12. Oblidar la capçalera Location en un 201. El client no sap on ha quedat el recurs que acaba de crear i l'ha d'endevinar.

Consell final: dissenya l'API abans d'implementar-la. Escriu la taula d'endpoints amb els seus codis d'estat, revisa-la, i només llavors escriu codi. Canviar una API publicada és car; canviar una taula en un document no costa res.

Exercicis

Els exercicis assumeixen el projecte tal com ha quedat en aquesta lliçó.

Exercici 1: endpoint de renovació

Implementa POST /api/prestecs/{id}/renovacio amb aquestes regles:

  • Cos opcional amb dies (1 a 30); si s'omet, es fa servir la durada estàndard del material.
  • Només es pot renovar una vegada (estat ACTIU; vegeu el patró Estat de 12-02).
  • No es pot renovar si el préstec està vençut.
  • No es pot renovar si hi ha reserves pendents d'aquest material.
  • Codis: 200 correcte, 404 no existeix, 409 en cadascuna de les tres violacions, 400 dies fora de rang.

Escriu el DTO de petició i resposta, el mètode del controlador, els gestors d'error necessaris i les proves @WebMvcTest dels cinc escenaris.

Exercici 2: cerca avançada amb paginació

Implementa GET /api/materials/cerca que accepti:

  • q: text lliure que cerca en títol i autor (mínim 2 caràcters).
  • tipus: repetible (?tipus=LLIBRE&tipus=DVD).
  • disponible: booleà.
  • anyDesDe i anyFinsA, validant que desDe <= finsA amb una anotació de classe.
  • Paginació i ordenació, amb màxim 50 per pàgina.
  • Ha de retornar, a més dels resultats, quants n'hi ha per tipus (facetes).

Inclou el DTO de criteris amb la seva validació creuada, la resposta amb facetes i les proves.

Exercici 3: idempotència en la creació de préstecs

Implementa el suport de la capçalera Idempotency-Key a POST /api/prestecs:

  • Si la capçalera hi és i la clau no s'ha vist, es processa normalment i es desa la resposta associada a la clau.
  • Si la clau ja s'ha processat, es retorna la resposta original amb la mateixa capçalera Location i una capçalera Idempotent-Replay: true, sense crear res.
  • Si la clau està en curs, es retorna 409.
  • Les claus caduquen a les 24 hores.

Fes servir un filtre o un interceptor, i explica les implicacions de concurrència.


Solucions

Solució 1

DTO:

public record RenovarPrestecRequest(
        @Min(value = 1, message = "La renovacio ha de ser d'almenys 1 dia")
        @Max(value = 30, message = "La renovacio no pot superar els 30 dies")
        Integer dies) {
}

public record RenovacioResponse(
        Long idPrestec,
        String titolMaterial,
        LocalDate vencimentAnterior,
        LocalDate vencimentNou,
        int diesAfegits,
        boolean potRenovarseDeNou) {

    public static RenovacioResponse desDe(Prestec p, LocalDate anterior) {
        return new RenovacioResponse(
                p.getId(), p.titolDelMaterial(), anterior, p.getDataVenciment(),
                (int) ChronoUnit.DAYS.between(anterior, p.getDataVenciment()),
                p.getEstat().permetRenovar());
    }
}

Controlador:

@PostMapping("/{id}/renovacio")
@Operation(summary = "Renova un prestec",
           description = "Amplia la data de venciment. Nomes es permet una renovacio per prestec.")
@ApiResponses({
    @ApiResponse(responseCode = "200", description = "Renovat"),
    @ApiResponse(responseCode = "404", description = "El prestec no existeix"),
    @ApiResponse(responseCode = "409", description = "Ja renovat, vencut, o amb reserves pendents")
})
public RenovacioResponse renovar(
        @PathVariable Long id,
        @RequestBody(required = false) @Valid RenovarPrestecRequest peticio) {

    Integer dies = (peticio != null) ? peticio.dies() : null;
    ResultatRenovacio resultat = gestor.renovar(id, dies);
    return RenovacioResponse.desDe(resultat.prestec(), resultat.vencimentAnterior());
}

Cas d'ús, on viuen de debò les tres regles:

@Override
@Transactional
public ResultatRenovacio renovar(Long id, Integer dies) {
    Prestec prestec = repositori.cercarPerId(id)
            .orElseThrow(() -> new PrestecNoTrobatException(id));

    LocalDate avui = LocalDate.now(rellotge);

    // Regla 1: l'estat governa (patro Estat, 12-02)
    if (!prestec.getEstat().permetRenovar()) {
        throw new RenovacioNoPermesaException(id, prestec.getEstat(),
                "Aquest prestec ja ha estat renovat o no admet renovacio.");
    }

    // Regla 2: vencut
    if (prestec.estaVencutA(avui)) {
        throw new RenovacioNoPermesaException(id, prestec.getEstat(),
                "El prestec va vencer el %s. Retorna'l i torna a prestar-lo."
                        .formatted(prestec.getDataVenciment()));
    }

    // Regla 3: reserves d'altri
    long pendents = reserves.comptarPendentsDe(prestec.getIsbn());
    if (pendents > 0) {
        throw new MaterialAmbReservesException(prestec.getIsbn(), pendents);
    }

    LocalDate anterior = prestec.getDataVenciment();
    int diesEfectius = (dies != null) ? dies : prestec.diesEstandardDelMaterial();
    prestec.renovar(diesEfectius);            // valida i transita l'estat

    return new ResultatRenovacio(prestec, anterior);
}

Gestors:

@ExceptionHandler(RenovacioNoPermesaException.class)
public ProblemDetail renovacioNoPermesa(RenovacioNoPermesaException e, HttpServletRequest p) {
    ProblemDetail d = problema(HttpStatus.CONFLICT, "renovacio-no-permesa",
            "Renovacio no permesa", e.getMessage(), p);
    d.setProperty("idPrestec", e.getIdPrestec());
    d.setProperty("estatActual", e.getEstat().name());
    return d;
}

@ExceptionHandler(MaterialAmbReservesException.class)
public ProblemDetail ambReserves(MaterialAmbReservesException e, HttpServletRequest p) {
    ProblemDetail d = problema(HttpStatus.CONFLICT, "material-amb-reserves",
            "Material amb reserves pendents",
            "No es pot renovar: hi ha %d empleat(s) esperant aquest material."
                    .formatted(e.getReservesPendents()), p);
    d.setProperty("reservesPendents", e.getReservesPendents());
    return d;
}

Proves dels cinc escenaris:

@WebMvcTest(PrestecController.class)
class RenovacioControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean GestionarPrestecs gestor;

    @Test
    void renovaAmbElsDiesIndicats() throws Exception {
        var prestec = unPrestec(42L).ambVenciment(LocalDate.of(2026, 8, 20));
        when(gestor.renovar(42L, 10)).thenReturn(
                new ResultatRenovacio(prestec.renovatFinsA(LocalDate.of(2026, 8, 30)),
                                      LocalDate.of(2026, 8, 20)));

        mvc.perform(post("/api/prestecs/42/renovacio")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"dies":10}"""))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.vencimentAnterior").value("2026-08-20"))
                .andExpect(jsonPath("$.vencimentNou").value("2026-08-30"))
                .andExpect(jsonPath("$.diesAfegits").value(10))
                .andExpect(jsonPath("$.potRenovarseDeNou").value(false));
    }

    @Test
    void renovaSenseCosAmbLaDuradaEstandard() throws Exception {
        when(gestor.renovar(42L, null)).thenReturn(unaRenovacioDe(15));

        mvc.perform(post("/api/prestecs/42/renovacio"))     // sense cos
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.diesAfegits").value(15));
    }

    @Test
    void retorna404SiElPrestecNoExisteix() throws Exception {
        when(gestor.renovar(eq(9999L), any())).thenThrow(new PrestecNoTrobatException(9999L));

        mvc.perform(post("/api/prestecs/9999/renovacio"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.type").value(endsWith("/errors/recurs-no-trobat")));
    }

    @Test
    void retorna409SiJaVaSerRenovat() throws Exception {
        when(gestor.renovar(eq(42L), any())).thenThrow(
                new RenovacioNoPermesaException(42L, EstatPrestec.RENOVAT,
                        "Aquest prestec ja ha estat renovat o no admet renovacio."));

        mvc.perform(post("/api/prestecs/42/renovacio"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.estatActual").value("RENOVAT"))
                .andExpect(jsonPath("$.detail").value(containsString("ja ha estat renovat")));
    }

    @Test
    void retorna409SiHiHaReservesPendents() throws Exception {
        when(gestor.renovar(eq(42L), any()))
                .thenThrow(new MaterialAmbReservesException(Isbn.de("978-0000000001"), 2));

        mvc.perform(post("/api/prestecs/42/renovacio"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.reservesPendents").value(2));
    }

    @ParameterizedTest
    @ValueSource(ints = {0, -5, 31, 100})
    void retorna400SiElsDiesEstanForaDeRang(int dies) throws Exception {
        mvc.perform(post("/api/prestecs/42/renovacio")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"dies\":%d}".formatted(dies)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].camp").value("dies"));

        verifyNoInteractions(gestor);      // ni tan sols s'ha cridat el cas d'us
    }
}

Solució 2

Validació creuada amb anotació de classe:

@Documented
@Constraint(validatedBy = ValidadorRangAnys.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface RangAnysValid {
    String message() default "anyDesDe no pot ser posterior a anyFinsA";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class ValidadorRangAnys implements ConstraintValidator<RangAnysValid, CercaRequest> {
    @Override
    public boolean isValid(CercaRequest r, ConstraintValidatorContext ctx) {
        if (r.anyDesDe() == null || r.anyFinsA() == null) return true;   // @NotNull no es cosa nostra
        if (r.anyDesDe() <= r.anyFinsA()) return true;

        ctx.disableDefaultConstraintViolation();
        ctx.buildConstraintViolationWithTemplate(
                "anyDesDe (%d) no pot ser posterior a anyFinsA (%d)"
                        .formatted(r.anyDesDe(), r.anyFinsA()))
           .addPropertyNode("anyDesDe")         // l'error s'associa al camp correcte
           .addConstraintViolation();
        return false;
    }
}

DTO de criteris:

@RangAnysValid
public record CercaRequest(
        @NotBlank(message = "El text de cerca es obligatori")
        @Size(min = 2, max = 100, message = "Entre 2 i 100 caracters")
        String q,

        List<TipusMaterial> tipus,        // repetible: ?tipus=LLIBRE&tipus=DVD

        Boolean disponible,

        @Min(1450) @Max(2100) Integer anyDesDe,
        @Min(1450) @Max(2100) Integer anyFinsA) {

    /** Normalitzacio: null → llista buida, per no repetir comprovacions aigues avall. */
    public CercaRequest {
        tipus = (tipus == null) ? List.of() : List.copyOf(tipus);
    }

    public CriteriCerca aDomini() {
        return CriteriCerca.builder()
                .text(q)
                .tipus(tipus)
                .nomesDisponibles(Boolean.TRUE.equals(disponible))
                .entre(anyDesDe, anyFinsA)
                .construir();
    }
}

Resposta amb facetes:

public record ResultatCercaResponse(
        List<MaterialResponse> resultats,
        PageResponse.MetadadesPagina pagina,
        Map<String, Long> facetesPerTipus,
        long totalGlobal,
        String consulta) {

    public static ResultatCercaResponse desDe(Page<Material> pagina,
                                              Map<TipusMaterial, Long> facetes,
                                              String consulta) {
        return new ResultatCercaResponse(
                pagina.getContent().stream().map(MaterialResponse::desDe).toList(),
                new PageResponse.MetadadesPagina(pagina.getNumber(), pagina.getSize(),
                        pagina.getTotalElements(), pagina.getTotalPages(),
                        pagina.isFirst(), pagina.isLast()),
                facetes.entrySet().stream()
                        .collect(Collectors.toMap(e -> e.getKey().name(), Map.Entry::getValue,
                                                  (a, b) -> a, LinkedHashMap::new)),
                facetes.values().stream().mapToLong(Long::longValue).sum(),
                consulta);
    }
}

Controlador:

@GetMapping("/cerca")
@Operation(summary = "Cerca avancada amb facetes per tipus de material")
public ResultatCercaResponse cercar(
        @Valid CercaRequest criteri,
        @PageableDefault(size = 20, sort = "titol") Pageable paginacio) {

    // Defensa en profunditat: encara que max-page-size estigui configurat, no te'n fiis
    if (paginacio.getPageSize() > 50) {
        paginacio = PageRequest.of(paginacio.getPageNumber(), 50, paginacio.getSort());
    }

    CriteriCerca domini = criteri.aDomini();
    Page<Material> pagina = cataleg.cercar(domini, paginacio);
    Map<TipusMaterial, Long> facetes = cataleg.comptarPerTipus(domini);   // consulta d'agregacio

    return ResultatCercaResponse.desDe(pagina, facetes, criteri.q());
}

Les facetes al repositori, amb una sola consulta d'agregació en lloc de N consultes:

@Query("""
       select m.tipus as tipus, count(m) as total
       from Material m
       where (lower(m.titol) like lower(concat('%', :text, '%'))
              or lower(m.autor) like lower(concat('%', :text, '%')))
         and (:nomesDisponibles = false or m.unitatsDisponibles > 0)
       group by m.tipus
       """)
List<FacetaTipus> comptarPerTipus(@Param("text") String text,
                                  @Param("nomesDisponibles") boolean nomesDisponibles);

Proves:

@Test
void retornaResultatsAmbFacetesPerTipus() throws Exception {
    when(cataleg.cercar(any(), any())).thenReturn(unaPaginaAmb(2, "Java Eficac", "Refactoritzacio"));
    when(cataleg.comptarPerTipus(any())).thenReturn(
            new LinkedHashMap<>(Map.of(TipusMaterial.LLIBRE, 5L, TipusMaterial.DVD, 2L)));

    mvc.perform(get("/api/materials/cerca").param("q", "java").param("tipus", "LLIBRE"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.resultats", hasSize(2)))
            .andExpect(jsonPath("$.facetesPerTipus.LLIBRE").value(5))
            .andExpect(jsonPath("$.totalGlobal").value(7))
            .andExpect(jsonPath("$.consulta").value("java"));
}

@Test
void rebutjaConsultesMassaCurtes() throws Exception {
    mvc.perform(get("/api/materials/cerca").param("q", "j"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors[0].camp").value("q"));
}

@Test
void rebutjaUnRangDAnysInvertit() throws Exception {
    mvc.perform(get("/api/materials/cerca")
                    .param("q", "java").param("anyDesDe", "2020").param("anyFinsA", "2010"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors[0].camp").value("anyDesDe"))
            .andExpect(jsonPath("$.errors[0].missatge").value(containsString("no pot ser posterior")));
}

@Test
void retallaLaMidaDePaginaAlMaxim() throws Exception {
    mvc.perform(get("/api/materials/cerca").param("q", "java").param("size", "500"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.pagina.mida").value(lessThanOrEqualTo(50)));
}

Solució 3

Magatzem de claus d'idempotència:

@Entity
@Table(name = "claus_idempotencia",
       indexes = @Index(name = "idx_caducitat", columnList = "caducaEl"))
public class ClauIdempotencia {

    @Id
    @Column(length = 100)
    private String clau;

    @Column(nullable = false, length = 64)
    private String empremtaPeticio;    // hash del cos: detecta reutilitzacio amb dades diferents

    @Enumerated(EnumType.STRING)
    private EstatClau estat;           // EN_CURS, COMPLETADA

    private Integer codiResposta;

    @Column(columnDefinition = "text")
    private String cosResposta;

    private String ubicacio;
    private Instant creadaEl;
    private Instant caducaEl;

    public enum EstatClau { EN_CURS, COMPLETADA }
}

L'interceptor, que embolcalla l'execució del controlador:

@Component
public class InterceptorIdempotencia implements HandlerInterceptor {

    private static final Logger log = LoggerFactory.getLogger(InterceptorIdempotencia.class);
    private static final String CAPCALERA = "Idempotency-Key";
    private static final Duration VIGENCIA = Duration.ofHours(24);

    private final RepositoriClausIdempotencia repositori;
    private final ObjectMapper json;
    private final Clock rellotge;

    @Override
    public boolean preHandle(HttpServletRequest peticio, HttpServletResponse resposta,
                             Object gestor) throws IOException {

        // Nomes aplica a metodes no idempotents per naturalesa
        if (!"POST".equals(peticio.getMethod())) return true;

        String clau = peticio.getHeader(CAPCALERA);
        if (clau == null || clau.isBlank()) return true;       // capcalera opcional

        String empremta = empremtaDe(peticio);

        // INSERT condicional: la restriccio de clau primaria resol la cursa
        // entre dues peticions simultanies amb la mateixa clau. NO facis "SELECT i despres INSERT".
        Optional<ClauIdempotencia> existent = repositori.reservarSiNoExisteix(
                clau, empremta, Instant.now(rellotge), Instant.now(rellotge).plus(VIGENCIA));

        if (existent.isEmpty()) {
            // L'hem reservada nosaltres: continuem amb el processament normal
            peticio.setAttribute("idempotencia.clau", clau);
            return true;
        }

        ClauIdempotencia previa = existent.get();

        // Mateixa clau, cos diferent: el client s'ha equivocat
        if (!previa.getEmpremtaPeticio().equals(empremta)) {
            escriureProblema(resposta, HttpStatus.UNPROCESSABLE_ENTITY,
                    "La clau d'idempotencia ja s'ha fet servir amb un cos diferent.");
            return false;
        }

        if (previa.getEstat() == EstatClau.EN_CURS) {
            // Una altra peticio identica s'esta processant ara mateix
            resposta.setHeader(HttpHeaders.RETRY_AFTER, "2");
            escriureProblema(resposta, HttpStatus.CONFLICT,
                    "Ja hi ha una peticio en curs amb aquesta clau d'idempotencia.");
            return false;
        }

        // COMPLETADA: reproduim la resposta original sense executar res
        log.info("Reproduint resposta idempotent per a la clau {}", clau);
        resposta.setStatus(previa.getCodiResposta());
        resposta.setContentType(MediaType.APPLICATION_JSON_VALUE);
        resposta.setHeader("Idempotent-Replay", "true");
        if (previa.getUbicacio() != null) {
            resposta.setHeader(HttpHeaders.LOCATION, previa.getUbicacio());
        }
        resposta.getWriter().write(previa.getCosResposta());
        return false;                       // NO es crida el controlador
    }

    private String empremtaDe(HttpServletRequest peticio) throws IOException {
        // Requereix ContentCachingRequestWrapper: el cos nomes es pot llegir una vegada
        byte[] cos = ((ContentCachingRequestWrapper) peticio).getContentAsByteArray();
        return HexFormat.of().formatHex(
                MessageDigest.getInstance("SHA-256").digest(cos));
    }
}

Desat de la resposta, en un filtre que ho embolcalla tot:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class FiltreIdempotencia extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest peticio, HttpServletResponse resposta,
                                    FilterChain cadena) throws ServletException, IOException {

        var peticioCacheada = new ContentCachingRequestWrapper(peticio);
        var respostaCacheada = new ContentCachingResponseWrapper(resposta);

        try {
            cadena.doFilter(peticioCacheada, respostaCacheada);

            String clau = (String) peticio.getAttribute("idempotencia.clau");
            if (clau != null) {
                int estat = respostaCacheada.getStatus();
                if (estat >= 200 && estat < 300) {
                    // Nomes es memoritza l'exit: un 500 ha de poder reintentar-se
                    repositori.completar(clau, estat,
                            new String(respostaCacheada.getContentAsByteArray(), UTF_8),
                            respostaCacheada.getHeader(HttpHeaders.LOCATION));
                } else {
                    repositori.alliberar(clau);   // allibera la clau per a un reintent legitim
                }
            }
        } finally {
            respostaCacheada.copyBodyToResponse();   // IMPRESCINDIBLE: sense aixo no arriba res al client
        }
    }
}

La reserva atòmica, que és el punt delicat:

@Repository
public class RepositoriClausIdempotencia {

    private final JdbcTemplate jdbc;

    /**
     * Retorna Optional.empty() si la clau s'ha reservat ara (som els primers),
     * o la fila existent si ja hi era.
     *
     * ON CONFLICT DO NOTHING fa l'operacio ATOMICA a la base de dades:
     * dues peticions simultanies amb la mateixa clau no poden reservar-la totes dues.
     * Un "SELECT i despres INSERT" en Java tindria una finestra de cursa.
     */
    public Optional<ClauIdempotencia> reservarSiNoExisteix(String clau, String empremta,
                                                           Instant ara, Instant caduca) {
        int files = jdbc.update("""
                insert into claus_idempotencia
                       (clau, empremta_peticio, estat, creada_el, caduca_el)
                values (?, ?, 'EN_CURS', ?, ?)
                on conflict (clau) do nothing
                """, clau, empremta, Timestamp.from(ara), Timestamp.from(caduca));

        if (files == 1) return Optional.empty();     // l'hem reservada nosaltres

        return jdbc.query("select * from claus_idempotencia where clau = ?",
                          this::mapejar, clau).stream().findFirst();
    }
}

Neteja periòdica:

@Scheduled(cron = "0 0 3 * * *")     // cada dia a les 3:00
@Transactional
public void purgarClausCaducades() {
    int esborrades = repositori.esborrarCaducadesAbansDe(Instant.now(rellotge));
    log.info("Claus d'idempotencia purgades: {}", esborrades);
}

Implicacions de concurrència, que és el que avalua l'exercici:

Escenari Sense protecció Amb aquesta implementació
Reintent després d'un timeout de xarxa Dos préstecs creats Es retorna la resposta original
Dues peticions simultànies, mateixa clau Dos préstecs Una processa, l'altra rep 409
Mateixa clau, cos diferent Comportament indefinit 422 explícit
Fallada del servidor a mitges Clau bloquejada per sempre alliberar() al filtre + caducitat
Dues instàncies de l'aplicació La memòria local no serveix La base de dades és el punt d'acord

La decisió clau és fer servir INSERT ... ON CONFLICT DO NOTHING en lloc de comprovar i després inserir. Entre el SELECT i l'INSERT de la versió ingènua hi ha una finestra per la qual una altra petició pot colar-se, i amb dues instàncies de l'aplicació (escalat horitzontal, 12-06) aquesta finestra s'obre constantment. L'atomicitat ha d'estar a la base de dades, que és l'únic recurs compartit.

Ús des del client:

CLAU=$(uuidgen)
curl -i -X POST http://localhost:8080/api/prestecs \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $CLAU" \
  -d '{"isbn":"978-0000000001","idEmpleat":1,"dies":15}'
# → 201 Created, Location: /api/prestecs/42

# Reintent amb la mateixa clau
curl -i -X POST … -H "Idempotency-Key: $CLAU" -d '{…}'
# → 201 Created, Location: /api/prestecs/42, Idempotent-Replay: true
#   (i NO s'ha creat un segon prestec)

Conclusió

BiblioTech té API.

Entens com funciona una aplicació web en Java de debò, no com una caixa negra: la petició HTTP és text sobre un socket, el servidor incrustat de Tomcat l'analitza, el DispatcherServlet actua de front controller, els HandlerMapping encaminen, els ArgumentResolver converteixen els paràmetres, els HttpMessageConverter serialitzen amb Jackson i els HandlerExceptionResolver tradueixen excepcions. I sobretot has vist la taula que ho posa tot en perspectiva: cada peça de Spring MVC correspon a alguna cosa que tu vas escriure a mà al ServidorCataleg del mòdul 9. No estàs fent servir màgia; estàs delegant feina que ja saps fer.

Dissenyes REST amb criteri: recursos com a substantius, verbs amb la seva semàntica de seguretat i idempotència —inclosa la raó pràctica per la qual la idempotència importa, que és poder reintentar sense duplicar—, URI amb jerarquia de màxim dos nivells, el cas difícil de les accions que no són CRUD resolt amb subrecursos (POST /api/prestecs/42/devolucio), i la taula de codis d'estat amb els cinc errors clàssics que cal evitar. Amb el nivell 2 de Richardson com a objectiu honest i realista.

Escrius controladors que només tradueixen HTTP: reben, deleguen en el cas d'ús i retornen. Amb @PathVariable, @RequestParam i @RequestBody; amb convertidors propis que fan que el controlador treballi amb Isbn i no amb String; amb ResponseEntity únicament en els tres casos que la justifiquen —el Location d'un 201, el codi que depèn del resultat, i les capçaleres de memòria cau amb ETag—; i amb DTO record d'entrada i de sortida, l'asimetria dels quals és una defensa estructural: el client no pot enviar id, version ni estat perquè l'objecte no els té.

Valides al lloc correcte: jakarta.validation per a la forma —inclòs un validador propi d'ISBN-13 que comprova el dígit de control i dona missatges específics— i el domini per a les regles de negoci, sense confondre les dues coses. I gestiones els errors globalment amb @RestControllerAdvice i el format RFC 7807 que Spring 6 porta de sèrie, amb la taula completa de traducció de la jerarquia BiblioTechException del mòdul 6, la llista de camps no vàlids que estalvia tres viatges al client, el traceId del MDC d'11-07 que uneix la resposta amb el log, i el criteri de nivell de registre que evita que un 404 dispari una alerta.

Pagines amb Pageable i un DTO propi que no exposa l'estructura interna de Page, amb la mida màxima limitada i l'avís sobre la degradació de l'OFFSET. Filtres amb un objecte de criteris i Specification, que és el patró Especificació de 12-02 i genera consultes parametritzades. Tens l'API completa documentada endpoint per endpoint, amb exemples curl i les seves respostes reals, documentació automàtica amb springdoc-openapi i Swagger UI, i CORS configurat amb llista explícita d'orígens i l'aclariment que CORS protegeix el navegador, no la teva API.

Vas posar la transacció on va —al cas d'ús, mai al controlador— amb les tres raons concretes, i open-in-view a false. I proves la capa web en els dos nivells amb propòsits diferents: @WebMvcTest amb MockMvc i @MockitoBean per a mapatge, validació, codis i forma del JSON, ràpid i exhaustiu; i @SpringBootTest(RANDOM_PORT) amb TestRestTemplate per als fluxos complets amb persistència real. Moltes de les primeres, poques de les segones.

Vas triar la interfície d'usuari amb criteri: l'API REST com a peça central perquè ha de servir la CLI, l'app mòbil i la integració amb recursos humans, i Thymeleaf per a la interfície dels empleats perquè l'equip és de Java i la interactivitat requerida és baixa — sense tancar la porta a un front-end separat més endavant.

I vas tancar el cercle de 10-06 amb els fils virtuals: una línia de configuració, spring.threads.virtual.enabled=true, que converteix cada petició en un fil virtual de Java 21 i multiplica la concurrència sense tocar ni una sola línia del codi de BiblioTech. Amb les cinc advertències que cal conèixer abans d'activar-los, especialment que synchronized els ancora i que el pool de connexions continua sent el límit real.

BiblioTech té ara arquitectura, patrons, CLI i API. I una pregunta incòmoda: funciona de debò?

Hi ha quaranta-una proves heretades del mòdul 11 i unes quantes de noves d'aquesta lliçó. No hi ha mesura de cobertura, ni idea de si aquestes proves comproven alguna cosa o només executen codi. Les proves de repositori corren sobre H2, que no és la base de dades de producció. Ningú no ha executat una anàlisi estàtica. No hi ha integració contínua: si el Diego Alonso trenca el càlcul de multes, ningú no se n'assabenta fins que un empleat es queixa.

La lliçó següent converteix «tinc proves» en «tinc una estratègia de qualitat»: què provar a cada nivell i amb quins temps objectiu, Testcontainers amb PostgreSQL real perquè H2 menteix, cobertura amb JaCoCo i la seva interpretació honesta, proves de mutació amb PIT com l'única mesura que avalua les teves assercions, anàlisi estàtica, refactorització segura, TDD desenvolupat pas a pas amb una regla nova de BiblioTech, revisió de codi, i integració contínua amb GitHub Actions que trenca el PR quan alguna cosa falla.

Curs de Programació en Java

Mòdul 1: Introducció a Java

Mòdul 2: Flux de control

Mòdul 3: Programació orientada a objectes

Mòdul 4: Programació orientada a objectes avançada

Mòdul 5: Estructures de dades i col·leccions

Mòdul 6: Gestió d'excepcions

Mòdul 7: Entrada/sortida de fitxers

Mòdul 8: Multifil i concurrència

Mòdul 9: Xarxes

Mòdul 10: Temes avançats

Mòdul 11: Frameworks i llibreries de Java

Mòdul 12: Construcció d'aplicacions del món real

© Copyright 2026. Tots els drets reservats