Spring Boot Course · Lesson 14

Documenting the API with OpenAPI

The Library API has ten endpoints, and the only way to learn them is to read the controllers or the lessons. A client developer has neither. This lesson gives the API a description that machines and people can both read — an OpenAPI spec, generated from the code by springdoc-openapi — and a page, Scalar, where anyone can browse the endpoints, copy a ready-made request and send it from the browser. Then it fixes what the generator gets wrong, and adds the descriptions only a person can write, without burying the controllers under them.

The win

/v3/api-docs serves an OpenAPI spec of the Library API, and /docs shows it as a reference you can try every endpoint from: the right status codes, page/size/sort as real parameters, the problem responses each operation can return, and a summary, description and example for every operation and field. The documentation lives in BookApi and AuthorApi interfaces, so the controllers stay as short as they were, and the page loads nothing from outside the app — no AI chat, its fonts served by the app itself. A test checks the spec is served and still says what the annotations put there.

A description of the API, generated from the code

OpenAPI is the standard way to describe an HTTP API: one JSON or YAML document listing every path and operation, its parameters, its request and response bodies, and the schema of each body [spec]. Tools read it: documentation pages render it, code generators build clients from it, API gateways and contract tests check requests against it.

You could write that document by hand, and then keep it in step with the code by hand, forever. Instead, the course generates the spec from the running application with springdoc-openapi: it reads the application's Spring MVC mappings — paths, HTTP methods, @PathVariables, @RequestBody records, return types, even the Bean Validation constraints — and serves the result [docs]. A new endpoint appears in the docs the moment it exists, and a renamed field is renamed in the docs too.

Step 1 — Add springdoc and look at what it generates

springdoc-openapi is not part of Spring Boot, so its version isn't managed by the parent: you name it. Version 3.x is the line for Spring Boot 4 [docs]; this lesson uses 3.1.1, the current release. Add it below uuid-creator:

pom.xml
83 collapsed lines
<?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 https://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>4.1.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>library</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>library</name>
<description/>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Bean Validation (@Valid + constraints); brings Hibernate Validator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Data JPA + Hibernate -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL JDBC driver (used at runtime to talk to the database) -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Liquibase migrations. The autoconfiguration lives in this module (it brings
liquibase-core transitively); the bare org.liquibase:liquibase-core won't activate it. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-liquibase</artifactId>
</dependency>
<!-- Starts/stops the Postgres container from compose.yaml when the app runs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-docker-compose</artifactId>
<optional>true</optional>
</dependency>
<!-- Lombok: generates getters/constructors/equals at compile time -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- uuid-creator: time-ordered UUIDv7 generation -->
<dependency>
<groupId>com.github.f4b6a3</groupId>
<artifactId>uuid-creator</artifactId>
<version>6.0.0</version> <!-- or the latest on Maven Central -->
</dependency>
<!-- springdoc-openapi: generates the OpenAPI spec from the controllers, serves it with Scalar -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-scalar</artifactId>
<version>3.1.1</version>
</dependency>
37 collapsed lines
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<!-- @DataJpaTest and TestEntityManager: the persistence-layer test slice -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<!-- @ServiceConnection: points Spring Boot at a database Testcontainers started -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers' PostgreSQL module: runs postgres in Docker for the tests -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

The webmvc-scalar starter brings both halves: the spec generator for Spring MVC, and Scalar, the page that renders the spec [docs]. Nothing else to configure. Start the app:

Terminal window
./mvnw spring-boot:run

Open http://localhost:8080/scalar in your browser. The sidebar lists the endpoints, grouped by controller; pick one to see its parameters, request body and responses, with a curl command for it on the right and a "Test Request" button that sends it from the page.

The course serves the docs with Scalar, through springdoc's own Scalar module: it reads as a reference, and every operation comes with a request you can copy into a terminal. The page you'll meet most often in Spring projects is Swagger UI, which renders the same spec as a list of expandable operations with a "Try it out" button. It is the same choice of one dependency — springdoc-openapi-starter-webmvc-ui instead, served at /swagger-ui.html [docs] — and nothing else in this lesson changes, because both pages render the spec springdoc generates.

The spec behind the page is at http://localhost:8080/v3/api-docs. It is one long line; most browsers offer to pretty-print it. Three parts of it — the API's info, the /api/books path with its two operations, and the CreateBookRequest schema:

"info": {
"title": "OpenAPI definition",
"version": "v0"
},
…
"/api/books": {
"get": {
"tags": [
"book-controller"
],
"operationId": "all",
"parameters": [
{
"name": "authorId",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "pageable",
"in": "query",
"required": true,
"schema": {
"$ref": "#/components/schemas/Pageable"
}
}
],
"responses": {
"200": { … }
}
},
"post": {
"tags": [
"book-controller"
],
"operationId": "create",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateBookRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/BookResponse"
}
}
}
}
}
}
},
…
"CreateBookRequest": {
"type": "object",
"properties": {
"title": {
"type": "string",
"minLength": 1
},
"year": {
"type": "integer",
"format": "int32",
"exclusiveMinimum": 0
},
"authorIds": {
"type": "array",
"items": {
"type": "string",
"format": "uuid"
},
"minItems": 1,
"uniqueItems": true
}
},
"required": [
"authorIds",
"title"
]
}

A lot is right, with no annotation at all:

Lesson 1's /hello endpoint is in the list too, under hello-controller.

It got four things wrong, though, and a client trusting the docs would be misled by each:

Stop the app with Ctrl+C. The next steps fix all four, and add what no generator can know: what each operation is for and what each field means.

Every operation gets a summary and its full list of responses, error responses included, and every parameter and every request and response field a description and an example. The docs are the API's front page — for a client developer, and for anyone looking at the project — and the code can only supply its shape, never its meaning.

Step 2 — Name the API

