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
This commit is contained in:
Sam Brannen
2019-06-27 17:05:36 +03:00
parent 7bc727c895
commit 5008423408
6 changed files with 242 additions and 46 deletions

View File

@@ -47,6 +47,9 @@ import org.springframework.util.MultiValueMap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.http.MediaType.MULTIPART_FORM_DATA;
import static org.springframework.http.MediaType.TEXT_XML;
/**
* Unit tests for {@link FormHttpMessageConverter} and
@@ -58,24 +61,46 @@ import static org.mockito.Mockito.verify;
*/
public class FormHttpMessageConverterTests {
protected static final MediaType MULTIPART_MIXED = new MediaType("multipart", "mixed");
protected static final MediaType MULTIPART_RELATED = new MediaType("multipart", "related");
private static final MediaType MULTIPART_ALL = new MediaType("multipart", "*");
private static final MediaType MULTIPART_MIXED = new MediaType("multipart", "mixed");
private static final MediaType MULTIPART_RELATED = new MediaType("multipart", "related");
private final FormHttpMessageConverter converter = new AllEncompassingFormHttpMessageConverter();
@Test
public void canRead() {
assertThat(this.converter.canRead(MultiValueMap.class, MediaType.APPLICATION_FORM_URLENCODED)).isTrue();
assertThat(this.converter.canRead(MultiValueMap.class, MediaType.MULTIPART_FORM_DATA)).isFalse();
assertCanRead(MultiValueMap.class, null);
assertCanRead(APPLICATION_FORM_URLENCODED);
assertCannotRead(String.class, null);
assertCannotRead(String.class, APPLICATION_FORM_URLENCODED);
}
@Test
public void cannotReadMultipart() {
// Without custom multipart types supported
assertCannotRead(MULTIPART_ALL);
assertCannotRead(MULTIPART_FORM_DATA);
assertCannotRead(MULTIPART_MIXED);
assertCannotRead(MULTIPART_RELATED);
this.converter.addSupportedMediaTypes(MULTIPART_MIXED, MULTIPART_RELATED);
// With custom multipart types supported
assertCannotRead(MULTIPART_ALL);
assertCannotRead(MULTIPART_FORM_DATA);
assertCannotRead(MULTIPART_MIXED);
assertCannotRead(MULTIPART_RELATED);
}
@Test
public void canWrite() {
assertCanWrite(MediaType.APPLICATION_FORM_URLENCODED);
assertCanWrite(MediaType.MULTIPART_FORM_DATA);
assertCanWrite(APPLICATION_FORM_URLENCODED);
assertCanWrite(MULTIPART_FORM_DATA);
assertCanWrite(new MediaType("multipart", "form-data", StandardCharsets.UTF_8));
assertCanWrite(MediaType.ALL);
assertCanWrite(null);
}
@Test
@@ -103,14 +128,6 @@ public class FormHttpMessageConverterTests {
assertCanWrite(MULTIPART_RELATED);
}
private void assertCanWrite(MediaType mediaType) {
assertThat(this.converter.canWrite(MultiValueMap.class, mediaType)).isTrue();
}
private void assertCannotWrite(MediaType mediaType) {
assertThat(this.converter.canWrite(MultiValueMap.class, mediaType)).isFalse();
}
@Test
public void readForm() throws Exception {
String body = "name+1=value+1&name+2=value+2%2B1&name+2=value+2%2B2&name+3";
@@ -136,7 +153,7 @@ public class FormHttpMessageConverterTests {
body.add("name 2", "value 2+2");
body.add("name 3", null);
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
this.converter.write(body, MediaType.APPLICATION_FORM_URLENCODED, outputMessage);
this.converter.write(body, APPLICATION_FORM_URLENCODED, outputMessage);
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");
assertThat(outputMessage.getHeaders().getContentType().toString()).as("Invalid content-type").isEqualTo("application/x-www-form-urlencoded;charset=UTF-8");
@@ -165,7 +182,7 @@ public class FormHttpMessageConverterTests {
Source xml = new StreamSource(new StringReader("<root><child/></root>"));
HttpHeaders entityHeaders = new HttpHeaders();
entityHeaders.setContentType(MediaType.TEXT_XML);
entityHeaders.setContentType(TEXT_XML);
HttpEntity<Source> 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<MyBean> entity = new HttpEntity<>(myBean, entityHeaders);
parts.add("part2", entity);
@@ -261,6 +278,32 @@ public class FormHttpMessageConverterTests {
.endsWith("><string>foo</string></MyBean>");
}
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 {

View File

@@ -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);

View File

@@ -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}.
*
* <h3>Logging configuration for {@code MockWebServer}</h3>
*
* <p>In order for our log4j2 configuration to be used in an IDE, you must
* set the following system property before running any tests &mdash; for
* example, in <em>Run Configurations</em> in Eclipse.
*
* <pre class="code">
* -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager
* </pre>
*
* @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<String, Object> 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<MultiValueMap<String, Object>> requestBody = new HttpEntity<>(parts);
Future<URI> future = template.postForLocation(baseUrl + "/multipart", requestBody);
Future<URI> future = template.postForLocation(baseUrl + "/multipartFormData", requestBody);
future.get();
}

View File

@@ -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}.
*
* <h3>Logging configuration for {@code MockWebServer}</h3>
*
* <p>In order for our log4j2 configuration to be used in an IDE, you must
* set the following system property before running any tests &mdash; for
* example, in <em>Run Configurations</em> in Eclipse.
*
* <pre class="code">
* -Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager
* </pre>
*
* @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<MultiValueMap<String, Object>> 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<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(createMultipartParts(),
requestHeaders);
template.postForLocation(baseUrl + "/multipartRelated", requestEntity);
}
private MultiValueMap<String, Object> createMultipartParts() {
MultiValueMap<String, Object> 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<String, String> form = new LinkedMultiValueMap<>();
form.add("name 1", "value 1");
form.add("name 2", "value 2+1");