CicloUrbana ja té una API completa: tretze endpoints, validació a la vora, DTO que separen el domini del contracte i errors uniformes en format Problem Details. I tanmateix, tot el que sabem sobre ella —quins camps accepta cada endpoint, què retorna, quins errors pot donar— viu al codi i al nostre cap. Si demà l'equip de l'app mòbil de Ribalta o el del portal de dades obertes de l'ajuntament es volguessin integrar, haurien de llegir el nostre codi o preguntar-nos endpoint per endpoint. En aquesta lliçó convertim aquest coneixement tàcit en un contracte formal, generat automàticament des del codi mateix, llegible per humans en una interfície web i per màquines per generar clients. Amb ella es tanca el mòdul 3 i l'API de Ribalta queda llesta perquè altres la facin servir.

Contingut

  1. Què és OpenAPI i per què un contracte llegible per màquines
  2. Code-first davant de design-first
  3. Integrar springdoc-openapi
  4. Metadades globals: el bean OpenAPI
  5. Documentar operacions: @Tag i @Operation
  6. Paràmetres i respostes: @Parameter i @ApiResponse
  7. Documentar els DTO amb @Schema
  8. Bean Validation a l'esquema, automàticament
  9. Documentar els errors Problem Details
  10. Agrupar endpoints amb GroupedOpenApi
  11. Swagger UI: provar l'API des del navegador
  12. Exportar el contracte i generar un client
  13. No exposar Swagger UI en producció
  14. Errors Comuns i Consells
  15. Exercicis

  1. Què és OpenAPI i per què un contracte llegible per màquines

OpenAPI és una especificació per descriure API HTTP en un document estructurat, en JSON o YAML. La versió actual és OpenAPI 3.1, que a diferència de la 3.0 és totalment compatible amb JSON Schema, cosa que permet descriure estructures de dades amb precisió. Un fragment del document de CicloUrbana:

openapi: 3.1.0
info: { title: API de CicloUrbana, version: "1.0.0" }
paths:
  /api/v1/estacions/{id}:
    get:
      tags: [Estacions]
      summary: Obtenir el detall d'una estació
      parameters:
        - { name: id, in: path, required: true,
            schema: { type: integer, format: int64, minimum: 1 } }
      responses:
        "200":
          description: Estació trobada
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EstacioDetallResponse" }
        "404":
          description: L'estació no existeix
          content:
            application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } }

Que sigui llegible per màquines és el que canvia la manera de treballar:

Benefici Què permet a la pràctica
Clients generats L'equip Android genera el seu client Kotlin sense escriure'l
Documentació viva Swagger UI s'actualitza a cada desplegament: mai no queda obsoleta
Proves de contracte Un pipeline detecta si un canvi el trenca (mòdul 8)
Simuladors El frontend treballa contra un servidor simulat abans que el backend
Portal de desenvolupador L'ajuntament publica la seva API amb documentació navegable
Validació automàtica Un gateway rebutja peticions que no compleixen l'esquema

El contrast amb l'alternativa —un document de text o una pàgina en un wiki— és que aquesta documentació es desincronitza el primer dia: ningú no recorda actualitzar-la en afegir un camp, i al cap de tres mesos menteix. Un contracte generat des del codi no pot mentir.

  1. Code-first davant de design-first

Hi ha dues maneres d'arribar al document OpenAPI:

Aspecte Code-first Design-first
Punt de partida El codi Java El fitxer openapi.yaml
El contracte es... Genera des del codi Escriu a mà i genera el codi
Sincronització Garantida per construcció Requereix disciplina i verificació
Disseny de l'API Emergeix del codi Es decideix i es negocia abans
Equips en paral·lel El client espera el backend Tots dos arrenquen alhora
Risc típic Una API que reflecteix el model intern Divergència entre contracte i codi

El curs fa servir code-first amb springdoc per dues raons: una de pedagògica, que es veu la relació directa entre cada anotació i el document resultant, i una altra de pràctica, que per a un equip petit amb un sol backend mantenir a mà un openapi.yaml de mil línies costa més del que aporta.

Ara bé, convé entendre per què design-first domina en organitzacions grans: quan cinc equips consumeixen la teva API, el contracte és una negociació prèvia i no un subproducte. Escriure'l primer permet que l'equip mòbil comenci contra un simulador el mateix dia que el backend comença a implementar-lo, i evita el risc més citat de code-first: que l'API acabi sent un reflex del model intern en lloc d'un disseny pensat per a qui la consumeix. Val la pena notar que en aquest mòdul hem treballat, de fet, design-first sense eines: la taula dels tretze endpoints de 03-01 es va escriure abans que cap controlador, només que vivia en una taula Markdown i ara passa a ser un artefacte formal.

  1. Integrar springdoc-openapi

springdoc-openapi inspecciona en execució els @RestController, les seves anotacions i els seus tipus, i construeix el document OpenAPI. Una sola dependència:

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

El sufix importa: -ui inclou Swagger UI, mentre que springdoc-openapi-starter-webmvc-api genera només el document; per a una aplicació reactiva seria webflux. Arrenquem i ja hi ha dos endpoints nous, sense escriure ni una línia:

curl -s http://localhost:8080/v3/api-docs | jq '.paths | keys'
# "/api/v1/lloguers", "/api/v1/lloguers/{id}/finalitzar", "/api/v1/bicicletes",
# "/api/v1/estacions", "/api/v1/estacions/{id}/bicicletes", ...

