mirror of
https://github.com/spring-projects/spring-framework
synced 2026-06-08 17:33:33 +00:00
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
This commit is contained in:
+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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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<String, Part>}, and
|
||||
* write {@link MultiValueMap MultiValueMap<String, Object>} 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<String, Object> form = new LinkedMultiValueMap<>();
|
||||
* form.add("field 1", "value 1");
|
||||
* form.add("field 2", "value 2");
|
||||
* form.add("field 2", "value 3");
|
||||
* form.add("field 3", 4);
|
||||
*
|
||||
* ResponseEntity<Void> 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<String, Object> parts = new LinkedMultiValueMap<>();
|
||||
* parts.add("field 1", "value 1");
|
||||
* parts.add("file", new ClassPathResource("myFile.jpg"));
|
||||
*
|
||||
* ResponseEntity<Void> 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<String, Part> 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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Provides an HttpMessageConverter for Multipart support.
|
||||
*/
|
||||
@NullMarked
|
||||
package org.springframework.http.converter.multipart;
|
||||
|
||||
import org.jspecify.annotations.NullMarked;
|
||||
+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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user