Back to blog

API Documentation with OpenAPI and Swagger in Spring Boot

javaspring-bootopenapiswaggerapidocumentation
API Documentation with OpenAPI and Swagger in Spring Boot

Introduction

Good API documentation is the difference between developers loving your API and abandoning it in frustration. With OpenAPI (formerly Swagger), you can auto-generate beautiful, interactive documentation directly from your Spring Boot code.

What You'll Learn

✅ OpenAPI 3.0 specification fundamentals
✅ Set up SpringDoc OpenAPI in Spring Boot
✅ Auto-generate Swagger UI from your controllers
✅ Document endpoints with rich annotations
✅ Add request/response examples and schemas
✅ Document authentication (JWT, OAuth2, API Keys)
✅ Customize Swagger UI appearance
✅ Secure API docs in production
✅ Generate OpenAPI JSON/YAML for external tools

Prerequisites


1. Understanding OpenAPI and Swagger

What is OpenAPI?

OpenAPI Specification (OAS) is a standard, language-agnostic interface for describing RESTful APIs. It allows both humans and computers to understand what an API does without looking at source code.

Key benefits:

  • Standardized format: JSON or YAML describing your entire API
  • Auto-generation: Generate documentation, client SDKs, and server stubs
  • Interactive testing: Try out API calls directly in the browser
  • Contract-first design: Define API before implementation

What is Swagger?

Swagger refers to the tooling ecosystem around OpenAPI:

ToolPurpose
Swagger UIInteractive API documentation in browser
Swagger EditorDesign and edit OpenAPI specifications
Swagger CodegenGenerate client/server code from specs
SwaggerHubCollaborative API design platform

OpenAPI 3.0 vs Swagger 2.0

FeatureSwagger 2.0OpenAPI 3.0
Request bodybody parameterDedicated requestBody object
Content typesLimitedMultiple content types per operation
Callbacks✅ (webhooks)
Links✅ (hypermedia)
ComponentsdefinitionsUnified components section

Recommendation: Always use OpenAPI 3.0+ for new projects.


2. Setting Up SpringDoc OpenAPI

Adding Dependencies

SpringDoc is the modern, actively maintained library for OpenAPI in Spring Boot (replacing the older Springfox).

Maven:

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

Gradle:

implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'

Basic Configuration

With zero configuration, SpringDoc automatically:

  • Scans all @RestController classes
  • Generates OpenAPI specification
  • Serves Swagger UI at /swagger-ui.html

Access your documentation:

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

Customizing API Information

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class OpenApiConfig {
 
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("E-Commerce API")
                .version("1.0.0")
                .description("REST API for managing products, orders, and customers")
                .termsOfService("https://example.com/terms")
                .contact(new Contact()
                    .name("API Support")
                    .email("api@example.com")
                    .url("https://example.com/support"))
                .license(new License()
                    .name("MIT License")
                    .url("https://opensource.org/licenses/MIT")))
            .addServersItem(new Server()
                .url("https://api.example.com")
                .description("Production server"))
            .addServersItem(new Server()
                .url("http://localhost:8080")
                .description("Development server"));
    }
}

Application Properties