Els tretze endpoints hi són, amb els seus paràmetres i els seus esquemes de resposta deduïts dels DTO, i a http://localhost:8080/swagger-ui.html hi ha una interfície navegable. Tota la feina de les lliçons anteriors —tipus precisos, DTO específics, validació declarativa— és el que fa que aquesta deducció automàtica sigui bona: un controlador que retornés Map<String, Object> no produiria res útil.

La configuració en YAML:

springdoc:
  api-docs:
    path: /v3/api-docs           # ruta del document JSON
    version: openapi_3_1         # 3.1 en lloc de la 3.0 per defecte
  swagger-ui:
    path: /swagger-ui.html
    operations-sorter: method    # ordena per verb: GET, POST, PUT, DELETE
    tags-sorter: alpha
    display-request-duration: true
    doc-expansion: none          # arrenca amb tot plegat: més llegible
    try-it-out-enabled: true
  show-actuator: false           # no documentar els endpoints d'Actuator
  packages-to-scan: com.ciclourbana
  paths-to-match: /api/**        # només l'API, res més

doc-expansion: none mereix un comentari: amb tretze endpoints desplegats la pàgina inicial és una paret de text, i plegada es veu l'estructura de l'API d'un cop d'ull.

  1. Metadades globals: el bean OpenAPI

Les anotacions documenten endpoints; les metadades globals —títol, versió, contacte, llicència i servidors— es declaren en un bean:

package com.ciclourbana.comu;

@Configuration
public class ConfiguracioOpenApi {

    @Bean
    OpenAPI apiCicloUrbana(@Value("${ciclourbana.versio:1.0.0}") String versio) {
        return new OpenAPI()
                .info(new Info()
                        .title("API de CicloUrbana")
                        .version(versio)
                        .description("""
                                API pública de la xarxa municipal de bicicletes elèctriques
                                de Ribalta. Els errors segueixen l'RFC 7807.""")
                        .contact(new Contact().name("Equip de plataforma de CicloUrbana")
                                .email("[email protected]"))
                        .license(new License()
                                .name("Llicència Oberta de l'Ajuntament de Ribalta")
                                .url("https://ribalta.example/dades-obertes/llicencia")))
                .servers(List.of(
                        new Server().url("https://api.ciclourbana.ribalta.example")
                                .description("Producció"),
                        new Server().url("https://api-pre.ciclourbana.ribalta.example")
                                .description("Preproducció"),
                        new Server().url("http://localhost:8080")
                                .description("Desenvolupament local")))
                .externalDocs(new ExternalDocumentation()
                        .description("Guia d'integració de CicloUrbana")
                        .url("https://ciclourbana.ribalta.example/docs/integracio"));
    }
}

Tres detalls útils. La llista de Server apareix a Swagger UI com un desplegable, de manera que qui prova l'API tria contra quin entorn llança les peticions sense editar URL. La versió s'injecta amb @Value i no s'escriu literal, així la documentació sempre diu quina versió està desplegada (al mòdul 7 podrà venir del pom.xml via Actuator). I la descripció admet Markdown: és el lloc per a les convencions transversals, com el format d'errors, la paginació o l'autenticació.

  1. Documentar operacions: @Tag i @Operation

Les etiquetes agrupen els endpoints en seccions dins de Swagger UI i es declaren a nivell de classe: @Tag(name = "Estacions", description = "Consulta i gestió de les estacions d'ancoratge de Ribalta") sobre EstacioController.

I @Operation descriu cada mètode:

@Operation(
    summary = "Obtenir el detall d'una estació",
    operationId = "obtenirEstacioPerId",
    description = """
            Retorna una estació amb les seves dades completes i la llista de bicicletes
            ancorades, amb els ancoratges lliures i si és plena.

            Les dades de disponibilitat es calculen en temps real i no s'han de
            desar a la memòria cau més de 60 segons.""")
@GetMapping("/{id:\\d+}")
public EstacioDetallResponse obtenirPerId(@PathVariable("id") @Positive Long id) { ... }
Atribut Què fa Consell
summary Títol d'una línia a la llista Comença per un verb, sense punt final
description Explicació llarga, admet Markdown Aquí van els matisos, no a summary
operationId Identificador únic de l'operació Determina el nom del mètode generat
deprecated Marca l'operació com a obsoleta Fes-lo servir abans de retirar, mai en lloc de

L'operationId és l'atribut que més es descuida i més conseqüències té: és el nom que tindrà el mètode als clients generats. Sense ell, springdoc inventa alguna cosa com obtenirPerId_1, i aquest nom lleig acaba al codi de tots els equips client. Amb operationId = "obtenirEstacioPerId", el client Kotlin de l'equip Android tindrà un obtenirEstacioPerId(id) llegible.

  1. Paràmetres i respostes: @Parameter i @ApiResponse

@Parameter documenta cada entrada:

@Operation(summary = "Llistar estacions", operationId = "llistarEstacions")
@GetMapping
public List<EstacioResponse> llistar(
        @Parameter(description = "Filtre per nom; coincidència parcial",
                   example = "nord")
        @RequestParam(required = false) @Size(max = 80) String nom,
        @Parameter(description = "Número de pàgina, començant en 0", example = "0")
        @RequestParam(defaultValue = "0") @Min(0) int pagina,
        @Parameter(description = "Elements per pàgina, màxim 100", example = "20")
        @RequestParam(defaultValue = "20") @Min(1) @Max(100) int mida) { ... }

Els exemples no són decoratius: Swagger UI els precarrega al formulari de "Try it out", així que qui prova l'API per primera vegada obté una crida que funciona sense inventar-se valors. I @ApiResponse descriu cada resposta possible:

@Operation(summary = "Donar d'alta una estació", operationId = "crearEstacio")
@ApiResponses({
    @ApiResponse(responseCode = "201", description = "Estació creada correctament",
        headers = @Header(name = "Location", description = "URI de l'estació creada",
                          schema = @Schema(type = "string")),
        content = @Content(schema = @Schema(implementation = EstacioResponse.class))),
    @ApiResponse(responseCode = "400", description = "Dades d'entrada no vàlides",
        content = @Content(mediaType = "application/problem+json",
                           schema = @Schema(implementation = ProblemDetail.class))),
    @ApiResponse(responseCode = "409", description = "Ja existeix una estació amb aquest nom",
        content = @Content(mediaType = "application/problem+json",
                           schema = @Schema(implementation = ProblemDetail.class)))})
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<EstacioResponse> crear(@Valid @RequestBody CrearEstacioRequest p) { }

Amb @ExampleObject es documenten cossos concrets, cosa especialment valuosa quan el mateix codi d'estat té diverses causes:

@ApiResponse(responseCode = "409", description = "Conflicte amb l'estat actual",
    content = @Content(mediaType = "application/problem+json",
        examples = {
            @ExampleObject(name = "Nom duplicat", value = """
                { "title": "Estació duplicada", "status": 409,
                  "detail": "Ja existeix una estació anomenada 'Plaça Major'",
                  "codi": "ESTACIO_DUPLICADA", "idExistent": 1 }"""),
            @ExampleObject(name = "Estació plena", value = """
                { "title": "Conflicte amb l'estat actual", "status": 409,
                  "detail": "L'estació Universitat no té ancoratges lliures",
                  "codi": "ESTACIO_PLENA" }""")}))

Swagger UI mostra un desplegable amb els dos exemples: és la manera més eficaç d'explicar els codis d'error del projecte sense escriure un document a part.

  1. Documentar els DTO amb @Schema

Els DTO de 03-05 es converteixen en esquemes OpenAPI, i @Schema els enriqueix:

@Schema(name = "EstacioResponse", description = "Vista resumida per als llistats")
public record EstacioResponse(

        @Schema(description = "Identificador únic", example = "1",
                requiredMode = Schema.RequiredMode.REQUIRED) Long id,

        @Schema(description = "Nom públic", example = "Plaça Major",
                minLength = 3, maxLength = 80) String nom,

        @Schema(description = "Adreça postal", example = "Plaça Major, 1") String adreca,

        @Schema(description = "Ancoratges totals", example = "24",
                minimum = "1", maximum = "60") int capacitat,

        @Schema(description = "Bicicletes disponibles ara mateix; es calcula en "
                            + "temps real", example = "7",
                accessMode = Schema.AccessMode.READ_ONLY) int bicicletesDisponibles,

        @Schema(description = "Coordenades geogràfiques") UbicacioResponse ubicacio) {}
Atribut Efecte
description Text explicatiu al costat del camp
example Valor d'exemple a la documentació i a "Try it out"
requiredMode REQUIRED, NOT_REQUIRED o AUTO (per defecte)
accessMode READ_ONLY (només respostes), WRITE_ONLY (només peticions)
defaultValue / deprecated Valor per defecte i marca d'obsolescència
allowableValues Valors permesos, per a enums o cadenes tancades

accessMode = READ_ONLY és el més útil i el menys conegut: marca un camp com a generat pel servidor, i els generadors de clients l'exclouen dels objectes de petició, de manera que el client no pot intentar enviar bicicletesDisponibles. És la traducció al contracte de la separació entre DTO de petició i de resposta.

Als DTO de petició, requiredMode i els exemples són l'important:

@Schema(description = "Dades per donar d'alta una estació a la xarxa de Ribalta")
public record CrearEstacioRequest(

        @Schema(description = "Nom públic, únic a tota la xarxa",
                example = "Mercat Central", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank @Size(min = 3, max = 80) String nom,

        @Schema(description = "Adreça postal completa", example = "Carrer del Mercat, 8",
                requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank @Size(max = 120) String adreca,

        @Schema(description = "Ancoratges totals. Múltiple de 6", example = "24",
                requiredMode = Schema.RequiredMode.REQUIRED)
        @Positive @Max(60) int capacitat,

        @Schema(description = "Latitud dins del terme de Ribalta", example = "41.3902")
        @DecimalMin("-90.0") @DecimalMax("90.0") double latitud,

        @Schema(description = "Longitud", example = "2.1655")
        @DecimalMin("-180.0") @DecimalMax("180.0") double longitud) {}

Els enums es documenten sols amb la seva llista de valors, però val la pena descriure cada estat del domini de Ribalta:

@Schema(description = "Estat operatiu d'una bicicleta a la xarxa")
public enum EstatBicicleta {
    @Schema(description = "Ancorada i llesta per llogar") DISPONIBLE,
    @Schema(description = "Llogada, circulant per la ciutat") EN_US,
    @Schema(description = "Retirada temporalment pel taller") MANTENIMENT,
    @Schema(description = "Retirada definitivament de la xarxa") RETIRADA
}

  1. Bean Validation a l'esquema, automàticament

Aquí arriba la recompensa de la lliçó 03-04. springdoc llegeix les anotacions de Bean Validation i les tradueix a restriccions de l'esquema OpenAPI, sense fer res.

Anotació de 03-04 Restricció a l'esquema
@NotNull, @NotBlank, @NotEmpty El camp apareix a required
@Size(min, max) minLength / maxLength (o minItems / maxItems)
@Min / @Max / @Positive minimum / maximum / exclusiveMinimum: 0
@DecimalMin / @DecimalMax minimum / maximum amb decimals
@Pattern(regexp) / @Email pattern / format: email

L'esquema generat per a CrearEstacioRequest:

"CrearEstacioRequest": {
  "type": "object", "required": ["nom", "adreca", "capacitat"],
  "properties": {
    "nom": { "type": "string", "minLength": 3, "maxLength": 80,
             "description": "Nom públic, únic a tota la xarxa",
             "example": "Mercat Central" },
    "capacitat": { "type": "integer", "exclusiveMinimum": 0, "maximum": 60 },
    "latitud": { "type": "number", "minimum": -90.0, "maximum": 90.0 } } }

Cap d'aquestes restriccions no es va escriure per a la documentació: totes venien ja de les anotacions de validació. Una única font de veritat que valida en execució i documenta alhora, sense possibilitat que es desincronitzin.

Les restriccions pròpies que vam construir —@MatriculaBicicleta, @MultipleDe— springdoc no les coneix, així que cal documentar-les a mà amb @Schema(pattern = "^RB-\\d{4}$", example = "RB-0142"). És un bon argument a favor de compondre les restriccions pròpies sobre les estàndard: si @MatriculaBicicleta s'hagués definit com una anotació composta que inclou @Pattern, springdoc n'hauria deduït el pattern sol.

  1. Documentar els errors Problem Details

Repetir tres @ApiResponse d'error a cadascun dels tretze endpoints és exactament el tipus de repetició que acaba desincronitzant-se. La solució són anotacions compostes pròpies:

package com.ciclourbana.comu.openapi;

/** Respostes d'error comunes a les operacions de consulta. */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@ApiResponse(responseCode = "400", description = "Petició mal formada o no vàlida",
    content = @Content(mediaType = "application/problem+json",
                       schema = @Schema(implementation = ProblemDetail.class)))
