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:
Documentation is generated from unit tests
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:
phase - If it is set to prepare-package documentation is available to be packaged. Spring boot can feed it as static content when the application is running.
sourceDirectory - This is where the Asciidoctor documentation templates can be found. I will get back to them later.
snippets - This attribute specifies the place where tests will generate documentation snippets. This attribute is used in Asciidoctor templates to include generated snippets in the documentation.
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 step is setting up Spring MVC test.
restDocumentation junit rule is configured with the output directory into which generated snippets should be written.
mockMvc needs to know about this configuration and this is done by the static documentationConfiguration() method on org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.
The first test method greetingGetWithProvidedContent() tests what happens if the greeting service is called with name parameter set:
The first parameter specifies the folder name where snippets for this method will be generated. In this case it is parameterized by the class and method names with {class-name}/{method-name}. The result is greeting-controller-test/greeting-get-with-provided-content.
requestParameters documents request parameters and generate request-parameters.adoc snippet.
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:
curl-request.adoc - Contains a sample curl command to access tested resource
http-request.adoc - Contains the request used by the test method
Besides these three default snippets, our test methods generate two additional snippets:
request-parameters.adoc - Contains a table with documented request parameters
After running 'mvn clean package' generated documentation can be found in target/generated-docs folder:
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.