CicloUrbana tiene ya una API completa: trece endpoints, validación en el borde, DTOs que separan el dominio del contrato y errores uniformes en formato Problem Details. Y sin embargo, todo lo que sabemos sobre ella —qué campos acepta cada endpoint, qué devuelve, qué errores puede dar— vive en el código y en nuestra cabeza. Si mañana el equipo de la app móvil de Ribalta o el del portal de datos abiertos del ayuntamiento quisieran integrarse, tendrían que leer nuestro código o preguntarnos endpoint por endpoint. En esta lección convertimos ese conocimiento tácito en un contrato formal, generado automáticamente desde el propio código, legible por humanos en una interfaz web y por máquinas para generar clientes. Con ella se cierra el módulo 3 y la API de Ribalta queda lista para que otros la usen.

Contenido

  1. Qué es OpenAPI y por qué un contrato legible por máquinas
  2. Code-first frente a design-first
  3. Integrar springdoc-openapi
  4. Metadatos globales: el bean OpenAPI
  5. Documentar operaciones: @Tag y @Operation
  6. Parámetros y respuestas: @Parameter y @ApiResponse
  7. Documentar los DTOs con @Schema
  8. Bean Validation en el esquema, automáticamente
  9. Documentar los errores Problem Details
  10. Agrupar endpoints con GroupedOpenApi
  11. Swagger UI: probar la API desde el navegador
  12. Exportar el contrato y generar un cliente
  13. No exponer Swagger UI en producción
  14. Errores Comunes y Consejos
  15. Ejercicios

  1. Qué es OpenAPI y por qué un contrato legible por máquinas

OpenAPI es una especificación para describir APIs HTTP en un documento estructurado, en JSON o YAML. La versión actual es OpenAPI 3.1, que a diferencia de la 3.0 es totalmente compatible con JSON Schema, lo que permite describir estructuras de datos con precisión. Un fragmento del documento de CicloUrbana:

openapi: 3.1.0
info: { title: API de CicloUrbana, version: "1.0.0" }
paths:
  /api/v1/estaciones/{id}:
    get:
      tags: [Estaciones]
      summary: Obtener el detalle de una estación
      parameters:
        - { name: id, in: path, required: true,
            schema: { type: integer, format: int64, minimum: 1 } }
      responses:
        "200":
          description: Estación encontrada
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EstacionDetalleResponse" }
        "404":
          description: La estación no existe
          content:
            application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } }

Que sea legible por máquinas es lo que cambia la forma de trabajar:

Beneficio Qué permite en la práctica
Clientes generados El equipo Android genera su cliente Kotlin sin escribirlo
Documentación viva Swagger UI se actualiza en cada despliegue: nunca queda obsoleta
Pruebas de contrato Un pipeline detecta si un cambio lo rompe (módulo 8)
Simuladores El frontend trabaja contra un servidor simulado antes que el backend
Portal de desarrollador El ayuntamiento publica su API con documentación navegable
Validación automática Un gateway rechaza peticiones que no cumplen el esquema

El contraste con la alternativa —un documento de texto o una página en un wiki— es que esa documentación se desincroniza el primer día: nadie recuerda actualizarla al añadir un campo, y a los tres meses miente. Un contrato generado desde el código no puede mentir.

  1. Code-first frente a design-first

Hay dos formas de llegar al documento OpenAPI:

Aspecto Code-first Design-first
Punto de partida El código Java El fichero openapi.yaml
El contrato se... Genera desde el código Escribe a mano y genera el código
Sincronización Garantizada por construcción Requiere disciplina y verificación
Diseño de la API Emerge del código Se decide y negocia antes
Equipos en paralelo El cliente espera al backend Ambos arrancan a la vez
Riesgo típico Una API que refleja el modelo interno Divergencia entre contrato y código

El curso usa code-first con springdoc por dos razones: una pedagógica, que se ve la relación directa entre cada anotación y el documento resultante, y otra práctica, que para un equipo pequeño con un solo backend mantener a mano un openapi.yaml de mil líneas cuesta más de lo que aporta.

Ahora bien, conviene entender por qué design-first domina en organizaciones grandes: cuando cinco equipos consumen tu API, el contrato es una negociación previa y no un subproducto. Escribirlo primero permite que el equipo móvil empiece contra un simulador el mismo día que el backend empieza a implementarlo, y evita el riesgo más citado de code-first: que la API acabe siendo un reflejo del modelo interno en lugar de un diseño pensado para quien la consume. Merece la pena notar que en este módulo hemos trabajado, de hecho, design-first sin herramientas: la tabla de los trece endpoints de 03-01 se escribió antes que ningún controlador, solo que vivía en una tabla Markdown y ahora pasa a ser un artefacto formal.

  1. Integrar springdoc-openapi

springdoc-openapi inspecciona en ejecución los @RestController, sus anotaciones y sus tipos, y construye el documento OpenAPI. Una sola dependencia:

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

El sufijo importa: -ui incluye Swagger UI, mientras que springdoc-openapi-starter-webmvc-api genera solo el documento; para una aplicación reactiva sería webflux. Arrancamos y ya hay dos endpoints nuevos, sin escribir una línea:

curl -s http://localhost:8080/v3/api-docs | jq '.paths | keys'
# "/api/v1/alquileres", "/api/v1/alquileres/{id}/finalizar", "/api/v1/bicicletas",
# "/api/v1/estaciones", "/api/v1/estaciones/{id}/bicicletas", ...

Los trece endpoints están ahí, con sus parámetros y sus esquemas de respuesta deducidos de los DTOs, y en http://localhost:8080/swagger-ui.html hay una interfaz navegable. Todo el trabajo de las lecciones anteriores —tipos precisos, DTOs específicos, validación declarativa— es lo que hace que esa deducción automática sea buena: un controlador que devolviera Map<String, Object> no produciría nada útil.

La configuración en YAML:

springdoc:
  api-docs:
    path: /v3/api-docs           # ruta del documento JSON
    version: openapi_3_1         # 3.1 en lugar de la 3.0 por defecto
  swagger-ui:
    path: /swagger-ui.html
    operations-sorter: method    # ordena por verbo: GET, POST, PUT, DELETE
    tags-sorter: alpha
    display-request-duration: true
    doc-expansion: none          # arranca con todo plegado: más legible
    try-it-out-enabled: true
  show-actuator: false           # no documentar los endpoints de Actuator
  packages-to-scan: com.ciclourbana
  paths-to-match: /api/**        # solo la API, nada más

doc-expansion: none merece un comentario: con trece endpoints desplegados la página inicial es una pared de texto, y plegada se ve la estructura de la API de un vistazo.

  1. Metadatos globales: el bean OpenAPI

Las anotaciones documentan endpoints; los metadatos globales —título, versión, contacto, licencia y servidores— se declaran en un bean:

package com.ciclourbana.comun;

@Configuration
public class ConfiguracionOpenApi {

    @Bean
    OpenAPI apiCicloUrbana(@Value("${ciclourbana.version:1.0.0}") String version) {
        return new OpenAPI()
                .info(new Info()
                        .title("API de CicloUrbana")
                        .version(version)
                        .description("""
                                API pública de la red municipal de bicicletas eléctricas
                                de Ribalta. Los errores siguen el RFC 7807.""")
                        .contact(new Contact().name("Equipo de plataforma de CicloUrbana")
                                .email("[email protected]"))
                        .license(new License()
                                .name("Licencia Abierta del Ayuntamiento de Ribalta")
                                .url("https://ribalta.es/datos-abiertos/licencia")))
                .servers(List.of(
                        new Server().url("https://api.ciclourbana.ribalta.es")
                                .description("Producción"),
                        new Server().url("https://api-pre.ciclourbana.ribalta.es")
                                .description("Preproducción"),
                        new Server().url("http://localhost:8080")
                                .description("Desarrollo local")))
                .externalDocs(new ExternalDocumentation()
                        .description("Guía de integración de CicloUrbana")
                        .url("https://ciclourbana.ribalta.es/docs/integracion"));
    }
}

Tres detalles útiles. La lista de Server aparece en Swagger UI como un desplegable, de modo que quien prueba la API elige contra qué entorno lanza las peticiones sin editar URLs. La versión se inyecta con @Value y no se escribe literal, así la documentación siempre dice qué versión está desplegada (en el módulo 7 podrá venir del pom.xml vía Actuator). Y la descripción admite Markdown: es el sitio para las convenciones transversales, como el formato de errores, la paginación o la autenticación.

  1. Documentar operaciones: @Tag y @Operation

Las etiquetas agrupan los endpoints en secciones dentro de Swagger UI y se declaran a nivel de clase: @Tag(name = "Estaciones", description = "Consulta y gestión de las estaciones de anclaje de Ribalta") sobre EstacionController.

Y @Operation describe cada método:

@Operation(
    summary = "Obtener el detalle de una estación",
    operationId = "obtenerEstacionPorId",
    description = """
            Devuelve una estación con sus datos completos y la lista de bicicletas
            ancladas en ella, con los anclajes libres y si está llena.

            Los datos de disponibilidad se calculan en tiempo real y no deben
            cachearse más de 60 segundos.""")
@GetMapping("/{id:\\d+}")
public EstacionDetalleResponse obtenerPorId(@PathVariable("id") @Positive Long id) { ... }
Atributo Qué hace Consejo
summary Título de una línea en la lista Empieza por un verbo, sin punto final
description Explicación larga, admite Markdown Aquí van los matices, no en summary
operationId Identificador único de la operación Determina el nombre del método generado
deprecated Marca la operación como obsoleta Úsalo antes de retirar, nunca en vez de

El operationId es el atributo que más se descuida y más consecuencias tiene: es el nombre que tendrá el método en los clientes generados. Sin él, springdoc inventa algo como obtenerPorId_1, y ese nombre feo acaba en el código de todos los equipos cliente. Con operationId = "obtenerEstacionPorId", el cliente Kotlin del equipo Android tendrá un obtenerEstacionPorId(id) legible.

  1. Parámetros y respuestas: @Parameter y @ApiResponse

@Parameter documenta cada entrada:

@Operation(summary = "Listar estaciones", operationId = "listarEstaciones")
@GetMapping
public List<EstacionResponse> listar(
        @Parameter(description = "Filtro por nombre; coincidencia parcial",
                   example = "norte")
        @RequestParam(required = false) @Size(max = 80) String nombre,
        @Parameter(description = "Número de página, empezando en 0", example = "0")
        @RequestParam(defaultValue = "0") @Min(0) int pagina,
        @Parameter(description = "Elementos por página, máximo 100", example = "20")
        @RequestParam(defaultValue = "20") @Min(1) @Max(100) int tamanio) { ... }

Los ejemplos no son decorativos: Swagger UI los precarga en el formulario de "Try it out", así que quien prueba la API por primera vez obtiene una llamada que funciona sin inventarse valores. Y @ApiResponse describe cada respuesta posible:

@Operation(summary = "Dar de alta una estación", operationId = "crearEstacion")
@ApiResponses({
    @ApiResponse(responseCode = "201", description = "Estación creada correctamente",
        headers = @Header(name = "Location", description = "URI de la estación creada",
                          schema = @Schema(type = "string")),
        content = @Content(schema = @Schema(implementation = EstacionResponse.class))),
    @ApiResponse(responseCode = "400", description = "Datos de entrada no válidos",
        content = @Content(mediaType = "application/problem+json",
                           schema = @Schema(implementation = ProblemDetail.class))),
    @ApiResponse(responseCode = "409", description = "Ya existe una estación con ese nombre",
        content = @Content(mediaType = "application/problem+json",
                           schema = @Schema(implementation = ProblemDetail.class)))})
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<EstacionResponse> crear(@Valid @RequestBody CrearEstacionRequest p) { }

Con @ExampleObject se documentan cuerpos concretos, algo especialmente valioso cuando el mismo código de estado tiene varias causas:

@ApiResponse(responseCode = "409", description = "Conflicto con el estado actual",
    content = @Content(mediaType = "application/problem+json",
        examples = {
            @ExampleObject(name = "Nombre duplicado", value = """
                { "title": "Estación duplicada", "status": 409,
                  "detail": "Ya existe una estación llamada 'Plaza Mayor'",
                  "codigo": "ESTACION_DUPLICADA", "idExistente": 1 }"""),
            @ExampleObject(name = "Estación llena", value = """
                { "title": "Conflicto con el estado actual", "status": 409,
                  "detail": "La estación Universidad no tiene anclajes libres",
                  "codigo": "ESTACION_LLENA" }""")}))

Swagger UI muestra un desplegable con los dos ejemplos: es la forma más eficaz de explicar los códigos de error del proyecto sin escribir un documento aparte.

  1. Documentar los DTOs con @Schema

Los DTOs de 03-05 se convierten en esquemas OpenAPI, y @Schema los enriquece:

@Schema(name = "EstacionResponse", description = "Vista resumida para los listados")
public record EstacionResponse(

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

        @Schema(description = "Nombre público", example = "Plaza Mayor",
                minLength = 3, maxLength = 80) String nombre,

        @Schema(description = "Dirección postal", example = "Plaza Mayor, 1") String direccion,

        @Schema(description = "Anclajes totales", example = "24",
                minimum = "1", maximum = "60") int capacidad,

        @Schema(description = "Bicicletas disponibles ahora mismo; se calcula en "
                            + "tiempo real", example = "7",
                accessMode = Schema.AccessMode.READ_ONLY) int bicicletasDisponibles,

        @Schema(description = "Coordenadas geográficas") UbicacionResponse ubicacion) {}
Atributo Efecto
description Texto explicativo junto al campo
example Valor de ejemplo en la documentación y en "Try it out"
requiredMode REQUIRED, NOT_REQUIRED o AUTO (por defecto)
accessMode READ_ONLY (solo respuestas), WRITE_ONLY (solo peticiones)
defaultValue / deprecated Valor por defecto y marca de obsolescencia
allowableValues Valores permitidos, para enums o cadenas cerradas

accessMode = READ_ONLY es el más útil y el menos conocido: marca un campo como generado por el servidor, y los generadores de clientes lo excluyen de los objetos de petición, de modo que el cliente no puede intentar enviar bicicletasDisponibles. Es la traducción al contrato de la separación entre DTOs de petición y respuesta.

En los DTOs de petición, requiredMode y los ejemplos son lo importante:

@Schema(description = "Datos para dar de alta una estación en la red de Ribalta")
public record CrearEstacionRequest(

        @Schema(description = "Nombre público, único en toda la red",
                example = "Mercado Central", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank @Size(min = 3, max = 80) String nombre,

        @Schema(description = "Dirección postal completa", example = "C/ del Mercado, 8",
                requiredMode = Schema.RequiredMode.REQUIRED)
        @NotBlank @Size(max = 120) String direccion,

        @Schema(description = "Anclajes totales. Múltiplo de 6", example = "24",
                requiredMode = Schema.RequiredMode.REQUIRED)
        @Positive @Max(60) int capacidad,

        @Schema(description = "Latitud en el término 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) {}

Los enums se documentan solos con su lista de valores, pero merece la pena describir cada estado del dominio de Ribalta:

@Schema(description = "Estado operativo de una bicicleta en la red")
public enum EstadoBicicleta {
    @Schema(description = "Anclada y lista para alquilar") DISPONIBLE,
    @Schema(description = "Alquilada, circulando por la ciudad") EN_USO,
    @Schema(description = "Retirada temporalmente por el taller") MANTENIMIENTO,
    @Schema(description = "Retirada definitivamente de la red") RETIRADA
}

  1. Bean Validation en el esquema, automáticamente

Aquí llega la recompensa de la lección 03-04. springdoc lee las anotaciones de Bean Validation y las traduce a restricciones del esquema OpenAPI, sin hacer nada.

Anotación de 03-04 Restricción en el esquema
@NotNull, @NotBlank, @NotEmpty El campo aparece en required
@Size(min, max) minLength / maxLength (o minItems / maxItems)
@Min / @Max / @Positive minimum / maximum / exclusiveMinimum: 0
@DecimalMin / @DecimalMax minimum / maximum con decimales
@Pattern(regexp) / @Email pattern / format: email

El esquema generado para CrearEstacionRequest:

"CrearEstacionRequest": {
  "type": "object", "required": ["nombre", "direccion", "capacidad"],
  "properties": {
    "nombre": { "type": "string", "minLength": 3, "maxLength": 80,
                "description": "Nombre público, único en toda la red",
                "example": "Mercado Central" },
    "capacidad": { "type": "integer", "exclusiveMinimum": 0, "maximum": 60 },
    "latitud": { "type": "number", "minimum": -90.0, "maximum": 90.0 } } }

Ninguna de esas restricciones se escribió para la documentación: todas venían ya de las anotaciones de validación. Una única fuente de verdad que valida en ejecución y documenta a la vez, sin posibilidad de que se desincronicen.

Las restricciones propias que construimos —@MatriculaBicicleta, @MultiploDe— no las conoce springdoc, así que hay que documentarlas a mano con @Schema(pattern = "^RB-\\d{4}$", example = "RB-0142"). Es un buen argumento a favor de componer las restricciones propias sobre las estándar: si @MatriculaBicicleta se hubiera definido como una anotación compuesta que incluye @Pattern, springdoc habría deducido el pattern solo.

  1. Documentar los errores Problem Details

Repetir tres @ApiResponse de error en cada uno de los trece endpoints es exactamente el tipo de repetición que acaba desincronizándose. La solución son anotaciones compuestas propias:

package com.ciclourbana.comun.openapi;

/** Respuestas de error comunes a las operaciones de consulta. */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@ApiResponse(responseCode = "400", description = "Petición mal formada o no válida",
    content = @Content(mediaType = "application/problem+json",
                       schema = @Schema(implementation = ProblemDetail.class)))