@ApiResponse(responseCode = "404", description = "El recurs sol·licitat no existeix",
    content = @Content(mediaType = "application/problem+json",
                       schema = @Schema(implementation = ProblemDetail.class)))
@ApiResponse(responseCode = "500", description = "Error intern del servidor",
    content = @Content(mediaType = "application/problem+json",
                       schema = @Schema(implementation = ProblemDetail.class)))
public @interface RespostesErrorEstandard {}

// I al controlador, els tres errors en una paraula:
@GetMapping("/{id:\\d+}")
@Operation(summary = "Obtenir el detall d'una estació", operationId = "obtenirEstacioPerId")
@RespostesErrorEstandard
public EstacioDetallResponse obtenirPerId(@PathVariable("id") @Positive Long id) { ... }

Com que ProblemDetail és una classe de Spring, el seu esquema generat no inclou les nostres extensions (codi, rastre, errors), així que el més adequat és declarar un esquema propi que les documenti:

/**
 * Només es fa servir per documentar: les respostes reals les construeix
 * GestorGlobalExcepcions amb ProblemDetail. Existeix perquè el
 * contracte descrigui les nostres extensions de l'RFC 7807.
 */
@Schema(name = "ErrorCicloUrbana",
        description = "Error en format RFC 7807 amb les extensions de CicloUrbana")
