Add support for documenting the parts of a multipart request

Closes gh-161
This commit is contained in:
Andy Wilkinson
2016-05-12 15:21:16 +01:00
parent 63a6ad2c91
commit fc3ae3b4a2
20 changed files with 890 additions and 0 deletions

View File

@@ -43,6 +43,17 @@ public abstract class RequestDocumentation {
return new ParameterDescriptor(name);
}
/**
* Creates a {@link RequestPartDescriptor} that describes a request part with the
* given {@code name}.
*
* @param name The name of the request part
* @return a {@link RequestPartDescriptor} ready for further configuration
*/
public static RequestPartDescriptor partWithName(String name) {
return new RequestPartDescriptor(name);
}
/**
* Returns a {@code Snippet} that will document the path parameters from the API
* operation's request. The parameters will be documented using the given
@@ -207,4 +218,83 @@ public abstract class RequestDocumentation {
return new RequestParametersSnippet(Arrays.asList(descriptors), attributes, true);
}
/**
* Returns a {@code Snippet} that will document the parts from the API operation's
* request. The parts will be documented using the given {@code descriptors}.
* <p>
* If a part is present in the request, but is not documented by one of the
* descriptors, a failure will occur when the snippet is invoked. Similarly, if a part
* is documented, is not marked as optional, and is not present in the request, a
* failure will also occur.
* <p>
* If you do not want to document a part, a part descriptor can be marked as
* {@link RequestPartDescriptor#ignored}. This will prevent it from appearing in the
* generated snippet while avoiding the failure described above.
*
* @param descriptors The descriptions of the request's parts
* @return the snippet
* @see OperationRequest#getParts()
*/
public static RequestPartsSnippet requestParts(RequestPartDescriptor... descriptors) {
return new RequestPartsSnippet(Arrays.asList(descriptors));
}
/**
* Returns a {@code Snippet} that will document the parts from the API operation's
* request. The parameters will be documented using the given {@code descriptors}.
* <p>
* If a part is documented, is not marked as optional, and is not present in the
* request, a failure will occur. Any undocumented parts will be ignored.
*
* @param descriptors The descriptions of the request's parts
* @return the snippet
* @see OperationRequest#getParts()
*/
public static RequestPartsSnippet relaxedRequestParts(
RequestPartDescriptor... descriptors) {
return new RequestPartsSnippet(Arrays.asList(descriptors), true);
}
/**
* Returns a {@code Snippet} that will document the parts from the API operation's
* request. The given {@code attributes} will be available during snippet rendering
* and the parts will be documented using the given {@code descriptors}.
* <p>
* If a part is present in the request, but is not documented by one of the
* descriptors, a failure will occur when the snippet is invoked. Similarly, if a part
* is documented, is not marked as optional, and is not present in the request, a
* failure will also occur.
* <p>
* If you do not want to document a part, a part descriptor can be marked as
* {@link RequestPartDescriptor#ignored}. This will prevent it from appearing in the
* generated snippet while avoiding the failure described above.
*
* @param attributes the attributes
* @param descriptors the descriptions of the request's parts
* @return the snippet
* @see OperationRequest#getParts()
*/
public static RequestPartsSnippet requestParts(Map<String, Object> attributes,
RequestPartDescriptor... descriptors) {
return new RequestPartsSnippet(Arrays.asList(descriptors), attributes);
}
/**
* Returns a {@code Snippet} that will document the parts from the API operation's
* request. The given {@code attributes} will be available during snippet rendering
* and the parts will be documented using the given {@code descriptors}.
* <p>
* If a part is documented, is not marked as optional, and is not present in the
* request, a failure will occur. Any undocumented parts will be ignored.
*
* @param attributes the attributes
* @param descriptors the descriptions of the request's parts
* @return the snippet
* @see OperationRequest#getParameters()
*/
public static RequestPartsSnippet relaxedRequestParts(Map<String, Object> attributes,
RequestPartDescriptor... descriptors) {
return new RequestPartsSnippet(Arrays.asList(descriptors), attributes, true);
}
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2014-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.restdocs.request;
import org.springframework.restdocs.snippet.IgnorableDescriptor;
/**
* A descriptor of a request part.
*
* @author Andy Wilkinson
* @see RequestDocumentation#partWithName
*/
public class RequestPartDescriptor extends IgnorableDescriptor<RequestPartDescriptor> {
private final String name;
private boolean optional;
/**
* Creates a new {@code RequestPartDescriptor} describing the request part with the
* given {@code name}.
*
* @param name the name of the request part
*/
protected RequestPartDescriptor(String name) {
this.name = name;
}
/**
* Marks the request part as optional.
*
* @return {@code this}
*/
public final RequestPartDescriptor optional() {
this.optional = true;
return this;
}
/**
* Returns the name of the request part being described by this descriptor.
*
* @return the name of the parameter
*/
public final String getName() {
return this.name;
}
/**
* Returns {@code true} if the described request part is optional, otherwise
* {@code false}.
*
* @return {@code true} if the described request part is optional, otherwise
* {@code false}
*/
public final boolean isOptional() {
return this.optional;
}
}