@ApiResponse(responseCode = "404", description = "El recurso solicitado no existe",
    content = @Content(mediaType = "application/problem+json",
                       schema = @Schema(implementation = ProblemDetail.class)))
@ApiResponse(responseCode = "500", description = "Error interno del servidor",
    content = @Content(mediaType = "application/problem+json",
                       schema = @Schema(implementation = ProblemDetail.class)))
public @interface RespuestasErrorEstandar {}

// Y en el controlador, los tres errores en una palabra:
@GetMapping("/{id:\\d+}")
@Operation(summary = "Obtener el detalle de una estación", operationId = "obtenerEstacionPorId")
@RespuestasErrorEstandar
public EstacionDetalleResponse obtenerPorId(@PathVariable("id") @Positive Long id) { ... }

Como ProblemDetail es una clase de Spring, su esquema generado no incluye nuestras extensiones (codigo, traza, errores), así que lo suyo es declarar un esquema propio que las documente:

/**
 * Solo se usa para documentar: las respuestas reales las construye
 * ManejadorGlobalExcepciones con ProblemDetail. Existe para que el
 * contrato describa nuestras extensiones del RFC 7807.
 */
@Schema(name = "ErrorCicloUrbana",
        description = "Error en formato RFC 7807 con las extensiones de CicloUrbana")