# application.yml
springdoc:
  api-docs:
    enabled: true
    path: /v3/api-docs
  swagger-ui:
    enabled: true
    path: /swagger-ui.html
    operationsSorter: method  # Sort by HTTP method
    tagsSorter: alpha         # Sort tags alphabetically
    displayRequestDuration: true
    filter: true              # Enable search filter
    tryItOutEnabled: true     # Enable "Try it out" by default
  show-actuator: false        # Hide actuator endpoints
  packages-to-scan: com.example.controller
  paths-to-match: /api/**     # Only document /api/* paths

3. Documenting Controllers

Basic Annotations

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
 
@RestController
@RequestMapping("/api/v1/products")
@Tag(name = "Products", description = "Product management operations")
public class ProductController {
 
    @GetMapping
    @Operation(
        summary = "List all products",
        description = "Retrieves a paginated list of products with optional filtering"
    )
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "Products retrieved successfully"),
        @ApiResponse(responseCode = "400", description = "Invalid request parameters")
    })
    public ResponseEntity<Page<ProductResponse>> getProducts(
            @Parameter(description = "Filter by category")
            @RequestParam(required = false) String category,
 
            @Parameter(description = "Page number (0-based)", example = "0")
            @RequestParam(defaultValue = "0") int page,
 
            @Parameter(description = "Page size (max 100)", example = "20")
            @RequestParam(defaultValue = "20") int size) {
 
        return ResponseEntity.ok(productService.findAll(category, page, size));
    }
 
    @GetMapping("/{id}")
    @Operation(summary = "Get product by ID")
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "Product found"),
        @ApiResponse(responseCode = "404", description = "Product not found")
    })
    public ResponseEntity<ProductResponse> getProduct(
            @Parameter(description = "Product ID", required = true, example = "123")
            @PathVariable Long id) {
 
        return ResponseEntity.ok(productService.findById(id));
    }
 
    @PostMapping
    @Operation(summary = "Create a new product")
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "Product created"),
        @ApiResponse(responseCode = "400", description = "Invalid input"),
        @ApiResponse(responseCode = "409", description = "Product already exists")
    })
    public ResponseEntity<ProductResponse> createProduct(
            @RequestBody @Valid ProductRequest request) {
 
        ProductResponse product = productService.create(request);
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .header("Location", "/api/v1/products/" + product.id())
            .body(product);
    }
 
    @PutMapping("/{id}")
    @Operation(summary = "Update product")
    public ResponseEntity<ProductResponse> updateProduct(
            @PathVariable Long id,
            @RequestBody @Valid ProductRequest request) {
 
        return ResponseEntity.ok(productService.update(id, request));
    }
 
    @DeleteMapping("/{id}")
    @Operation(summary = "Delete product")
    @ApiResponse(responseCode = "204", description = "Product deleted")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

Grouping with Tags

// Organize endpoints into logical groups
@Tag(name = "Products", description = "Product CRUD operations")
@Tag(name = "Inventory", description = "Stock management")
public class ProductController {
 
    @GetMapping
    @Operation(tags = {"Products"})
    public ResponseEntity<Page<Product>> getProducts() { /* ... */ }
 
    @GetMapping("/{id}/stock")
    @Operation(tags = {"Inventory"})
    public ResponseEntity<StockInfo> getStock(@PathVariable Long id) { /* ... */ }
}
 
// Or configure globally
@Configuration
public class OpenApiConfig {
 
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .tags(List.of(
                new io.swagger.v3.oas.models.tags.Tag()
                    .name("Products")
                    .description("Product management operations"),
                new io.swagger.v3.oas.models.tags.Tag()
                    .name("Orders")
                    .description("Order processing operations"),
                new io.swagger.v3.oas.models.tags.Tag()
                    .name("Users")
                    .description("User account management")
            ));
    }
}

Hiding Endpoints

// Hide specific endpoints from documentation
@Operation(hidden = true)
@GetMapping("/internal/health")
public ResponseEntity<String> internalHealth() {
    return ResponseEntity.ok("OK");
}
 
// Or exclude entire controllers
@Hidden
@RestController
@RequestMapping("/internal")
public class InternalController {
    // All endpoints hidden
}

4. Documenting Request/Response Models

Schema Annotations

import io.swagger.v3.oas.annotations.media.Schema;
 
@Schema(description = "Product creation/update request")
public record ProductRequest(
 
    @Schema(
        description = "Product name",
        example = "MacBook Pro 16\"",
        minLength = 3,
        maxLength = 100,
        requiredMode = Schema.RequiredMode.REQUIRED
    )
    @NotBlank
    @Size(min = 3, max = 100)
    String name,
 
    @Schema(
        description = "Product price in USD",
        example = "2499.99",
        minimum = "0.01",
        maximum = "999999.99"
    )
    @NotNull
    @DecimalMin("0.01")
    BigDecimal price,
 
    @Schema(
        description = "Product category",
        example = "electronics",
        allowableValues = {"electronics", "clothing", "books", "home", "sports"}
    )
    @NotBlank
    String category,
 
    @Schema(
        description = "Product description",
        example = "The most powerful MacBook Pro ever",
        maxLength = 1000,
        nullable = true
    )
    @Size(max = 1000)
    String description,
 
    @Schema(
        description = "Current stock quantity",
        example = "50",
        minimum = "0",
        defaultValue = "0"
    )
    Integer stockQuantity
) {}

Response Model with Examples

