Browse Source

Move multipart support to dedicated converter

Prior to this commit, gh-36255 introduced the new
`MultipartHttpMessageConverter`, focusing on multipart message
conversion in a separate converter. The `FormHttpMessageConverter` did
conflate URL encoded forms and multipart messages in the same converter.

With the introduction of the new converter and related types in the same
package (with `Part`, `FormFieldPart` and `FilePart`), we can now
revisit this arrangement.

This commit restricts the `FormHttpMessageConverter` to URL encoded
forms only and as a result, changes its implementation to only consider
`MultiValueMap<String, String>` types for reading and writing HTTP
messages. Because type erasure, this converter is now a
`SmartHttpMessageConverter` to get better type information with
`ResolvableType`.

As a result, the `AllEncompassingFormHttpMessageConverter` is formally
deprecated and replaced by the `MultipartHttpMessageConverter`, by
setting part converters explicitly in its constructor.

Closes gh-36256
pull/36549/head
Brian Clozel 5 days ago
parent
commit
abc3cfc7be
  1. 35
      framework-docs/modules/ROOT/pages/integration/rest-clients.adoc
  2. 13
      framework-docs/modules/ROOT/pages/web/webmvc/message-converters.adoc
  3. 4
      spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java
  4. 5
      spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java
  5. 6
      spring-test/src/test/java/org/springframework/test/web/client/match/MultipartRequestMatchersTests.java
  6. 41
      spring-web/src/main/java/org/springframework/http/converter/DefaultHttpMessageConverters.java
  7. 555
      spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java
  8. 8
      spring-web/src/main/java/org/springframework/http/converter/HttpMessageConverters.java
  9. 1
      spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverter.java
  10. 13
      spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java
  11. 10
      spring-web/src/main/java/org/springframework/web/filter/FormContentFilter.java
  12. 41
      spring-web/src/test/java/org/springframework/http/converter/DefaultHttpMessageConvertersTests.java
  13. 396
      spring-web/src/test/java/org/springframework/http/converter/FormHttpMessageConverterTests.java
  14. 20
      spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverterTests.java
  15. 4
      spring-web/src/test/java/org/springframework/web/client/RestClientBuilderTests.java
  16. 12
      spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java
  17. 6
      spring-web/src/test/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequestTests.java
  18. 8
      spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolver.java
  19. 8
      spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java
  20. 4
      spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java
  21. 5
      spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestPartIntegrationTests.java
  22. 4
      spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java

35
framework-docs/modules/ROOT/pages/integration/rest-clients.adoc

