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