@Schema(description = "Product response object")
public record ProductResponse(
 
    @Schema(description = "Unique product ID", example = "12345")
    Long id,
 
    @Schema(description = "Product name", example = "MacBook Pro 16\"")
    String name,
 
    @Schema(description = "Price in USD", example = "2499.99")
    BigDecimal price,
 
    @Schema(description = "Product category", example = "electronics")
    String category,
 
    @Schema(description = "Stock availability")
    Boolean inStock,
 
    @Schema(description = "Creation timestamp", example = "2026-02-07T10:30:00Z")
    LocalDateTime createdAt
) {}

Enum Documentation

@Schema(description = "Order status values")
public enum OrderStatus {
 
    @Schema(description = "Order placed, awaiting payment")
    PENDING,
 
    @Schema(description = "Payment confirmed")
    CONFIRMED,
 
    @Schema(description = "Order is being prepared")
    PROCESSING,
 
    @Schema(description = "Order shipped to customer")
    SHIPPED,
 
    @Schema(description = "Order delivered successfully")
    DELIVERED,
 
    @Schema(description = "Order cancelled")
    CANCELLED
}

Complex Nested Objects

@Schema(description = "Order with line items")
public record OrderResponse(
 
    @Schema(description = "Order ID")
    Long id,
 
    @Schema(description = "Customer information")
    CustomerInfo customer,
 
    @Schema(description = "Order line items")
    List<OrderItem> items,
 
    @Schema(description = "Order total")
    BigDecimal total,
 
    @Schema(description = "Current order status")
    OrderStatus status
) {}
 
@Schema(description = "Order line item")
public record OrderItem(
 
    @Schema(description = "Product ID")
    Long productId,
 
    @Schema(description = "Product name")
    String productName,
 
    @Schema(description = "Quantity ordered")
    Integer quantity,
 
    @Schema(description = "Unit price at time of order")
    BigDecimal unitPrice,
 
    @Schema(description = "Line total (quantity × unit price)")
    BigDecimal lineTotal
) {}

5. Adding Request/Response Examples

Inline Examples

@PostMapping
@Operation(summary = "Create order")
@io.swagger.v3.oas.annotations.parameters.RequestBody(
    description = "Order creation request",
    required = true,
    content = @Content(
        mediaType = "application/json",
        schema = @Schema(implementation = OrderRequest.class),
        examples = {
            @ExampleObject(
                name = "Simple order",
                summary = "Single item order",
                value = """
                    {
                      "customerId": 123,
                      "items": [
                        {"productId": 456, "quantity": 1}
                      ],
                      "shippingAddress": {
                        "street": "123 Main St",
                        "city": "San Francisco",
                        "zipCode": "94102"
                      }
                    }
                    """
            ),
            @ExampleObject(
                name = "Multiple items",
                summary = "Order with multiple items",
                value = """
                    {
                      "customerId": 123,
                      "items": [
                        {"productId": 456, "quantity": 2},
                        {"productId": 789, "quantity": 1}
                      ],
                      "shippingAddress": {
                        "street": "456 Oak Ave",
                        "city": "Los Angeles",
                        "zipCode": "90001"
                      },
                      "couponCode": "SAVE10"
                    }
                    """
            )
        }
    )
)
public ResponseEntity<OrderResponse> createOrder(@RequestBody @Valid OrderRequest request) {
    // ...
}

Response Examples

@GetMapping("/{id}")
@Operation(summary = "Get order by ID")
@ApiResponses({
    @ApiResponse(
        responseCode = "200",
        description = "Order found",
        content = @Content(
            mediaType = "application/json",
            schema = @Schema(implementation = OrderResponse.class),
            examples = @ExampleObject(
                value = """
                    {
                      "id": 12345,
                      "status": "PROCESSING",
                      "items": [
                        {
                          "productId": 456,
                          "productName": "MacBook Pro",
                          "quantity": 1,
                          "unitPrice": 2499.99,
                          "lineTotal": 2499.99
                        }
                      ],
                      "total": 2499.99,
                      "createdAt": "2026-02-07T10:30:00Z"
                    }
                    """
            )
        )
    ),
    @ApiResponse(
        responseCode = "404",
        description = "Order not found",
        content = @Content(
            mediaType = "application/json",
            schema = @Schema(implementation = ErrorResponse.class),
            examples = @ExampleObject(
                value = """
                    {
                      "success": false,
                      "error": {
                        "code": "ORDER_NOT_FOUND",
                        "message": "Order with ID 12345 not found"
                      },
                      "timestamp": "2026-02-07T10:30:00Z"
                    }
                    """
            )
        )
    )
})
public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
    // ...
}

Error Response Documentation