The title, version and description at the top of the spec come from an OpenAPI bean, if the application declares one [docs]. Give it a small configuration class of its own, in a new config package:

src/main/java/com/example/library/config/OpenApiConfig.java
package com.example.library.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class OpenApiConfig {
@Bean
OpenAPI libraryOpenApi() {
return new OpenAPI().info(new Info()
.title("Library API")
.version("v1")
.description("Books and their authors. Errors are RFC 9457 problem details (application/problem+json)."));
}
}
What proxyBeanMethods = false switches off

By default, Spring doesn't use a @Configuration class as you wrote it: at startup it generates a subclass of it, a proxy, and uses that instead [docs]. The proxy is there for configurations whose @Bean methods call each other, as in this made-up one:

@Configuration
public class ImportConfig {
@Bean
IdGenerator idGenerator() {
return new IdGenerator();
}
@Bean
BookImporter bookImporter() {
return new BookImporter(idGenerator()); // calls the other @Bean method
}
}

In plain Java, every call to idGenerator() runs the method again and returns a new IdGenerator, so BookImporter would get a different one from the bean Spring registered. The proxy overrides idGenerator(): the call returns the bean the container already holds, so the bean stays one object however often the method is called.

proxyBeanMethods = false asks for no proxy. Spring uses the class as written, and each @Bean method is an ordinary factory method that Spring calls once. That is safe whenever no @Bean method calls another — OpenApiConfig and TestcontainersConfiguration each have a single one — and it saves generating the subclass at startup. Spring Boot's own auto-configuration classes are written this way, and Spring Initializr generates TestcontainersConfiguration with it. A configuration without the proxy can still wire one bean into another: it takes the bean as a parameter, BookImporter bookImporter(IdGenerator idGenerator), and Spring passes in the one it holds.

Step 3 — Describe the errors once

Every error the API sends comes from Lesson 7's ApiExceptionHandler, as a problem body like this one — GET /api/books/{id} for a book that doesn't exist:

{"detail":"No book with id 01a125d2-f80e-7ba4-aa2d-d296d723ecb7","instance":"/api/books/01a125d2-f80e-7ba4-aa2d-d296d723ecb7","status":404,"title":"Resource not found"}

The usual way to document such a body is to point at Spring's ProblemDetail class, @Schema(implementation = ProblemDetail.class), and let springdoc describe it. That describes the Java class, not the JSON: it lists a type the API leaves out, and a properties object that never appears — Spring writes those extra properties, such as validation's errors, at the top level — and the docs page fills the example with placeholders, "title": "string", "status": 1. A client reading it learns nothing about this API's errors.

So each error response is described once, in ProblemResponses, with a schema of the fields the API really sends and an example copied from a real response, and the operations refer to it by name. The errors are shared — the same "no book with this id" comes from GET, PUT and DELETE — so describing them in one place also keeps them consistent. The class sits next to OpenApiConfig:

src/main/java/com/example/library/config/ProblemResponses.java
package com.example.library.config;
import java.util.LinkedHashMap;
import java.util.Map;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.examples.Example;
import io.swagger.v3.oas.models.media.Content;
import io.swagger.v3.oas.models.media.IntegerSchema;
import io.swagger.v3.oas.models.media.MediaType;
import io.swagger.v3.oas.models.media.ObjectSchema;
import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.oas.models.media.StringSchema;
import io.swagger.v3.oas.models.responses.ApiResponse;
// The API's error responses, each described once, with the body ApiExceptionHandler really sends.
public final class ProblemResponses {
// The names BookApi and AuthorApi refer to: @ApiResponse(responseCode = "404", ref = BOOK_NOT_FOUND)
public static final String MALFORMED_BOOK_ID = "MalformedBookId";
public static final String INVALID_BOOK = "InvalidBook";
public static final String INVALID_BOOK_OR_ID = "InvalidBookOrId";
public static final String BOOK_NOT_FOUND = "BookNotFound";
public static final String UNKNOWN_AUTHOR_ID = "UnknownAuthorId";
public static final String BOOK_OR_AUTHOR_NOT_FOUND = "BookOrAuthorNotFound";
public static final String MALFORMED_AUTHOR_ID = "MalformedAuthorId";
public static final String INVALID_AUTHOR = "InvalidAuthor";
public static final String INVALID_AUTHOR_OR_ID = "InvalidAuthorOrId";
public static final String AUTHOR_NOT_FOUND = "AuthorNotFound";
public static final String AUTHOR_HAS_BOOKS = "AuthorHasBooks";
// The same ids as the parameter and record examples
private static final String BOOK_ID = "01a125d2-f80e-7ba4-aa2d-d296d723ecb7";
private static final String AUTHOR_ID = "01a125d2-f7d2-77d5-a5a7-945523ee5cc1";
private ProblemResponses() {}
static Components components() {
Map<String, String> bookErrors = new LinkedHashMap<>();
bookErrors.put("year", "must be greater than 0");
bookErrors.put("title", "must not be blank");
bookErrors.put("authorIds", "must not be empty");
Map<String, String> authorErrors = new LinkedHashMap<>();
authorErrors.put("name", "must not be blank");
Example malformedBookId = problem("The id is not a UUID",
"Failed to convert 'id' with value: 'not-a-uuid'", "/api/books/not-a-uuid", 400, "Bad Request");
Example invalidNewBook = invalid("/api/books", bookErrors);
Example invalidBook = invalid("/api/books/" + BOOK_ID, bookErrors);
Example bookNotFound = problem("No book with this id",
"No book with id " + BOOK_ID, "/api/books/" + BOOK_ID, 404, "Resource not found");
Example unknownAuthorOfNewBook = problem("An author id doesn't exist",
"No author with id [" + AUTHOR_ID + "]", "/api/books", 404, "Resource not found");
Example unknownAuthorOfBook = problem("An author id doesn't exist",
"No author with id [" + AUTHOR_ID + "]", "/api/books/" + BOOK_ID, 404, "Resource not found");
Example malformedAuthorId = problem("The id is not a UUID",
"Failed to convert 'id' with value: 'not-a-uuid'", "/api/authors/not-a-uuid", 400, "Bad Request");
Example invalidNewAuthor = invalid("/api/authors", authorErrors);
Example invalidAuthor = invalid("/api/authors/" + AUTHOR_ID, authorErrors);
Example authorNotFound = problem("No author with this id",
"No author with id " + AUTHOR_ID, "/api/authors/" + AUTHOR_ID, 404, "Resource not found");
Example authorHasBooks = problem("The author still has books",
"Author " + AUTHOR_ID + " still has books; delete or re-author them first", "/api/authors/" + AUTHOR_ID,
409, "Conflict");
return new Components()
.addSchemas("Problem", problemSchema())
.addResponses(MALFORMED_BOOK_ID, response("The id is not a UUID", malformedBookId))
.addResponses(INVALID_BOOK, response("The request body is invalid", invalidNewBook))
.addResponses(INVALID_BOOK_OR_ID, response("The request body is invalid, or the id is not a UUID",
invalidBook, malformedBookId))
.addResponses(BOOK_NOT_FOUND, response("No book with this id", bookNotFound))
.addResponses(UNKNOWN_AUTHOR_ID, response("An author id doesn't exist", unknownAuthorOfNewBook))
.addResponses(BOOK_OR_AUTHOR_NOT_FOUND, response("No book with this id, or an author id doesn't exist",
bookNotFound, unknownAuthorOfBook))
.addResponses(MALFORMED_AUTHOR_ID, response("The id is not a UUID", malformedAuthorId))
.addResponses(INVALID_AUTHOR, response("The request body is invalid", invalidNewAuthor))
.addResponses(INVALID_AUTHOR_OR_ID, response("The request body is invalid, or the id is not a UUID",
invalidAuthor, malformedAuthorId))
.addResponses(AUTHOR_NOT_FOUND, response("No author with this id", authorNotFound))
.addResponses(AUTHOR_HAS_BOOKS, response("The author still has books", authorHasBooks));
}
// The fields ApiExceptionHandler's problems have; "errors" only on a failed validation.
private static Schema<?> problemSchema() {
return new ObjectSchema()
.description("An RFC 9457 problem detail")
.addProperty("detail", new StringSchema().description("What went wrong with this request"))
.addProperty("instance", new StringSchema().description("The path of the request"))
.addProperty("status", new IntegerSchema().description("The HTTP status code"))
.addProperty("title", new StringSchema().description("A short summary of the kind of problem"))
.addProperty("errors", new ObjectSchema()
.additionalProperties(new StringSchema())
.description("Only when validation failed: each invalid field and its message"));
}
// A response with a Problem body and the given examples, each named by its summary.
private static ApiResponse response(String description, Example... examples) {
MediaType problem = new MediaType().schema(new Schema<>().$ref("#/components/schemas/Problem"));
for (Example example : examples) {
problem.addExamples(example.getSummary(), example);
}
return new ApiResponse()
.description(description)
.content(new Content().addMediaType("application/problem+json", problem));
}
// An example problem with the given fields.
private static Example problem(String summary, String detail, String instance, int status, String title) {
return new Example().summary(summary).value(body(detail, instance, status, title));
}
// An example of a failed validation: ApiExceptionHandler's fixed detail and title, plus the field errors.
private static Example invalid(String instance, Map<String, String> errors) {
Map<String, Object> body = body("One or more fields are invalid.", instance, 400, "Validation failed");
body.put("errors", errors);
return new Example().summary("The request body is invalid").value(body);
}
// A problem body, its fields in the order the API sends them.
private static Map<String, Object> body(String detail, String instance, int status, String title) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("detail", detail);
body.put("instance", instance);
body.put("status", status);
body.put("title", title);
return body;
}
}

OpenApiConfig adds the components to the spec:

src/main/java/com/example/library/config/OpenApiConfig.java
package com.example.library.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class OpenApiConfig {
@Bean
OpenAPI libraryOpenApi() {
return new OpenAPI()
.info(new Info()
.title("Library API")
.version("v1")
.description("Books and their authors. Errors are RFC 9457 problem details (application/problem+json)."))
.components(ProblemResponses.components()); // the error responses BookApi and AuthorApi refer to
}
}

Step 4 — Document the book endpoints in an interface

The rest of the information is per endpoint, written with the annotations of the Swagger library (io.swagger.v3.oas.annotations) and one from springdoc. For one operation that is a summary, a description, three or four responses and a parameter description — more lines than the method itself. Put on the controller, it would bury the code a reader of BookController comes for.

So the documentation goes in an interface, BookApi, that BookController implements: the interface holds what the docs say, the controller what Spring MVC does. springdoc reads the annotations from the interface method a controller method implements [docs], so the spec comes out the same as if they were on the controller. Create it next to the controller:

src/main/java/com/example/library/book/BookApi.java
package com.example.library.book;
import java.util.UUID;
import com.example.library.config.ProblemResponses;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
// The book endpoints' API documentation. BookController implements it and keeps only the Spring MVC code.
@Tag(name = "Books", description = "Create, read, replace and delete books")
public interface BookApi {
@Operation(summary = "List books", description = "A page of books, sorted by title unless `sort` says otherwise.")
@ApiResponse(responseCode = "200", description = "A page of books")
Page<BookResponse> all(
@Parameter(description = "Only the books this author wrote or co-wrote", example = "01a125d2-f7d2-77d5-a5a7-945523ee5cc1")
UUID authorId,
@ParameterObject Pageable pageable);
@Operation(summary = "Get a book")
@ApiResponse(responseCode = "200", description = "The book")
@ApiResponse(responseCode = "400", ref = ProblemResponses.MALFORMED_BOOK_ID)
@ApiResponse(responseCode = "404", ref = ProblemResponses.BOOK_NOT_FOUND)
BookResponse byId(@Parameter(description = "The book's id", example = "01a125d2-f80e-7ba4-aa2d-d296d723ecb7") UUID id);
@Operation(summary = "Create a book", description = "Every author id must belong to an existing author.")
@ApiResponse(responseCode = "201", description = "Created; the Location header holds the new book's URL")
@ApiResponse(responseCode = "400", ref = ProblemResponses.INVALID_BOOK)
@ApiResponse(responseCode = "404", ref = ProblemResponses.UNKNOWN_AUTHOR_ID)
ResponseEntity<BookResponse> create(CreateBookRequest req);
@Operation(summary = "Replace a book", description = "Sets the title, year and authors; authors left out are unlinked.")
@ApiResponse(responseCode = "200", description = "The book as stored now")
@ApiResponse(responseCode = "400", ref = ProblemResponses.INVALID_BOOK_OR_ID)
@ApiResponse(responseCode = "404", ref = ProblemResponses.BOOK_OR_AUTHOR_NOT_FOUND)
BookResponse replace(
@Parameter(description = "The book's id", example = "01a125d2-f80e-7ba4-aa2d-d296d723ecb7") UUID id,
CreateBookRequest req);
@Operation(summary = "Delete a book", description = "Its authors stay.")
@ApiResponse(responseCode = "204", description = "Deleted")
@ApiResponse(responseCode = "400", ref = ProblemResponses.MALFORMED_BOOK_ID)
@ApiResponse(responseCode = "404", ref = ProblemResponses.BOOK_NOT_FOUND)
void delete(@Parameter(description = "The book's id", example = "01a125d2-f80e-7ba4-aa2d-d296d723ecb7") UUID id);
}

BookController then changes in two ways only: it implements BookApi, and each handler method is marked @Override:

src/main/java/com/example/library/book/BookController.java
24 collapsed lines
package com.example.library.book;
import java.net.URI;
import java.util.UUID;
import jakarta.validation.Valid;
import com.example.library.util.Pageables;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/books")
public class BookController implements BookApi {
5 collapsed lines
private final BookService books;
public BookController(BookService books) {
this.books = books;
}
@Override
@GetMapping // GET /api/books?authorId=...&page=0&size=20&sort=title
public Page<BookResponse> all(
@RequestParam(required = false) UUID authorId,
@PageableDefault(size = 20, sort = "title") Pageable pageable) {
return books.findAll(authorId, Pageables.withIdTiebreaker(pageable)).map(BookResponse::from);
}
@Override
@GetMapping("/{id}")
public BookResponse byId(@PathVariable UUID id) {
return BookResponse.from(books.getById(id));
}
@Override
@PostMapping // POST /api/books
public ResponseEntity<BookResponse> create(@Valid @RequestBody CreateBookRequest req) {
Book saved = books.create(req.title(), req.year(), req.authorIds());
URI location = URI.create("/api/books/" + saved.getId());
return ResponseEntity.created(location).body(BookResponse.from(saved));
}
@Override
@PutMapping("/{id}") // PUT /api/books/{id}
public BookResponse replace(@PathVariable UUID id, @Valid @RequestBody CreateBookRequest req) {
return BookResponse.from(books.replace(id, req.title(), req.year(), req.authorIds()));
}
@Override
@DeleteMapping("/{id}") // DELETE /api/books/{id}
@ResponseStatus(HttpStatus.NO_CONTENT) // 204 on success
public void delete(@PathVariable UUID id) {
books.delete(id); // throws NotFoundException -> 404
}
}

Step 5 — Describe the book records

The schemas come from the records themselves, so their documentation stays on them: @Schema on the record describes the whole object; on a component, that field — with an example, which the docs page uses in the requests and responses it shows:

src/main/java/com/example/library/book/CreateBookRequest.java
package com.example.library.book;
import java.util.Set;
import java.util.UUID;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Positive;
@Schema(description = "A book to create, or the new state of an existing one")
public record CreateBookRequest(
@Schema(description = "The book's title", example = "The Pragmatic Programmer")
@NotBlank String title, // not null, and not just whitespace
@Schema(description = "Year of publication", example = "1999")
@Positive int year, // must be > 0
@Schema(description = "Ids of the book's authors; each must exist", example = "[\"01a125d2-f7d2-77d5-a5a7-945523ee5cc1\"]")
@NotEmpty Set<UUID> authorIds // at least one author
) {}
src/main/java/com/example/library/book/BookResponse.java
package com.example.library.book;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import com.example.library.author.AuthorResponse;
import io.swagger.v3.oas.annotations.media.Schema;
// What we SEND BACK. Authors as a list, sorted by name so the JSON order is stable.
@Schema(description = "A book with its authors")
public record BookResponse(
@Schema(description = "The book's id, a UUIDv7", example = "01a125d2-f80e-7ba4-aa2d-d296d723ecb7") UUID id,
@Schema(description = "The book's title", example = "The Pragmatic Programmer") String title,
@Schema(description = "Year of publication", example = "1999") int year,
@Schema(description = "The book's authors, sorted by name") List<AuthorResponse> authors) {
public static BookResponse from(Book book) {
List<AuthorResponse> authors = book.getAuthors().stream()
.map(AuthorResponse::from)
.sorted(Comparator.comparing(AuthorResponse::name))
.toList();
return new BookResponse(book.getId(), book.getTitle(), book.getYear(), authors);
}
}

Step 6 — The author endpoints and records

The same for authors. AuthorApi first:

src/main/java/com/example/library/author/AuthorApi.java
package com.example.library.author;
import java.util.UUID;
import com.example.library.config.ProblemResponses;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
// The author endpoints' API documentation. AuthorController implements it and keeps only the Spring MVC code.
@Tag(name = "Authors", description = "Create, read, replace and delete authors")
public interface AuthorApi {
@Operation(summary = "List authors", description = "A page of authors, sorted by name unless `sort` says otherwise.")
@ApiResponse(responseCode = "200", description = "A page of authors")
Page<AuthorResponse> all(@ParameterObject Pageable pageable);
@Operation(summary = "Get an author")
@ApiResponse(responseCode = "200", description = "The author")
@ApiResponse(responseCode = "400", ref = ProblemResponses.MALFORMED_AUTHOR_ID)
@ApiResponse(responseCode = "404", ref = ProblemResponses.AUTHOR_NOT_FOUND)
AuthorResponse byId(@Parameter(description = "The author's id", example = "01a125d2-f7d2-77d5-a5a7-945523ee5cc1") UUID id);
@Operation(summary = "Create an author")
@ApiResponse(responseCode = "201", description = "Created; the Location header holds the new author's URL")
@ApiResponse(responseCode = "400", ref = ProblemResponses.INVALID_AUTHOR)
ResponseEntity<AuthorResponse> create(CreateAuthorRequest req);
@Operation(summary = "Replace an author")
@ApiResponse(responseCode = "200", description = "The author as stored now")
@ApiResponse(responseCode = "400", ref = ProblemResponses.INVALID_AUTHOR_OR_ID)
@ApiResponse(responseCode = "404", ref = ProblemResponses.AUTHOR_NOT_FOUND)
AuthorResponse replace(
@Parameter(description = "The author's id", example = "01a125d2-f7d2-77d5-a5a7-945523ee5cc1") UUID id,
CreateAuthorRequest req);
@Operation(summary = "Delete an author", description = "Only an author with no books can be deleted.")
@ApiResponse(responseCode = "204", description = "Deleted")
@ApiResponse(responseCode = "400", ref = ProblemResponses.MALFORMED_AUTHOR_ID)
@ApiResponse(responseCode = "404", ref = ProblemResponses.AUTHOR_NOT_FOUND)
@ApiResponse(responseCode = "409", ref = ProblemResponses.AUTHOR_HAS_BOOKS)
void delete(@Parameter(description = "The author's id", example = "01a125d2-f7d2-77d5-a5a7-945523ee5cc1") UUID id);
}

Only DELETE can answer 409, the one Lesson 7's conflict rule produces, so only DELETE declares it. AuthorController implements it, the same two changes as BookController:

src/main/java/com/example/library/author/AuthorController.java
23 collapsed lines
package com.example.library.author;
import java.net.URI;
import java.util.UUID;
import jakarta.validation.Valid;
import com.example.library.util.Pageables;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
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;
@RestController
@RequestMapping("/api/authors")
public class AuthorController implements AuthorApi {
5 collapsed lines
private final AuthorService authors;
public AuthorController(AuthorService authors) {
this.authors = authors;
}
@Override
@GetMapping // GET /api/authors?page=0&size=20&sort=name
public Page<AuthorResponse> all(@PageableDefault(size = 20, sort = "name") Pageable pageable) {
return authors.findAll(Pageables.withIdTiebreaker(pageable)).map(AuthorResponse::from);
}
@Override
@GetMapping("/{id}")
public AuthorResponse byId(@PathVariable UUID id) {
return AuthorResponse.from(authors.getById(id));
}
@Override
@PostMapping
public ResponseEntity<AuthorResponse> create(@Valid @RequestBody CreateAuthorRequest req) {
Author saved = authors.create(req.name());
URI location = URI.create("/api/authors/" + saved.getId());
return ResponseEntity.created(location).body(AuthorResponse.from(saved));
}
@Override
@PutMapping("/{id}")
public AuthorResponse replace(@PathVariable UUID id, @Valid @RequestBody CreateAuthorRequest req) {
return AuthorResponse.from(authors.replace(id, req.name()));
}
@Override
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204 on success
public void delete(@PathVariable UUID id) {
authors.delete(id); // NotFoundException -> 404, ConflictException -> 409
}
}

And the two records:

src/main/java/com/example/library/author/CreateAuthorRequest.java
package com.example.library.author;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
@Schema(description = "An author to create, or the new state of an existing one")
public record CreateAuthorRequest(
@Schema(description = "The author's name", example = "Andrew Hunt") @NotBlank String name) {}
src/main/java/com/example/library/author/AuthorResponse.java
package com.example.library.author;
import java.util.UUID;
import io.swagger.v3.oas.annotations.media.Schema;
// What we SEND BACK for an author - on its own, or nested inside a book.
@Schema(description = "An author")
public record AuthorResponse(
@Schema(description = "The author's id, a UUIDv7", example = "01a125d2-f7d2-77d5-a5a7-945523ee5cc1") UUID id,
@Schema(description = "The author's name", example = "Andrew Hunt") String name) {
public static AuthorResponse from(Author author) {
return new AuthorResponse(author.getId(), author.getName());
}
}

Step 7 — A guide for what the endpoints share

The reference describes each endpoint on its own. What they have in common — how to get started, what an id is, how paging works, what an error looks like — has no endpoint to live on, and a client developer needs it before the list makes sense.

That goes in a short guide, written by hand in Markdown, kept in the project next to the code and served as the API's description. The spec's description is Markdown [spec], and Scalar shows it at the top of the page, with each ## heading as its own entry in the sidebar. Create src/main/resources/api-guide.md:

src/main/resources/api-guide.md
Books and their authors, as a JSON API. The sidebar lists every endpoint; this guide covers what they have in common.
## Getting started
The API runs on `http://localhost:8080`. Create an author:
```bash
curl -i -X POST http://localhost:8080/api/authors \
-H 'Content-Type: application/json' \
-d '{"name": "Andrew Hunt"}'
```
The answer is `201 Created`: the new author in the body, its address in the `Location` header. Create a book by that author, with the `id` from the body:
```bash
curl -i -X POST http://localhost:8080/api/books \
-H 'Content-Type: application/json' \
-d '{"title": "The Pragmatic Programmer", "year": 1999, "authorIds": ["<the author id>"]}'
```
Then list the books:
```bash
curl http://localhost:8080/api/books
```
## Ids
Books and authors are identified by UUIDs, version 7, which the API creates. A request body never carries an id: a `POST` returns the new one, in the body and in the `Location` header.
## Paging and sorting
`GET /api/books` and `GET /api/authors` return one page at a time, as `{ "content": [...], "page": {...} }`, where `page` holds `size`, `number`, `totalElements` and `totalPages`. Three query parameters choose the page:
| Parameter | Meaning | Default |
| --- | --- | --- |
| `page` | Page number, starting at 0 | `0` |
| `size` | Items per page; more than 100 counts as 100 | `20` |
| `sort` | `property,asc` or `property,desc`; repeat it to sort by several | `title` (books), `name` (authors) |
Items that sort equal are ordered by id, so the pages of a list never overlap.
## Errors
Every error is an RFC 9457 problem detail, `application/problem+json`, with `detail`, `instance`, `status` and `title`:
| Status | When |
| --- | --- |
| `400` | The request body is invalid (the body then has an `errors` object: field name to message), or an id in the path isn't a UUID |
| `404` | No book or author with that id, or a book names an author id that doesn't exist |
| `409` | An author can't be deleted while they still have books |
Each operation lists the errors it can return, with an example of each.

OpenApiConfig reads it as the description, in place of the one line it had:

src/main/java/com/example/library/config/OpenApiConfig.java
package com.example.library.config;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
@Configuration(proxyBeanMethods = false)
public class OpenApiConfig {
@Bean
OpenAPI libraryOpenApi() throws IOException {
return new OpenAPI()
.info(new Info()
.title("Library API")
.version("v1")
.description(new ClassPathResource("api-guide.md").getContentAsString(StandardCharsets.UTF_8)))
.components(ProblemResponses.components()); // the error responses BookApi and AuthorApi refer to
}
}

Step 8 — Look again

Start the app again:

Terminal window
./mvnw spring-boot:run

Reload http://localhost:8080/scalar. The page is titled "Library API" with the guide below it, and the sidebar starts with the guide's sections — "Getting started", "Ids", "Paging and sorting", "Errors" — then groups the endpoints under "Books" and "Authors", each by its summary; open "Create a book" and the body fields carry their descriptions, the responses list 201, 400 and 404, and the curl command on the right sends the example body; switch the example on the right to 404 and it shows the body the API really sends. The info, the /api/books path and one of the shared responses in http://localhost:8080/v3/api-docs now:

"info": {
"title": "Library API",
"description": "Books and their authors, as a JSON API. The sidebar lists every endpoint; this guide covers what they have in common.\n\n## Getting started\n\nThe API runs on …",
"version": "v1"
},
…
"/api/books": {
"get": {
"tags": [
"Books"
],
"summary": "List books",
"description": "A page of books, sorted by title unless `sort` says otherwise.",
"operationId": "all",
"parameters": [
{
"name": "authorId",
"in": "query",
"description": "Only the books this author wrote or co-wrote",
"required": false,
"schema": {
"type": "string",
"format": "uuid"
},
"example": "01a125d2-f7d2-77d5-a5a7-945523ee5cc1"
},
{
"name": "page",
"in": "query",
"description": "Zero-based page index (0..N)",
"required": false,
"schema": {
"type": "integer",
"default": 0,
"minimum": 0
}
},
{
"name": "size",
"in": "query",
"description": "The size of the page to be returned",
"required": false,
"schema": {
"type": "integer",
"default": 20,
"minimum": 1
}
},
{
"name": "sort",
"in": "query",
"description": "Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.",
"required": false,
"schema": {
"type": "array",
"default": [
"title,ASC"
],
"items": {
"type": "string"
}
}
}
],
"responses": {
"200": { … }
}
},
"post": {
"tags": [
"Books"
],
"summary": "Create a book",
"description": "Every author id must belong to an existing author.",
"operationId": "create",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateBookRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Created; the Location header holds the new book's URL",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/BookResponse"
}
}
}
},
"400": {
"$ref": "#/components/responses/InvalidBook"
},
"404": {
"$ref": "#/components/responses/UnknownAuthorId"
}
}
}
},
…
"UnknownAuthorId": {
"description": "An author id doesn't exist",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
},
"examples": {
"An author id doesn't exist": {
"summary": "An author id doesn't exist",
"value": {
"detail": "No author with id [01a125d2-f7d2-77d5-a5a7-945523ee5cc1]",
"instance": "/api/books",
"status": 404,
"title": "Resource not found"
}
}
}
}
}
}
…

Compared with Step 1: the title and version are the API's own, and the description is the guide, one long Markdown string; the tag is Books; authorId has its description and example; page, size and sort are three optional parameters, with the defaults from the controller's @PageableDefault — size 20, sort title,ASC — and springdoc's own descriptions; POST answers 201, and its 400 and 404 are references to two of Step 3's shared responses. The last part is one of them, under components: the Problem schema and the example, the same body POST /api/books returns for an author that doesn't exist. The words came from BookApi, ProblemResponses and OpenApiConfig; the paths and defaults from the controller. The record schemas, further down, now carry the descriptions and examples from @Schema.

Stop the app with Ctrl+C.

Who sees the docs?

