Advanced Java

API Documentation

Module 9 - OpenAPI, Swagger, and API-First Design

Module Objectives

By the end of this module, you will:

Why Document APIs?

Benefits of Good API Documentation:

An undocumented API is an unusable API. Good documentation is as important as the code itself.

OpenAPI Specification

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

Setting Up SpringDoc OpenAPI

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:

API Metadata Configuration

@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")
            ));
    }
}

Basic Controller Annotations

@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());
    }
}

Documenting Request Bodies

@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));
}

Documenting DTOs with @Schema

@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;
}

Documenting Response Objects

@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;
}

Documenting Query Parameters

@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));
}

Documenting Security

@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))
            );
    }
}

Per-Endpoint Security Requirements

@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);
    }
}

Documenting Error Responses

@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());
    }
}

Grouping APIs by Module

@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();
    }
}

API-First Design Approach

Design the API specification BEFORE writing code:

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
    

Generating Code from OpenAPI

<!-- 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>

Generating Client SDKs

# 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.

API Versioning Strategies

1. URL Path Versioning (Recommended)

@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 { }

@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 { }

2. Header Versioning

@GetMapping(value = "/users", headers = "X-API-Version=1")
public List<UserV1DTO> getUsersV1() { }

@GetMapping(value = "/users", headers = "X-API-Version=2")
public List<UserV2DTO> getUsersV2() { }

3. Media Type Versioning

@GetMapping(value = "/users", produces = "application/vnd.api.v1+json")
public List<UserDTO> getUsersV1() { }

Deprecating API Endpoints

@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;
}

Customizing Swagger UI

# 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}

Contract Testing

@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");
    }
}

API Documentation Best Practices

Summary

In this module, you learned:

Modules 8 and 9 complete the core Advanced Java curriculum!

Resources

Slide Overview