Browse Source

Add MultipartHttpMessageConverter

Prior to this commit, the `FormHttpMessageConverter` would write, but
not read, multipart HTTP messages. Reading multipart messages is
typicaly performed by Servlet containers with Spring's
`MultipartResolver` infrastructure.

This commit introduces a new `MultipartHttpMessageConverter` that copies
the existing feature for writing multipart messages, borrowed from
`FormHttpMessageConverter`. This also introduces a new `MultipartParser`
class that is an imperative port of the reactive variant, but keeping it
based on the `DataBuffer` abstraction. This will allow us to maintain
both side by side more easily.

This change also adds new `Part`, `FilePart` and `FormFieldPart` types
that will be used when converting multipart messages to
`MultiValueMap<String, Part>` maps.

Closes gh-36255
pull/36549/head
Brian Clozel 4 days ago
parent
commit
44302aca93
  1. 297
      spring-web/src/main/java/org/springframework/http/converter/multipart/DefaultParts.java
  2. 72
      spring-web/src/main/java/org/springframework/http/converter/multipart/FilePart.java
  3. 34
      spring-web/src/main/java/org/springframework/http/converter/multipart/FormFieldPart.java
  4. 651
      spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverter.java
  5. 553
      spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartParser.java
  6. 47
      spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartUtils.java
  7. 69
      spring-web/src/main/java/org/springframework/http/converter/multipart/Part.java
  8. 394
      spring-web/src/main/java/org/springframework/http/converter/multipart/PartGenerator.java
  9. 7
      spring-web/src/main/java/org/springframework/http/converter/multipart/package-info.java
  10. 554
      spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverterTests.java
  11. 282
      spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartParserTests.java

297
spring-web/src/main/java/org/springframework/http/converter/multipart/DefaultParts.java

@ -0,0 +1,297 @@ @@ -0,0 +1,297 @@
/*
* Copyright 2002-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.http.converter.multipart;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.util.Assert;
/**
* Default implementations of {@link Part} and subtypes.
*
* @author Arjen Poutsma
* @author Brian Clozel
*/
abstract class DefaultParts {
/**
* Create a new {@link FormFieldPart} with the given parameters.
* @param headers the part headers
* @param value the form field value
* @return the created part
*/
public static FormFieldPart formFieldPart(HttpHeaders headers, String value) {
Assert.notNull(headers, "Headers must not be null");
Assert.notNull(value, "Value must not be null");
return new DefaultFormFieldPart(headers, value);
}
/**
* Create a new {@link Part} or {@link FilePart} based on a flux of data
* buffers. Returns {@link FilePart} if the {@code Content-Disposition} of
* the given headers contains a filename, or a "normal" {@link Part}
* otherwise.
* @param headers the part headers
* @param dataBuffer the content of the part
* @return {@link Part} or {@link FilePart}, depending on {@link HttpHeaders#getContentDisposition()}
*/
public static Part part(HttpHeaders headers, DataBuffer dataBuffer) {
Assert.notNull(headers, "Headers must not be null");
Assert.notNull(dataBuffer, "DataBuffer must not be null");
return partInternal(headers, new DataBufferContent(dataBuffer));
}
/**
* Create a new {@link Part} or {@link FilePart} based on the given file.
* Returns {@link FilePart} if the {@code Content-Disposition} of the given
* headers contains a filename, or a "normal" {@link Part} otherwise
* @param headers the part headers
* @param file the file
* @return {@link Part} or {@link FilePart}, depending on {@link HttpHeaders#getContentDisposition()}
*/
public static Part part(HttpHeaders headers, Path file) {
Assert.notNull(headers, "Headers must not be null");
Assert.notNull(file, "File must not be null");
return partInternal(headers, new FileContent(file));
}
private static Part partInternal(HttpHeaders headers, Content content) {
String filename = headers.getContentDisposition().getFilename();
if (filename != null) {
return new DefaultFilePart(headers, content);
}
else {
return new DefaultPart(headers, content);
}
}
/**
* Abstract base class for {@link Part} implementations.
*/
private abstract static class AbstractPart implements Part {
private final HttpHeaders headers;
protected AbstractPart(HttpHeaders headers) {
Assert.notNull(headers, "HttpHeaders is required");
this.headers = headers;
}
@Override
public String name() {
String name = headers().getContentDisposition().getName();
Assert.state(name != null, "No part name available");
return name;
}
@Override
public HttpHeaders headers() {
return this.headers;
}
}
/**
* Default implementation of {@link FormFieldPart}.
*/
private static class DefaultFormFieldPart extends AbstractPart implements FormFieldPart {
private final String value;
public DefaultFormFieldPart(HttpHeaders headers, String value) {
super(headers);
this.value = value;
}
@Override
public InputStream content() {
byte[] bytes = this.value.getBytes(MultipartUtils.charset(headers()));
return new ByteArrayInputStream(bytes);
}
@Override
public String value() {
return this.value;
}
@Override
public String toString() {
String name = headers().getContentDisposition().getName();
if (name != null) {
return "DefaultFormFieldPart{" + name() + "}";
}
else {
return "DefaultFormFieldPart";
}
}
}
/**
* Default implementation of {@link Part}.
*/
private static class DefaultPart extends AbstractPart {
protected final Content content;
public DefaultPart(HttpHeaders headers, Content content) {
super(headers);
this.content = content;
}
@Override
public InputStream content() throws IOException {
return this.content.content();
}
@Override
public void delete() throws IOException {
this.content.delete();
}
@Override
public String toString() {
String name = headers().getContentDisposition().getName();
if (name != null) {
return "DefaultPart{" + name + "}";
}
else {
return "DefaultPart";
}
}
}
/**
* Default implementation of {@link FilePart}.
*/
private static final class DefaultFilePart extends DefaultPart implements FilePart {
public DefaultFilePart(HttpHeaders headers, Content content) {
super(headers, content);
}
@Override
public String filename() {
String filename = headers().getContentDisposition().getFilename();
Assert.state(filename != null, "No filename found");
return filename;
}
@Override
public void transferTo(Path dest) throws IOException {
this.content.transferTo(dest);
}
@Override
public String toString() {
ContentDisposition contentDisposition = headers().getContentDisposition();
String name = contentDisposition.getName();
String filename = contentDisposition.getFilename();
if (name != null) {
return "DefaultFilePart{" + name + " (" + filename + ")}";
}
else {
return "DefaultFilePart{(" + filename + ")}";
}
}
}
/**
* Part content abstraction.
*/
private interface Content {
InputStream content() throws IOException;
void transferTo(Path dest) throws IOException;
void delete() throws IOException;
}
/**
* {@code Content} implementation based on an in-memory {@code InputStream}.
*/
private static final class DataBufferContent implements Content {
private final DataBuffer content;
public DataBufferContent(DataBuffer content) {
this.content = content;
}
@Override
public InputStream content() {
return this.content.asInputStream();
}
@Override
public void transferTo(Path dest) throws IOException {
Files.copy(this.content.asInputStream(), dest, StandardCopyOption.REPLACE_EXISTING);
}
@Override
public void delete() throws IOException {
}
}
/**
* {@code Content} implementation based on a file.
*/
private static final class FileContent implements Content {
private final Path file;
public FileContent(Path file) {
this.file = file;
}
@Override
public InputStream content() throws IOException {
return Files.newInputStream(this.file.toAbsolutePath(), StandardOpenOption.READ);
}
@Override
public void transferTo(Path dest) throws IOException {
Files.copy(this.file, dest, StandardCopyOption.REPLACE_EXISTING);
}
@Override
public void delete() throws IOException {
Files.delete(this.file);
}
}
}

72
spring-web/src/main/java/org/springframework/http/converter/multipart/FilePart.java

@ -0,0 +1,72 @@ @@ -0,0 +1,72 @@
/*
* Copyright 2002-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.http.converter.multipart;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
/**
* Specialization of {@link Part} that represents an uploaded file received in
* a multipart request.
*
* @author Brian Clozel
* @author Rossen Stoyanchev
* @author Juergen Hoeller
* @since 7.1
*/
public interface FilePart extends Part {
/**
* Return the original filename in the client's filesystem.
* <p><strong>Note:</strong> Please keep in mind this filename is supplied
* by the client and should not be used blindly. In addition to not using
* the directory portion, the file name could also contain characters such
* as ".." and others that can be used maliciously. It is recommended to not
* use this filename directly. Preferably generate a unique one and save
* this one somewhere for reference, if necessary.
* @return the original filename, or the empty String if no file has been chosen
* in the multipart form, or {@code null} if not defined or not available
* @see <a href="https://tools.ietf.org/html/rfc7578#section-4.2">RFC 7578, Section 4.2</a>
* @see <a href="https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload">Unrestricted File Upload</a>
*/
String filename();
/**
* Convenience method to copy the content of the file in this part to the
* given destination file. If the destination file already exists, it will
* be truncated first.
* <p>The default implementation delegates to {@link #transferTo(Path)}.
* @param dest the target file
* @throws IllegalStateException if the part isn't a file
* @see #transferTo(Path)
*/
default void transferTo(File dest) throws IOException {
transferTo(dest.toPath());
}
/**
* Convenience method to copy the content of the file in this part to the
* given destination file. If the destination file already exists, it will
* be truncated first.
* @param dest the target file
* @throws IllegalStateException if the part isn't a file
* @see #transferTo(File)
*/
void transferTo(Path dest) throws IOException;
}

34
spring-web/src/main/java/org/springframework/http/converter/multipart/FormFieldPart.java

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
/*
* Copyright 2002-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.http.converter.multipart;
/**
* Specialization of {@link Part} for a form field.
*
* @author Brian Clozel
* @author Rossen Stoyanchev
* @since 7.1
*/
public interface FormFieldPart extends Part {
/**
* Return the form field value.
*/
String value();
}

651
spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverter.java

