← Back to Advanced Java
Practical Work 9

API Documentation

Document your REST API with OpenAPI and Swagger UI

Duration2-3 hours
DifficultyBeginner
Session9 - API Documentation

What You Will Learn

Good documentation makes your API usable. In this practical work, you'll learn:

  • OpenAPI Specification - The standard for describing REST APIs
  • Swagger UI - Interactive documentation and testing interface
  • SpringDoc - Library to auto-generate OpenAPI from Spring code
  • Annotations - How to add descriptions, examples, and schemas
  • Code Generation - Generate client code from your API spec

Understanding OpenAPI

What is OpenAPI?

OpenAPI (formerly Swagger) is a specification for describing REST APIs. Think of it as a "blueprint" that tells developers:

  • What endpoints exist
  • What parameters each endpoint accepts
  • What responses each endpoint returns
  • How to authenticate

This blueprint can be used to auto-generate documentation, client code, and tests!


Your Code → SpringDoc → OpenAPI Spec (JSON) → Swagger UI
    │                        │                     │
    │                        │                     └── Interactive docs
    │                        │
    │                        └── Machine-readable API description
    │
    └── Java annotations describe your API

Prerequisites

  • Java 17+ and Maven installed
  • Basic REST API knowledge
  • A working Spring Boot REST API (or use our starter)

Part 1: Project Setup

Step 1.1: Create Project Structure

documented-api/
├── pom.xml
└── src/
    └── main/
        ├── java/com/example/docs/
        │   ├── DocsApiApplication.java
        │   ├── config/
        │   ├── controller/
        │   ├── dto/
        │   └── entity/
        └── resources/
            └── application.properties

Step 1.2: Create pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.3</version>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>documented-api</artifactId>
    <version>1.0.0-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- (#1:SpringDoc OpenAPI - this is the magic!) -->
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.3.0</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
That's it!

Just by adding the springdoc-openapi-starter-webmvc-ui dependency, SpringDoc will automatically scan your controllers and generate documentation!

Step 1.3: Create application.properties

server.port=8080

# (#1:Customize Swagger UI path - default is /swagger-ui.html)
springdoc.swagger-ui.path=/swagger-ui.html

# (#2:Customize API docs path - default is /v3/api-docs)
springdoc.api-docs.path=/api-docs

# (#3:Show operation IDs in UI)
springdoc.swagger-ui.operations-sorter=method

# (#4:Expand all by default)
springdoc.swagger-ui.doc-expansion=list

Step 1.4: Create Main Application

Create src/main/java/com/example/docs/DocsApiApplication.java:

package com.example.docs;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DocsApiApplication {

    public static void main(String[] args) {
        SpringApplication.run(DocsApiApplication.class, args);
    }
}

Part 2: Create a Simple API

Step 2.1: Create Product DTO

Create src/main/java/com/example/docs/dto/ProductDTO.java:

package com.example.docs.dto;

import jakarta.validation.constraints.*;
import java.math.BigDecimal;

public class ProductDTO {

    private Long id;

    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100)
    private String name;

    @Size(max = 500)
    private String description;

    @NotNull(message = "Price is required")
    @DecimalMin(value = "0.01", message = "Price must be positive")
    private BigDecimal price;

    @NotBlank(message = "Category is required")
    private String category;

    private boolean inStock = true;

    // Constructors
    public ProductDTO() {}

    public ProductDTO(Long id, String name, String description, BigDecimal price, String category) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
        this.category = category;
    }

    // Getters and Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public boolean isInStock() { return inStock; }
    public void setInStock(boolean inStock) { this.inStock = inStock; }
}

Step 2.2: Create Basic Controller

Create src/main/java/com/example/docs/controller/ProductController.java:

package com.example.docs.controller;

