TSM - Un exemplu cu Spring REST Docs

Vasile Boris - Programator, team lead, trainer & coach

În luna mai din acest an, am participat la conferința Spring I/O din Barcelona unde am auzit de Spring REST docs. Două idei mi-au atras atenția:

După conferință am căutat mai multe informații în documentația Spring REST docs și am creat un exemplu de serviciu REST ca să pun în practică. Exemplul este foarte simplu și este clasicul 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 folosește Asciidoctor să genereze documentația prin urmare configurarea maven a fost primul pas.

<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>

Informația relevantă din această configurare este:

Următorul pas este să creez clasa de test:

@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")
                )));

    }

}

Documentația este generată de această clasă de test în doi pași:

Prima metodă de test greetingGetWithProvidedContent() verifică ce se întâmplă dacă acest serviciu este apelat cu parametrul name setat:

A doua metodă de test greetingGetWithDefaultContent() verifică ce se întâmplă dacă serviciul este apelat fără parametrul name. În acest caz are sens să documentez doar ce este diferit:

Fragmentele de documentație sunt generate de clasele de test și avem nevoie de o cale de a le îmbina. Acum este momentul când avem nevoie de cunoștințele noastre de Asciidoctor. Trebuie sa creăm un fișier în directorul src/docs/asciidocs (vezi sourceDirectory din configurarea asciidoctor-maven-plugin):

= 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[]

Cel mai important aspect este includerea fragmentelor generate. Fiecare metodă de test generează implicit trei fragmente:

Pe lângă aceste trei fragmente implicite metodele noastre de test generează două fragmente adiționale:

După ce se rulează mvn clean package documentația generată poate fi găsită în directorul target/generated-docs:

http://espressoprogrammer.com/blog/wp-content/uploads/2016/06/greeting-service-api-guide.html

Am generat documentația, hai să verificăm ce se întâmplă când instrucțiunile de documentare nu sunt actualizate odată cu implementarea.

Primul scenariu este să schimb parametrul din cerere din name în content. Am actualizat controlerul și modul în care metoda de test accesează resursa dar am uitat să actualizez instrucțiunile de documentare.

@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()
            )));

}

Testul eșuează cu:

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]

Al doilea scenariu este să omit să documentez un câmp. În greetingGetWithDefaultContent() nu voi documenta câmpul id:

@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")
            )));

}

Testul eșuează cu:

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

Ultimul scenariu este să documentez un câmp care nu este returnat. Voi șterge optional() din documentarea câmpului optionalContent din metoda greetingGetWithProvidedContent():

@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)
            )));

}

Testul eșuează cu:

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

Nu am folosit încă Spring REST docs să documentez un serviciu real din producție, dar am de gând să fac o încercare.

Referințe

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