Skip to content

Commit 5008423

Browse files
committed
Support multipart/* MediaTypes in RestTemplate
Prior to this commit, RestTemplate posted multipart with Content-Type "multipart/form-data" even if the FormHttpMessageConverter configured in the RestTemplate had been configured to support additional multipart subtypes. This made it impossible to POST form data using a content type such as "multipart/mixed" or "multipart/related". This commit addresses this issue by updating FormHttpMessageConverter to support custom multipart subtypes for writing form data. For example, the following use case is now supported. 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); Closes gh-23159
1 parent 7bc727c commit 5008423

File tree

6 files changed

+242
-46
lines changed

6 files changed

+242
-46
lines changed

spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,33 +55,76 @@
5555
* write (but not read) the {@code "multipart/form-data"} media type as
5656
* {@link MultiValueMap MultiValueMap&lt;String, Object&gt;}.
5757
*
58+
* <h3>Multipart Data</h3>
59+
*
60+
* <p>By default, {@code "multipart/form-data"} is used as the content type when
61+
* {@linkplain #write writing} multipart data. As of Spring Framework 5.2 it is
62+
* also possible to write multipart data using other multipart subtypes such as
63+
* {@code "multipart/mixed"} and {@code "multipart/related"}, as long as the
64+
* multipart subtype is registered as a {@linkplain #getSupportedMediaTypes
65+
* supported media type} <em>and</em> the desired multipart subtype is specified
66+
* as the content type when {@linkplain #write writing} the multipart data.
67+
*
5868
* <p>When writing multipart data, this converter uses other
5969
* {@link HttpMessageConverter HttpMessageConverters} to write the respective
60-
* MIME parts. By default, basic converters are registered (e.g., for {@code String}
61-
* and {@code Resource}). These can be overridden through the
62-
* {@link #setPartConverters partConverters} property.
70+
* MIME parts. By default, basic converters are registered for byte array,
71+
* {@code String}, and {@code Resource}. These can be overridden via
72+
* {@link #setPartConverters} or augmented via {@link #addPartConverter}.
73+
*
74+
* <h3>Examples</h3>
75+
*
76+
* <p>The following snippet shows how to submit an HTML form using the
77+
* {@code "multipart/form-data"} content type.
6378
*
64-
* <p>For example, the following snippet shows how to submit an HTML form:
6579
* <pre class="code">
66-
* RestTemplate template = new RestTemplate();
80+
* RestTemplate restTemplate = new RestTemplate();
6781
* // AllEncompassingFormHttpMessageConverter is configured by default
6882
*
6983
* MultiValueMap&lt;String, Object&gt; form = new LinkedMultiValueMap&lt;&gt;();
7084
* form.add("field 1", "value 1");
7185
* form.add("field 2", "value 2");
7286
* form.add("field 2", "value 3");
7387
* form.add("field 3", 4); // non-String form values supported as of 5.1.4
74-
* template.postForLocation("https://example.com/myForm", form);
88+
* restTemplate.postForLocation("https://example.com/myForm", form);
7589
* </pre>
7690
*
77-
* <p>The following snippet shows how to do a file upload:
91+
* <p>The following snippet shows how to do a file upload using the
92+
* {@code "multipart/form-data"} content type.
93+
*
7894
* <pre class="code">
7995
* MultiValueMap&lt;String, Object&gt; parts = new LinkedMultiValueMap&lt;&gt;();
8096
* parts.add("field 1", "value 1");
8197
* parts.add("file", new ClassPathResource("myFile.jpg"));
82-
* template.postForLocation("https://example.com/myFileUpload", parts);
98+
* restTemplate.postForLocation("https://example.com/myFileUpload", parts);
8399
* </pre>
84100
*
101+
* <p>The following snippet shows how to do a file upload using the
102+
* {@code "multipart/mixed"} content type.
103+
*
104+
* <pre class="code">
105+
* MediaType multipartMixed = new MediaType("multipart", "mixed");
106+
*
107+
* restTemplate.getMessageConverters().stream()
108+
* .filter(FormHttpMessageConverter.class::isInstance)
109+
* .map(FormHttpMessageConverter.class::cast)
110+
* .findFirst()
111+
* .orElseThrow(() -&gt; new IllegalStateException("Failed to find FormHttpMessageConverter"))
112+
* .addSupportedMediaTypes(multipartMixed);
113+
*
114+
* MultiValueMap&lt;String, Object&gt; parts = new LinkedMultiValueMap&lt;&gt;();
115+
* parts.add("field 1", "value 1");
116+
* parts.add("file", new ClassPathResource("myFile.jpg"));
117+
*
118+
* HttpHeaders requestHeaders = new HttpHeaders();
119+
* requestHeaders.setContentType(multipartMixed);
120+
* HttpEntity&lt;MultiValueMap&lt;String, Object&gt;&gt; requestEntity =
121+
* new HttpEntity&lt;&gt;(parts, requestHeaders);
122+
*
123+
* restTemplate.postForLocation("https://example.com/myFileUpload", requestEntity);
124+
* </pre>
125+
*
126+
* <h3>Miscellaneous</h3>
127+
*
85128
* <p>Some methods in this class were inspired by
86129
* {@code org.apache.commons.httpclient.methods.multipart.MultipartRequestEntity}.
87130
*
@@ -95,6 +138,8 @@
95138
*/
96139
public class FormHttpMessageConverter implements HttpMessageConverter<MultiValueMap<String, ?>> {
97140

141+
private static final MediaType MULTIPART_ALL = new MediaType("multipart", "*");
142+
98143
/**
99144
* The default charset used by the converter.
100145
*/
@@ -154,6 +199,12 @@ public void addSupportedMediaTypes(MediaType... supportedMediaTypes) {
154199
}
155200
}
156201