@Schema(description = "Standard error response")
public record ErrorResponse(
 
    @Schema(description = "Error code", example = "VALIDATION_ERROR")
    String code,
 
    @Schema(description = "Human-readable message", example = "Invalid request parameters")
    String message,
 
    @Schema(description = "Field-level errors")
    List<FieldError> details,
 
    @Schema(description = "Error timestamp")
    LocalDateTime timestamp,
 
    @Schema(description = "Request path", example = "/api/v1/products")
    String path
) {}
 
@Schema(description = "Field validation error")
public record FieldError(
 
    @Schema(description = "Field name", example = "price")
    String field,
 
    @Schema(description = "Error message", example = "Price must be positive")
    String message,
 
    @Schema(description = "Rejected value", example = "-10")
    Object rejectedValue
) {}

6. Documenting Authentication

JWT Bearer Token

@Configuration
public class OpenApiConfig {
 
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info().title("Secure API").version("1.0.0"))
            .addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
            .components(new Components()
                .addSecuritySchemes("bearerAuth", new SecurityScheme()
                    .type(SecurityScheme.Type.HTTP)
                    .scheme("bearer")
                    .bearerFormat("JWT")
                    .description("Enter JWT token obtained from /api/auth/login")));
    }
}

API Key Authentication

@Bean
public OpenAPI customOpenAPI() {
    return new OpenAPI()
        .components(new Components()
            .addSecuritySchemes("apiKey", new SecurityScheme()
                .type(SecurityScheme.Type.APIKEY)
                .in(SecurityScheme.In.HEADER)
                .name("X-API-Key")
                .description("API key for authentication")));
}

OAuth2 Configuration

@Bean
public OpenAPI customOpenAPI() {
    return new OpenAPI()
        .components(new Components()
            .addSecuritySchemes("oauth2", new SecurityScheme()
                .type(SecurityScheme.Type.OAUTH2)
                .flows(new OAuthFlows()
                    .authorizationCode(new OAuthFlow()
                        .authorizationUrl("https://auth.example.com/authorize")
                        .tokenUrl("https://auth.example.com/token")
                        .scopes(new Scopes()
                            .addString("read:products", "Read products")
                            .addString("write:products", "Create/update products")
                            .addString("delete:products", "Delete products"))))));
}

Endpoint-Level Security

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
 
    // Public endpoint - no auth required
    @GetMapping
    @Operation(summary = "List products", security = {})
    public ResponseEntity<List<Product>> getProducts() { /* ... */ }
 
    // Requires authentication
    @PostMapping
    @Operation(
        summary = "Create product",
        security = @SecurityRequirement(name = "bearerAuth")
    )
    public ResponseEntity<Product> createProduct(@RequestBody ProductRequest request) { /* ... */ }
 
    // Requires specific OAuth scopes
    @DeleteMapping("/{id}")
    @Operation(
        summary = "Delete product",
        security = @SecurityRequirement(name = "oauth2", scopes = {"delete:products"})
    )
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) { /* ... */ }
}

Multiple Authentication Methods

@Bean
public OpenAPI customOpenAPI() {
    return new OpenAPI()
        .components(new Components()
            .addSecuritySchemes("bearerAuth", new SecurityScheme()
                .type(SecurityScheme.Type.HTTP)
                .scheme("bearer")
                .bearerFormat("JWT"))
            .addSecuritySchemes("apiKey", new SecurityScheme()
                .type(SecurityScheme.Type.APIKEY)
                .in(SecurityScheme.In.HEADER)
                .name("X-API-Key")))
        // Require EITHER bearer token OR API key
        .security(List.of(
            new SecurityRequirement().addList("bearerAuth"),
            new SecurityRequirement().addList("apiKey")
        ));
}

7. Customizing Swagger UI

Appearance Configuration

# application.yml
springdoc:
  swagger-ui:
    # Display settings
    displayOperationId: false
    displayRequestDuration: true
    defaultModelsExpandDepth: 1
    defaultModelExpandDepth: 3
    defaultModelRendering: model
    docExpansion: none  # none, list, or full
    filter: true
    operationsSorter: alpha  # alpha or method
    showExtensions: true
    showCommonExtensions: true
    tagsSorter: alpha
 
    # Try it out settings
    tryItOutEnabled: true
    supportedSubmitMethods:
      - get
      - post
      - put
      - delete
      - patch
 
    # Syntax highlighting
    syntaxHighlight:
      activated: true
      theme: monokai  # agate, arta, monokai, nord, obsidian, etc.
 
    # OAuth2 settings
    oauth:
      clientId: my-client-id
      clientSecret: my-secret
      realm: my-realm
      appName: My API
      scopeSeparator: ' '

