Skip to content

Commit 8083eaa

Browse files
committed
syncBody better differentiates plain vs multipart forms
FromHttpMessageWriter and MultipartHttpMessageWriter both support MultiValueMap except the former supports String values only. This presents an issue since either full generic type information must be provided, which is cumbersome on the client side, or if left out there is no good way to order the writers to make a proper decision. This commit: - refines the canWrite behavior of to not a accept MultiValueMap without proper generic information unless the MediaType is explicitly set providing a strong hint. - modifies MultipartHttpMessageWriter to be configured with a FormHttpMessageWriter so it can write both plan and multipart data with the ability to properly differentiate based on actual map values. Issue: SPR-16131
1 parent e5c8dc0 commit 8083eaa

File tree

8 files changed

+161
-65
lines changed

8 files changed

+161
-65
lines changed

spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageWriter.java

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
import java.util.Map;
2828

2929
import org.reactivestreams.Publisher;
30-
import reactor.core.publisher.Flux;
3130
import reactor.core.publisher.Mono;
3231

3332
import org.springframework.core.ResolvableType;
@@ -39,12 +38,24 @@
3938
import org.springframework.util.MultiValueMap;
4039

4140
/**
42-
* Implementation of an {@link HttpMessageWriter} to write HTML form data, i.e.
43-
* response body with media type {@code "application/x-www-form-urlencoded"}.
41+
* {@link HttpMessageWriter} for writing a {@code MultiValueMap<String, String>}
42+
* as HTML form data, i.e. {@code "application/x-www-form-urlencoded"}, to the
43+
* body of a request.
44+
*
45+
* <p>Note that unless the media type is explicitly set to
46+
* {@link MediaType#APPLICATION_FORM_URLENCODED}, the {@link #canWrite} method
47+
* will need generic type information to confirm the target map has String values.
48+
* This is because a MultiValueMap with non-String values can be used to write
49+
* multipart requests.
50+
*
51+
* <p>To support both form data and multipart requests, consider using
52+
* {@link org.springframework.http.codec.multipart.MultipartHttpMessageWriter}
53+
* configured with this writer as the fallback for writing plain form data.
4454
*
4555
* @author Sebastien Deleuze
4656
* @author Rossen Stoyanchev
4757
* @since 5.0
58+
* @see org.springframework.http.codec.multipart.MultipartHttpMessageWriter
4859
*/
4960
public class FormHttpMessageWriter implements HttpMessageWriter<MultiValueMap<String, String>> {
5061

@@ -53,6 +64,9 @@ public class FormHttpMessageWriter implements HttpMessageWriter<MultiValueMap<St
5364
private static final ResolvableType MULTIVALUE_TYPE =
5465
ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class);
5566

67+
private static final List<MediaType> MEDIA_TYPES =
68+
Collections.singletonList(MediaType.APPLICATION_FORM_URLENCODED);
69+
5670

5771
private Charset defaultCharset = DEFAULT_CHARSET;
5872

@@ -75,12 +89,27 @@ public Charset getDefaultCharset() {
7589
}
7690

7791

92+
@Override
93+
public List<MediaType> getWritableMediaTypes() {
94+
return MEDIA_TYPES;
95+
}
96+
97+
7898
@Override
7999
public boolean canWrite(ResolvableType elementType, @Nullable MediaType mediaType) {
80-
return (MULTIVALUE_TYPE.isAssignableFrom(elementType) ||
81-
(elementType.hasUnresolvableGenerics() &&
82-
MultiValueMap.class.isAssignableFrom(elementType.resolve(Object.class)))) &&
83-
(mediaType == null || MediaType.APPLICATION_FORM_URLENCODED.isCompatibleWith(mediaType));
100+
Class<?> rawClass = elementType.getRawClass();
101+
if (rawClass == null || !MultiValueMap.class.isAssignableFrom(rawClass)) {
102+
return false;
103+
}
104+
if (MediaType.APPLICATION_FORM_URLENCODED.isCompatibleWith(mediaType)) {
105+
// Optimistically, any MultiValueMap with or without generics
106+
return true;
107+
}
108+
if (mediaType == null) {
109+
// Only String-based MultiValueMap
110+
return MULTIVALUE_TYPE.isAssignableFrom(elementType);
111+
}
112+
return false;
84113
}
85114

