TSM - Spring REST Docs example

Vasile Boris - Programmer, team lead, trainer & coach

This year, in May, I attended the Spring I/O conference in Barcelona where I heard about Spring REST docs. Two things drew my attention:

After the conference, I checked out Spring REST docs reference and I created a sample REST service to see it in practice. The code is very simple and it is the classic Hello World.

public class Greeting {

    private final long id;
    private final String content;

    public Greeting(long id, String content) {
        this.id = id;
        this.content = content;
    }

    public long getId() {
        return id;
    }

    public String getContent() {
        return content;
    }

}

@RestController
@RequestMapping("/greeting")
public class GreetingController {

    private static final String template = "Hello, %s!";
    private final AtomicLong counter = new AtomicLong();

    @RequestMapping(method = RequestMethod.GET)
    public Greeting greeting(@RequestParam(value="name", defaultValue="World") String name) {
        return new Greeting(counter.incrementAndGet(), String.format(template, name));
    }

}

Spring REST docs uses Asciidoctor to generate the documentation so configuring maven is the first step.

<plugin>
    <groupId>org.asciidoctor</groupId>
    <artifactId>asciidoctor-maven-plugin</artifactId>
    <version>1.5.2</version>
    <executions>
        <execution>
            <id>generate-docs</id>
            <phase>prepare-package</phase>
            <goals>
                <goal>process-asciidoc</goal>
            </goals>
            <configuration>
                <backend>html</backend>
                <doctype>book</doctype>
                <attributes>
                    <snippets>${project.build.directory}/generated-snippets</snippets>
                </attributes>
                <sourceDirectory>src/docs/asciidocs</sourceDirectory>
                <outputDirectory>target/generated-docs</outputDirectory>
            </configuration>
        </execution>
    </executions>
</plugin>

Relevant information from this configuration is:

The next step is to create the test class:

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
@WebAppConfiguration
public class GreetingControllerTest {

    @Rule
    public RestDocumentation restDocumentation = new RestDocumentation("target/generated-snippets");

    @Autowired
    private WebApplicationContext context;

    private MockMvc mockMvc;

    @Before
    public void setUp(){
        this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
                .apply(documentationConfiguration(this.restDocumentation))
                .build();
    }