Custom CSS

@Configuration
public class SwaggerConfig implements WebMvcConfigurer {
 
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/swagger-ui/**")
            .addResourceLocations("classpath:/static/swagger-ui/");
    }
}

Create src/main/resources/static/swagger-ui/custom.css:

/* Custom Swagger UI styling */
.swagger-ui .topbar {
    background-color: #1a1a2e;
}
 
.swagger-ui .info .title {
    color: #0066cc;
}
 
.swagger-ui .opblock.opblock-get {
    border-color: #61affe;
    background: rgba(97, 175, 254, 0.1);
}
 
.swagger-ui .opblock.opblock-post {
    border-color: #49cc90;
    background: rgba(73, 204, 144, 0.1);
}
 
.swagger-ui .opblock.opblock-put {
    border-color: #fca130;
    background: rgba(252, 161, 48, 0.1);
}
 
.swagger-ui .opblock.opblock-delete {
    border-color: #f93e3e;
    background: rgba(249, 62, 62, 0.1);
}

Grouping APIs

@Configuration
public class OpenApiConfig {
 
    // Group: Public APIs (v1)
    @Bean
    public GroupedOpenApi publicApiV1() {
        return GroupedOpenApi.builder()
            .group("public-v1")
            .displayName("Public API v1")
            .pathsToMatch("/api/v1/**")
            .pathsToExclude("/api/v1/admin/**")
            .build();
    }
 
    // Group: Admin APIs
    @Bean
    public GroupedOpenApi adminApi() {
        return GroupedOpenApi.builder()
            .group("admin")
            .displayName("Admin API")
            .pathsToMatch("/api/v1/admin/**")
            .build();
    }
 
    // Group: Internal APIs (hidden in production)
    @Bean
    @Profile("!prod")
    public GroupedOpenApi internalApi() {
        return GroupedOpenApi.builder()
            .group("internal")
            .displayName("Internal API")
            .pathsToMatch("/internal/**")
            .build();
    }
}

8. Securing Documentation in Production

Disable in Production

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

Password Protection

@Configuration
@Profile("!prod")
public class SwaggerSecurityConfig {
 
    @Bean
    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();
    }
}
 
// In application.yml for non-prod
spring:
  security:
    user:
      name: docs
      password: ${SWAGGER_PASSWORD:changeme}

IP Whitelist

@Configuration
@Profile("staging")
public class SwaggerSecurityConfig {
 
    private static final List<String> ALLOWED_IPS = List.of(
        "192.168.1.0/24",  // Internal network
        "10.0.0.0/8"       // VPN
    );
 
    @Bean
    public FilterRegistrationBean<SwaggerIpFilter> swaggerIpFilter() {
        FilterRegistrationBean<SwaggerIpFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new SwaggerIpFilter(ALLOWED_IPS));
        registration.addUrlPatterns("/swagger-ui/*", "/v3/api-docs/*");
        return registration;
    }
}

Conditional Exposure

@Configuration
public class OpenApiConfig {
 
    @Value("${app.swagger.enabled:true}")
    private boolean swaggerEnabled;
 
    @Bean
    @ConditionalOnProperty(name = "app.swagger.enabled", havingValue = "true", matchIfMissing = true)
    public OpenAPI openAPI() {
        return new OpenAPI()
            .info(new Info().title("My API").version("1.0.0"));
    }
}

9. Generating OpenAPI Specification

Export at Build Time

Maven Plugin:

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

Generate during build:

mvn verify -DskipTests
# Creates target/openapi.json

Programmatic Export

@RestController
@RequestMapping("/api/docs")
public class OpenApiExportController {
 
    @Autowired
    private OpenApiService openApiService;
 
    @GetMapping(value = "/openapi.yaml", produces = "text/yaml")
    public String exportYaml() {
        return openApiService.toYaml();
    }
 
    @GetMapping(value = "/openapi.json", produces = "application/json")
    public String exportJson() {
        return openApiService.toJson();
    }
}
 
@Service
public class OpenApiService {
 
    @Autowired
    private OpenAPI openAPI;
 
    public String toYaml() {
        return Yaml.pretty().writeValueAsString(openAPI);
    }
 