import com.example.docs.dto.ProductDTO;
import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    // In-memory storage for demo
    private final Map<Long, ProductDTO> products = new HashMap<>();
    private final AtomicLong idGenerator = new AtomicLong(1);

    public ProductController() {
        // Sample data
        ProductDTO laptop = new ProductDTO(idGenerator.getAndIncrement(), "Laptop", "High-performance laptop", new BigDecimal("999.99"), "Electronics");
        ProductDTO mouse = new ProductDTO(idGenerator.getAndIncrement(), "Mouse", "Wireless mouse", new BigDecimal("29.99"), "Electronics");
        products.put(laptop.getId(), laptop);
        products.put(mouse.getId(), mouse);
    }

    @GetMapping
    public List<ProductDTO> getAllProducts() {
        return new ArrayList<>(products.values());
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProductDTO> getProductById(@PathVariable Long id) {
        ProductDTO product = products.get(id);
        if (product == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(product);
    }

    @PostMapping
    public ResponseEntity<ProductDTO> createProduct(@Valid @RequestBody ProductDTO product) {
        product.setId(idGenerator.getAndIncrement());
        products.put(product.getId(), product);
        return ResponseEntity.status(HttpStatus.CREATED).body(product);
    }

    @PutMapping("/{id}")
    public ResponseEntity<ProductDTO> updateProduct(@PathVariable Long id, @Valid @RequestBody ProductDTO product) {
        if (!products.containsKey(id)) {
            return ResponseEntity.notFound().build();
        }
        product.setId(id);
        products.put(id, product);
        return ResponseEntity.ok(product);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        if (!products.containsKey(id)) {
            return ResponseEntity.notFound().build();
        }
        products.remove(id);
        return ResponseEntity.noContent().build();
    }
}

Step 2.3: Test Auto-Generated Docs

cd documented-api
mvn spring-boot:run

Open your browser:

SpringDoc automatically discovered your controller and created documentation!

Part 3: Add Rich Documentation

Step 3.1: Configure Global API Info

Create src/main/java/com/example/docs/config/OpenApiConfig.java:

package com.example.docs.config;

import io.swagger.v3.oas.models.*;
import io.swagger.v3.oas.models.info.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                // (#1:API metadata)
                .info(new Info()
                        .title("Product Catalog API")
                        .version("1.0.0")
                        .description("A comprehensive API for managing product inventory. " +
                                "This API supports CRUD operations on products with filtering and pagination.")
                        .termsOfService("https://example.com/terms")
                        .contact(new Contact()
                                .name("API Support Team")
                                .email("api-support@example.com")
                                .url("https://example.com/support"))
                        .license(new License()
                                .name("Apache 2.0")
                                .url("https://www.apache.org/licenses/LICENSE-2.0")))

                // (#2:External documentation link)
                .externalDocs(new ExternalDocumentation()
                        .description("Full API Documentation")
                        .url("https://example.com/docs"));
    }
}

Step 3.2: Add Documentation Annotations to Controller

Update ProductController.java with OpenAPI annotations:

package com.example.docs.controller;

import com.example.docs.dto.ProductDTO;
import io.swagger.v3.oas.annotations.*;
import io.swagger.v3.oas.annotations.media.*;
import io.swagger.v3.oas.annotations.responses.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;

@RestController
@RequestMapping("/api/products")
@Tag(name = "Products", description = "Product management operations")  // (#1:Group endpoints)
public class ProductController {

    private final Map<Long, ProductDTO> products = new HashMap<>();
    private final AtomicLong idGenerator = new AtomicLong(1);

    public ProductController() {
        ProductDTO laptop = new ProductDTO(idGenerator.getAndIncrement(), "Laptop", "High-performance laptop", new BigDecimal("999.99"), "Electronics");
        ProductDTO mouse = new ProductDTO(idGenerator.getAndIncrement(), "Mouse", "Wireless mouse", new BigDecimal("29.99"), "Electronics");
        products.put(laptop.getId(), laptop);
        products.put(mouse.getId(), mouse);
    }

