Documenting Spring Boot REST APIs with OpenAPI and springdoc

Introduction

This tutorial documents a Spring Boot REST API with OpenAPI, using the springdoc-openapi library to generate the documentation from the code and serve it, together with the interactive Swagger UI. We will start with what OpenAPI is and why generating the description from code beats writing it by hand. We will then add springdoc to a book API and read the OpenAPI document it produces with no annotations at all. Next we will enrich it with annotations - tags, operation summaries, response codes, and schema descriptions and examples - plus general information about the API, and read the improved document. Lastly, we will look at Swagger UI, the configuration properties that matter, and how to keep the documentation out of production when needed.

(This tutorial documents the kind of API built in this module, with the validation from Bean Validation in Spring BootClick to read tutorial: Bean Validation in Spring Boot: @Valid and Custom Validators and the problem responses from REST Error Handling with @ControllerAdvice and ProblemDetailClick to read tutorial: REST Error Handling with @ControllerAdvice and ProblemDetail.)

What is OpenAPI?

The OpenAPI Specification (formerly known as Swagger) is a standard, language-neutral format for describing an HTTP API in JSON or YAML: its endpoints, their parameters and request bodies, the possible responses with their status codes, and the structure of every JSON object. One OpenAPI document serves many purposes -
  • Documentation that people read in a browser, with tools such as Swagger UI or Redoc.
  • Client generation: tools such as OpenAPI Generator create client libraries in Java, TypeScript, Python and many other languages.
  • Testing and mocking: tools can send requests based on it, or simulate the API before it exists.
  • API gateways can import it to set up routing and validation.
A hand-written description quickly drifts away from the real API. springdoc-openapi generates the document at runtime by inspecting the application's controllers - their mappings, parameters, return types and validation annotations - so the documentation always matches the code.

Adding springdoc

One dependency adds both the OpenAPI document and Swagger UI to a Spring MVC application. Version 3 of springdoc supports Spring Boot 4; 3.2.0 is built against Spring Boot 4.1.2 -
pom.xml - springdoc-openapi for Spring MVC
<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
  <version>3.2.0</version>
</dependency>
springdoc is a third-party library, so its version is not managed by Spring Boot and must be given explicitly. It brings the Swagger libraries that build the document - which still use Jackson 2 internally, so the application then contains Jackson 2 alongside Spring Boot's Jackson 3 - and the Swagger UI web files.

The API to document

The book API below combines ordinary Spring MVC code with OpenAPI annotations from the io.swagger.v3.oas.annotations package - @Tag, @Operation, @ApiResponse, @Parameter and @Schema - and an OpenAPI bean with general information about the API -
DocumentedApiApp.java - a book API annotated for OpenAPI
//DocumentedApiApp.java
package com.javabrahman.rest.openapi;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Schema(description = "A book in the catalogue")
record Book(
    @Schema(description = "Catalogue id, assigned by the server", example = "1") long id,
    @Schema(description = "Title of the book", example = "Effective Java") String title,
    @Schema(description = "Price in US dollars", example = "45.99") BigDecimal price) {
}

@Schema(description = "The data needed to add a book")
record NewBook(
    @NotBlank @Schema(example = "Clean Code") String title,
    @Positive @Schema(example = "37.49") BigDecimal price) {
}

class BookNotFoundException extends RuntimeException {
  BookNotFoundException(long id) {
    super("No book with id " + id);
  }
}

@RestController
@RequestMapping("/api/books")
@Tag(name = "Books", description = "Browse and add books in the catalogue")
class BookController {

  @GetMapping
  @Operation(summary = "List all books")
  List<Book> all() {
    return List.of(new Book(1, "Effective Java", new BigDecimal("45.99")));
  }

  @GetMapping("/{id}")
  @Operation(summary = "Get one book")
  @ApiResponse(responseCode = "200", description = "The book")
  @ApiResponse(responseCode = "404", description = "No book with this id",
      content = @Content(mediaType = "application/problem+json", schema = @Schema(implementation = ProblemDetail.class)))
  Book one(@Parameter(description = "Catalogue id of the book", example = "1") @PathVariable long id) {
    if (id != 1) {
      throw new BookNotFoundException(id);
    }
    return all().getFirst();
  }

  @PostMapping
  @ResponseStatus(HttpStatus.CREATED)
  @Operation(summary = "Add a book", description = "Adds a book and returns it with its new id.")
  Book create(@Valid @RequestBody NewBook book) {
    return new Book(2, book.title(), book.price());
  }
}

@RestControllerAdvice
class ApiExceptionHandler {
  @ExceptionHandler(BookNotFoundException.class)
  ProblemDetail notFound(BookNotFoundException e) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
  }
}

@SpringBootApplication
public class DocumentedApiApp {