public record ErrorCicloUrbanaSchema(

        @Schema(description = "URI que identifica el tipo de problema",
                example = "https://api.ciclourbana.es/errores/recurso-no-encontrado")
        String type,

        @Schema(description = "Resumen estable del tipo", example = "Recurso no encontrado")
        String title,

        @Schema(description = "Código de estado HTTP", example = "404") int status,

        @Schema(description = "Explicación de esta ocurrencia concreta",
                example = "No se ha encontrado estación con identificador 999") String detail,

        @Schema(description = "Ruta que provocó el error",
                example = "/api/v1/estaciones/999") String instance,

        @Schema(description = "Código de error estable de CicloUrbana. Úsalo en tu "
                            + "lógica en lugar del texto de 'detail'",
                example = "RECURSO_NO_ENCONTRADO") String codigo,

        @Schema(description = "Traza; inclúyela al contactar con soporte",
                example = "a3f5c9e1") String traza,

        @Schema(description = "Errores por campo, solo en las respuestas 400")
        Map<String, List<String>> errores) {}

El campo codigo documentado con "úsalo en tu lógica en lugar del texto" es exactamente la clase de indicación que evita que un cliente compare cadenas y se rompa cuando traduzcamos un mensaje.

  1. Agrupar endpoints con GroupedOpenApi

