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
- Qué es OpenAPI y por qué un contrato legible por máquinas
- Code-first frente a design-first
- Integrar springdoc-openapi
- Metadatos globales: el bean
OpenAPI - Documentar operaciones:
@Tagy@Operation - Parámetros y respuestas:
@Parametery@ApiResponse - Documentar los DTOs con
@Schema - Bean Validation en el esquema, automáticamente
- Documentar los errores Problem Details
- Agrupar endpoints con
GroupedOpenApi - Swagger UI: probar la API desde el navegador
- Exportar el contrato y generar un cliente
- No exponer Swagger UI en producción
- Errores Comunes y Consejos
- Ejercicios
- 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.
- 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.
- 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ásdoc-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.
- Metadatos globales: el bean
OpenAPI
OpenAPILas 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.
- Documentar operaciones:
@Tag y @Operation
@Tag y @OperationLas 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.
- Parámetros y respuestas:
@Parameter y @ApiResponse
@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.
- Documentar los DTOs con
@Schema
@SchemaLos 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
}
- 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.
- 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.
- Agrupar endpoints con
GroupedOpenApi
GroupedOpenApiCon 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.
- 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.
- 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.
- 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
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