    // (#2:Document the operation)
    @Operation(
            summary = "Get all products",
            description = "Retrieves a list of all products in the catalog"
    )
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "Successfully retrieved products")
    })
    @GetMapping
    public List<ProductDTO> getAllProducts() {
        return new ArrayList<>(products.values());
    }

    // (#3:Document with multiple responses)
    @Operation(
            summary = "Get product by ID",
            description = "Retrieves a single product by its unique identifier"
    )
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "Product found",
                    content = @Content(schema = @Schema(implementation = ProductDTO.class))),
            @ApiResponse(responseCode = "404", description = "Product not found",
                    content = @Content)
    })
    @GetMapping("/{id}")
    public ResponseEntity<ProductDTO> getProductById(
            @Parameter(description = "Product ID", example = "1")  // (#4:Document parameter)
            @PathVariable Long id) {
        ProductDTO product = products.get(id);
        if (product == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(product);
    }

    // (#5:Document request body)
    @Operation(
            summary = "Create a new product",
            description = "Adds a new product to the catalog"
    )
    @ApiResponses({
            @ApiResponse(responseCode = "201", description = "Product created successfully"),
            @ApiResponse(responseCode = "400", description = "Invalid input data")
    })
    @PostMapping
    public ResponseEntity<ProductDTO> createProduct(
            @io.swagger.v3.oas.annotations.parameters.RequestBody(
                    description = "Product to create",
                    required = true,
                    content = @Content(schema = @Schema(implementation = ProductDTO.class)))
            @Valid @RequestBody ProductDTO product) {
        product.setId(idGenerator.getAndIncrement());
        products.put(product.getId(), product);
        return ResponseEntity.status(HttpStatus.CREATED).body(product);
    }

    @Operation(summary = "Update a product", description = "Updates an existing product")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "Product updated"),
            @ApiResponse(responseCode = "404", description = "Product not found"),
            @ApiResponse(responseCode = "400", description = "Invalid input")
    })
    @PutMapping("/{id}")
    public ResponseEntity<ProductDTO> updateProduct(
            @Parameter(description = "Product ID to update") @PathVariable Long id,
            @Valid @RequestBody ProductDTO product) {
        if (!products.containsKey(id)) {
            return ResponseEntity.notFound().build();
        }
        product.setId(id);
        products.put(id, product);
        return ResponseEntity.ok(product);
    }

    @Operation(summary = "Delete a product", description = "Removes a product from the catalog")
    @ApiResponses({
            @ApiResponse(responseCode = "204", description = "Product deleted"),
            @ApiResponse(responseCode = "404", description = "Product not found")
    })
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(
            @Parameter(description = "Product ID to delete") @PathVariable Long id) {
        if (!products.containsKey(id)) {
            return ResponseEntity.notFound().build();
        }
        products.remove(id);
        return ResponseEntity.noContent().build();
    }
}

Step 3.3: Add Schema Documentation to DTO

Update ProductDTO.java with schema annotations:

package com.example.docs.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;

@Schema(description = "Product information")  // (#1:Describe the schema)
public class ProductDTO {

    @Schema(description = "Unique product identifier", example = "1", accessMode = Schema.AccessMode.READ_ONLY)
    private Long id;

    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100)
    @Schema(description = "Product name", example = "MacBook Pro", required = true, minLength = 2, maxLength = 100)
    private String name;

    @Size(max = 500)
    @Schema(description = "Product description", example = "16-inch laptop with M3 chip")
    private String description;

    @NotNull(message = "Price is required")
    @DecimalMin(value = "0.01", message = "Price must be positive")
    @Schema(description = "Product price in USD", example = "2499.99", required = true, minimum = "0.01")
    private BigDecimal price;

    @NotBlank(message = "Category is required")
    @Schema(description = "Product category", example = "Electronics", required = true, allowableValues = {"Electronics", "Clothing", "Books", "Food"})
    private String category;

    @Schema(description = "Whether the product is currently in stock", example = "true", defaultValue = "true")
    private boolean inStock = true;

    // Constructors
    public ProductDTO() {}

    public ProductDTO(Long id, String name, String description, BigDecimal price, String category) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
        this.category = category;
    }

    // Getters and Setters (same as before)
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public boolean isInStock() { return inStock; }
    public void setInStock(boolean inStock) { this.inStock = inStock; }
}