  @Bean
  OpenAPI apiInfo() {
    return new OpenAPI().info(new Info()
        .title("JavaBrahman Book Catalogue API")
        .version("1.0")
        .description("A small example API for the Spring REST tutorials.")
        .contact(new Contact().name("JavaBrahman").url("https://www.javabrahman.com")));
  }

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

The generated OpenAPI document

springdoc serves the document at /v3/api-docs as JSON and at /v3/api-docs.yaml as YAML. Fetching the YAML version from the running application -  OUTPUT of GET /v3/api-docs.yaml
> curl -i http://localhost:8080/v3/api-docs.yaml
HTTP/1.1 200
Content-Length: 3038
Content-Type: application/vnd.oai.openapi

openapi: 3.1.0
info:
  title: JavaBrahman Book Catalogue API
  description: A small example API for the Spring REST tutorials.
  contact:
    name: JavaBrahman
    url: https://www.javabrahman.com
  version: "1.0"
servers:
- url: http://localhost:8080
  description: Generated server url
tags:
- name: Books
  description: Browse and add books in the catalogue
paths:
  /api/books:
    get:
      tags:
      - Books
      summary: List all books
      operationId: all
      responses:
        "200":
          description: OK
          content:
            '*/*':
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Book"
    post:
      tags:
      - Books
      summary: Add a book
      description: Adds a book and returns it with its new id.
      operationId: create
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NewBook"
        required: true
      responses:
        "201":
          description: Created
          content:
            '*/*':
              schema:
                $ref: "#/components/schemas/Book"
  /api/books/{id}:
    get:
      tags:
      - Books
      summary: Get one book
      operationId: one
      parameters:
      - name: id
        in: path
        description: Catalogue id of the book
        required: true
        schema:
          type: integer
          format: int64
        example: 1
      responses:
        "200":
          description: The book
          content:
            '*/*':
              schema:
                $ref: "#/components/schemas/Book"
        "404":
          description: No book with this id
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetail"
components:
  schemas:
    NewBook:
      type: object
      description: The data needed to add a book
      properties:
        title:
          type: string
          example: Clean Code
          minLength: 1
        price:
          type: number
          example: 37.49
          exclusiveMinimum: 0
      required:
      - title
    Book:
      type: object
      description: A book in the catalogue
      properties:
        id:
          type: integer
          format: int64
          description: "Catalogue id, assigned by the server"
          example: 1
        title:
          type: string
          description: Title of the book
          example: Effective Java
        price:
          type: number
          description: Price in US dollars
          example: 45.99
    ProblemDetail:
      type: object
      properties:
        type:
          type: string
          format: uri
        title:
          type: string
        status:
          type: integer
          format: int32
        detail:
          type: string
        instance:
          type: string
          format: uri
        properties:
          type: object
          additionalProperties: {}

Explanation of the document

  • openapi gives the version of the specification, and info comes from the OpenAPI bean: the title, version, description and contact.
  • servers lists the URL the document was requested from, so tools know where to send requests.
  • tags groups operations; Books and its description come from @Tag on the controller.
  • Under paths, every mapping appears with its HTTP method, an operationId taken from the method name, the summary and description from @Operation, its parameters - with the id path parameter's description and example from @Parameter - and its request body and responses.
  • The 201 response of the POST was inferred from @ResponseStatus(HttpStatus.CREATED), and the 404 of the single-book endpoint, with its application/problem+json body, came from @ApiResponse.
  • Under components/schemas, each record became a schema, with property types, descriptions and examples from @Schema. The validation annotations were translated too: @NotBlank made title required with a minLength, and @Positive became an exclusiveMinimum of 0. ProblemDetail got a schema as well.
  • Much of the document came from the Spring code alone: the paths and methods from the mappings, parameter and property types from the Java types, the request body from @RequestBody, the 201 from @ResponseStatus, and the constraints from the validation annotations. The io.swagger annotations added the human-friendly parts - descriptions, examples - and the 404 response, which springdoc cannot infer from an exception thrown inside the method.

Swagger UI

springdoc also serves Swagger UI, an interactive web page that renders the OpenAPI document: every endpoint grouped by tag, with its parameters, schemas and examples, and a Try it out button that sends real requests to the API from the browser. It is at /swagger-ui.html, which redirects to the page itself -  OUTPUT of requesting /swagger-ui.html
> curl -i http://localhost:8080/swagger-ui.html
HTTP/1.1 302
Location: /swagger-ui/index.html
Content-Length: 0
The 302 response points the browser to /swagger-ui/index.html, the Swagger UI page, which loads the OpenAPI document from /v3/api-docs and displays it. Opening http://localhost:8080/swagger-ui.html in a browser while the application runs shows the book API, ready to be tried out.

Useful springdoc properties

springdoc is configured with springdoc.* properties in application.properties -
  • springdoc.api-docs.path=/api-docs and springdoc.swagger-ui.path=/docs change where the document and the UI are served.
  • springdoc.packages-to-scan and springdoc.paths-to-match=/api/** limit the document to some controllers or paths - useful to keep internal endpoints out.
  • springdoc.swagger-ui.operations-sorter=method and tags-sorter=alpha control the order in the UI.
  • springdoc.api-docs.enabled=false and springdoc.swagger-ui.enabled=false switch them off - typically in a production profile, if the API is not meant to be explored publicly.

Good practices

  • Let the code be the source of truth: rely on springdoc's inference, and add annotations only where they add information - summaries, examples, extra responses.
  • Document error responses, in particular the problem types your API returns, so that client developers know what to handle.
  • Publish the document as part of the build, for example by saving /v3/api-docs in a pipeline step, so that changes to the API show up in code review and clients can be generated from a fixed version.
  • Decide deliberately whether production exposes the document and Swagger UI; if it does, secure them like any other endpoint (Spring Security is covered in module 5).
  • Version the document along with the API, as described in API Versioning in Spring Framework 7Click to read tutorial: API Versioning in Spring Framework 7 and Spring Boot 4.

Summary

In this tutorial we first saw what the OpenAPI Specification is, what an OpenAPI document is used for, and why springdoc generates it from the code. We then added springdoc-openapi-starter-webmvc-ui 3.2.0 to a book API, and annotated it with @Tag, @Operation, @ApiResponse, @Parameter and @Schema, with general information from an OpenAPI bean. Next we read the generated YAML document - paths, responses, and schemas that even include the validation rules - and saw how Swagger UI is reached. Lastly, we listed the most useful springdoc properties and good practices. The final tutorial of this module covers CORS, content negotiation and file uploads: CORS, Content Negotiation and File UploadsClick to read tutorial: CORS, Content Negotiation and File Uploads in Spring Boot.