Con la API creciendo, un solo documento con todo mezclado se vuelve difícil de navegar. GroupedOpenApi produce varios documentos desde la misma aplicación:

@Bean GroupedOpenApi grupoPublico() {
    return GroupedOpenApi.builder().group("publico")
            .displayName("API pública de Ribalta")
            .pathsToMatch("/api/v1/estaciones/**", "/api/v1/bicicletas/**").build();
}

@Bean GroupedOpenApi grupoAlquileres() {
    return GroupedOpenApi.builder().group("alquileres")
            .displayName("Alquileres (requiere autenticación)")
            .pathsToMatch("/api/v1/alquileres/**").build();
}

@Bean GroupedOpenApi grupoInterno() {
    return GroupedOpenApi.builder().group("interno")
            .displayName("Panel de operarios")
            .pathsToMatch("/api/v1/interno/**").build();
}

Cada grupo tiene su propio documento en /v3/api-docs/publico, /v3/api-docs/alquileres y /v3/api-docs/interno, y Swagger UI muestra un desplegable para cambiar entre ellos. Los tres usos habituales: separar audiencias, publicando solo el grupo público en el portal del ayuntamiento; separar versiones, con un grupo /api/v1/** y otro /api/v2/**; y generar clientes distintos, uno por grupo, de modo que la app ciudadana no arrastre las operaciones del panel de operarios.

  1. Swagger UI: probar la API desde el navegador

En http://localhost:8080/swagger-ui.html aparece la API completa, agrupada por las etiquetas del apartado 5, y cada operación se despliega mostrando su descripción, sus parámetros con ejemplos, el esquema del cuerpo y todas las respuestas posibles. El botón "Try it out" convierte la documentación en un cliente HTTP: rellena el formulario con los example que declaramos, permite editarlos y ejecuta la petición real contra el servidor elegido en el desplegable, mostrando la respuesta, sus cabeceras, el código de estado y —muy útil para compartir— el comando curl equivalente.

Vale la pena entender qué lo hace útil de verdad, porque no es la herramienta en sí: es que los ejemplos estén bien puestos. Un endpoint documentado sin example obliga a inventarse los valores, y probar POST /api/v1/estaciones sin saber que la capacidad debe ser múltiplo de 6 acaba en un 400. Con los ejemplos, la primera llamada de cualquier desarrollador nuevo funciona. Un detalle para el módulo 5: cuando añadamos JWT, Swagger UI mostrará un botón "Authorize" si declaramos el esquema de seguridad, y a partir de ahí incluirá el token en todas las llamadas.

  1. Exportar el contrato y generar un cliente

El documento se puede volcar a un fichero durante el build con el plugin de springdoc, que levanta la aplicación, descarga el JSON y la para:

<plugin>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-maven-plugin</artifactId>
    <version>1.4</version>
    <executions><execution>
        <id>generar-contrato</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>

Requiere el spring-boot-maven-plugin con los objetivos start y stop enlazados a pre-integration-test y post-integration-test. El resultado, target/openapi.json, es el artefacto que se publica: se sube al portal del ayuntamiento, se compara con la versión anterior para detectar cambios incompatibles (módulo 8) y alimenta la generación de clientes con el 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.cliente.api</apiPackage>
            <modelPackage>com.ciclourbana.cliente.modelo</modelPackage>
            <configOptions>
                <useJakartaEe>true</useJakartaEe>
                <serializationLibrary>jackson</serializationLibrary>
            </configOptions>
        </configuration>
    </execution></executions>
</plugin>

El generador soporta más de cincuenta lenguajes: java, kotlin, typescript-axios, python, swift5, go. El equipo de la app Android de Ribalta genera su cliente Kotlin desde el mismo openapi.json, y el del portal web su cliente TypeScript.

El código generado tiene esta forma:

// Generado automáticamente. No editar.
public class EstacionesApi {
    public EstacionDetalleResponse obtenerEstacionPorId(Long id) { ... }
    public List<EstacionResponse> listarEstaciones(String nombre, Integer pagina,
                                                   Integer tamanio) { ... }
    public EstacionResponse crearEstacion(CrearEstacionRequest crearEstacionRequest) { ... }
}

Aquí se ve por qué insistimos en el operationId: esos nombres de método salen directamente de él. Y los tipos EstacionDetalleResponse y CrearEstacionRequest se generan a partir de nuestros esquemas, con las mismas restricciones de validación, de modo que el cliente valida antes de enviar porque el contrato las lleva dentro. Un beneficio adicional que se aprecia con el tiempo: si retiramos un campo de un DTO, el cliente generado deja de compilar en la siguiente actualización; un cambio incompatible que en una API sin contrato se descubre en producción, aquí se descubre al compilar.

  1. No exponer Swagger UI en producción

Swagger UI es una herramienta de desarrollo. En producción es un mapa detallado de tu superficie de ataque: todos los endpoints, todos los parámetros, todos los formatos esperados, y un formulario para probarlos.

Escenario Recomendación
API pública documentada a propósito Publicar el documento en un portal, no Swagger UI
API interna de empresa Swagger UI solo tras la VPN o con autenticación
API con datos personales Ni documento ni interfaz accesibles públicamente

La forma más simple de apagarlo es springdoc.api-docs.enabled: false y springdoc.swagger-ui.enabled: false.

Y el enfoque correcto, con perfiles: dejarlo activo en el application.yml base —que usan desarrollo y las pruebas— y desactivarlo en application-prod.yml, que lo sobrescribe al arrancar con --spring.profiles.active=prod. Los perfiles se estudian en 07-02, donde retomaremos esta configuración.

Si el ayuntamiento de Ribalta quiere publicar la documentación de su API abierta, la vía correcta es exportar el openapi.json en el build (apartado 12) y servirlo desde un portal estático independiente, que no expone la aplicación real ni permite lanzar peticiones contra ella. Y un último riesgo: springdoc genera la documentación inspeccionando todos los controladores del contexto, así que un controlador interno olvidado acaba publicado; springdoc.paths-to-match: /api/** es una defensa barata contra ese descuido.

Errores Comunes y Consejos

Documentar solo el camino feliz. Un endpoint con solo el 200 documentado obliga al cliente a descubrir los errores en producción. Los @ApiResponse de error son la mitad del valor del contrato.

Olvidar operationId. Los métodos de los clientes generados salen con nombres automáticos y feos, y cambian solos al reordenar el código.

Dejar Swagger UI accesible en producción. Es una descripción completa de tu superficie de ataque.

Poner ejemplos que no funcionan. Un example con capacidad 25 cuando la restricción exige múltiplos de 6 hace que la primera prueba de todo el mundo falle. Copia los ejemplos de peticiones reales.

Documentar la entidad en lugar del DTO. Si @Schema se anota sobre Estacion y el endpoint devuelve EstacionResponse, la documentación describe algo que la API no devuelve.

Repetir los mismos @ApiResponse en cada método. Trece copias que se desincronizan: usa anotaciones compuestas. Y no confíes en que springdoc adivine las restricciones propias: @MatriculaBicicleta no aparece en el esquema, así que añade pattern a mano o compón tu restricción sobre @Pattern.

Consejo: lee el /v3/api-docs como si fueras un cliente externo. Si un campo no se entiende sin abrir el código, falta una description; es la revisión más eficaz y cuesta diez minutos. Y versiona el openapi.json en el repositorio: comparar el generado con el anterior en cada build convierte cualquier cambio incompatible en un fallo de integración continua, antes de que llegue a los clientes.

Ejercicios

Ejercicio 1: Documentar el ciclo completo de un alquiler

AlquilerController no tiene ninguna anotación de OpenAPI. Documenta las tres operaciones —iniciar, finalizar y consultar— con sus etiquetas, resúmenes, operationId, parámetros, ejemplos y todas las respuestas de error posibles, incluidas las de negocio de 03-06.

Ejercicio 2: Anotación compuesta para las operaciones de escritura

Las operaciones que escriben (POST, PUT, PATCH, DELETE) comparten un conjunto de errores distinto al de las de lectura: además de 400 y 500, pueden dar 409 y 422. Crea @RespuestasErrorEscritura y aplícala, evitando duplicar la definición de los errores comunes.

Ejercicio 3: Publicar el contrato y detectar cambios incompatibles

Configura el build para exportar openapi.json y añade una comprobación que falle cuando un cambio rompa el contrato. Explica qué cambios debe detectar y cuáles debe permitir.

Soluciones

Solución 1.

@RestController
@RequestMapping(path = "/api/v1/alquileres", produces = MediaType.APPLICATION_JSON_VALUE)
@Tag(name = "Alquileres",
     description = "Ciclo de vida de un alquiler: inicio, consulta y finalización")
public class AlquilerController {

    @Operation(summary = "Iniciar un alquiler", operationId = "iniciarAlquiler",
        description = """
                Desancla una bicicleta y abre un alquiler a nombre del usuario.
                La bicicleta debe estar `DISPONIBLE` y con batería igual o superior
                al umbral de la red (20% por defecto). El importe no se conoce hasta
                finalizar el alquiler.""")
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "Alquiler iniciado",
            headers = @Header(name = "Location", description = "URI del alquiler creado",
                              schema = @Schema(type = "string"))),
        @ApiResponse(responseCode = "404", description = "El usuario o la bicicleta no existen",
            content = @Content(mediaType = "application/problem+json",
                schema = @Schema(implementation = ErrorCicloUrbanaSchema.class))),
        @ApiResponse(responseCode = "422", description = "La bicicleta no puede alquilarse",
            content = @Content(mediaType = "application/problem+json",
                schema = @Schema(implementation = ErrorCicloUrbanaSchema.class),
                examples = {
                    @ExampleObject(name = "En mantenimiento", value = """
                        { "status": 422, "codigo": "BICICLETA_NO_DISPONIBLE",
                          "detail": "La bicicleta RB-0143 no está disponible" }"""),
                    @ExampleObject(name = "Batería insuficiente", value = """
                        { "status": 422, "codigo": "BATERIA_INSUFICIENTE",
                          "detail": "La bicicleta RB-0151 tiene un 12% de batería" }""")}))
    })
    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<AlquilerResponse> iniciar(
            @Valid @RequestBody IniciarAlquilerRequest peticion) { ... }

    @Operation(summary = "Finalizar un alquiler", operationId = "finalizarAlquiler",
        description = """
                Ancla la bicicleta en la estación de destino, cierra el alquiler y
                calcula el importe según la tarifa del tipo de usuario. Operación
                **no idempotente**: finalizar dos veces devuelve `422`.""")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "Alquiler finalizado con su importe"),
        @ApiResponse(responseCode = "404", description = "El alquiler o la estación no existen"),
        @ApiResponse(responseCode = "409", description = "La estación de destino está llena"),
        @ApiResponse(responseCode = "422", description = "El alquiler ya estaba finalizado")})
    @PostMapping(path = "/{id:\\d+}/finalizar", consumes = MediaType.APPLICATION_JSON_VALUE)
    public AlquilerResponse finalizar(
            @Parameter(description = "Identificador del alquiler en curso", example = "7")
            @PathVariable("id") @Positive Long id,
            @Valid @RequestBody FinalizarAlquilerRequest peticion) { ... }
}

Dos avisos prácticos. El primero, una colisión de nombres: @RequestBody existe tanto en OpenAPI (io.swagger.v3.oas.annotations.parameters) como en Spring, así que si necesitas la de OpenAPI tendrás que cualificar una de las dos; lo más limpio es poner los ejemplos en el @Schema del DTO, como hace el código anterior. El segundo, y es lo importante del ejercicio: la documentación explica la semántica, no solo los tipos —que finalizar no es idempotente, que el importe no se conoce hasta el final, que la batería mínima depende de la configuración—. Nada de eso se deduce de las firmas Java, y es justo lo que un equipo cliente necesita saber.

Solución 2.

Se aprovecha que las anotaciones compuestas se pueden anidar:

/** Errores que puede dar cualquier operación de la API. */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@ApiResponse(responseCode = "400", description = "Petición mal formada o no válida",
    content = @Content(mediaType = "application/problem+json",
        schema = @Schema(implementation = ErrorCicloUrbanaSchema.class)))