Step 3.4: View Enhanced Documentation

Restart the application and refresh Swagger UI. You'll now see:

  • API title, version, and description
  • Contact and license information
  • Detailed descriptions for each endpoint
  • Example values in the schemas
  • Try-it-out functionality with pre-filled examples

Part 4: Add Query Parameters and Filtering

Step 4.1: Add Search Endpoint

Add this method to ProductController.java:

@Operation(
        summary = "Search products",
        description = "Search and filter products by various criteria"
)
@ApiResponses({
        @ApiResponse(responseCode = "200", description = "Search results returned")
})
@GetMapping("/search")
public List<ProductDTO> searchProducts(
        @Parameter(description = "Filter by product name (partial match)", example = "laptop")
        @RequestParam(required = false) String name,

        @Parameter(description = "Filter by category", example = "Electronics",
                schema = @Schema(allowableValues = {"Electronics", "Clothing", "Books", "Food"}))
        @RequestParam(required = false) String category,

        @Parameter(description = "Minimum price filter", example = "10.00")
        @RequestParam(required = false) BigDecimal minPrice,

        @Parameter(description = "Maximum price filter", example = "1000.00")
        @RequestParam(required = false) BigDecimal maxPrice,

        @Parameter(description = "Filter by stock availability")
        @RequestParam(required = false, defaultValue = "true") Boolean inStock) {

    return products.values().stream()
            .filter(p -> name == null || p.getName().toLowerCase().contains(name.toLowerCase()))
            .filter(p -> category == null || p.getCategory().equalsIgnoreCase(category))
            .filter(p -> minPrice == null || p.getPrice().compareTo(minPrice) >= 0)
            .filter(p -> maxPrice == null || p.getPrice().compareTo(maxPrice) <= 0)
            .filter(p -> inStock == null || p.isInStock() == inStock)
            .toList();
}

Part 5: Add JWT Security Documentation

Step 5.1: Configure Security Scheme

Update OpenApiConfig.java:

package com.example.docs.config;

import io.swagger.v3.oas.models.*;
import io.swagger.v3.oas.models.info.*;
import io.swagger.v3.oas.models.security.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        // (#1:Define JWT security scheme)
        final String securitySchemeName = "bearerAuth";

        return new OpenAPI()
                .info(new Info()
                        .title("Product Catalog API")
                        .version("1.0.0")
                        .description("A comprehensive API for managing product inventory.\n\n" +
                                "## Authentication\n" +
                                "This API uses JWT Bearer tokens for authentication.\n\n" +
                                "1. Get a token from `/api/auth/login`\n" +
                                "2. Click 'Authorize' button above\n" +
                                "3. Enter: `Bearer your-token-here`")
                        .contact(new Contact()
                                .name("API Support")
                                .email("support@example.com")))

                // (#2:Add security component)
                .addSecurityItem(new SecurityRequirement()
                        .addList(securitySchemeName))

                .components(new Components()
                        .addSecuritySchemes(securitySchemeName,
                                new SecurityScheme()
                                        .name(securitySchemeName)
                                        .type(SecurityScheme.Type.HTTP)
                                        .scheme("bearer")
                                        .bearerFormat("JWT")
                                        .description("Enter JWT token")));
    }
}
What this does:

Adds an "Authorize" button to Swagger UI. Users can enter their JWT token, and it will be automatically included in all subsequent requests.

Step 5.2: Mark Public Endpoints

