Spring Boot API Documentation With OpenAPI — Generating, Hosting, and Keeping It Accurate
by Eric Hanson, Backend Developer at Clean Systems Consulting
springdoc-openapi — the standard library
springdoc-openapi generates an OpenAPI 3 specification by scanning your Spring MVC controllers at runtime. Add the dependency and the spec appears at /v3/api-docs with Swagger UI at /swagger-ui.html:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
springdoc:
api-docs:
path: /v3/api-docs
enabled: true
swagger-ui:
path: /swagger-ui.html
enabled: true
operations-sorter: method
tags-sorter: alpha
display-request-duration: true
show-actuator: false # don't include actuator endpoints in API docs
packages-to-scan: com.example.api # limit scanning to API controllers
With no annotations, springdoc infers reasonable defaults from method signatures, parameter types, and return types. @RequestBody @Valid CreateOrderRequest produces a required request body with validation constraints. ResponseEntity<OrderResponse> produces the response schema. @PathVariable Long id produces a required path parameter.
Global API metadata
Configure the OpenAPI metadata — title, version, contact, servers — in a @Bean:
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("Order Service API")
.description("Manages order creation, fulfillment, and tracking")
.version("v1.4.2")
.contact(new Contact()
.name("Platform Team")
.email("platform@example.com")
.url("https://developers.example.com"))
.license(new License()
.name("Proprietary")
.url("https://example.com/terms")))
.servers(List.of(
new Server().url("https://api.example.com").description("Production"),
new Server().url("https://api.staging.example.com").description("Staging"),
new Server().url("http://localhost:8080").description("Local development")
))
.components(new Components()
.addSecuritySchemes("bearerAuth", new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("JWT Bearer token from the authorization server")));
}
}
Apply the security scheme globally (all endpoints require authentication) or per-controller:
// Global — all endpoints require bearerAuth
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
// ...
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"));
}
// Per-controller — only this controller requires authentication
@RestController
@SecurityRequirement(name = "bearerAuth")
public class OrderController { ... }
Annotation-driven spec enrichment
springdoc infers a lot from method signatures, but the inferred spec is often incomplete. Annotations fill the gaps.
@Operation — describe what an endpoint does:
@GetMapping("/{id}")
@Operation(
summary = "Retrieve an order by ID",
description = "Returns the full order details including line items and shipping information. "
+ "Requires the caller to own the order or have ADMIN role.",
responses = {
@ApiResponse(responseCode = "200", description = "Order found",
content = @Content(schema = @Schema(implementation = OrderResponse.class))),
@ApiResponse(responseCode = "404", description = "Order not found",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "403", description = "Not authorized to view this order",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
}
)
public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) { ... }
@Parameter — describe individual parameters:
@GetMapping
public Page<OrderResponse> listOrders(
@Parameter(description = "Filter by order status", example = "PENDING")
@RequestParam(required = false) OrderStatus status,
@Parameter(description = "Filter orders created after this date (ISO 8601)",
example = "2026-01-01T00:00:00Z")
@RequestParam(required = false) Instant createdAfter,
@Parameter(hidden = true) // don't document internal parameters
@RequestHeader(value = "X-Internal-Caller", required = false) String caller
) { ... }
@Schema — enrich request and response models:
@Schema(description = "Request to create a new order")
public record CreateOrderRequest(
@Schema(description = "Line items to include in the order",
minItems = 1, maxItems = 100)
@NotEmpty @Size(max = 100) List<@Valid LineItemRequest> items,
@Schema(description = "Payment method ID from the payment service",
example = "pm_1234567890abcdef")
@NotBlank String paymentMethodId,
@Schema(description = "Optional discount coupon code",
example = "SUMMER20", nullable = true)
String couponCode
) {}
@Tag — group endpoints by feature area:
@RestController
@RequestMapping("/api/v1/orders")
@Tag(name = "Orders", description = "Order management operations")
public class OrderController { ... }
@RestController
@RequestMapping("/api/v1/payments")
@Tag(name = "Payments", description = "Payment processing and history")
public class PaymentController { ... }
Keeping documentation accurate — the drift problem
Manually written API documentation drifts from the implementation. springdoc generates from code, which helps, but there are still ways the spec can become inaccurate:
Missing response codes. springdoc infers the success response (200/201/204) but doesn't know about all the error responses your exception handlers produce. Document them explicitly with @ApiResponse or accept that error responses aren't in the spec.
@ControllerAdvice exception mapping. Error response shapes come from @ExceptionHandler methods in @ControllerAdvice. Document them at the global level:
@RestControllerAdvice
@Hidden // exclude from per-controller docs — documented at OpenAPI level
public class GlobalExceptionHandler {
// handled error responses documented in OpenApiConfig
}
Or document the error schema in the @Operation annotation on each endpoint.
Validation constraints not reflected. Bean Validation annotations (@NotBlank, @Size, @Min) are partially reflected in the schema — springdoc reads them. But custom validators and cross-field constraints aren't. Document complex validation rules in @Schema(description = "...").
Contract testing — enforcing accuracy
The most reliable way to keep docs accurate is to test against the spec:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OpenApiContractTest {
@LocalServerPort int port;
@Autowired TestRestTemplate restTemplate;
@Test
void openApiSpec_isValid() throws Exception {
// Fetch the generated spec
ResponseEntity<String> response = restTemplate.getForEntity(
"/v3/api-docs", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// Validate the spec is valid OpenAPI 3
OpenAPIV3Parser parser = new OpenAPIV3Parser();
ParseResult result = parser.readContents(response.getBody(), null, null);
assertThat(result.getMessages())
.as("OpenAPI spec validation errors")
.isEmpty();
}
@Test
void createOrder_responseMatchesSpec() throws Exception {
// Use swagger-request-validator to validate responses against the spec
// This catches when the actual response doesn't match the documented schema
}
}
swagger-request-validator from Atlassian validates actual HTTP requests and responses against an OpenAPI spec:
<dependency>
<groupId>com.atlassian.oai</groupId>
<artifactId>swagger-request-validator-spring-webmvc</artifactId>
<version>2.40.0</version>
<scope>test</scope>
</dependency>
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ApiSpecComplianceTest {
private OpenApiValidationFilter validationFilter;
@BeforeEach
void setUp() {
validationFilter = new OpenApiValidationFilter(
"http://localhost:" + port + "/v3/api-docs");
}
@Test
@WithMockUser(roles = "USER")
void createOrder_responseCompilesWithSpec() throws Exception {
MockMvc mockMvc = MockMvcBuilders
.webAppContextSetup(context)
.addFilters(validationFilter) // validates request and response against spec
.build();
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"items": [{"productId": "p-1", "quantity": 2}],
"paymentMethodId": "pm-visa"}"""))
.andExpect(status().isCreated());
// If the response doesn't match the spec, the validation filter throws an error
}
}
This test fails if the actual API response doesn't match the OpenAPI spec — catching documentation drift at test time rather than when an integrator reports incorrect docs.
Generating a static spec at build time
Runtime spec generation is useful for development. For CI/CD pipelines, publishing to developer portals, and versioning the spec alongside code, generate a static JSON file at build time:
<plugin>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-maven-plugin</artifactId>
<version>1.4</version>
<executions>
<execution>
<id>integration-test</id>
<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>
The plugin starts the application, fetches the spec, and writes it to a file. This file can be:
- Committed to the repository (the spec is versioned alongside the code)
- Published to a developer portal (Stoplight, Redoc, Confluence)
- Used as input for client SDK generation (
openapi-generator) - Checked in CI against the previous version to detect breaking changes
Breaking change detection:
# In CI — compare current spec against the released spec
npx openapi-diff \
https://api.example.com/v3/api-docs \
./target/openapi.json
# Exit code 1 if breaking changes detected
openapi-diff flags: removed endpoints, removed fields from request/response schemas, changed required/optional status, changed types. These are all breaking changes for existing integrators.
Securing the documentation endpoint
In production, the Swagger UI and spec endpoint should not be publicly accessible — they reveal the full API surface:
springdoc:
swagger-ui:
enabled: ${SWAGGER_UI_ENABLED:false} # disable by default in production
api-docs:
enabled: ${API_DOCS_ENABLED:false} # disable by default in production
// In SecurityConfig — protect docs behind authentication in staging
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**")
.hasRole("INTERNAL")
// ...
)
Or expose the docs at a different path that's behind a VPN or internal network only — not on the public API port.
For public APIs that want to expose documentation, expose a read-only spec without the interactive Swagger UI (which could be used to probe the API):
springdoc:
swagger-ui:
enabled: false # disable interactive UI
api-docs:
enabled: true # expose the spec for tooling
path: /openapi.json # well-known path
Clients and tooling consume /openapi.json; the interactive test interface isn't publicly available.
The documentation workflow that works
The discipline that keeps API documentation accurate: code first, spec generated, spec tested. Write the controller with proper annotations. springdoc generates the spec. Contract tests verify the spec matches the implementation. The spec is committed at build time. Breaking changes are detected in CI before merging.
Manually written YAML specs that are maintained alongside code always drift — the code is updated, the spec is forgotten. Generated specs drift only when annotations lag the implementation — which contract tests catch immediately.