@ApiResponse(responseCode = "500", description = "Error interno del servidor",
    content = @Content(mediaType = "application/problem+json",
        schema = @Schema(implementation = ErrorCicloUrbanaSchema.class)))
public @interface RespuestasErrorComunes {}

/** Errores comunes + los específicos de las operaciones de escritura. */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@RespuestasErrorComunes                       // hereda 400 y 500
@ApiResponse(responseCode = "409", description = "Conflicto con el estado actual",
    content = @Content(mediaType = "application/problem+json",
        schema = @Schema(implementation = ErrorCicloUrbanaSchema.class)))
@ApiResponse(responseCode = "422", description = "Regla de negocio violada",
    content = @Content(mediaType = "application/problem+json",
        schema = @Schema(implementation = ErrorCicloUrbanaSchema.class)))
public @interface RespuestasErrorEscritura {}

// Y en el controlador: 400, 409, 422 y 500 de golpe
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Dar de alta una estación", operationId = "crearEstacion")
@ApiResponse(responseCode = "201", description = "Estación creada")
@RespuestasErrorEscritura
public ResponseEntity<EstacionResponse> crear(@Valid @RequestBody CrearEstacionRequest p) { }

La composición es lo que hace mantenible esta solución: el día que el formato de error cambie —al añadir un campo soporte al esquema, por ejemplo—, se toca RespuestasErrorComunes y el cambio llega a los trece endpoints; si hubiéramos copiado los @ApiResponse, habría trece sitios que actualizar y alguno quedaría atrás. Una advertencia práctica: springdoc lee anotaciones anidadas, pero conviene verificarlo tras crearlas, y la comprobación es directa:

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