For endpoints that don't require authentication, add this annotation:

import io.swagger.v3.oas.annotations.security.SecurityRequirements;

@Operation(summary = "Get all products")
@SecurityRequirements  // (#1:Empty = no auth required)
@GetMapping
public List<ProductDTO> getAllProducts() {
    // ...
}

Part 6: Group APIs with Tags

Step 6.1: Create Multiple Controllers with Tags

Create src/main/java/com/example/docs/controller/CategoryController.java:

package com.example.docs.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;
import java.util.*;

@RestController
@RequestMapping("/api/categories")
@Tag(name = "Categories", description = "Product category management")
public class CategoryController {

    private final List<String> categories = new ArrayList<>(
            List.of("Electronics", "Clothing", "Books", "Food", "Home & Garden")
    );

    @Operation(summary = "Get all categories")
    @GetMapping
    public List<String> getAllCategories() {
        return categories;
    }

    @Operation(summary = "Add a new category")
    @PostMapping
    public List<String> addCategory(@RequestBody String category) {
        categories.add(category);
        return categories;
    }
}

Step 6.2: Configure Tag Order

Update application.properties:

# Sort tags alphabetically
springdoc.swagger-ui.tags-sorter=alpha

# Or define custom order in OpenApiConfig:
# springdoc.swagger-ui.tagsSorter = custom order defined in OpenAPI bean

Part 7: Export and Use OpenAPI Spec

Step 7.1: Download OpenAPI JSON

While your app is running, download the spec:

# Download OpenAPI JSON
curl http://localhost:8080/api-docs -o openapi.json

# Or download YAML format
curl http://localhost:8080/api-docs.yaml -o openapi.yaml

Step 7.2: Understanding the Generated Spec

The openapi.json file contains:

{
  "openapi": "3.0.1",
  "info": {
    "title": "Product Catalog API",
    "version": "1.0.0"
  },
  "paths": {
    "/api/products": {
      "get": {
        "tags": ["Products"],
        "summary": "Get all products",
        "responses": {
          "200": {
            "description": "Successfully retrieved products",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProductDTO"
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ProductDTO": {
        "type": "object",
        "properties": {
          "id": { "type": "integer", "example": 1 },
          "name": { "type": "string", "example": "MacBook Pro" }
        }
      }
    }
  }
}

Step 7.3: Generate Client Code (Optional)

You can use the OpenAPI spec to generate client code in any language. Example using OpenAPI Generator:

# Install OpenAPI Generator (requires Java)
npm install @openapitools/openapi-generator-cli -g

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

# Generate Python client
openapi-generator-cli generate \
  -i http://localhost:8080/api-docs \
  -g python \
  -o ./python-client
Why generate clients?

Generated clients provide type-safe API access with proper method signatures, error handling, and serialization - all matching your exact API spec!

Deliverables Checklist

  • SpringDoc dependency added and working
  • Swagger UI accessible at /swagger-ui.html
  • API info configured (title, version, description)
  • All endpoints documented with @Operation
  • Response codes documented with @ApiResponses
  • DTOs documented with @Schema
  • Query parameters documented with @Parameter
  • Security scheme configured for JWT
  • OpenAPI JSON downloadable from /api-docs

Key Annotations Summary

AnnotationPurposeLocation
@TagGroup related endpointsController class
@OperationDescribe an endpointController method
@ApiResponsesDocument response codesController method
@ParameterDescribe path/query paramMethod parameter
@SchemaDescribe DTO propertiesDTO class/field
@HiddenExclude from docsAny

Bonus Challenges

Challenge 1: API Versioning

Add v1 and v2 API versions and configure Swagger to show them separately.

Challenge 2: Custom Response Examples

Add @ExampleObject annotations to show different example responses for success and error cases.

Challenge 3: Generate SDK

Use OpenAPI Generator to create a TypeScript or Python client and test it against your API.