Spring Boot REST Docs Example

Spring REST Docs writes your API documentation from your tests. If the test passes, the documentation is true. If the test fails, you get no documentation at all.

Things you will use:

  • Spring Boot 4.1.0
  • Spring REST Docs 4.0.x (Spring Boot picks the version)
  • Java 25
  • Maven 3.9.6
  • JUnit 6

Table of contents:

What this delivered

A Spring REST Docs page for a Spring controller.

final page

Project Directory

project directory

Project Dependencies

You need three things: the REST Docs test library, the Asciidoctor plugin that turns snippets into HTML, and the plugin’s REST Docs helper.

pom.xml

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

    <artifactId>spring-boot-rest-doc</artifactId>
    <packaging>jar</packaging>
    <name>Spring Boot REST Docs Example</name>
    <version>1.0</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.0</version>
        <relativePath/> <!-- lookup parent from repository, not local -->
    </parent>

    <properties>
        <java.version>25</java.version>
    </properties>

    <dependencies>

        <!-- In Spring Boot 4 the old spring-boot-starter-web is renamed to this -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>

        <!-- Gives you JUnit 6, MockMvc and the rest of the test tools -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- REST Docs itself. It only runs in tests, so scope is test -->
        <!-- No version needed, the parent picks one that works -->
        <dependency>
            <groupId>org.springframework.restdocs</groupId>
            <artifactId>spring-restdocs-mockmvc</artifactId>
            <scope>test</scope>
        </dependency>

    </dependencies>

    <build>
        <plugins>

            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>

            <!-- This turns your .adoc file into an HTML page -->
            <plugin>
                <groupId>org.asciidoctor</groupId>
                <artifactId>asciidoctor-maven-plugin</artifactId>

                <!-- Version note: the official tutorial says 2.2.1. That version bundles a JRuby
                too old to run on Java 25, and it fails at startup. 3.2.0 works.
                -->
                <version>3.2.0</version>
                <executions>
                    <execution>
                        <id>generate-docs</id>
                        <!-- Runs before the jar is packed -->
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>process-asciidoc</goal>
                        </goals>
                        <configuration>
                            <backend>html</backend>
                            <doctype>book</doctype>
                        </configuration>
                    </execution>
                </executions>
                <dependencies>
                    <!-- This teaches the plugin where the snippets live -->
                    <dependency>
                        <groupId>org.springframework.restdocs</groupId>
                        <artifactId>spring-restdocs-asciidoctor</artifactId>
                        <version>4.0.0</version>
                    </dependency>
                </dependencies>
            </plugin>

            <!-- Add this AFTER the asciidoctor plugin, so the HTML exists first -->
            <plugin>
                <artifactId>maven-resources-plugin</artifactId>
                <executions>
                    <execution>
                        <id>copy-resources</id>
                        <phase>prepare-package</phase>
                        <goals>
                            <goal>copy-resources</goal>
                        </goals>
                        <configuration>
                            <!-- Anything in static/docs is served by Spring Boot -->
                            <outputDirectory>
                                ${project.build.outputDirectory}/static/docs
                            </outputDirectory>
                            <resources>
                                <resource>
                                    <directory>
                                        ${project.build.directory}/generated-docs
                                    </directory>
                                </resource>
                            </resources>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

        </plugins>

    </build>

</project>

That last helper sets a shortcut called {snippets} for you. It points at target/generated-snippets.

The API you will document

A Book Manager. First the book.

Book.java

package com.mkyong.book;

// A small box that holds one book
public record Book(Long id, String title, String author) {
}

Now the controller.

BookController.java

package com.mkyong.book;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/books")
public class BookController {

    // Two books kept in memory
    private final List<Book> books = List.of(
            new Book(1L, "Java 25 Basics", "Mkyong"),
            new Book(2L, "Spring Boot 4 Basics", "Mkyong")
    );

    // GET /books/1
    @GetMapping("/{id}")
    public Book findById(@PathVariable Long id) {
        return books.stream()
                .filter(book -> book.id().equals(id)) // keep the matching book
                .findFirst()                          // take the first one
                .orElse(null);
    }
}

And the starter class.

Application.java

package com.mkyong;

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

@SpringBootApplication
public class Application {

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

No documentation annotations anywhere. The controller stays clean.

Write the test

This is where the documentation comes from. Put this in src/test/java/com/mkyong/book/.

BookControllerDocumentationTests.java

package com.mkyong.book;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

// This extension writes the snippets for you
@ExtendWith(RestDocumentationExtension.class)
@SpringBootTest
class BookControllerDocumentationTests {

    private MockMvc mockMvc;

    // Runs before every test
    @BeforeEach
    void setUp(WebApplicationContext webApplicationContext,
               RestDocumentationContextProvider restDocumentation) {

        this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                // This one line switches REST Docs on
                .apply(documentationConfiguration(restDocumentation))
                .build();
    }

    @Test
    void findBookById() throws Exception {

        this.mockMvc.perform(get("/books/1"))  // call the real endpoint
                .andExpect(status().isOk())    // check it worked
                .andDo(document("find-book")); // write the snippets
    }
}

Run it with mvn test.

Output:


[INFO] --- surefire:3.5.2:test (default-test) @ spring-boot-rest-doc ---
[INFO] Running com.mkyong.book.BookControllerDocumentationTests
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Now look inside target/generated-snippets/find-book/.

Output:


target/generated-snippets/find-book/
├── curl-request.adoc
├── http-request.adoc
├── http-response.adoc
├── httpie-request.adoc
├── request-body.adoc
└── response-body.adoc

snippets

Six files, from one test. The name find-book is the folder name you passed to document().

Look inside a snippet

Open target/generated-snippets/find-book/curl-request.adoc.

Output:

curl-request.adoc

[source,bash]
----
$ curl 'http://localhost:8080/books/1' -i -X GET
----

And http-request.adoc:

Output:

http-request.adoc

[source,http,options="nowrap"]
----
GET /books/1 HTTP/1.1
Host: localhost:8080

----

And http-response.adoc:

Output:

http-response.adoc

[source,http,options="nowrap"]
----
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 51

{"id":1,"title":"Java 25 Basics","author":"Mkyong"}
----

Nobody typed the content of those files. REST Docs wrote them while the test was running.

Glue the snippets into a page

Snippets are pieces. You still write the page. Create src/main/asciidoc/index.adoc.

index.adoc

= Book Manager API
:toc: left

== Get one book

Ask for a book by its id.

Here is the call:

include::{snippets}/find-book/curl-request.adoc[]

And here is what comes back:

include::{snippets}/find-book/http-response.adoc[]

Build it with mvn package.

Output:

Terminal

[INFO] --- asciidoctor:3.2.0:process-asciidoc (generate-docs) @ spring-boot-rest-doc ---
[INFO] sourceDirectory E:\projects\spring-boot\spring-boot-rest-doc\src\docs\asciidoc does not exist
[INFO] sourceDirectory E:\projects\spring-boot\spring-boot-rest-doc\src\asciidoc does not exist
[INFO] Converted E:\projects\spring-boot\spring-boot-rest-doc\src\main\asciidoc\index.adoc

[INFO] --- resources:3.5.0:copy-resources (copy-resources) @ spring-boot-rest-doc ---
[INFO] Copying 1 resource from target\generated-docs to target\classes\static\docs

[INFO] BUILD SUCCESS

Open target/generated-docs/index.html in your browser.

Output:

api doc

You wrote the words. The test wrote the code and the answer.

Describe each field

So far the page shows raw JSON. You can add a table that explains every field.

BookControllerDocumentationTests.java

package com.mkyong.book;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// This extension writes the snippets for you
@ExtendWith(RestDocumentationExtension.class)
@SpringBootTest
class BookControllerDocumentationTests {

    private MockMvc mockMvc;

    // Runs before every test
    @BeforeEach
    void setUp(WebApplicationContext webApplicationContext,
               RestDocumentationContextProvider restDocumentation) {

        this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                // This one line switches REST Docs on
                .apply(documentationConfiguration(restDocumentation))
                .build();
    }

    @Test
    void findBookById() throws Exception {

        this.mockMvc.perform(get("/books/1"))  // call the real endpoint
                .andExpect(status().isOk())    // check it worked
                .andDo(document("find-book",
                        // Explain every field that comes back
                        responseFields(
                                fieldWithPath("id").description("The book id"),
                                fieldWithPath("title").description("The name of the book"),
                                fieldWithPath("author").description("Who wrote it")
                        )));

    }

}

Run mvn test again. You get a new snippet, response-fields.adoc. Append it to the end of src/main/asciidoc/index.adoc:

index.adoc

include::{snippets}/find-book/response-fields.adoc[]

Build it with mvn package.

Open target/generated-docs/index.html in your browser.

Output:

api doc updated

You get a neat table, and every row was checked against a real response.

Watch it catch a mistake

Add a field to Book but say nothing about it in the test.

Book.java

package com.mkyong.book;

// A new "year" field, and the test does not know about it
public record Book(Long id, String title, String author, int year) {
}

Update BookController:

BookController.java

@RestController
@RequestMapping("/books")
public class BookController {

    // Two books kept in memory
    private final List<Book> books = List.of(
            new Book(1L, "Java 25 Basics", "Mkyong", 2026),
            new Book(2L, "Spring Boot 4 Basics", "Mkyong", 2026)
    );

    //...
}

Run mvn test.

Output:

Terminal

[ERROR] BookControllerDocumentationTests.findBookById
  org.springframework.restdocs.snippet.SnippetException:
  The following parts of the payload were not documented:
  {
    "year" : 2026
  }

[INFO] BUILD FAILURE

The build stopped. Your documentation can never quietly fall behind your code, because out-of-date documentation is not documentation; it is a lie with a nice font.

Now take the year field out of Book and BookController again, so the rest of this article keeps working.

Document the URL parts

The {id} in /books/{id} deserves an explanation too.

BookControllerDocumentationTests.java

package com.mkyong.book;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// This extension writes the snippets for you
@ExtendWith(RestDocumentationExtension.class)
@SpringBootTest
class BookControllerDocumentationTests {

    private MockMvc mockMvc;

    // Runs before every test
    @BeforeEach
    void setUp(WebApplicationContext webApplicationContext,
               RestDocumentationContextProvider restDocumentation) {

        this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                // This one line switches REST Docs on
                .apply(documentationConfiguration(restDocumentation))
                .build();
    }

    @Test
    void findBookById() throws Exception {

        // Pass the template "/books/{id}" and the value 1 separately
        this.mockMvc.perform(get("/books/{id}", 1))
                .andExpect(status().isOk())
                .andDo(document("find-book",
                        pathParameters(
                                parameterWithName("id").description("The id of the book you want")
                        ),
                        responseFields(
                                fieldWithPath("id").description("The book id"),
                                fieldWithPath("title").description("The name of the book"),
                                fieldWithPath("author").description("Who wrote it")
                        )));
    }

}

Run mvn test again. You get a new snippet, path-parameters.adoc. Add it to your src/main/asciidoc/index.adoc:

index.adoc

= Book Manager API
:toc: left

== Get one book

Ask for a book by its id.

Here is the call:

include::{snippets}/find-book/curl-request.adoc[]

include::{snippets}/find-book/path-parameters.adoc[]

And here is what comes back:

include::{snippets}/find-book/http-response.adoc[]

include::{snippets}/find-book/response-fields.adoc[]

Build it with mvn clean package.

Open target/generated-docs/index.html in your browser.

Output:

api doc updated

Further Read
Older guides tell you to swap MockMvcRequestBuilders.get for RestDocumentationRequestBuilders.get at this point. You no longer need to. Before this, the plain get threw away the {id} template and kept only the finished URL, so REST Docs could not tell that a path parameter was there. Spring Framework 6.2 made the template available from the request, and Spring REST Docs 3.0.2 started reading it. You are on Framework 7 and REST Docs 4, so the plain get works.

Ship the docs with your app

The maven-resources-plugin moves the finished HTML into your jar, and Spring Boot serves it as a normal page.

pom.xml


    <!-- This turns your .adoc file into an HTML page -->
    <plugin>
        <groupId>org.asciidoctor</groupId>
        <artifactId>asciidoctor-maven-plugin</artifactId>
        <!-- hide -->
    </plugin>

    <!-- Add this AFTER the asciidoctor plugin, so you always ship the latest HTML doc -->
    <plugin>
        <artifactId>maven-resources-plugin</artifactId>
        <executions>
            <execution>
                <id>copy-resources</id>
                <phase>prepare-package</phase>
                <goals>
                    <goal>copy-resources</goal>
                </goals>
                <configuration>
                    <!-- Anything in static/docs is served by Spring Boot -->
                    <outputDirectory>
                        ${project.build.outputDirectory}/static/docs
                    </outputDirectory>
                    <resources>
                        <resource>
                            <directory>
                                ${project.build.directory}/generated-docs
                            </directory>
                        </resource>
                    </resources>
                </configuration>
            </execution>
        </executions>
    </plugin>

Run mvn clean package, then mvn spring-boot:run.

Open http://localhost:8080/docs/index.html

Output:

final page

Your documentation now travels with your app. The version you deploy is always the version that was tested.

A trap to avoid

Both plugins run in the same prepare-package phase, and Maven runs them in the order you declare them. So maven-resources-plugin must come after asciidoctor-maven-plugin. Put it first and there is nothing to copy yet.

pom.xml

    <plugin>
        <groupId>org.asciidoctor</groupId>
        <artifactId>asciidoctor-maven-plugin</artifactId>
        <!-- hide -->
    </plugin>

    <!-- Add this AFTER the asciidoctor plugin, so you always ship the latest HTML doc -->
    <plugin>
        <artifactId>maven-resources-plugin</artifactId>
        <!-- hide -->
    </plugin>

Output: (the wrong order, with mvn clean package)

Terminal

[INFO] --- resources:3.5.0:copy-resources (copy-resources) @ spring-boot-rest-doc ---
[INFO] skip non existing resourceDirectory target\generated-docs

The sneaky part is mvn package without clean. The folder still holds the last build’s HTML, so the copy quietly succeeds and you ship yesterday’s documentation. Always check the order of these two plugins.

Download Source Code

$ git clone https://github.com/mkyong/spring-boot.git

$ cd spring-boot/spring-boot-rest-doc

$ mvn clean package

$ mvn spring-boot:run

References

P.S This article is part of the Spring Boot 4 series, tested against Spring Boot 4.1.0 GA and Java 25.

mkyong

Founder of Mkyong.com, passionate Java and open-source technologies. If you enjoy my tutorials, consider making a donation to these charities.

Subscribe
Notify of
0 Comments
Most Voted
Newest Oldest
Inline Feedbacks
View all comments