Î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:
Documentația e generată din teste automate.
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:
phase - Dacă este setată ca prepare-package,
documentația este disponibilă pentru a fi împachetată. Spring boot o poate servi drept conținut static când aplicația rulează.
sourceDirectory - Aceasta este locația unde șabloanele Asciidoctor pot fi găsite. În rândurile următoare vom analiza aceste șabloane.
snippets - Acest atribut specifică locația unde testele vor genera fragmentele de documentație. Atributul este folosit în șabloanele Asciidoctor pentru a include în documentație fragmentele generate.
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:
Primul pas este să configurez testul Spring MVC.
restDocumentation
junit rule este configurat cu directorul unde fragmentele de documentație vor fi generate.
mockMvc
trebuie să știe de această configurare și o primește cu ajutorul metodei statice documentationConfiguration()
din clasa org.springframework.restdocs.mockmvc.MockMvcRestDocumentation
.
document()
din aceeași clasă org.springframework.restdocs.mockmvc.MockMvcRestDocumentation
.Prima metodă de test greetingGetWithProvidedContent()
verifică ce se întâmplă dacă acest serviciu este apelat cu parametrul name
setat:
Primul parametru specifică directorul unde fragmentele de documentație ale acestei metode vor fi generate. În acest caz este parametrizat cu numele clasei și metodei {class-name}/{method-name}
. Rezultatul este greeting-controller-test/greeting-get-with-provided-content
.
requestParameters
documentează parametrii cererii și generează fragmentul request-parameters.adoc
.
responseFields
documentează câmpurile răspunsului și generează fragmentul response-fields.adoc
. Toate câmpurile răspunsului trebuie să apară în răspunsul generat, altfel testul eșuează. Câmpul optionalContent
nu este returnat in răspunsul nostru și l-am marcat ca opțional pentru a demonstra această posibilitate. Se poate întâmpla în practică ca un câmp să fie returnat doar în anumite situații.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:
responseFields
documentează doar câmpul content
deoarece doar valoarea lui se schimbă în această situație. Pentru a nu documenta din nou parametrul id
l-am marcat ca ignored
.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:
curl-request.adoc - Conține un exemplu de comandă curl
care accesează resursa testată.
http-request.adoc - Conține cererea folosită de metoda de test.
Pe lângă aceste trei fragmente implicite metodele noastre de test generează două fragmente adiționale:
request-parameters.adoc - Conține un tabel cu parametri documentați ai cererii.
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.
de Ovidiu Mățan
de Paul Hrimiuc