Anyone who can reach the app can read /v3/api-docs and open /scalar, in production too. For a public API that is the point. For an internal one you may not want to publish its map: springdoc.api-docs.enabled=false switches the spec off [docs] and scalar.enabled=false the page, typically in the configuration of the environments where they shouldn't be. The Library API keeps them on.

Step 9 — Make the docs page the app's own

Open your browser's developer tools on http://localhost:8080/scalar, switch to the Network tab and reload. Besides the app's own /scalar/scalar.js and /v3/api-docs, the page talks to Scalar's servers: it loads its fonts from fonts.scalar.com, and it fetches a list of public APIs through proxy.scalar.com. That list belongs to two extras in the page's header:

The page also carries Scalar's name instead of the API's: it lives at /scalar, a path named after the tool, and the browser tab says "Scalar API Reference". A client shouldn't need to know which tool renders the docs; /docs says what is there.

The docs page is self-contained: no AI chat, no developer toolbar, no separate API client, and its fonts served by the app itself, so nothing about the API leaves the app and the page doesn't depend on anyone else's servers to look right. Both fonts, Inter and JetBrains Mono, are open source under the SIL Open Font License, which allows shipping them with your app as long as the license goes with them.

Download them from their projects' official releases into src/main/resources/static/fonts/, each with its license. Run this from the project directory (the Inter archive is about 34 MB):

Terminal window
mkdir -p src/main/resources/static/fonts
curl -L -o /tmp/inter.zip https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip
unzip -j /tmp/inter.zip web/InterVariable.woff2 -d src/main/resources/static/fonts
unzip -p /tmp/inter.zip LICENSE.txt > src/main/resources/static/fonts/Inter-LICENSE.txt
curl -L -o /tmp/jetbrains-mono.zip https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip
unzip -j /tmp/jetbrains-mono.zip fonts/webfonts/JetBrainsMono-Regular.woff2 -d src/main/resources/static/fonts
unzip -p /tmp/jetbrains-mono.zip OFL.txt > src/main/resources/static/fonts/JetBrainsMono-LICENSE.txt

Then seven settings at the end of application.properties:

src/main/resources/application.properties
13 collapsed lines
# Hibernate never alters the schema - Liquibase owns it. Just verify entities match the tables.
spring.jpa.hibernate.ddl-auto=validate
# Render Page results in the stable { content, page } shape (PagedModel) instead of raw PageImpl
spring.data.web.pageable.serialization-mode=via_dto
# Cap how many rows one page request can pull
spring.data.web.pageable.max-page-size=100
# (optional) change the default page size for unparameterized requests
spring.data.web.pageable.default-page-size=20
# Close the session at the end of the service layer, not the end of the request
spring.jpa.open-in-view=false
# Docs page (Scalar) at /docs, titled after the API
scalar.path=/docs
scalar.page-title=Library API
# No AI chat - it would send the spec to Scalar's servers - and no developer toolbar
scalar.agent.disabled=true
scalar.show-developer-tools=never
# No separate API client: each operation's Test Request button is enough
scalar.hide-client-button=true
# Fonts from the app itself (static/fonts), not from Scalar's font server; no "Powered by Scalar" link
scalar.with-default-fonts=false
scalar.custom-css=\
@font-face { font-family: "Inter"; font-weight: 100 900; font-display: swap; src: url(/fonts/InterVariable.woff2) format("woff2"); } \
@font-face { font-family: "JetBrains Mono"; font-weight: 400; font-display: swap; src: url(/fonts/JetBrainsMono-Regular.woff2) format("woff2"); } \
a[href="https://www.scalar.com"] { display: none; }

Restart the app (./mvnw spring-boot:run) and open the page at its new address, http://localhost:8080/docs, with the Network tab open. The tab reads "Library API", and every request goes to localhost:8080: the page, /docs/scalar.js, /v3/api-docs and the two font files. "Ask AI" and "Developer Tools" are gone from the header, "Open API Client" and "Powered by Scalar" from the sidebar, and the text looks as it did. Stop the app with Ctrl+C.

Step 10 — A test for the docs

The spec is produced at runtime. Nothing at startup checks it: if a future springdoc and Spring Boot stop getting along, the app starts fine and the docs endpoint answers 500. And an annotation removed by accident — a @ParameterObject, an @ApiResponse — makes the docs wrong without breaking anything else.

So one integration test asks for the spec and checks what the annotations put there. It goes in the base test package, next to Lesson 13's:

src/test/java/com/example/library/ApiDocsIntegrationTest.java
package com.example.library;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.context.annotation.Import;
import org.springframework.test.web.servlet.client.RestTestClient;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) // the same three annotations as LibraryApiIntegrationTest,
@AutoConfigureRestTestClient // so both classes share one cached context
@Import(TestcontainersConfiguration.class)
class ApiDocsIntegrationTest {
@Autowired RestTestClient client;
@Test
@DisplayName("/v3/api-docs serves the spec, with what the code alone can't say")
void apiDocs() {
client.get().uri("/v3/api-docs")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.info.title").isEqualTo("Library API")
.jsonPath("$.paths['/api/books'].post.responses['201']").exists()
.jsonPath("$.paths['/api/books'].get.parameters[*].name").isEqualTo(List.of("authorId", "page", "size", "sort"))
.jsonPath("$.paths['/api/authors/{id}'].delete.responses['409']['$ref']").isEqualTo("#/components/responses/AuthorHasBooks")
.jsonPath("$.components.responses.AuthorHasBooks.content['application/problem+json'].examples").exists();
}
}
Why not compare the whole spec strictly?

Lesson 11 and Lesson 13 compare every response body in full, because the body is the API's contract and every byte of it is the application's. The spec is different: it is hundreds of lines, and much of its exact text is springdoc's — the order of the paths, the descriptions it writes for page and size, the schema of ProblemDetail — and changes with springdoc versions. A strict comparison would fail on each upgrade for reasons that aren't regressions. The test checks the claims the code makes and leaves the rest to springdoc.

