Spring Boot 7 min read

OpenAPI & Swagger: Your API Documentation That Actually Stays Updated

Generate beautiful, interactive API docs that can't go stale. Learn contract-first vs code-first, customization, and generating client SDKs.

MR

Moshiour Rahman

Advertisement

The Problem: Documentation Lies

Your README says the endpoint returns { "user": {...} }.

The actual response is { "data": { "user": {...} } }.

The README was updated 6 months ago. The code changed 47 times since.

Documentation that isn’t generated from code will always drift.

OpenAPI: The Solution

OpenAPI (formerly Swagger) is a specification for describing REST APIs. From this spec, you can generate:

  • Interactive documentation (Swagger UI)
  • Client SDKs (any language)
  • Server stubs
  • Mock servers
  • Test cases

OpenAPI Ecosystem

OpenAPI supports two development workflows: Code-First (write controller annotations → generate spec) or Contract-First (design spec → generate controllers). Most Spring Boot teams use Code-First for rapid development.

Setup: Code-First with SpringDoc

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.3.0</version>
</dependency>
springdoc:
  api-docs:
    path: /api-docs
  swagger-ui:
    path: /swagger-ui.html
    operationsSorter: method
    tagsSorter: alpha

That’s it. Visit /swagger-ui.html to see auto-generated docs.

Basic Annotations

@RestController
@RequestMapping("/api/users")
@Tag(name = "Users", description = "User management endpoints")
public class UserController {

    @Operation(
        summary = "Get user by ID",
        description = "Returns a single user by their unique identifier"
    )
    @ApiResponses({
        @ApiResponse(
            responseCode = "200",
            description = "User found",
            content = @Content(schema = @Schema(implementation = UserResponse.class))
        ),
        @ApiResponse(
            responseCode = "404",
            description = "User not found",
            content = @Content(schema = @Schema(implementation = ErrorResponse.class))
        )
    })
    @GetMapping("/{id}")
    public UserResponse getUser(
        @Parameter(description = "User ID", example = "123")
        @PathVariable Long id
    ) {
        return userService.findById(id);
    }

    @Operation(summary = "Create a new user")
    @ApiResponse(responseCode = "201", description = "User created")
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse createUser(
        @RequestBody @Valid CreateUserRequest request
    ) {
        return userService.create(request);
    }
}

Documenting DTOs

@Schema(description = "Request to create a new user")
public record CreateUserRequest(

    @Schema(
        description = "User's email address",
        example = "john@example.com",
        requiredMode = Schema.RequiredMode.REQUIRED
    )
    @Email
    @NotBlank
    String email,

    @Schema(
        description = "User's display name",
        example = "John Doe",
        minLength = 2,
        maxLength = 100
    )
    @Size(min = 2, max = 100)
    @NotBlank
    String name,

    @Schema(
        description = "User's role",
        allowableValues = {"USER", "ADMIN", "MODERATOR"},
        defaultValue = "USER"
    )
    String role
) {}
@Schema(description = "User response")
public record UserResponse(

    @Schema(description = "Unique identifier", example = "12345")
    Long id,

    @Schema(description = "Email address", example = "john@example.com")
    String email,

    @Schema(description = "Display name", example = "John Doe")
    String name,

    @Schema(description = "Account creation timestamp")
    Instant createdAt
) {}

Pagination Documentation

@Operation(summary = "List all users with pagination")
@GetMapping
public Page<UserResponse> listUsers(
    @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 = "createdAt") String sort,

    @Parameter(description = "Sort direction", schema = @Schema(allowableValues = {"asc", "desc"}))
    @RequestParam(defaultValue = "desc") String direction
) {
    Pageable pageable = PageRequest.of(page, size, Sort.Direction.fromString(direction), sort);
    return userService.findAll(pageable);
}

Security Documentation

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("TechyOwls API")
                .version("1.0.0")
                .description("REST API for TechyOwls platform")
                .contact(new Contact()
                    .name("TechyOwls Team")
                    .email("contact@techyowls.io")
                    .url("https://techyowls.io"))
                .license(new License()
                    .name("MIT")
                    .url("https://opensource.org/licenses/MIT")))
            .components(new Components()
                .addSecuritySchemes("bearer-jwt", new SecurityScheme()
                    .type(SecurityScheme.Type.HTTP)
                    .scheme("bearer")
                    .bearerFormat("JWT")
                    .description("Enter JWT token")));
    }
}
@RestController
@RequestMapping("/api/admin")
@Tag(name = "Admin", description = "Admin-only endpoints")
@SecurityRequirement(name = "bearer-jwt")  // Apply to all endpoints in controller
public class AdminController {

    @Operation(summary = "Get admin dashboard")
    @GetMapping("/dashboard")
    public DashboardResponse getDashboard() {
        return adminService.getDashboard();
    }
}

Grouping APIs

For larger APIs, group endpoints into multiple specs:

@Configuration
public class OpenApiGroupConfig {

    @Bean
    public GroupedOpenApi publicApi() {
        return GroupedOpenApi.builder()
            .group("public")
            .displayName("Public API")
            .pathsToMatch("/api/public/**")
            .build();
    }

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