    public String toJson() {
        return Json.pretty().writeValueAsString(openAPI);
    }
}

Client SDK Generation

Use the exported OpenAPI spec to generate client SDKs:

# Install OpenAPI Generator
npm install @openapitools/openapi-generator-cli -g
 
# Generate TypeScript client
openapi-generator-cli generate \
  -i http://localhost:8080/v3/api-docs \
  -g typescript-fetch \
  -o ./generated/typescript-client
 
# Generate Java client
openapi-generator-cli generate \
  -i http://localhost:8080/v3/api-docs \
  -g java \
  -o ./generated/java-client
 
# Generate Python client
openapi-generator-cli generate \
  -i http://localhost:8080/v3/api-docs \
  -g python \
  -o ./generated/python-client

10. Hands-On Project: Documenting E-Commerce API

Let's create comprehensive documentation for a complete API.

Project Structure

src/main/java/com/example/ecommerce/
├── config/
│   └── OpenApiConfig.java
├── controller/
│   ├── ProductController.java
│   ├── OrderController.java
│   └── AuthController.java
├── dto/
│   ├── ProductRequest.java
│   ├── ProductResponse.java
│   ├── OrderRequest.java
│   ├── OrderResponse.java
│   └── ErrorResponse.java
└── EcommerceApplication.java

Complete OpenAPI Configuration

@Configuration
public class OpenApiConfig {
 
    @Bean
    public OpenAPI ecommerceOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("E-Commerce Platform API")
                .version("2.0.0")
                .description("""
                    Complete REST API for the E-Commerce platform.
 
                    ## Features
                    - Product catalog management
                    - Order processing
                    - User authentication
                    - Inventory tracking
 
                    ## Authentication
                    Most endpoints require JWT authentication.
                    Obtain a token via POST /api/auth/login.
 
                    ## Rate Limiting
                    - Anonymous: 100 requests/minute
                    - Authenticated: 1000 requests/minute
                    """)
                .contact(new Contact()
                    .name("API Team")
                    .email("api@ecommerce.com")
                    .url("https://developers.ecommerce.com"))
                .license(new License()
                    .name("Proprietary")
                    .url("https://ecommerce.com/api-terms")))
            .externalDocs(new ExternalDocumentation()
                .description("API Developer Guide")
                .url("https://developers.ecommerce.com/guide"))
            .servers(List.of(
                new Server().url("https://api.ecommerce.com").description("Production"),
                new Server().url("https://staging-api.ecommerce.com").description("Staging"),
                new Server().url("http://localhost:8080").description("Local Development")))
            .tags(List.of(
                new Tag().name("Authentication").description("User authentication and token management"),
                new Tag().name("Products").description("Product catalog operations"),
                new Tag().name("Orders").description("Order management"),
                new Tag().name("Inventory").description("Stock management (Admin only)")))
            .components(new Components()
                .addSecuritySchemes("bearerAuth", new SecurityScheme()
                    .type(SecurityScheme.Type.HTTP)
                    .scheme("bearer")
                    .bearerFormat("JWT")
                    .description("JWT token from /api/auth/login"))
                .addSecuritySchemes("apiKey", new SecurityScheme()
                    .type(SecurityScheme.Type.APIKEY)
                    .in(SecurityScheme.In.HEADER)
                    .name("X-API-Key")
                    .description("API key for service-to-service calls")));
    }
 
    @Bean
    public GroupedOpenApi publicApi() {
        return GroupedOpenApi.builder()
            .group("public")
            .displayName("Public API")
            .pathsToMatch("/api/v1/**")
            .pathsToExclude("/api/v1/admin/**")
            .build();
    }
 
    @Bean
    public GroupedOpenApi adminApi() {
        return GroupedOpenApi.builder()
            .group("admin")
            .displayName("Admin API")
            .pathsToMatch("/api/v1/admin/**")
            .addOpenApiCustomizer(openApi -> {
                openApi.info(new Info()
                    .title("E-Commerce Admin API")
                    .version("2.0.0")
                    .description("Administrative operations (requires ADMIN role)"));
            })
            .build();
    }
}

Fully Documented Controller

@RestController
@RequestMapping("/api/v1/products")
@Tag(name = "Products")
public class ProductController {
 
    private final ProductService productService;
 
    public ProductController(ProductService productService) {
        this.productService = productService;
    }
 
