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
- Java 17+ installed
- Spring Boot basics (see Getting Started guide)
- Familiarity with REST API concepts (REST API Best Practices)
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:
| Tool | Purpose |
|---|---|
| Swagger UI | Interactive API documentation in browser |
| Swagger Editor | Design and edit OpenAPI specifications |
| Swagger Codegen | Generate client/server code from specs |
| SwaggerHub | Collaborative API design platform |
OpenAPI 3.0 vs Swagger 2.0
| Feature | Swagger 2.0 | OpenAPI 3.0 |
|---|---|---|
| Request body | body parameter | Dedicated requestBody object |
| Content types | Limited | Multiple content types per operation |
| Callbacks | ❌ | ✅ (webhooks) |
| Links | ❌ | ✅ (hypermedia) |
| Components | definitions | Unified 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
@RestControllerclasses - 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 specCustomizing 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/* paths3. 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: falsePassword 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.jsonProgrammatic 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-client10. 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.javaComplete 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.yamlCommon 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.internalSummary 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:
- Docker & Kubernetes Deployment - Deploy your documented API
- Microservices with Spring Cloud - Document distributed systems
- REST API Advanced Patterns - Exception handling & testing
Related Spring Boot Posts:
- REST API Best Practices - API design standards
- JWT Authentication - Secure your API
- Spring Boot Learning Roadmap - Complete learning path
REST API Fundamentals:
- What is REST API? - Complete guide to REST principles
- HTTP Protocol Complete Guide - HTTP methods, status codes, headers
OpenAPI in Other Frameworks:
- Understanding OpenAPI with FastAPI - Auto-generated docs in Python
- FastAPI Learning Roadmap - Python's modern API framework
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.