Run it:

Terminal window
./mvnw test -Dtest=ApiDocsIntegrationTest
[INFO] Running com.example.library.ApiDocsIntegrationTest
…
2026-10-11T20:07:13.904Z INFO 60040 --- [ main] c.e.library.ApiDocsIntegrationTest : Started ApiDocsIntegrationTest in 17.773 seconds (process running for 20.039)
2026-10-11T20:07:14.999Z WARN 60040 --- [ main] o.s.core.events.SpringDocAppInitializer : SpringDoc /v3/api-docs endpoint is enabled by default. To disable it in production, set the property 'springdoc.api-docs.enabled=false'
2026-10-11T20:07:14.999Z WARN 60040 --- [ main] o.s.core.events.SpringDocAppInitializer : SpringDoc /docs endpoint is enabled by default. To disable it in production, set the property 'scalar.enabled=false'
…
2026-10-11T20:07:17.191Z INFO 60040 --- [o-auto-1-exec-1] o.springdoc.api.AbstractOpenApiResource : Init duration for springdoc-openapi is: 1035 ms
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 22.43 s -- in com.example.library.ApiDocsIntegrationTest
…
[INFO] BUILD SUCCESS

Now make it fail the way it is meant to. In BookApi, delete @ParameterObject from the all method's pageable parameter (leave the import), and run the test again:

Terminal window
./mvnw test -Dtest=ApiDocsIntegrationTest
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 21.91 s <<< FAILURE! -- in com.example.library.ApiDocsIntegrationTest
[ERROR] com.example.library.ApiDocsIntegrationTest.apiDocs -- Time elapsed: 2.010 s <<< FAILURE!
java.lang.AssertionError: JSON path "$.paths['/api/books'].get.parameters[*].name" expected:<[authorId, page, size, sort]> but was:<[authorId, pageable]>
…
at com.example.library.ApiDocsIntegrationTest.apiDocs(ApiDocsIntegrationTest.java:29)
…
[INFO] BUILD FAILURE

The app compiles, starts and serves every request as before — only the docs went back to Step 1's single pageable object, and the message says exactly that, path and both values.

Put @ParameterObject back.

Step 11 — Run it

Run the whole suite:

Terminal window
./mvnw test
[INFO] Running com.example.library.LibraryApiIntegrationTest
…
2026-10-11T20:08:11.516Z INFO 61246 --- [ main] tc.postgres:18 : Container postgres:18 started in PT1.494773987S
…
2026-10-11T20:08:18.946Z INFO 61246 --- [ main] c.e.library.LibraryApiIntegrationTest : Started LibraryApiIntegrationTest in 14.307 seconds (process running for 16.385)
…
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 17.77 s -- in com.example.library.LibraryApiIntegrationTest
[INFO] Running com.example.library.author.AuthorControllerTest
…
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.064 s -- in com.example.library.author.AuthorControllerTest
[INFO] Running com.example.library.author.AuthorServiceTest
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.545 s -- in com.example.library.author.AuthorServiceTest
[INFO] Running com.example.library.ApiDocsIntegrationTest
…
2026-10-11T20:08:26.165Z INFO 61246 --- [o-auto-1-exec-5] o.springdoc.api.AbstractOpenApiResource : Init duration for springdoc-openapi is: 866 ms
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.100 s -- in com.example.library.ApiDocsIntegrationTest
[INFO] Running com.example.library.book.BookControllerTest
…
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.156 s -- in com.example.library.book.BookControllerTest
[INFO] Running com.example.library.book.BookServiceTest
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.059 s -- in com.example.library.book.BookServiceTest
[INFO] Running com.example.library.book.BookRepositoryTest
…
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.272 s -- in com.example.library.book.BookRepositoryTest
[INFO] Running com.example.library.LibraryApplicationTests
…
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.623 s -- in com.example.library.LibraryApplicationTests
…
[INFO] Tests run: 56, Failures: 0, Errors: 0, Skipped: 0
…
[INFO] BUILD SUCCESS
If it doesn't behave
  • The docs page is a 404? Until Step 9 it is at /scalar, from then on at /docs. If neither answers, check the dependency: it is springdoc-openapi-starter-webmvc-scalar; the -ui artifact serves Swagger UI at /swagger-ui.html instead, and the -api artifact serves only the spec. Re-run ./mvnw spring-boot:run after changing pom.xml.
  • The docs page doesn't show your endpoints? It renders whatever /v3/api-docs returns. Open http://localhost:8080/v3/api-docs directly; if it answers with an error, the exception that broke the spec is in the app's console.

Check yourself

Answer from memory before revealing.

Where does springdoc-openapi get the API description from?
Why did the generated spec document POST /api/books as 200?
What does @ParameterObject change for the Pageable parameter?
Why are the error responses declared on each operation by hand?
Why does ProblemResponses define a Problem schema instead of using ProblemDetail?
How does springdoc document BookController.byId when its annotations are in BookApi?
Why does ApiDocsIntegrationTest start no server or container of its own?

Read this next

Primary source: the springdoc-openapi documentation — getting started, the Scalar and Swagger UI modules, and its long FAQ, which answers most "how do I document X" questions (paging, errors, interfaces, security, grouping). For what a spec can say, the OpenAPI Specification 3.1 itself, and the Swagger library's annotations reference for every attribute of @Operation, @ApiResponse, @Parameter and @Schema. Scalar's own documentation covers its scalar.* settings: theme, layout, which client languages it shows.

Glossary: the terms this lesson introduces are in the Glossary, under Lesson 14 — your quick-reference for every term we use.

Architecture decisions: the 7 choices this lesson makes, each with its reason, are in Architecture Decisions, under Lesson 14.

Code: the project at the end of this lesson · what changed in this lesson, exercises included.

💬 Discuss