Solución 3.

La exportación es la del apartado 12, enlazando además el arranque y la parada de la aplicación al ciclo de integración con el spring-boot-maven-plugin (start en pre-integration-test, stop en post-integration-test). Después, la comprobación de compatibilidad con openapi-diff frente al contrato publicado:

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

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

--fail-on-incompatible devuelve un código de salida distinto de cero si detecta un cambio que rompe a los clientes, lo que hace fallar la integración continua (módulo 8).

Cambio ¿Rompe? Por qué
Eliminar un endpoint u operación Sí Los clientes reciben 404 o 405
Eliminar o renombrar un campo de respuesta Sí El cliente obtiene null o falla
Añadir un campo obligatorio a una petición Sí Las peticiones existentes dan 400
Cambiar el tipo de un campo Sí Fallo de deserialización en el cliente
Endurecer una validación (maxLength 80 → 40) Sí Peticiones válidas empiezan a fallar
Eliminar un valor de un enum de petición Sí El cliente lo envía y recibe 400
Cambiar el código de estado de éxito Sí El cliente comprueba 201 y recibe 200
Añadir un endpoint No Nadie lo llamaba
Añadir un campo a una respuesta No Los clientes lo ignoran por configuración
Añadir un parámetro opcional No Las peticiones existentes siguen igual
Relajar una validación (maxLength 80 → 120) No Todo lo que valía sigue valiendo
Añadir un valor a un enum de respuesta Matices Un switch exhaustivo puede fallar
Cambiar una descripción o un ejemplo No No afecta al comportamiento