    @Bean
    public GroupedOpenApi internalApi() {
        return GroupedOpenApi.builder()
            .group("internal")
            .displayName("Internal API")
            .pathsToMatch("/internal/**")
            .build();
    }
}

Contract-First Approach

For teams where API design is done upfront:

# openapi.yaml
openapi: 3.0.3
info:
  title: User Service API
  version: 1.0.0

paths:
  /users/{id}:
    get:
      operationId: getUserById
      summary: Get user by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: User found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '404':
          description: User not found

components:
  schemas:
    User:
      type: object
      required:
        - id
        - email
        - name
      properties:
        id:
          type: integer
          format: int64
        email:
          type: string
          format: email
        name:
          type: string

Generate Code from Spec

<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/openapi.yaml</inputSpec>
                <generatorName>spring</generatorName>
                <configOptions>
                    <interfaceOnly>true</interfaceOnly>
                    <useSpringBoot3>true</useSpringBoot3>
                    <useTags>true</useTags>
                </configOptions>
                <apiPackage>io.techyowls.api</apiPackage>
                <modelPackage>io.techyowls.api.model</modelPackage>
            </configuration>
        </execution>
    </executions>
</plugin>

This generates:

  • API interfaces you implement
  • Model classes (DTOs)
  • Validation annotations
// Generated interface
public interface UsersApi {
    @GetMapping("/users/{id}")
    ResponseEntity<User> getUserById(@PathVariable Long id);
}

// Your implementation
@RestController
public class UsersApiController implements UsersApi {
    @Override
    public ResponseEntity<User> getUserById(Long id) {
        return ResponseEntity.ok(userService.findById(id));
    }
}

Generating Client SDKs

# Generate TypeScript client
openapi-generator generate \
  -i http://localhost:8080/api-docs \
  -g typescript-fetch \
  -o ./generated/typescript-client

# Generate Java client
openapi-generator generate \
  -i http://localhost:8080/api-docs \
  -g java \
  -o ./generated/java-client \
  --additional-properties=useJakartaEe=true

The SDK generation process automates client creation across all platforms:

SDK Generation Workflow

The result: type-safe API calls across TypeScript, Java, Python, and Go - automatically updated whenever your API changes.

Customizing Swagger UI

springdoc:
  swagger-ui:
    path: /docs
    display-request-duration: true
    filter: true
    show-extensions: true
    show-common-extensions: true
    doc-expansion: none  # none, list, full
    default-models-expand-depth: 3
    default-model-expand-depth: 3
    operations-sorter: method
    tags-sorter: alpha
    try-it-out-enabled: true
    persist-authorization: true  # Remember auth token

Custom CSS

@Bean
public OpenAPI customOpenAPI() {
    return new OpenAPI()
        .info(new Info()
            .title("My API")
            .extensions(Map.of(
                "x-logo", Map.of(
                    "url", "https://techyowls.io/logo.png",
                    "altText", "TechyOwls Logo"
                )
            )));
}

Production Considerations

Disable in Production

springdoc:
  api-docs:
    enabled: ${ENABLE_API_DOCS:false}
  swagger-ui:
    enabled: ${ENABLE_SWAGGER_UI:false}

Protect with Authentication

@Configuration
@Profile("!prod")  // Only enable in non-prod
public class SwaggerSecurityConfig {

    @Bean
    public SecurityFilterChain swaggerSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/swagger-ui/**", "/api-docs/**")
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/swagger-ui/**", "/api-docs/**")
                .hasRole("DEVELOPER"))
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

Common Patterns

Polymorphic Types

@Schema(
    description = "Notification",
    oneOf = {EmailNotification.class, SmsNotification.class, PushNotification.class},
    discriminatorProperty = "type"
)
public abstract class Notification {
    @Schema(description = "Notification type")
    public abstract String getType();
}

@Schema(description = "Email notification")
public class EmailNotification extends Notification {
    @Override
    public String getType() { return "email"; }

    @Schema(description = "Recipient email")
    private String to;

    @Schema(description = "Email subject")
    private String subject;
}

File Upload

@Operation(summary = "Upload user avatar")
@PostMapping(value = "/{id}/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public void uploadAvatar(
    @PathVariable Long id,
    @Parameter(description = "Avatar image file", content = @Content(mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE))
    @RequestParam("file") MultipartFile file
) {
    userService.uploadAvatar(id, file);
}

Deprecation

@Operation(
    summary = "Get user (deprecated)",
    deprecated = true,
    description = "Use GET /api/v2/users/{id} instead"
)
@GetMapping("/v1/users/{id}")
@Deprecated
public UserResponse getUserV1(@PathVariable Long id) {
    return userService.findById(id);
}

Code Sample

Full working example: github.com/Moshiour027/techyowls-io-blog-public/openapi-swagger-guide

Summary

ApproachBest ForTrade-off
Code-FirstRapid developmentSpec derived from code
Contract-FirstAPI-first teamsSpec is source of truth
HybridLarge teamsBest of both worlds

Start code-first, add annotations as you go. Move to contract-first when:

  • Multiple teams consume your API
  • Frontend needs to work before backend is ready
  • You need generated SDKs

Documentation that generates from code never lies.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.