diff --git a/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java index 40d8f5aa7e..5ccd265edc 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java @@ -55,15 +55,29 @@ import org.springframework.util.StringUtils; * write (but not read) the {@code "multipart/form-data"} media type as * {@link MultiValueMap MultiValueMap<String, Object>}. * + *

Multipart Data

+ * + *

By default, {@code "multipart/form-data"} is used as the content type when + * {@linkplain #write writing} multipart data. As of Spring Framework 5.2 it is + * also possible to write multipart data using other multipart subtypes such as + * {@code "multipart/mixed"} and {@code "multipart/related"}, as long as the + * multipart subtype is registered as a {@linkplain #getSupportedMediaTypes + * supported media type} and the desired multipart subtype is specified + * as the content type when {@linkplain #write writing} the multipart data. + * *

When writing multipart data, this converter uses other * {@link HttpMessageConverter HttpMessageConverters} to write the respective - * MIME parts. By default, basic converters are registered (e.g., for {@code String} - * and {@code Resource}). These can be overridden through the - * {@link #setPartConverters partConverters} property. + * MIME parts. By default, basic converters are registered for byte array, + * {@code String}, and {@code Resource}. These can be overridden via + * {@link #setPartConverters} or augmented via {@link #addPartConverter}. + * + *

Examples

+ * + *

The following snippet shows how to submit an HTML form using the + * {@code "multipart/form-data"} content type. * - *

For example, the following snippet shows how to submit an HTML form: *

- * RestTemplate template = new RestTemplate();
+ * RestTemplate restTemplate = new RestTemplate();
  * // AllEncompassingFormHttpMessageConverter is configured by default
  *
  * MultiValueMap<String, Object> form = new LinkedMultiValueMap<>();
@@ -71,17 +85,46 @@ import org.springframework.util.StringUtils;
  * form.add("field 2", "value 2");
  * form.add("field 2", "value 3");
  * form.add("field 3", 4);  // non-String form values supported as of 5.1.4
- * template.postForLocation("https://example.com/myForm", form);
+ * restTemplate.postForLocation("https://example.com/myForm", form);
  * 
* - *

The following snippet shows how to do a file upload: + *

The following snippet shows how to do a file upload using the + * {@code "multipart/form-data"} content type. + * *

  * MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
  * parts.add("field 1", "value 1");
  * parts.add("file", new ClassPathResource("myFile.jpg"));
- * template.postForLocation("https://example.com/myFileUpload", parts);
+ * restTemplate.postForLocation("https://example.com/myFileUpload", parts);
  * 
* + *

The following snippet shows how to do a file upload using the + * {@code "multipart/mixed"} content type. + * + *

+ * MediaType multipartMixed = new MediaType("multipart", "mixed");
+ *
+ * restTemplate.getMessageConverters().stream()
+ *     .filter(FormHttpMessageConverter.class::isInstance)
+ *     .map(FormHttpMessageConverter.class::cast)
+ *     .findFirst()
+ *     .orElseThrow(() -> new IllegalStateException("Failed to find FormHttpMessageConverter"))
+ *     .addSupportedMediaTypes(multipartMixed);
+ *
+ * MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
+ * parts.add("field 1", "value 1");
+ * parts.add("file", new ClassPathResource("myFile.jpg"));
+ *
+ * HttpHeaders requestHeaders = new HttpHeaders();
+ * requestHeaders.setContentType(multipartMixed);
+ * HttpEntity<MultiValueMap<String, Object>> requestEntity =
+ *     new HttpEntity<>(parts, requestHeaders);
+ *
+ * restTemplate.postForLocation("https://example.com/myFileUpload", requestEntity);
+ * 
+ * + *

Miscellaneous

+ * *

Some methods in this class were inspired by * {@code org.apache.commons.httpclient.methods.multipart.MultipartRequestEntity}. * @@ -95,6 +138,8 @@ import org.springframework.util.StringUtils; */ public class FormHttpMessageConverter implements HttpMessageConverter> { + private static final MediaType MULTIPART_ALL = new MediaType("multipart", "*"); + /** * The default charset used by the converter. */ @@ -154,6 +199,12 @@ public class FormHttpMessageConverter implements HttpMessageConverter getSupportedMediaTypes() { return Collections.unmodifiableList(this.supportedMediaTypes); @@ -236,8 +287,11 @@ public class FormHttpMessageConverter implements HttpMessageConverter) map, outputMessage); + writeMultipart((MultiValueMap) map, contentType, outputMessage); } else { writeForm((MultiValueMap) map, contentType, outputMessage); @@ -301,7 +355,7 @@ public class FormHttpMessageConverter implements HttpMessageConverter map, @Nullable MediaType contentType) { if (contentType != null) { - return MediaType.MULTIPART_FORM_DATA.includes(contentType); + return MULTIPART_ALL.includes(contentType); } for (List values : map.values()) { for (Object value : values) { @@ -368,9 +422,16 @@ public class FormHttpMessageConverter implements HttpMessageConverter parts, HttpOutputMessage outputMessage) + private void writeMultipart(final MultiValueMap parts, MediaType contentType, HttpOutputMessage outputMessage) throws IOException { + // If the supplied content type is null, fall back to multipart/form-data. + // Otherwise rely on the fact that isMultipart() already verified the + // supplied content type is multipart/*. + if (contentType == null) { + contentType = MediaType.MULTIPART_FORM_DATA; + } + final byte[] boundary = generateMultipartBoundary(); Map parameters = new LinkedHashMap<>(2); if (!isFilenameCharsetSet()) { @@ -378,9 +439,9 @@ public class FormHttpMessageConverter implements HttpMessageConverter")); HttpHeaders entityHeaders = new HttpHeaders(); - entityHeaders.setContentType(MediaType.TEXT_XML); + entityHeaders.setContentType(TEXT_XML); HttpEntity entity = new HttpEntity<>(xml, entityHeaders); parts.add("xml", entity); @@ -226,7 +243,7 @@ public class FormHttpMessageConverterTests { parts.add("part1", myBean); HttpHeaders entityHeaders = new HttpHeaders(); - entityHeaders.setContentType(MediaType.TEXT_XML); + entityHeaders.setContentType(TEXT_XML); HttpEntity entity = new HttpEntity<>(myBean, entityHeaders); parts.add("part2", entity); @@ -261,6 +278,32 @@ public class FormHttpMessageConverterTests { .endsWith(">foo"); } + private void assertCanRead(MediaType mediaType) { + assertCanRead(MultiValueMap.class, mediaType); + } + + private void assertCanRead(Class clazz, MediaType mediaType) { + assertThat(this.converter.canRead(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isTrue(); + } + + private void assertCannotRead(MediaType mediaType) { + assertCannotRead(MultiValueMap.class, mediaType); + } + + private void assertCannotRead(Class clazz, MediaType mediaType) { + assertThat(this.converter.canRead(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isFalse(); + } + + private void assertCanWrite(MediaType mediaType) { + Class clazz = MultiValueMap.class; + assertThat(this.converter.canWrite(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isTrue(); + } + + private void assertCannotWrite(MediaType mediaType) { + Class clazz = MultiValueMap.class; + assertThat(this.converter.canWrite(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isFalse(); + } + private static class MockHttpOutputMessageRequestContext implements RequestContext { diff --git a/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTestCase.java b/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTestCase.java index 635bdce6d2..8039ccd31c 100644 --- a/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTestCase.java +++ b/spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTestCase.java @@ -35,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.http.HttpHeaders.CONTENT_LENGTH; import static org.springframework.http.HttpHeaders.CONTENT_TYPE; import static org.springframework.http.HttpHeaders.LOCATION; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA; /** * @author Brian Clozel @@ -42,6 +43,9 @@ import static org.springframework.http.HttpHeaders.LOCATION; */ public class AbstractMockWebServerTestCase { + protected static final MediaType MULTIPART_MIXED = new MediaType("multipart", "mixed"); + protected static final MediaType MULTIPART_RELATED = new MediaType("multipart", "related"); + protected static final MediaType textContentType = new MediaType("text", "plain", Collections.singletonMap("charset", "UTF-8")); @@ -120,10 +124,31 @@ public class AbstractMockWebServerTestCase { .setResponseCode(201); } - private MockResponse multipartRequest(RecordedRequest request) { - MediaType mediaType = MediaType.parseMediaType(request.getHeader("Content-Type")); - assertThat(mediaType.isCompatibleWith(MediaType.MULTIPART_FORM_DATA)).isTrue(); + private MockResponse multipartFormDataRequest(RecordedRequest request) { + MediaType mediaType = MediaType.parseMediaType(request.getHeader(CONTENT_TYPE)); + assertThat(mediaType.isCompatibleWith(MULTIPART_FORM_DATA)).as(MULTIPART_FORM_DATA.toString()).isTrue(); + assertMultipart(request, mediaType); + return new MockResponse().setResponseCode(200); + } + + private MockResponse multipartMixedRequest(RecordedRequest request) { + MediaType mediaType = MediaType.parseMediaType(request.getHeader(CONTENT_TYPE)); + assertThat(mediaType.isCompatibleWith(MULTIPART_MIXED)).as(MULTIPART_MIXED.toString()).isTrue(); + assertMultipart(request, mediaType); + return new MockResponse().setResponseCode(200); + } + + private MockResponse multipartRelatedRequest(RecordedRequest request) { + MediaType mediaType = MediaType.parseMediaType(request.getHeader(CONTENT_TYPE)); + assertThat(mediaType.isCompatibleWith(MULTIPART_RELATED)).as(MULTIPART_RELATED.toString()).isTrue(); + assertMultipart(request, mediaType); + return new MockResponse().setResponseCode(200); + } + + private void assertMultipart(RecordedRequest request, MediaType mediaType) { + assertThat(mediaType.isCompatibleWith(new MediaType("multipart", "*"))).as("multipart/*").isTrue(); String boundary = mediaType.getParameter("boundary"); + assertThat(boundary).as("boundary").isNotBlank(); Buffer body = request.getBody(); try { assertPart(body, "form-data", boundary, "name 1", "text/plain", "value 1"); @@ -132,9 +157,8 @@ public class AbstractMockWebServerTestCase { assertFilePart(body, "form-data", boundary, "logo", "logo.jpg", "image/jpeg"); } catch (EOFException ex) { - throw new IllegalStateException(ex); + throw new AssertionError(ex); } - return new MockResponse().setResponseCode(200); } private void assertPart(Buffer buffer, String disposition, String boundary, String name, @@ -245,8 +269,14 @@ public class AbstractMockWebServerTestCase { else if (request.getPath().contains("/uri/")) { return new MockResponse().setBody(request.getPath()).setHeader(CONTENT_TYPE, "text/plain"); } - else if (request.getPath().equals("/multipart")) { - return multipartRequest(request); + else if (request.getPath().equals("/multipartFormData")) { + return multipartFormDataRequest(request); + } + else if (request.getPath().equals("/multipartMixed")) { + return multipartMixedRequest(request); + } + else if (request.getPath().equals("/multipartRelated")) { + return multipartRelatedRequest(request); } else if (request.getPath().equals("/form")) { return formRequest(request); diff --git a/spring-web/src/test/java/org/springframework/web/client/AsyncRestTemplateIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/client/AsyncRestTemplateIntegrationTests.java index b13e7be015..e76f91b32c 100644 --- a/spring-web/src/test/java/org/springframework/web/client/AsyncRestTemplateIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/AsyncRestTemplateIntegrationTests.java @@ -49,6 +49,18 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.fail; /** + * Integration tests for {@link AsyncRestTemplate}. + * + *

Logging configuration for {@code MockWebServer}

+ * + *

In order for our log4j2 configuration to be used in an IDE, you must + * set the following system property before running any tests — for + * example, in Run Configurations in Eclipse. + * + *

+ * -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager
+ * 
+ * * @author Arjen Poutsma * @author Sebastien Deleuze */ @@ -578,7 +590,7 @@ public class AsyncRestTemplateIntegrationTests extends AbstractMockWebServerTest } @Test - public void multipart() throws Exception { + public void multipartFormData() throws Exception { MultiValueMap parts = new LinkedMultiValueMap<>(); parts.add("name 1", "value 1"); parts.add("name 2", "value 2+1"); @@ -587,7 +599,7 @@ public class AsyncRestTemplateIntegrationTests extends AbstractMockWebServerTest parts.add("logo", logo); HttpEntity> requestBody = new HttpEntity<>(parts); - Future future = template.postForLocation(baseUrl + "/multipart", requestBody); + Future future = template.postForLocation(baseUrl + "/multipartFormData", requestBody); future.get(); } diff --git a/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java index 9221023244..db84403712 100644 --- a/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java +++ b/spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java @@ -54,6 +54,7 @@ import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.OkHttp3ClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.http.converter.json.MappingJacksonValue; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -66,6 +67,16 @@ import static org.springframework.http.HttpMethod.POST; /** * Integration tests for {@link RestTemplate}. * + *

Logging configuration for {@code MockWebServer}

+ * + *

In order for our log4j2 configuration to be used in an IDE, you must + * set the following system property before running any tests — for + * example, in Run Configurations in Eclipse. + * + *

+ * -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager
+ * 
+ * * @author Arjen Poutsma * @author Brian Clozel * @author Sam Brannen @@ -258,19 +269,55 @@ public class RestTemplateIntegrationTests extends AbstractMockWebServerTestCase } @Test - public void multipart() throws UnsupportedEncodingException { + public void multipartFormData() { + template.postForLocation(baseUrl + "/multipartFormData", createMultipartParts()); + } + + @Test + public void multipartMixed() { + addSupportedMediaTypeToFormHttpMessageConverter(MULTIPART_MIXED); + + HttpHeaders requestHeaders = new HttpHeaders(); + requestHeaders.setContentType(MULTIPART_MIXED); + HttpEntity> requestEntity = new HttpEntity<>(createMultipartParts(), + requestHeaders); + + template.postForLocation(baseUrl + "/multipartMixed", requestEntity); + } + + @Test + public void multipartRelated() { + addSupportedMediaTypeToFormHttpMessageConverter(MULTIPART_RELATED); + + HttpHeaders requestHeaders = new HttpHeaders(); + requestHeaders.setContentType(MULTIPART_RELATED); + HttpEntity> requestEntity = new HttpEntity<>(createMultipartParts(), + requestHeaders); + + template.postForLocation(baseUrl + "/multipartRelated", requestEntity); + } + + private MultiValueMap createMultipartParts() { MultiValueMap parts = new LinkedMultiValueMap<>(); parts.add("name 1", "value 1"); parts.add("name 2", "value 2+1"); parts.add("name 2", "value 2+2"); Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg"); parts.add("logo", logo); + return parts; + } - template.postForLocation(baseUrl + "/multipart", parts); + private void addSupportedMediaTypeToFormHttpMessageConverter(MediaType mediaType) { + this.template.getMessageConverters().stream() + .filter(FormHttpMessageConverter.class::isInstance) + .map(FormHttpMessageConverter.class::cast) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Failed to find FormHttpMessageConverter")) + .addSupportedMediaTypes(mediaType); } @Test - public void form() throws UnsupportedEncodingException { + public void form() { MultiValueMap form = new LinkedMultiValueMap<>(); form.add("name 1", "value 1"); form.add("name 2", "value 2+1"); diff --git a/src/docs/asciidoc/integration.adoc b/src/docs/asciidoc/integration.adoc index 0fe2f2e86c..ee7bde48ce 100644 --- a/src/docs/asciidoc/integration.adoc +++ b/src/docs/asciidoc/integration.adoc @@ -1142,7 +1142,10 @@ request and response. By default, this converter supports all text media types | An `HttpMessageConverter` implementation that can read and write form data from the HTTP request and response. By default, this converter reads and writes the `application/x-www-form-urlencoded` media type. Form data is read from and written into a -`MultiValueMap`. +`MultiValueMap`. The converter can also write (but not read) multipart +data read from a `MultiValueMap`. By default, `multipart/form-data` is +supported. As of Spring Framework 5.2, additional multipart subtypes can be supported for +writing form data. Consult the javadoc for `FormHttpMessageConverter` for further details. | `ByteArrayHttpMessageConverter` | An `HttpMessageConverter` implementation that can read and write byte arrays from the