Spring Boot Course · Lesson 14
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.
/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.
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.
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:
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:
./mvnw spring-boot:runOpen 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:
authorId as an optional query parameter of type string, format uuid — from @RequestParam(required = false) UUID.CreateBookRequest, and its schema carries Lesson 7's constraints: @NotBlank became minLength: 1, @Positive became exclusiveMinimum: 0, @NotEmpty became minItems: 1, and the two non-null fields are required. A Set is an array with uniqueItems.BookResponse, and the list's is PagedModelBookResponse — Lesson 8's stable { content, page } shape, the one the API really sends.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:
POST answers 201, not 200. The controller returns a ResponseEntity, and the status in it is decided at runtime by ResponseEntity.created(...). springdoc can only see the type, so it assumes the default.pageable. Lesson 8's Pageable is filled from three query parameters, page, size and sort; springdoc sees one method parameter of type Pageable and documents it as one required object.404, no 400, no 409. springdoc does read @ExceptionHandler methods, but only learns their status from @ResponseStatus [docs]; Lesson 7's handlers return a ProblemDetail that carries its status inside. And even with @ResponseStatus, springdoc would add every handler's status to every operation — a 409 on GET /api/books too, which can't happen.v0, and the endpoints are grouped under book-controller and author-controller, the class names.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.
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:
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).")); }}OpenAPI, Info are the Swagger library's Java model of an OpenAPI document (io.swagger.v3.oas.models), which springdoc uses underneath. springdoc starts from this bean and adds the paths and schemas it generates.@Configuration(proxyBeanMethods = false), as on Lesson 12's TestcontainersConfiguration, lets Spring use the class exactly as written. The callout below explains what it switches off.proxyBeanMethods = false switches offBy 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:
@Configurationpublic 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.
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:
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; }}$ref [spec]. components() builds them with the same Java model as OpenApiConfig's Info: one schema, Problem, and eleven named responses.Problem is the schema of the JSON, not of a Java class: detail, instance, status and title, which every error has, and errors, which only a failed validation adds. type isn't there because the API never sends it.PUT /api/books/{id} answers 400 for an invalid body and for an id that isn't a UUID, so InvalidBookOrId carries both examples; the docs page offers them by their summaries. GET and DELETE have no body, so they use MalformedBookId alone.bookNotFound is the body above. They are kept honest by hand: change a message in ApiExceptionHandler or a service, and Lesson 11's and Lesson 13's tests fail and point at it, but this class needs the same edit.BookApi and AuthorApi refer to ProblemResponses.BOOK_NOT_FOUND rather than a string a typo could break. components() is package-private: only OpenApiConfig calls it.LinkedHashMap keeps the keys in the order they are put, so each example lists its fields as the API sends them; Map.of would change the order from run to run.OpenApiConfig adds the components to the spec:
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 }}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:
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);}@Tag names the group on the docs page — "Books" instead of book-controller — and describes it.@Operation gives each operation a summary, the one-liner shown in the sidebar, and an optional longer description. The descriptions say what the signature can't: which order a list comes in, that a PUT unlinks the authors left out, that deleting a book leaves its authors.@ApiResponse, one per status the operation can answer with. Once an operation declares any, springdoc documents exactly those. A success response without content still gets its body schema from the return type, so 201 is listed with BookResponse. An error response is a ref to one of Step 3's named responses — schema, description and examples included — so each is one line. Every operation with an id in its path also lists the 400 for an id that isn't a UUID, which Lesson 11's controller tests already check.@ParameterObject tells springdoc to document the fields of Pageable as separate query parameters, which is how Spring binds them [docs].@Parameter describes a path or query parameter; its type and format (string, uuid) springdoc already knew. The example is the id the docs page puts into the request it shows. The ids match the ones in Step 3's error examples and in the records' examples in Step 5, so the examples tell one consistent story.@PathVariable: those stay on the controller, where Spring reads them. An interface method is abstract, so the interface is nothing but signatures and their documentation.BookController then changes in two ways only: it implements BookApi, and each handler method is marked @Override:
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 }}BookApi; the paths, the parameter bindings and @PageableDefault come from the controller — so the docs still show the default size of 20 and the sort by title.@Override keeps the two in step. Change a method's signature in one file and not the other, and the code no longer compiles, instead of the docs quietly describing a method that is gone.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:
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) {}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); }}@NotBlank, @Positive and @NotEmpty already reach the spec — as minLength, exclusiveMinimum and minItems — so @Schema doesn't repeat them; it only adds the words."1999"; an array is the JSON text ["…"], with its quotes escaped.BookResponse's components carry their annotations in front of each, which is why the record header now spans several lines. The from method is unchanged.The same for authors. AuthorApi first:
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:
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:
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) {}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()); }}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:
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:
```bashcurl -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:
```bashcurl -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:
```bashcurl 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.201 and Location from Lesson 4, the server-made UUIDv7 ids from Lesson 5, the page shape, the cap of 100 and the id tie-breaker from Lesson 8, the errors from Lesson 7. The guide links them together for someone who has read none of those.<the author id> is the one place a reader has to fill in.src/main/resources, so it is packed into the app, and it changes in the same commit as the code it describes. Markdown tables and fenced code blocks render as they do on GitHub.OpenApiConfig reads it as the description, in place of the one line it had:
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 }}ClassPathResource("api-guide.md") finds the file on the classpath, where Maven copies everything from src/main/resources — in the IDE, in ./mvnw spring-boot:run and inside a packaged jar alike [docs]. getContentAsString reads it whole.throws IOException: a missing or unreadable guide fails the bean, so the app doesn't start — the mistake shows up at once, not as empty docs.Start the app again:
./mvnw spring-boot:runReload 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.
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.
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:
localhost it is on by default with a few free messages; anywhere else it needs a paid key [docs]. Using it means your spec and your questions go to Scalar's servers — fine for a public API, not something to offer by accident for an internal one.localhost.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):
mkdir -p src/main/resources/static/fontscurl -L -o /tmp/inter.zip https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zipunzip -j /tmp/inter.zip web/InterVariable.woff2 -d src/main/resources/static/fontsunzip -p /tmp/inter.zip LICENSE.txt > src/main/resources/static/fonts/Inter-LICENSE.txtcurl -L -o /tmp/jetbrains-mono.zip https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zipunzip -j /tmp/jetbrains-mono.zip fonts/webfonts/JetBrainsMono-Regular.woff2 -d src/main/resources/static/fontsunzip -p /tmp/jetbrains-mono.zip OFL.txt > src/main/resources/static/fonts/JetBrainsMono-LICENSE.txtunzip -j extracts the one file named after the archive, without the folders it sits in (web/, fonts/webfonts/); unzip -p prints a file instead, here into the license file next to the font.InterVariable.woff2 is Inter as a variable font: one file with every weight from 100 to 900, which is how Scalar uses it. Of JetBrains Mono, Scalar uses only the regular weight.src/main/resources/static/ as it is, at the root of the app [docs]: the fonts will be at /fonts/InterVariable.woff2 and /fonts/JetBrainsMono-Regular.woff2.Then seven settings at the end of 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 PageImplspring.data.web.pageable.serialization-mode=via_dto
# Cap how many rows one page request can pullspring.data.web.pageable.max-page-size=100# (optional) change the default page size for unparameterized requestsspring.data.web.pageable.default-page-size=20
# Close the session at the end of the service layer, not the end of the requestspring.jpa.open-in-view=false
# Docs page (Scalar) at /docs, titled after the APIscalar.path=/docsscalar.page-title=Library API# No AI chat - it would send the spec to Scalar's servers - and no developer toolbarscalar.agent.disabled=truescalar.show-developer-tools=never# No separate API client: each operation's Test Request button is enoughscalar.hide-client-button=true# Fonts from the app itself (static/fonts), not from Scalar's font server; no "Powered by Scalar" linkscalar.with-default-fonts=falsescalar.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; }scalar.* are Scalar's own settings, which springdoc's module passes to the page [docs]. path moves the page, and its script with it, to /docs; /scalar then answers 404. page-title is the text in the browser tab. The spec stays at /v3/api-docs, springdoc's standard address, which tools and the test in Step 10 expect. agent.disabled removes the chat, and with it the list of public APIs the page fetched; show-developer-tools=never hides the toolbar on localhost too.hide-client-button=true removes the "Open API Client" button at the bottom of the sidebar, which opens a whole request workspace for the API inside the page. Trying an endpoint is what the docs page is for, and each operation's own "Test Request" button, which stays, does exactly that.with-default-fonts=false stops Scalar loading its fonts from its own server. On its own, the page would then fall back to whatever fonts the machine has.custom-css adds CSS to the page. The two @font-face rules declare the same font families Scalar's styles ask for, "Inter" and "JetBrains Mono", but with files from /fonts/ — so the page looks exactly as before. The last rule hides the "Powered by Scalar" link at the bottom of the sidebar. Scalar has no setting for it, so the rule targets the link itself; a Scalar upgrade that changes the link would bring it back. Scalar's MIT license asks for its license text to travel with copies of the code, not for a credit on the page [license].\ continues a value on the next line in a .properties file; the spaces at the start of the next line are dropped [docs]. The CSS stays one value, just readable.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.
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:
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(); }}LibraryApiIntegrationTest, exactly. That is Lesson 13's context caching put to use: the two classes have the same configuration, so they share one application context, one server and one PostgreSQL container, and this test costs no start-up of its own.jsonPath(...) picks one value out of the response body with a JSONPath expression and checks it [docs]. Keys with / or { in them go in brackets: $.paths['/api/authors/{id}']. [*].name collects the name of every parameter, in order.OpenApiConfig, the 201 from @ApiResponse, the paging parameters from @ParameterObject, and the 409 — a reference to AuthorHasBooks, which exists in the components with its example. A name constant that components() forgets to register still compiles, and leaves a $ref in the spec that points nowhere; the last two checks would catch that for this response.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:
./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 SUCCESSWARN lines are springdoc's own reminder of the callout above: both endpoints are on unless a property turns them off.Init duration for springdoc-openapi shows when the spec is built. Look at its place and its thread. It comes after Started ApiDocsIntegrationTest, so startup was already over, and it runs on o-auto-1-exec-1, a Tomcat thread handling a request, as in Lesson 13. springdoc doesn't build the spec at startup: it builds it the first time someone asks for /v3/api-docs — here, the test — and keeps it for the requests after that [docs]. That is what the start of this step relies on: an app whose spec can't be built still starts, and only a request for the docs finds out.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:
./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 FAILUREThe 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.
Run the whole suite:
./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 SUCCESS56 tests, all green, one more than Lesson 13. As before, your total may be smaller if you skipped Lesson 10's exercises.ApiDocsIntegrationTest has no Started line and took about a second: it ran in the context LibraryApiIntegrationTest had already started — same server, same container./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./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.Answer from memory before revealing.
POST /api/books as 200?@ParameterObject change for the Pageable parameter?ProblemResponses define a Problem schema instead of using ProblemDetail?BookController.byId when its annotations are in BookApi?ApiDocsIntegrationTest start no server or container of its own?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.