public record ErrorCicloUrbanaSchema(

        @Schema(description = "URI que identifica el tipus de problema",
                example = "https://api.ciclourbana.example/errors/recurs-no-trobat")
        String type,

        @Schema(description = "Resum estable del tipus", example = "Recurs no trobat")
        String title,

        @Schema(description = "Codi d'estat HTTP", example = "404") int status,

        @Schema(description = "Explicació d'aquesta ocurrència concreta",
                example = "No s'ha trobat estació amb identificador 999") String detail,

        @Schema(description = "Ruta que ha provocat l'error",
                example = "/api/v1/estacions/999") String instance,

        @Schema(description = "Codi d'error estable de CicloUrbana. Fes-lo servir a la teva "
                            + "lògica en lloc del text de 'detail'",
                example = "RECURS_NO_TROBAT") String codi,

        @Schema(description = "Rastre; inclou-lo en contactar amb suport",
                example = "a3f5c9e1") String rastre,

        @Schema(description = "Errors per camp, només a les respostes 400")
        Map<String, List<String>> errors) {}

El camp codi documentat amb "fes-lo servir a la teva lògica en lloc del text" és exactament la mena d'indicació que evita que un client compari cadenes i es trenqui quan traduïm un missatge.

  1. Agrupar endpoints amb GroupedOpenApi