    @Test
    public void greetingGetWithProvidedContent() throws Exception {

        this.mockMvc.perform(get("/greeting"). param("name", "Everybody"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
                .andExpect(jsonPath("$.content", is("Hello, Everybody!")))
                .andDo(document("{class-name}/{method-name}",
                        requestParameters(parameterWithName("name").description("Greeting's target")),
                        responseFields(fieldWithPath("id").description("Greeting's generated id"),
                                fieldWithPath("content").description("Greeting's content"),
                                fieldWithPath("optionalContent").description("Greeting's optional content").type(JsonFieldType.STRING).optional()
                )));

    }

    @Test
    public void greetingGetWithDefaultContent() throws Exception {

        this.mockMvc.perform(get("/greeting"))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
                .andExpect(jsonPath("$.content", is("Hello, World!")))
                .andDo(document("{class-name}/{method-name}",
                        responseFields(fieldWithPath("id").ignored(),
                                fieldWithPath("content").description("When name is not provided, this field contains the default value")
                )));

    }

}

This test class performs the documentation in two steps:

The first test method greetingGetWithProvidedContent() tests what happens if the greeting service is called with name parameter set:

The second test method greetingGetWithDefaultContent() tests what happens if the greeting service is called without name parameter. In this case it makes sense to document only what is different:

Test classes now generate documentation snippets, and we need a way to merge it all together. This is where our Asciidoctor skills are needed. We need to create a file in src/docs/asciidocs folder (see sourceDirectory from asciidoctor-maven-plugin configuration):

= Greeting REST Service API Guide
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:sectlinks:

= Resources

== Greeting REST Service

The Greeting provides the entry point into the service.

=== Accessing the greeting GET with provided content

==== Request structure

include::{snippets}/greeting-controller-test/greeting-get-with-provided-content/http-request.adoc[]

==== Request parameters

include::{snippets}/greeting-controller-test/greeting-get-with-provided-content/request-parameters.adoc[]

==== Response fields

include::{snippets}/greeting-controller-test/greeting-get-with-provided-content/response-fields.adoc[]

==== Example response

include::{snippets}/greeting-controller-test/greeting-get-with-provided-content/http-response.adoc[]

==== CURL request

include::{snippets}/greeting-controller-test/greeting-get-with-provided-content/curl-request.adoc[]

=== Accessing the greeting GET with default content

==== Request structure

include::{snippets}/greeting-controller-test/greeting-get-with-default-content/http-request.adoc[]

==== Response fields

include::{snippets}/greeting-controller-test/greeting-get-with-default-content/response-fields.adoc[]

==== Example response

include::{snippets}/greeting-controller-test/greeting-get-with-default-content/http-response.adoc[]

==== CURL request

include::{snippets}/greeting-controller-test/greeting-get-with-default-content/curl-request.adoc[]

The most important aspect is how to include generated snippets. Each test method generates three snippets by default:

Besides these three default snippets, our test methods generate two additional snippets:

After running 'mvn clean package' generated documentation can be found in target/generated-docs folder:

[generated documentation]

We generated the documentation, so let us now check what happens when our documenting code is out of sync with the implementation.

The first scenario is to change the request parameter from name to content. I updated the controller and the way in which the test accesses the resource, but I failed to update the documenting code.

@RequestMapping(method = RequestMethod.GET)
public Greeting greeting(@RequestParam(value="content", defaultValue="World") String content) {
    return new Greeting(counter.incrementAndGet(), String.format(template, content));
}

@Test
public void greetingGetWithProvidedContent() throws Exception {

    this.mockMvc.perform(get("/greeting"). param("content", "Everybody"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
            .andExpect(jsonPath("$.content", is("Hello, Everybody!")))
            .andDo(document("{class-name}/{method-name}",
                    requestParameters(parameterWithName("name").description("Greeting's target")),
                    responseFields(fieldWithPath("id").description("Greeting's generated id"),
                            fieldWithPath("content").description("Greeting's content"),
                            fieldWithPath("optionalContent").description("Greeting's optional content").type(JsonFieldType.STRING).optional()
            )));

}

The test fails with:

org.springframework.restdocs.snippet.SnippetException: Request parameters with the following names were not documented: [content]. Request parameters with the following names were not found in the request: [name]

The second scenario is to miss a field from the document. In greetingGetWithDefaultContent() I will not document the id field:

@Test
public void greetingGetWithDefaultContent() throws Exception {

    this.mockMvc.perform(get("/greeting"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
            .andExpect(jsonPath("$.content", is("Hello, World!")))
            .andDo(document("{class-name}/{method-name}",
                    responseFields(fieldWithPath("content").description("When name is not provided, this field contains the default value")
            )));

}

The test fails with:

org.springframework.restdocs.snippet.SnippetException: The following parts of the payload were not documented: { "id" : 1 }

The last scenario is to document a field that is not returned. I will remove optional() from documenting optionalContent field on greetingGetWithProvidedContent() method:

@Test
public void greetingGetWithProvidedContent() throws Exception {

    this.mockMvc.perform(get("/greeting"). param("name", "Everybody"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE))
            .andExpect(jsonPath("$.content", is("Hello, Everybody!")))
            .andDo(document("{class-name}/{method-name}",
                    requestParameters(parameterWithName("name").description("Greeting's target")),
                    responseFields(fieldWithPath("id").description("Greeting's generated id"),
                            fieldWithPath("content").description("Greeting's content"),
                            fieldWithPath("optionalContent").description("Greeting's optional content").type(JsonFieldType.STRING)
            )));

}

The test fails with:

org.springframework.restdocs.snippet.SnippetException: Fields with the following paths were not found in the payload: [optionalContent]

I didn't use Spring REST docs to document real production code but I am planning to give it a try.

References

  1. Spring REST Docs - http://projects.spring.io/spring-restdocs/
  2. Asciidoctor - http://asciidoctor.org/