86115
@Override
@@ -96,11 +125,8 @@ public Mono<Void> write(Publisher<? extends MultiValueMap<String, String>> input
96125

97126
Charset charset = getMediaTypeCharset(contentType);
98127

99-
return Flux
100-
.from(inputStream)
101-
.single()
102-
.map(form -> generateForm(form, charset))
103-
.flatMap(value -> {
128+
return Mono.from(inputStream).flatMap(form -> {
129+
String value = serializeForm(form, charset);
104130
ByteBuffer byteBuffer = charset.encode(value);
105131
DataBuffer buffer = message.bufferFactory().wrap(byteBuffer);
106132
message.getHeaders().setContentLength(byteBuffer.remaining());
@@ -118,17 +144,20 @@ private Charset getMediaTypeCharset(@Nullable MediaType mediaType) {
118144
}
119145
}
120146

121-
private String generateForm(MultiValueMap<String, String> form, Charset charset) {
147+
private String serializeForm(MultiValueMap<String, String> form, Charset charset) {
122148
StringBuilder builder = new StringBuilder();
123149
try {
124150
for (Iterator<String> names = form.keySet().iterator(); names.hasNext();) {
125151
String name = names.next();
126-
for (Iterator<String> values = form.get(name).iterator(); values.hasNext();) {
127-
String value = values.next();
152+
for (Iterator<?> values = form.get(name).iterator(); values.hasNext();) {
153+
Object rawValue = values.next();
128154
builder.append(URLEncoder.encode(name, charset.name()));
129-
if (value != null) {
155+
if (rawValue != null) {
130156
builder.append('=');
131-
builder.append(URLEncoder.encode(value, charset.name()));
157+
Assert.isInstanceOf(String.class, rawValue,
158+
"FormHttpMessageWriter supports String values only. " +
159+
"Use MultipartHttpMessageWriter for multipart requests.");
160+
builder.append(URLEncoder.encode((String) rawValue, charset.name()));
132161
if (values.hasNext()) {
133162
builder.append('&');
134163
}
@@ -145,9 +174,4 @@ private String generateForm(MultiValueMap<String, String> form, Charset charset)
145174
return builder.toString();
146175
}
147176

148-
@Override
149-
public List<MediaType> getWritableMediaTypes() {
150-
return Collections.singletonList(MediaType.APPLICATION_FORM_URLENCODED);
151-
}
152-
153177
}

spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriter.java

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.nio.charset.Charset;
2020
import java.nio.charset.StandardCharsets;
21+
import java.util.ArrayList;
2122
import java.util.Arrays;
2223
import java.util.Collections;
2324
import java.util.HashMap;
@@ -44,6 +45,7 @@
4445
import org.springframework.http.MediaType;
4546
import org.springframework.http.ReactiveHttpOutputMessage;
4647
import org.springframework.http.codec.EncoderHttpMessageWriter;
48+
import org.springframework.http.codec.FormHttpMessageWriter;
4749
import org.springframework.http.codec.HttpMessageWriter;
4850
import org.springframework.http.codec.ResourceHttpMessageWriter;
4951
import org.springframework.lang.Nullable;
@@ -52,37 +54,82 @@
5254
import org.springframework.util.MultiValueMap;
5355

5456
/**
55-
* {@code HttpMessageWriter} for {@code "multipart/form-data"} requests.
57+
* {@link HttpMessageWriter} for writing a {@code MultiValueMap<String, ?>}
58+
* as multipart form data, i.e. {@code "multipart/form-data"}, to the body
59+
* of a request.
5660
*
57-
* <p>This writer delegates to other message writers to write the respective
58-
* parts. By default basic writers are registered for {@code String}, and
59-
* {@code Resources}. These can be overridden through the provided constructors.
61+
* <p>The serialization of individual parts is delegated to other writers.
62+
* By default only {@link String} and {@link Resource} parts are supported but
63+
* you can configure others through a constructor argument.
64+
*
65+
* <p>This writer can be configured with a {@link FormHttpMessageWriter} to
66+
* delegate to. It is the preferred way of supporting both form data and
67+
* multipart data (as opposed to registering each writer separately) so that
68+
* when the {@link MediaType} is not specified and generics are not present on
69+
* the target element type, we can inspect the values in the actual map and
70+
* decide whether to write plain form data (String values only) or otherwise.
6071
*
6172
* @author Sebastien Deleuze
6273
* @author Rossen Stoyanchev
6374
* @since 5.0
75+
* @see FormHttpMessageWriter
6476
*/
6577
public class MultipartHttpMessageWriter implements HttpMessageWriter<MultiValueMap<String, ?>> {
6678

6779
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
6880

6981

70-
private final DataBufferFactory bufferFactory = new DefaultDataBufferFactory();
71-
7282
private final List<HttpMessageWriter<?>> partWriters;
7383

84+
@Nullable
85+
private final HttpMessageWriter<MultiValueMap<String, String>> formWriter;
86+
7487
private Charset charset = DEFAULT_CHARSET;
7588

89+
private final List<MediaType> supportedMediaTypes;
7690

91+
private final DataBufferFactory bufferFactory = new DefaultDataBufferFactory();
92+
93+
94+
/**
95+
* Constructor with a default list of part writers (String and Resource).
96+
*/
7797
public MultipartHttpMessageWriter() {
78-
this.partWriters = Arrays.asList(
98+
this(Arrays.asList(
7999
new EncoderHttpMessageWriter<>(CharSequenceEncoder.textPlainOnly()),
80100
new ResourceHttpMessageWriter()
81-
);
101+
));
82102
}
83103

104+
/**
105+
* Constructor with explicit list of writers for serializing parts.
106+
*/
84107
public MultipartHttpMessageWriter(List<HttpMessageWriter<?>> partWriters) {
108+
this(partWriters, new FormHttpMessageWriter());
109+
}
110+
111+
/**
112+
* Constructor with explicit list of writers for serializing parts and a
113+
* writer for plain form data to fall back when no media type is specified
114+
* and the actual map consists of String values only.
115+
* @param partWriters the writers for serializing parts
116+
* @param formWriter the fallback writer for form data, {@code null} by default
117+
*/
118+
public MultipartHttpMessageWriter(List<HttpMessageWriter<?>> partWriters,
119+
@Nullable HttpMessageWriter<MultiValueMap<String, String>> formWriter) {
120+
85121
this.partWriters = partWriters;
122+
this.formWriter = formWriter;
123+
this.supportedMediaTypes = initMediaTypes(formWriter);
124+
}
125+
126+
private static List<MediaType> initMediaTypes(@Nullable HttpMessageWriter<?> formWriter) {
127+
List<MediaType> result = new ArrayList<>();
128+
result.add(MediaType.MULTIPART_FORM_DATA);
129+
if (formWriter != null) {
130+
result.addAll(formWriter.getWritableMediaTypes());
131+
}
132+
return Collections.unmodifiableList(result);
86133
}
87134

88135

@@ -106,34 +153,63 @@ public Charset getCharset() {
106153

107154
@Override
108155
public List<MediaType> getWritableMediaTypes() {
109-
return Collections.singletonList(MediaType.MULTIPART_FORM_DATA);
156+
return this.supportedMediaTypes;
110157
}
111158

112159
@Override
113160
public boolean canWrite(ResolvableType elementType, @Nullable MediaType mediaType) {
114161
Class<?> rawClass = elementType.getRawClass();
115-
return (rawClass != null && MultiValueMap.class.isAssignableFrom(rawClass) &&
116-
(mediaType == null || MediaType.MULTIPART_FORM_DATA.isCompatibleWith(mediaType)));
162+
return rawClass != null && MultiValueMap.class.isAssignableFrom(rawClass) &&
163+
(mediaType == null ||
164+
this.supportedMediaTypes.stream().anyMatch(m -> m.isCompatibleWith(mediaType)));
117165
}
118166

119167
@Override
120168
public Mono<Void> write(Publisher<? extends MultiValueMap<String, ?>> inputStream,
121169
ResolvableType elementType, @Nullable MediaType mediaType, ReactiveHttpOutputMessage outputMessage,
122170
Map<String, Object> hints) {
123171

172+
return Mono.from(inputStream).flatMap(map -> {
173+
if (this.formWriter == null || isMultipart(map, mediaType)) {
174+
return writeMultipart(map, outputMessage);
175+
}
176+
else {
177+
@SuppressWarnings("unchecked")
178+
MultiValueMap<String, String> formData = (MultiValueMap<String, String>) map;
179+
return this.formWriter.write(Mono.just(formData), elementType, mediaType, outputMessage, hints);
180+
}
181+
182+
});
183+
}
184+
185+
private boolean isMultipart(MultiValueMap<String, ?> map, @Nullable MediaType contentType) {
186+
if (contentType != null) {
187+
return MediaType.MULTIPART_FORM_DATA.includes(contentType);
188+
}
189+
for (String name : map.keySet()) {
190+
for (Object value : map.get(name)) {
191+
if (value != null && !(value instanceof String)) {
192+
return true;
193+
}
194+
}
195+
}
196+
return false;
197+
}
198+
199+
private Mono<Void> writeMultipart(MultiValueMap<String, ?> map, ReactiveHttpOutputMessage outputMessage) {
124200
byte[] boundary = generateMultipartBoundary();
125201

126202
Map<String, String> params = new HashMap<>(2);
127203
params.put("boundary", new String(boundary, StandardCharsets.US_ASCII));
128204
params.put("charset", getCharset().name());
205+
129206
outputMessage.getHeaders().setContentType(new MediaType(MediaType.MULTIPART_FORM_DATA, params));
130207

131-
return Mono.from(inputStream).flatMap(map -> {
132-
Flux<DataBuffer> body = Flux.fromIterable(map.entrySet())
133-
.concatMap(entry -> encodePartValues(boundary, entry.getKey(), entry.getValue()))
134-
.concatWith(Mono.just(generateLastLine(boundary)));
135-
return outputMessage.writeWith(body);
136-
});
208+
Flux<DataBuffer> body = Flux.fromIterable(map.entrySet())
209+
.concatMap(entry -> encodePartValues(boundary, entry.getKey(), entry.getValue()))
210+
.concatWith(Mono.just(generateLastLine(boundary)));
211+
212+
return outputMessage.writeWith(body);
137213
}
138214

139215
/**

spring-web/src/main/java/org/springframework/http/codec/support/DefaultClientCodecConfigurer.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,11 @@ List<HttpMessageWriter<?>> getTypedWriters() {
9999
return Collections.emptyList();
100100
}
101101
List<HttpMessageWriter<?>> result = super.getTypedWriters();
102-
result.add(new FormHttpMessageWriter());
103-
result.add(getMultipartHttpMessageWriter());
102+
result.add(new MultipartHttpMessageWriter(getPartWriters(), new FormHttpMessageWriter()));
104103
return result;
105104
}
106105

107-
private MultipartHttpMessageWriter getMultipartHttpMessageWriter() {
106+
private List<HttpMessageWriter<?>> getPartWriters() {
108107
List<HttpMessageWriter<?>> partWriters;
109108
if (this.multipartCodecs != null) {
110109
partWriters = this.multipartCodecs.getWriters();
@@ -122,7 +121,7 @@ private MultipartHttpMessageWriter getMultipartHttpMessageWriter() {
122121
}
123122
partWriters.addAll(super.getCatchAllWriters());
124123
}
125-
return new MultipartHttpMessageWriter(partWriters);
124+
return partWriters;
126125
}
127126
}
128127

spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageWriterTests.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
import org.springframework.util.LinkedMultiValueMap;
2828
import org.springframework.util.MultiValueMap;
2929

30-
import static org.junit.Assert.*;
30+
import static org.junit.Assert.assertEquals;
31+
import static org.junit.Assert.assertFalse;
32+
import static org.junit.Assert.assertTrue;
3133

3234
/**
3335
* @author Sebastien Deleuze
@@ -43,17 +45,18 @@ public void canWrite() {
4345
ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class),
4446
MediaType.APPLICATION_FORM_URLENCODED));
4547

48+
// No generic information
4649
assertTrue(this.writer.canWrite(
4750
ResolvableType.forInstance(new LinkedMultiValueMap<String, String>()),
4851
MediaType.APPLICATION_FORM_URLENCODED));
4952

5053
assertFalse(this.writer.canWrite(
5154
ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class),
52-
MediaType.APPLICATION_FORM_URLENCODED));
55+
null));
5356

5457
assertFalse(this.writer.canWrite(
5558
ResolvableType.forClassWithGenerics(MultiValueMap.class, Object.class, String.class),
56-
MediaType.APPLICATION_FORM_URLENCODED));
59+
null));
5760

5861
assertFalse(this.writer.canWrite(
5962
ResolvableType.forClassWithGenerics(Map.class, String.class, String.class),

spring-web/src/test/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriterTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public void canWrite() {
7070
assertFalse(this.writer.canWrite(
7171
ResolvableType.forClassWithGenerics(Map.class, String.class, Object.class),
7272
MediaType.MULTIPART_FORM_DATA));
73-
assertFalse(this.writer.canWrite(
73+
assertTrue(this.writer.canWrite(
7474
ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class),
7575
MediaType.APPLICATION_FORM_URLENCODED));
7676
}

0 commit comments

Comments
 (0)