From 5d0428b9d0c3ae0f8345ae4dfcf92e691952d547 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Mon, 13 Jul 2015 17:32:38 +0100 Subject: [PATCH] Add support for documenting multipart requests This commit adds support for documenting multipart requests in both curl and HTTP request snippets. Closes gh-48 --- .../restdocs/curl/CurlDocumentation.java | 28 +++++++++++ .../restdocs/http/HttpDocumentation.java | 48 ++++++++++++++++++- .../util/DocumentableHttpServletRequest.java | 28 +++++++++++ .../restdocs/curl/CurlDocumentationTests.java | 46 ++++++++++++++++++ .../restdocs/http/HttpDocumentationTests.java | 33 +++++++++++++ 5 files changed, 182 insertions(+), 1 deletion(-) diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/curl/CurlDocumentation.java b/spring-restdocs/src/main/java/org/springframework/restdocs/curl/CurlDocumentation.java index a022fdcd..45e4d081 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/curl/CurlDocumentation.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/curl/CurlDocumentation.java @@ -26,6 +26,7 @@ import org.springframework.restdocs.snippet.SnippetWritingResultHandler; import org.springframework.restdocs.util.DocumentableHttpServletRequest; import org.springframework.test.web.servlet.MvcResult; import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; /** * Static factory methods for documenting a RESTful API as if it were being driven using @@ -94,6 +95,11 @@ public abstract class CurlDocumentation { writeOptionToIncludeHeadersInOutput(); writeHttpMethodIfNecessary(request); writeHeaders(request); + + if (request.isMultipartRequest()) { + writeParts(request); + } + writeContent(request); this.writer.println(); @@ -142,6 +148,28 @@ public abstract class CurlDocumentation { } } + private void writeParts(DocumentableHttpServletRequest request) + throws IOException { + for (Entry> entry : request.getMultipartFiles() + .entrySet()) { + for (MultipartFile file : entry.getValue()) { + this.writer.printf(" -F '%s=", file.getName()); + if (!StringUtils.hasText(file.getOriginalFilename())) { + this.writer.append(new String(file.getBytes())); + } + else { + this.writer.printf("@%s", file.getOriginalFilename()); + } + + if (StringUtils.hasText(file.getContentType())) { + this.writer.append(";type=").append(file.getContentType()); + } + this.writer.append("'"); + } + } + + } + private void writeContent(DocumentableHttpServletRequest request) throws IOException { if (request.getContentLength() > 0) { diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/http/HttpDocumentation.java b/spring-restdocs/src/main/java/org/springframework/restdocs/http/HttpDocumentation.java index 0e1b9fc9..5c569f1c 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/http/HttpDocumentation.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/http/HttpDocumentation.java @@ -29,6 +29,7 @@ import org.springframework.restdocs.snippet.SnippetWritingResultHandler; import org.springframework.restdocs.util.DocumentableHttpServletRequest; import org.springframework.test.web.servlet.MvcResult; import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; /** * Static factory methods for documenting a RESTful API's HTTP requests. @@ -82,6 +83,8 @@ public abstract class HttpDocumentation { private static class HttpRequestDocumentationAction implements DocumentationAction { + private static final String MULTIPART_BOUNDARY = "6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm"; + private final DocumentationWriter writer; private final MvcResult result; @@ -97,9 +100,18 @@ public abstract class HttpDocumentation { this.result.getRequest()); this.writer.printf("%s %s HTTP/1.1%n", request.getMethod(), request.getRequestUriWithQueryString()); + for (Entry> header : request.getHeaders().entrySet()) { for (String value : header.getValue()) { - this.writer.printf("%s: %s%n", header.getKey(), value); + if (header.getKey() == HttpHeaders.CONTENT_TYPE + && request.isMultipartRequest()) { + this.writer.printf("%s: %s; boundary=%s%n", header.getKey(), + value, MULTIPART_BOUNDARY); + } + else { + this.writer.printf("%s: %s%n", header.getKey(), value); + } + } } if (requiresFormEncodingContentType(request)) { @@ -115,6 +127,9 @@ public abstract class HttpDocumentation { if (StringUtils.hasText(queryString)) { this.writer.println(queryString); } + if (request.isMultipartRequest()) { + writeParts(request); + } } } @@ -124,6 +139,37 @@ public abstract class HttpDocumentation { && (request.isPostRequest() || request.isPutRequest()) && StringUtils.hasText(request.getParameterMapAsQueryString()); } + + private void writeParts(DocumentableHttpServletRequest request) + throws IOException { + for (Entry> entry : request.getMultipartFiles() + .entrySet()) { + for (MultipartFile file : entry.getValue()) { + writePartBoundary(); + writePart(file); + this.writer.println(); + } + } + writeMultipartEnd(); + } + + private void writePartBoundary() { + this.writer.printf("--%s%n", MULTIPART_BOUNDARY); + } + + private void writePart(MultipartFile part) throws IOException { + this.writer.printf("Content-Disposition: form-data; name=%s%n", + part.getName()); + if (StringUtils.hasText(part.getContentType())) { + this.writer.printf("Content-Type: %s%n", part.getContentType()); + } + this.writer.println(); + this.writer.print(new String(part.getBytes())); + } + + private void writeMultipartEnd() { + this.writer.printf("--%s--%n", MULTIPART_BOUNDARY); + } } private static final class HttpResponseDocumentationAction implements diff --git a/spring-restdocs/src/main/java/org/springframework/restdocs/util/DocumentableHttpServletRequest.java b/spring-restdocs/src/main/java/org/springframework/restdocs/util/DocumentableHttpServletRequest.java index a4779722..4f7e6e06 100644 --- a/spring-restdocs/src/main/java/org/springframework/restdocs/util/DocumentableHttpServletRequest.java +++ b/spring-restdocs/src/main/java/org/springframework/restdocs/util/DocumentableHttpServletRequest.java @@ -28,9 +28,12 @@ import javax.servlet.http.HttpServletRequest; import org.springframework.http.HttpHeaders; import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockMultipartHttpServletRequest; import org.springframework.util.FileCopyUtils; +import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.multipart.MultipartFile; /** * An {@link HttpServletRequest} wrapper that provides a limited set of methods intended @@ -83,6 +86,31 @@ public class DocumentableHttpServletRequest { return RequestMethod.PUT == RequestMethod.valueOf(this.delegate.getMethod()); } + /** + * Whether or not this is a multipart request. + * + * @return {@code true} if it is a multipart request, otherwise {@code false}. + * @see MockMultipartHttpServletRequest + */ + public boolean isMultipartRequest() { + return this.delegate instanceof MockMultipartHttpServletRequest; + } + + /** + * Returns a {@code Map} of the request's multipart files, or {@code null} if this + * request is not a multipart request. + * + * @return a {@code Map} of the multipart files contained in the request, or + * {@code null} + * @see #isMultipartRequest() + */ + public MultiValueMap getMultipartFiles() { + if (!isMultipartRequest()) { + return null; + } + return ((MockMultipartHttpServletRequest) this.delegate).getMultiFileMap(); + } + /** * Returns the request's headers. The headers are ordered based on the ordering of * {@link HttpServletRequest#getHeaderNames()} and diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/curl/CurlDocumentationTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/curl/CurlDocumentationTests.java index dfda7b68..faa25a0f 100644 --- a/spring-restdocs/src/test/java/org/springframework/restdocs/curl/CurlDocumentationTests.java +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/curl/CurlDocumentationTests.java @@ -19,6 +19,7 @@ package org.springframework.restdocs.curl; import static org.springframework.restdocs.curl.CurlDocumentation.documentCurlRequest; import static org.springframework.restdocs.test.SnippetMatchers.codeBlock; import static org.springframework.restdocs.test.StubMvcResult.result; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.fileUpload; 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.request.MockMvcRequestBuilders.put; @@ -27,8 +28,10 @@ import java.io.IOException; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.ExpectedException; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.restdocs.test.ExpectedSnippet; /** @@ -44,6 +47,9 @@ public class CurlDocumentationTests { @Rule public ExpectedSnippet snippet = new ExpectedSnippet(); + @Rule + public ExpectedException thrown = ExpectedException.none(); + @Test public void getRequest() throws IOException { this.snippet.expectCurlRequest("get-request").withContents( @@ -241,4 +247,44 @@ public class CurlDocumentationTests { result(request)); } + @Test + public void multipartPostWithNoOriginalFilename() throws IOException { + String expectedContent = "$ curl 'http://localhost/upload' -i -X POST -H " + + "'Content-Type: multipart/form-data' -F " + + "'metadata={\"description\": \"foo\"}'"; + this.snippet.expectCurlRequest("multipart-post-no-original-filename") + .withContents(codeBlock("bash").content(expectedContent)); + MockMultipartFile multipartFile = new MockMultipartFile("metadata", + "{\"description\": \"foo\"}".getBytes()); + documentCurlRequest("multipart-post-no-original-filename").handle( + result(fileUpload("/upload").file(multipartFile))); + } + + @Test + public void multipartPostWithContentType() throws IOException { + String expectedContent = "$ curl 'http://localhost/upload' -i -X POST -H " + + "'Content-Type: multipart/form-data' -F " + + "'image=@documents/images/example.png;type=image/png'"; + this.snippet.expectCurlRequest("multipart-post-with-content-type").withContents( + codeBlock("bash").content(expectedContent)); + MockMultipartFile multipartFile = new MockMultipartFile("image", + "documents/images/example.png", MediaType.IMAGE_PNG_VALUE, + "bytes".getBytes()); + documentCurlRequest("multipart-post-with-content-type").handle( + result(fileUpload("/upload").file(multipartFile))); + } + + @Test + public void multipartPost() throws IOException { + String expectedContent = "$ curl 'http://localhost/upload' -i -X POST -H " + + "'Content-Type: multipart/form-data' -F " + + "'image=@documents/images/example.png'"; + this.snippet.expectCurlRequest("multipart-post").withContents( + codeBlock("bash").content(expectedContent)); + MockMultipartFile multipartFile = new MockMultipartFile("image", + "documents/images/example.png", null, "bytes".getBytes()); + documentCurlRequest("multipart-post").handle( + result(fileUpload("/upload").file(multipartFile))); + } + } diff --git a/spring-restdocs/src/test/java/org/springframework/restdocs/http/HttpDocumentationTests.java b/spring-restdocs/src/test/java/org/springframework/restdocs/http/HttpDocumentationTests.java index a1e30562..73a639e1 100644 --- a/spring-restdocs/src/test/java/org/springframework/restdocs/http/HttpDocumentationTests.java +++ b/spring-restdocs/src/test/java/org/springframework/restdocs/http/HttpDocumentationTests.java @@ -23,6 +23,7 @@ import static org.springframework.restdocs.http.HttpDocumentation.documentHttpRe import static org.springframework.restdocs.test.SnippetMatchers.httpRequest; import static org.springframework.restdocs.test.SnippetMatchers.httpResponse; import static org.springframework.restdocs.test.StubMvcResult.result; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.fileUpload; 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.request.MockMvcRequestBuilders.put; @@ -36,6 +37,7 @@ import org.junit.Rule; import org.junit.Test; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.restdocs.test.ExpectedSnippet; /** @@ -156,4 +158,35 @@ public class HttpDocumentationTests { documentHttpResponse("response-with-content").handle(result(response)); } + @Test + public void multipartPost() throws IOException { + String boundary = "6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm"; + String expectedContent = String.format("--%s%nContent-Disposition: form-data; " + + "name=image%n%n<< data >>%n--%s--", boundary, boundary); + this.snippet.expectHttpRequest("multipart-post").withContents( + httpRequest(POST, "/upload").header("Content-Type", + "multipart/form-data; boundary=" + boundary).content( + expectedContent)); + MockMultipartFile multipartFile = new MockMultipartFile("image", + "documents/images/example.png", null, "<< data >>".getBytes()); + documentHttpRequest("multipart-post").handle( + result(fileUpload("/upload").file(multipartFile))); + } + + @Test + public void multipartPostWithContentType() throws IOException { + String boundary = "6o2knFse3p53ty9dmcQvWAIx1zInP11uCfbm"; + String expectedContent = String.format("--%s%nContent-Disposition: form-data; " + + "name=image%nContent-Type: image/png%n%n<< data >>%n--%s--", boundary, + boundary); + this.snippet.expectHttpRequest("multipart-post-with-content-type").withContents( + httpRequest(POST, "/upload").header("Content-Type", + "multipart/form-data; boundary=" + boundary).content( + expectedContent)); + MockMultipartFile multipartFile = new MockMultipartFile("image", + "documents/images/example.png", MediaType.IMAGE_PNG_VALUE, + "<< data >>".getBytes()); + documentHttpRequest("multipart-post-with-content-type").handle( + result(fileUpload("/upload").file(multipartFile))); + } }