Skip to content

Commit 80faa94

Browse files
committed
Support Map in FormHttpMessageConverter
This commit changes the FormHttpMessageConverter from a HttpMessageConverter<MultiValueMap<String, ?>> to a HttpMessageConverter<Map<String, ?>>, so that normal, single-value maps can also be used as form representation, both for reading and writing. Closes gh-32826
1 parent 67d2b25 commit 80faa94

File tree

5 files changed

+226
-68
lines changed

5 files changed

+226
-68
lines changed

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,13 @@ public RequestMatcher formDataContains(Map<String, String> expected) {
174174
return formData(multiValueMap, false);
175175
}
176176

177+
@SuppressWarnings("unchecked")
177178
private RequestMatcher formData(MultiValueMap<String, String> expectedMap, boolean containsExactly) {
178179
return request -> {
179180
MockClientHttpRequest mockRequest = (MockClientHttpRequest) request;
180181
MockHttpInputMessage message = new MockHttpInputMessage(mockRequest.getBodyAsBytes());
181182
message.getHeaders().putAll(mockRequest.getHeaders());
182-
MultiValueMap<String, String> actualMap = new FormHttpMessageConverter().read(null, message);
183+
MultiValueMap<String, String> actualMap = (MultiValueMap<String, String>) new FormHttpMessageConverter().read(null, message);
183184
if (containsExactly) {
184185
assertEquals("Form data", expectedMap, actualMap);
185186
}

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,7 @@ public HttpHeaders getHeaders() {
899899
}
900900
}
901901

902+
@SuppressWarnings("unchecked")
902903
private MultiValueMap<String, String> parseFormData(MediaType mediaType) {
903904
HttpInputMessage message = new HttpInputMessage() {
904905
@Override
@@ -914,7 +915,7 @@ public HttpHeaders getHeaders() {
914915
};
915916

916917
try {
917-
return new FormHttpMessageConverter().read(null, message);
918+
return (MultiValueMap<String, String>) new FormHttpMessageConverter().read(null, message);
918919
}
919920
catch (IOException ex) {
920921
throw new IllegalStateException("Failed to parse form data in request body", ex);

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

+105-58
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.util.LinkedHashMap;
2929
import java.util.List;
3030
import java.util.Map;
31+
import java.util.function.BiConsumer;
3132

3233
import org.springframework.core.io.Resource;
3334
import org.springframework.http.ContentDisposition;
@@ -52,9 +53,10 @@
5253
*
5354
* <p>In other words, this converter can read and write the
5455
* {@code "application/x-www-form-urlencoded"} media type as
56+
* {@code Map<String, String>} or as
5557
* {@link MultiValueMap MultiValueMap&lt;String, String&gt;}, and it can also
5658
* write (but not read) the {@code "multipart/form-data"} and
57-
* {@code "multipart/mixed"} media types as
59+
* {@code "multipart/mixed"} media types as {@code Map<String, Object>} or as
5860
* {@link MultiValueMap MultiValueMap&lt;String, Object&gt;}.
5961
*
6062
* <h3>Multipart Data</h3>
@@ -81,7 +83,7 @@
8183
* {@code "multipart/form-data"} content type.
8284
*
8385
* <pre class="code">
84-
* RestTemplate restTemplate = new RestTemplate();
86+
* RestClient restClient = RestClient.create();
8587
* // AllEncompassingFormHttpMessageConverter is configured by default
8688
*
8789
* MultiValueMap&lt;String, Object&gt; form = new LinkedMultiValueMap&lt;&gt;();
@@ -90,7 +92,12 @@
9092
* form.add("field 2", "value 3");
9193
* form.add("field 3", 4); // non-String form values supported as of 5.1.4
9294
*
93-
* restTemplate.postForLocation("https://example.com/myForm", form);</pre>
95+
* ResponseEntity&lt;Void&gt; response = restClient.post()
96+
* .uri("https://example.com/myForm")
97+
* .contentType(MULTIPART_FORM_DATA)
98+
* .body(form)
99+
* .retrieve()
100+
* .toBodilessEntity();</pre>
94101
*
95102
* <p>The following snippet shows how to do a file upload using the
96103
* {@code "multipart/form-data"} content type.
@@ -100,7 +107,12 @@
100107
* parts.add("field 1", "value 1");
101108
* parts.add("file", new ClassPathResource("myFile.jpg"));
102109
*
103-
* restTemplate.postForLocation("https://example.com/myFileUpload", parts);</pre>
110+
* ResponseEntity&lt;Void&gt; response = restClient.post()
111+
* .uri("https://example.com/myForm")
112+
* .contentType(MULTIPART_FORM_DATA)
113+
* .body(parts)
114+
* .retrieve()
115+
* .toBodilessEntity();</pre>
104116
*
105117
* <p>The following snippet shows how to do a file upload using the
106118
* {@code "multipart/mixed"} content type.
@@ -110,40 +122,45 @@
110122
* parts.add("field 1", "value 1");
111123
* parts.add("file", new ClassPathResource("myFile.jpg"));
112124
*
113-
* HttpHeaders requestHeaders = new HttpHeaders();
114-
* requestHeaders.setContentType(MediaType.MULTIPART_MIXED);
115-
*
116-
* restTemplate.postForLocation("https://example.com/myFileUpload",
117-
* new HttpEntity&lt;&gt;(parts, requestHeaders));</pre>
125+
* ResponseEntity&lt;Void&gt; response = restClient.post()
126+
* .uri("https://example.com/myForm")
127+
* .contentType(MULTIPART_MIXED)
128+
* .body(form)
129+
* .retrieve()
130+
* .toBodilessEntity();</pre>
118131
*
119132
* <p>The following snippet shows how to do a file upload using the
120133
* {@code "multipart/related"} content type.
121134
*
122135
* <pre class="code">
123-
* MediaType multipartRelated = new MediaType("multipart", "related");
124-
*
125-
* restTemplate.getMessageConverters().stream()
126-
* .filter(FormHttpMessageConverter.class::isInstance)
136+
* restClient = restClient.mutate()
137+
* .messageConverters(l -> l.stream()
138+
* .filter(FormHttpMessageConverter.class::isInstance)
127139
* .map(FormHttpMessageConverter.class::cast)
128140
* .findFirst()
129141
* .orElseThrow(() -&gt; new IllegalStateException("Failed to find FormHttpMessageConverter"))
130-
* .addSupportedMediaTypes(multipartRelated);
142+
* .addSupportedMediaTypes(MULTIPART_RELATED);
131143
*
132144
* MultiValueMap&lt;String, Object&gt; parts = new LinkedMultiValueMap&lt;&gt;();
133145
* parts.add("field 1", "value 1");
134146
* parts.add("file", new ClassPathResource("myFile.jpg"));
135147
*
136-
* HttpHeaders requestHeaders = new HttpHeaders();
137-
* requestHeaders.setContentType(multipartRelated);
138-
*
139-
* restTemplate.postForLocation("https://example.com/myFileUpload",
140-
* new HttpEntity&lt;&gt;(parts, requestHeaders));</pre>
148+
* ResponseEntity&lt;Void&gt; response = restClient.post()
149+
* .uri("https://example.com/myForm")
150+
* .contentType(MULTIPART_RELATED)
151+
* .body(form)
152+
* .retrieve()
153+
* .toBodilessEntity();</pre>
141154
*
142155
* <h3>Miscellaneous</h3>
143156
*
144157
* <p>Some methods in this class were inspired by
145158
* {@code org.apache.commons.httpclient.methods.multipart.MultipartRequestEntity}.
146159
*
160+
* <p>As of 6.2, the {@code FormHttpMessageConverter} is parameterized over
161+
* {@code Map<String, ?>}, whereas before it was {@code MultiValueMap<String, ?>},
162+
* in order to support single-value maps.
163+
*
147164
* @author Arjen Poutsma
148165
* @author Rossen Stoyanchev
149166
* @author Juergen Hoeller
@@ -152,7 +169,7 @@
152169
* @see org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter
153170
* @see org.springframework.util.MultiValueMap
154171
*/
155-
public class FormHttpMessageConverter implements HttpMessageConverter<MultiValueMap<String, ?>> {
172+
public class FormHttpMessageConverter implements HttpMessageConverter<Map<String, ?>> {
156173

157174
/** The default charset used by the converter. */
158175
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
@@ -295,7 +312,7 @@ public void setMultipartCharset(Charset charset) {
295312

296313
@Override
297314
public boolean canRead(Class<?> clazz, @Nullable MediaType mediaType) {
298-
if (!MultiValueMap.class.isAssignableFrom(clazz)) {
315+
if (!Map.class.isAssignableFrom(clazz)) {
299316
return false;
300317
}
301318
if (mediaType == null) {
@@ -315,7 +332,7 @@ public boolean canRead(Class<?> clazz, @Nullable MediaType mediaType) {
315332

316333
@Override
317334
public boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType) {
318-
if (!MultiValueMap.class.isAssignableFrom(clazz)) {
335+
if (!Map.class.isAssignableFrom(clazz)) {
319336
return false;
320337
}
321338
if (mediaType == null || MediaType.ALL.equals(mediaType)) {
@@ -330,59 +347,75 @@ public boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType) {
330347
}
331348

332349
@Override
333-
public MultiValueMap<String, String> read(@Nullable Class<? extends MultiValueMap<String, ?>> clazz,
350+
public Map<String, ?> read(@Nullable Class<? extends Map<String, ?>> clazz,
334351
HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
335352

336353
MediaType contentType = inputMessage.getHeaders().getContentType();
337354
Charset charset = (contentType != null && contentType.getCharset() != null ?
338355
contentType.getCharset() : this.charset);
339356
String body = StreamUtils.copyToString(inputMessage.getBody(), charset);
340-
341357
String[] pairs = StringUtils.tokenizeToStringArray(body, "&");
342-
MultiValueMap<String, String> result = new LinkedMultiValueMap<>(pairs.length);
358+
359+
if (clazz == null || MultiValueMap.class.isAssignableFrom(clazz)) {
360+
MultiValueMap<String, String> result = new LinkedMultiValueMap<>(pairs.length);
361+
readToMap(pairs, charset, result::add);
362+
return result;
363+
}
364+
else {
365+
Map<String, String> result = CollectionUtils.newLinkedHashMap(pairs.length);
366+
readToMap(pairs, charset, result::putIfAbsent);
367+
return result;
368+
}
369+
}
370+
371+
private static void readToMap(String[] pairs, Charset charset, BiConsumer<String, String> addFunction) {
343372
for (String pair : pairs) {
344373
int idx = pair.indexOf('=');
345374
if (idx == -1) {
346-
result.add(URLDecoder.decode(pair, charset), null);
375+
addFunction.accept(URLDecoder.decode(pair, charset), null);
347376
}
348377
else {
349378
String name = URLDecoder.decode(pair.substring(0, idx), charset);
350379
String value = URLDecoder.decode(pair.substring(idx + 1), charset);
351-
result.add(name, value);
380+
addFunction.accept(name, value);
352381
}
353382
}
354-
return result;
355383
}
356384

357385
@Override
358386
@SuppressWarnings("unchecked")
359-
public void write(MultiValueMap<String, ?> map, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
387+
public void write(Map<String, ?> map, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
360388
throws IOException, HttpMessageNotWritableException {
361389

362390
if (isMultipart(map, contentType)) {
363-
writeMultipart((MultiValueMap<String, Object>) map, contentType, outputMessage);
391+
writeMultipart((Map<String, Object>) map, contentType, outputMessage);
364392
}
365393
else {
366-
writeForm((MultiValueMap<String, Object>) map, contentType, outputMessage);
394+
writeForm((Map<String, Object>) map, contentType, outputMessage);
367395
}
368396
}
369397

370398

371-
private boolean isMultipart(MultiValueMap<String, ?> map, @Nullable MediaType contentType) {
399+
private boolean isMultipart(Map<String, ?> map, @Nullable MediaType contentType) {
372400
if (contentType != null) {
373401
return contentType.getType().equalsIgnoreCase("multipart");
374402
}
375-
for (List<?> values : map.values()) {
376-
for (Object value : values) {
377-
if (value != null && !(value instanceof String)) {
378-
return true;
403+
for (Object value : map.values()) {
404+
if (value instanceof List<?> values) {
405+
for (Object v : values) {
406+
if (v != null && !(v instanceof String)) {
407+
return true;
408+
}
379409
}
380410
}
411+
else if (value != null && !(value instanceof String)) {
412+
return true;
413+
}
381414
}
382415
return false;
383416
}
384417

385-
private void writeForm(MultiValueMap<String, Object> formData, @Nullable MediaType mediaType,
418+
private void writeForm(Map<String, Object> formData, @Nullable MediaType mediaType,
386419
HttpOutputMessage outputMessage) throws IOException {
387420

388421
mediaType = getFormContentType(mediaType);
@@ -430,30 +463,36 @@ protected MediaType getFormContentType(@Nullable MediaType contentType) {
430463
return contentType;
431464
}
432465

433-
protected String serializeForm(MultiValueMap<String, Object> formData, Charset charset) {
466+
protected String serializeForm(Map<String, Object> formData, Charset charset) {
434467
StringBuilder builder = new StringBuilder();
435-
formData.forEach((name, values) -> {
468+
formData.forEach((name, value) -> {
469+
if (value instanceof List<?> values) {
436470
if (name == null) {
437471
Assert.isTrue(CollectionUtils.isEmpty(values), () -> "Null name in form data: " + formData);
438472
return;
439473
}
440-
values.forEach(value -> {
441-
if (builder.length() != 0) {
442-
builder.append('&');
443-
}
444-
builder.append(URLEncoder.encode(name, charset));
445-
if (value != null) {
446-
builder.append('=');
447-
builder.append(URLEncoder.encode(String.valueOf(value), charset));
448-
}
449-
});
474+
values.forEach(v -> appendFormValue(builder, name, v, charset));
475+
}
476+
else {
477+
appendFormValue(builder, name, value, charset);
478+
}
450479
});
451-
452480
return builder.toString();
453481
}
454482

483+
private static void appendFormValue(StringBuilder builder, String name, @Nullable Object value, Charset charset) {
484+
if (!builder.isEmpty()) {
485+
builder.append('&');
486+
}
487+
builder.append(URLEncoder.encode(name, charset));
488+
if (value != null) {
489+
builder.append('=');
490+
builder.append(URLEncoder.encode(String.valueOf(value), charset));
491+
}
492+
}
493+
455494
private void writeMultipart(
456-
MultiValueMap<String, Object> parts, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
495+
Map<String, Object> parts, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
457496
throws IOException {
458497

459498
// If the supplied content type is null, fall back to multipart/form-data.
@@ -500,16 +539,24 @@ private boolean isFilenameCharsetSet() {
500539
return (this.multipartCharset != null);
501540
}
502541

503-
private void writeParts(OutputStream os, MultiValueMap<String, Object> parts, byte[] boundary) throws IOException {
504-
for (Map.Entry<String, List<Object>> entry : parts.entrySet()) {
542+
private void writeParts(OutputStream os, Map<String, Object> parts, byte[] boundary) throws IOException {
543+
for (Map.Entry<String, Object> entry : parts.entrySet()) {
505544
String name = entry.getKey();
506-
for (Object part : entry.getValue()) {
507-
if (part != null) {
508-
writeBoundary(os, boundary);
509-
writePart(name, getHttpEntity(part), os);
510-
writeNewLine(os);
545+
Object value = entry.getValue();
546+
if (value instanceof List<?> values) {
547+
for (Object part : values) {
548+
if (part != null) {
549+
writeBoundary(os, boundary);
550+
writePart(name, getHttpEntity(part), os);
551+
writeNewLine(os);
552+
}
511553
}
512554
}
555+
else if (value != null) {
556+
writeBoundary(os, boundary);
557+
writePart(name, getHttpEntity(value), os);
558+
writeNewLine(os);
559+
}
513560
}
514561
}
515562

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

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2021 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -95,6 +95,7 @@ protected void doFilterInternal(
9595
}
9696

9797
@Nullable
98+
@SuppressWarnings("unchecked")
9899
private MultiValueMap<String, String> parseIfNecessary(HttpServletRequest request) throws IOException {
99100
if (!shouldParse(request)) {
100101
return null;
@@ -106,7 +107,7 @@ public InputStream getBody() throws IOException {
106107
return request.getInputStream();
107108
}
108109
};
109-
return this.formConverter.read(null, inputMessage);
110+
return (MultiValueMap<String, String>) this.formConverter.read(null, inputMessage);
110111
}
111112

112113
private boolean shouldParse(HttpServletRequest request) {

0 commit comments

Comments
 (0)