Amb l'API creixent, un sol document amb tot barrejat es torna difícil de navegar. GroupedOpenApi produeix diversos documents des de la mateixa aplicació:

@Bean GroupedOpenApi grupPublic() {
    return GroupedOpenApi.builder().group("public")
            .displayName("API pública de Ribalta")
            .pathsToMatch("/api/v1/estacions/**", "/api/v1/bicicletes/**").build();
}

@Bean GroupedOpenApi grupLloguers() {
    return GroupedOpenApi.builder().group("lloguers")
            .displayName("Lloguers (requereix autenticació)")
            .pathsToMatch("/api/v1/lloguers/**").build();
}

@Bean GroupedOpenApi grupIntern() {
    return GroupedOpenApi.builder().group("intern")
            .displayName("Tauler d'operaris")
            .pathsToMatch("/api/v1/intern/**").build();
}

Cada grup té el seu propi document a /v3/api-docs/public, /v3/api-docs/lloguers i /v3/api-docs/intern, i Swagger UI mostra un desplegable per canviar entre ells. Els tres usos habituals: separar audiències, publicant només el grup públic al portal de l'ajuntament; separar versions, amb un grup /api/v1/** i un altre /api/v2/**; i generar clients diferents, un per grup, de manera que l'app ciutadana no arrossegui les operacions del tauler d'operaris.

  1. Swagger UI: provar l'API des del navegador

A http://localhost:8080/swagger-ui.html apareix l'API completa, agrupada per les etiquetes de l'apartat 5, i cada operació es desplega mostrant la seva descripció, els seus paràmetres amb exemples, l'esquema del cos i totes les respostes possibles. El botó "Try it out" converteix la documentació en un client HTTP: omple el formulari amb els example que vam declarar, permet editar-los i executa la petició real contra el servidor triat al desplegable, mostrant la resposta, les seves capçaleres, el codi d'estat i —molt útil per compartir— l'ordre curl equivalent.

Val la pena entendre què el fa útil de veritat, perquè no és l'eina en si: és que els exemples estiguin ben posats. Un endpoint documentat sense example obliga a inventar-se els valors, i provar POST /api/v1/estacions sense saber que la capacitat ha de ser múltiple de 6 acaba en un 400. Amb els exemples, la primera crida de qualsevol desenvolupador nou funciona. Un detall per al mòdul 5: quan hi afegim JWT, Swagger UI mostrarà un botó "Authorize" si declarem l'esquema de seguretat, i a partir d'aquí inclourà el token a totes les crides.

  1. Exportar el contracte i generar un client

El document es pot abocar a un fitxer durant el build amb el plugin de springdoc, que aixeca l'aplicació, descarrega el JSON i la para:

<plugin>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-maven-plugin</artifactId>
    <version>1.4</version>
    <executions><execution>
        <id>generar-contracte</id>
        <phase>integration-test</phase>
        <goals><goal>generate</goal></goals>
    </execution></executions>
    <configuration>
        <apiDocsUrl>http://localhost:8080/v3/api-docs</apiDocsUrl>
        <outputFileName>openapi.json</outputFileName>
        <outputDir>${project.build.directory}</outputDir>
    </configuration>
</plugin>

Requereix el spring-boot-maven-plugin amb els objectius start i stop enllaçats a pre-integration-test i post-integration-test. El resultat, target/openapi.json, és l'artefacte que es publica: es puja al portal de l'ajuntament, es compara amb la versió anterior per detectar canvis incompatibles (mòdul 8) i alimenta la generació de clients amb l'openapi-generator-maven-plugin:

<plugin>
    <groupId>org.openapitools</groupId>
    <artifactId>openapi-generator-maven-plugin</artifactId>
    <version>7.10.0</version>
    <executions><execution>
        <goals><goal>generate</goal></goals>
        <configuration>
            <inputSpec>${project.build.directory}/openapi.json</inputSpec>
            <generatorName>java</generatorName>
            <library>resttemplate</library>
            <apiPackage>com.ciclourbana.client.api</apiPackage>
            <modelPackage>com.ciclourbana.client.model</modelPackage>
            <configOptions>
                <useJakartaEe>true</useJakartaEe>
                <serializationLibrary>jackson</serializationLibrary>
            </configOptions>
        </configuration>
    </execution></executions>
</plugin>

El generador suporta més de cinquanta llenguatges: java, kotlin, typescript-axios, python, swift5, go. L'equip de l'app Android de Ribalta genera el seu client Kotlin des del mateix openapi.json, i el del portal web el seu client TypeScript.

El codi generat té aquesta forma:

// Generat automàticament. No editar.
public class EstacionsApi {
    public EstacioDetallResponse obtenirEstacioPerId(Long id) { ... }
    public List<EstacioResponse> llistarEstacions(String nom, Integer pagina,
                                                  Integer mida) { ... }
    public EstacioResponse crearEstacio(CrearEstacioRequest crearEstacioRequest) { ... }
}

Aquí es veu per què insistim en l'operationId: aquests noms de mètode surten directament d'ell. I els tipus EstacioDetallResponse i CrearEstacioRequest es generen a partir dels nostres esquemes, amb les mateixes restriccions de validació, de manera que el client valida abans d'enviar perquè el contracte les duu a dins. Un benefici addicional que s'aprecia amb el temps: si retirem un camp d'un DTO, el client generat deixa de compilar a l'actualització següent; un canvi incompatible que en una API sense contracte es descobreix en producció, aquí es descobreix en compilar.

  1. No exposar Swagger UI en producció