View File

@@ -0,0 +1,206 @@
/*
* Copyright 2014-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.restdocs.request;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
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.operation.Operation;
import org.springframework.restdocs.operation.OperationRequestPart;
import org.springframework.restdocs.snippet.Snippet;
import org.springframework.restdocs.snippet.SnippetException;
import org.springframework.restdocs.snippet.TemplatedSnippet;
import org.springframework.util.Assert;
/**
* A {@link Snippet} that documents the request parts supported by a RESTful resource.
*
* @author Andy Wilkinson
* @see RequestDocumentation#requestParts(RequestPartDescriptor...)
* @see RequestDocumentation#requestParts(Map, RequestPartDescriptor...)
* @see RequestDocumentation#relaxedRequestParts(RequestPartDescriptor...)
* @see RequestDocumentation#relaxedRequestParts(Map, RequestPartDescriptor...)
*/
public class RequestPartsSnippet extends TemplatedSnippet {
private final Map<String, RequestPartDescriptor> descriptorsByName = new LinkedHashMap<>();
private final boolean ignoreUndocumentedParts;
/**
* Creates a new {@code RequestPartsSnippet} that will document the request's parts
* using the given {@code descriptors}. Undocumented parts will trigger a failure.
*
* @param descriptors the parameter descriptors
*/
protected RequestPartsSnippet(List<RequestPartDescriptor> descriptors) {
this(descriptors, null, false);
}
/**
* Creates a new {@code RequestPartsSnippet} that will document the request's parts
* using the given {@code descriptors}. If {@code ignoreUndocumentedParts} is
* {@code true}, undocumented parts will be ignored and will not trigger a failure.
*
* @param descriptors the parameter descriptors
* @param ignoreUndocumentedParts whether undocumented parts should be ignored
*/
protected RequestPartsSnippet(List<RequestPartDescriptor> descriptors,
boolean ignoreUndocumentedParts) {
this(descriptors, null, ignoreUndocumentedParts);
}
/**
* Creates a new {@code RequestPartsSnippet} that will document the request's parts
* using the given {@code descriptors}. The given {@code attributes} will be included
* in the model during template rendering. Undocumented parts will trigger a failure.
*
* @param descriptors the parameter descriptors
* @param attributes the additional attributes
*/
protected RequestPartsSnippet(List<RequestPartDescriptor> descriptors,
Map<String, Object> attributes) {
this(descriptors, attributes, false);
}
/**
* Creates a new {@code RequestPartsSnippet} that will document the request's parts
* using the given {@code descriptors}. The given {@code attributes} will be included
* in the model during template rendering. If {@code ignoreUndocumentedParts} is
* {@code true}, undocumented parts will be ignored and will not trigger a failure.
*
* @param descriptors the parameter descriptors
* @param attributes the additional attributes
* @param ignoreUndocumentedParts whether undocumented parts should be ignored
*/
protected RequestPartsSnippet(List<RequestPartDescriptor> descriptors,
Map<String, Object> attributes, boolean ignoreUndocumentedParts) {
super("request-parts", attributes);
for (RequestPartDescriptor descriptor : descriptors) {
Assert.notNull(descriptor.getName(),
"Request part descriptors must have a name");
if (!descriptor.isIgnored()) {
Assert.notNull(descriptor.getDescription(),
"The descriptor for request part '" + descriptor.getName()
+ "' must either have a description or be marked as "
+ "ignored");
}
this.descriptorsByName.put(descriptor.getName(), descriptor);
}
this.ignoreUndocumentedParts = ignoreUndocumentedParts;
}
/**
* Returns a new {@code RequestPartsSnippet} configured with this snippet's attributes
* and its descriptors combined with the given {@code additionalDescriptors}.
*
* @param additionalDescriptors the additional descriptors
* @return the new snippet
*/
public RequestPartsSnippet and(RequestPartDescriptor... additionalDescriptors) {
List<RequestPartDescriptor> combinedDescriptors = new ArrayList<>();
combinedDescriptors.addAll(this.descriptorsByName.values());
combinedDescriptors.addAll(Arrays.asList(additionalDescriptors));
return new RequestPartsSnippet(combinedDescriptors, this.getAttributes());
}
@Override
protected Map<String, Object> createModel(Operation operation) {
verifyRequestPartDescriptors(operation);
Map<String, Object> model = new HashMap<>();
List<Map<String, Object>> requestParts = new ArrayList<>();
for (Entry<String, RequestPartDescriptor> entry : this.descriptorsByName
.entrySet()) {
RequestPartDescriptor descriptor = entry.getValue();
if (!descriptor.isIgnored()) {
requestParts.add(createModelForDescriptor(descriptor));
}
}
model.put("requestParts", requestParts);
return model;
}
private void verifyRequestPartDescriptors(Operation operation) {
Set<String> actualRequestParts = extractActualRequestParts(operation);
Set<String> expectedRequestParts = new HashSet<>();
for (Entry<String, RequestPartDescriptor> entry : this.descriptorsByName
.entrySet()) {
if (!entry.getValue().isOptional()) {
expectedRequestParts.add(entry.getKey());
}
}
Set<String> undocumentedRequestParts;
if (this.ignoreUndocumentedParts) {
undocumentedRequestParts = Collections.emptySet();
}
else {
undocumentedRequestParts = new HashSet<>(actualRequestParts);
undocumentedRequestParts.removeAll(this.descriptorsByName.keySet());
}
Set<String> missingRequestParts = new HashSet<>(expectedRequestParts);
missingRequestParts.removeAll(actualRequestParts);
if (!undocumentedRequestParts.isEmpty() || !missingRequestParts.isEmpty()) {
verificationFailed(undocumentedRequestParts, missingRequestParts);
}
}
private Set<String> extractActualRequestParts(Operation operation) {
Set<String> actualRequestParts = new HashSet<>();
for (OperationRequestPart requestPart : operation.getRequest().getParts()) {
actualRequestParts.add(requestPart.getName());
}
return actualRequestParts;
}
private void verificationFailed(Set<String> undocumentedRequestParts,
Set<String> missingRequestParts) {
String message = "";
if (!undocumentedRequestParts.isEmpty()) {
message += "Request parts with the following names were not documented: "
+ undocumentedRequestParts;
}
if (!missingRequestParts.isEmpty()) {
if (message.length() > 0) {
message += ". ";
}
message += "Request parts with the following names were not found in "
+ "the request: " + missingRequestParts;
}
throw new SnippetException(message);
}
private Map<String, Object> createModelForDescriptor(
RequestPartDescriptor descriptor) {
Map<String, Object> model = new HashMap<>();
model.put("name", descriptor.getName());
model.put("description", descriptor.getDescription());
model.put("optional", descriptor.isOptional());
model.putAll(descriptor.getAttributes());
return model;
}
}

