Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 <i>read</i>
* <code>multipart/form-data</code> content in an HTTP request.
* An {@link HttpMessageConverter} implementation that delegates to instances of
* {@link FormHttpMessageConverter} and {@link MultipartHttpMessageConverter} while adding
* the capability to <i>read</i> <code>multipart/form-data</code> content in an HTTP request.
*
* @author Mark Fisher
* @author Gary Russell
Expand All @@ -51,7 +55,11 @@
*/
public class MultipartAwareFormHttpMessageConverter implements HttpMessageConverter<MultiValueMap<String, ?>> {

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();

Expand All @@ -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);
}

/**
Expand All @@ -74,7 +83,9 @@ public void setMultipartFileReader(MultipartFileReader<?> multipartFileReader) {

@Override
public List<MediaType> getSupportedMediaTypes() {
return this.wrappedConverter.getSupportedMediaTypes();
List<MediaType> supportedMediaTypes = new ArrayList<>(this.formConverter.getSupportedMediaTypes());
supportedMediaTypes.addAll(this.multipartConverter.getSupportedMediaTypes());
return Collections.unmodifiableList(supportedMediaTypes);
}

@Override
Expand All @@ -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<String, ?> read(Class<? extends MultiValueMap<String, ?>> 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<String, ?>) 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. "
Expand Down Expand Up @@ -130,7 +144,26 @@ public boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType) {
public void write(MultiValueMap<String, ?> 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<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;
}

}
Original file line number Diff line number Diff line change
@@ -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<String, Object> result =
(MultiValueMap<String, Object>) this.converter.read(
(Class<? extends MultiValueMap<String, ?>>) (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<String, Object>) result).containsOnlyKeys("name", "other");
}

@Test
public void writesFormUrlEncodedBody() throws Exception {
MultiValueMap<String, String> 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;
}

};
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand Down