API Documentation with OpenAPI and Springdoc

Good API documentation is non-negotiable. Springdoc reads your Spring MVC code and auto-generates interactive OpenAPI 3.1 documentation — no separate doc files to maintain.

Setup

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

Start the app and visit:

  • Swagger UI: http://localhost:8080/swagger-ui.html
  • OpenAPI JSON: http://localhost:8080/v3/api-docs
  • OpenAPI YAML: http://localhost:8080/v3/api-docs.yaml

Springdoc scans your @RestController classes and generates the spec automatically. Zero configuration needed for basic docs.

Configuring the API Info

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI openAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("Order Service API")
                .description("REST API for managing orders in the DevOpsMonk e-commerce platform")
                .version("v1.0.0")
                .contact(new Contact()
                    .name("DevOpsMonk Team")
                    .email("api@devopsmonk.com")
                    .url("https://devopsmonk.com"))
                .license(new License()
                    .name("MIT")
                    .url("https://opensource.org/licenses/MIT")))
            .externalDocs(new ExternalDocumentation()
                .description("Full documentation")
                .url("https://docs.devopsmonk.com"))
            .servers(List.of(
                new Server().url("https://api.devopsmonk.com").description("Production"),
                new Server().url("https://staging-api.devopsmonk.com").description("Staging"),
                new Server().url("http://localhost:8080").description("Local")
            ));
    }
}

Documenting Controllers with @Operation

@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
@Tag(name = "Orders", description = "Order management operations")
public class OrderController {

    private final OrderService orderService;

