From 14861024d51479ae08c1358865410bc6f98d4391 Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Thu, 16 May 2024 11:51:36 +0200 Subject: [PATCH] WIP --- .../client/match/ContentRequestMatchers.java | 3 +- .../MockHttpServletRequestBuilder.java | 3 +- .../converter/FormHttpMessageConverter.java | 120 +++++++++++++----- .../web/filter/FormContentFilter.java | 5 +- .../FormHttpMessageConverterTests.java | 20 ++- 5 files changed, 111 insertions(+), 40 deletions(-) diff --git a/spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java b/spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java index a2eb29a010..faf19a1314 100644 --- a/spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java +++ b/spring-test/src/main/java/org/springframework/test/web/client/match/ContentRequestMatchers.java @@ -174,12 +174,13 @@ public class ContentRequestMatchers { return formData(multiValueMap, false); } + @SuppressWarnings("unchecked") private RequestMatcher formData(MultiValueMap expectedMap, boolean containsExactly) { return request -> { MockClientHttpRequest mockRequest = (MockClientHttpRequest) request; MockHttpInputMessage message = new MockHttpInputMessage(mockRequest.getBodyAsBytes()); message.getHeaders().putAll(mockRequest.getHeaders()); - MultiValueMap actualMap = new FormHttpMessageConverter().read(null, message); + MultiValueMap actualMap = (MultiValueMap) new FormHttpMessageConverter().read(null, message); if (containsExactly) { assertEquals("Form data", expectedMap, actualMap); } diff --git a/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilder.java b/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilder.java index 433d78e86c..6ec52e6671 100644 --- a/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilder.java +++ b/spring-test/src/main/java/org/springframework/test/web/servlet/request/MockHttpServletRequestBuilder.java @@ -899,6 +899,7 @@ public class MockHttpServletRequestBuilder } } + @SuppressWarnings("unchecked") private MultiValueMap parseFormData(MediaType mediaType) { HttpInputMessage message = new HttpInputMessage() { @Override @@ -914,7 +915,7 @@ public class MockHttpServletRequestBuilder }; try { - return new FormHttpMessageConverter().read(null, message); + return (MultiValueMap) new FormHttpMessageConverter().read(null, message); } catch (IOException ex) { throw new IllegalStateException("Failed to parse form data in request body", ex); diff --git a/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java index 7d325cbade..7da3310c90 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java @@ -152,7 +152,7 @@ import org.springframework.util.StringUtils; * @see org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter * @see org.springframework.util.MultiValueMap */ -public class FormHttpMessageConverter implements HttpMessageConverter> { +public class FormHttpMessageConverter implements HttpMessageConverter> { /** The default charset used by the converter. */ public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; @@ -295,7 +295,7 @@ public class FormHttpMessageConverter implements HttpMessageConverter clazz, @Nullable MediaType mediaType) { - if (!MultiValueMap.class.isAssignableFrom(clazz)) { + if (!Map.class.isAssignableFrom(clazz)) { return false; } if (mediaType == null) { @@ -330,7 +330,7 @@ public class FormHttpMessageConverter implements HttpMessageConverter read(@Nullable Class> clazz, + public Map read(@Nullable Class> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { MediaType contentType = inputMessage.getHeaders().getContentType(); @@ -339,41 +339,76 @@ public class FormHttpMessageConverter implements HttpMessageConverter result = new LinkedMultiValueMap<>(pairs.length); + Map svResult = null; + MultiValueMap mvResult = null; + if (clazz == null || MultiValueMap.class.isAssignableFrom(clazz)) { + mvResult = new LinkedMultiValueMap<>(pairs.length); + } + else { + svResult = CollectionUtils.newLinkedHashMap(pairs.length); + } for (String pair : pairs) { int idx = pair.indexOf('='); if (idx == -1) { - result.add(URLDecoder.decode(pair, charset), null); + String name = URLDecoder.decode(pair, charset); + if (mvResult != null) { + mvResult.add(name, null); + } + else if (svResult != null) { + svResult.put(name, null); + } } else { String name = URLDecoder.decode(pair.substring(0, idx), charset); String value = URLDecoder.decode(pair.substring(idx + 1), charset); - result.add(name, value); + if (mvResult != null) { + mvResult.add(name, value); + } + else if (svResult != null) { + svResult.put(name, value); + } } } - return result; + if (mvResult != null) { + return mvResult; + } + else if (svResult != null) { + return svResult; + } + else { + throw new IllegalStateException(); + } } @Override @SuppressWarnings("unchecked") - public void write(MultiValueMap map, @Nullable MediaType contentType, HttpOutputMessage outputMessage) + public void write(Map map, @Nullable MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { if (isMultipart(map, contentType)) { - writeMultipart((MultiValueMap) map, contentType, outputMessage); + writeMultipart((Map) map, contentType, outputMessage); } else { - writeForm((MultiValueMap) map, contentType, outputMessage); + writeForm((Map) map, contentType, outputMessage); } } - private boolean isMultipart(MultiValueMap map, @Nullable MediaType contentType) { + private boolean isMultipart(Map map, @Nullable MediaType contentType) { if (contentType != null) { return contentType.getType().equalsIgnoreCase("multipart"); } - for (List values : map.values()) { - for (Object value : values) { + if (map instanceof MultiValueMap mvMap) { + for (List values : mvMap.values()) { + for (Object value : values) { + if (value != null && !(value instanceof String)) { + return true; + } + } + } + } + else { + for (Object value : map.values()) { if (value != null && !(value instanceof String)) { return true; } @@ -382,7 +417,7 @@ public class FormHttpMessageConverter implements HttpMessageConverter formData, @Nullable MediaType mediaType, + private void writeForm(Map formData, @Nullable MediaType mediaType, HttpOutputMessage outputMessage) throws IOException { mediaType = getFormContentType(mediaType); @@ -430,30 +465,37 @@ public class FormHttpMessageConverter implements HttpMessageConverter formData, Charset charset) { + protected String serializeForm(Map formData, Charset charset) { StringBuilder builder = new StringBuilder(); - formData.forEach((name, values) -> { + formData.forEach((name, value) -> { if (name == null) { - Assert.isTrue(CollectionUtils.isEmpty(values), () -> "Null name in form data: " + formData); + Assert.isTrue(value == null || value instanceof List values && values.isEmpty(), + () -> "Null name in form data: " + formData); return; } - values.forEach(value -> { - if (builder.length() != 0) { - builder.append('&'); - } - builder.append(URLEncoder.encode(name, charset)); - if (value != null) { - builder.append('='); - builder.append(URLEncoder.encode(String.valueOf(value), charset)); - } - }); + if (value instanceof List values) { + values.forEach(v -> serializeFormValue(name, value, charset, builder)); + } + else { + serializeFormValue(name, value, charset, builder); + } }); - return builder.toString(); } + private static void serializeFormValue(String name, @Nullable Object value, Charset charset, StringBuilder builder) { + if (!builder.isEmpty()) { + builder.append('&'); + } + builder.append(URLEncoder.encode(name, charset)); + if (value != null) { + builder.append('='); + builder.append(URLEncoder.encode(String.valueOf(value), charset)); + } + } + private void writeMultipart( - MultiValueMap parts, @Nullable MediaType contentType, HttpOutputMessage outputMessage) + Map parts, @Nullable MediaType contentType, HttpOutputMessage outputMessage) throws IOException { // If the supplied content type is null, fall back to multipart/form-data. @@ -500,13 +542,23 @@ public class FormHttpMessageConverter implements HttpMessageConverter parts, byte[] boundary) throws IOException { - for (Map.Entry> entry : parts.entrySet()) { + private void writeParts(OutputStream os, Map parts, byte[] boundary) throws IOException { + for (Map.Entry entry : parts.entrySet()) { String name = entry.getKey(); - for (Object part : entry.getValue()) { - if (part != null) { + Object value = entry.getValue(); + if (value instanceof List values) { + for (Object part : values) { + if (part != null) { + writeBoundary(os, boundary); + writePart(name, getHttpEntity(part), os); + writeNewLine(os); + } + } + } + else { + if (value != null) { writeBoundary(os, boundary); - writePart(name, getHttpEntity(part), os); + writePart(name, getHttpEntity(value), os); writeNewLine(os); } } diff --git a/spring-web/src/main/java/org/springframework/web/filter/FormContentFilter.java b/spring-web/src/main/java/org/springframework/web/filter/FormContentFilter.java index bad214fb5d..a0c29bf60f 100644 --- a/spring-web/src/main/java/org/springframework/web/filter/FormContentFilter.java +++ b/spring-web/src/main/java/org/springframework/web/filter/FormContentFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -94,6 +94,7 @@ public class FormContentFilter extends OncePerRequestFilter { } } + @SuppressWarnings("unchecked") @Nullable private MultiValueMap parseIfNecessary(HttpServletRequest request) throws IOException { if (!shouldParse(request)) { @@ -106,7 +107,7 @@ public class FormContentFilter extends OncePerRequestFilter { return request.getInputStream(); } }; - return this.formConverter.read(null, inputMessage); + return (MultiValueMap) this.formConverter.read(null, inputMessage); } private boolean shouldParse(HttpServletRequest request) { diff --git a/spring-web/src/test/java/org/springframework/http/converter/FormHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/FormHttpMessageConverterTests.java index 98c937bc73..3312e3b7e8 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/FormHttpMessageConverterTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/FormHttpMessageConverterTests.java @@ -117,13 +117,14 @@ class FormHttpMessageConverterTests { assertCanWrite(MULTIPART_RELATED); } + @SuppressWarnings("unchecked") @Test - void readForm() throws Exception { + void readMultiValueForm() throws Exception { String body = "name+1=value+1&name+2=value+2%2B1&name+2=value+2%2B2&name+3"; MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.ISO_8859_1)); inputMessage.getHeaders().setContentType( new MediaType("application", "x-www-form-urlencoded", StandardCharsets.ISO_8859_1)); - MultiValueMap result = this.converter.read(null, inputMessage); + MultiValueMap result = (MultiValueMap) this.converter.read(null, inputMessage); assertThat(result).as("Invalid result").hasSize(3); assertThat(result.getFirst("name 1")).as("Invalid result").isEqualTo("value 1"); @@ -132,6 +133,21 @@ class FormHttpMessageConverterTests { assertThat(result.getFirst("name 3")).as("Invalid result").isNull(); } + @SuppressWarnings("unchecked") + @Test + void readSingleValueForm() throws Exception { + String body = "name+1=value+1&name+2=value+2&name+3"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes(StandardCharsets.ISO_8859_1)); + inputMessage.getHeaders().setContentType( + new MediaType("application", "x-www-form-urlencoded", StandardCharsets.ISO_8859_1)); + Map result = (Map) this.converter.read((Class>) Map.class, inputMessage); + + assertThat(result).as("Invalid result").hasSize(3); + assertThat(result.get("name 1")).as("Invalid result").isEqualTo("value 1"); + assertThat(result.get("name 2")).as("Invalid result").isEqualTo("value 2"); + assertThat(result.get("name 3")).as("Invalid result").isNull(); + } + @Test void writeForm() throws IOException { MultiValueMap body = new LinkedMultiValueMap<>();