View File

@@ -0,0 +1,9 @@
|===
|Part|Description
{{#requestParts}}
|`{{name}}`
|{{description}}
{{/requestParts}}
|===

View File

@@ -0,0 +1,5 @@
Part | Description
---- | -----------
{{#requestParts}}
`{{name}}` | {{description}}
{{/requestParts}}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2012-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.restdocs.request;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.restdocs.snippet.SnippetException;
import org.springframework.restdocs.templates.TemplateFormats;
import org.springframework.restdocs.test.ExpectedSnippet;
import org.springframework.restdocs.test.OperationBuilder;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.springframework.restdocs.request.RequestDocumentation.partWithName;
/**
* Tests for failures when rendering {@link RequestPartsSnippet} due to missing or
* undocumented request parts.
*
* @author Andy Wilkinson
*/
public class RequestPartsSnippetFailureTests {
@Rule
public ExpectedSnippet snippet = new ExpectedSnippet(TemplateFormats.asciidoctor());
@Rule
public ExpectedException thrown = ExpectedException.none();
@Test
public void undocumentedPart() throws IOException {
this.thrown.expect(SnippetException.class);
this.thrown.expectMessage(equalTo(
"Request parts with the following names were" + " not documented: [a]"));
new RequestPartsSnippet(Collections.<RequestPartDescriptor>emptyList())
.document(new OperationBuilder("undocumented-part",
this.snippet.getOutputDirectory()).request("http://localhost")
.part("a", "alpha".getBytes()).build());
}
@Test
public void missingPart() throws IOException {
this.thrown.expect(SnippetException.class);
this.thrown.expectMessage(equalTo("Request parts with the following names were"
+ " not found in the request: [a]"));
new RequestPartsSnippet(Arrays.asList(partWithName("a").description("one")))
.document(new OperationBuilder("missing-part",
this.snippet.getOutputDirectory()).request("http://localhost")
.build());
}
@Test
public void undocumentedAndMissingParts() throws IOException {
this.thrown.expect(SnippetException.class);
this.thrown.expectMessage(equalTo("Request parts with the following names were"
+ " not documented: [b]. Request parts with the following"
+ " names were not found in the request: [a]"));
new RequestPartsSnippet(Arrays.asList(partWithName("a").description("one")))
.document(new OperationBuilder("undocumented-and-missing-parts",
this.snippet.getOutputDirectory()).request("http://localhost")
.part("b", "bravo".getBytes()).build());
}
}

View File

@@ -0,0 +1,183 @@
/*
* Copyright 2014-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.restdocs.request;
import java.io.IOException;
import java.util.Arrays;
import org.junit.Test;
import org.springframework.restdocs.AbstractSnippetTests;
import org.springframework.restdocs.templates.TemplateEngine;
import org.springframework.restdocs.templates.TemplateFormat;
import org.springframework.restdocs.templates.TemplateResourceResolver;
import org.springframework.restdocs.templates.mustache.MustacheTemplateEngine;
import static org.hamcrest.CoreMatchers.containsString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.springframework.restdocs.request.RequestDocumentation.partWithName;
import static org.springframework.restdocs.snippet.Attributes.attributes;
import static org.springframework.restdocs.snippet.Attributes.key;
/**
* Tests for {@link RequestPartsSnippet}.
*
* @author Andy Wilkinson
*/
public class RequestPartsSnippetTests extends AbstractSnippetTests {
public RequestPartsSnippetTests(String name, TemplateFormat templateFormat) {
super(name, templateFormat);
}
@Test
public void requestParts() throws IOException {
this.snippet.expectRequestParts("request-parts")
.withContents(tableWithHeader("Part", "Description").row("`a`", "one")
.row("`b`", "two"));
new RequestPartsSnippet(Arrays.asList(partWithName("a").description("one"),
partWithName("b").description("two")))
.document(operationBuilder("request-parts")
.request("http://localhost").part("a", "bravo".getBytes())
.and().part("b", "bravo".getBytes()).build());
}
@Test
public void ignoredRequestPart() throws IOException {
this.snippet.expectRequestParts("ignored-request-part")
.withContents(tableWithHeader("Part", "Description").row("`b`", "two"));
new RequestPartsSnippet(Arrays.asList(partWithName("a").ignored(),
partWithName("b").description("two")))
.document(operationBuilder("ignored-request-part")
.request("http://localhost").part("a", "bravo".getBytes())
.and().part("b", "bravo".getBytes()).build());
}
@Test
public void allUndocumentedRequestPartsCanBeIgnored() throws IOException {
this.snippet.expectRequestParts("ignore-all-undocumented")
.withContents(tableWithHeader("Part", "Description").row("`b`", "two"));
new RequestPartsSnippet(Arrays.asList(partWithName("b").description("two")), true)
.document(operationBuilder("ignore-all-undocumented")
.request("http://localhost").part("a", "bravo".getBytes()).and()
.part("b", "bravo".getBytes()).build());
}
@Test
public void missingOptionalRequestPart() throws IOException {
this.snippet.expectRequestParts("missing-optional-request-parts")
.withContents(tableWithHeader("Part", "Description").row("`a`", "one")
.row("`b`", "two"));
new RequestPartsSnippet(
Arrays.asList(partWithName("a").description("one").optional(),
partWithName("b").description("two"))).document(
operationBuilder("missing-optional-request-parts")
.request("http://localhost")
.part("b", "bravo".getBytes()).build());
}
@Test
public void presentOptionalRequestPart() throws IOException {
this.snippet.expectRequestParts("present-optional-request-part")
.withContents(tableWithHeader("Part", "Description").row("`a`", "one"));
new RequestPartsSnippet(
Arrays.asList(partWithName("a").description("one").optional()))
.document(operationBuilder("present-optional-request-part")
.request("http://localhost").part("a", "one".getBytes())
.build());
}
@Test
public void requestPartsWithCustomAttributes() throws IOException {
TemplateResourceResolver resolver = mock(TemplateResourceResolver.class);
given(resolver.resolveTemplateResource("request-parts"))
.willReturn(snippetResource("request-parts-with-title"));
this.snippet.expectRequestParts("request-parts-with-custom-attributes")
.withContents(containsString("The title"));
new RequestPartsSnippet(
Arrays.asList(
partWithName("a").description("one")
.attributes(key("foo").value("alpha")),
partWithName("b").description("two")
.attributes(key("foo").value("bravo"))),
attributes(key("title").value("The title")))
.document(operationBuilder("request-parts-with-custom-attributes")
.attribute(TemplateEngine.class.getName(),
new MustacheTemplateEngine(resolver))
.request("http://localhost").part("a", "alpha".getBytes())
.and().part("b", "bravo".getBytes()).build());
}
@Test
public void requestPartsWithCustomDescriptorAttributes() throws IOException {
TemplateResourceResolver resolver = mock(TemplateResourceResolver.class);
given(resolver.resolveTemplateResource("request-parts"))
.willReturn(snippetResource("request-parts-with-extra-column"));
this.snippet.expectRequestParts("request-parts-with-custom-descriptor-attributes")
.withContents(tableWithHeader("Part", "Description", "Foo")
.row("a", "one", "alpha").row("b", "two", "bravo"));
new RequestPartsSnippet(Arrays.asList(
partWithName("a").description("one")
.attributes(key("foo").value("alpha")),
partWithName("b").description("two")
.attributes(key("foo").value("bravo"))))
.document(operationBuilder(
"request-parts-with-custom-descriptor-attributes")
.attribute(TemplateEngine.class.getName(),
new MustacheTemplateEngine(
resolver))
.request("http://localhost")
.part("a", "alpha".getBytes()).and()
.part("b", "bravo".getBytes()).build());
}
@Test
public void requestPartsWithOptionalColumn() throws IOException {
TemplateResourceResolver resolver = mock(TemplateResourceResolver.class);
given(resolver.resolveTemplateResource("request-parts"))
.willReturn(snippetResource("request-parts-with-optional-column"));
this.snippet.expectRequestParts("request-parts-with-optional-column")
.withContents(tableWithHeader("Part", "Optional", "Description")
.row("a", "true", "one").row("b", "false", "two"));
new RequestPartsSnippet(
Arrays.asList(partWithName("a").description("one").optional(),
partWithName("b").description("two"))).document(
operationBuilder("request-parts-with-optional-column")
.attribute(TemplateEngine.class.getName(),
new MustacheTemplateEngine(resolver))
.request("http://localhost")
.part("a", "alpha".getBytes()).and()
.part("b", "bravo".getBytes()).build());
}
@Test
public void additionalDescriptors() throws IOException {
this.snippet.expectRequestParts("additional-descriptors")
.withContents(tableWithHeader("Part", "Description").row("`a`", "one")
.row("`b`", "two"));
RequestDocumentation.requestParts(partWithName("a").description("one"))
.and(partWithName("b").description("two"))
.document(operationBuilder("additional-descriptors")
.request("http://localhost").part("a", "bravo".getBytes()).and()
.part("b", "bravo".getBytes()).build());
}
}

View File

@@ -126,6 +126,11 @@ public class ExpectedSnippet implements TestRule {
return this;
}
public ExpectedSnippet expectRequestParts(String name) {
expect(name, "request-parts");
return this;
}
private ExpectedSnippet expect(String name, String type) {
this.expectedName = name;
this.expectedType = type;

View File

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

View File

@@ -0,0 +1,10 @@
|===
|Part|Optional|Description
{{#requestParts}}
|{{name}}
|{{optional}}
|{{description}}
{{/requestParts}}
|===

View File

@@ -0,0 +1,10 @@
.{{title}}
|===
|Part|Description
{{#requestParts}}
|{{name}}
|{{description}}
{{/requestParts}}
|===

View File

@@ -0,0 +1,5 @@
Part | Description | Foo
---- | ----------- | ---
{{#requestParts}}
{{name}} | {{description}} | {{foo}}
{{/requestParts}}

View File

@@ -0,0 +1,5 @@
Part | Optional | Description
---- | -------- | -----------
{{#requestParts}}
{{name}} | {{optional}} | {{description}}
{{/requestParts}}

View File

@@ -0,0 +1,6 @@
{{title}}
Part | Description
---- | -----------
{{#requestParts}}
{{name}} | {{description}}
{{/requestParts}}