Module 9 - OpenAPI, Swagger, and API-First Design
By the end of this module, you will:
An undocumented API is an unusable API. Good documentation is as important as the code itself.
OpenAPI (formerly Swagger) is the industry standard for describing REST APIs:
openapi: 3.0.3
info:
title: User API
version: 1.0.0
paths:
/users:
get:
summary: List all users
responses:
'200':
description: Success
SpringDoc is the modern choice for Spring Boot 3.x:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
# application.properties
springdoc.api-docs.path=/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.operationsSorter=method
springdoc.swagger-ui.tagsSorter=alpha
Access points:
http://localhost:8080/swagger-ui.htmlhttp://localhost:8080/api-docs@Configuration
public class OpenAPIConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info() // (#1:API information)
.title("E-Commerce API")
.version("1.0.0")
.description("API for managing products, orders, and users")
.contact(new Contact()
.name("API Support")
.email("api@example.com"))
.license(new License()
.name("Apache 2.0")
.url("https://www.apache.org/licenses/LICENSE-2.0")))
.externalDocs(new ExternalDocumentation() // (#2:External docs)
.description("Full Documentation")
.url("https://docs.example.com"))
.servers(List.of( // (#3:Server URLs)
new Server().url("https://api.example.com").description("Production"),
new Server().url("http://localhost:8080").description("Development")
));
}
}
@RestController
@RequestMapping("/api/v1/users")
@Tag(name = "Users", description = "User management operations") // (#1:Group endpoints)
public class UserController {
@Operation( // (#2:Describe the operation)
summary = "Get user by ID",
description = "Returns a single user by their unique identifier"
)
@ApiResponses({ // (#3:Document responses)
@ApiResponse(responseCode = "200", description = "User found"),
@ApiResponse(responseCode = "404", description = "User not found")
})
@GetMapping("/{id}")
public ResponseEntity<UserDTO> getUser(
@Parameter(description = "User ID") // (#4:Describe parameter)
@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
@PostMapping
@Operation(summary = "Create a new user")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "User created"),
@ApiResponse(responseCode = "400", description = "Invalid input")
})
public ResponseEntity<UserDTO> createUser(
@io.swagger.v3.oas.annotations.parameters.RequestBody( // (#1:Request body docs)
description = "User to create",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = CreateUserRequest.class), // (#2:Schema reference)
examples = @ExampleObject( // (#3:Example value)
name = "Sample user",
value = """
{
"username": "johndoe",
"email": "john@example.com",
"password": "securePass123"
}
"""
)
)
)
@Valid @RequestBody CreateUserRequest request) {
return ResponseEntity.status(201).body(userService.create(request));
}
@Schema(description = "Request object for creating a new user")
public class CreateUserRequest {
@Schema( // (#1:Field documentation)
description = "Unique username",
example = "johndoe",
minLength = 3,
maxLength = 50,
pattern = "^[a-zA-Z0-9_]+$"
)
@NotBlank
private String username;
@Schema(
description = "User's email address",
example = "john@example.com",
format = "email"
)
@Email
private String email;
@Schema(
description = "User password",
example = "securePass123",
minLength = 8,
accessMode = Schema.AccessMode.WRITE_ONLY // (#2:Hide in responses)
)
private String password;
}
@Schema(description = "User data transfer object")
public class UserDTO {
@Schema(description = "Unique identifier", example = "12345")
private Long id;
@Schema(description = "Username", example = "johndoe")
private String username;
@Schema(description = "Email address", example = "john@example.com")
private String email;
@Schema(description = "Account creation date", example = "2024-01-15T10:30:00Z")
private Instant createdAt;
@Schema(description = "User roles", example = "[\"USER\", \"ADMIN\"]")
private List<String> roles;
@Schema(description = "Account status", example = "ACTIVE",
allowableValues = {"ACTIVE", "INACTIVE", "SUSPENDED"}) // (#1:Enum values)
private String status;
}
@GetMapping
@Operation(summary = "Search users with pagination")
public Page<UserDTO> searchUsers(
@Parameter(description = "Search term for username or email")
@RequestParam(required = false) String query,
@Parameter(description = "Filter by status",
schema = @Schema(allowableValues = {"ACTIVE", "INACTIVE"}))
@RequestParam(required = false) String status,
@Parameter(description = "Page number (0-indexed)", example = "0")
@RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size", example = "20")
@RequestParam(defaultValue = "20") int size,
@Parameter(description = "Sort field", example = "createdAt")
@RequestParam(defaultValue = "id") String sortBy,
@Parameter(description = "Sort direction",
schema = @Schema(allowableValues = {"ASC", "DESC"}))
@RequestParam(defaultValue = "ASC") String sortDir) {
return userService.search(query, status, PageRequest.of(page, size));
}
@Configuration
public class OpenAPISecurityConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info().title("Secure API").version("1.0"))
.addSecurityItem(new SecurityRequirement() // (#1:Global security)
.addList("bearerAuth"))
.components(new Components()
.addSecuritySchemes("bearerAuth", // (#2:JWT scheme)
new SecurityScheme()
.name("bearerAuth")
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("Enter JWT token"))
.addSecuritySchemes("apiKey", // (#3:API key scheme)
new SecurityScheme()
.name("X-API-KEY")
.type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER))
);
}
}
@RestController
@RequestMapping("/api/v1")
public class MixedSecurityController {
@GetMapping("/public/info")
@Operation(
summary = "Public endpoint",
security = {} // (#1:No security required)
)
public String publicInfo() {
return "Public information";
}
@GetMapping("/users/me")
@Operation(
summary = "Get current user",
security = @SecurityRequirement(name = "bearerAuth") // (#2:JWT required)
)
public UserDTO getCurrentUser() {
return userService.getCurrentUser();
}
@PostMapping("/admin/users")
@Operation(
summary = "Create admin user",
security = { // (#3:Multiple schemes)
@SecurityRequirement(name = "bearerAuth"),
@SecurityRequirement(name = "apiKey")
}
)
public UserDTO createAdmin(@RequestBody CreateUserRequest request) {
return userService.createAdmin(request);
}
}
@Schema(description = "Standard error response")
public class ErrorResponse {
@Schema(description = "Error code", example = "USER_NOT_FOUND")
private String code;
@Schema(description = "Error message", example = "User with ID 123 not found")
private String message;
@Schema(description = "Timestamp", example = "2024-01-15T10:30:00Z")
private Instant timestamp;
@Schema(description = "Request path", example = "/api/v1/users/123")
private String path;
}
// Global error documentation
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) {
return new ErrorResponse("NOT_FOUND", ex.getMessage(), Instant.now(), req.getRequestURI());
}
}
@Configuration
public class OpenAPIGroupConfig {
@Bean
public GroupedOpenApi publicApi() { // (#1:Public API group)
return GroupedOpenApi.builder()
.group("public")
.pathsToMatch("/api/v1/public/**")
.build();
}
@Bean
public GroupedOpenApi adminApi() { // (#2:Admin API group)
return GroupedOpenApi.builder()
.group("admin")
.pathsToMatch("/api/v1/admin/**")
.addOpenApiCustomizer(openApi -> openApi
.info(new Info()
.title("Admin API")
.description("Administrative endpoints")))
.build();
}
@Bean
public GroupedOpenApi allApis() { // (#3:All APIs)
return GroupedOpenApi.builder()
.group("all")
.pathsToMatch("/api/**")
.build();
}
}
flowchart LR
subgraph Design["1. Design"]
A[Write OpenAPI Spec]
end
subgraph Review["2. Review"]
B[Team Review]
C[Stakeholder Approval]
end
subgraph Generate["3. Generate"]
D[Server Stubs]
E[Client SDKs]
F[Documentation]
end
subgraph Implement["4. Implement"]
G[Write Business Logic]
end
A --> B --> C --> D
C --> E
C --> F
D --> G
<!-- Maven plugin for code generation -->
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>7.2.0</version>
<executions>
<execution>
<goals><goal>generate</goal></goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/api.yaml</inputSpec>
<generatorName>spring</generatorName> <!-- (#1:Server generator) -->
<apiPackage>com.example.api</apiPackage>
<modelPackage>com.example.model</modelPackage>
<configOptions>
<interfaceOnly>true</interfaceOnly> <!-- (#2:Generate interfaces only) -->
<useSpringBoot3>true</useSpringBoot3>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
# Generate TypeScript client
npx @openapitools/openapi-generator-cli generate \
-i http://localhost:8080/api-docs \
-g typescript-axios \
-o ./generated/ts-client
# Generate Java client
openapi-generator generate \
-i api.yaml \
-g java \
--library webclient \
-o ./generated/java-client
# Generate Python client
openapi-generator generate \
-i api.yaml \
-g python \
-o ./generated/python-client
Consistency: Generated clients always match the API spec exactly.
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 { }
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 { }
@GetMapping(value = "/users", headers = "X-API-Version=1")
public List<UserV1DTO> getUsersV1() { }
@GetMapping(value = "/users", headers = "X-API-Version=2")
public List<UserV2DTO> getUsersV2() { }
@GetMapping(value = "/users", produces = "application/vnd.api.v1+json")
public List<UserDTO> getUsersV1() { }
@Operation(
summary = "Get user by ID",
deprecated = true, // (#1:Mark as deprecated)
description = """
**DEPRECATED**: This endpoint will be removed in v3.
Please use GET /api/v2/users/{id} instead.
"""
)
@GetMapping("/api/v1/users/{id}")
public UserDTO getUserV1(@PathVariable Long id) {
return userService.findById(id);
}
// In OpenAPI spec
@Schema(
deprecated = true,
description = "Use UserV2DTO instead"
)
public class UserV1DTO {
@Schema(deprecated = true, description = "Use 'username' field instead")
private String name; // (#2:Deprecated field)
private String username;
}
# Swagger UI configuration
springdoc.swagger-ui.path=/docs
springdoc.swagger-ui.operationsSorter=method
springdoc.swagger-ui.tagsSorter=alpha
springdoc.swagger-ui.displayRequestDuration=true
springdoc.swagger-ui.filter=true
springdoc.swagger-ui.showExtensions=true
springdoc.swagger-ui.showCommonExtensions=true
# Enable "Try it out" by default
springdoc.swagger-ui.tryItOutEnabled=true
# Syntax highlighting theme
springdoc.swagger-ui.syntaxHighlight.theme=monokai
# Disable in production
springdoc.swagger-ui.enabled=${SWAGGER_ENABLED:true}
springdoc.api-docs.enabled=${SWAGGER_ENABLED:true}
@SpringBootTest
@AutoConfigureMockMvc
class OpenApiValidationTest {
@Autowired
private MockMvc mockMvc;
@Test
void apiDocsShouldBeAccessible() throws Exception {
mockMvc.perform(get("/api-docs")) // (#1:Verify docs endpoint)
.andExpect(status().isOk())
.andExpect(jsonPath("$.openapi").value("3.0.1"))
.andExpect(jsonPath("$.info.title").exists());
}
@Test
void swaggerUiShouldBeAccessible() throws Exception {
mockMvc.perform(get("/swagger-ui.html")) // (#2:Verify Swagger UI)
.andExpect(status().is3xxRedirection());
}
@Test
void allEndpointsShouldBeDocumented() throws Exception {
String spec = mockMvc.perform(get("/api-docs"))
.andReturn().getResponse().getContentAsString();
// Validate against expected paths
assertThat(spec).contains("/api/v1/users"); // (#3:Check endpoints)
assertThat(spec).contains("/api/v1/products");
}
}
In this module, you learned:
Modules 8 and 9 complete the core Advanced Java curriculum!