Swagger UI és una eina de desenvolupament. En producció és un mapa detallat de la teva superfície d'atac: tots els endpoints, tots els paràmetres, tots els formats esperats, i un formulari per provar-los.

Escenari Recomanació
API pública documentada expressament Publicar el document en un portal, no Swagger UI
API interna d'empresa Swagger UI només darrere de la VPN o amb autenticació
API amb dades personals Ni document ni interfície accessibles públicament

La manera més simple d'apagar-lo és springdoc.api-docs.enabled: false i springdoc.swagger-ui.enabled: false.

I l'enfocament correcte, amb perfils: deixar-lo actiu a l'application.yml base —que fan servir desenvolupament i les proves— i desactivar-lo a application-prod.yml, que el sobreescriu en arrencar amb --spring.profiles.active=prod. Els perfils s'estudien a 07-02, on reprendrem aquesta configuració.

Si l'ajuntament de Ribalta vol publicar la documentació de la seva API oberta, la via correcta és exportar l'openapi.json al build (apartat 12) i servir-lo des d'un portal estàtic independent, que no exposa l'aplicació real ni permet llançar peticions contra ella. I un últim risc: springdoc genera la documentació inspeccionant tots els controladors del context, així que un controlador intern oblidat acaba publicat; springdoc.paths-to-match: /api/** és una defensa barata contra aquest descuit.

Errors Comuns i Consells

Documentar només el camí feliç. Un endpoint amb només el 200 documentat obliga el client a descobrir els errors en producció. Els @ApiResponse d'error són la meitat del valor del contracte.

Oblidar operationId. Els mètodes dels clients generats surten amb noms automàtics i lletjos, i canvien sols en reordenar el codi.

Deixar Swagger UI accessible en producció. És una descripció completa de la teva superfície d'atac.

Posar exemples que no funcionen. Un example amb capacitat 25 quan la restricció exigeix múltiples de 6 fa que la primera prova de tothom falli. Copia els exemples de peticions reals.

Documentar l'entitat en lloc del DTO. Si @Schema s'anota sobre Estacio i l'endpoint retorna EstacioResponse, la documentació descriu alguna cosa que l'API no retorna.

Repetir els mateixos @ApiResponse a cada mètode. Tretze còpies que es desincronitzen: fes servir anotacions compostes. I no confiïs que springdoc endevini les restriccions pròpies: @MatriculaBicicleta no apareix a l'esquema, així que afegeix-hi pattern a mà o compon la teva restricció sobre @Pattern.

Consell: llegeix el /v3/api-docs com si fossis un client extern. Si un camp no s'entén sense obrir el codi, hi falta una description; és la revisió més eficaç i costa deu minuts. I versiona l'openapi.json al repositori: comparar el generat amb l'anterior a cada build converteix qualsevol canvi incompatible en una fallada d'integració contínua, abans que arribi als clients.

Exercicis

Exercici 1: Documentar el cicle complet d'un lloguer

LloguerController no té cap anotació d'OpenAPI. Documenta les tres operacions —iniciar, finalitzar i consultar— amb les seves etiquetes, resums, operationId, paràmetres, exemples i totes les respostes d'error possibles, incloses les de negoci de 03-06.

Exercici 2: Anotació composta per a les operacions d'escriptura

Les operacions que escriuen (POST, PUT, PATCH, DELETE) comparteixen un conjunt d'errors diferent del de les de lectura: a més de 400 i 500, poden donar 409 i 422. Crea @RespostesErrorEscriptura i aplica-la, evitant duplicar la definició dels errors comuns.

Exercici 3: Publicar el contracte i detectar canvis incompatibles

Configura el build per exportar openapi.json i afegeix una comprovació que falli quan un canvi trenqui el contracte. Explica quins canvis ha de detectar i quins ha de permetre.

Solucions

Solució 1.

@RestController
@RequestMapping(path = "/api/v1/lloguers", produces = MediaType.APPLICATION_JSON_VALUE)
@Tag(name = "Lloguers",
     description = "Cicle de vida d'un lloguer: inici, consulta i finalització")
public class LloguerController {

    @Operation(summary = "Iniciar un lloguer", operationId = "iniciarLloguer",
        description = """
                Desancora una bicicleta i obre un lloguer a nom de l'usuari.
                La bicicleta ha d'estar `DISPONIBLE` i amb bateria igual o superior
                al llindar de la xarxa (20% per defecte). L'import no es coneix fins
                a finalitzar el lloguer.""")
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "Lloguer iniciat",
            headers = @Header(name = "Location", description = "URI del lloguer creat",
                              schema = @Schema(type = "string"))),
        @ApiResponse(responseCode = "404", description = "L'usuari o la bicicleta no existeixen",
            content = @Content(mediaType = "application/problem+json",
                schema = @Schema(implementation = ErrorCicloUrbanaSchema.class))),
        @ApiResponse(responseCode = "422", description = "La bicicleta no es pot llogar",
            content = @Content(mediaType = "application/problem+json",
                schema = @Schema(implementation = ErrorCicloUrbanaSchema.class),
                examples = {
                    @ExampleObject(name = "En manteniment", value = """
                        { "status": 422, "codi": "BICICLETA_NO_DISPONIBLE",
                          "detail": "La bicicleta RB-0143 no està disponible" }"""),
                    @ExampleObject(name = "Bateria insuficient", value = """
                        { "status": 422, "codi": "BATERIA_INSUFICIENT",
                          "detail": "La bicicleta RB-0151 té un 12% de bateria" }""")}))
    })
    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<LloguerResponse> iniciar(
            @Valid @RequestBody IniciarLloguerRequest peticio) { ... }

    @Operation(summary = "Finalitzar un lloguer", operationId = "finalitzarLloguer",
        description = """
                Ancora la bicicleta a l'estació de destí, tanca el lloguer i
                calcula l'import segons la tarifa del tipus d'usuari. Operació
                **no idempotent**: finalitzar dues vegades retorna `422`.""")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "Lloguer finalitzat amb el seu import"),
        @ApiResponse(responseCode = "404", description = "El lloguer o l'estació no existeixen"),
        @ApiResponse(responseCode = "409", description = "L'estació de destí és plena"),
        @ApiResponse(responseCode = "422", description = "El lloguer ja estava finalitzat")})
    @PostMapping(path = "/{id:\\d+}/finalitzar", consumes = MediaType.APPLICATION_JSON_VALUE)
    public LloguerResponse finalitzar(
            @Parameter(description = "Identificador del lloguer en curs", example = "7")
            @PathVariable("id") @Positive Long id,
            @Valid @RequestBody FinalitzarLloguerRequest peticio) { ... }
}

Dos avisos pràctics. El primer, una col·lisió de noms: @RequestBody existeix tant a OpenAPI (io.swagger.v3.oas.annotations.parameters) com a Spring, així que si necessites la d'OpenAPI n'hauràs de qualificar una de les dues; el més net és posar els exemples al @Schema del DTO, com fa el codi anterior. El segon, i és l'important de l'exercici: la documentació explica la semàntica, no només els tipus —que finalitzar no és idempotent, que l'import no es coneix fins al final, que la bateria mínima depèn de la configuració—. Res d'això no es dedueix de les signatures Java, i és just el que un equip client necessita saber.

Solució 2.

S'aprofita que les anotacions compostes es poden imbricar:

/** Errors que pot donar qualsevol operació de l'API. */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@ApiResponse(responseCode = "400", description = "Petició mal formada o no vàlida",
    content = @Content(mediaType = "application/problem+json",
        schema = @Schema(implementation = ErrorCicloUrbanaSchema.class)))
