DEV Community

Noe Lopez
Noe Lopez

Posted on

Document your API with Spring Rest Docs

Overview

Documenting you API is crucial not only for other programmers in your organization but also any clients who have to consume it. When it comes to documenting REST APIs in Spring projects we have mainly two options available:

  1. Swagger/OpenAPI specification.
  2. Spring REST docs.

They have a different way of producing documentation:

OpenAPI has an API first approach. The springdoc-openapi library helps to automate the generation of the documentation in JSON/YAML and HTML format APIs. It inspects the code in the Controllers to create the JSON format. It also comes with a UI to interact with the API. To customise the input/output of the operations, annotations can be declared in the source code.

Spring rest docs uses a test-driven approach. This garantees that the documentation is always up to date and accurate. By default Spring uses Asciidoctor to generate API the documentation. Asciidoctor is an open source text processor written in Ruby that can convert an Asciidoc text file to other formats like HTML, PDF, etc. The final output will be an static HTML page. Documentation is created at built-time.

We will show how you can add documentation for you APIs with Spring rest doc.

Requirements

The minimum requirements for Spring REST dosc are:

  1. Java 17
  2. Spring Framework 6

In our project the testing framework is JUnit 5 and the documentation will be generated by Spring MVC's test framework (aka MocMvc)

Dependencies and Configuration

The first thing to do is to set up the project build. Documentation will be generated during the project build phase.

Add the below dependency in test scope in your pom.xml. In case you are testing the web layer with WebTestClient or REST Assured then add the dependecies spring-restdocs-webtestclient or spring-restdocs-restassured respectively.

<dependency> 
    <groupId>org.springframework.restdocs</groupId>
    <artifactId>spring-restdocs-mockmvc</artifactId>
    <scope>test</scope>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Examining the above artifact we can see it contains the spring-restdocs-core, spring-webmvc, spring-test and the servlet-api from jakarta.

The next step is to configure the Asciidoctor plugin. With it the AsciiDoc files can be converted to HTML format.

<plugin>
    <groupId>org.asciidoctor</groupId>
    <artifactId>asciidoctor-maven-plugin</artifactId>
    <version>2.2.1</version>
    <executions>
        <execution>
        <id>generate-docs</id>
        <phase>prepare-package</phase>
        <goals>
        <goal>process-asciidoc</goal>
        </goals>
        <configuration>
            <backend>html</backend>
            <doctype>book</doctype>
        </configuration>
    </execution>
    </executions>
    <dependencies>
        <dependency>
        <groupId>org.springframework.restdocs</groupId>
        <artifactId>spring-restdocs-asciidoctor</artifactId>
    </dependency>
    </dependencies>
</plugin>
Enter fullscreen mode Exit fullscreen mode

Optionally, the documentation can be packaged in the jar/war file and served as static content on your application. To do that, the resource pluging must be placed after the Asciidoc plugin as they are bound together to the prepare-package phase.

<plugin> 
    <artifactId>
</artifactId>
    <version>2.7</version>
    <executions>
    <execution>
        <id>copy-resources</id>
        <phase>prepare-package</phase>
        <goals>
            <goal>copy-resources</goal>
        </goals>
        <configuration> 
            <outputDirectory>
                    ${project.build.outputDirectory}/static/docs
        </outputDirectory>
            <resources>
            <resource>
                <directory>
              ${project.build.directory}/generated-docs
                </directory>
            </resource>
        </resources>
        </configuration>
    </execution>
    </executions>
</plugin>
Enter fullscreen mode Exit fullscreen mode

In the configuration section the generated code is copied to the static/docs folder and included in the jar/war (every time mvn package is run).

We are done with the configuration. Now it is time to write an integration test for the customer controller.

JUnit 5 testing

We will start with the customers endpoint, in particular the findCustomer method which is displayed in the below lines

@RestController
@RequestMapping("api/v1/customers")
public class CustomerController {
...
    @GetMapping(path = "{customerId}", 
                produces = APPLICATION_JSON_VALUE)
    public ResponseEntity<CustomerResponse> findCustomer( 
        @PathVariable("customerId") Long id) {
        return customerService
            .findById(id)
            .map(CustomerMapper::mapToCustomerResponse)
            .map(ResponseEntity::ok)
            .orElseThrow(() -> 
                new EntityNotFoundException(id, Customer.class));
}
...
Enter fullscreen mode Exit fullscreen mode

Lets write a test for findCustomer method. As we are using JUnit 5, the rest documentation extension must be used. Then, mvcmocktest annotation in order to test http semantics. This is called a slicing because it will initiate the Bean Container with just the necessary beans to load the web layer (No real http comunication happens here). Moreover, as we are only testing the Customer Controller, it can be specified in the annotation. This way it is possible narrow down the scope of the test by only loading one specific controller.

@ExtendWith({ RestDocumentationExtension.class})
@WebMvcTest(CustomerController.class)
public class CustomerControllerIntegrationTest {

    private MockMvc mockMvc;

    @BeforeEach
    void setUp(WebApplicationContext webApplicationContext, 
        RestDocumentationContextProvider restDocumentation) {
        mockMvc = MockMvcBuilders
           .webAppContextSetup(webApplicationContext)              
           .apply(documentationConfiguration(restDocumentation))
           .build();
    }
}
Enter fullscreen mode Exit fullscreen mode

However, the service must be mocked as it is a dependency of the controller. The method findById in the service is invoked and will return a customer object as shown below. Then, mockMvc simulates a get request to the endpoint URL. We check that the controller returns the correct status code. Finally, andDo method is where the test method is documented and the snippets are written to a folder named customer-get-by-id.

@ExtendWith({ RestDocumentationExtension.class})
@WebMvcTest(CustomerController.class)
public class CustomerControllerIntegrationTest {
    private MockMvc mockMvc;

    @MockBean
    private CustomerService customerService;

    @BeforeEach
    void setUp(WebApplicationContext webApplicationContext, 
        RestDocumentationContextProvider restDocumentation) {
        mockMvc = MockMvcBuilders
            .webAppContextSetup(webApplicationContext)
            .apply(documentationConfiguration(restDocumentation))                        
            .build();
    }

    @Test
    @DisplayName("When getting a customer by id then return the 
        customer data.")
    void givenId_whenGetCustomerById_thenReturnCustomer() 
    throws Exception {
        final var id = 1L;

        Mockito.when(customerService.findById(id))
            .thenReturn(Optional.of(Customer.Builder
                .newCustomer()
                .id(id)
                .name("John Wick")
                .status(Customer.Status.ACTIVATED)
                .email("john.wick@gmail.com")
                .dob(LocalDate.of(1981,9,05))
                .withDetails("Info", true)
                .build()));

        mockMvc.perform(get("/api/v1/customers/{id}", id)
            .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andDo(document("customer-get-by-id"));
    }
}
Enter fullscreen mode Exit fullscreen mode

One important thing to mention is that the andDo method takes a ResultHanlder argument. The static method document belongs to org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.

Another alternative is to load the full context with @SpringBootTest annotation. It will configure all the layers of the application. In this scenario, the data is retreived from the in-memory database H2. There is no need to have an instance of the Controller because it is managed by the Bean Container.

@ExtendWith({ RestDocumentationExtension.class})
@SpringBootTest
public class CustomerControllerIntegrationTest {
    private MockMvc mockMvc;

    @BeforeEach
    void setUp(WebApplicationContext webApplicationContext, 
        RestDocumentationContextProvider restDocumentation) {
        mockMvc = MockMvcBuilders
            .webAppContextSetup(webApplicationContext)
            .apply(documentationConfiguration(restDocumentation))
            .build();
    }

    @Test
    @DisplayName("When getting a customer by id then return the 
         customer data.")
    void givenId_whenGetCustomerById_thenReturnCustomer() 
        throws Exception {
        final var id = 1L;

        mockMvc.perform(get("/api/v1/customers/{id}", id)
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(document("customer-get-by-id"));
    }
}
Enter fullscreen mode Exit fullscreen mode

Now running the command to package the application as below

mvn clean package
Enter fullscreen mode Exit fullscreen mode

generates the code snippets for the test method in the folder target\generated-snippets. The sub folder customer-get-by-id contains all the asciidoc files created for the test method (Figure 1)

Image description

By default, 6 snippets are found in the directory: curl-request.adoc, http-request.adoc, http-response.adoc, httpie-request.adoc, request-body.adoc and response-body.adoc.

The contents of the file http-response.adoc are displayed next

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

{"id":1,"status":"ACTIVATED","personInfo":{"name":"John Wick","email":"john.wick@gmail.com","dateOfBirth":"05/09/1981"},"detailsInfo":{"info":"Info","vip":true}}
----
Enter fullscreen mode Exit fullscreen mode

Asciidoc File

Now the snippets are generated but how this can be converted to a more visually friendly format like HTML? A base asciidoc file can be added to the project referencing the snippets using the include directive. In our project the file is saved in src/docs/asciidocs/index.adoc. Its contents can be seen in the following lines

= RESTful Customer API Specification

:doctype: book

== Find a customer by id

A `GET` request is used to find a new person by id

operation::customer-get-by-id[snippets='http-request,http-response']
Enter fullscreen mode Exit fullscreen mode

Asciidoc semantic is intuitive. If you are interested in it and want to look at it in more detailed, visit its website

Next, we must indicate the asciidoctor plugin:

  • The directory where our base/index asciidoc file is located

  • The output directory where the html page is written.

<configuration>                          
   <backend>html</backend>
   <doctype>book</doctype>
   <sourceDirectory>src/docs/asciidocs</sourceDirectory>
   <outputDirectory>target/generated-docs</outputDirectory>
</configuration>
Enter fullscreen mode Exit fullscreen mode

Once the application is re-packaged the index.html is found in the folder target/generated-docs. It is also copied to the classes folder as well as inside the jar file in static/docs. This is done by the maven-resources-plugin that was setup in the Dependencies and Configuration section.

Next figure (Figure 2) is a screenshot of the index.html

Image description

So far, we have seen a simple example and how to put all the pieces together to create the index.html page. In the next sections, we will explore more features of Spring Doc Rest.

Path and Query parameters

Let's document the path parameters for the get customer id endpoint. The method expects the id as part of the URL. Static method pathParameters of class RequestDocumentation allows us to do so.

mockMvc.perform(get("/api/v1/customers/{id}", id)
    .accept(MediaType.APPLICATION_JSON))
    .andExpect(status().isOk())
    .andDo(document("customer-get-by-id",
             pathParameters(parameterWithName("id")
                 .description("Customer id"))))
Enter fullscreen mode Exit fullscreen mode

The static document accepts a Snippet varargs as the second argument. We passed the pathParameters with a single ParameterDescriptor.

Likewise, the query parameters can be defined using the static method queryParameters. FindCustomers method in the Controller expects up to 4 optional parameters. Let's code a test for it and document it

@Test
@DisplayName("When getting customers then return list of customers.")
void givenParams_whenGetCustomers_thenReturnListOfCustomers() throws Exception {
    final var queryParams = 
        "?name=name 1&status=ACTIVATED&info=info&vip=true";

    mockMvc.perform(get("/api/v1/customers"+queryParams)
        .accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk())
        .andDo(document("customer-search-customers",
            queryParameters(
                parameterWithName("name")
                    .description("Customer name filter"),
                parameterWithName("status")
                    .description("Customer status filter. Values  
                    ACTIVATED/DEACTIVATED/SUSPENDED"),
                parameterWithName("info")
                    .description("Customer info filter"),
                parameterWithName("vip")
                    .description("Customer vip filter. Values 
                    true/false"))));
}
Enter fullscreen mode Exit fullscreen mode

Re-running the maven command to build the application will write two new adoc files. One named path-parameters.adoc in customer-get-by-id folder and the other named query-parameters.adoc in customer-search-customers folder.

Request/Response payload

By default, the body of the request and response are generated in files request-body.adoc and response-body.adoc. They contain sample data tha was used to run the test. However, more complete documentation related to the request and response can be provided.

Let us demostrate it with the tests we have coded so far. Both, findById and findCustomers response share the same json structure. The only difference is findcustomers returns an array rather than a single object. An example is presented below

{
    "id": 1,
    "status": "DEACTIVATED",
    "personInfo": {
        "name": "name 1 surname 1",
        "email": "organisation1@email.com",
        "dateOfBirth": "04/02/1971"
    },
    "detailsInfo": {
        "info": "Customer info details 1",
        "vip": false
    }
}
Enter fullscreen mode Exit fullscreen mode

The response field's of the above sample are documented as follows

@Test
    @DisplayName("When getting a customer by id then return the customer data.")
    void givenValidId_whenGetCustomerById_thenReturnCustomer() throws Exception {
        final var id = 1L;

        mockMvc.perform(get("/api/v1/customers/{id}", id)
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(document("customer-get-by-id",
                        pathParameters(customerIdParam),
                        responseFields(customerResponseFields)));
}

private FieldDescriptor[] customerResponseFields = {
   fieldWithPath("id").description("Customer id"),
   fieldWithPath("status").description("Customer's status"),
   subsectionWithPath("personInfo")
      .description("The customer's personal info section"),            
   fieldWithPath("personInfo.name").description("Customer's name"),            
   fieldWithPath("personInfo.email")
      .description("Customer's email"),            
   fieldWithPath("personInfo.dateOfBirth")
      .description("Customer's Date Of Birth"),            
   subsectionWithPath("detailsInfo")
      .description("The customer's details info section"),            
   fieldWithPath("detailsInfo.info")
      .description("Customer's detailed info"),            
   fieldWithPath("detailsInfo.vip")
      .description("Customer's vip flag")};    
Enter fullscreen mode Exit fullscreen mode

Static method responseFields will produce a snippet describing the fields named response-fields.adoc. In our code all the fields are extracted to a member named customerResponseFields so that it can be reused and avoid repetition. Also, method subsectionWithPath will document the nested objects in the json object.

But the test for findCustomers expects an array of the same form. Well, that is easy to do as shown below

mockMvc.perform(get("/api/v1/customers"+queryParams)
    .accept(MediaType.APPLICATION_JSON))
    .andExpect(status().isOk())
    .andDo(document("customer-search-customers",
         queryParameters(customerSearchQueryParams),
         responseFields(fieldWithPath("[]")
             .description("An array of customers"))
             .andWithPrefix("[].", customerResponseFields)));
Enter fullscreen mode Exit fullscreen mode

The [] represents an array in combination with the method andWithPrefix describing the content of each element in the array.

Request/Response headers

If you read all the way down to here, you are already familiar with the sprind doc principles. Documenting headers is very similar to what we have done to generate other snippets such as queryparams. The following test method verifies adding a new customer successfully. The response returns http status 201 created along with the location header to find the resouce.

@Test
@DisplayName("When adding a customer then return created with location.")
void givenCustomerRequest_whenAddCustomer_thenReturnCreated() 
    throws Exception {
    final var body = """
                {
                      "name" : "Lawrence Lesnar",
                      "email" : "lawrence.lesnar@gmail.com123",
                      "dateOfBirth" : "2001-03-28",
                      "info" : "more info here",
                      "vip" : "true"
                }
                """;

    mockMvc.perform(post("/api/v1/customers")
                       .content(body)
                       .contentType(MediaType.APPLICATION_JSON))
        .andExpect(status().isCreated())
        .andDo(document("customer-add",
               requestFields(customerRequestFields),
               responseHeaders(headerWithName("location")
          .description("The location/URL of the new customer."))));
}
Enter fullscreen mode Exit fullscreen mode

This time responseHeaders method documents the location header. It also generates a snippet called response-headers.adoc with the information.

The update customer and delete customer test methods will not be presented in this article because they use all the features explained in the previos examples. They are in the repository code in case you want to take a look at them. Click here to go to code in github.

Preprocessors

Spring doc comes with some preprocessors which can be used to modify the request and response before the snippets are created. This comes in handy when you want to add or remove parts of the request/response to your documentation.

Preprocessing can be configured at test method level or for all the tests. This is setup with an OperationRequestPreprocessor or an OperationResponsePreprocessor in the document method for individual test or in the documentationConfiguration in the @Before method for all your tests.

The below code applies the prettyPrint preprocessor to any request/response documentation generate during the tests.

@BeforeEach
void setUp(WebApplicationContext webApplicationContext, 
           RestDocumentationContextProvider restDocumentation) {
    this.mockMvc = MockMvcBuilders
        .webAppContextSetup(webApplicationContext)
        .apply(documentationConfiguration(restDocumentation)
               .operationPreprocessors()
               .withRequestDefaults(prettyPrint())
               .withResponseDefaults(prettyPrint()))
        .build();
}
Enter fullscreen mode Exit fullscreen mode

There are other preprocessors that allow to modify headers or URIs. Full list of preprocessors can be read in Spring doc official documentation.

Index HTML.

All the tests for our API are documented now. Let's add the generated snippets to the index.adoc asciidoc file. Then, this file will be converted to the final index.html containing all the information.

= RESTful Customer API Specification
;
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:

[[Index]]
= Index

This is the index for the Customer Rest API endpoint.

[[index-find-customer]]
== Find a customer by id

A `GET` request is used to find a customer by id

operation::customer-get-by-id[snippets='http-request,path-parameters,http-response,response-fields']

[[index-find-customers]]
== Find customers

A `GET` request is used to find customers

operation::customer-search-customers[snippets='http-request,query-parameters,http-response,response-fields']

[[index-add-customer]]
== Add a customer

A `POST` request to add a new customer

operation::customer-add[snippets='http-request,request-fields,http-response,response-headers']

[[index-update-customer]]
== Update a customer

A `PUT` request to update an existing customer

operation::customer-update[snippets='http-request,path-parameters,request-fields,http-response,response-fields']

[[index-delete-customer]]
== Delete a customer by id

A `DELETE` request is used to delete an existing customer by id

operation::customer-delete[snippets='http-request,path-parameters,http-response']

Enter fullscreen mode Exit fullscreen mode

And executing the maven package command again to re-build the app as well as the documentation. Html file is located in target\generated-docs as explain earlier in this article. Next figure shows a screenshot of the above adoc file.

Image description

Conclusion

We started this article looking at the different options available for documenting our APIs. Then, it was needed some setup and configuraton to have spring doc ready in our project. This part is a bit annoying but the rest is straighforward. Then, we created the test methods and added the snippets to generate the adoc files. The last step is to write an adoc file with the information contained in the snippets and conver it to html.

As usual the repository code can be found in github. Hope you liked the content of the article and subcribe!

Top comments (0)