@ -0,0 +1,651 @@ @@ -0,0 +1,651 @@
/*
* 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.http.converter.multipart;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.jspecify.annotations.Nullable;
import org.springframework.core.ResolvableType;
import org.springframework.core.io.Resource;
import org.springframework.core.io.buffer.DataBufferLimitException;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.StreamingHttpOutputMessage;
import org.springframework.http.converter.AbstractHttpMessageConverter;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.http.converter.ResourceHttpMessageConverter;
import org.springframework.http.converter.SmartHttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.util.Assert;
import org.springframework.util.MimeTypeUtils;
import org.springframework.util.MultiValueMap;
/**
* Implementation of {@link HttpMessageConverter} to read and write
* multipart data (for example, file uploads).
*
* <p>This converter can read {@code "multipart/form-data"}
* and {@code "multipart/mixed"} messages as
* {@link MultiValueMap MultiValueMap&lt;String, Part&gt;}, and
* write {@link MultiValueMap MultiValueMap&lt;String, Object&gt;} as
* multipart messages.
*
* <p>On Servlet containers, the reading of multipart messages should be
* delegated to the {@link org.springframework.web.multipart.MultipartResolver}.
*
* <h3>Multipart Data</h3>
*
* <p>By default, {@code "multipart/form-data"} is used as the content type when
* {@linkplain #write writing} multipart data. It is also possible to write
* multipart data using other multipart subtypes such as {@code "multipart/mixed"}
* and {@code "multipart/related"}, as long as the multipart subtype is registered
* as a {@linkplain #getSupportedMediaTypes supported media type} <em>and</em> the
* desired multipart subtype is specified as the content type when
* {@linkplain #write writing} the multipart data. Note that {@code "multipart/mixed"}
* is registered as a supported media type by default.
*
* <p>When writing multipart data, this converter uses other
* {@link HttpMessageConverter HttpMessageConverters} to write the respective
* MIME parts. By default, basic converters are registered for byte array,
* {@code String}, and {@code Resource}. This can be set with the main
* {@link #MultipartHttpMessageConverter(Iterable) constructor}.
*
* <h3>Examples</h3>
*
* <p>The following snippet shows how to submit an HTML form using the
* {@code "multipart/form-data"} content type.
*
* <pre class="code">
* RestClient restClient = RestClient.create();
* // MultipartHttpMessageConverter is configured by default
*
* MultiValueMap&lt;String, Object&gt; form = new LinkedMultiValueMap&lt;&gt;();
* form.add("field 1", "value 1");
* form.add("field 2", "value 2");
* form.add("field 2", "value 3");
* form.add("field 3", 4);
*
* ResponseEntity&lt;Void&gt; response = restClient.post()
* .uri("https://example.com/myForm")
* .contentType(MULTIPART_FORM_DATA)
* .body(form)
* .retrieve()
* .toBodilessEntity();</pre>
*
* <p>The following snippet shows how to do a file upload using the
* {@code "multipart/form-data"} content type.
*
* <pre class="code">
* MultiValueMap&lt;String, Object&gt; parts = new LinkedMultiValueMap&lt;&gt;();
* parts.add("field 1", "value 1");
* parts.add("file", new ClassPathResource("myFile.jpg"));
*
* ResponseEntity&lt;Void&gt; response = restClient.post()
* .uri("https://example.com/myForm")
* .contentType(MULTIPART_FORM_DATA)
* .body(parts)
* .retrieve()
* .toBodilessEntity();</pre>
*
* <p>The following snippet shows how to decode a multipart response.
*
* <pre class="code">
* MultiValueMap&lt;String, Part&gt; body = this.restClient.get()
* .uri("https://example.com/parts/42")
* .accept(MediaType.MULTIPART_FORM_DATA)
* .retrieve()
* .body(new ParameterizedTypeReference<>() {});</pre>
*
* @author Brian Clozel
* @author Arjen Poutsma
* @author Rossen Stoyanchev
* @author Juergen Hoeller
* @author Sam Brannen
* @since 7.1
* @see org.springframework.util.MultiValueMap
*/
public class MultipartHttpMessageConverter implements SmartHttpMessageConverter<MultiValueMap<String, ?>> {
private final List<HttpMessageConverter<?>> partConverters;
private @Nullable Path tempDirectory;
private List<MediaType> supportedMediaTypes = new ArrayList<>();
private Charset charset = StandardCharsets.UTF_8;
private @Nullable Charset multipartCharset;
private int maxInMemorySize = 256 * 1024;
private int maxHeadersSize = 10 * 1024;
private long maxDiskUsagePerPart = -1;
private int maxParts = -1;
/**
* Create a new converter instance with the given converter instances for reading and
* writing parts.
* @param converters the converters to use for reading and writing parts
*/
public MultipartHttpMessageConverter(Iterable<HttpMessageConverter<?>> converters) {
this.supportedMediaTypes.add(MediaType.MULTIPART_FORM_DATA);
this.supportedMediaTypes.add(MediaType.MULTIPART_MIXED);
this.supportedMediaTypes.add(MediaType.MULTIPART_RELATED);
this.partConverters = new ArrayList<>();
converters.forEach(this.partConverters::add);
}
/**
* Create a new converter instance with default converter instances for reading and
* writing parts.
* @see ByteArrayHttpMessageConverter
* @see StringHttpMessageConverter
* @see ResourceHttpMessageConverter
*/
public MultipartHttpMessageConverter() {
this(List.of( new ByteArrayHttpMessageConverter(), new StringHttpMessageConverter(),
new ResourceHttpMessageConverter()));
}
/**
* Set the list of {@link MediaType} objects supported by this converter.
* @see #addSupportedMediaTypes(MediaType...)
* @see #getSupportedMediaTypes()
*/
public void setSupportedMediaTypes(List<MediaType> supportedMediaTypes) {
Assert.notNull(supportedMediaTypes, "'supportedMediaTypes' must not be null");
// Ensure internal list is mutable.
this.supportedMediaTypes = new ArrayList<>(supportedMediaTypes);
}
/**
* Add {@link MediaType} objects to be supported by this converter.
* <p>The supplied {@code MediaType} objects will be appended to the list
* of {@linkplain #getSupportedMediaTypes() supported MediaType objects}.
* @param supportedMediaTypes a var-args list of {@code MediaType} objects to add
* @see #setSupportedMediaTypes(List)
*/
public void addSupportedMediaTypes(MediaType... supportedMediaTypes) {
Assert.notNull(supportedMediaTypes, "'supportedMediaTypes' must not be null");
Assert.noNullElements(supportedMediaTypes, "'supportedMediaTypes' must not contain null elements");
Collections.addAll(this.supportedMediaTypes, supportedMediaTypes);
}
/**
* {@inheritDoc}
* @see #setSupportedMediaTypes(List)
* @see #addSupportedMediaTypes(MediaType...)
*/
@Override
public List<MediaType> getSupportedMediaTypes() {
return Collections.unmodifiableList(this.supportedMediaTypes);
}
/**
* Return the configured converters for MIME parts.
*/
public List<HttpMessageConverter<?>> getPartConverters() {
return Collections.unmodifiableList(this.partConverters);
}
/**
* Set the default character set to use for reading and writing form data when
* the request or response {@code Content-Type} header does not explicitly
* specify it.
* <p>As of 4.3, this is also used as the default charset for the conversion
* of text bodies in a multipart request.
* <p>As of 5.0, this is also used for part headers including
* {@code Content-Disposition} (and its filename parameter) unless (the mutually
* exclusive) {@link #setMultipartCharset multipartCharset} is also set, in
* which case part headers are encoded as ASCII and <i>filename</i> is encoded
* with the {@code encoded-word} syntax from RFC 2047.
* <p>By default, this is set to "UTF-8".
*/
public void setCharset(@Nullable Charset charset) {
if (charset != this.charset) {
this.charset = (charset != null ? charset : StandardCharsets.UTF_8);
applyDefaultCharset();
}
}
/**
* Apply the configured charset as a default to registered part converters.
*/
private void applyDefaultCharset() {
for (HttpMessageConverter<?> candidate : this.partConverters) {
if (candidate instanceof AbstractHttpMessageConverter<?> converter) {
// Only override default charset if the converter operates with a charset to begin with...
if (converter.getDefaultCharset() != null) {
converter.setDefaultCharset(this.charset);
}
}
}
}
/**
* Set the character set to use when writing multipart data to encode file
* names. Encoding is based on the {@code encoded-word} syntax defined in
* RFC 2047 and relies on {@code MimeUtility} from {@code jakarta.mail}.
* <p>As of 5.0 by default part headers, including {@code Content-Disposition}
* (and its filename parameter) will be encoded based on the setting of
* {@link #setCharset(Charset)} or {@code UTF-8} by default.
* @see <a href="https://en.wikipedia.org/wiki/MIME#Encoded-Word">Encoded-Word</a>
*/
public void setMultipartCharset(Charset charset) {
this.multipartCharset = charset;
}
/**
* Configure the maximum amount of memory that is allowed per headers section of each part.
* <p>By default, this is set to 10K.
* @param byteCount the maximum amount of memory for headers
*/
public void setMaxHeadersSize(int byteCount) {
this.maxHeadersSize = byteCount;
}
/**
* Configure the maximum amount of memory allowed per part.
* When the limit is exceeded:
* <ul>
* <li>File parts are written to a temporary file.
* <li>Non-file parts are rejected with {@link DataBufferLimitException}.
* </ul>
* <p>By default, this is set to 256K.
* @param maxInMemorySize the in-memory limit in bytes; if set to -1 the entire
* contents will be stored in memory
*/
public void setMaxInMemorySize(int maxInMemorySize) {
this.maxInMemorySize = maxInMemorySize;
}
/**
* Configure the maximum amount of disk space allowed for file parts.
* <p>By default, this is set to -1, meaning that there is no maximum.
* <p>Note that this property is ignored when
* {@link #setMaxInMemorySize(int) maxInMemorySize} is set to -1.
*/
public void setMaxDiskUsagePerPart(long maxDiskUsagePerPart) {
this.maxDiskUsagePerPart = maxDiskUsagePerPart;
}
/**
* Specify the maximum number of parts allowed in a given multipart request.
* <p>By default, this is set to -1, meaning that there is no maximum.
*/
public void setMaxParts(int maxParts) {
this.maxParts = maxParts;
}
@Override
public boolean canRead(ResolvableType elementType, @Nullable MediaType mediaType) {
if (!supportsMediaType(mediaType)) {
return false;
}
if (!MultiValueMap.class.isAssignableFrom(elementType.toClass()) ||
(!elementType.hasUnresolvableGenerics() &&
!Part.class.isAssignableFrom(elementType.getGeneric(1).toClass()))) {
return false;
}
return true;
}
private boolean supportsMediaType(@Nullable MediaType mediaType) {
if (mediaType == null) {
return true;
}
for (MediaType supportedMediaType : getSupportedMediaTypes()) {
if (supportedMediaType.includes(mediaType)) {
return true;
}
}
return false;
}
@Override
public MultiValueMap<String, Part> read(ResolvableType type, HttpInputMessage message, @Nullable Map<String, Object> hints) throws IOException, HttpMessageNotReadableException {
Charset headersCharset = MultipartUtils.charset(message.getHeaders());
byte[] boundary = boundary(message, headersCharset);
if (boundary == null) {
throw new HttpMessageNotReadableException("No multipart boundary found in Content-Type: \"" +
message.getHeaders().getContentType() + "\"", message);
}
PartGenerator partListener = new PartGenerator(this.maxInMemorySize, this.maxDiskUsagePerPart, this.maxParts, getTempDirectory());
new MultipartParser(this.maxHeadersSize, 2 * 1024).parse(message.getBody(), boundary,
headersCharset, partListener);
return partListener.getParts();
}
private static byte @Nullable [] boundary(HttpInputMessage message, Charset headersCharset) {
MediaType contentType = message.getHeaders().getContentType();
if (contentType != null) {
String boundary = contentType.getParameter("boundary");
if (boundary != null) {
int len = boundary.length();
if (len > 2 && boundary.charAt(0) == '"' && boundary.charAt(len - 1) == '"') {
boundary = boundary.substring(1, len - 1);
}
return boundary.getBytes(headersCharset);
}
}
return null;
}
private Path getTempDirectory() throws IOException {
if (this.tempDirectory == null || !this.tempDirectory.toFile().exists()) {
this.tempDirectory = Files.createTempDirectory("spring-multipart-");
}
return this.tempDirectory;
}
@Override
public boolean canWrite(ResolvableType targetType, Class<?> valueClass, @Nullable MediaType mediaType) {
if (!MultiValueMap.class.isAssignableFrom(targetType.toClass())) {
return false;
}
if (mediaType == null || MediaType.ALL.equals(mediaType)) {
return true;
}
for (MediaType supportedMediaType : getSupportedMediaTypes()) {
if (supportedMediaType.isCompatibleWith(mediaType)) {
return true;
}
}
return false;
}
@Override
@SuppressWarnings("unchecked")
public void write(MultiValueMap<String, ?> map, ResolvableType type, @Nullable MediaType contentType, HttpOutputMessage outputMessage, @Nullable Map<String, Object> hints) throws IOException, HttpMessageNotWritableException {
MultiValueMap<String, Object> parts = (MultiValueMap<String, Object>) map;
// If the supplied content type is null, fall back to multipart/form-data.
// Otherwise, rely on the fact that isMultipart() already verified the
// supplied content type is multipart.
if (contentType == null) {
contentType = MediaType.MULTIPART_FORM_DATA;
}
Map<String, String> parameters = new LinkedHashMap<>(contentType.getParameters().size() + 2);
parameters.putAll(contentType.getParameters());
byte[] boundary = MimeTypeUtils.generateMultipartBoundary();
if (!isFilenameCharsetSet()) {
if (!this.charset.equals(StandardCharsets.UTF_8) &&
!this.charset.equals(StandardCharsets.US_ASCII)) {
parameters.put("charset", this.charset.name());
}
}
parameters.put("boundary", new String(boundary, StandardCharsets.US_ASCII));
// Add parameters to output content type
contentType = new MediaType(contentType, parameters);
outputMessage.getHeaders().setContentType(contentType);
if (outputMessage instanceof StreamingHttpOutputMessage streamingOutputMessage) {
boolean repeatable = checkPartsRepeatable(parts, contentType);
streamingOutputMessage.setBody(new StreamingHttpOutputMessage.Body() {
@Override
public void writeTo(OutputStream outputStream) throws IOException {
MultipartHttpMessageConverter.this.writeParts(outputStream, parts, boundary);
writeEnd(outputStream, boundary);
}
@Override
public boolean repeatable() {
return repeatable;
}
});
}
else {
writeParts(outputMessage.getBody(), parts, boundary);
writeEnd(outputMessage.getBody(), boundary);
}
}
@SuppressWarnings({"unchecked", "ConstantValue"})
private <T> boolean checkPartsRepeatable(MultiValueMap<String, Object> map, MediaType contentType) {
return map.entrySet().stream().allMatch(e -> e.getValue().stream().filter(Objects::nonNull).allMatch(part -> {
HttpHeaders headers = null;
Object body = part;
if (part instanceof HttpEntity<?> entity) {
headers = entity.getHeaders();
body = entity.getBody();
Assert.state(body != null, "Empty body for part '" + e.getKey() + "': " + part);
}
HttpMessageConverter<T> converter = (HttpMessageConverter<T>) findConverterFor(e.getKey(), headers, body);
return converter != null && converter.canWriteRepeatedly((T) body, contentType);
}));
}
private @Nullable HttpMessageConverter<?> findConverterFor(
String name, @Nullable HttpHeaders headers, Object body) {
Class<?> partType = body.getClass();
MediaType contentType = (headers != null ? headers.getContentType() : null);
for (HttpMessageConverter<?> converter : this.partConverters) {
if (converter.canWrite(partType, contentType)) {
return converter;
}
}
return null;
}
/**
* When {@link #setMultipartCharset(Charset)} is configured (i.e. RFC 2047,
* {@code encoded-word} syntax) we need to use ASCII for part headers, or
* otherwise we encode directly using the configured {@link #setCharset(Charset)}.
*/
private boolean isFilenameCharsetSet() {
return (this.multipartCharset != null);
}
private void writeParts(OutputStream os, MultiValueMap<String, Object> parts, byte[] boundary) throws IOException {
for (Map.Entry<String, List<Object>> entry : parts.entrySet()) {
String name = entry.getKey();
for (Object part : entry.getValue()) {
if (part != null) {
writeBoundary(os, boundary);
writePart(name, getHttpEntity(part), os);
writeNewLine(os);
}
}
}
}
@SuppressWarnings("unchecked")
private void writePart(String name, HttpEntity<?> partEntity, OutputStream os) throws IOException {
Object partBody = partEntity.getBody();
Assert.state(partBody != null, "Empty body for part '" + name + "': " + partEntity);
HttpHeaders partHeaders = partEntity.getHeaders();
MediaType partContentType = partHeaders.getContentType();
HttpMessageConverter<?> converter = findConverterFor(name, partHeaders, partBody);
if (converter != null) {
Charset charset = isFilenameCharsetSet() ? StandardCharsets.US_ASCII : this.charset;
HttpOutputMessage multipartMessage = new MultipartHttpOutputMessage(os, charset);
String filename = getFilename(partBody);
ContentDisposition.Builder cd = ContentDisposition.formData().name(name);
if (filename != null) {
cd.filename(filename, this.multipartCharset);
}
multipartMessage.getHeaders().setContentDisposition(cd.build());
if (!partHeaders.isEmpty()) {
multipartMessage.getHeaders().putAll(partHeaders);
}
((HttpMessageConverter<Object>) converter).write(partBody, partContentType, multipartMessage);
return;
}
throw new HttpMessageNotWritableException("Could not write request: " +
"no suitable HttpMessageConverter found for request type [" + partBody.getClass().getName() + "]");
}
/**
* Return an {@link HttpEntity} for the given part Object.
* @param part the part to return an {@link HttpEntity} for
* @return the part Object itself it is an {@link HttpEntity},
* or a newly built {@link HttpEntity} wrapper for that part
*/
protected HttpEntity<?> getHttpEntity(Object part) {
return (part instanceof HttpEntity<?> httpEntity ? httpEntity : new HttpEntity<>(part));
}
/**
* Return the filename of the given multipart part. This value will be used for the
* {@code Content-Disposition} header.
* <p>The default implementation returns {@link Resource#getFilename()} if the part is a
* {@code Resource}, and {@code null} in other cases. Can be overridden in subclasses.
* @param part the part to determine the file name for
* @return the filename, or {@code null} if not known
*/
protected @Nullable String getFilename(Object part) {
if (part instanceof Resource resource) {
return resource.getFilename();
}
else {
return null;
}
}
private void writeBoundary(OutputStream os, byte[] boundary) throws IOException {
os.write('-');
os.write('-');
os.write(boundary);
writeNewLine(os);
}
private static void writeEnd(OutputStream os, byte[] boundary) throws IOException {
os.write('-');
os.write('-');
os.write(boundary);
os.write('-');
os.write('-');
writeNewLine(os);
}
private static void writeNewLine(OutputStream os) throws IOException {
os.write('\r');
os.write('\n');
}
/**
* Implementation of {@link org.springframework.http.HttpOutputMessage} used
* to write a MIME multipart.
*/
private static class MultipartHttpOutputMessage implements HttpOutputMessage {
private final OutputStream outputStream;
private final Charset charset;
private final HttpHeaders headers = new HttpHeaders();
private boolean headersWritten = false;
public MultipartHttpOutputMessage(OutputStream outputStream, Charset charset) {
this.outputStream = new MultipartOutputStream(outputStream);
this.charset = charset;
}
@Override
public HttpHeaders getHeaders() {
return (this.headersWritten ? HttpHeaders.readOnlyHttpHeaders(this.headers) : this.headers);
}
@Override
public OutputStream getBody() throws IOException {
writeHeaders();
return this.outputStream;
}
private void writeHeaders() throws IOException {
if (!this.headersWritten) {
for (Map.Entry<String, List<String>> entry : this.headers.headerSet()) {
byte[] headerName = getBytes(entry.getKey());
for (String headerValueString : entry.getValue()) {
byte[] headerValue = getBytes(headerValueString);
this.outputStream.write(headerName);
this.outputStream.write(':');
this.outputStream.write(' ');
this.outputStream.write(headerValue);
writeNewLine(this.outputStream);
}
}
writeNewLine(this.outputStream);
this.headersWritten = true;
}
}
private byte[] getBytes(String name) {
return name.getBytes(this.charset);
}
}
/**
* OutputStream that neither flushes nor closes.
*/
private static class MultipartOutputStream extends FilterOutputStream {
public MultipartOutputStream(OutputStream out) {
super(out);
}
@Override
public void write(byte[] b, int off, int let) throws IOException {
this.out.write(b, off, let);
}
@Override
public void flush() {
}
@Override
public void close() {
}
}
}

553
spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartParser.java

@ -0,0 +1,553 @@ @@ -0,0 +1,553 @@
/*
* 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.http.converter.multipart;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.Iterator;
import java.util.List;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jspecify.annotations.Nullable;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferLimitException;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.converter.HttpMessageConversionException;
/**
* Read a Multipart message as a byte stream and parse its content
* and signals them to the {@link PartListener}.
*
* @author Brian Clozel
* @author Arjen Poutsma
*/
final class MultipartParser {
private static final Log logger = LogFactory.getLog(MultipartParser.class);
private final int maxHeadersSize;
private final int bufferSize;
/**
* Create a new multipart parser instance.
*
* @param maxHeadersSize the maximum buffered header size
* @param bufferSize the size of the reading buffer
*/
MultipartParser(int maxHeadersSize, int bufferSize) {
this.maxHeadersSize = maxHeadersSize;
this.bufferSize = bufferSize;
}
/**
* Parses the given stream of bytes into events published to the {@link PartListener}.
* @param input the input stream
* @param boundary the multipart boundary, as found in the {@code Content-Type} header
* @param headersCharset the charset to use for decoding headers
* @param listener a listener for parsed tokens
*/
public void parse(InputStream input, byte[] boundary, Charset headersCharset, PartListener listener) {
InternalParser internalParser = new InternalParser(boundary, headersCharset, listener);
try {
while (true) {
byte[] read = input.readNBytes(this.bufferSize);
if (read.length == 0) {
break;
}
internalParser.state.data(DefaultDataBufferFactory.sharedInstance.wrap(read));
}
internalParser.state.complete();
}
catch (IOException ex) {
internalParser.state.dispose();
listener.onError(new HttpMessageConversionException("Could not decode multipart message", ex));
}
}
private final class InternalParser {
private final byte[] boundary;
private final Charset headersCharset;
private final PartListener listener;
private State state;
InternalParser(byte[] boundary, Charset headersCharset, PartListener listener) {
this.boundary = boundary;
this.headersCharset = headersCharset;
this.listener = listener;
this.state = new PreambleState();
}
void changeState(State newState, @Nullable DataBuffer remainder) {
if (logger.isTraceEnabled()) {
logger.trace("Changed state: " + this.state + " -> " + newState);
}
this.state.dispose();
this.state = newState;
if (remainder != null) {
if (remainder.readableByteCount() > 0) {
newState.data(remainder);
}
else {
DataBufferUtils.release(remainder);
}
}
}
/**
* Concatenates the given array of byte arrays.
*/
private static byte[] concat(byte[]... byteArrays) {
int len = 0;
for (byte[] byteArray : byteArrays) {
len += byteArray.length;
}
byte[] result = new byte[len];
len = 0;
for (byte[] byteArray : byteArrays) {
System.arraycopy(byteArray, 0, result, len, byteArray.length);
len += byteArray.length;
}
return result;
}
/**
* Represents the internal state of the {@link MultipartParser}.
* The flow for well-formed multipart messages is shown below:
* <p><pre>
* PREAMBLE
* |
* v
* +-->HEADERS--->DISPOSED
* | |
* | v
* +----BODY
* </pre>
* For malformed messages the flow ends in DISPOSED.
*/
private interface State {
byte[] CR_LF = {'\r', '\n'};
byte HYPHEN = '-';
byte[] TWO_HYPHENS = {HYPHEN, HYPHEN};
String HEADER_ENTRY_SEPARATOR = "\\r\\n";
void data(DataBuffer buf);
void complete();
default void dispose() {
}
}
/**
* The initial state of the parser. Looks for the first boundary of the
* multipart message. Note that the first boundary is not necessarily
* prefixed with {@code CR LF}; only the prefix {@code --} is required.
*/
private final class PreambleState implements State {
private final DataBufferUtils.Matcher firstBoundary;
PreambleState() {
this.firstBoundary = DataBufferUtils.matcher(concat(TWO_HYPHENS, InternalParser.this.boundary));
}
/**
* Looks for the first boundary in the given buffer. If found, changes
* state to {@link HeadersState}, and passes on the remainder of the
* buffer.
*/
@Override
public void data(DataBuffer buf) {
int endIdx = this.firstBoundary.match(buf);
if (endIdx != -1) {
if (logger.isTraceEnabled()) {
logger.trace("First boundary found @" + endIdx + " in " + buf);
}
DataBuffer preambleBuffer = buf.split(endIdx + 1);
DataBufferUtils.release(preambleBuffer);
changeState(new HeadersState(), buf);
}
else {
DataBufferUtils.release(buf);
}
}
@Override
public void complete() {
changeState(DisposedState.INSTANCE, null);
InternalParser.this.listener.onError(new HttpMessageConversionException("Could not find first boundary"));
}
@Override
public String toString() {
return "PREAMBLE";
}
}
/**
* The state of the parser dealing with part headers. Parses header
* buffers into a {@link HttpHeaders} instance, making sure that
* the amount does not exceed {@link #maxHeadersSize}.
*/
private final class HeadersState implements State {
private final DataBufferUtils.Matcher endHeaders = DataBufferUtils.matcher(concat(CR_LF, CR_LF));
private final List<DataBuffer> buffers = new ArrayList<>();
private int byteCount;
/**
* First checks whether the multipart boundary leading to this state
* was the final boundary. Then looks for the header-body boundary
* ({@code CR LF CR LF}) in the given buffer. If found, checks whether
* the size of all header buffers does not exceed {@link #maxHeadersSize},
* converts all buffers collected so far into a {@link HttpHeaders} object
* and changes to {@link BodyState}, passing the remainder of the
* buffer. If the boundary is not found, the buffer is collected if
* its size does not exceed {@link #maxHeadersSize}.
*/
@Override
public void data(DataBuffer buf) {
if (isLastBoundary(buf)) {
if (logger.isTraceEnabled()) {
logger.trace("Last boundary found in " + buf);
}
changeState(DisposedState.INSTANCE, buf);
InternalParser.this.listener.onComplete();
return;
}
int endIdx = this.endHeaders.match(buf);
if (endIdx != -1) {
if (logger.isTraceEnabled()) {
logger.trace("End of headers found @" + endIdx + " in " + buf);
}
this.byteCount += endIdx;
if (belowMaxHeaderSize(this.byteCount)) {
DataBuffer headerBuf = buf.split(endIdx + 1);
this.buffers.add(headerBuf);
emitHeaders();
changeState(new BodyState(), buf);
}
}
else {
this.byteCount += buf.readableByteCount();
if (belowMaxHeaderSize(this.byteCount)) {
this.buffers.add(buf);
}
}
}
private void emitHeaders() {
HttpHeaders headers = parseHeaders();
if (logger.isTraceEnabled()) {
logger.trace("Emitting headers: " + headers);
}
InternalParser.this.listener.onHeaders(headers);
}
/**
* If the given buffer is the first buffer, check whether it starts with {@code --}.
* If it is the second buffer, check whether it makes up {@code --} together with the first buffer.
*/
private boolean isLastBoundary(DataBuffer buf) {
return (this.buffers.isEmpty() &&
buf.readableByteCount() >= 2 &&
buf.getByte(0) == HYPHEN && buf.getByte(1) == HYPHEN) ||
(this.buffers.size() == 1 &&
this.buffers.get(0).readableByteCount() == 1 &&
this.buffers.get(0).getByte(0) == HYPHEN &&
buf.readableByteCount() >= 1 &&
buf.getByte(0) == HYPHEN);
}
/**
* Checks whether the given {@code count} is below or equal to {@link #maxHeadersSize}
* and throws a {@link DataBufferLimitException} if not.
*/
private boolean belowMaxHeaderSize(long count) {
if (count <= MultipartParser.this.maxHeadersSize) {
return true;
}
else {
InternalParser.this.listener.onError(
new HttpMessageConversionException("Part headers exceeded the memory usage limit of " +
MultipartParser.this.maxHeadersSize + " bytes"));
return false;
}
}
/**
* Parses the list of buffers into a {@link HttpHeaders} instance.
* Converts the joined buffers into a string using ISO=8859-1, and parses
* that string into key and values.
*/
private HttpHeaders parseHeaders() {
if (this.buffers.isEmpty()) {
return HttpHeaders.EMPTY;
}
DataBuffer joined = this.buffers.get(0).factory().join(this.buffers);
this.buffers.clear();
String string = joined.toString(InternalParser.this.headersCharset);
DataBufferUtils.release(joined);
String[] lines = string.split(HEADER_ENTRY_SEPARATOR);
HttpHeaders result = new HttpHeaders();
for (String line : lines) {
int idx = line.indexOf(':');
if (idx != -1) {
String name = line.substring(0, idx);
String value = line.substring(idx + 1);
while (value.startsWith(" ")) {
value = value.substring(1);
}
result.add(name, value);
}
}
return result;
}
@Override
public void complete() {
changeState(DisposedState.INSTANCE, null);
InternalParser.this.listener.onError(new HttpMessageConversionException("Could not find end of headers"));
}
@Override
public void dispose() {
this.buffers.forEach(DataBufferUtils::release);
}
@Override
public String toString() {
return "HEADERS";
}
}
/**
* The state of the parser dealing with multipart bodies. Relays
* data buffers as {@link PartListener#onBody(DataBuffer, boolean)}
* until the boundary is found (or rather: {@code CR LF - - boundary}).
*/
private final class BodyState implements State {
private final DataBufferUtils.Matcher boundaryMatcher;
private final int boundaryLength;
private final Deque<DataBuffer> queue = new ArrayDeque<>();
public BodyState() {
byte[] delimiter = concat(CR_LF, TWO_HYPHENS, InternalParser.this.boundary);
this.boundaryMatcher = DataBufferUtils.matcher(delimiter);
this.boundaryLength = delimiter.length;
}
/**
* Checks whether the (end of the) needle {@code CR LF - - boundary}
* can be found in {@code buffer}. If found, the needle can overflow into the
* previous buffer, so we calculate the length and slice the current
* and previous buffers accordingly. We then change to {@link HeadersState}
* and pass on the remainder of {@code buffer}. If the needle is not found, we
* enqueue {@code buffer}.
*/
@Override
public void data(DataBuffer buffer) {
int endIdx = this.boundaryMatcher.match(buffer);
if (endIdx != -1) {
DataBuffer boundaryBuffer = buffer.split(endIdx + 1);
if (logger.isTraceEnabled()) {
logger.trace("Boundary found @" + endIdx + " in " + buffer);
}
int len = endIdx - this.boundaryLength + 1 - boundaryBuffer.readPosition();
if (len > 0) {
// whole boundary in buffer.
// slice off the body part, and flush
DataBuffer body = boundaryBuffer.split(len);
DataBufferUtils.release(boundaryBuffer);
enqueue(body);
flush();
}
else if (len < 0) {
// boundary spans multiple buffers, and we've just found the end
// iterate over buffers in reverse order
DataBufferUtils.release(boundaryBuffer);
DataBuffer prev;
while ((prev = this.queue.pollLast()) != null) {
int prevByteCount = prev.readableByteCount();
int prevLen = prevByteCount + len;
if (prevLen >= 0) {
// slice body part of previous buffer, and flush it
DataBuffer body = prev.split(prevLen + prev.readPosition());
DataBufferUtils.release(prev);
enqueue(body);
flush();
break;
}
else {
// previous buffer only contains boundary bytes
DataBufferUtils.release(prev);
len += prevByteCount;
}
}
}
else /* if (len == 0) */ {
// buffer starts with complete delimiter, flush out the previous buffers
DataBufferUtils.release(boundaryBuffer);
flush();
}
changeState(new HeadersState(), buffer);
}
else {
enqueue(buffer);
}
}
/**
* Store the given buffer. Emit buffers that cannot contain boundary bytes,
* by iterating over the queue in reverse order, and summing buffer sizes.
* The first buffer that passes the boundary length and subsequent buffers
* are emitted (in the correct, non-reverse order).
*/
private void enqueue(DataBuffer buf) {
this.queue.add(buf);
int len = 0;
Deque<DataBuffer> emit = new ArrayDeque<>();
for (Iterator<DataBuffer> iterator = this.queue.descendingIterator(); iterator.hasNext(); ) {
DataBuffer previous = iterator.next();
if (len > this.boundaryLength) {
// addFirst to negate iterating in reverse order
emit.addFirst(previous);
iterator.remove();
}
len += previous.readableByteCount();
}
emit.forEach(buffer -> InternalParser.this.listener.onBody(buffer, false));
}
private void flush() {
for (Iterator<DataBuffer> iterator = this.queue.iterator(); iterator.hasNext(); ) {
DataBuffer buffer = iterator.next();
boolean last = !iterator.hasNext();
InternalParser.this.listener.onBody(buffer, last);
}
this.queue.clear();
}
@Override
public void complete() {
changeState(DisposedState.INSTANCE, null);
String msg = "Could not find end of body (␍␊--" +
new String(InternalParser.this.boundary, StandardCharsets.UTF_8) +
")";
InternalParser.this.listener.onError(new HttpMessageConversionException(msg));
}
@Override
public void dispose() {
this.queue.forEach(DataBufferUtils::release);
this.queue.clear();
}
@Override
public String toString() {
return "BODY";
}
}
/**
* The state of the parser when finished, either due to seeing the final
* boundary or to a malformed message. Releases all incoming buffers.
*/
private static final class DisposedState implements State {
public static final DisposedState INSTANCE = new DisposedState();
private DisposedState() {
}
@Override
public void data(DataBuffer buf) {
DataBufferUtils.release(buf);
}
@Override
public void complete() {
}
@Override
public String toString() {
return "DISPOSED";
}
}
}
/**
* Listen for part events while parsing the inbound stream of data.
*/
interface PartListener {
/**
* Handle {@link HttpHeaders} for a part.
*/
void onHeaders(HttpHeaders headers);
/**
* Handle a piece of data for a body part.
* @param buffer a chunk of body
* @param last whether this is the last chunk for the part
*/
void onBody(DataBuffer buffer, boolean last);
/**
* Handle the completion event for the Multipart message.
*/
void onComplete();
/**
* Handle any error thrown during the parsing phase.
*/
void onError(Throwable error);
}
}

47
spring-web/src/main/java/org/springframework/http/converter/multipart/MultipartUtils.java

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
/*
* 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.http.converter.multipart;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
/**
* Various static utility methods for dealing with multipart parsing.
* @author Arjen Poutsma
* @author Brian Clozel
*/
abstract class MultipartUtils {
/**
* Return the character set of the given headers, as defined in the
* {@link HttpHeaders#getContentType()} header.
*/
static Charset charset(HttpHeaders headers) {
MediaType contentType = headers.getContentType();
if (contentType != null) {
Charset charset = contentType.getCharset();
if (charset != null) {
return charset;
}
}
return StandardCharsets.UTF_8;
}
}

69
spring-web/src/main/java/org/springframework/http/converter/multipart/Part.java

@ -0,0 +1,69 @@ @@ -0,0 +1,69 @@
/*
* Copyright 2002-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.http.converter.multipart;
import java.io.IOException;
import java.io.InputStream;
import org.springframework.http.HttpHeaders;
/**
* Representation for a part in a "multipart/form-data" request.
*
* <p>The origin of a multipart request may be a browser form in which case each
* part is either a {@link FormFieldPart} or a {@link FilePart}.
*
* <p>Multipart requests may also be used outside a browser for data of any
* content type (for example, JSON, PDF, etc).
*
* @author Brian Clozel
* @author Sebastien Deleuze
* @author Rossen Stoyanchev
* @since 7.1
* @see <a href="https://tools.ietf.org/html/rfc7578">RFC 7578 (multipart/form-data)</a>
* @see <a href="https://tools.ietf.org/html/rfc2183">RFC 2183 (Content-Disposition)</a>
* @see <a href="https://www.w3.org/TR/html5/forms.html#multipart-form-data">HTML5 (multipart forms)</a>
*/
public interface Part {
/**
* Return the name of the part in the multipart form.
* @return the name of the part, never {@code null} or empty
*/
String name();
/**
* Return the headers associated with the part.
*/
HttpHeaders headers();
/**
* Return the content for this part.
* <p>Note that for a {@link FormFieldPart} the content may be accessed
* more easily via {@link FormFieldPart#value()}.
*/
InputStream content() throws IOException;
/**
* Delete the underlying storage for this part.
*/
default void delete() throws IOException {
}
}

394
spring-web/src/main/java/org/springframework/http/converter/multipart/PartGenerator.java

@ -0,0 +1,394 @@ @@ -0,0 +1,394 @@
/*
* 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.http.converter.multipart;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayDeque;
import java.util.List;
import java.util.Queue;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBuffer;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConversionException;
import org.springframework.util.FastByteArrayOutputStream;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
/**
* {@link MultipartParser.PartListener Listen} to a stream of part tokens
* and return a {@code MultiValueMap<String, Part>} as a result.
*
* @author Brian Clozel
* @author Arjen Poutsma
*/
final class PartGenerator implements MultipartParser.PartListener {
private static final Log logger = LogFactory.getLog(PartGenerator.class);
private final MultiValueMap<String, Part> parts = new LinkedMultiValueMap<>();
private final int maxInMemorySize;
private final long maxDiskUsagePerPart;
private final int maxParts;
private final Path fileStorageDirectory;
private int partCount;
private State state;
PartGenerator(int maxInMemorySize, long maxDiskUsagePerPart, int maxParts, Path fileStorageDirectory) {
this.maxInMemorySize = maxInMemorySize;
this.maxDiskUsagePerPart = maxDiskUsagePerPart;
this.maxParts = maxParts;
this.fileStorageDirectory = fileStorageDirectory;
this.state = new InitialState();
}
/**
* Return the collected parts.
*/
public MultiValueMap<String, Part> getParts() {
return this.parts;
}
@Override
public void onHeaders(HttpHeaders headers) {
if (isFormField(headers)) {
this.state = new FormFieldState(headers);
}
else {
this.state = new InMemoryState(headers);
}
}
private static boolean isFormField(HttpHeaders headers) {
MediaType contentType = headers.getContentType();
return (contentType == null || MediaType.TEXT_PLAIN.equalsTypeAndSubtype(contentType)) &&
headers.getContentDisposition().getFilename() == null;
}
@Override
public void onBody(DataBuffer buffer, boolean last) {
try {
this.state.onBody(buffer, last);
}
catch (Throwable ex) {
deleteParts();
throw ex;
}
}
void deleteParts() {
try {
for (List<Part> partList : this.parts.values()) {
for (Part part : partList) {
part.delete();
}
}
}
catch (IOException ex) {
// ignored
}
}
@Override
public void onComplete() {
if (logger.isTraceEnabled()) {
logger.trace("Finished reading " + this.partCount + " part(s)");
}
}
@Override
public void onError(Throwable error) {
deleteParts();
throw new HttpMessageConversionException("Cannot decode multipart body", error);
}
void addPart(Part part) {
if (this.maxParts != -1 && this.partCount == this.maxParts) {
throw new HttpMessageConversionException("Maximum number of parts exceeded: " + this.maxParts);
}
try {
this.partCount++;
this.parts.add(part.name(), part);
}
catch (Exception exc) {
throw new HttpMessageConversionException("Part #" + this.partCount + " is unnamed", exc);
}
}
/**
* Represents the internal state of the {@link PartGenerator} for creating a single {@link Part}.
* {@link State} instances are stateful, and created when a new
* {@link MultipartParser.PartListener#onHeaders(HttpHeaders) headers instance} is accepted.
* The following rules determine which state the creator will have:
* <ol>
* <li>If the part is a {@linkplain #isFormField(HttpHeaders) form field},
* the creator will be in the {@link FormFieldState}.</li>
* <li>Otherwise, the creator will initially be in the
* {@link InMemoryState}, but will switch over to {@link FileState}
* when the part byte count exceeds {@link #maxInMemorySize}</li>
* </ol>
*/
private interface State {
/**
* Invoked when a {@link MultipartParser.PartListener#onBody(DataBuffer, boolean)} is received.
*/
void onBody(DataBuffer dataBuffer, boolean last);
}
/**
* The initial state of the creator. Throws an exception for {@link #onBody(DataBuffer, boolean)}.
*/
private static final class InitialState implements State {
private InitialState() {
}
@Override
public void onBody(DataBuffer dataBuffer, boolean last) {
DataBufferUtils.release(dataBuffer);
throw new HttpMessageConversionException("Body token not expected");
}
@Override
public String toString() {
return "INITIAL";
}
}
/**
* The creator state when a form field is received.
* Stores all body buffers in memory (up until {@link #maxInMemorySize}).
*/
private final class FormFieldState implements State {
private final FastByteArrayOutputStream value = new FastByteArrayOutputStream();
private final HttpHeaders headers;
public FormFieldState(HttpHeaders headers) {
this.headers = headers;
}
@Override
public void onBody(DataBuffer dataBuffer, boolean last) {
int size = this.value.size() + dataBuffer.readableByteCount();
if (PartGenerator.this.maxInMemorySize == -1 ||
size < PartGenerator.this.maxInMemorySize) {
store(dataBuffer);
}
else {
DataBufferUtils.release(dataBuffer);
throw new HttpMessageConversionException("Form field value exceeded the memory usage limit of " +
PartGenerator.this.maxInMemorySize + " bytes");
}
if (last) {
byte[] bytes = this.value.toByteArrayUnsafe();
String value = new String(bytes, MultipartUtils.charset(this.headers));
FormFieldPart formFieldPart = DefaultParts.formFieldPart(this.headers, value);
PartGenerator.this.addPart(formFieldPart);
}
}
private void store(DataBuffer dataBuffer) {
try {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
this.value.write(bytes);
}
catch (IOException ex) {
throw new HttpMessageConversionException("Cannot store multipart body", ex);
}
finally {
DataBufferUtils.release(dataBuffer);
}
}
@Override
public String toString() {
return "FORM-FIELD";
}
}
/**
* The creator state when not handling a form field.
* Stores all received buffers in a queue.
* If the byte count exceeds {@link #maxInMemorySize}, the creator state
* is changed to {@link FileState}.
*/
private final class InMemoryState implements State {
private final Queue<DataBuffer> content = new ArrayDeque<>();
private long byteCount;
private final HttpHeaders headers;
public InMemoryState(HttpHeaders headers) {
this.headers = headers;
}
@Override
public void onBody(DataBuffer dataBuffer, boolean last) {
this.byteCount += dataBuffer.readableByteCount();
if (PartGenerator.this.maxInMemorySize == -1 ||
this.byteCount <= PartGenerator.this.maxInMemorySize) {
this.content.add(dataBuffer);
if (last) {
emitMemoryPart();
}
}
else {
switchToFile(dataBuffer, last);
}
}
private void switchToFile(DataBuffer current, boolean last) {
FileState newState = new FileState(this.headers, PartGenerator.this.fileStorageDirectory);
this.content.forEach(newState::writeBuffer);
newState.onBody(current, last);
PartGenerator.this.state = newState;
}
private void emitMemoryPart() {
byte[] bytes = new byte[(int) this.byteCount];
int idx = 0;
for (DataBuffer buffer : this.content) {
int len = buffer.readableByteCount();
buffer.read(bytes, idx, len);
idx += len;
DataBufferUtils.release(buffer);
}
this.content.clear();
DefaultDataBuffer content = DefaultDataBufferFactory.sharedInstance.wrap(bytes);
Part part = DefaultParts.part(this.headers, content);
PartGenerator.this.addPart(part);
}
@Override
public String toString() {
return "IN-MEMORY";
}
}
/**
* The creator state when writing for a temporary file.
* {@link InMemoryState} initially switches to this state when the byte
* count exceeds {@link #maxInMemorySize}.
*/
private final class FileState implements State {
private final HttpHeaders headers;
private final Path file;
private final OutputStream outputStream;
private long byteCount;
public FileState(HttpHeaders headers, Path folder) {
this.headers = headers;
this.file = createFile(folder);
this.outputStream = createOutputStream(this.file);
}
@Override
public void onBody(DataBuffer dataBuffer, boolean last) {
this.byteCount += dataBuffer.readableByteCount();
if (PartGenerator.this.maxDiskUsagePerPart == -1 || this.byteCount <= PartGenerator.this.maxDiskUsagePerPart) {
writeBuffer(dataBuffer);
if (last) {
Part part = DefaultParts.part(this.headers, this.file);
PartGenerator.this.addPart(part);
}
}
else {
try {
this.outputStream.close();
}
catch (IOException exc) {
// ignored
}
throw new HttpMessageConversionException("Part exceeded the disk usage limit of " +
PartGenerator.this.maxDiskUsagePerPart + " bytes");
}
}
private Path createFile(Path directory) {
try {
Path tempFile = Files.createTempFile(directory, null, ".multipart");
if (logger.isTraceEnabled()) {
logger.trace("Storing multipart data in file " + tempFile);
}
return tempFile;
}
catch (IOException ex) {
throw new UncheckedIOException("Could not create temp file in " + directory, ex);
}
}
private OutputStream createOutputStream(Path file) {
try {
return Files.newOutputStream(file, StandardOpenOption.WRITE);
}
catch (IOException ex) {
throw new UncheckedIOException("Could not write to temp file " + file, ex);
}
}
private void writeBuffer(DataBuffer dataBuffer) {
try (InputStream in = dataBuffer.asInputStream()) {
in.transferTo(this.outputStream);
this.outputStream.flush();
}
catch (IOException exc) {
throw new UncheckedIOException("Could not write to temp file ", exc);
}
finally {
DataBufferUtils.release(dataBuffer);
}
}
@Override
public String toString() {
return "WRITE-FILE";
}
}
}

7
spring-web/src/main/java/org/springframework/http/converter/multipart/package-info.java

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
/**
* Provides an HttpMessageConverter for Multipart support.
*/
@NullMarked
package org.springframework.http.converter.multipart;
import org.jspecify.annotations.NullMarked;

554
spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverterTests.java

@ -0,0 +1,554 @@ @@ -0,0 +1,554 @@
/*
* 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.http.converter.multipart;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import javax.xml.transform.Source;
import javax.xml.transform.stream.StreamSource;
import org.apache.tomcat.util.http.fileupload.FileItem;
import org.apache.tomcat.util.http.fileupload.FileUpload;
import org.apache.tomcat.util.http.fileupload.RequestContext;
import org.apache.tomcat.util.http.fileupload.UploadContext;
import org.apache.tomcat.util.http.fileupload.disk.DiskFileItemFactory;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.core.ResolvableType;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.StreamingHttpOutputMessage;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConversionException;
import org.springframework.http.converter.ResourceHttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
import org.springframework.http.converter.xml.SourceHttpMessageConverter;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.testfixture.http.MockHttpInputMessage;
import org.springframework.web.testfixture.http.MockHttpOutputMessage;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Collections.singletonMap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.http.MediaType.MULTIPART_FORM_DATA;
import static org.springframework.http.MediaType.MULTIPART_MIXED;
import static org.springframework.http.MediaType.MULTIPART_RELATED;
import static org.springframework.http.MediaType.TEXT_XML;
/**
* Tests for {@link MultipartHttpMessageConverter}.
*
* @author Brian Clozel
* @author Arjen Poutsma
* @author Rossen Stoyanchev
* @author Sam Brannen
* @author Sebastien Deleuze
*/
class MultipartHttpMessageConverterTests {
private MultipartHttpMessageConverter converter = new MultipartHttpMessageConverter(
List.of(new StringHttpMessageConverter(), new ByteArrayHttpMessageConverter(),
new ResourceHttpMessageConverter(), new JacksonJsonHttpMessageConverter())
);
@Test
void canRead() {
assertCanRead(MULTIPART_FORM_DATA);
assertCanRead(MULTIPART_MIXED);
assertCanRead(MULTIPART_RELATED);
assertCanRead(ResolvableType.forClass(LinkedMultiValueMap.class), MULTIPART_FORM_DATA);
assertCanRead(ResolvableType.forClassWithGenerics(LinkedMultiValueMap.class, String.class, Part.class), MULTIPART_FORM_DATA);
assertCannotRead(ResolvableType.forClassWithGenerics(LinkedMultiValueMap.class, String.class, Object.class), MULTIPART_FORM_DATA);
}
@Test
void canWrite() {
assertCanWrite(MULTIPART_FORM_DATA);
assertCanWrite(MULTIPART_MIXED);
assertCanWrite(MULTIPART_RELATED);
assertCanWrite(new MediaType("multipart", "form-data", UTF_8));
assertCanWrite(MediaType.ALL);
assertCanWrite(null);
assertCanWrite(ResolvableType.forClassWithGenerics(LinkedMultiValueMap.class, String.class, Object.class), MULTIPART_FORM_DATA);
}
@Test
void setSupportedMediaTypes() {
this.converter.setSupportedMediaTypes(List.of(MULTIPART_FORM_DATA));
assertCannotWrite(MULTIPART_MIXED);
this.converter.setSupportedMediaTypes(List.of(MULTIPART_MIXED));
assertCanWrite(MULTIPART_MIXED);
}
@Test
void addSupportedMediaTypes() {
this.converter.setSupportedMediaTypes(List.of(MULTIPART_FORM_DATA));
assertCannotWrite(MULTIPART_MIXED);
this.converter.addSupportedMediaTypes(MULTIPART_RELATED);
assertCanWrite(MULTIPART_RELATED);
}
private void assertCanRead(MediaType mediaType) {
assertCanRead(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), mediaType);
}
private void assertCanRead(ResolvableType type, MediaType mediaType) {
assertThat(this.converter.canRead(type, mediaType)).as(type + " : " + mediaType).isTrue();
}
private void assertCannotRead(ResolvableType type, MediaType mediaType) {
assertThat(this.converter.canRead(type, mediaType)).as(type + " : " + mediaType).isFalse();
}
private void assertCanWrite(ResolvableType type, MediaType mediaType) {
assertThat(this.converter.canWrite(type, MultiValueMap.class, mediaType)).as(type + " : " + mediaType).isTrue();
}
private void assertCanWrite(MediaType mediaType) {
assertCanWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), mediaType);
}
private void assertCannotWrite(MediaType mediaType) {
Class<?> clazz = MultiValueMap.class;
assertThat(this.converter.canWrite(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isFalse();
}
@Nested
class ReadingTests {
@Test
void readMultipartFiles() throws Exception {
MockHttpInputMessage response = createMultipartResponse("files.multipart", "----WebKitFormBoundaryG8fJ50opQOML0oGD");
MultiValueMap<String, Part> result = converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null);
assertThat(result).containsOnlyKeys("file2");
assertThat(result.get("file2")).anyMatch(isFilePart("a.txt"))
.anyMatch(isFilePart("b.txt"));
}
@Test
void readMultipartBrowser() throws Exception {
MockHttpInputMessage response = createMultipartResponse("firefox.multipart", "---------------------------18399284482060392383840973206");
MultiValueMap<String, Part> result = converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null);
assertThat(result).containsOnlyKeys("file1", "file2", "text1", "text2");
assertThat(result.get("file1")).anyMatch(isFilePart("a.txt"));
assertThat(result.get("file2")).anyMatch(isFilePart("a.txt"))
.anyMatch(isFilePart("b.txt"));
assertThat(result.get("text1")).anyMatch(isFormData("text1", "a"));
assertThat(result.get("text2")).anyMatch(isFormData("text2", "b"));
}
@Test
void readMultipartInvalid() throws Exception {
MockHttpInputMessage response = createMultipartResponse("garbage-1.multipart", "boundary");
assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null))
.isInstanceOf(HttpMessageConversionException.class).hasMessage("Cannot decode multipart body");
}
@Test
void readMultipartMaxPartsExceeded() throws Exception {
MockHttpInputMessage response = createMultipartResponse("files.multipart", "----WebKitFormBoundaryG8fJ50opQOML0oGD");
converter.setMaxParts(1);
assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null))
.isInstanceOf(HttpMessageConversionException.class).hasMessage("Maximum number of parts exceeded: 1");
}
@Test
void readMultipartToFiles() throws Exception {
MockHttpInputMessage response = createMultipartResponse("files.multipart", "----WebKitFormBoundaryG8fJ50opQOML0oGD");
converter.setMaxInMemorySize(1);
MultiValueMap<String, Part> result = converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null);
assertThat(result).containsOnlyKeys("file2");
}
@Test
void readMultipartMaxInMemoryExceeded() throws Exception {
MockHttpInputMessage response = createMultipartResponse("firefox.multipart", "---------------------------18399284482060392383840973206");
converter.setMaxInMemorySize(1);
assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null))
.isInstanceOf(HttpMessageConversionException.class).hasMessage("Form field value exceeded the memory usage limit of 1 bytes");
}
@Test
void readMultipartMaxDiskUsageExceeded() throws Exception {
MockHttpInputMessage response = createMultipartResponse("firefox.multipart", "---------------------------18399284482060392383840973206");
converter.setMaxInMemorySize(30);
converter.setMaxDiskUsagePerPart(35);
assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null))
.isInstanceOf(HttpMessageConversionException.class).hasMessage("Part exceeded the disk usage limit of 35 bytes");
}
@Test
void readMultipartUnnamedPart() throws Exception {
MockHttpInputMessage response = createMultipartResponse("simple.multipart", "simple-boundary");
assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null))
.isInstanceOf(HttpMessageConversionException.class).hasMessage("Part #1 is unnamed");
}
private MockHttpInputMessage createMultipartResponse(String fileName, String boundary) throws Exception {
InputStream stream = createStream(fileName);
MockHttpInputMessage response = new MockHttpInputMessage(stream);
response.getHeaders().setContentType(
new MediaType("multipart", "form-data", singletonMap("boundary", boundary)));
return response;
}
private InputStream createStream(String fileName) throws IOException {
Resource resource = new ClassPathResource("/org/springframework/http/multipart/" + fileName);
return resource.getInputStream();
}
private Predicate<Part> isFilePart(String fileName) {
return part -> part instanceof FilePart filePart &&
filePart.filename().equals(fileName);
}
private Predicate<Part> isFormData(String name, String value) {
return part -> part instanceof FormFieldPart formFieldPart &&
formFieldPart.name().equals(name) &&
formFieldPart.value().equals(value);
}
}
@Nested
class WritingTests {
@Test
void writeMultipart() throws Exception {
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
parts.add("name 1", "value 1");
parts.add("name 2", "value 2+1");
parts.add("name 2", "value 2+2");
parts.add("name 3", null);
Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg");
parts.add("logo", logo);
// SPR-12108
Resource utf8 = new ClassPathResource("/org/springframework/http/converter/logo.jpg") {
@Override
public String getFilename() {
return "Hall\u00F6le.jpg";
}
};
parts.add("utf8", utf8);
MyBean myBean = new MyBean();
myBean.setString("foo");
HttpHeaders entityHeaders = new HttpHeaders();
entityHeaders.setContentType(APPLICATION_JSON);
HttpEntity<MyBean> entity = new HttpEntity<>(myBean, entityHeaders);
parts.add("json", entity);
Map<String, String> parameters = new LinkedHashMap<>(2);
parameters.put("charset", UTF_8.name());
parameters.put("foo", "bar");
StreamingMockHttpOutputMessage outputMessage = new StreamingMockHttpOutputMessage();
converter.write(parts, new MediaType("multipart", "form-data", parameters), outputMessage);
final MediaType contentType = outputMessage.getHeaders().getContentType();
assertThat(contentType.getParameters()).containsKeys("charset", "boundary", "foo"); // gh-21568, gh-25839
// see if Commons FileUpload can read what we wrote
FileUpload fileUpload = new FileUpload();
fileUpload.setFileItemFactory(new DiskFileItemFactory());
RequestContext requestContext = new MockHttpOutputMessageRequestContext(outputMessage);
List<FileItem> items = fileUpload.parseRequest(requestContext);
assertThat(items).hasSize(6);
FileItem item = items.get(0);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("name 1");
assertThat(item.getString()).isEqualTo("value 1");
item = items.get(1);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("name 2");
assertThat(item.getString()).isEqualTo("value 2+1");
item = items.get(2);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("name 2");
assertThat(item.getString()).isEqualTo("value 2+2");
item = items.get(3);
assertThat(item.isFormField()).isFalse();
assertThat(item.getFieldName()).isEqualTo("logo");
assertThat(item.getName()).isEqualTo("logo.jpg");
assertThat(item.getContentType()).isEqualTo("image/jpeg");
assertThat(item.getSize()).isEqualTo(logo.getFile().length());
item = items.get(4);
assertThat(item.isFormField()).isFalse();
assertThat(item.getFieldName()).isEqualTo("utf8");
assertThat(item.getName()).isEqualTo("Hall\u00F6le.jpg");
assertThat(item.getContentType()).isEqualTo("image/jpeg");
assertThat(item.getSize()).isEqualTo(logo.getFile().length());
item = items.get(5);
assertThat(item.getFieldName()).isEqualTo("json");
assertThat(item.getContentType()).isEqualTo("application/json");
assertThat(outputMessage.wasRepeatable()).isTrue();
}
@Test
void writeMultipartWithSourceHttpMessageConverter() throws Exception {
converter = new MultipartHttpMessageConverter(List.of(
new StringHttpMessageConverter(),
new ResourceHttpMessageConverter(),
new SourceHttpMessageConverter<>()));
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
parts.add("name 1", "value 1");
parts.add("name 2", "value 2+1");
parts.add("name 2", "value 2+2");
parts.add("name 3", null);
Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg");
parts.add("logo", logo);
// SPR-12108
Resource utf8 = new ClassPathResource("/org/springframework/http/converter/logo.jpg") {
@Override
public String getFilename() {
return "Hall\u00F6le.jpg";
}
};
parts.add("utf8", utf8);
Source xml = new StreamSource(new StringReader("<root><child/></root>"));
HttpHeaders entityHeaders = new HttpHeaders();
entityHeaders.setContentType(TEXT_XML);
HttpEntity<Source> entity = new HttpEntity<>(xml, entityHeaders);
parts.add("xml", entity);
Map<String, String> parameters = new LinkedHashMap<>(2);
parameters.put("charset", UTF_8.name());
parameters.put("foo", "bar");
StreamingMockHttpOutputMessage outputMessage = new StreamingMockHttpOutputMessage();
converter.write(parts, new MediaType("multipart", "form-data", parameters), outputMessage);
final MediaType contentType = outputMessage.getHeaders().getContentType();
assertThat(contentType.getParameters()).containsKeys("charset", "boundary", "foo"); // gh-21568, gh-25839
// see if Commons FileUpload can read what we wrote
FileUpload fileUpload = new FileUpload();
fileUpload.setFileItemFactory(new DiskFileItemFactory());
RequestContext requestContext = new MockHttpOutputMessageRequestContext(outputMessage);
List<FileItem> items = fileUpload.parseRequest(requestContext);
assertThat(items).hasSize(6);
FileItem item = items.get(0);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("name 1");
assertThat(item.getString()).isEqualTo("value 1");
item = items.get(1);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("name 2");
assertThat(item.getString()).isEqualTo("value 2+1");
item = items.get(2);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("name 2");
assertThat(item.getString()).isEqualTo("value 2+2");
item = items.get(3);
assertThat(item.isFormField()).isFalse();
assertThat(item.getFieldName()).isEqualTo("logo");
assertThat(item.getName()).isEqualTo("logo.jpg");
assertThat(item.getContentType()).isEqualTo("image/jpeg");
assertThat(item.getSize()).isEqualTo(logo.getFile().length());
item = items.get(4);
assertThat(item.isFormField()).isFalse();
assertThat(item.getFieldName()).isEqualTo("utf8");
assertThat(item.getName()).isEqualTo("Hall\u00F6le.jpg");
assertThat(item.getContentType()).isEqualTo("image/jpeg");
assertThat(item.getSize()).isEqualTo(logo.getFile().length());
item = items.get(5);
assertThat(item.getFieldName()).isEqualTo("xml");
assertThat(item.getContentType()).isEqualTo("text/xml");
assertThat(outputMessage.wasRepeatable()).isFalse();
}
@Test // SPR-13309
void writeMultipartOrder() throws Exception {
MyBean myBean = new MyBean();
myBean.setString("foo");
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
parts.add("part1", myBean);
HttpHeaders entityHeaders = new HttpHeaders();
entityHeaders.setContentType(APPLICATION_JSON);
HttpEntity<MyBean> entity = new HttpEntity<>(myBean, entityHeaders);
parts.add("part2", entity);
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
converter.setMultipartCharset(UTF_8);
converter.write(parts, new MediaType("multipart", "form-data", UTF_8), outputMessage);
final MediaType contentType = outputMessage.getHeaders().getContentType();
assertThat(contentType.getParameter("boundary")).as("No boundary found").isNotNull();
// see if Commons FileUpload can read what we wrote
FileUpload fileUpload = new FileUpload();
fileUpload.setFileItemFactory(new DiskFileItemFactory());
RequestContext requestContext = new MockHttpOutputMessageRequestContext(outputMessage);
List<FileItem> items = fileUpload.parseRequest(requestContext);
assertThat(items).hasSize(2);
FileItem item = items.get(0);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("part1");
assertThat(item.getString()).isEqualTo("{\"string\":\"foo\"}");
item = items.get(1);
assertThat(item.isFormField()).isTrue();
assertThat(item.getFieldName()).isEqualTo("part2");
assertThat(item.getString())
.contains("{\"string\":\"foo\"}");
}
@Test
void writeMultipartCharset() throws Exception {
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>();
Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg");
parts.add("logo", logo);
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
converter.write(parts, MULTIPART_FORM_DATA, outputMessage);
MediaType contentType = outputMessage.getHeaders().getContentType();
Map<String, String> parameters = contentType.getParameters();
assertThat(parameters).containsOnlyKeys("boundary");
converter.setCharset(StandardCharsets.ISO_8859_1);
outputMessage = new MockHttpOutputMessage();
converter.write(parts, MULTIPART_FORM_DATA, outputMessage);
parameters = outputMessage.getHeaders().getContentType().getParameters();
assertThat(parameters).containsOnlyKeys("boundary", "charset");
assertThat(parameters).containsEntry("charset", "ISO-8859-1");
}
}
private static class StreamingMockHttpOutputMessage extends MockHttpOutputMessage implements StreamingHttpOutputMessage {
private boolean repeatable;
public boolean wasRepeatable() {
return this.repeatable;
}
@Override
public void setBody(Body body) {
try {
this.repeatable = body.repeatable();
body.writeTo(getBody());
}
catch (IOException ex) {
throw new RuntimeException(ex);
}
}
}
private static class MockHttpOutputMessageRequestContext implements UploadContext {
private final MockHttpOutputMessage outputMessage;
private final byte[] body;
private MockHttpOutputMessageRequestContext(MockHttpOutputMessage outputMessage) {
this.outputMessage = outputMessage;
this.body = this.outputMessage.getBodyAsBytes();
}
@Override
public String getCharacterEncoding() {
MediaType type = this.outputMessage.getHeaders().getContentType();
return (type != null && type.getCharset() != null ? type.getCharset().name() : null);
}
@Override
public String getContentType() {
MediaType type = this.outputMessage.getHeaders().getContentType();
return (type != null ? type.toString() : null);
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(body);
}
@Override
public long contentLength() {
return body.length;
}
}
public static class MyBean {
private String string;
public String getString() {
return this.string;
}
public void setString(String string) {
this.string = string;
}
}
}

282
spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartParserTests.java

@ -0,0 +1,282 @@ @@ -0,0 +1,282 @@
/*
* Copyright 2002-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.http.converter.multipart;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.function.Consumer;
import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConversionException;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link MultipartParser}.
*
* @author Brian Clozel
* @author Arjen Poutsma
*/
class MultipartParserTests {
private static final MediaType TEXT_PLAIN_ASCII = new MediaType("text", "plain", StandardCharsets.US_ASCII);
@Test
void simple() throws Exception {
TestListener listener = new TestListener();
parse("simple.multipart", "simple-boundary", listener);
listener.assertHeader(headers -> assertThat(headers.isEmpty()).isTrue())
.assertBodyChunk("This is implicitly typed plain ASCII text.\r\nIt does NOT end with a linebreak.")
.assertHeader(headers -> assertThat(headers.getContentType()).isEqualTo(TEXT_PLAIN_ASCII))
.assertBodyChunk("This is explicitly typed plain ASCII text.\r\nIt DOES end with a linebreak.\r\n")
.assertComplete();
}
@Test
void noHeaders() throws Exception {
TestListener listener = new TestListener();
parse("no-header.multipart", "boundary", listener);
listener.assertHeader(headers -> assertThat(headers.isEmpty()).isTrue())
.assertBodyChunk("a")
.assertComplete();
}
@Test
void noEndBoundary() throws Exception {
TestListener listener = new TestListener();
parse("no-end-boundary.multipart", "boundary", listener);
assertThat(listener.error).isInstanceOf(HttpMessageConversionException.class);
}
@Test
void garbage() throws Exception {
TestListener listener = new TestListener();
parse("garbage-1.multipart", "boundary", listener);
assertThat(listener.error).isInstanceOf(HttpMessageConversionException.class);
}
@Test
void noEndHeader() throws Exception {
TestListener listener = new TestListener();
parse("no-end-header.multipart", "boundary", listener);
assertThat(listener.error).isInstanceOf(HttpMessageConversionException.class);
}
@Test
void noEndBody() throws Exception {
TestListener listener = new TestListener();
parse("no-end-body.multipart", "boundary", listener);
assertThat(listener.error).isInstanceOf(HttpMessageConversionException.class);
}
@Test
void noBody() throws Exception {
TestListener listener = new TestListener();
parse("no-body.multipart", "boundary", listener);
listener.assertHeader(headers -> assertThat(headers.hasHeaderValues("Part", List.of("1"))).isTrue())
.assertHeader(headers -> assertThat(headers.hasHeaderValues("Part", List.of("2"))).isTrue())
.assertBodyChunk("a")
.assertComplete();
}
@Test
void firefox() throws Exception {
TestListener listener = new TestListener();
parse("firefox.multipart",
"---------------------------18399284482060392383840973206", listener);
listener.assertHeadersFormField("text1")
.assertBodyChunk("a")
.assertHeadersFormField("text2")
.assertBodyChunk("b")
.assertHeadersFile("file1", "a.txt")
.assertBodyChunk()
.assertHeadersFile("file2", "a.txt")
.assertBodyChunk()
.assertHeadersFile("file2", "b.txt")
.assertBodyChunk()
.assertComplete();
}
@Test
void chrome() throws Exception {
TestListener listener = new TestListener();
parse("chrome.multipart",
"----WebKitFormBoundaryEveBLvRT65n21fwU", listener);
listener.assertHeadersFormField("text1")
.assertBodyChunk("a")
.assertHeadersFormField("text2")
.assertBodyChunk("b")
.assertHeadersFile("file1", "a.txt")
.assertBodyChunk()
.assertHeadersFile("file2", "a.txt")
.assertBodyChunk()
.assertHeadersFile("file2", "b.txt")
.assertBodyChunk()
.assertComplete();
}
@Test
void safari() throws Exception {
TestListener listener = new TestListener();
parse("safari.multipart",
"----WebKitFormBoundaryG8fJ50opQOML0oGD", listener);
listener.assertHeadersFormField("text1")
.assertBodyChunk("a")
.assertHeadersFormField("text2")
.assertBodyChunk("b")
.assertHeadersFile("file1", "a.txt")
.assertBodyChunk()
.assertHeadersFile("file2", "a.txt")
.assertBodyChunk()
.assertHeadersFile("file2", "b.txt")
.assertBodyChunk()
.assertComplete();
}
@Test
void utf8Headers() throws Exception {
TestListener listener = new TestListener();
parse("utf8.multipart", "simple-boundary", listener);
listener.assertHeader(headers ->
assertThat(headers.hasHeaderValues("Føø", List.of("Bår"))).isTrue())
.assertBodyChunk("This is plain ASCII text.")
.assertComplete();
}
private InputStream createStream(String fileName) throws IOException {
Resource resource = new ClassPathResource("/org/springframework/http/multipart/" + fileName);
return resource.getInputStream();
}
private void parse(String fileName, String boundary, MultipartParser.PartListener listener) throws Exception {
try (InputStream input = createStream(fileName)) {
MultipartParser multipartParser = new MultipartParser(10 * 1024, 4 * 1024);
multipartParser.parse(input, boundary.getBytes(UTF_8), StandardCharsets.UTF_8, listener);
}
}
static class TestListener implements MultipartParser.PartListener {
Deque<Object> received = new ArrayDeque<>();
boolean complete;
Throwable error;
@Override
public void onHeaders(@NonNull HttpHeaders headers) {
this.received.add(headers);
}
@Override
public void onBody(@NonNull DataBuffer buffer, boolean last) {
this.received.add(buffer);
}
@Override
public void onComplete() {
this.complete = true;
}
@Override
public void onError(@NonNull Throwable error) {
this.error = error;
}
TestListener assertHeader(Consumer<HttpHeaders> headersConsumer) {
Object value = received.pollFirst();
assertThat(value).isInstanceOf(HttpHeaders.class);
headersConsumer.accept((HttpHeaders) value);
return this;
}
TestListener assertHeadersFormField(String expectedName) {
return assertHeader(headers -> {
ContentDisposition cd = headers.getContentDisposition();
assertThat(cd.isFormData()).isTrue();
assertThat(cd.getName()).isEqualTo(expectedName);
});
}
TestListener assertHeadersFile(String expectedName, String expectedFilename) {
return assertHeader(headers -> {
ContentDisposition cd = headers.getContentDisposition();
assertThat(cd.isFormData()).isTrue();
assertThat(cd.getName()).isEqualTo(expectedName);
assertThat(cd.getFilename()).isEqualTo(expectedFilename);
});
}
TestListener assertBodyChunk(Consumer<DataBuffer> bodyConsumer) {
Object value = received.pollFirst();
assertThat(value).isInstanceOf(DataBuffer.class);
bodyConsumer.accept((DataBuffer) value);
DataBufferUtils.release((DataBuffer) value);
return this;
}
TestListener assertBodyChunk(String bodyContent) {
return assertBodyChunk(buffer -> {
String actual = buffer.toString(UTF_8);
assertThat(actual).isEqualTo(bodyContent);
});
}
TestListener assertBodyChunk() {
return assertBodyChunk(buffer -> {
});
}
TestListener assertLastBodyChunk() {
if (!received.isEmpty()) {
assertThat(received.peek()).isNotInstanceOf(DataBuffer.class);
}
return this;
}
void assertComplete() {
assertThat(this.complete).isTrue();
}
}
}
Loading…
Cancel
Save