@ApiResponse(responseCode = "500", description = "Error intern del servidor",
    content = @Content(mediaType = "application/problem+json",
        schema = @Schema(implementation = ErrorCicloUrbanaSchema.class)))
public @interface RespostesErrorComunes {}

/** Errors comuns + els específics de les operacions d'escriptura. */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@RespostesErrorComunes                        // hereta 400 i 500
@ApiResponse(responseCode = "409", description = "Conflicte amb l'estat actual",
    content = @Content(mediaType = "application/problem+json",
        schema = @Schema(implementation = ErrorCicloUrbanaSchema.class)))
@ApiResponse(responseCode = "422", description = "Regla de negoci violada",
    content = @Content(mediaType = "application/problem+json",
        schema = @Schema(implementation = ErrorCicloUrbanaSchema.class)))
public @interface RespostesErrorEscriptura {}

// I al controlador: 400, 409, 422 i 500 de cop
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Donar d'alta una estació", operationId = "crearEstacio")
@ApiResponse(responseCode = "201", description = "Estació creada")
@RespostesErrorEscriptura
public ResponseEntity<EstacioResponse> crear(@Valid @RequestBody CrearEstacioRequest p) { }

La composició és el que fa mantenible aquesta solució: el dia que el format d'error canviï —en afegir un camp suport a l'esquema, per exemple—, es toca RespostesErrorComunes i el canvi arriba als tretze endpoints; si haguéssim copiat els @ApiResponse, hi hauria tretze llocs per actualitzar i algun quedaria endarrere. Un advertiment pràctic: springdoc llegeix anotacions imbricades, però convé verificar-ho després de crear-les, i la comprovació és directa:

curl -s http://localhost:8080/v3/api-docs \
  | jq '.paths."/api/v1/estacions".post.responses | keys'
# ["201", "400", "409", "422", "500"]

Solució 3.

L'exportació és la de l'apartat 12, enllaçant a més l'arrencada i l'aturada de l'aplicació al cicle d'integració amb el spring-boot-maven-plugin (start a pre-integration-test, stop a post-integration-test). Després, la comprovació de compatibilitat amb openapi-diff davant del contracte publicat:

#!/usr/bin/env bash
# scripts/verificar-contracte.sh
set -euo pipefail

docker run --rm -v "$PWD:/repo" openapitools/openapi-diff:latest \
    /repo/src/main/resources/openapi/openapi-publicat.json \
    /repo/target/openapi.json --fail-on-incompatible

--fail-on-incompatible retorna un codi de sortida diferent de zero si detecta un canvi que trenca els clients, cosa que fa fallar la integració contínua (mòdul 8).

