diff --git a/spring-integration-http/src/main/java/org/springframework/integration/http/converter/MultipartAwareFormHttpMessageConverter.java b/spring-integration-http/src/main/java/org/springframework/integration/http/converter/MultipartAwareFormHttpMessageConverter.java index 98a16f90d6e..bb8df00a2ca 100644 --- a/spring-integration-http/src/main/java/org/springframework/integration/http/converter/MultipartAwareFormHttpMessageConverter.java +++ b/spring-integration-http/src/main/java/org/springframework/integration/http/converter/MultipartAwareFormHttpMessageConverter.java @@ -18,14 +18,18 @@ import java.io.IOException; import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import org.jspecify.annotations.Nullable; +import org.springframework.core.ResolvableType; import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpOutputMessage; import org.springframework.http.MediaType; +import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; @@ -39,9 +43,9 @@ import org.springframework.web.multipart.MultipartFile; /** - * An {@link HttpMessageConverter} implementation that delegates to an instance of - * {@link MultipartHttpMessageConverter} while adding the capability to read - * multipart/form-data content in an HTTP request. + * An {@link HttpMessageConverter} implementation that delegates to instances of + * {@link FormHttpMessageConverter} and {@link MultipartHttpMessageConverter} while adding + * the capability to read multipart/form-data content in an HTTP request. * * @author Mark Fisher * @author Gary Russell @@ -51,7 +55,11 @@ */ public class MultipartAwareFormHttpMessageConverter implements HttpMessageConverter> { - private final MultipartHttpMessageConverter wrappedConverter = new MultipartHttpMessageConverter(); + private static final ResolvableType MULTI_VALUE_MAP_TYPE = ResolvableType.forClass(MultiValueMap.class); + + private final FormHttpMessageConverter formConverter = new FormHttpMessageConverter(); + + private final MultipartHttpMessageConverter multipartConverter = new MultipartHttpMessageConverter(); private MultipartFileReader multipartFileReader = new DefaultMultipartFileReader(); @@ -60,7 +68,8 @@ public class MultipartAwareFormHttpMessageConverter implements HttpMessageConver * @param charset The charset. */ public void setCharset(Charset charset) { - this.wrappedConverter.setCharset(charset); + this.formConverter.setCharset(charset); + this.multipartConverter.setCharset(charset); } /** @@ -74,7 +83,9 @@ public void setMultipartFileReader(MultipartFileReader multipartFileReader) { @Override public List getSupportedMediaTypes() { - return this.wrappedConverter.getSupportedMediaTypes(); + List supportedMediaTypes = new ArrayList<>(this.formConverter.getSupportedMediaTypes()); + supportedMediaTypes.addAll(this.multipartConverter.getSupportedMediaTypes()); + return Collections.unmodifiableList(supportedMediaTypes); } @Override @@ -93,16 +104,19 @@ public boolean canRead(Class clazz, @Nullable MediaType mediaType) { @Override public boolean canWrite(Class clazz, @Nullable MediaType mediaType) { - return this.wrappedConverter.canWrite(clazz, mediaType); + return this.formConverter.canWrite(clazz, mediaType) || this.multipartConverter.canWrite(clazz, mediaType); } @Override + @SuppressWarnings("unchecked") public MultiValueMap read(Class> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException { MediaType contentType = inputMessage.getHeaders().getContentType(); if (!MediaType.MULTIPART_FORM_DATA.includes(contentType)) { - return this.wrappedConverter.read(clazz, inputMessage); + // The target type is always a MultiValueMap: a byte[] would make the delegate + // fall back to a single-value Map, which is not what this converter produces. + return (MultiValueMap) this.formConverter.read(MULTI_VALUE_MAP_TYPE, inputMessage, null); } Assert.state(inputMessage instanceof MultipartHttpInputMessage, "A request with 'multipart/form-data' Content-Type must be a MultipartHttpInputMessage. " @@ -130,7 +144,26 @@ public boolean canWrite(Class clazz, @Nullable MediaType mediaType) { public void write(MultiValueMap map, @Nullable MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { - this.wrappedConverter.write(map, contentType, outputMessage); + if (isMultipart(map, contentType)) { + this.multipartConverter.write(map, contentType, outputMessage); + } + else { + this.formConverter.write(map, contentType, outputMessage); + } + } + + private static boolean isMultipart(MultiValueMap 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; } } diff --git a/spring-integration-http/src/test/java/org/springframework/integration/http/converter/MultipartAwareFormHttpMessageConverterTests.java b/spring-integration-http/src/test/java/org/springframework/integration/http/converter/MultipartAwareFormHttpMessageConverterTests.java new file mode 100644 index 00000000000..8e52734f309 --- /dev/null +++ b/spring-integration-http/src/test/java/org/springframework/integration/http/converter/MultipartAwareFormHttpMessageConverterTests.java @@ -0,0 +1,121 @@ +/* + * Copyright 2026-present 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. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.integration.http.converter; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for the {@link MultipartAwareFormHttpMessageConverter}. + * + * @since 7.2 + */ +public class MultipartAwareFormHttpMessageConverterTests { + + private final MultipartAwareFormHttpMessageConverter converter = new MultipartAwareFormHttpMessageConverter(); + + @Test + public void supportedMediaTypesCoverFormAndMultipart() { + assertThat(this.converter.getSupportedMediaTypes()) + .contains(MediaType.APPLICATION_FORM_URLENCODED, MediaType.MULTIPART_FORM_DATA); + } + + @Test + @SuppressWarnings("unchecked") + public void readsFormUrlEncodedBody() throws Exception { + MultiValueMap result = + (MultiValueMap) this.converter.read( + (Class>) (Class) LinkedMultiValueMap.class, + formInputMessage()); + + assertThat(result).containsOnlyKeys("name", "other"); + assertThat(result.get("name")).containsExactly("foo", "bar"); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + public void readsFormUrlEncodedBodyForByteArrayTargetType() throws Exception { + // The inbound endpoint falls back to a 'byte[]' target type when no request payload type is configured. + Object result = ((HttpMessageConverter) this.converter).read(byte[].class, formInputMessage()); + + assertThat(result).isInstanceOf(MultiValueMap.class); + assertThat((MultiValueMap) result).containsOnlyKeys("name", "other"); + } + + @Test + public void writesFormUrlEncodedBody() throws Exception { + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("name", "foo"); + form.add("name", "bar"); + + ByteArrayOutputStream body = new ByteArrayOutputStream(); + HttpHeaders headers = new HttpHeaders(); + this.converter.write(form, MediaType.APPLICATION_FORM_URLENCODED, new HttpOutputMessage() { + + @Override + public OutputStream getBody() { + return body; + } + + @Override + public HttpHeaders getHeaders() { + return headers; + } + + }); + + MediaType contentType = headers.getContentType(); + assertThat(contentType).isNotNull(); + assertThat(contentType.isCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED)).isTrue(); + assertThat(body.toString(StandardCharsets.UTF_8)).isEqualTo("name=foo&name=bar"); + } + + private static HttpInputMessage formInputMessage() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + byte[] body = "name=foo&name=bar&other=baz".getBytes(StandardCharsets.UTF_8); + return new HttpInputMessage() { + + @Override + public InputStream getBody() { + return new ByteArrayInputStream(body); + } + + @Override + public HttpHeaders getHeaders() { + return headers; + } + + }; + } + +} diff --git a/spring-integration-http/src/test/java/org/springframework/integration/http/dsl/HttpDslTests.java b/spring-integration-http/src/test/java/org/springframework/integration/http/dsl/HttpDslTests.java index f0256c1587c..87d2f1988ab 100644 --- a/spring-integration-http/src/test/java/org/springframework/integration/http/dsl/HttpDslTests.java +++ b/spring-integration-http/src/test/java/org/springframework/integration/http/dsl/HttpDslTests.java @@ -23,7 +23,6 @@ import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -126,7 +125,6 @@ public void setup() { } @Test - @Disabled("See https://github.com/spring-projects/spring-integration/issues/11235") public void testHttpProxyFlow() throws Exception { RestTestClient restTestClient = RestTestClient.bindTo(this.mockMvc).build(); ClientHttpRequestFactory mockRequestFactory =