diff --git a/docs/src/docs/asciidoc/documenting-your-api.adoc b/docs/src/docs/asciidoc/documenting-your-api.adoc index 7e53f1d9..d1a27442 100644 --- a/docs/src/docs/asciidoc/documenting-your-api.adoc +++ b/docs/src/docs/asciidoc/documenting-your-api.adoc @@ -527,6 +527,55 @@ above. +[[documenting-your-api-request-parts]] +=== Request parts + +The parts of a multipart request can be documenting using `requestParts`. For example: + +[source,java,indent=0,role="primary"] +.MockMvc +---- +include::{examples-dir}/com/example/mockmvc/RequestParts.java[tags=request-parts] +---- +<1> Perform a `POST` request with a single part named `file`. +<2> Configure Spring REST Docs to produce a snippet describing the request's parts. Uses + the static `requestParts` method on + `org.springframework.restdocs.request.RequestDocumentation`. +<3> Document the part named `file`. Uses the static `partWithName` method on + `org.springframework.restdocs.request.RequestDocumentation`. + +[source,java,indent=0,role="secondary"] +.REST Assured +---- +include::{examples-dir}/com/example/restassured/RequestParts.java[tags=request-parts] +---- +<1> Configure Spring REST Docs to produce a snippet describing the request's parts. Uses + the static `requestParts` method on + `org.springframework.restdocs.request.RequestDocumentation`. +<2> Document the part named `file`. Uses the static `partWithName` method on + `org.springframework.restdocs.request.RequestDocumentation`. +<3> Configure the request with the part named `file`. +<4> Perform the `POST` request to `/upload`. + +The result is a snippet named `request-parts.adoc` that contains a table describing the +request parts that are supported by the resource. + +When documenting request parts, the test will fail if an undocumented part is used in the +request. Similarly, the test will also fail if a documented part is not found in the +request and the part has not been marked as optional. + +Request parts can also be documented in a relaxed mode where any undocumented +parts will not cause a test failure. To do so, use the `relaxedRequestParts` method on +`org.springframework.restdocs.request.RequestDocumentation`. This can be useful +when documenting a particular scenario where you only want to focus on a subset of the +request parts. + +If you do not want to document a request part, you can mark it as ignored. This will +prevent it from appearing in the generated snippet while avoiding the failure described +above. + + + [[documenting-your-api-http-headers]] === HTTP headers diff --git a/docs/src/test/java/com/example/mockmvc/RequestParts.java b/docs/src/test/java/com/example/mockmvc/RequestParts.java new file mode 100644 index 00000000..90210d50 --- /dev/null +++ b/docs/src/test/java/com/example/mockmvc/RequestParts.java @@ -0,0 +1,41 @@ +/* + * 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 com.example.mockmvc; + +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.fileUpload; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class RequestParts { + + private MockMvc mockMvc; + + public void upload() throws Exception { + // tag::request-parts[] + this.mockMvc.perform(fileUpload("/upload").file("file", "example".getBytes())) // <1> + .andExpect(status().isOk()) + .andDo(document("upload", requestParts( // <2> + partWithName("file").description("The file to upload")) // <3> + )); + // end::request-parts[] + } + +} diff --git a/docs/src/test/java/com/example/restassured/RequestParts.java b/docs/src/test/java/com/example/restassured/RequestParts.java new file mode 100644 index 00000000..c6a3f8c1 --- /dev/null +++ b/docs/src/test/java/com/example/restassured/RequestParts.java @@ -0,0 +1,42 @@ +/* + * 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 com.example.restassured; + +import com.jayway.restassured.RestAssured; +import com.jayway.restassured.specification.RequestSpecification; + +import static org.hamcrest.CoreMatchers.is; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; + +public class RequestParts { + + private RequestSpecification spec; + + public void upload() throws Exception { + // tag::request-parts[] + RestAssured.given(this.spec) + .filter(document("users", requestParts( // <1> + partWithName("file").description("The file to upload")))) // <2> + .multiPart("file", "example") // <3> + .when().post("/upload") // <4> + .then().statusCode(is(200)); + // end::request-parts[] + } + +} diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/request/RequestDocumentation.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/request/RequestDocumentation.java index 4c4aee73..4fec764a 100644 --- a/spring-restdocs-core/src/main/java/org/springframework/restdocs/request/RequestDocumentation.java +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/request/RequestDocumentation.java @@ -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}. + *

+ * 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. + *

+ * 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}. + *

+ * 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}. + *

+ * 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. + *

+ * 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 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}. + *

+ * 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 attributes, + RequestPartDescriptor... descriptors) { + return new RequestPartsSnippet(Arrays.asList(descriptors), attributes, true); + } + } diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/request/RequestPartDescriptor.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/request/RequestPartDescriptor.java new file mode 100644 index 00000000..78964960 --- /dev/null +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/request/RequestPartDescriptor.java @@ -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 { + + 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; + } + +} diff --git a/spring-restdocs-core/src/main/java/org/springframework/restdocs/request/RequestPartsSnippet.java b/spring-restdocs-core/src/main/java/org/springframework/restdocs/request/RequestPartsSnippet.java new file mode 100644 index 00000000..1a5d3059 --- /dev/null +++ b/spring-restdocs-core/src/main/java/org/springframework/restdocs/request/RequestPartsSnippet.java @@ -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 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 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 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 descriptors, + Map 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 descriptors, + Map 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 combinedDescriptors = new ArrayList<>(); + combinedDescriptors.addAll(this.descriptorsByName.values()); + combinedDescriptors.addAll(Arrays.asList(additionalDescriptors)); + return new RequestPartsSnippet(combinedDescriptors, this.getAttributes()); + } + + @Override + protected Map createModel(Operation operation) { + verifyRequestPartDescriptors(operation); + Map model = new HashMap<>(); + List> requestParts = new ArrayList<>(); + for (Entry 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 actualRequestParts = extractActualRequestParts(operation); + Set expectedRequestParts = new HashSet<>(); + for (Entry entry : this.descriptorsByName + .entrySet()) { + if (!entry.getValue().isOptional()) { + expectedRequestParts.add(entry.getKey()); + } + } + Set undocumentedRequestParts; + if (this.ignoreUndocumentedParts) { + undocumentedRequestParts = Collections.emptySet(); + } + else { + undocumentedRequestParts = new HashSet<>(actualRequestParts); + undocumentedRequestParts.removeAll(this.descriptorsByName.keySet()); + } + + Set missingRequestParts = new HashSet<>(expectedRequestParts); + missingRequestParts.removeAll(actualRequestParts); + + if (!undocumentedRequestParts.isEmpty() || !missingRequestParts.isEmpty()) { + verificationFailed(undocumentedRequestParts, missingRequestParts); + } + } + + private Set extractActualRequestParts(Operation operation) { + Set actualRequestParts = new HashSet<>(); + for (OperationRequestPart requestPart : operation.getRequest().getParts()) { + actualRequestParts.add(requestPart.getName()); + } + return actualRequestParts; + } + + private void verificationFailed(Set undocumentedRequestParts, + Set 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 createModelForDescriptor( + RequestPartDescriptor descriptor) { + Map model = new HashMap<>(); + model.put("name", descriptor.getName()); + model.put("description", descriptor.getDescription()); + model.put("optional", descriptor.isOptional()); + model.putAll(descriptor.getAttributes()); + return model; + } + +} diff --git a/spring-restdocs-core/src/main/resources/org/springframework/restdocs/templates/asciidoctor/default-request-parts.snippet b/spring-restdocs-core/src/main/resources/org/springframework/restdocs/templates/asciidoctor/default-request-parts.snippet new file mode 100644 index 00000000..3ed4773b --- /dev/null +++ b/spring-restdocs-core/src/main/resources/org/springframework/restdocs/templates/asciidoctor/default-request-parts.snippet @@ -0,0 +1,9 @@ +|=== +|Part|Description + +{{#requestParts}} +|`{{name}}` +|{{description}} + +{{/requestParts}} +|=== \ No newline at end of file diff --git a/spring-restdocs-core/src/main/resources/org/springframework/restdocs/templates/markdown/default-request-parts.snippet b/spring-restdocs-core/src/main/resources/org/springframework/restdocs/templates/markdown/default-request-parts.snippet new file mode 100644 index 00000000..b313f2d3 --- /dev/null +++ b/spring-restdocs-core/src/main/resources/org/springframework/restdocs/templates/markdown/default-request-parts.snippet @@ -0,0 +1,5 @@ +Part | Description +---- | ----------- +{{#requestParts}} +`{{name}}` | {{description}} +{{/requestParts}} \ No newline at end of file diff --git a/spring-restdocs-core/src/test/java/org/springframework/restdocs/request/RequestPartsSnippetFailureTests.java b/spring-restdocs-core/src/test/java/org/springframework/restdocs/request/RequestPartsSnippetFailureTests.java new file mode 100644 index 00000000..93de349a --- /dev/null +++ b/spring-restdocs-core/src/test/java/org/springframework/restdocs/request/RequestPartsSnippetFailureTests.java @@ -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.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()); + } + +} diff --git a/spring-restdocs-core/src/test/java/org/springframework/restdocs/request/RequestPartsSnippetTests.java b/spring-restdocs-core/src/test/java/org/springframework/restdocs/request/RequestPartsSnippetTests.java new file mode 100644 index 00000000..6ce2a09a --- /dev/null +++ b/spring-restdocs-core/src/test/java/org/springframework/restdocs/request/RequestPartsSnippetTests.java @@ -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()); + } + +} diff --git a/spring-restdocs-core/src/test/java/org/springframework/restdocs/test/ExpectedSnippet.java b/spring-restdocs-core/src/test/java/org/springframework/restdocs/test/ExpectedSnippet.java index 2799f1cc..bc1c7785 100644 --- a/spring-restdocs-core/src/test/java/org/springframework/restdocs/test/ExpectedSnippet.java +++ b/spring-restdocs-core/src/test/java/org/springframework/restdocs/test/ExpectedSnippet.java @@ -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; diff --git a/spring-restdocs-core/src/test/resources/custom-snippet-templates/asciidoctor/request-parts-with-extra-column.snippet b/spring-restdocs-core/src/test/resources/custom-snippet-templates/asciidoctor/request-parts-with-extra-column.snippet new file mode 100644 index 00000000..95f52f2f --- /dev/null +++ b/spring-restdocs-core/src/test/resources/custom-snippet-templates/asciidoctor/request-parts-with-extra-column.snippet @@ -0,0 +1,10 @@ +|=== +|Part|Description|Foo + +{{#requestParts}} +|{{name}} +|{{description}} +|{{foo}} + +{{/requestParts}} +|=== \ No newline at end of file diff --git a/spring-restdocs-core/src/test/resources/custom-snippet-templates/asciidoctor/request-parts-with-optional-column.snippet b/spring-restdocs-core/src/test/resources/custom-snippet-templates/asciidoctor/request-parts-with-optional-column.snippet new file mode 100644 index 00000000..ca023464 --- /dev/null +++ b/spring-restdocs-core/src/test/resources/custom-snippet-templates/asciidoctor/request-parts-with-optional-column.snippet @@ -0,0 +1,10 @@ +|=== +|Part|Optional|Description + +{{#requestParts}} +|{{name}} +|{{optional}} +|{{description}} + +{{/requestParts}} +|=== \ No newline at end of file diff --git a/spring-restdocs-core/src/test/resources/custom-snippet-templates/asciidoctor/request-parts-with-title.snippet b/spring-restdocs-core/src/test/resources/custom-snippet-templates/asciidoctor/request-parts-with-title.snippet new file mode 100644 index 00000000..7e673182 --- /dev/null +++ b/spring-restdocs-core/src/test/resources/custom-snippet-templates/asciidoctor/request-parts-with-title.snippet @@ -0,0 +1,10 @@ +.{{title}} +|=== +|Part|Description + +{{#requestParts}} +|{{name}} +|{{description}} + +{{/requestParts}} +|=== \ No newline at end of file diff --git a/spring-restdocs-core/src/test/resources/custom-snippet-templates/markdown/request-parts-with-extra-column.snippet b/spring-restdocs-core/src/test/resources/custom-snippet-templates/markdown/request-parts-with-extra-column.snippet new file mode 100644 index 00000000..b81a8396 --- /dev/null +++ b/spring-restdocs-core/src/test/resources/custom-snippet-templates/markdown/request-parts-with-extra-column.snippet @@ -0,0 +1,5 @@ +Part | Description | Foo +---- | ----------- | --- +{{#requestParts}} +{{name}} | {{description}} | {{foo}} +{{/requestParts}} \ No newline at end of file diff --git a/spring-restdocs-core/src/test/resources/custom-snippet-templates/markdown/request-parts-with-optional-column.snippet b/spring-restdocs-core/src/test/resources/custom-snippet-templates/markdown/request-parts-with-optional-column.snippet new file mode 100644 index 00000000..c92cdc89 --- /dev/null +++ b/spring-restdocs-core/src/test/resources/custom-snippet-templates/markdown/request-parts-with-optional-column.snippet @@ -0,0 +1,5 @@ +Part | Optional | Description +---- | -------- | ----------- +{{#requestParts}} +{{name}} | {{optional}} | {{description}} +{{/requestParts}} \ No newline at end of file diff --git a/spring-restdocs-core/src/test/resources/custom-snippet-templates/markdown/request-parts-with-title.snippet b/spring-restdocs-core/src/test/resources/custom-snippet-templates/markdown/request-parts-with-title.snippet new file mode 100644 index 00000000..0dc1b3e9 --- /dev/null +++ b/spring-restdocs-core/src/test/resources/custom-snippet-templates/markdown/request-parts-with-title.snippet @@ -0,0 +1,6 @@ +{{title}} +Part | Description +---- | ----------- +{{#requestParts}} +{{name}} | {{description}} +{{/requestParts}} \ No newline at end of file diff --git a/spring-restdocs-mockmvc/src/test/java/org/springframework/restdocs/mockmvc/MockMvcRestDocumentationIntegrationTests.java b/spring-restdocs-mockmvc/src/test/java/org/springframework/restdocs/mockmvc/MockMvcRestDocumentationIntegrationTests.java index 28b76962..5068d34f 100644 --- a/spring-restdocs-mockmvc/src/test/java/org/springframework/restdocs/mockmvc/MockMvcRestDocumentationIntegrationTests.java +++ b/spring-restdocs-mockmvc/src/test/java/org/springframework/restdocs/mockmvc/MockMvcRestDocumentationIntegrationTests.java @@ -71,8 +71,10 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWit import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; import static org.springframework.restdocs.snippet.Attributes.attributes; import static org.springframework.restdocs.snippet.Attributes.key; import static org.springframework.restdocs.templates.TemplateFormats.asciidoctor; @@ -81,6 +83,7 @@ import static org.springframework.restdocs.test.SnippetMatchers.codeBlock; import static org.springframework.restdocs.test.SnippetMatchers.httpRequest; import static org.springframework.restdocs.test.SnippetMatchers.httpResponse; import static org.springframework.restdocs.test.SnippetMatchers.snippet; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.fileUpload; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -259,6 +262,20 @@ public class MockMvcRestDocumentationIntegrationTests { "request-fields.adoc"); } + @Test + public void requestPartsSnippet() throws Exception { + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.context) + .apply(documentationConfiguration(this.restDocumentation)).build(); + + mockMvc.perform(fileUpload("/upload").file("foo", "bar".getBytes())) + .andExpect(status().isOk()).andDo(document("request-parts", requestParts( + partWithName("foo").description("The description")))); + + assertExpectedSnippetFilesExist( + new File("build/generated-snippets/request-parts"), "http-request.adoc", + "http-response.adoc", "curl-request.adoc", "request-parts.adoc"); + } + @Test public void responseFieldsSnippet() throws Exception { MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.context) @@ -432,6 +449,15 @@ public class MockMvcRestDocumentationIntegrationTests { "$ curl 'http://localhost:8080/custom/' -i -H 'Accept: application/json'")))); } + @Test + public void multiPart() throws Exception { + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.context) + .apply(documentationConfiguration(this.restDocumentation)).build(); + mockMvc.perform(fileUpload("/upload").file("test", "content".getBytes())) + .andExpect(status().isOk()).andDo(document("upload", + requestParts(partWithName("test").description("Foo")))); + } + private void assertExpectedSnippetFilesExist(File directory, String... snippets) { for (String snippet : snippets) { assertTrue(new File(directory, snippet).isFile()); @@ -473,6 +499,11 @@ public class MockMvcRestDocumentationIntegrationTests { return "{\"companyName\": \"FooBar\",\"employee\": [{\"name\": \"Lorem\",\"age\": \"42\"},{\"name\": \"Ipsum\",\"age\": \"24\"}]}"; } + @RequestMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void upload() { + + } + } } diff --git a/spring-restdocs-mockmvc/src/test/resources/org/springframework/restdocs/templates/request-parts.snippet b/spring-restdocs-mockmvc/src/test/resources/org/springframework/restdocs/templates/request-parts.snippet new file mode 100644 index 00000000..e1db090b --- /dev/null +++ b/spring-restdocs-mockmvc/src/test/resources/org/springframework/restdocs/templates/request-parts.snippet @@ -0,0 +1,9 @@ +|=== +|Request part|Description + +{{#requestParts}} +|`{{name}}` +|{{description}} + +{{/requestParts}} +|=== \ No newline at end of file diff --git a/spring-restdocs-restassured/src/test/java/org/springframework/restdocs/restassured/RestAssuredRestDocumentationIntegrationTests.java b/spring-restdocs-restassured/src/test/java/org/springframework/restdocs/restassured/RestAssuredRestDocumentationIntegrationTests.java index 408d2905..658793ee 100644 --- a/spring-restdocs-restassured/src/test/java/org/springframework/restdocs/restassured/RestAssuredRestDocumentationIntegrationTests.java +++ b/spring-restdocs-restassured/src/test/java/org/springframework/restdocs/restassured/RestAssuredRestDocumentationIntegrationTests.java @@ -64,8 +64,10 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWit import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document; import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.documentationConfiguration; import static org.springframework.restdocs.restassured.operation.preprocess.RestAssuredPreprocessors.modifyUris; @@ -185,6 +187,17 @@ public class RestAssuredRestDocumentationIntegrationTests { "http-response.adoc", "curl-request.adoc", "request-fields.adoc"); } + @Test + public void requestPartsSnippet() throws Exception { + given().port(this.port).filter(documentationConfiguration(this.restDocumentation)) + .filter(document("request-parts", + requestParts(partWithName("a").description("The description")))) + .multiPart("a", "foo").post("/upload").then().statusCode(200); + assertExpectedSnippetFilesExist( + new File("build/generated-snippets/request-parts"), "http-request.adoc", + "http-response.adoc", "curl-request.adoc", "request-parts.adoc"); + } + @Test public void responseFieldsSnippet() throws Exception { given().port(this.port).filter(documentationConfiguration(this.restDocumentation)) @@ -343,6 +356,11 @@ public class RestAssuredRestDocumentationIntegrationTests { return "{\"companyName\": \"FooBar\",\"employee\": [{\"name\": \"Lorem\",\"age\": \"42\"},{\"name\": \"Ipsum\",\"age\": \"24\"}]}"; } + @RequestMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public void upload() { + + } + } }