    @GetMapping("/{id}")
    @Operation(
        summary = "Get order by ID",
        description = "Retrieves a single order by its UUID. Returns 404 if not found.",
        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 = ProblemDetail.class))),
            @ApiResponse(responseCode = "401", description = "Unauthorized")
        }
    )
    public ResponseEntity<OrderResponse> getOrder(
            @PathVariable @Parameter(description = "Order UUID", example = "550e8400-e29b-41d4-a716-446655440000")
            UUID id) {

        return orderService.findById(id)
            .map(o -> ResponseEntity.ok(OrderResponse.from(o)))
            .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    @Operation(
        summary = "Create a new order",
        description = "Creates an order. Returns 201 with a Location header pointing to the new order."
    )
    @ApiResponse(responseCode = "201", description = "Order created successfully")
    @ApiResponse(responseCode = "400", description = "Validation failed",
        content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
    public ResponseEntity<OrderResponse> createOrder(
            @RequestBody @Valid
            @io.swagger.v3.oas.annotations.parameters.RequestBody(
                description = "Order creation request",
                required = true,
                content = @Content(
                    schema = @Schema(implementation = CreateOrderRequest.class),
                    examples = @ExampleObject(
                        name = "Simple order",
                        value = """
                            {
                              "customerId": "550e8400-e29b-41d4-a716-446655440000",
                              "items": [
                                {"productId": "prod-123", "quantity": 2}
                              ],
                              "shippingAddress": {
                                "line1": "123 Main St",
                                "city": "London",
                                "country": "GB",
                                "postalCode": "EC1A1BB"
                              }
                            }
                            """
                    )
                )
            )
            CreateOrderRequest request) {

        Order order = orderService.create(request);
        return ResponseEntity
            .created(URI.create("/api/orders/" + order.id()))
            .body(OrderResponse.from(order));
    }
}

Documenting DTO Schemas with @Schema

@Schema(description = "Request body for creating a new order")
public record CreateOrderRequest(

    @Schema(description = "UUID of the customer placing the order",
            example = "550e8400-e29b-41d4-a716-446655440000",
            requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull UUID customerId,

    @Schema(description = "List of items to include in the order. Must have at least one item.",
            minLength = 1,
            requiredMode = Schema.RequiredMode.REQUIRED)
    @NotEmpty List<@Valid OrderItemRequest> items,

    @Schema(description = "Promotional code to apply (optional)",
            example = "SPRING20",
            maxLength = 20)
    String promoCode

) {}

@Schema(description = "An item within an order")
public record OrderItemRequest(

    @Schema(description = "UUID of the product",
            example = "prod-00000001",
            requiredMode = Schema.RequiredMode.REQUIRED)
    @NotNull UUID productId,

    @Schema(description = "Number of units to order",
            example = "2",
            minimum = "1",
            maximum = "999")
    @Positive @Max(999) int quantity

) {}

Grouping Endpoints

For larger APIs, split docs into groups:

@Configuration
public class OpenApiGroupConfig {

    @Bean
    public GroupedOpenApi ordersApi() {
        return GroupedOpenApi.builder()
            .group("orders")
            .displayName("Order Management")
            .pathsToMatch("/api/orders/**")
            .build();
    }

    @Bean
    public GroupedOpenApi customersApi() {
        return GroupedOpenApi.builder()
            .group("customers")
            .displayName("Customer Management")
            .pathsToMatch("/api/customers/**")
            .build();
    }

    @Bean
    public GroupedOpenApi adminApi() {
        return GroupedOpenApi.builder()
            .group("admin")
            .displayName("Admin Operations")
            .pathsToMatch("/api/admin/**")
            .addOpenApiCustomizer(openApi ->
                openApi.info(new Info().title("Admin API").version("v1")))
            .build();
    }
}

Navigate groups in Swagger UI via the dropdown in the top right.

Documenting Security

JWT Bearer Authentication

@Bean
public OpenAPI openAPI() {
    return new OpenAPI()
        .addSecurityItem(new SecurityRequirement().addList("Bearer Auth"))
        .components(new Components()
            .addSecuritySchemes("Bearer Auth",
                new SecurityScheme()
                    .name("Bearer Auth")
                    .type(SecurityScheme.Type.HTTP)
                    .scheme("bearer")
                    .bearerFormat("JWT")
                    .description("Enter JWT token from /api/auth/login")))
        .info(apiInfo());
}

Now Swagger UI shows an “Authorize” button. Click it, paste a JWT, and all subsequent requests include it.

Exclude specific endpoints from auth:

@GetMapping("/health")
@Operation(security = {})  // no security required
public HealthResponse health() { ... }

API Key Authentication

.addSecuritySchemes("API Key",
    new SecurityScheme()
        .type(SecurityScheme.Type.APIKEY)
        .in(SecurityScheme.In.HEADER)
        .name("X-API-Key"))

Documenting Pagination

Springdoc doesn’t auto-document Pageable parameters well. Add explicit parameter docs:

@GetMapping
@Operation(summary = "List orders with pagination")
@Parameters({
    @Parameter(name = "page", description = "Page number (0-based)", example = "0",
               in = ParameterIn.QUERY, schema = @Schema(type = "integer", defaultValue = "0")),
    @Parameter(name = "size", description = "Page size (max 100)", example = "20",
               in = ParameterIn.QUERY, schema = @Schema(type = "integer", defaultValue = "20")),
    @Parameter(name = "sort", description = "Sort field and direction",
               example = "createdAt,desc",
               in = ParameterIn.QUERY, schema = @Schema(type = "string"))
})
public PagedResponse<OrderResponse> listOrders(
        @Parameter(hidden = true) Pageable pageable) {  // hide the Pageable param itself
    return PagedResponse.from(orderRepository.findAll(pageable), OrderResponse::from);
}

Configuration Properties

springdoc:
  api-docs:
    path: /v3/api-docs           # OpenAPI JSON endpoint
    enabled: true
  swagger-ui:
    path: /swagger-ui.html       # Swagger UI path
    enabled: true
    operations-sorter: alpha     # sort by method name (or 'method' for HTTP method)
    tags-sorter: alpha
    display-request-duration: true
    try-it-out-enabled: true     # enable "Try it out" by default
    filter: true                 # show search filter
  show-actuator: false           # don't show actuator endpoints in docs
  packages-to-scan: com.devopsmonk.order  # limit scanning
  paths-to-exclude: /api/internal/**     # hide internal endpoints

Securing the Docs Endpoint

In production, you don’t want Swagger UI publicly accessible. Several options:

Option 1: Disable in production

# application-prod.yml
springdoc:
  swagger-ui:
    enabled: false
  api-docs:
    enabled: false

Option 2: Basic auth for docs only

@Configuration
@Profile("!prod")
public class SwaggerSecurityConfig {

    @Bean
    @Order(1)  // higher priority than main security config
    public SecurityFilterChain swaggerSecurityChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/swagger-ui/**", "/v3/api-docs/**")
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

Option 3: IP allowlist via reverse proxy (Nginx)

location /swagger-ui {
    allow 10.0.0.0/8;  # internal network only
    deny all;
    proxy_pass http://app:8080;
}

Exporting the Spec for Code Generation

The OpenAPI spec is the source of truth for client SDKs:

# Export as JSON
curl http://localhost:8080/v3/api-docs > openapi.json

# Export as YAML
curl http://localhost:8080/v3/api-docs.yaml > openapi.yaml

Generate a TypeScript client:

npx @openapitools/openapi-generator-cli generate \
  -i openapi.yaml \
  -g typescript-fetch \
  -o ./frontend/src/api-client

Generate a Java client:

openapi-generator generate \
  -i openapi.yaml \
  -g java \
  --library restclient \
  -o ./order-service-client

Auto-export on build

<!-- pom.xml — export spec during build -->
<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.yaml</apiDocsUrl>
        <outputFileName>openapi.yaml</outputFileName>
        <outputDir>${project.build.directory}</outputDir>
    </configuration>
</plugin>

What You’ve Learned

  • Add springdoc-openapi-starter-webmvc-ui — Swagger UI appears at /swagger-ui.html automatically
  • Configure API metadata with a OpenAPI bean
  • Document operations with @Operation, @ApiResponse, @Parameter, @Schema
  • Group endpoints for large APIs using GroupedOpenApi beans
  • Add JWT/API key security schemes and expose the Authorize button in Swagger UI
  • Disable docs in production or restrict with auth/IP allowlist
  • Export the spec and generate typed clients for frontend/partner teams

This completes Part 2: Building REST APIs. You now have a complete foundation for building production-quality REST APIs. In Part 3, we add persistence with Spring Data JPA.

Next: Article 15 — Introduction to Spring Data JPA — the ORM layer that maps Java objects to database tables.