Allow documentation of extra attributes on fields, links, and query parameters

This commit adds support for associating custom attributes with field,
link, and query parameter descriptors. These attributes are then
included in the model during snippet rendering. Coupled with a custom
snippet template, this enables the inclusion of extra column(s) in the
generated tables.

Closes gh-70
This commit is contained in:
Andy Wilkinson
2015-07-23 18:01:57 +01:00
parent 4f850cddd9
commit 70824aa509
19 changed files with 364 additions and 46 deletions

View File

@@ -1,12 +0,0 @@
[[customizing-snippets]]
== Customizing the generated snippets
Spring REST Docs uses https://mustache.github.io[Mustache] templates to produce the
generated snippets. You can customize the generated snippets by overriding the
{source}spring-restdocs/src/main/resources/org/springframework/restdocs/templates[default
templates].
Templates are loaded from the classpath in the `org.springframework.restdocs.templates`
package and each template is named after the snippet that it will produce. For example, to
override the template for the `curl-request.adoc` snippet, create a template named
`curl-request.snippet` in `src/test/resources/org/springframework/restdocs/templates`.

View File

@@ -257,4 +257,65 @@ include::{examples-dir}/com/example/AlwaysDo.java[tags=always-do]
With this configuration in place, every call to `MockMvc.perform` will produce
the <<documenting-your-api-default-snippets,default snippets>> without any further
configuration. Take a look at the `GettingStartedDocumentation` classes in each of the
sample applications to see this functionality in action.
sample applications to see this functionality in action.
[[documenting-your-api-customizing]]
=== Customizing the output
[[documenting-your-api-customizing-snippets]]
==== Customizing the generated snippets
Spring REST Docs uses https://mustache.github.io[Mustache] templates to produce the
generated snippets.
{source}spring-restdocs/src/main/resources/org/springframework/restdocs/templates[Default
templates] are provided for each of the snippets that Spring REST Docs can produce. To
customize a snippet's content, you can provide your own template.
Templates are loaded from the classpath in the `org.springframework.restdocs.templates`
package and each template is named after the snippet that it will produce. For example, to
override the template for the `curl-request.adoc` snippet, create a template named
`curl-request.snippet` in `src/test/resources/org/springframework/restdocs/templates`.
[[documenting-your-api-customizing-including-extra-information]]
==== Including extra information
The descriptors for fields, links, and query parameters all have an `attribute` method
that can be used to associate one or more key-value pairs with the descriptor. These
attributes are made available during the template rendering process. Coupled with
a custom snippet template, this makes it possible to include extra information in a
generated snippet.
A concrete example of the above is the addition of a constraints column when documenting
request fields. The first step is to provide a `constraints` attribute for each field that
you are documenting:
[source,java,indent=0]
----
include::{examples-dir}/com/example/Payload.java[tags=constraints]
----
The second step is to provide a custom template named `request-fields.snippet` that
includes the information about the fields' constraints in the generated snippet's table:
[source,indent=0]
----
|===
|Path|Type|Description|Constraints <1>
{{#fields}}
|{{path}}
|{{type}}
|{{description}}
|{{constraints}} <2>
{{/fields}}
|===
----
<1> Add a new column named "Constraints"
<2> Include the descriptors' `constraints` attribute in each row of the table

View File

@@ -24,7 +24,6 @@ include::introduction.adoc[]
include::getting-started.adoc[]
include::documenting-your-api.adoc[]
include::customizing-responses.adoc[]
include::customizing-snippets.adoc[]
include::configuration.adoc[]
include::working-with-asciidoctor.adoc[]
include::contributing.adoc[]

View File

@@ -19,6 +19,7 @@ package com.example;
import static org.springframework.restdocs.RestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.springframework.http.MediaType;
@@ -51,4 +52,18 @@ private MockMvc mockMvc;
// end::explicit-type[]
}
public void constraints() throws Exception {
this.mockMvc.perform(post("/users/").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
// tag::constraints[]
.andDo(document("create-user").withRequestFields(
fieldWithPath("name")
.description("The user's name")
.attribute("constraints", "Must not be null. Must not be empty"),
fieldWithPath("email")
.description("The user's email address")
.attribute("constrains", "Must be a valid email address")));
// end::constraints[]
}
}

View File

@@ -0,0 +1,39 @@
package org.springframework.restdocs;
import java.util.HashMap;
import java.util.Map;
/**
* Base class for descriptors. Provides the ability to associate arbitrary attributes with
* a descriptor.
*
* @author Andy Wilkinson
*
* @param <T> the type of the descriptor
*/
public abstract class AbstractDescriptor<T extends AbstractDescriptor<T>> {
private final Map<String, Object> attributes = new HashMap<>();
/**
* Sets an attribute with the given {@code name} and {@code value}.
*
* @param name The name of the attribute
* @param value The value of the attribute
*/
@SuppressWarnings("unchecked")
public T attribute(String name, Object value) {
this.attributes.put(name, value);
return (T) this;
}
/**
* Returns the descriptor's attributes
*
* @return the attributes
*/
protected Map<String, Object> getAttributes() {
return this.attributes;
}
}

View File

@@ -16,6 +16,11 @@
package org.springframework.restdocs.hypermedia;
import java.util.HashMap;
import java.util.Map;
import org.springframework.restdocs.AbstractDescriptor;
/**
* A description of a link found in a hypermedia API
*
@@ -23,7 +28,7 @@ package org.springframework.restdocs.hypermedia;
*
* @author Andy Wilkinson
*/
public class LinkDescriptor {
public class LinkDescriptor extends AbstractDescriptor<LinkDescriptor> {
private final String rel;
@@ -67,4 +72,13 @@ public class LinkDescriptor {
boolean isOptional() {
return this.optional;
}
Map<String, Object> toModel() {
Map<String, Object> model = new HashMap<>();
model.put("rel", this.rel);
model.put("description", this.description);
model.put("optional", this.optional);
model.putAll(getAttributes());
return model;
}
}

View File

@@ -18,11 +18,13 @@ package org.springframework.restdocs.hypermedia;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import org.springframework.restdocs.snippet.SnippetGenerationException;
@@ -34,7 +36,7 @@ import org.springframework.util.Assert;
/**
* A {@link SnippetWritingResultHandler} that produces a snippet documenting a RESTful
* resource's links.
*
*
* @author Andy Wilkinson
*/
public class LinkSnippetResultHandler extends SnippetWritingResultHandler {
@@ -114,8 +116,16 @@ public class LinkSnippetResultHandler extends SnippetWritingResultHandler {
TemplateEngine templateEngine = (TemplateEngine) result.getRequest()
.getAttribute(TemplateEngine.class.getName());
Map<String, Object> context = new HashMap<>();
context.put("links", this.descriptorsByRel.values());
context.put("links", createLinksModel());
writer.print(templateEngine.compileTemplate("links").render(context));
}
private List<Map<String, Object>> createLinksModel() {
List<Map<String, Object>> model = new ArrayList<>();
for (Entry<String, LinkDescriptor> entry : this.descriptorsByRel.entrySet()) {
model.add(entry.getValue().toModel());
}
return model;
}
}

View File

@@ -16,6 +16,11 @@
package org.springframework.restdocs.payload;
import java.util.HashMap;
import java.util.Map;
import org.springframework.restdocs.AbstractDescriptor;
/**
* A description of a field found in a request or response payload
*
@@ -24,7 +29,7 @@ package org.springframework.restdocs.payload;
* @author Andreas Evers
* @author Andy Wilkinson
*/
public class FieldDescriptor {
public class FieldDescriptor extends AbstractDescriptor<FieldDescriptor> {
private final String path;
@@ -86,4 +91,14 @@ public class FieldDescriptor {
String getDescription() {
return this.description;
}
Map<String, Object> toModel() {
Map<String, Object> model = new HashMap<String, Object>();
model.put("path", this.path);
model.put("type", this.type.toString());
model.put("description", this.description);
model.put("optional", this.optional);
model.putAll(this.getAttributes());
return model;
}
}

View File

@@ -73,39 +73,32 @@ public abstract class FieldSnippetResultHandler extends SnippetWritingResultHand
Object payload = extractPayload(result);
TemplateEngine templateEngine = (TemplateEngine) result.getRequest()
.getAttribute(TemplateEngine.class.getName());
Map<String, Object> context = new HashMap<>();
List<Map<String, String>> fields = new ArrayList<>();
List<Map<String, Object>> fields = new ArrayList<>();
context.put("fields", fields);
for (Entry<String, FieldDescriptor> entry : this.descriptorsByPath.entrySet()) {
FieldDescriptor descriptor = entry.getValue();
FieldType type = getFieldType(descriptor, payload);
Map<String, String> fieldModel = new HashMap<>();
fieldModel.put("path", entry.getKey());
fieldModel.put("type", type.toString());
fieldModel.put("description", descriptor.getDescription());
fields.add(fieldModel);
if (descriptor.getType() == null) {
descriptor.type(getFieldType(descriptor, payload));
}
fields.add(descriptor.toModel());
}
TemplateEngine templateEngine = (TemplateEngine) result.getRequest()
.getAttribute(TemplateEngine.class.getName());
writer.print(templateEngine.compileTemplate(this.templateName).render(context));
}
private FieldType getFieldType(FieldDescriptor descriptor, Object payload) {
if (descriptor.getType() != null) {
return descriptor.getType();
try {
return FieldSnippetResultHandler.this.fieldTypeResolver.resolveFieldType(
descriptor.getPath(), payload);
}
else {
try {
return FieldSnippetResultHandler.this.fieldTypeResolver.resolveFieldType(
descriptor.getPath(), payload);
}
catch (FieldDoesNotExistException ex) {
String message = "Cannot determine the type of the field '"
+ descriptor.getPath() + "' as it is not present in the"
+ " payload. Please provide a type using"
+ " FieldDescriptor.type(FieldType).";
throw new FieldTypeRequiredException(message);
}
catch (FieldDoesNotExistException ex) {
String message = "Cannot determine the type of the field '"
+ descriptor.getPath() + "' as it is not present in the"
+ " payload. Please provide a type using"
+ " FieldDescriptor.type(FieldType).";
throw new FieldTypeRequiredException(message);
}
}

View File

@@ -16,6 +16,11 @@
package org.springframework.restdocs.request;
import java.util.HashMap;
import java.util.Map;
import org.springframework.restdocs.AbstractDescriptor;
/**
* A descriptor of a parameter in a query string
*
@@ -23,7 +28,7 @@ package org.springframework.restdocs.request;
* @see RequestDocumentation#parameterWithName
*
*/
public class ParameterDescriptor {
public class ParameterDescriptor extends AbstractDescriptor<ParameterDescriptor> {
private final String name;
@@ -52,4 +57,12 @@ public class ParameterDescriptor {
return this.description;
}
Map<String, Object> toModel() {
Map<String, Object> model = new HashMap<>();
model.put("name", this.name);
model.put("description", this.description);
model.putAll(getAttributes());
return model;
}
}

View File

@@ -18,10 +18,13 @@ package org.springframework.restdocs.request;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import org.springframework.restdocs.snippet.SnippetGenerationException;
@@ -33,7 +36,7 @@ import org.springframework.util.Assert;
/**
* A {@link SnippetWritingResultHandler} that produces a snippet documenting the query
* parameters supported by a RESTful resource.
*
*
* @author Andy Wilkinson
*/
public class QueryParametersSnippetResultHandler extends SnippetWritingResultHandler {
@@ -90,7 +93,11 @@ public class QueryParametersSnippetResultHandler extends SnippetWritingResultHan
TemplateEngine templateEngine = (TemplateEngine) result.getRequest()
.getAttribute(TemplateEngine.class.getName());
Map<String, Object> context = new HashMap<>();
context.put("parameters", this.descriptorsByName.values());
List<Map<String, Object>> parameters = new ArrayList<>();
for (Entry<String, ParameterDescriptor> entry : this.descriptorsByName.entrySet()) {
parameters.add(entry.getValue().toModel());
}
context.put("parameters", parameters);
writer.print(templateEngine.compileTemplate("query-parameters").render(context));
}

View File

@@ -17,6 +17,8 @@
package org.springframework.restdocs.hypermedia;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.documentLinks;
import static org.springframework.restdocs.test.SnippetMatchers.tableWithHeader;
import static org.springframework.restdocs.test.StubMvcResult.result;
@@ -26,8 +28,13 @@ import java.io.IOException;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.core.io.FileSystemResource;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.restdocs.snippet.SnippetGenerationException;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateResourceResolver;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
import org.springframework.restdocs.test.ExpectedSnippet;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
@@ -107,6 +114,29 @@ public class HypermediaDocumentationTests {
new LinkDescriptor("b").description("two")).handle(result());
}
@Test
public void linksWithCustomAttributes() throws IOException {
this.snippet.expectLinks("documented-links").withContents( //
tableWithHeader("Relation", "Description", "Foo") //
.row("a", "one", "alpha") //
.row("b", "two", "bravo"));
MockHttpServletRequest request = new MockHttpServletRequest();
TemplateResourceResolver resolver = mock(TemplateResourceResolver.class);
when(resolver.resolveTemplateResource("links"))
.thenReturn(
new FileSystemResource(
"src/test/resources/custom-snippet-templates/links-with-extra-column.snippet"));
request.setAttribute(TemplateEngine.class.getName(), new MustacheTemplateEngine(
resolver));
documentLinks(
"documented-links",
new StubLinkExtractor().withLinks(new Link("a", "alpha"), new Link("b",
"bravo")),
new LinkDescriptor("a").description("one").attribute("foo", "alpha"),
new LinkDescriptor("b").description("two").attribute("foo", "bravo"))
.handle(result(request));
}
private static class StubLinkExtractor implements LinkExtractor {
private MultiValueMap<String, Link> linksByRel = new LinkedMultiValueMap<String, Link>();

View File

@@ -18,6 +18,8 @@ package org.springframework.restdocs.payload;
import static org.hamcrest.CoreMatchers.endsWith;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.CoreMatchers.startsWith;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.restdocs.payload.PayloadDocumentation.documentRequestFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.documentResponseFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
@@ -30,8 +32,13 @@ import java.io.IOException;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.core.io.FileSystemResource;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.restdocs.snippet.SnippetGenerationException;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateResourceResolver;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
import org.springframework.restdocs.test.ExpectedSnippet;
/**
@@ -174,4 +181,53 @@ public class PayloadDocumentationTests {
fieldWithPath("a.b").description("one")).handle(
result(get("/foo").content("{ \"a\": { \"c\": 5 }}")));
}
@Test
public void requestFieldsWithCustomAttributes() throws IOException {
this.snippet.expectRequestFields("request-fields-with-custom-attributes")
.withContents( //
tableWithHeader("Path", "Type", "Description", "Foo") //
.row("a.b", "Number", "one", "alpha") //
.row("a.c", "String", "two", "bravo") //
.row("a", "Object", "three", "charlie"));
TemplateResourceResolver resolver = mock(TemplateResourceResolver.class);
when(resolver.resolveTemplateResource("fields"))
.thenReturn(
new FileSystemResource(
"src/test/resources/custom-snippet-templates/request-fields-with-extra-column.snippet"));
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAttribute(TemplateEngine.class.getName(), new MustacheTemplateEngine(
resolver));
request.setContent("{\"a\": {\"b\": 5, \"c\": \"charlie\"}}".getBytes());
documentRequestFields("request-fields-with-custom-attributes",
fieldWithPath("a.b").description("one").attribute("foo", "alpha"),
fieldWithPath("a.c").description("two").attribute("foo", "bravo"),
fieldWithPath("a").description("three").attribute("foo", "charlie"))
.handle(result(request));
}
@Test
public void responseFieldsWithCustomAttributes() throws IOException {
this.snippet.expectResponseFields("response-fields-with-custom-attributes")
.withContents( //
tableWithHeader("Path", "Type", "Description", "Foo") //
.row("a.b", "Number", "one", "alpha") //
.row("a.c", "String", "two", "bravo") //
.row("a", "Object", "three", "charlie"));
TemplateResourceResolver resolver = mock(TemplateResourceResolver.class);
when(resolver.resolveTemplateResource("fields"))
.thenReturn(
new FileSystemResource(
"src/test/resources/custom-snippet-templates/response-fields-with-extra-column.snippet"));
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAttribute(TemplateEngine.class.getName(), new MustacheTemplateEngine(
resolver));
MockHttpServletResponse response = new MockHttpServletResponse();
response.getOutputStream().print("{\"a\": {\"b\": 5, \"c\": \"charlie\"}}");
documentResponseFields("response-fields-with-custom-attributes",
fieldWithPath("a.b").description("one").attribute("foo", "alpha"),
fieldWithPath("a.c").description("two").attribute("foo", "bravo"),
fieldWithPath("a").description("three").attribute("foo", "charlie"))
.handle(result(request, response));
}
}

View File

@@ -17,6 +17,8 @@
package org.springframework.restdocs.request;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.restdocs.request.RequestDocumentation.documentQueryParameters;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.test.SnippetMatchers.tableWithHeader;
@@ -28,7 +30,12 @@ import java.io.IOException;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.core.io.FileSystemResource;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.restdocs.snippet.SnippetGenerationException;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateResourceResolver;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
import org.springframework.restdocs.test.ExpectedSnippet;
/**
@@ -100,4 +107,26 @@ public class RequestDocumentationTests {
result(get("/?a=alpha&b=bravo")));
}
@Test
public void parametersWithCustomAttributes() throws IOException {
this.snippet.expectQueryParameters("parameters-with-custom-attributes")
.withContents(
tableWithHeader("Parameter", "Description", "Foo").row("a",
"one", "alpha").row("b", "two", "bravo"));
TemplateResourceResolver resolver = mock(TemplateResourceResolver.class);
when(resolver.resolveTemplateResource("query-parameters"))
.thenReturn(
new FileSystemResource(
"src/test/resources/custom-snippet-templates/query-parameters-with-extra-column.snippet"));
MockHttpServletRequest request = new MockHttpServletRequest();
request.setAttribute(TemplateEngine.class.getName(), new MustacheTemplateEngine(
resolver));
request.addParameter("a", "bravo");
request.addParameter("b", "bravo");
documentQueryParameters("parameters-with-custom-attributes",
parameterWithName("a").description("one").attribute("foo", "alpha"),
parameterWithName("b").description("two").attribute("foo", "bravo"))
.handle(result(request));
}
}

View File

@@ -61,6 +61,11 @@ public class StubMvcResult implements MvcResult {
return new StubMvcResult(response);
}
public static StubMvcResult result(MockHttpServletRequest request,
MockHttpServletResponse response) {
return new StubMvcResult(request, response);
}
private StubMvcResult() {
this(new MockHttpServletRequest(), new MockHttpServletResponse());
}
@@ -79,8 +84,10 @@ public class StubMvcResult implements MvcResult {
private StubMvcResult(MockHttpServletRequest request, MockHttpServletResponse response) {
this.request = request;
this.request.setAttribute(TemplateEngine.class.getName(),
new MustacheTemplateEngine(new StandardTemplateResourceResolver()));
if (this.request.getAttribute(TemplateEngine.class.getName()) == null) {
this.request.setAttribute(TemplateEngine.class.getName(),
new MustacheTemplateEngine(new StandardTemplateResourceResolver()));
}
this.response = response;
}

View File

@@ -0,0 +1,10 @@
|===
|Relation|Description|Foo
{{#links}}
|{{rel}}
|{{description}}
|{{foo}}
{{/links}}
|===

View File

@@ -0,0 +1,10 @@
|===
|Parameter|Description|Foo
{{#parameters}}
|{{name}}
|{{description}}
|{{foo}}
{{/parameters}}
|===

View File

@@ -0,0 +1,11 @@
|===
|Path|Type|Description|Foo
{{#fields}}
|{{path}}
|{{type}}
|{{description}}
|{{foo}}
{{/fields}}
|===

View File

@@ -0,0 +1,11 @@
|===
|Path|Type|Description|Foo
{{#fields}}
|{{path}}
|{{type}}
|{{description}}
|{{foo}}
{{/fields}}
|===