    @GetMapping
    @Operation(
        summary = "List products",
        description = "Retrieves a paginated, filterable list of products. " +
                      "Public endpoint - no authentication required.",
        security = {}  // Explicitly public
    )
    @ApiResponses({
        @ApiResponse(
            responseCode = "200",
            description = "Products retrieved successfully",
            content = @Content(
                mediaType = "application/json",
                schema = @Schema(implementation = PageProductResponse.class)
            ),
            headers = {
                @Header(name = "X-Total-Count", description = "Total number of products",
                        schema = @Schema(type = "integer")),
                @Header(name = "X-Page-Count", description = "Total number of pages",
                        schema = @Schema(type = "integer"))
            }
        ),
        @ApiResponse(responseCode = "400", description = "Invalid filter parameters")
    })
    public ResponseEntity<Page<ProductResponse>> getProducts(
            @Parameter(description = "Filter by category", example = "electronics",
                      schema = @Schema(allowableValues = {"electronics", "clothing", "books", "home"}))
            @RequestParam(required = false) String category,
 
            @Parameter(description = "Minimum price filter", example = "10.00")
            @RequestParam(required = false) @DecimalMin("0") BigDecimal minPrice,
 
            @Parameter(description = "Maximum price filter", example = "1000.00")
            @RequestParam(required = false) BigDecimal maxPrice,
 
            @Parameter(description = "Search in name and description", example = "laptop")
            @RequestParam(required = false) String search,
 
            @Parameter(description = "Only show in-stock items", example = "true")
            @RequestParam(required = false) Boolean inStock,
 
            @Parameter(description = "Sort field", example = "price",
                      schema = @Schema(allowableValues = {"name", "price", "createdAt", "popularity"}))
            @RequestParam(defaultValue = "name") String sortBy,
 
            @Parameter(description = "Sort direction", example = "asc",
                      schema = @Schema(allowableValues = {"asc", "desc"}))
            @RequestParam(defaultValue = "asc") String sortDirection,
 
            @Parameter(description = "Page number (0-based)", example = "0")
            @RequestParam(defaultValue = "0") @Min(0) int page,
 
            @Parameter(description = "Items per page (max 100)", example = "20")
            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
 
        Page<ProductResponse> products = productService.findAll(
            category, minPrice, maxPrice, search, inStock,
            sortBy, sortDirection, page, size
        );
 
        return ResponseEntity.ok()
            .header("X-Total-Count", String.valueOf(products.getTotalElements()))
            .header("X-Page-Count", String.valueOf(products.getTotalPages()))
            .body(products);
    }
 
    @GetMapping("/{id}")
    @Operation(
        summary = "Get product details",
        description = "Retrieves detailed information about a specific product"
    )
    @ApiResponses({
        @ApiResponse(
            responseCode = "200",
            description = "Product found",
            content = @Content(schema = @Schema(implementation = ProductResponse.class))
        ),
        @ApiResponse(
            responseCode = "404",
            description = "Product not found",
            content = @Content(
                schema = @Schema(implementation = ErrorResponse.class),
                examples = @ExampleObject(value = """
                    {
                      "code": "PRODUCT_NOT_FOUND",
                      "message": "Product with ID 12345 not found",
                      "timestamp": "2026-02-07T10:30:00Z"
                    }
                    """)
            )
        )
    })
    public ResponseEntity<ProductResponse> getProduct(
            @Parameter(description = "Product ID", required = true, example = "12345")
            @PathVariable Long id) {
 
        return ResponseEntity.ok(productService.findById(id));
    }
 