202+
/**
203+
* {@inheritDoc}
204+
*
205+
* @see #setSupportedMediaTypes(List)
206+
* @see #addSupportedMediaTypes(MediaType...)
207+
*/
157208
@Override
158209
public List<MediaType> getSupportedMediaTypes() {
159210
return Collections.unmodifiableList(this.supportedMediaTypes);
@@ -236,8 +287,11 @@ public boolean canRead(Class<?> clazz, @Nullable MediaType mediaType) {
236287
return true;
237288
}
238289
for (MediaType supportedMediaType : getSupportedMediaTypes()) {
239-
// We can't read multipart....
240-
if (!supportedMediaType.equals(MediaType.MULTIPART_FORM_DATA) && supportedMediaType.includes(mediaType)) {
290+
if (MULTIPART_ALL.includes(supportedMediaType)) {
291+
// We can't read multipart, so skip this supported media type.
292+
continue;
293+
}
294+
if (supportedMediaType.includes(mediaType)) {
241295
return true;
242296
}
243297
}
@@ -291,7 +345,7 @@ public void write(MultiValueMap<String, ?> map, @Nullable MediaType contentType,
291345
throws IOException, HttpMessageNotWritableException {
292346

293347
if (isMultipart(map, contentType)) {
294-
writeMultipart((MultiValueMap<String, Object>) map, outputMessage);
348+
writeMultipart((MultiValueMap<String, Object>) map, contentType, outputMessage);
295349
}
296350
else {
297351
writeForm((MultiValueMap<String, Object>) map, contentType, outputMessage);
@@ -301,7 +355,7 @@ public void write(MultiValueMap<String, ?> map, @Nullable MediaType contentType,
301355

302356
private boolean isMultipart(MultiValueMap<String, ?> map, @Nullable MediaType contentType) {
303357
if (contentType != null) {
304-
return MediaType.MULTIPART_FORM_DATA.includes(contentType);
358+
return MULTIPART_ALL.includes(contentType);
305359
}
306360
for (List<?> values : map.values()) {
307361
for (Object value : values) {
@@ -368,19 +422,26 @@ protected String serializeForm(MultiValueMap<String, Object> formData, Charset c
368422
return builder.toString();
369423
}
370424

371-
private void writeMultipart(final MultiValueMap<String, Object> parts, HttpOutputMessage outputMessage)
425+
private void writeMultipart(final MultiValueMap<String, Object> parts, MediaType contentType, HttpOutputMessage outputMessage)
372426
throws IOException {
373427

428+
// If the supplied content type is null, fall back to multipart/form-data.
429+
// Otherwise rely on the fact that isMultipart() already verified the
430+
// supplied content type is multipart/*.
431+
if (contentType == null) {
432+
contentType = MediaType.MULTIPART_FORM_DATA;
433+
}
434+
374435
final byte[] boundary = generateMultipartBoundary();
375436
Map<String, String> parameters = new LinkedHashMap<>(2);
376437
if (!isFilenameCharsetSet()) {
377438
parameters.put("charset", this.charset.name());
378439
}
379440
parameters.put("boundary", new String(boundary, StandardCharsets.US_ASCII));
380441

381-
MediaType contentType = new MediaType(MediaType.MULTIPART_FORM_DATA, parameters);
382-
HttpHeaders headers = outputMessage.getHeaders();
383-
headers.setContentType(contentType);
442+
// Add parameters to output content type
443+
contentType = new MediaType(contentType, parameters);
444+
outputMessage.getHeaders().setContentType(contentType);
384445

385446
if (outputMessage instanceof StreamingHttpOutputMessage) {
386447
StreamingHttpOutputMessage streamingOutputMessage = (StreamingHttpOutputMessage) outputMessage;

spring-web/src/test/java/org/springframework/http/converter/FormHttpMessageConverterTests.java

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
import static org.assertj.core.api.Assertions.assertThat;
4848
import static org.mockito.Mockito.never;
4949
import static org.mockito.Mockito.verify;
50+
import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
51+
import static org.springframework.http.MediaType.MULTIPART_FORM_DATA;
52+
import static org.springframework.http.MediaType.TEXT_XML;
5053

5154
/**
5255
* Unit tests for {@link FormHttpMessageConverter} and
@@ -58,24 +61,46 @@
5861
*/
5962
public class FormHttpMessageConverterTests {
6063

61-
protected static final MediaType MULTIPART_MIXED = new MediaType("multipart", "mixed");
62-
protected static final MediaType MULTIPART_RELATED = new MediaType("multipart", "related");
64+
private static final MediaType MULTIPART_ALL = new MediaType("multipart", "*");
65+
private static final MediaType MULTIPART_MIXED = new MediaType("multipart", "mixed");
66+
private static final MediaType MULTIPART_RELATED = new MediaType("multipart", "related");
6367

6468
private final FormHttpMessageConverter converter = new AllEncompassingFormHttpMessageConverter();
6569

6670

6771
@Test
6872
public void canRead() {
69-
assertThat(this.converter.canRead(MultiValueMap.class, MediaType.APPLICATION_FORM_URLENCODED)).isTrue();
70-
assertThat(this.converter.canRead(MultiValueMap.class, MediaType.MULTIPART_FORM_DATA)).isFalse();
73+
assertCanRead(MultiValueMap.class, null);
74+
assertCanRead(APPLICATION_FORM_URLENCODED);
75+
76+
assertCannotRead(String.class, null);
77+
assertCannotRead(String.class, APPLICATION_FORM_URLENCODED);
78+
}
79+
80+
@Test
81+
public void cannotReadMultipart() {
82+
// Without custom multipart types supported
83+
assertCannotRead(MULTIPART_ALL);
84+
assertCannotRead(MULTIPART_FORM_DATA);
85+
assertCannotRead(MULTIPART_MIXED);
86+
assertCannotRead(MULTIPART_RELATED);
87+
88+
this.converter.addSupportedMediaTypes(MULTIPART_MIXED, MULTIPART_RELATED);
89+
90+
// With custom multipart types supported
91+
assertCannotRead(MULTIPART_ALL);
92+
assertCannotRead(MULTIPART_FORM_DATA);
93+
assertCannotRead(MULTIPART_MIXED);
94+
assertCannotRead(MULTIPART_RELATED);
7195
}
7296

7397
@Test
7498
public void canWrite() {
75-
assertCanWrite(MediaType.APPLICATION_FORM_URLENCODED);
76-
assertCanWrite(MediaType.MULTIPART_FORM_DATA);
99+
assertCanWrite(APPLICATION_FORM_URLENCODED);
100+
assertCanWrite(MULTIPART_FORM_DATA);
77101
assertCanWrite(new MediaType("multipart", "form-data", StandardCharsets.UTF_8));
78102
assertCanWrite(MediaType.ALL);
103+
assertCanWrite(null);
79104
}
80105

81106
@Test
@@ -103,14 +128,6 @@ public void addSupportedMediaTypes() {
103128
assertCanWrite(MULTIPART_RELATED);
104129
}
105130

106-
private void assertCanWrite(MediaType mediaType) {
107-
assertThat(this.converter.canWrite(MultiValueMap.class, mediaType)).isTrue();
108-
}
109-
110-
private void assertCannotWrite(MediaType mediaType) {
111-
assertThat(this.converter.canWrite(MultiValueMap.class, mediaType)).isFalse();
112-
}
113-
114131
@Test
115132
public void readForm() throws Exception {
116133
String body = "name+1=value+1&name+2=value+2%2B1&name+2=value+2%2B2&name+3";
@@ -136,7 +153,7 @@ public void writeForm() throws IOException {
136153
body.add("name 2", "value 2+2");
137154
body.add("name 3", null);
138155
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
139-
this.converter.write(body, MediaType.APPLICATION_FORM_URLENCODED, outputMessage);
156+
this.converter.write(body, APPLICATION_FORM_URLENCODED, outputMessage);
140157

141158
assertThat(outputMessage.getBodyAsString(StandardCharsets.UTF_8)).as("Invalid result").isEqualTo("name+1=value+1&name+2=value+2%2B1&name+2=value+2%2B2&name+3");
142159
assertThat(outputMessage.getHeaders().getContentType().toString()).as("Invalid content-type").isEqualTo("application/x-www-form-urlencoded;charset=UTF-8");
@@ -165,7 +182,7 @@ public String getFilename() {
165182

166183
Source xml = new StreamSource(new StringReader("<root><child/></root>"));
167184
HttpHeaders entityHeaders = new HttpHeaders();
168-
entityHeaders.setContentType(MediaType.TEXT_XML);
185+
entityHeaders.setContentType(TEXT_XML);
169186
HttpEntity<Source> entity = new HttpEntity<>(xml, entityHeaders);
170187
parts.add("xml", entity);
171188

@@ -226,7 +243,7 @@ public void writeMultipartOrder() throws Exception {
226243
parts.add("part1", myBean);
227244

228245
HttpHeaders entityHeaders = new HttpHeaders();
229-
entityHeaders.setContentType(MediaType.TEXT_XML);
246+
entityHeaders.setContentType(TEXT_XML);
230247
HttpEntity<MyBean> entity = new HttpEntity<>(myBean, entityHeaders);
231248
parts.add("part2", entity);
232249

@@ -261,6 +278,32 @@ public void writeMultipartOrder() throws Exception {
261278
.endsWith("><string>foo</string></MyBean>");
262279
}
263280

281+
private void assertCanRead(MediaType mediaType) {
282+
assertCanRead(MultiValueMap.class, mediaType);
283+
}
284+
285+
private void assertCanRead(Class<?> clazz, MediaType mediaType) {
286+
assertThat(this.converter.canRead(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isTrue();
287+
}
288+
289+
private void assertCannotRead(MediaType mediaType) {
290+
assertCannotRead(MultiValueMap.class, mediaType);
291+
}
292+
293+
private void assertCannotRead(Class<?> clazz, MediaType mediaType) {
294+
assertThat(this.converter.canRead(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isFalse();
295+
}
296+
297+
private void assertCanWrite(MediaType mediaType) {
298+
Class<?> clazz = MultiValueMap.class;
299+
assertThat(this.converter.canWrite(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isTrue();
300+
}
301+
302+
private void assertCannotWrite(MediaType mediaType) {
303+
Class<?> clazz = MultiValueMap.class;
304+
assertThat(this.converter.canWrite(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isFalse();
305+
}
306+
264307

265308
private static class MockHttpOutputMessageRequestContext implements RequestContext {
266309

spring-web/src/test/java/org/springframework/web/client/AbstractMockWebServerTestCase.java

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,17 @@
3535
import static org.springframework.http.HttpHeaders.CONTENT_LENGTH;
3636
import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
3737
import static org.springframework.http.HttpHeaders.LOCATION;
38+
import static org.springframework.http.MediaType.MULTIPART_FORM_DATA;
3839

3940
/**
4041
* @author Brian Clozel
4142
* @author Sam Brannen
4243
*/
4344
public class AbstractMockWebServerTestCase {
4445

46+
protected static final MediaType MULTIPART_MIXED = new MediaType("multipart", "mixed");
47+
protected static final MediaType MULTIPART_RELATED = new MediaType("multipart", "related");
48+
4549
protected static final MediaType textContentType =
4650
new MediaType("text", "plain", Collections.singletonMap("charset", "UTF-8"));
4751

@@ -120,10 +124,31 @@ private MockResponse jsonPostRequest(RecordedRequest request, String location, S
120124
.setResponseCode(201);
121125
}
122126

123-
private MockResponse multipartRequest(RecordedRequest request) {
124-
MediaType mediaType = MediaType.parseMediaType(request.getHeader("Content-Type"));
125-
assertThat(mediaType.isCompatibleWith(MediaType.MULTIPART_FORM_DATA)).isTrue();
127+
private MockResponse multipartFormDataRequest(RecordedRequest request) {
128+
MediaType mediaType = MediaType.parseMediaType(request.getHeader(CONTENT_TYPE));
129+
assertThat(mediaType.isCompatibleWith(MULTIPART_FORM_DATA)).as(MULTIPART_FORM_DATA.toString()).isTrue();
130+
assertMultipart(request, mediaType);
131+
return new MockResponse().setResponseCode(200);
132+
}
133+
134+
private MockResponse multipartMixedRequest(RecordedRequest request) {
135+
MediaType mediaType = MediaType.parseMediaType(request.getHeader(CONTENT_TYPE));
136+
assertThat(mediaType.isCompatibleWith(MULTIPART_MIXED)).as(MULTIPART_MIXED.toString()).isTrue();
137+
assertMultipart(request, mediaType);
138+
return new MockResponse().setResponseCode(200);
139+
}
140+
141+
private MockResponse multipartRelatedRequest(RecordedRequest request) {
142+
MediaType mediaType = MediaType.parseMediaType(request.getHeader(CONTENT_TYPE));
143+
assertThat(mediaType.isCompatibleWith(MULTIPART_RELATED)).as(MULTIPART_RELATED.toString()).isTrue();
144+
assertMultipart(request, mediaType);
145+
return new MockResponse().setResponseCode(200);
146+
}
147+
148+
private void assertMultipart(RecordedRequest request, MediaType mediaType) {
149+
assertThat(mediaType.isCompatibleWith(new MediaType("multipart", "*"))).as("multipart/*").isTrue();
126150
String boundary = mediaType.getParameter("boundary");
151+
assertThat(boundary).as("boundary").isNotBlank();
127152
Buffer body = request.getBody();
128153
try {
129154
assertPart(body, "form-data", boundary, "name 1", "text/plain", "value 1");
@@ -132,9 +157,8 @@ private MockResponse multipartRequest(RecordedRequest request) {
132157
assertFilePart(body, "form-data", boundary, "logo", "logo.jpg", "image/jpeg");
133158
}
134159
catch (EOFException ex) {
135-
throw new IllegalStateException(ex);
160+
throw new AssertionError(ex);
136161
}
137-
return new MockResponse().setResponseCode(200);
138162
}
139163

140164
private void assertPart(Buffer buffer, String disposition, String boundary, String name,
@@ -245,8 +269,14 @@ else if (request.getPath().equals("/status/server")) {
245269
else if (request.getPath().contains("/uri/")) {
246270
return new MockResponse().setBody(request.getPath()).setHeader(CONTENT_TYPE, "text/plain");
247271
}
248-
else if (request.getPath().equals("/multipart")) {
249-
return multipartRequest(request);
272+
else if (request.getPath().equals("/multipartFormData")) {
273+
return multipartFormDataRequest(request);
274+
}
275+
else if (request.getPath().equals("/multipartMixed")) {
276+
return multipartMixedRequest(request);
277+
}
278+
else if (request.getPath().equals("/multipartRelated")) {
279+
return multipartRelatedRequest(request);
250280
}
251281
else if (request.getPath().equals("/form")) {
252282
return formRequest(request);

0 commit comments

Comments
 (0)