@ -402,6 +402,27 @@ To serialize only a subset of the object properties, you can specify a {baeldung @@ -402,6 +402,27 @@ To serialize only a subset of the object properties, you can specify a {baeldung
.toBodilessEntity();
----
==== URL encoded Forms
URL encoded forms, using the `"application/x-www-form-urlencoded"` media type, are useful for sending String key/values over the wire.
This is supported by the `FormHttpMessageConverter`, if the application uses a `MultiValueMap<String, String>` as source instance
or a target type.
For example:
[source,java,indent=0,subs="verbatim"]
----
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("project", "Spring Framework");
form.add("module", "spring-web");
ResponseEntity<Void> response = this.restClient.post()
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(form)
.retrieve()
.toBodilessEntity();
----
==== Multipart
To send multipart data, you need to provide a `MultiValueMap<String, Object>` whose values may be an `Object` for part content, a `Resource` for a file part, or an `HttpEntity` for part content with headers.
@ -419,18 +440,22 @@ For example: @@ -419,18 +440,22 @@ For example:
headers.setContentType(MediaType.APPLICATION_XML);
parts.add("xmlPart", new HttpEntity<>(myBean, headers));
// send using RestClient.post or RestTemplate.postForEntity
ResponseEntity<Void> response = this.restClient.post()
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(parts)
.retrieve()
.toBodilessEntity();
----
In most cases, you do not have to specify the `Content-Type` for each part.
The content type is determined automatically based on the `HttpMessageConverter` chosen to serialize it or, in the case of a `Resource`, based on the file extension.
If necessary, you can explicitly provide the `MediaType` with an `HttpEntity` wrapper.
Once the `MultiValueMap` is ready, you can use it as the body of a `POST` request, using `RestClient.post().body(parts)` (or `RestTemplate.postForObject`).
The `Content-Type` is set to `multipart/form-data` by the `MultiPartHttpMessageConverter`.
As seen in the previous section, `MultiValueMap` types can also be used for URL encoded forms.
It is preferable to explicitly set the media type in the `Content-Type` or `Accept` HTTP request headers to ensure that the expected
message converter is used.
If the `MultiValueMap` contains at least one non-`String` value, the `Content-Type` is set to `multipart/form-data` by the `FormHttpMessageConverter`.
If the `MultiValueMap` has `String` values, the `Content-Type` defaults to `application/x-www-form-urlencoded`.
If necessary the `Content-Type` may also be set explicitly.
[[rest-request-factories]]
=== Client Request Factories

13
framework-docs/modules/ROOT/pages/web/webmvc/message-converters.adoc

@ -23,13 +23,16 @@ For all converters, a default media type is used, but you can override it by set @@ -23,13 +23,16 @@ For all converters, a default media type is used, but you can override it by set
By default, this converter supports all text media types(`text/{asterisk}`) and writes with a `Content-Type` of `text/plain`.
| `FormHttpMessageConverter`
| An `HttpMessageConverter` implementation that can read and write form data from the HTTP request and response.
| An `HttpMessageConverter` implementation that can read and write URL encoded forms.
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<String, String>`.
The converter can also write (but not read) multipart data read from a `MultiValueMap<String, Object>`.
By default, `multipart/form-data` is supported.
Additional multipart subtypes can be supported for writing form data.
Consult the javadoc for `FormHttpMessageConverter` for further details.
| `MultipartHttpMessageConverter`
| An `HttpMessageConverter` implementation that can read and write multipart messages.
`MultiValueMap<String, Object>` can be written to multipart messages, converting each part independently using
the configured message converters. Multipart messages can be read into `MultiValueMap<String, Part>`, each value
being a `Part` or one of its subtypes (`FormFieldPart` and `FilePart`).
By default, `multipart/form-data` is supported. Additional multipart subtypes can be supported for writing form data.
| `ByteArrayHttpMessageConverter`
| An `HttpMessageConverter` implementation that can read and write byte arrays from the HTTP request and response.

4
spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java

@ -35,6 +35,7 @@ import org.hamcrest.Matcher; @@ -35,6 +35,7 @@ import org.hamcrest.Matcher;
import org.jspecify.annotations.Nullable;
import org.w3c.dom.Node;
import org.springframework.core.ResolvableType;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
@ -180,7 +181,8 @@ public class ContentRequestMatchers { @@ -180,7 +181,8 @@ public class ContentRequestMatchers {
MockClientHttpRequest mockRequest = (MockClientHttpRequest) request;
MockHttpInputMessage message = new MockHttpInputMessage(mockRequest.getBodyAsBytes());
message.getHeaders().putAll(mockRequest.getHeaders());
MultiValueMap<String, String> actualMap = new FormHttpMessageConverter().read(null, message);
MultiValueMap<String, String> actualMap = new FormHttpMessageConverter()
.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class), message, null);
if (containsExactly) {
assertEquals("Form data", expectedMap, actualMap);
}

5
spring-test/src/main/java/org/springframework/test/web/servlet/request/AbstractMockHttpServletRequestBuilder.java

@ -46,6 +46,7 @@ import org.jspecify.annotations.Nullable; @@ -46,6 +46,7 @@ import org.jspecify.annotations.Nullable;
import org.springframework.beans.Mergeable;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.core.ResolvableType;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpMethod;
@ -1021,7 +1022,6 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo @@ -1021,7 +1022,6 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo
}
}
@SuppressWarnings("unchecked")
private MultiValueMap<String, String> parseFormData(MediaType mediaType) {
HttpInputMessage message = new HttpInputMessage() {
@Override
@ -1038,7 +1038,8 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo @@ -1038,7 +1038,8 @@ public abstract class AbstractMockHttpServletRequestBuilder<B extends AbstractMo
};
try {
return new FormHttpMessageConverter().read(null, message);
return new FormHttpMessageConverter()
.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class), message, null);
}
catch (IOException ex) {
throw new IllegalStateException("Failed to parse form data in request body", ex);

6
spring-test/src/test/java/org/springframework/test/web/client/match/MultipartRequestMatchersTests.java

@ -25,9 +25,10 @@ import org.junit.jupiter.api.Test; @@ -25,9 +25,10 @@ import org.junit.jupiter.api.Test;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.multipart.MultipartHttpMessageConverter;
import org.springframework.mock.http.client.MockClientHttpRequest;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.util.LinkedMultiValueMap;
@ -55,6 +56,7 @@ class MultipartRequestMatchersTests { @@ -55,6 +56,7 @@ class MultipartRequestMatchersTests {
@BeforeEach
void setup() {
this.request.setMethod(HttpMethod.POST);
this.request.getHeaders().setContentType(MediaType.MULTIPART_FORM_DATA);
}
@ -188,7 +190,7 @@ class MultipartRequestMatchersTests { @@ -188,7 +190,7 @@ class MultipartRequestMatchersTests {
}
private void writeForm() throws IOException {
new FormHttpMessageConverter().write(this.input, MediaType.MULTIPART_FORM_DATA,
new MultipartHttpMessageConverter().write(this.input, MediaType.MULTIPART_FORM_DATA,
new HttpOutputMessage() {
@Override
public OutputStream getBody() throws IOException {

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

@ -34,10 +34,10 @@ import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; @@ -34,10 +34,10 @@ import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import org.springframework.http.converter.json.JsonbHttpMessageConverter;
import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.http.converter.multipart.MultipartHttpMessageConverter;
import org.springframework.http.converter.protobuf.KotlinSerializationProtobufHttpMessageConverter;
import org.springframework.http.converter.smile.JacksonSmileHttpMessageConverter;
import org.springframework.http.converter.smile.MappingJackson2SmileHttpMessageConverter;
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
import org.springframework.http.converter.xml.JacksonXmlHttpMessageConverter;
import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter;
import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter;
@ -120,6 +120,8 @@ class DefaultHttpMessageConverters implements HttpMessageConverters { @@ -120,6 +120,8 @@ class DefaultHttpMessageConverters implements HttpMessageConverters {
@Nullable HttpMessageConverter<?> resourceRegionConverter;
@Nullable HttpMessageConverter<?> formConverter;
@Nullable Consumer<HttpMessageConverter<?>> configurer;
@Nullable Consumer<List<HttpMessageConverter<?>>> convertersListConfigurer;
@ -175,6 +177,11 @@ class DefaultHttpMessageConverters implements HttpMessageConverters { @@ -175,6 +177,11 @@ class DefaultHttpMessageConverters implements HttpMessageConverters {
this.stringConverter = stringConverter;
}
public void setFormConverter(HttpMessageConverter<?> formConverter) {
checkConverterSupports(formConverter, MediaType.APPLICATION_FORM_URLENCODED);
this.formConverter = formConverter;
}
void setKotlinSerializationJsonConverter(HttpMessageConverter<?> kotlinJsonConverter) {
Assert.notNull(kotlinJsonConverter, "kotlinJsonConverter must not be null");
this.kotlinJsonConverter = kotlinJsonConverter;
@ -241,6 +248,9 @@ class DefaultHttpMessageConverters implements HttpMessageConverters { @@ -241,6 +248,9 @@ class DefaultHttpMessageConverters implements HttpMessageConverters {
if (this.stringConverter != null) {
converters.add(this.stringConverter);
}
if (this.formConverter != null) {
converters.add(this.formConverter);
}
return converters;
}
@ -289,6 +299,9 @@ class DefaultHttpMessageConverters implements HttpMessageConverters { @@ -289,6 +299,9 @@ class DefaultHttpMessageConverters implements HttpMessageConverters {
if (this.stringConverter == null) {
this.stringConverter = new StringHttpMessageConverter();
}
if (this.formConverter == null) {
this.formConverter = new FormHttpMessageConverter();
}
if (this.kotlinJsonConverter == null) {
if (KOTLIN_SERIALIZATION_JSON_PRESENT) {
if (this.jsonConverter != null || JACKSON_PRESENT || JACKSON_2_PRESENT || GSON_PRESENT || JSONB_PRESENT) {
@ -401,6 +414,12 @@ class DefaultHttpMessageConverters implements HttpMessageConverters { @@ -401,6 +414,12 @@ class DefaultHttpMessageConverters implements HttpMessageConverters {
return this;
}
@Override
public ClientBuilder withFormConverter(HttpMessageConverter<?> formMessageConverter) {
setFormConverter(formMessageConverter);
return this;
}
@Override
public ClientBuilder withKotlinSerializationJsonConverter(HttpMessageConverter<?> kotlinSerializationJsonConverter) {
setKotlinSerializationJsonConverter(kotlinSerializationJsonConverter);
@ -487,14 +506,17 @@ class DefaultHttpMessageConverters implements HttpMessageConverters { @@ -487,14 +506,17 @@ class DefaultHttpMessageConverters implements HttpMessageConverters {
List<HttpMessageConverter<?>> partConverters = new ArrayList<>(this.getCustomConverters());
List<HttpMessageConverter<?>> allConverters = new ArrayList<>(this.getCustomConverters());
if (this.registerDefaults) {
partConverters.addAll(this.getCoreConverters());
allConverters.addAll(this.getBaseConverters());
if (this.resourceConverter != null) {
allConverters.add(this.resourceConverter);
}
// use separate instances of base converters for multipart
partConverters.addAll(List.of(new ByteArrayHttpMessageConverter(),
new StringHttpMessageConverter(), new ResourceHttpMessageConverter()));
partConverters.addAll(this.getCoreConverters());
}
if (!partConverters.isEmpty() || !allConverters.isEmpty()) {
allConverters.add(new AllEncompassingFormHttpMessageConverter(partConverters));
allConverters.add(new MultipartHttpMessageConverter(partConverters));
}
if (this.registerDefaults) {
allConverters.addAll(this.getCoreConverters());
@ -530,6 +552,12 @@ class DefaultHttpMessageConverters implements HttpMessageConverters { @@ -530,6 +552,12 @@ class DefaultHttpMessageConverters implements HttpMessageConverters {
return this;
}
@Override
public ServerBuilder withFormConverter(HttpMessageConverter<?> formMessageConverter) {
setFormConverter(formMessageConverter);
return this;
}
@Override
public ServerBuilder withKotlinSerializationJsonConverter(HttpMessageConverter<?> kotlinSerializationJsonConverter) {
setKotlinSerializationJsonConverter(kotlinSerializationJsonConverter);
@ -614,7 +642,6 @@ class DefaultHttpMessageConverters implements HttpMessageConverters { @@ -614,7 +642,6 @@ class DefaultHttpMessageConverters implements HttpMessageConverters {
List<HttpMessageConverter<?>> partConverters = new ArrayList<>(this.getCustomConverters());
List<HttpMessageConverter<?>> allConverters = new ArrayList<>(this.getCustomConverters());
if (this.registerDefaults) {
partConverters.addAll(this.getCoreConverters());
allConverters.addAll(this.getBaseConverters());
if (this.resourceConverter != null) {
allConverters.add(this.resourceConverter);
@ -622,9 +649,13 @@ class DefaultHttpMessageConverters implements HttpMessageConverters { @@ -622,9 +649,13 @@ class DefaultHttpMessageConverters implements HttpMessageConverters {
if (this.resourceRegionConverter != null) {
allConverters.add(this.resourceRegionConverter);
}
// use separate instances of base converters for multipart
partConverters.addAll(List.of(new ByteArrayHttpMessageConverter(),
new StringHttpMessageConverter(), new ResourceHttpMessageConverter()));
partConverters.addAll(this.getCoreConverters());
}
if (!partConverters.isEmpty() || !allConverters.isEmpty()) {
allConverters.add(new AllEncompassingFormHttpMessageConverter(partConverters));
allConverters.add(new MultipartHttpMessageConverter(partConverters));
}
if (this.registerDefaults) {
allConverters.addAll(this.getCoreConverters());

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

@ -16,26 +16,17 @@ @@ -16,26 +16,17 @@
package org.springframework.http.converter;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.jspecify.annotations.Nullable;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.core.ResolvableType;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
@ -43,280 +34,84 @@ import org.springframework.http.StreamingHttpOutputMessage; @@ -43,280 +34,84 @@ import org.springframework.http.StreamingHttpOutputMessage;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MimeTypeUtils;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
/**
* Implementation of {@link HttpMessageConverter} to read and write 'normal' HTML
* forms and also to write (but not read) multipart data (for example, file uploads).
* Implementation of {@link HttpMessageConverter} to read and
* write URL encoded forms. For multipart support, see the
* {@link org.springframework.http.converter.multipart.MultipartHttpMessageConverter}.
*
* <p>In other words, this converter can read and write the
* <p>This converter can read and write the
* {@code "application/x-www-form-urlencoded"} media type as
* {@link MultiValueMap MultiValueMap&lt;String, String&gt;}, and it can also
* write (but not read) the {@code "multipart/form-data"} and
* {@code "multipart/mixed"} media types as
* {@link MultiValueMap MultiValueMap&lt;String, Object&gt;}.
*
* <h3>Multipart Data</h3>
*
* <p>By default, {@code "multipart/form-data"} is used as the content type when
* {@linkplain #write writing} multipart data. 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} <em>and</em> the
* desired multipart subtype is specified as the content type when
* {@linkplain #write writing} the multipart data. Note that {@code "multipart/mixed"}
* is registered as a supported media type by default.
*
* <p>When writing multipart data, this converter uses other
* {@link HttpMessageConverter HttpMessageConverters} to write the respective
* 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}.
* {@link MultiValueMap MultiValueMap&lt;String, String&gt;}.
*
* <h3>Examples</h3>
*
* <p>The following snippet shows how to submit an HTML form using the
* {@code "multipart/form-data"} content type.
* {@code "application/x-www-form-urlencoded"} content type.
*
* <pre class="code">
* RestClient restClient = RestClient.create();
* // AllEncompassingFormHttpMessageConverter is configured by default
*
* MultiValueMap&lt;String, Object&gt; form = new LinkedMultiValueMap&lt;&gt;();
* MultiValueMap&lt;String, String&gt; form = new LinkedMultiValueMap&lt;&gt;();
* form.add("field 1", "value 1");
* 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
*
* ResponseEntity&lt;Void&gt; response = restClient.post()
* .uri("https://example.com/myForm")
* .contentType(MULTIPART_FORM_DATA)
* .body(form)
* .retrieve()
* .toBodilessEntity();</pre>
*
* <p>The following snippet shows how to do a file upload using the
* {@code "multipart/form-data"} content type.
*
* <pre class="code">
* MultiValueMap&lt;String, Object&gt; parts = new LinkedMultiValueMap&lt;&gt;();
* parts.add("field 1", "value 1");
* parts.add("file", new ClassPathResource("myFile.jpg"));
*
* ResponseEntity&lt;Void&gt; response = restClient.post()
* .uri("https://example.com/myForm")
* .contentType(MULTIPART_FORM_DATA)
* .body(parts)
* .retrieve()
* .toBodilessEntity();</pre>
*
* <p>The following snippet shows how to do a file upload using the
* {@code "multipart/mixed"} content type.
*
* <pre class="code">
* MultiValueMap&lt;String, Object&gt; parts = new LinkedMultiValueMap&lt;&gt;();
* parts.add("field 1", "value 1");
* parts.add("file", new ClassPathResource("myFile.jpg"));
*
* ResponseEntity&lt;Void&gt; response = restClient.post()
* .uri("https://example.com/myForm")
* .contentType(MULTIPART_MIXED)
* .body(form)
* .retrieve()
* .toBodilessEntity();</pre>
*
* <p>The following snippet shows how to do a file upload using the
* {@code "multipart/related"} content type.
*
* <pre class="code">
* restClient = restClient.mutate()
* .messageConverters(l -> l.stream()
* .filter(FormHttpMessageConverter.class::isInstance)
* .map(FormHttpMessageConverter.class::cast)
* .findFirst()
* .orElseThrow(() -&gt; new IllegalStateException("Failed to find FormHttpMessageConverter"))
* .addSupportedMediaTypes(MULTIPART_RELATED);
*
* MultiValueMap&lt;String, Object&gt; parts = new LinkedMultiValueMap&lt;&gt;();
* parts.add("field 1", "value 1");
* parts.add("file", new ClassPathResource("myFile.jpg"));
* form.add("field 3", 4);
*
* ResponseEntity&lt;Void&gt; response = restClient.post()
* .uri("https://example.com/myForm")
* .contentType(MULTIPART_RELATED)
* .contentType(MediaType.APPLICATION_FORM_URLENCODED)
* .body(form)
* .retrieve()
* .toBodilessEntity();</pre>
*
* <h3>Miscellaneous</h3>
*
* <p>Some methods in this class were inspired by
* {@code org.apache.commons.httpclient.methods.multipart.MultipartRequestEntity}.
*
* @author Arjen Poutsma
* @author Rossen Stoyanchev
* @author Juergen Hoeller
* @author Sam Brannen
* @author Brian Clozel
* @since 3.0
* @see org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter
* @see org.springframework.util.MultiValueMap
*/
public class FormHttpMessageConverter implements HttpMessageConverter<MultiValueMap<String, ?>> {
public class FormHttpMessageConverter implements SmartHttpMessageConverter<MultiValueMap<String, String>> {
/** The default charset used by the converter. */
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
private List<MediaType> supportedMediaTypes = new ArrayList<>();
private List<HttpMessageConverter<?>> partConverters = new ArrayList<>();
private Charset charset = DEFAULT_CHARSET;
private @Nullable Charset multipartCharset;
public FormHttpMessageConverter() {
this.supportedMediaTypes.add(MediaType.APPLICATION_FORM_URLENCODED);
this.supportedMediaTypes.add(MediaType.MULTIPART_FORM_DATA);
this.supportedMediaTypes.add(MediaType.MULTIPART_MIXED);
this.supportedMediaTypes.add(MediaType.MULTIPART_RELATED);
this.partConverters.add(new ByteArrayHttpMessageConverter());
this.partConverters.add(new StringHttpMessageConverter());
this.partConverters.add(new ResourceHttpMessageConverter());
applyDefaultCharset();
}
/**
* Set the list of {@link MediaType} objects supported by this converter.
* @see #addSupportedMediaTypes(MediaType...)
* @see #getSupportedMediaTypes()
*/
public void setSupportedMediaTypes(List<MediaType> supportedMediaTypes) {
Assert.notNull(supportedMediaTypes, "'supportedMediaTypes' must not be null");
// Ensure internal list is mutable.
this.supportedMediaTypes = new ArrayList<>(supportedMediaTypes);
}
/**
* Add {@link MediaType} objects to be supported by this converter.
* <p>The supplied {@code MediaType} objects will be appended to the list
* of {@linkplain #getSupportedMediaTypes() supported MediaType objects}.
* @param supportedMediaTypes a var-args list of {@code MediaType} objects to add
* @since 5.2
* @see #setSupportedMediaTypes(List)
*/
public void addSupportedMediaTypes(MediaType... supportedMediaTypes) {
Assert.notNull(supportedMediaTypes, "'supportedMediaTypes' must not be null");
Assert.noNullElements(supportedMediaTypes, "'supportedMediaTypes' must not contain null elements");
Collections.addAll(this.supportedMediaTypes, supportedMediaTypes);
}
/**
* {@inheritDoc}
* @see #setSupportedMediaTypes(List)
* @see #addSupportedMediaTypes(MediaType...)
*/
@Override
public List<MediaType> getSupportedMediaTypes() {
return Collections.unmodifiableList(this.supportedMediaTypes);
}
/**
* Set the message body converters to use. These converters are used to
* convert objects to MIME parts.
*/
public void setPartConverters(List<HttpMessageConverter<?>> partConverters) {
Assert.notEmpty(partConverters, "'partConverters' must not be empty");
this.partConverters = partConverters;
}
/**
* Return the {@linkplain #setPartConverters configured converters} for MIME
* parts.
* @since 5.3
*/
public List<HttpMessageConverter<?>> getPartConverters() {
return Collections.unmodifiableList(this.partConverters);
}
/**
* Add a message body converter. Such a converter is used to convert objects
* to MIME parts.
*/
public void addPartConverter(HttpMessageConverter<?> partConverter) {
Assert.notNull(partConverter, "'partConverter' must not be null");
this.partConverters.add(partConverter);
return List.of(MediaType.APPLICATION_FORM_URLENCODED);
}
/**
* Set the default character set to use for reading and writing form data when
* the request or response {@code Content-Type} header does not explicitly
* specify it.
* <p>As of 4.3, this is also used as the default charset for the conversion
* of text bodies in a multipart request.
* <p>As of 5.0, this is also used for part headers including
* {@code Content-Disposition} (and its filename parameter) unless (the mutually
* exclusive) {@link #setMultipartCharset multipartCharset} is also set, in
* which case part headers are encoded as ASCII and <i>filename</i> is encoded
* with the {@code encoded-word} syntax from RFC 2047.
* <p>By default this is set to "UTF-8".
* <p>By default, this is set to "UTF-8".
*/
public void setCharset(@Nullable Charset charset) {
if (charset != this.charset) {
this.charset = (charset != null ? charset : DEFAULT_CHARSET);
applyDefaultCharset();
}
}
/**
* Apply the configured charset as a default to registered part converters.
*/
private void applyDefaultCharset() {
for (HttpMessageConverter<?> candidate : this.partConverters) {
if (candidate instanceof AbstractHttpMessageConverter<?> converter) {
// Only override default charset if the converter operates with a charset to begin with...
if (converter.getDefaultCharset() != null) {
converter.setDefaultCharset(this.charset);
}
}
}
}
/**
* Set the character set to use when writing multipart data to encode file
* names. Encoding is based on the {@code encoded-word} syntax defined in
* RFC 2047 and relies on {@code MimeUtility} from {@code jakarta.mail}.
* <p>As of 5.0 by default part headers, including {@code Content-Disposition}
* (and its filename parameter) will be encoded based on the setting of
* {@link #setCharset(Charset)} or {@code UTF-8} by default.
* @since 4.1.1
* @see <a href="https://en.wikipedia.org/wiki/MIME#Encoded-Word">Encoded-Word</a>
*/
public void setMultipartCharset(Charset charset) {
this.multipartCharset = charset;
}
@Override
public boolean canRead(Class<?> clazz, @Nullable MediaType mediaType) {
if (!MultiValueMap.class.isAssignableFrom(clazz)) {
public boolean canRead(ResolvableType type, @Nullable MediaType mediaType) {
if (!MultiValueMap.class.isAssignableFrom(type.toClass()) ||
(!type.hasUnresolvableGenerics() &&
!String.class.isAssignableFrom(type.getGeneric(1).toClass()))) {
return false;
}
if (mediaType == null) {
return true;
}
for (MediaType supportedMediaType : getSupportedMediaTypes()) {
if (supportedMediaType.getType().equalsIgnoreCase("multipart")) {
// We can't read multipart, so skip this supported media type.
continue;
}
if (supportedMediaType.includes(mediaType)) {
return true;
}
@ -325,13 +120,12 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue @@ -325,13 +120,12 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue
}
@Override
public boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType) {
if (!MultiValueMap.class.isAssignableFrom(clazz)) {
public boolean canWrite(ResolvableType targetType, Class<?> valueClass, @Nullable MediaType mediaType) {
if (!MultiValueMap.class.isAssignableFrom(targetType.toClass()) ||
(!targetType.hasUnresolvableGenerics() &&
!String.class.isAssignableFrom(targetType.getGeneric(1).toClass()))) {
return false;
}
if (mediaType == null || MediaType.ALL.equals(mediaType)) {
return true;
}
for (MediaType supportedMediaType : getSupportedMediaTypes()) {
if (supportedMediaType.isCompatibleWith(mediaType)) {
return true;
@ -341,8 +135,8 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue @@ -341,8 +135,8 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue
}
@Override
public MultiValueMap<String, String> read(@Nullable Class<? extends MultiValueMap<String, ?>> clazz,
HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
public MultiValueMap<String, String> read(ResolvableType type, HttpInputMessage inputMessage, @Nullable Map<String, Object> hints)
throws IOException, HttpMessageNotReadableException {
MediaType contentType = inputMessage.getHeaders().getContentType();
Charset charset = (contentType != null && contentType.getCharset() != null ?
@ -372,39 +166,13 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue @@ -372,39 +166,13 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue
@Override
@SuppressWarnings("unchecked")
public void write(MultiValueMap<String, ?> map, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
if (isMultipart(map, contentType)) {
writeMultipart((MultiValueMap<String, Object>) map, contentType, outputMessage);
}
else {
writeForm((MultiValueMap<String, Object>) map, contentType, outputMessage);
}
}
private boolean isMultipart(MultiValueMap<String, ?> map, @Nullable MediaType contentType) {
if (contentType != null) {
return contentType.getType().equalsIgnoreCase("multipart");
}
for (List<?> values : map.values()) {
for (Object value : values) {
if (value != null && !(value instanceof String)) {
return true;
}
}
}
return false;
}
private void writeForm(MultiValueMap<String, Object> formData, @Nullable MediaType mediaType,
HttpOutputMessage outputMessage) throws IOException {
public void write(MultiValueMap<String, String> formData, ResolvableType type, @Nullable MediaType contentType,
HttpOutputMessage outputMessage, @Nullable Map<String, Object> hints) throws IOException, HttpMessageNotWritableException {
mediaType = getFormContentType(mediaType);
outputMessage.getHeaders().setContentType(mediaType);
contentType = getFormContentType(contentType);
outputMessage.getHeaders().setContentType(contentType);
Charset charset = (mediaType.getCharset() != null ? mediaType.getCharset() : this.charset);
Charset charset = (contentType.getCharset() != null ? contentType.getCharset() : this.charset);
byte[] bytes = serializeForm(formData, charset).getBytes(charset);
outputMessage.getHeaders().setContentLength(bytes.length);
@ -436,7 +204,7 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue @@ -436,7 +204,7 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue
return contentType;
}
protected String serializeForm(MultiValueMap<String, Object> formData, Charset charset) {
protected String serializeForm(MultiValueMap<String, String> formData, Charset charset) {
StringBuilder builder = new StringBuilder();
formData.forEach((name, values) -> {
if (name == null) {
@ -450,7 +218,7 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue @@ -450,7 +218,7 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue
builder.append(URLEncoder.encode(name, charset));
if (value != null) {
builder.append('=');
builder.append(URLEncoder.encode(String.valueOf(value), charset));
builder.append(URLEncoder.encode(value, charset));
}
});
});
@ -458,265 +226,4 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue @@ -458,265 +226,4 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue
return builder.toString();
}
private void writeMultipart(
MultiValueMap<String, Object> parts, @Nullable 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;
}
Map<String, String> parameters = new LinkedHashMap<>(contentType.getParameters().size() + 2);
parameters.putAll(contentType.getParameters());
byte[] boundary = generateMultipartBoundary();
if (!isFilenameCharsetSet()) {
if (!this.charset.equals(StandardCharsets.UTF_8) &&
!this.charset.equals(StandardCharsets.US_ASCII)) {
parameters.put("charset", this.charset.name());
}
}
parameters.put("boundary", new String(boundary, StandardCharsets.US_ASCII));
// Add parameters to output content type
contentType = new MediaType(contentType, parameters);
outputMessage.getHeaders().setContentType(contentType);
if (outputMessage instanceof StreamingHttpOutputMessage streamingOutputMessage) {
boolean repeatable = checkPartsRepeatable(parts, contentType);
streamingOutputMessage.setBody(new StreamingHttpOutputMessage.Body() {
@Override
public void writeTo(OutputStream outputStream) throws IOException {
FormHttpMessageConverter.this.writeParts(outputStream, parts, boundary);
writeEnd(outputStream, boundary);
}
@Override
public boolean repeatable() {
return repeatable;
}
});
}
else {
writeParts(outputMessage.getBody(), parts, boundary);
writeEnd(outputMessage.getBody(), boundary);
}
}
@SuppressWarnings({"unchecked", "ConstantValue"})
private <T> boolean checkPartsRepeatable(MultiValueMap<String, Object> map, MediaType contentType) {
return map.entrySet().stream().allMatch(e -> e.getValue().stream().filter(Objects::nonNull).allMatch(part -> {
HttpHeaders headers = null;
Object body = part;
if (part instanceof HttpEntity<?> entity) {
headers = entity.getHeaders();
body = entity.getBody();
Assert.state(body != null, "Empty body for part '" + e.getKey() + "': " + part);
}
HttpMessageConverter<T> converter = (HttpMessageConverter<T>) findConverterFor(e.getKey(), headers, body);
return converter != null && converter.canWriteRepeatedly((T) body, contentType);
}));
}
private @Nullable HttpMessageConverter<?> findConverterFor(
String name, @Nullable HttpHeaders headers, Object body) {
Class<?> partType = body.getClass();
MediaType contentType = (headers != null ? headers.getContentType() : null);
for (HttpMessageConverter<?> converter : this.partConverters) {
if (converter.canWrite(partType, contentType)) {
return converter;
}
}
return null;
}
/**
* When {@link #setMultipartCharset(Charset)} is configured (i.e. RFC 2047,
* {@code encoded-word} syntax) we need to use ASCII for part headers, or
* otherwise we encode directly using the configured {@link #setCharset(Charset)}.
*/
private boolean isFilenameCharsetSet() {
return (this.multipartCharset != null);
}
private void writeParts(OutputStream os, MultiValueMap<String, Object> parts, byte[] boundary) throws IOException {
for (Map.Entry<String, List<Object>> entry : parts.entrySet()) {
String name = entry.getKey();
for (Object part : entry.getValue()) {
if (part != null) {
writeBoundary(os, boundary);
writePart(name, getHttpEntity(part), os);
writeNewLine(os);
}
}
}
}
@SuppressWarnings("unchecked")
private void writePart(String name, HttpEntity<?> partEntity, OutputStream os) throws IOException {
Object partBody = partEntity.getBody();
Assert.state(partBody != null, "Empty body for part '" + name + "': " + partEntity);
HttpHeaders partHeaders = partEntity.getHeaders();
MediaType partContentType = partHeaders.getContentType();
HttpMessageConverter<?> converter = findConverterFor(name, partHeaders, partBody);
if (converter != null) {
Charset charset = isFilenameCharsetSet() ? StandardCharsets.US_ASCII : this.charset;
HttpOutputMessage multipartMessage = new MultipartHttpOutputMessage(os, charset);
String filename = getFilename(partBody);
ContentDisposition.Builder cd = ContentDisposition.formData().name(name);
if (filename != null) {
cd.filename(filename, this.multipartCharset);
}
multipartMessage.getHeaders().setContentDisposition(cd.build());
if (!partHeaders.isEmpty()) {
multipartMessage.getHeaders().putAll(partHeaders);
}
((HttpMessageConverter<Object>) converter).write(partBody, partContentType, multipartMessage);
return;
}
throw new HttpMessageNotWritableException("Could not write request: " +
"no suitable HttpMessageConverter found for request type [" + partBody.getClass().getName() + "]");
}
/**
* Generate a multipart boundary.
* <p>This implementation delegates to
* {@link MimeTypeUtils#generateMultipartBoundary()}.
*/
protected byte[] generateMultipartBoundary() {
return MimeTypeUtils.generateMultipartBoundary();
}
/**
* Return an {@link HttpEntity} for the given part Object.
* @param part the part to return an {@link HttpEntity} for
* @return the part Object itself it is an {@link HttpEntity},
* or a newly built {@link HttpEntity} wrapper for that part
*/
protected HttpEntity<?> getHttpEntity(Object part) {
return (part instanceof HttpEntity<?> httpEntity ? httpEntity : new HttpEntity<>(part));
}
/**
* Return the filename of the given multipart part. This value will be used for the
* {@code Content-Disposition} header.
* <p>The default implementation returns {@link Resource#getFilename()} if the part is a
* {@code Resource}, and {@code null} in other cases. Can be overridden in subclasses.
* @param part the part to determine the file name for
* @return the filename, or {@code null} if not known
*/
protected @Nullable String getFilename(Object part) {
if (part instanceof Resource resource) {
return resource.getFilename();
}
else {
return null;
}
}
private void writeBoundary(OutputStream os, byte[] boundary) throws IOException {
os.write('-');
os.write('-');
os.write(boundary);
writeNewLine(os);
}
private static void writeEnd(OutputStream os, byte[] boundary) throws IOException {
os.write('-');
os.write('-');
os.write(boundary);
os.write('-');
os.write('-');
writeNewLine(os);
}
private static void writeNewLine(OutputStream os) throws IOException {
os.write('\r');
os.write('\n');
}
/**
* Implementation of {@link org.springframework.http.HttpOutputMessage} used
* to write a MIME multipart.
*/
private static class MultipartHttpOutputMessage implements HttpOutputMessage {
private final OutputStream outputStream;
private final Charset charset;
private final HttpHeaders headers = new HttpHeaders();
private boolean headersWritten = false;
public MultipartHttpOutputMessage(OutputStream outputStream, Charset charset) {
this.outputStream = new MultipartOutputStream(outputStream);
this.charset = charset;
}
@Override
public HttpHeaders getHeaders() {
return (this.headersWritten ? HttpHeaders.readOnlyHttpHeaders(this.headers) : this.headers);
}
@Override
public OutputStream getBody() throws IOException {
writeHeaders();
return this.outputStream;
}
private void writeHeaders() throws IOException {
if (!this.headersWritten) {
for (Map.Entry<String, List<String>> entry : this.headers.headerSet()) {
byte[] headerName = getBytes(entry.getKey());
for (String headerValueString : entry.getValue()) {
byte[] headerValue = getBytes(headerValueString);
this.outputStream.write(headerName);
this.outputStream.write(':');
this.outputStream.write(' ');
this.outputStream.write(headerValue);
writeNewLine(this.outputStream);
}
}
writeNewLine(this.outputStream);
this.headersWritten = true;
}
}
private byte[] getBytes(String name) {
return name.getBytes(this.charset);
}
}
/**
* OutputStream that neither flushes nor closes.
*/
private static class MultipartOutputStream extends FilterOutputStream {
public MultipartOutputStream(OutputStream out) {
super(out);
}
@Override
public void write(byte[] b, int off, int let) throws IOException {
this.out.write(b, off, let);
}
@Override
public void flush() {
}
@Override
public void close() {
}
}
}

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

@ -114,6 +114,14 @@ public interface HttpMessageConverters extends Iterable<HttpMessageConverter<?>> @@ -114,6 +114,14 @@ public interface HttpMessageConverters extends Iterable<HttpMessageConverter<?>>
*/
T withStringConverter(HttpMessageConverter<?> stringMessageConverter);
/**
* Override the default {@code HttpMessageConverter} for URL encoded forms.
* @param formMessageConverter the converter instance to use
* @since 7.1
* @see FormHttpMessageConverter
*/
T withFormConverter(HttpMessageConverter<?> formMessageConverter);
/**
* Override the default String {@code HttpMessageConverter}
* with any converter supporting the Kotlin Serialization conversion for JSON.

1
spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverter.java

@ -170,6 +170,7 @@ public class MultipartHttpMessageConverter implements SmartHttpMessageConverter< @@ -170,6 +170,7 @@ public class MultipartHttpMessageConverter implements SmartHttpMessageConverter<
this.partConverters = new ArrayList<>();
converters.forEach(this.partConverters::add);
applyDefaultCharset();
}
/**

13
spring-web/src/main/java/org/springframework/http/converter/support/AllEncompassingFormHttpMessageConverter.java

@ -16,12 +16,12 @@ @@ -16,12 +16,12 @@
package org.springframework.http.converter.support;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverters;
import org.springframework.http.converter.multipart.MultipartHttpMessageConverter;
/**
* Extension of {@link org.springframework.http.converter.FormHttpMessageConverter},
* Extension of {@link MultipartHttpMessageConverter},
* adding support for XML, JSON, Smile, CBOR, Protobuf and Yaml based parts when
* related libraries are present in the classpath.
*
@ -29,17 +29,18 @@ import org.springframework.http.converter.HttpMessageConverters; @@ -29,17 +29,18 @@ import org.springframework.http.converter.HttpMessageConverters;
* @author Juergen Hoeller
* @author Sebastien Deleuze
* @since 3.2
* @deprecated since 7.1 in favor of {@link MultipartHttpMessageConverter}.
*/
public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConverter {
@Deprecated(since = "7.1", forRemoval = true)
public class AllEncompassingFormHttpMessageConverter extends MultipartHttpMessageConverter {
/**
* Create a new {@link AllEncompassingFormHttpMessageConverter} instance
* that will auto-detect part converters.
*/
@SuppressWarnings("removal")
public AllEncompassingFormHttpMessageConverter() {
HttpMessageConverters.forClient().registerDefaults().build().forEach(this::addPartConverter);
super(HttpMessageConverters.forClient().registerDefaults().build());
}
/**
@ -49,7 +50,7 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv @@ -49,7 +50,7 @@ public class AllEncompassingFormHttpMessageConverter extends FormHttpMessageConv
* @since 7.0
*/
public AllEncompassingFormHttpMessageConverter(Iterable<HttpMessageConverter<?>> converters) {
converters.forEach(this::addPartConverter);
super(converters);
}
}

10
spring-web/src/main/java/org/springframework/web/filter/FormContentFilter.java

@ -36,10 +36,10 @@ import jakarta.servlet.http.HttpServletRequestWrapper; @@ -36,10 +36,10 @@ import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.Nullable;
import org.springframework.core.ResolvableType;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
@ -48,7 +48,7 @@ import org.springframework.util.StringUtils; @@ -48,7 +48,7 @@ import org.springframework.util.StringUtils;
/**
* {@code Filter} that parses form data for HTTP PUT, PATCH, and DELETE requests
* and exposes it as Servlet request parameters. By default the Servlet spec
* and exposes it as Servlet request parameters. By default, the Servlet spec
* only requires this for HTTP POST.
*
* @author Rossen Stoyanchev
@ -58,12 +58,12 @@ public class FormContentFilter extends OncePerRequestFilter { @@ -58,12 +58,12 @@ public class FormContentFilter extends OncePerRequestFilter {
private static final List<String> HTTP_METHODS = Arrays.asList("PUT", "PATCH", "DELETE");
private FormHttpMessageConverter formConverter = new AllEncompassingFormHttpMessageConverter();
private FormHttpMessageConverter formConverter = new FormHttpMessageConverter();
/**
* Set the converter to use for parsing form content.
* <p>By default this is an instance of {@link AllEncompassingFormHttpMessageConverter}.
* <p>By default, this is an instance of {@link FormHttpMessageConverter}.
*/
public void setFormConverter(FormHttpMessageConverter converter) {
Assert.notNull(converter, "FormHttpMessageConverter is required");
@ -105,7 +105,7 @@ public class FormContentFilter extends OncePerRequestFilter { @@ -105,7 +105,7 @@ public class FormContentFilter extends OncePerRequestFilter {
return request.getInputStream();
}
};
return this.formConverter.read(null, inputMessage);
return this.formConverter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class), inputMessage, null);
}
private boolean shouldParse(HttpServletRequest request) {

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

@ -33,9 +33,9 @@ import org.springframework.http.converter.feed.AtomFeedHttpMessageConverter; @@ -33,9 +33,9 @@ import org.springframework.http.converter.feed.AtomFeedHttpMessageConverter;
import org.springframework.http.converter.feed.RssChannelHttpMessageConverter;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import org.springframework.http.converter.json.KotlinSerializationJsonHttpMessageConverter;
import org.springframework.http.converter.multipart.MultipartHttpMessageConverter;
import org.springframework.http.converter.protobuf.KotlinSerializationProtobufHttpMessageConverter;
import org.springframework.http.converter.smile.JacksonSmileHttpMessageConverter;
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
import org.springframework.http.converter.xml.JacksonXmlHttpMessageConverter;
import org.springframework.http.converter.yaml.JacksonYamlHttpMessageConverter;
@ -68,6 +68,13 @@ class DefaultHttpMessageConvertersTests { @@ -68,6 +68,13 @@ class DefaultHttpMessageConvertersTests {
.withMessage("converter should support 'text/plain'");
}
@Test
void failsWhenFormConverterDoesNotSupportMediaType() {
assertThatIllegalArgumentException()
.isThrownBy(() -> HttpMessageConverters.forClient().withFormConverter(new CustomHttpMessageConverter()).build())
.withMessage("converter should support 'application/x-www-form-urlencoded'");
}
@Test
void failsWhenJsonConverterDoesNotSupportMediaType() {
assertThatIllegalArgumentException()
@ -116,8 +123,9 @@ class DefaultHttpMessageConvertersTests { @@ -116,8 +123,9 @@ class DefaultHttpMessageConvertersTests {
void defaultConverters() {
var converters = HttpMessageConverters.forClient().registerDefaults().build();
assertThat(converters).hasExactlyElementsOfTypes(ByteArrayHttpMessageConverter.class,
StringHttpMessageConverter.class, ResourceHttpMessageConverter.class,
AllEncompassingFormHttpMessageConverter.class, KotlinSerializationJsonHttpMessageConverter.class,
StringHttpMessageConverter.class, FormHttpMessageConverter.class,
ResourceHttpMessageConverter.class, MultipartHttpMessageConverter.class,
KotlinSerializationJsonHttpMessageConverter.class,
JacksonJsonHttpMessageConverter.class, JacksonSmileHttpMessageConverter.class,
KotlinSerializationCborHttpMessageConverter.class, JacksonCborHttpMessageConverter.class,
JacksonYamlHttpMessageConverter.class, JacksonXmlHttpMessageConverter.class,
@ -134,7 +142,7 @@ class DefaultHttpMessageConvertersTests { @@ -134,7 +142,7 @@ class DefaultHttpMessageConvertersTests {
@Test
void multipartConverterContainsOtherConverters() {
var converters = HttpMessageConverters.forClient().registerDefaults().build();
var multipartConverter = findMessageConverter(AllEncompassingFormHttpMessageConverter.class, converters);
var multipartConverter = findMessageConverter(MultipartHttpMessageConverter.class, converters);
assertThat(multipartConverter.getPartConverters()).hasExactlyElementsOfTypes(
ByteArrayHttpMessageConverter.class, StringHttpMessageConverter.class,
@ -150,7 +158,7 @@ class DefaultHttpMessageConvertersTests { @@ -150,7 +158,7 @@ class DefaultHttpMessageConvertersTests {
void registerCustomMessageConverter() {
var converters = HttpMessageConverters.forClient()
.addCustomConverter(new CustomHttpMessageConverter()).build();
assertThat(converters).hasExactlyElementsOfTypes(CustomHttpMessageConverter.class, AllEncompassingFormHttpMessageConverter.class);
assertThat(converters).hasExactlyElementsOfTypes(CustomHttpMessageConverter.class, MultipartHttpMessageConverter.class);
}
@Test
@ -159,8 +167,9 @@ class DefaultHttpMessageConvertersTests { @@ -159,8 +167,9 @@ class DefaultHttpMessageConvertersTests {
.addCustomConverter(new CustomHttpMessageConverter()).build();
assertThat(converters).hasExactlyElementsOfTypes(
CustomHttpMessageConverter.class, ByteArrayHttpMessageConverter.class,
StringHttpMessageConverter.class, ResourceHttpMessageConverter.class,
AllEncompassingFormHttpMessageConverter.class, KotlinSerializationJsonHttpMessageConverter.class,
StringHttpMessageConverter.class, FormHttpMessageConverter.class,
ResourceHttpMessageConverter.class, MultipartHttpMessageConverter.class,
KotlinSerializationJsonHttpMessageConverter.class,
JacksonJsonHttpMessageConverter.class, JacksonSmileHttpMessageConverter.class,
KotlinSerializationCborHttpMessageConverter.class, JacksonCborHttpMessageConverter.class,
JacksonYamlHttpMessageConverter.class, JacksonXmlHttpMessageConverter.class,
@ -172,7 +181,7 @@ class DefaultHttpMessageConvertersTests { @@ -172,7 +181,7 @@ class DefaultHttpMessageConvertersTests {
void registerCustomConverterInMultipartConverter() {
var converters = HttpMessageConverters.forClient().registerDefaults()
.addCustomConverter(new CustomHttpMessageConverter()).build();
var multipartConverter = findMessageConverter(AllEncompassingFormHttpMessageConverter.class, converters);
var multipartConverter = findMessageConverter(MultipartHttpMessageConverter.class, converters);
assertThat(multipartConverter.getPartConverters()).hasAtLeastOneElementOfType(CustomHttpMessageConverter.class);
}
@ -248,8 +257,9 @@ class DefaultHttpMessageConvertersTests { @@ -248,8 +257,9 @@ class DefaultHttpMessageConvertersTests {
var converters = HttpMessageConverters.forServer().registerDefaults().build();
assertThat(converters).hasExactlyElementsOfTypes(
ByteArrayHttpMessageConverter.class, StringHttpMessageConverter.class,
ResourceHttpMessageConverter.class, ResourceRegionHttpMessageConverter.class,
AllEncompassingFormHttpMessageConverter.class, KotlinSerializationJsonHttpMessageConverter.class,
FormHttpMessageConverter.class, ResourceHttpMessageConverter.class,
ResourceRegionHttpMessageConverter.class, MultipartHttpMessageConverter.class,
KotlinSerializationJsonHttpMessageConverter.class,
JacksonJsonHttpMessageConverter.class, JacksonSmileHttpMessageConverter.class,
KotlinSerializationCborHttpMessageConverter.class, JacksonCborHttpMessageConverter.class,
JacksonYamlHttpMessageConverter.class, JacksonXmlHttpMessageConverter.class,
@ -266,7 +276,7 @@ class DefaultHttpMessageConvertersTests { @@ -266,7 +276,7 @@ class DefaultHttpMessageConvertersTests {
@Test
void multipartConverterContainsOtherConverters() {
var converters = HttpMessageConverters.forServer().registerDefaults().build();
var multipartConverter = findMessageConverter(AllEncompassingFormHttpMessageConverter.class, converters);
var multipartConverter = findMessageConverter(MultipartHttpMessageConverter.class, converters);
assertThat(multipartConverter.getPartConverters()).hasExactlyElementsOfTypes(
ByteArrayHttpMessageConverter.class, StringHttpMessageConverter.class,
@ -282,7 +292,7 @@ class DefaultHttpMessageConvertersTests { @@ -282,7 +292,7 @@ class DefaultHttpMessageConvertersTests {
void registerCustomMessageConverter() {
var converters = HttpMessageConverters.forServer()
.addCustomConverter(new CustomHttpMessageConverter()).build();
assertThat(converters).hasExactlyElementsOfTypes(CustomHttpMessageConverter.class, AllEncompassingFormHttpMessageConverter.class);
assertThat(converters).hasExactlyElementsOfTypes(CustomHttpMessageConverter.class, MultipartHttpMessageConverter.class);
}
@Test
@ -292,8 +302,9 @@ class DefaultHttpMessageConvertersTests { @@ -292,8 +302,9 @@ class DefaultHttpMessageConvertersTests {
assertThat(converters).hasExactlyElementsOfTypes(
CustomHttpMessageConverter.class,
ByteArrayHttpMessageConverter.class, StringHttpMessageConverter.class,
ResourceHttpMessageConverter.class, ResourceRegionHttpMessageConverter.class,
AllEncompassingFormHttpMessageConverter.class, KotlinSerializationJsonHttpMessageConverter.class,
FormHttpMessageConverter.class, ResourceHttpMessageConverter.class,
ResourceRegionHttpMessageConverter.class, MultipartHttpMessageConverter.class,
KotlinSerializationJsonHttpMessageConverter.class,
JacksonJsonHttpMessageConverter.class, JacksonSmileHttpMessageConverter.class,
KotlinSerializationCborHttpMessageConverter.class, JacksonCborHttpMessageConverter.class,
JacksonYamlHttpMessageConverter.class, JacksonXmlHttpMessageConverter.class,
@ -305,7 +316,7 @@ class DefaultHttpMessageConvertersTests { @@ -305,7 +316,7 @@ class DefaultHttpMessageConvertersTests {
void registerCustomConverterInMultipartConverter() {
var converters = HttpMessageConverters.forServer().registerDefaults()
.addCustomConverter(new CustomHttpMessageConverter()).build();
var multipartConverter = findMessageConverter(AllEncompassingFormHttpMessageConverter.class, converters);
var multipartConverter = findMessageConverter(MultipartHttpMessageConverter.class, converters);
assertThat(multipartConverter.getPartConverters()).hasAtLeastOneElementOfType(CustomHttpMessageConverter.class);
}

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

@ -16,33 +16,14 @@ @@ -16,33 +16,14 @@
package org.springframework.http.converter;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.xml.transform.Source;
import javax.xml.transform.stream.StreamSource;
import org.apache.tomcat.util.http.fileupload.FileItem;
import org.apache.tomcat.util.http.fileupload.FileUpload;
import org.apache.tomcat.util.http.fileupload.RequestContext;
import org.apache.tomcat.util.http.fileupload.UploadContext;
import org.apache.tomcat.util.http.fileupload.disk.DiskFileItemFactory;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.core.ResolvableType;
import org.springframework.http.MediaType;
import org.springframework.http.StreamingHttpOutputMessage;
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
import org.springframework.http.converter.xml.SourceHttpMessageConverter;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.testfixture.http.MockHttpInputMessage;
@ -52,15 +33,12 @@ import static java.nio.charset.StandardCharsets.UTF_8; @@ -52,15 +33,12 @@ import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.http.MediaType.MULTIPART_FORM_DATA;
import static org.springframework.http.MediaType.MULTIPART_MIXED;
import static org.springframework.http.MediaType.MULTIPART_RELATED;
import static org.springframework.http.MediaType.TEXT_XML;
/**
* Tests for {@link FormHttpMessageConverter} and
* {@link AllEncompassingFormHttpMessageConverter}.
* Tests for {@link FormHttpMessageConverter}.
*
* @author Arjen Poutsma
* @author Rossen Stoyanchev
@ -69,16 +47,25 @@ import static org.springframework.http.MediaType.TEXT_XML; @@ -69,16 +47,25 @@ import static org.springframework.http.MediaType.TEXT_XML;
*/
class FormHttpMessageConverterTests {
private final FormHttpMessageConverter converter = new AllEncompassingFormHttpMessageConverter();
private static final ResolvableType LINKED_MULTI_VALUE_MAP =
ResolvableType.forClassWithGenerics(LinkedMultiValueMap.class, String.class, String.class);
private static final ResolvableType MULTI_VALUE_MAP =
ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class);
private final FormHttpMessageConverter converter = new FormHttpMessageConverter();
@Test
void canRead() {
assertCanRead(MultiValueMap.class, null);
assertCanRead(APPLICATION_FORM_URLENCODED);
assertCanRead(LINKED_MULTI_VALUE_MAP, APPLICATION_FORM_URLENCODED);
assertCanRead(ResolvableType.forClass(LinkedMultiValueMap.class), APPLICATION_FORM_URLENCODED);
assertCannotRead(String.class, null);
assertCannotRead(String.class, APPLICATION_FORM_URLENCODED);
ResolvableType mapStringObject = ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class);
assertCannotRead(mapStringObject, null);
assertCannotRead(mapStringObject, APPLICATION_FORM_URLENCODED);
}
@Test
@ -93,30 +80,18 @@ class FormHttpMessageConverterTests { @@ -93,30 +80,18 @@ class FormHttpMessageConverterTests {
@Test
void canWrite() {
assertCanWrite(APPLICATION_FORM_URLENCODED);
assertCanWrite(MULTIPART_FORM_DATA);
assertCanWrite(MULTIPART_MIXED);
assertCanWrite(MULTIPART_RELATED);
assertCanWrite(new MediaType("multipart", "form-data", UTF_8));
assertCanWrite(MediaType.ALL);
assertCanWrite(null);
}
@Test
void setSupportedMediaTypes() {
this.converter.setSupportedMediaTypes(List.of(MULTIPART_FORM_DATA));
assertCannotWrite(MULTIPART_MIXED);
this.converter.setSupportedMediaTypes(List.of(MULTIPART_MIXED));
assertCanWrite(MULTIPART_MIXED);
assertCanWrite(LINKED_MULTI_VALUE_MAP, APPLICATION_FORM_URLENCODED);
assertCanWrite(ResolvableType.forClass(LinkedMultiValueMap.class), APPLICATION_FORM_URLENCODED);
}
@Test
void addSupportedMediaTypes() {
this.converter.setSupportedMediaTypes(List.of(MULTIPART_FORM_DATA));
void cannotWriteMultipart() {
assertCannotWrite(MULTIPART_FORM_DATA);
assertCannotWrite(MULTIPART_MIXED);
this.converter.addSupportedMediaTypes(MULTIPART_RELATED);
assertCanWrite(MULTIPART_RELATED);
assertCannotWrite(MULTIPART_RELATED);
assertCannotWrite(new MediaType("multipart", "form-data", UTF_8));
assertCannotWrite(null);
}
@Test
@ -173,243 +148,12 @@ class FormHttpMessageConverterTests { @@ -173,243 +148,12 @@ class FormHttpMessageConverterTests {
.as("Invalid content-length").isEqualTo(outputMessage.getBodyAsBytes().length);
}
@Test
void writeMultipart() throws Exception {
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");
parts.add("name 3", null);
Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg");
parts.add("logo", logo);
// SPR-12108
Resource utf8 = new ClassPathResource("/org/springframework/http/converter/logo.jpg") {
@Override
public String getFilename() {
return "Hall\u00F6le.jpg";
}
};
parts.add("utf8", utf8);
MyBean myBean = new MyBean();
myBean.setString("foo");
HttpHeaders entityHeaders = new HttpHeaders();
entityHeaders.setContentType(APPLICATION_JSON);
HttpEntity<MyBean> entity = new HttpEntity<>(myBean, entityHeaders);
parts.add("json", entity);
Map<String, String> parameters = new LinkedHashMap<>(2);
parameters.put("charset", UTF_8.name());
parameters.put("foo", "bar");
StreamingMockHttpOutputMessage outputMessage = new StreamingMockHttpOutputMessage();
this.converter.write(parts, new MediaType("multipart", "form-data", parameters), outputMessage);
final MediaType contentType = outputMessage.getHeaders().getContentType();
assertThat(contentType.getParameters()).containsKeys("charset", "boundary", "foo"); // gh-21568, gh-25839
// see if Commons FileUpload can read what we wrote
FileUpload fileUpload = new FileUpload();
fileUpload.setFileItemFactory(new DiskFileItemFactory());
RequestContext requestContext = new MockHttpOutputMessageRequestContext(outputMessage);
List<FileItem> items = fileUpload.parseRequest(requestContext);
assertThat(items).hasSize(6);
FileItem item = items.get(0);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("name 1");
assertThat(item.getString()).isEqualTo("value 1");
item = items.get(1);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("name 2");
assertThat(item.getString()).isEqualTo("value 2+1");
item = items.get(2);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("name 2");
assertThat(item.getString()).isEqualTo("value 2+2");
item = items.get(3);
assertThat(item.isFormField()).isFalse();
assertThat(item.getFieldName()).isEqualTo("logo");
assertThat(item.getName()).isEqualTo("logo.jpg");
assertThat(item.getContentType()).isEqualTo("image/jpeg");
assertThat(item.getSize()).isEqualTo(logo.getFile().length());
item = items.get(4);
assertThat(item.isFormField()).isFalse();
assertThat(item.getFieldName()).isEqualTo("utf8");
assertThat(item.getName()).isEqualTo("Hall\u00F6le.jpg");
assertThat(item.getContentType()).isEqualTo("image/jpeg");
assertThat(item.getSize()).isEqualTo(logo.getFile().length());
item = items.get(5);
assertThat(item.getFieldName()).isEqualTo("json");
assertThat(item.getContentType()).isEqualTo("application/json");
assertThat(outputMessage.wasRepeatable()).isTrue();
}
@Test
void writeMultipartWithSourceHttpMessageConverter() throws Exception {
converter.setPartConverters(List.of(
new StringHttpMessageConverter(),
new ResourceHttpMessageConverter(),
new SourceHttpMessageConverter<>()));
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");
parts.add("name 3", null);
Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg");
parts.add("logo", logo);
// SPR-12108
Resource utf8 = new ClassPathResource("/org/springframework/http/converter/logo.jpg") {
@Override
public String getFilename() {
return "Hall\u00F6le.jpg";
}
};
parts.add("utf8", utf8);
Source xml = new StreamSource(new StringReader("<root><child/></root>"));
HttpHeaders entityHeaders = new HttpHeaders();
entityHeaders.setContentType(TEXT_XML);
HttpEntity<Source> entity = new HttpEntity<>(xml, entityHeaders);
parts.add("xml", entity);
Map<String, String> parameters = new LinkedHashMap<>(2);
parameters.put("charset", UTF_8.name());
parameters.put("foo", "bar");
StreamingMockHttpOutputMessage outputMessage = new StreamingMockHttpOutputMessage();
this.converter.write(parts, new MediaType("multipart", "form-data", parameters), outputMessage);
final MediaType contentType = outputMessage.getHeaders().getContentType();
assertThat(contentType.getParameters()).containsKeys("charset", "boundary", "foo"); // gh-21568, gh-25839
// see if Commons FileUpload can read what we wrote
FileUpload fileUpload = new FileUpload();
fileUpload.setFileItemFactory(new DiskFileItemFactory());
RequestContext requestContext = new MockHttpOutputMessageRequestContext(outputMessage);
List<FileItem> items = fileUpload.parseRequest(requestContext);
assertThat(items).hasSize(6);
FileItem item = items.get(0);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("name 1");
assertThat(item.getString()).isEqualTo("value 1");
item = items.get(1);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("name 2");
assertThat(item.getString()).isEqualTo("value 2+1");
item = items.get(2);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("name 2");
assertThat(item.getString()).isEqualTo("value 2+2");
item = items.get(3);
assertThat(item.isFormField()).isFalse();
assertThat(item.getFieldName()).isEqualTo("logo");
assertThat(item.getName()).isEqualTo("logo.jpg");
assertThat(item.getContentType()).isEqualTo("image/jpeg");
assertThat(item.getSize()).isEqualTo(logo.getFile().length());
item = items.get(4);
assertThat(item.isFormField()).isFalse();
assertThat(item.getFieldName()).isEqualTo("utf8");
assertThat(item.getName()).isEqualTo("Hall\u00F6le.jpg");
assertThat(item.getContentType()).isEqualTo("image/jpeg");
assertThat(item.getSize()).isEqualTo(logo.getFile().length());
item = items.get(5);
assertThat(item.getFieldName()).isEqualTo("xml");
assertThat(item.getContentType()).isEqualTo("text/xml");
assertThat(outputMessage.wasRepeatable()).isFalse();
}
@Test // SPR-13309
void writeMultipartOrder() throws Exception {
MyBean myBean = new MyBean();
myBean.setString("foo");
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
parts.add("part1", myBean);
HttpHeaders entityHeaders = new HttpHeaders();
entityHeaders.setContentType(TEXT_XML);
HttpEntity<MyBean> entity = new HttpEntity<>(myBean, entityHeaders);
parts.add("part2", entity);
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
this.converter.setMultipartCharset(UTF_8);
this.converter.write(parts, new MediaType("multipart", "form-data", UTF_8), outputMessage);
final MediaType contentType = outputMessage.getHeaders().getContentType();
assertThat(contentType.getParameter("boundary")).as("No boundary found").isNotNull();
// see if Commons FileUpload can read what we wrote
FileUpload fileUpload = new FileUpload();
fileUpload.setFileItemFactory(new DiskFileItemFactory());
RequestContext requestContext = new MockHttpOutputMessageRequestContext(outputMessage);
List<FileItem> items = fileUpload.parseRequest(requestContext);
assertThat(items).hasSize(2);
FileItem item = items.get(0);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("part1");
assertThat(item.getString()).isEqualTo("{\"string\":\"foo\"}");
item = items.get(1);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("part2");
// With developer builds we get: <MyBean><string>foo</string></MyBean>
// But on CI server we get: <MyBean xmlns=""><string>foo</string></MyBean>
// So... we make a compromise:
assertThat(item.getString())
.startsWith("<MyBean")
.endsWith("><string>foo</string></MyBean>");
}
@Test
void writeMultipartCharset() throws Exception {
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg");
parts.add("logo", logo);
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
this.converter.write(parts, MULTIPART_FORM_DATA, outputMessage);
MediaType contentType = outputMessage.getHeaders().getContentType();
Map<String, String> parameters = contentType.getParameters();
assertThat(parameters).containsOnlyKeys("boundary");
this.converter.setCharset(StandardCharsets.ISO_8859_1);
outputMessage = new MockHttpOutputMessage();
this.converter.write(parts, MULTIPART_FORM_DATA, outputMessage);
parameters = outputMessage.getHeaders().getContentType().getParameters();
assertThat(parameters).containsOnlyKeys("boundary", "charset");
assertThat(parameters).containsEntry("charset", "ISO-8859-1");
}
private void assertCanRead(MediaType mediaType) {
assertCanRead(MultiValueMap.class, mediaType);
assertCanRead(MULTI_VALUE_MAP, mediaType);
}
private void assertCanRead(Class<?> clazz, MediaType mediaType) {
assertThat(this.converter.canRead(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isTrue();
private void assertCanRead(ResolvableType type, MediaType mediaType) {
assertThat(this.converter.canRead(type, mediaType)).as(type.toClass().getSimpleName() + " : " + mediaType).isTrue();
}
private void asssertCannotReadMultipart() {
@ -420,21 +164,25 @@ class FormHttpMessageConverterTests { @@ -420,21 +164,25 @@ class FormHttpMessageConverterTests {
}
private void assertCannotRead(MediaType mediaType) {
assertCannotRead(MultiValueMap.class, mediaType);
assertCannotRead(MULTI_VALUE_MAP, mediaType);
}
private void assertCannotRead(Class<?> clazz, MediaType mediaType) {
assertThat(this.converter.canRead(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isFalse();
private void assertCannotRead(ResolvableType type, MediaType mediaType) {
assertThat(this.converter.canRead(type, mediaType)).as(type + " : " + mediaType).isFalse();
}
private void assertCanWrite(ResolvableType type, MediaType mediaType) {
assertThat(this.converter.canWrite(type, LinkedMultiValueMap.class, mediaType))
.as(type + " : " + mediaType).isTrue();
}
private void assertCanWrite(MediaType mediaType) {
Class<?> clazz = MultiValueMap.class;
assertThat(this.converter.canWrite(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isTrue();
assertCanWrite(MULTI_VALUE_MAP, mediaType);
}
private void assertCannotWrite(MediaType mediaType) {
Class<?> clazz = MultiValueMap.class;
assertThat(this.converter.canWrite(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isFalse();
assertThat(this.converter.canWrite(MULTI_VALUE_MAP, MultiValueMap.class, mediaType))
.as(MultiValueMap.class.getSimpleName() + " : " + mediaType).isFalse();
}
private void assertInvalidFormIsRejectedWithSpecificException(String body) {
@ -442,80 +190,10 @@ class FormHttpMessageConverterTests { @@ -442,80 +190,10 @@ class FormHttpMessageConverterTests {
inputMessage.getHeaders().setContentType(
new MediaType("application", "x-www-form-urlencoded", StandardCharsets.ISO_8859_1));
assertThatThrownBy(() -> this.converter.read(null, inputMessage))
assertThatThrownBy(() -> this.converter.read(MULTI_VALUE_MAP, inputMessage, null))
.isInstanceOf(HttpMessageNotReadableException.class)
.hasCauseInstanceOf(IllegalArgumentException.class)
.hasMessage("Could not decode HTTP form payload");
}
private static class StreamingMockHttpOutputMessage extends MockHttpOutputMessage implements StreamingHttpOutputMessage {
private boolean repeatable;
public boolean wasRepeatable() {
return this.repeatable;
}
@Override
public void setBody(Body body) {
try {
this.repeatable = body.repeatable();
body.writeTo(getBody());
}
catch (IOException ex) {
throw new RuntimeException(ex);
}
}
}
private static class MockHttpOutputMessageRequestContext implements UploadContext {
private final MockHttpOutputMessage outputMessage;
private final byte[] body;
private MockHttpOutputMessageRequestContext(MockHttpOutputMessage outputMessage) {
this.outputMessage = outputMessage;
this.body = this.outputMessage.getBodyAsBytes();
}
@Override
public String getCharacterEncoding() {
MediaType type = this.outputMessage.getHeaders().getContentType();
return (type != null && type.getCharset() != null ? type.getCharset().name() : null);
}
@Override
public String getContentType() {
MediaType type = this.outputMessage.getHeaders().getContentType();
return (type != null ? type.toString() : null);
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(body);
}
@Override
public long contentLength() {
return body.length;
}
}
public static class MyBean {
private String string;
public String getString() {
return this.string;
}
public void setString(String string) {
this.string = string;
}
}
}

20
spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverterTests.java

@ -44,6 +44,7 @@ import org.springframework.http.HttpEntity; @@ -44,6 +44,7 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.StreamingHttpOutputMessage;
import org.springframework.http.converter.AbstractHttpMessageConverter;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConversionException;
import org.springframework.http.converter.ResourceHttpMessageConverter;
@ -123,6 +124,25 @@ class MultipartHttpMessageConverterTests { @@ -123,6 +124,25 @@ class MultipartHttpMessageConverterTests {
assertCanWrite(MULTIPART_RELATED);
}
@Test
void applyDefaultCharsetToPartConverters() {
this.converter.getPartConverters().forEach(converter -> {
if (converter instanceof AbstractHttpMessageConverter<?> abstractConverter) {
assertThat(abstractConverter.getDefaultCharset()).isIn(null, StandardCharsets.UTF_8);
}
});
}
@Test
void customCharsetAppliedToPartConverters() {
this.converter.setCharset(StandardCharsets.UTF_16);
this.converter.getPartConverters().forEach(converter -> {
if (converter instanceof AbstractHttpMessageConverter<?> abstractConverter) {
assertThat(abstractConverter.getDefaultCharset()).isIn(null, StandardCharsets.UTF_16);
}
});
}
private void assertCanRead(MediaType mediaType) {
assertCanRead(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), mediaType);

4
spring-web/src/test/java/org/springframework/web/client/RestClientBuilderTests.java

@ -33,7 +33,7 @@ import org.springframework.http.client.JettyClientHttpRequestFactory; @@ -33,7 +33,7 @@ import org.springframework.http.client.JettyClientHttpRequestFactory;
import org.springframework.http.client.support.BasicAuthenticationInterceptor;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
import org.springframework.http.converter.multipart.MultipartHttpMessageConverter;
import org.springframework.web.util.DefaultUriBuilderFactory;
import static org.assertj.core.api.Assertions.assertThat;
@ -154,7 +154,7 @@ class RestClientBuilderTests { @@ -154,7 +154,7 @@ class RestClientBuilderTests {
assertThat(fieldValue("messageConverters", restClient))
.asInstanceOf(InstanceOfAssertFactories.LIST)
.hasExactlyElementsOfTypes(StringHttpMessageConverter.class, AllEncompassingFormHttpMessageConverter.class);
.hasExactlyElementsOfTypes(StringHttpMessageConverter.class, MultipartHttpMessageConverter.class);
}
@Test

12
spring-web/src/test/java/org/springframework/web/client/RestTemplateIntegrationTests.java

@ -53,8 +53,8 @@ import org.springframework.http.client.JdkClientHttpRequestFactory; @@ -53,8 +53,8 @@ import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.http.client.JettyClientHttpRequestFactory;
import org.springframework.http.client.ReactorClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.json.MappingJacksonValue;
import org.springframework.http.converter.multipart.MultipartHttpMessageConverter;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
@ -348,23 +348,25 @@ class RestTemplateIntegrationTests extends AbstractMockWebServerTests { @@ -348,23 +348,25 @@ class RestTemplateIntegrationTests extends AbstractMockWebServerTests {
private void addSupportedMediaTypeToFormHttpMessageConverter(MediaType mediaType) {
this.template.getMessageConverters().stream()
.filter(FormHttpMessageConverter.class::isInstance)
.map(FormHttpMessageConverter.class::cast)
.filter(MultipartHttpMessageConverter.class::isInstance)
.map(MultipartHttpMessageConverter.class::cast)
.findFirst()
.orElseThrow(() -> new IllegalStateException("Failed to find FormHttpMessageConverter"))
.orElseThrow(() -> new IllegalStateException("Failed to find MultipartHttpMessageConverter"))
.addSupportedMediaTypes(mediaType);
}
@ParameterizedRestTemplateTest
void form(ClientHttpRequestFactory clientHttpRequestFactory) {
setUpClient(clientHttpRequestFactory);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("name 1", "value 1");
form.add("name 2", "value 2+1");
form.add("name 2", "value 2+2");
template.postForLocation(baseUrl + "/form", form);
template.exchange(baseUrl + "/form", POST, new HttpEntity<>(form, headers), Void.class);
}
@ParameterizedRestTemplateTest

6
spring-web/src/test/java/org/springframework/web/multipart/support/StandardMultipartHttpServletRequestTests.java

@ -24,7 +24,8 @@ import jakarta.servlet.ServletException; @@ -24,7 +24,8 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.Part;
import org.junit.jupiter.api.Test;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.multipart.MultipartHttpMessageConverter;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
@ -87,7 +88,7 @@ class StandardMultipartHttpServletRequestTests { @@ -87,7 +88,7 @@ class StandardMultipartHttpServletRequestTests {
map.add(name, multipartFile.getResource());
MockHttpOutputMessage output = new MockHttpOutputMessage();
new FormHttpMessageConverter().write(map, null, output);
new MultipartHttpMessageConverter().write(map, null, output);
assertThat(output.getBodyAsString(StandardCharsets.UTF_8)).contains("""
Content-Disposition: form-data; name="file"; filename="myFile.txt"
@ -166,6 +167,7 @@ class StandardMultipartHttpServletRequestTests { @@ -166,6 +167,7 @@ class StandardMultipartHttpServletRequestTests {
private static StandardMultipartHttpServletRequest requestWithPart(String name, String disposition, String content) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContentType(MediaType.MULTIPART_FORM_DATA_VALUE);
MockPart part = new MockPart(name, null, content.getBytes(StandardCharsets.UTF_8));
part.getHeaders().set("Content-Disposition", disposition);
request.addPart(part);

8
spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolver.java

@ -36,9 +36,11 @@ import org.springframework.http.HttpHeaders; @@ -36,9 +36,11 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverters;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
import org.springframework.http.converter.multipart.MultipartHttpMessageConverter;
import org.springframework.ui.ModelMap;
import org.springframework.web.ErrorResponse;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
@ -293,7 +295,9 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce @@ -293,7 +295,9 @@ public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExce
}
this.messageConverters.add(new ByteArrayHttpMessageConverter());
this.messageConverters.add(new StringHttpMessageConverter());
this.messageConverters.add(new AllEncompassingFormHttpMessageConverter());
this.messageConverters.add(new FormHttpMessageConverter());
this.messageConverters.add(new MultipartHttpMessageConverter(HttpMessageConverters.forServer()
.registerDefaults().build()));
}
private void initExceptionHandlerAdviceCache() {

8
spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java

@ -51,9 +51,11 @@ import org.springframework.core.log.LogFormatUtils; @@ -51,9 +51,11 @@ import org.springframework.core.log.LogFormatUtils;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverters;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
import org.springframework.http.converter.multipart.MultipartHttpMessageConverter;
import org.springframework.ui.ModelMap;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
@ -579,7 +581,9 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter @@ -579,7 +581,9 @@ public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter
this.messageConverters.add(new ByteArrayHttpMessageConverter());
this.messageConverters.add(new StringHttpMessageConverter());
this.messageConverters.add(new AllEncompassingFormHttpMessageConverter());
this.messageConverters.add(new FormHttpMessageConverter());
this.messageConverters.add(new MultipartHttpMessageConverter(HttpMessageConverters.forServer()
.registerDefaults().build()));
}
private void initControllerAdviceCache() {

4
spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportExtensionTests.java

@ -39,7 +39,7 @@ import org.springframework.http.converter.HttpMessageConverter; @@ -39,7 +39,7 @@ import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverters;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
import org.springframework.http.converter.multipart.MultipartHttpMessageConverter;
import org.springframework.scheduling.concurrent.ConcurrentTaskExecutor;
import org.springframework.stereotype.Controller;
import org.springframework.util.AntPathMatcher;
@ -213,7 +213,7 @@ class WebMvcConfigurationSupportExtensionTests { @@ -213,7 +213,7 @@ class WebMvcConfigurationSupportExtensionTests {
assertThat(converters).hasSize(3);
assertThat(converters.get(0).getClass()).isEqualTo(StringHttpMessageConverter.class);
assertThat(converters.get(1).getClass()).isEqualTo(JacksonJsonHttpMessageConverter.class);
assertThat(converters.get(2).getClass()).isEqualTo(AllEncompassingFormHttpMessageConverter.class);
assertThat(converters.get(2).getClass()).isEqualTo(MultipartHttpMessageConverter.class);
JsonMapper jsonMapper = ((JacksonJsonHttpMessageConverter) converters.get(1)).getMapper();
assertThat(jsonMapper.deserializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION)).isFalse();
assertThat(jsonMapper.deserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse();

5
spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestPartIntegrationTests.java

@ -51,7 +51,7 @@ import org.springframework.http.converter.ByteArrayHttpMessageConverter; @@ -51,7 +51,7 @@ import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.ResourceHttpMessageConverter;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
import org.springframework.http.converter.multipart.MultipartHttpMessageConverter;
import org.springframework.stereotype.Controller;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.LinkedMultiValueMap;
@ -135,8 +135,7 @@ class RequestPartIntegrationTests { @@ -135,8 +135,7 @@ class RequestPartIntegrationTests {
converters.add(new ResourceHttpMessageConverter());
converters.add(new JacksonJsonHttpMessageConverter());
AllEncompassingFormHttpMessageConverter converter = new AllEncompassingFormHttpMessageConverter();
converter.setPartConverters(converters);
MultipartHttpMessageConverter converter = new MultipartHttpMessageConverter(converters);
restTemplate = new RestTemplate(new HttpComponentsClientHttpRequestFactory());
restTemplate.setMessageConverters(Collections.singletonList(converter));

4
spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorTests.java

@ -49,13 +49,13 @@ import org.springframework.http.MediaType; @@ -49,13 +49,13 @@ import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.ResourceHttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
import org.springframework.http.converter.xml.JacksonXmlHttpMessageConverter;
import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter;
import org.springframework.util.MultiValueMap;
@ -135,7 +135,7 @@ class RequestResponseBodyMethodProcessorTests { @@ -135,7 +135,7 @@ class RequestResponseBodyMethodProcessorTests {
this.servletRequest.setContent(content.getBytes(StandardCharsets.UTF_8));
this.servletRequest.setContentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE);
List<HttpMessageConverter<?>> converters = List.of(new AllEncompassingFormHttpMessageConverter());
List<HttpMessageConverter<?>> converters = List.of(new FormHttpMessageConverter());
RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters);
@SuppressWarnings("unchecked")

Loading…
Cancel
Save