    @PostMapping
    @Operation(
        summary = "Create product",
        description = "Creates a new product. Requires ADMIN role.",
        security = @SecurityRequirement(name = "bearerAuth")
    )
    @ApiResponses({
        @ApiResponse(
            responseCode = "201",
            description = "Product created successfully",
            headers = @Header(name = "Location", description = "URL of created product",
                             schema = @Schema(type = "string", example = "/api/v1/products/12345"))
        ),
        @ApiResponse(responseCode = "400", description = "Invalid input"),
        @ApiResponse(responseCode = "401", description = "Not authenticated"),
        @ApiResponse(responseCode = "403", description = "Not authorized (requires ADMIN role)"),
        @ApiResponse(responseCode = "409", description = "Product with same SKU already exists")
    })
    @io.swagger.v3.oas.annotations.parameters.RequestBody(
        description = "Product to create",
        required = true,
        content = @Content(
            schema = @Schema(implementation = ProductRequest.class),
            examples = {
                @ExampleObject(
                    name = "Electronics",
                    summary = "Electronics product example",
                    value = """
                        {
                          "name": "MacBook Pro 16\\"",
                          "sku": "MBP16-2026",
                          "price": 2499.99,
                          "category": "electronics",
                          "description": "The most powerful MacBook Pro ever",
                          "stockQuantity": 50
                        }
                        """
                ),
                @ExampleObject(
                    name = "Clothing",
                    summary = "Clothing product example",
                    value = """
                        {
                          "name": "Classic T-Shirt",
                          "sku": "TSH-BLK-L",
                          "price": 29.99,
                          "category": "clothing",
                          "description": "Comfortable cotton t-shirt",
                          "stockQuantity": 200,
                          "variants": [
                            {"size": "S", "color": "black"},
                            {"size": "M", "color": "black"},
                            {"size": "L", "color": "black"}
                          ]
                        }
                        """
                )
            }
        )
    )
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<ProductResponse> createProduct(
            @RequestBody @Valid ProductRequest request) {
 
        ProductResponse product = productService.create(request);
 
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .header("Location", "/api/v1/products/" + product.id())
            .body(product);
    }
 
    @PatchMapping("/{id}")
    @Operation(
        summary = "Partially update product",
        description = "Updates only the provided fields. Requires ADMIN role."
    )
    @io.swagger.v3.oas.annotations.parameters.RequestBody(
        content = @Content(examples = {
            @ExampleObject(
                name = "Update price",
                value = """
                    {"price": 1999.99}
                    """
            ),
            @ExampleObject(
                name = "Update stock and status",
                value = """
                    {
                      "stockQuantity": 100,
                      "active": true
                    }
                    """
            )
        })
    )
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<ProductResponse> patchProduct(
            @PathVariable Long id,
            @RequestBody Map<String, Object> updates) {
 
        return ResponseEntity.ok(productService.patch(id, updates));
    }
 
    @DeleteMapping("/{id}")
    @Operation(
        summary = "Delete product",
        description = "Soft-deletes a product. Can be restored within 30 days. Requires ADMIN role."
    )
    @ApiResponse(responseCode = "204", description = "Product deleted")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

Testing the Documentation

# Start the application
./mvnw spring-boot:run
 
# Access Swagger UI
open http://localhost:8080/swagger-ui.html
 
# Get OpenAPI spec
curl http://localhost:8080/v3/api-docs | jq .
 
# Get YAML spec
curl http://localhost:8080/v3/api-docs.yaml

Common Pitfalls and Solutions

1. Annotations Not Detected

Problem: Controller endpoints not showing in Swagger UI.

Solution: Ensure packages are scanned:

springdoc:
  packages-to-scan: com.example.controller
  # OR
  paths-to-match: /api/**

2. Security Not Applied

Problem: "Authorize" button missing or not working.

Solution: Add security globally or per-operation:

@Bean
public OpenAPI openAPI() {
    return new OpenAPI()
        .addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
        .components(new Components()
            .addSecuritySchemes("bearerAuth", new SecurityScheme()...));
}

3. Validation Annotations Ignored

Problem: @NotNull, @Size not reflected in docs.

Solution: Add validation dependency and enable:

<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
</dependency>

4. Records Not Documented

Problem: Java records fields not showing.

Solution: Use constructor parameter annotations:

public record ProductRequest(
    @Schema(description = "Name") @NotBlank String name,
    @Schema(description = "Price") @NotNull BigDecimal price
) {}

5. Slow Startup

Problem: Application startup slow due to OpenAPI scanning.

Solution: Limit scanning scope:

springdoc:
  packages-to-scan: com.example.controller
  packages-to-exclude: com.example.internal

Summary and Key Takeaways

SpringDoc OpenAPI is the modern choice for Spring Boot API documentation
Zero configuration gets you started—auto-generates from controllers
Rich annotations let you document parameters, responses, examples
Authentication docs support JWT, API keys, OAuth2
Swagger UI customization for branding and usability
Secure in production by disabling or password-protecting
Export OpenAPI spec for client SDK generation
Group APIs for better organization

What's Next?

Now that your API is well-documented, explore these topics:

Continue the Spring Boot series:

Related Spring Boot Posts:

REST API Fundamentals:

OpenAPI in Other Frameworks:


Part of the Spring Boot Learning Roadmap series

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.