Esta tabla es, punto por punto, la de cambios compatibles e incompatibles de la lección 03-01. La diferencia es que allí era una guía que había que recordar y aquí es una comprobación automática que se ejecuta en cada build. Ese es el salto de valor real de tener un contrato formal: convierte una regla de disciplina en una barrera técnica.

El proceso completo al publicar: se genera target/openapi.json, se compara con el publicado y, si el cambio es compatible, se copia sobre openapi-publicado.json y se sube al portal del ayuntamiento. Si es incompatible, la integración continua falla y el equipo decide conscientemente si toca negociar una /api/v2.

Conclusión

El módulo 3 se cierra con la API de CicloUrbana descrita en un contrato formal que se genera solo. Sabes qué es OpenAPI 3.1 y qué desbloquea un contrato legible por máquinas: clientes generados, documentación que no se desincroniza, pruebas de contrato, simuladores y portales de desarrollador. Has comparado code-first y design-first con criterio para elegir según el tamaño del equipo. Integraste springdoc-openapi con una sola dependencia y viste que los trece endpoints aparecían documentados sin escribir nada, porque el trabajo estaba hecho antes: tipos precisos, DTOs específicos y validación declarativa. Configuraste los metadatos globales con el bean OpenAPI, documentaste operaciones con @Tag y @Operation —cuidando el operationId, que acaba siendo el nombre de método en los clientes de otros equipos—, los parámetros con @Parameter y ejemplos que hacen que la primera llamada funcione, y las respuestas con @ApiResponse y @ExampleObject. Enriqueciste los DTOs con @Schema, incluido accessMode = READ_ONLY para los campos que el servidor calcula, y comprobaste que las restricciones de Bean Validation de 03-04 aparecen solas en el esquema: una única fuente de verdad que valida y documenta a la vez. Documentaste los Problem Details de 03-06 con anotaciones compuestas que evitan trece copias, agrupaste con GroupedOpenApi, exportaste el openapi.json en el build y generaste un cliente Java con el openapi-generator-maven-plugin. Y sabes por qué Swagger UI no debe quedar expuesto en producción.

Mira atrás al módulo entero. Empezó con un único endpoint, GET /api/v1/estaciones, que devolvía una lista de objetos de dominio en crudo. Termina con trece endpoints diseñados sobre las restricciones de REST y el nivel 2 de Richardson, con los verbos y códigos de estado correctos, la cabecera Location en las creaciones y ETag para el control de concurrencia. Con validación declarativa en el borde, restricciones propias del dominio de Ribalta e internacionalización en los tres idiomas. Con una jerarquía de DTOs que separa limpiamente lo que CicloUrbana sabe de lo que promete, y un mapeo que permite renombrar en el dominio sin romper a ningún cliente. Con errores uniformes en RFC 7807, un manejador global, códigos estables y trazas correlacionables con el log. Y con un contrato OpenAPI publicable desde el que otros equipos generan su cliente sin preguntarnos nada. La API que verán los ciudadanos de Ribalta está completa.

Le falta, eso sí, lo más elemental: cuando la aplicación se reinicia, todo desaparece. Las cuatro estaciones vuelven a cargarse desde CargadorEstacionesDemo, las bicicletas dadas de alta se esfuman y los alquileres del día se pierden. Todo vive en un ConcurrentHashMap que existe mientras existe el proceso. Hemos podido llegar hasta aquí gracias a haber escondido esa provisionalidad tras la interfaz EstacionRepositorio desde la lección 02-01, una decisión que ahora se cobra su recompensa.

El módulo 4, Acceso a Datos con Spring Boot, la sustituye por persistencia real. Veremos qué son JPA, Hibernate y Spring Data y cómo se relacionan; configuraremos fuentes de datos y un pool de conexiones; convertiremos Estacion, Bicicleta y Alquiler en entidades JPA con sus identificadores y su versión optimista —la que sustituirá al ShallowEtagHeaderFilter de 03-03—; modelaremos las relaciones entre ellas junto con los problemas que ya anticipamos, la carga perezosa y el N+1; usaremos repositorios de Spring Data y sus métodos de consulta derivados; entenderemos las transacciones y por qué @Transactional en el servicio cambia las reglas del juego; y gestionaremos la evolución del esquema con Flyway. Las cuatro estaciones de Ribalta están a punto de sobrevivir a un reinicio.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados