August 22, 2018

Micronaut Mastery: Documenting Our API Using Spring REST Docs

Spring REST Docs is a project to document a RESTful API using tests. The tests are used to invoke real REST calls on the application and to generate Asciidoctor markup snippets. We can use the generated snippets in an Asciidoctor document with documentation about our API. We can use Spring REST Docs to document a REST API we create using Micronaut.

First we must change our build file and include the Asciidoctor plugin and add dependencies to Spring REST Docs. The following example Gradle build file adds the Gradle Asciidoctor plugin, Spring REST Docs dependencies and configures the test and asciidoctor tasks. Spring REST Docs supports three different web clients to invoke the REST API of our application: Spring MockMVC, Spring Webflux WebTestClient and REST Assured. We use REST Assured 3, because it has little dependencies on other frameworks (like Spring).

// File: build.gradle
...

plugins {
    id "org.asciidoctor.convert" version "1.5.8.1"
}

...

ext {
    snippetsDir = file('build/generated-snippets')
    springRestDocsVersion = '2.0.2.RELEASE'
}

dependencies {
    asciidoctor "org.springframework.restdocs:spring-restdocs-asciidoctor:$springRestDocsVersion"
    testCompile "org.springframework.restdocs:spring-restdocs-restassured:$springRestDocsVersion"
}

test {
    outputs.dir snippetsDir
}

asciidoctor {
    inputs.dir snippetsDir
    dependsOn test
}

Let's add a controller to our application that has two methods to return one or more Conference objects. We want to document both REST API resource methods. First we look at the Conference class that is used:

// File: src/main/java/mrhaki/micronaut/Conference.java
package mrhaki.micronaut;

public class Conference {
    private final String name;
    private final String location;

    public Conference(final String name, final String location) {
        this.name = name;
        this.location = location;
    }

    public String getName() {
        return name;
    }

    public String getLocation() {
        return location;
    }
}

Next we write the following controller to implement /conference to return multiple conferences and /conference/{name} to return a specific conference. The controller is dependent on the class ConferenceService that contains the real logic to get the data, but the implementation is not important for our example to document the controller:

// File: src/main/java/mrhaki/micronaut/ConferenceController.java
package mrhaki.micronaut;

import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Controller("/conference")
public class ConferenceController {
    
    private final ConferenceService conferenceService;

    public ConferenceController(final ConferenceService conferenceService) {
        this.conferenceService = conferenceService;
    }

    @Get("/")
    public Flux<Conference> all() {
        return conferenceService.all();
    }
    
    @Get("/{name}")
    public Mono<Conference> findByName(final String name) {
        return conferenceService.findByName(name);
    }
}

Now it is time to write our test that will invoke our controller and generate Asciidoctor markup snippets. We use Spock for writing the test in our example:

// File: src/test/groovy/mrhaki/micronaut/ConferenceApiSpec.groovy
package mrhaki.micronaut

import io.micronaut.context.ApplicationContext
import io.micronaut.http.HttpStatus
import io.micronaut.runtime.server.EmbeddedServer
import io.restassured.builder.RequestSpecBuilder
import io.restassured.specification.RequestSpecification
import org.junit.Rule
import org.springframework.restdocs.JUnitRestDocumentation
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification

import static io.restassured.RestAssured.given
import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields
import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.document
import static org.springframework.restdocs.restassured3.RestAssuredRestDocumentation.documentationConfiguration

class ConferenceApiSpec extends Specification {

    @Shared
    @AutoCleanup
    private EmbeddedServer embeddedServer = ApplicationContext.run(EmbeddedServer)

    @Rule
    private JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation()

    private RequestSpecification spec

    void setup() {
        // Create a REST Assured request specification
        // with some defaults. All URI's
        // will not have localhost as server name,
        // but api.example.com and the port is removed.
        // All JSON responses are prettyfied.
        this.spec = new RequestSpecBuilder()
                .addFilter(
                    documentationConfiguration(restDocumentation)
                        .operationPreprocessors()
                        .withRequestDefaults(
                            modifyUris().host('api.example.com')
                                        .removePort())
                        .withResponseDefaults(prettyPrint()))
                .build()
    }

    void "get all conferences"() {
        given:
        final request =
                given(this.spec)
                    // The server port is set and the value is
                    // used from embeddedServer.
                    .port(embeddedServer.URL.port)
                    .accept("application/json")
                    .filter(
                        document(
                            "all", 
                            responseFields(
                                fieldWithPath("[].name").description("Name of conference."),
                                fieldWithPath("[].location").description("Location of conference.")
                        )))

        when:
        final response = request.get("/conference")

        then:
        response.statusCode() == HttpStatus.OK.code
    }

    void "get conference with given name"() {
        given:
        final request = 
                given(this.spec)
                    .port(embeddedServer.URL.port)
                    .accept("application/json")
                    .filter(
                        document(
                            "getByName", 
                            responseFields(
                                fieldWithPath("name").description("Name of conference."),
                                fieldWithPath("location").description("Location of conference.")
                )))

        when:
        final response = request.get("/conference/Gr8Conf EU")

        then:
        response.statusCode() == HttpStatus.OK.code
    }

}

Finally we create a Asciidoctor document to describe our API and use the generated Asciidoctor markup snippets from Spring REST Docs in our document. We rely in our example document on the operation macro that is part of Spring REST Docs to include some generated snippets:

// File: src/docs/asciidoc/api.adoc
= Conference API

== Get all conferences

operation::all[snippets="curl-request,httpie-request,response-body,response-fields"]

== Get conference using name

operation::getByName[snippets="curl-request,httpie-request,response-body,response-fields"]

We run the Gradle asciidoctor task to create the documentation. When we open the generated HTML we see the following result:

Written with Micronaut 1.0.0.M4 and Spring REST Docs 2.0.2.RELEASE.