Canvi Trenca? Per què
Eliminar un endpoint o operació Sí Els clients reben 404 o 405
Eliminar o reanomenar un camp de resposta Sí El client obté null o falla
Afegir un camp obligatori a una petició Sí Les peticions existents donen 400
Canviar el tipus d'un camp Sí Fallada de deserialització al client
Endurir una validació (maxLength 80 → 40) Sí Peticions vàlides comencen a fallar
Eliminar un valor d'un enum de petició Sí El client l'envia i rep 400
Canviar el codi d'estat d'èxit Sí El client comprova 201 i rep 200
Afegir un endpoint No Ningú no el cridava
Afegir un camp a una resposta No Els clients l'ignoren per configuració
Afegir un paràmetre opcional No Les peticions existents continuen igual
Relaxar una validació (maxLength 80 → 120) No Tot el que valia continua valent
Afegir un valor a un enum de resposta Matisos Un switch exhaustiu pot fallar
Canviar una descripció o un exemple No No afecta el comportament

Aquesta taula és, punt per punt, la de canvis compatibles i incompatibles de la lliçó 03-01. La diferència és que allà era una guia que calia recordar i aquí és una comprovació automàtica que s'executa a cada build. Aquest és el salt de valor real de tenir un contracte formal: converteix una regla de disciplina en una barrera tècnica.

El procés complet en publicar: es genera target/openapi.json, es compara amb el publicat i, si el canvi és compatible, es copia sobre openapi-publicat.json i es puja al portal de l'ajuntament. Si és incompatible, la integració contínua falla i l'equip decideix conscientment si toca negociar una /api/v2.

Conclusió

El mòdul 3 es tanca amb l'API de CicloUrbana descrita en un contracte formal que es genera sol. Saps què és OpenAPI 3.1 i què desbloqueja un contracte llegible per màquines: clients generats, documentació que no es desincronitza, proves de contracte, simuladors i portals de desenvolupador. Has comparat code-first i design-first amb criteri per triar segons la mida de l'equip. Vas integrar springdoc-openapi amb una sola dependència i vas veure que els tretze endpoints apareixien documentats sense escriure res, perquè la feina estava feta abans: tipus precisos, DTO específics i validació declarativa. Vas configurar les metadades globals amb el bean OpenAPI, vas documentar operacions amb @Tag i @Operation —tenint cura de l'operationId, que acaba sent el nom de mètode als clients d'altres equips—, els paràmetres amb @Parameter i exemples que fan que la primera crida funcioni, i les respostes amb @ApiResponse i @ExampleObject. Vas enriquir els DTO amb @Schema, inclòs accessMode = READ_ONLY per als camps que el servidor calcula, i vas comprovar que les restriccions de Bean Validation de 03-04 apareixen soles a l'esquema: una única font de veritat que valida i documenta alhora. Vas documentar els Problem Details de 03-06 amb anotacions compostes que eviten tretze còpies, vas agrupar amb GroupedOpenApi, vas exportar l'openapi.json al build i vas generar un client Java amb l'openapi-generator-maven-plugin. I saps per què Swagger UI no ha de quedar exposat en producció.

Mira enrere al mòdul sencer. Va començar amb un únic endpoint, GET /api/v1/estacions, que retornava una llista d'objectes de domini en cru. Acaba amb tretze endpoints dissenyats sobre les restriccions de REST i el nivell 2 de Richardson, amb els verbs i codis d'estat correctes, la capçalera Location a les creacions i ETag per al control de concurrència. Amb validació declarativa a la vora, restriccions pròpies del domini de Ribalta i internacionalització en els tres idiomes. Amb una jerarquia de DTO que separa netament el que CicloUrbana sap del que promet, i un mapatge que permet reanomenar al domini sense trencar cap client. Amb errors uniformes en RFC 7807, un gestor global, codis estables i rastres correlacionables amb el log. I amb un contracte OpenAPI publicable des del qual altres equips generen el seu client sense preguntar-nos res. L'API que veuran els ciutadans de Ribalta està completa.

Li falta, això sí, el més elemental: quan l'aplicació es reinicia, tot desapareix. Les quatre estacions es tornen a carregar des de CarregadorEstacionsDemo, les bicicletes donades d'alta s'esfumen i els lloguers del dia es perden. Tot viu en un ConcurrentHashMap que existeix mentre existeix el procés. Hem pogut arribar fins aquí gràcies a haver amagat aquesta provisionalitat darrere de la interfície EstacioRepositori des de la lliçó 02-01, una decisió que ara es cobra la seva recompensa.

El mòdul 4, Accés a Dades amb Spring Boot, la substitueix per persistència real. Veurem què són JPA, Hibernate i Spring Data i com es relacionen; configurarem fonts de dades i un pool de connexions; convertirem Estacio, Bicicleta i Lloguer en entitats JPA amb els seus identificadors i la seva versió optimista —la que substituirà el ShallowEtagHeaderFilter de 03-03—; modelarem les relacions entre elles juntament amb els problemes que ja hem anticipat, la càrrega mandrosa i l'N+1; farem servir repositoris de Spring Data i els seus mètodes de consulta derivats; entendrem les transaccions i per què @Transactional al servei canvia les regles del joc; i gestionarem l'evolució de l'esquema amb Flyway. Les quatre estacions de Ribalta estan a punt de sobreviure a un reinici.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

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

Mòdul 3: Construint serveis web RESTful

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

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

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

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats