Browse Source
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-36255pull/36549/head
11 changed files with 2960 additions and 0 deletions
@ -0,0 +1,297 @@
@@ -0,0 +1,297 @@
|
||||
/* |
||||
* Copyright 2002-present the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.http.converter.multipart; |
||||
|
||||
import java.io.ByteArrayInputStream; |
||||
import java.io.IOException; |
||||
import java.io.InputStream; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.nio.file.StandardCopyOption; |
||||
import java.nio.file.StandardOpenOption; |
||||
|
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.http.ContentDisposition; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.util.Assert; |
||||
|
||||
/** |
||||
* Default implementations of {@link Part} and subtypes. |
||||
* |
||||
* @author Arjen Poutsma |
||||
* @author Brian Clozel |
||||
*/ |
||||
abstract class DefaultParts { |
||||
|
||||
/** |
||||
* Create a new {@link FormFieldPart} with the given parameters. |
||||
* @param headers the part headers |
||||
* @param value the form field value |
||||
* @return the created part |
||||
*/ |
||||
public static FormFieldPart formFieldPart(HttpHeaders headers, String value) { |
||||
Assert.notNull(headers, "Headers must not be null"); |
||||
Assert.notNull(value, "Value must not be null"); |
||||
|
||||
return new DefaultFormFieldPart(headers, value); |
||||
} |
||||
|
||||
/** |
||||
* Create a new {@link Part} or {@link FilePart} based on a flux of data |
||||
* buffers. Returns {@link FilePart} if the {@code Content-Disposition} of |
||||
* the given headers contains a filename, or a "normal" {@link Part} |
||||
* otherwise. |
||||
* @param headers the part headers |
||||
* @param dataBuffer the content of the part |
||||
* @return {@link Part} or {@link FilePart}, depending on {@link HttpHeaders#getContentDisposition()} |
||||
*/ |
||||
public static Part part(HttpHeaders headers, DataBuffer dataBuffer) { |
||||
Assert.notNull(headers, "Headers must not be null"); |
||||
Assert.notNull(dataBuffer, "DataBuffer must not be null"); |
||||
|
||||
return partInternal(headers, new DataBufferContent(dataBuffer)); |
||||
} |
||||
|
||||
/** |
||||
* Create a new {@link Part} or {@link FilePart} based on the given file. |
||||
* Returns {@link FilePart} if the {@code Content-Disposition} of the given |
||||
* headers contains a filename, or a "normal" {@link Part} otherwise |
||||
* @param headers the part headers |
||||
* @param file the file |
||||
* @return {@link Part} or {@link FilePart}, depending on {@link HttpHeaders#getContentDisposition()} |
||||
*/ |
||||
public static Part part(HttpHeaders headers, Path file) { |
||||
Assert.notNull(headers, "Headers must not be null"); |
||||
Assert.notNull(file, "File must not be null"); |
||||
|
||||
return partInternal(headers, new FileContent(file)); |
||||
} |
||||
|
||||
|
||||
private static Part partInternal(HttpHeaders headers, Content content) { |
||||
String filename = headers.getContentDisposition().getFilename(); |
||||
if (filename != null) { |
||||
return new DefaultFilePart(headers, content); |
||||
} |
||||
else { |
||||
return new DefaultPart(headers, content); |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Abstract base class for {@link Part} implementations. |
||||
*/ |
||||
private abstract static class AbstractPart implements Part { |
||||
|
||||
private final HttpHeaders headers; |
||||
|
||||
protected AbstractPart(HttpHeaders headers) { |
||||
Assert.notNull(headers, "HttpHeaders is required"); |
||||
this.headers = headers; |
||||
} |
||||
|
||||
@Override |
||||
public String name() { |
||||
String name = headers().getContentDisposition().getName(); |
||||
Assert.state(name != null, "No part name available"); |
||||
return name; |
||||
} |
||||
|
||||
@Override |
||||
public HttpHeaders headers() { |
||||
return this.headers; |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Default implementation of {@link FormFieldPart}. |
||||
*/ |
||||
private static class DefaultFormFieldPart extends AbstractPart implements FormFieldPart { |
||||
|
||||
private final String value; |
||||
|
||||
public DefaultFormFieldPart(HttpHeaders headers, String value) { |
||||
super(headers); |
||||
this.value = value; |
||||
} |
||||
|
||||
@Override |
||||
public InputStream content() { |
||||
byte[] bytes = this.value.getBytes(MultipartUtils.charset(headers())); |
||||
return new ByteArrayInputStream(bytes); |
||||
} |
||||
|
||||
@Override |
||||
public String value() { |
||||
return this.value; |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
String name = headers().getContentDisposition().getName(); |
||||
if (name != null) { |
||||
return "DefaultFormFieldPart{" + name() + "}"; |
||||
} |
||||
else { |
||||
return "DefaultFormFieldPart"; |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Default implementation of {@link Part}. |
||||
*/ |
||||
private static class DefaultPart extends AbstractPart { |
||||
|
||||
protected final Content content; |
||||
|
||||
public DefaultPart(HttpHeaders headers, Content content) { |
||||
super(headers); |
||||
this.content = content; |
||||
} |
||||
|
||||
@Override |
||||
public InputStream content() throws IOException { |
||||
return this.content.content(); |
||||
} |
||||
|
||||
@Override |
||||
public void delete() throws IOException { |
||||
this.content.delete(); |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
String name = headers().getContentDisposition().getName(); |
||||
if (name != null) { |
||||
return "DefaultPart{" + name + "}"; |
||||
} |
||||
else { |
||||
return "DefaultPart"; |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Default implementation of {@link FilePart}. |
||||
*/ |
||||
private static final class DefaultFilePart extends DefaultPart implements FilePart { |
||||
|
||||
public DefaultFilePart(HttpHeaders headers, Content content) { |
||||
super(headers, content); |
||||
} |
||||
|
||||
@Override |
||||
public String filename() { |
||||
String filename = headers().getContentDisposition().getFilename(); |
||||
Assert.state(filename != null, "No filename found"); |
||||
return filename; |
||||
} |
||||
|
||||
@Override |
||||
public void transferTo(Path dest) throws IOException { |
||||
this.content.transferTo(dest); |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
ContentDisposition contentDisposition = headers().getContentDisposition(); |
||||
String name = contentDisposition.getName(); |
||||
String filename = contentDisposition.getFilename(); |
||||
if (name != null) { |
||||
return "DefaultFilePart{" + name + " (" + filename + ")}"; |
||||
} |
||||
else { |
||||
return "DefaultFilePart{(" + filename + ")}"; |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Part content abstraction. |
||||
*/ |
||||
private interface Content { |
||||
|
||||
InputStream content() throws IOException; |
||||
|
||||
void transferTo(Path dest) throws IOException; |
||||
|
||||
void delete() throws IOException; |
||||
} |
||||
|
||||
|
||||
/** |
||||
* {@code Content} implementation based on an in-memory {@code InputStream}. |
||||
*/ |
||||
private static final class DataBufferContent implements Content { |
||||
|
||||
private final DataBuffer content; |
||||
|
||||
public DataBufferContent(DataBuffer content) { |
||||
this.content = content; |
||||
} |
||||
|
||||
@Override |
||||
public InputStream content() { |
||||
return this.content.asInputStream(); |
||||
} |
||||
|
||||
@Override |
||||
public void transferTo(Path dest) throws IOException { |
||||
Files.copy(this.content.asInputStream(), dest, StandardCopyOption.REPLACE_EXISTING); |
||||
} |
||||
|
||||
@Override |
||||
public void delete() throws IOException { |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* {@code Content} implementation based on a file. |
||||
*/ |
||||
private static final class FileContent implements Content { |
||||
|
||||
private final Path file; |
||||
|
||||
public FileContent(Path file) { |
||||
this.file = file; |
||||
} |
||||
|
||||
@Override |
||||
public InputStream content() throws IOException { |
||||
return Files.newInputStream(this.file.toAbsolutePath(), StandardOpenOption.READ); |
||||
} |
||||
|
||||
@Override |
||||
public void transferTo(Path dest) throws IOException { |
||||
Files.copy(this.file, dest, StandardCopyOption.REPLACE_EXISTING); |
||||
} |
||||
|
||||
@Override |
||||
public void delete() throws IOException { |
||||
Files.delete(this.file); |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
|
||||
/* |
||||
* Copyright 2002-present the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.http.converter.multipart; |
||||
|
||||
import java.io.File; |
||||
import java.io.IOException; |
||||
import java.nio.file.Path; |
||||
|
||||
/** |
||||
* Specialization of {@link Part} that represents an uploaded file received in |
||||
* a multipart request. |
||||
* |
||||
* @author Brian Clozel |
||||
* @author Rossen Stoyanchev |
||||
* @author Juergen Hoeller |
||||
* @since 7.1 |
||||
*/ |
||||
public interface FilePart extends Part { |
||||
|
||||
/** |
||||
* Return the original filename in the client's filesystem. |
||||
* <p><strong>Note:</strong> Please keep in mind this filename is supplied |
||||
* by the client and should not be used blindly. In addition to not using |
||||
* the directory portion, the file name could also contain characters such |
||||
* as ".." and others that can be used maliciously. It is recommended to not |
||||
* use this filename directly. Preferably generate a unique one and save |
||||
* this one somewhere for reference, if necessary. |
||||
* @return the original filename, or the empty String if no file has been chosen |
||||
* in the multipart form, or {@code null} if not defined or not available |
||||
* @see <a href="https://tools.ietf.org/html/rfc7578#section-4.2">RFC 7578, Section 4.2</a> |
||||
* @see <a href="https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload">Unrestricted File Upload</a> |
||||
*/ |
||||
String filename(); |
||||
|
||||
/** |
||||
* Convenience method to copy the content of the file in this part to the |
||||
* given destination file. If the destination file already exists, it will |
||||
* be truncated first. |
||||
* <p>The default implementation delegates to {@link #transferTo(Path)}. |
||||
* @param dest the target file |
||||
* @throws IllegalStateException if the part isn't a file |
||||
* @see #transferTo(Path) |
||||
*/ |
||||
default void transferTo(File dest) throws IOException { |
||||
transferTo(dest.toPath()); |
||||
} |
||||
|
||||
/** |
||||
* Convenience method to copy the content of the file in this part to the |
||||
* given destination file. If the destination file already exists, it will |
||||
* be truncated first. |
||||
* @param dest the target file |
||||
* @throws IllegalStateException if the part isn't a file |
||||
* @see #transferTo(File) |
||||
*/ |
||||
void transferTo(Path dest) throws IOException; |
||||
|
||||
} |
||||
@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
|
||||
/* |
||||
* Copyright 2002-present the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.http.converter.multipart; |
||||
|
||||
|
||||
/** |
||||
* Specialization of {@link Part} for a form field. |
||||
* |
||||
* @author Brian Clozel |
||||
* @author Rossen Stoyanchev |
||||
* @since 7.1 |
||||
*/ |
||||
public interface FormFieldPart extends Part { |
||||
|
||||
/** |
||||
* Return the form field value. |
||||
*/ |
||||
String value(); |
||||
|
||||
} |
||||
@ -0,0 +1,651 @@
@@ -0,0 +1,651 @@
|
||||
/* |
||||
* Copyright 2026-present the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.http.converter.multipart; |
||||
|
||||
import java.io.FilterOutputStream; |
||||
import java.io.IOException; |
||||
import java.io.OutputStream; |
||||
import java.nio.charset.Charset; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.util.ArrayList; |
||||
import java.util.Collections; |
||||
import java.util.LinkedHashMap; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
import java.util.Objects; |
||||
|
||||
import org.jspecify.annotations.Nullable; |
||||
|
||||
import org.springframework.core.ResolvableType; |
||||
import org.springframework.core.io.Resource; |
||||
import org.springframework.core.io.buffer.DataBufferLimitException; |
||||
import org.springframework.http.ContentDisposition; |
||||
import org.springframework.http.HttpEntity; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.HttpInputMessage; |
||||
import org.springframework.http.HttpOutputMessage; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.StreamingHttpOutputMessage; |
||||
import org.springframework.http.converter.AbstractHttpMessageConverter; |
||||
import org.springframework.http.converter.ByteArrayHttpMessageConverter; |
||||
import org.springframework.http.converter.HttpMessageConverter; |
||||
import org.springframework.http.converter.HttpMessageNotReadableException; |
||||
import org.springframework.http.converter.HttpMessageNotWritableException; |
||||
import org.springframework.http.converter.ResourceHttpMessageConverter; |
||||
import org.springframework.http.converter.SmartHttpMessageConverter; |
||||
import org.springframework.http.converter.StringHttpMessageConverter; |
||||
import org.springframework.util.Assert; |
||||
import org.springframework.util.MimeTypeUtils; |
||||
import org.springframework.util.MultiValueMap; |
||||
|
||||
/** |
||||
* Implementation of {@link HttpMessageConverter} to read and write |
||||
* multipart data (for example, file uploads). |
||||
* |
||||
* <p>This converter can read {@code "multipart/form-data"} |
||||
* and {@code "multipart/mixed"} messages as |
||||
* {@link MultiValueMap MultiValueMap<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() { |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,553 @@
@@ -0,0 +1,553 @@
|
||||
/* |
||||
* Copyright 2026-present the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.http.converter.multipart; |
||||
|
||||
import java.io.IOException; |
||||
import java.io.InputStream; |
||||
import java.nio.charset.Charset; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.util.ArrayDeque; |
||||
import java.util.ArrayList; |
||||
import java.util.Deque; |
||||
import java.util.Iterator; |
||||
import java.util.List; |
||||
|
||||
import org.apache.commons.logging.Log; |
||||
import org.apache.commons.logging.LogFactory; |
||||
import org.jspecify.annotations.Nullable; |
||||
|
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.core.io.buffer.DataBufferLimitException; |
||||
import org.springframework.core.io.buffer.DataBufferUtils; |
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.converter.HttpMessageConversionException; |
||||
|
||||
/** |
||||
* Read a Multipart message as a byte stream and parse its content |
||||
* and signals them to the {@link PartListener}. |
||||
* |
||||
* @author Brian Clozel |
||||
* @author Arjen Poutsma |
||||
*/ |
||||
final class MultipartParser { |
||||
|
||||
private static final Log logger = LogFactory.getLog(MultipartParser.class); |
||||
|
||||
private final int maxHeadersSize; |
||||
|
||||
private final int bufferSize; |
||||
|
||||
/** |
||||
* Create a new multipart parser instance. |
||||
* |
||||
* @param maxHeadersSize the maximum buffered header size |
||||
* @param bufferSize the size of the reading buffer |
||||
*/ |
||||
MultipartParser(int maxHeadersSize, int bufferSize) { |
||||
this.maxHeadersSize = maxHeadersSize; |
||||
this.bufferSize = bufferSize; |
||||
} |
||||
|
||||
/** |
||||
* Parses the given stream of bytes into events published to the {@link PartListener}. |
||||
* @param input the input stream |
||||
* @param boundary the multipart boundary, as found in the {@code Content-Type} header |
||||
* @param headersCharset the charset to use for decoding headers |
||||
* @param listener a listener for parsed tokens |
||||
*/ |
||||
public void parse(InputStream input, byte[] boundary, Charset headersCharset, PartListener listener) { |
||||
|
||||
InternalParser internalParser = new InternalParser(boundary, headersCharset, listener); |
||||
try { |
||||
while (true) { |
||||
byte[] read = input.readNBytes(this.bufferSize); |
||||
if (read.length == 0) { |
||||
break; |
||||
} |
||||
internalParser.state.data(DefaultDataBufferFactory.sharedInstance.wrap(read)); |
||||
} |
||||
internalParser.state.complete(); |
||||
} |
||||
catch (IOException ex) { |
||||
internalParser.state.dispose(); |
||||
listener.onError(new HttpMessageConversionException("Could not decode multipart message", ex)); |
||||
} |
||||
} |
||||
|
||||
private final class InternalParser { |
||||
|
||||
private final byte[] boundary; |
||||
|
||||
private final Charset headersCharset; |
||||
|
||||
private final PartListener listener; |
||||
|
||||
private State state; |
||||
|
||||
InternalParser(byte[] boundary, Charset headersCharset, PartListener listener) { |
||||
this.boundary = boundary; |
||||
this.headersCharset = headersCharset; |
||||
this.listener = listener; |
||||
this.state = new PreambleState(); |
||||
} |
||||
|
||||
void changeState(State newState, @Nullable DataBuffer remainder) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Changed state: " + this.state + " -> " + newState); |
||||
} |
||||
this.state.dispose(); |
||||
this.state = newState; |
||||
if (remainder != null) { |
||||
if (remainder.readableByteCount() > 0) { |
||||
newState.data(remainder); |
||||
} |
||||
else { |
||||
DataBufferUtils.release(remainder); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Concatenates the given array of byte arrays. |
||||
*/ |
||||
private static byte[] concat(byte[]... byteArrays) { |
||||
int len = 0; |
||||
for (byte[] byteArray : byteArrays) { |
||||
len += byteArray.length; |
||||
} |
||||
byte[] result = new byte[len]; |
||||
len = 0; |
||||
for (byte[] byteArray : byteArrays) { |
||||
System.arraycopy(byteArray, 0, result, len, byteArray.length); |
||||
len += byteArray.length; |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
/** |
||||
* Represents the internal state of the {@link MultipartParser}. |
||||
* The flow for well-formed multipart messages is shown below: |
||||
* <p><pre> |
||||
* PREAMBLE |
||||
* | |
||||
* v |
||||
* +-->HEADERS--->DISPOSED |
||||
* | | |
||||
* | v |
||||
* +----BODY |
||||
* </pre> |
||||
* For malformed messages the flow ends in DISPOSED. |
||||
*/ |
||||
private interface State { |
||||
|
||||
byte[] CR_LF = {'\r', '\n'}; |
||||
|
||||
byte HYPHEN = '-'; |
||||
|
||||
byte[] TWO_HYPHENS = {HYPHEN, HYPHEN}; |
||||
|
||||
String HEADER_ENTRY_SEPARATOR = "\\r\\n"; |
||||
|
||||
void data(DataBuffer buf); |
||||
|
||||
void complete(); |
||||
|
||||
default void dispose() { |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* The initial state of the parser. Looks for the first boundary of the |
||||
* multipart message. Note that the first boundary is not necessarily |
||||
* prefixed with {@code CR LF}; only the prefix {@code --} is required. |
||||
*/ |
||||
private final class PreambleState implements State { |
||||
|
||||
private final DataBufferUtils.Matcher firstBoundary; |
||||
|
||||
|
||||
PreambleState() { |
||||
this.firstBoundary = DataBufferUtils.matcher(concat(TWO_HYPHENS, InternalParser.this.boundary)); |
||||
} |
||||
|
||||
/** |
||||
* Looks for the first boundary in the given buffer. If found, changes |
||||
* state to {@link HeadersState}, and passes on the remainder of the |
||||
* buffer. |
||||
*/ |
||||
@Override |
||||
public void data(DataBuffer buf) { |
||||
int endIdx = this.firstBoundary.match(buf); |
||||
if (endIdx != -1) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("First boundary found @" + endIdx + " in " + buf); |
||||
} |
||||
DataBuffer preambleBuffer = buf.split(endIdx + 1); |
||||
DataBufferUtils.release(preambleBuffer); |
||||
changeState(new HeadersState(), buf); |
||||
} |
||||
else { |
||||
DataBufferUtils.release(buf); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void complete() { |
||||
changeState(DisposedState.INSTANCE, null); |
||||
InternalParser.this.listener.onError(new HttpMessageConversionException("Could not find first boundary")); |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "PREAMBLE"; |
||||
} |
||||
|
||||
} |
||||
|
||||
/** |
||||
* The state of the parser dealing with part headers. Parses header |
||||
* buffers into a {@link HttpHeaders} instance, making sure that |
||||
* the amount does not exceed {@link #maxHeadersSize}. |
||||
*/ |
||||
private final class HeadersState implements State { |
||||
|
||||
private final DataBufferUtils.Matcher endHeaders = DataBufferUtils.matcher(concat(CR_LF, CR_LF)); |
||||
|
||||
private final List<DataBuffer> buffers = new ArrayList<>(); |
||||
|
||||
private int byteCount; |
||||
|
||||
|
||||
/** |
||||
* First checks whether the multipart boundary leading to this state |
||||
* was the final boundary. Then looks for the header-body boundary |
||||
* ({@code CR LF CR LF}) in the given buffer. If found, checks whether |
||||
* the size of all header buffers does not exceed {@link #maxHeadersSize}, |
||||
* converts all buffers collected so far into a {@link HttpHeaders} object |
||||
* and changes to {@link BodyState}, passing the remainder of the |
||||
* buffer. If the boundary is not found, the buffer is collected if |
||||
* its size does not exceed {@link #maxHeadersSize}. |
||||
*/ |
||||
@Override |
||||
public void data(DataBuffer buf) { |
||||
if (isLastBoundary(buf)) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Last boundary found in " + buf); |
||||
} |
||||
changeState(DisposedState.INSTANCE, buf); |
||||
InternalParser.this.listener.onComplete(); |
||||
return; |
||||
} |
||||
int endIdx = this.endHeaders.match(buf); |
||||
if (endIdx != -1) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("End of headers found @" + endIdx + " in " + buf); |
||||
} |
||||
this.byteCount += endIdx; |
||||
if (belowMaxHeaderSize(this.byteCount)) { |
||||
DataBuffer headerBuf = buf.split(endIdx + 1); |
||||
this.buffers.add(headerBuf); |
||||
emitHeaders(); |
||||
changeState(new BodyState(), buf); |
||||
} |
||||
} |
||||
else { |
||||
this.byteCount += buf.readableByteCount(); |
||||
if (belowMaxHeaderSize(this.byteCount)) { |
||||
this.buffers.add(buf); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private void emitHeaders() { |
||||
HttpHeaders headers = parseHeaders(); |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Emitting headers: " + headers); |
||||
} |
||||
InternalParser.this.listener.onHeaders(headers); |
||||
} |
||||
|
||||
/** |
||||
* If the given buffer is the first buffer, check whether it starts with {@code --}. |
||||
* If it is the second buffer, check whether it makes up {@code --} together with the first buffer. |
||||
*/ |
||||
private boolean isLastBoundary(DataBuffer buf) { |
||||
return (this.buffers.isEmpty() && |
||||
buf.readableByteCount() >= 2 && |
||||
buf.getByte(0) == HYPHEN && buf.getByte(1) == HYPHEN) || |
||||
(this.buffers.size() == 1 && |
||||
this.buffers.get(0).readableByteCount() == 1 && |
||||
this.buffers.get(0).getByte(0) == HYPHEN && |
||||
buf.readableByteCount() >= 1 && |
||||
buf.getByte(0) == HYPHEN); |
||||
} |
||||
|
||||
/** |
||||
* Checks whether the given {@code count} is below or equal to {@link #maxHeadersSize} |
||||
* and throws a {@link DataBufferLimitException} if not. |
||||
*/ |
||||
private boolean belowMaxHeaderSize(long count) { |
||||
if (count <= MultipartParser.this.maxHeadersSize) { |
||||
return true; |
||||
} |
||||
else { |
||||
InternalParser.this.listener.onError( |
||||
new HttpMessageConversionException("Part headers exceeded the memory usage limit of " + |
||||
MultipartParser.this.maxHeadersSize + " bytes")); |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Parses the list of buffers into a {@link HttpHeaders} instance. |
||||
* Converts the joined buffers into a string using ISO=8859-1, and parses |
||||
* that string into key and values. |
||||
*/ |
||||
private HttpHeaders parseHeaders() { |
||||
if (this.buffers.isEmpty()) { |
||||
return HttpHeaders.EMPTY; |
||||
} |
||||
DataBuffer joined = this.buffers.get(0).factory().join(this.buffers); |
||||
this.buffers.clear(); |
||||
String string = joined.toString(InternalParser.this.headersCharset); |
||||
DataBufferUtils.release(joined); |
||||
String[] lines = string.split(HEADER_ENTRY_SEPARATOR); |
||||
HttpHeaders result = new HttpHeaders(); |
||||
for (String line : lines) { |
||||
int idx = line.indexOf(':'); |
||||
if (idx != -1) { |
||||
String name = line.substring(0, idx); |
||||
String value = line.substring(idx + 1); |
||||
while (value.startsWith(" ")) { |
||||
value = value.substring(1); |
||||
} |
||||
result.add(name, value); |
||||
} |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
@Override |
||||
public void complete() { |
||||
changeState(DisposedState.INSTANCE, null); |
||||
InternalParser.this.listener.onError(new HttpMessageConversionException("Could not find end of headers")); |
||||
} |
||||
|
||||
@Override |
||||
public void dispose() { |
||||
this.buffers.forEach(DataBufferUtils::release); |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "HEADERS"; |
||||
} |
||||
|
||||
} |
||||
|
||||
/** |
||||
* The state of the parser dealing with multipart bodies. Relays |
||||
* data buffers as {@link PartListener#onBody(DataBuffer, boolean)} |
||||
* until the boundary is found (or rather: {@code CR LF - - boundary}). |
||||
*/ |
||||
private final class BodyState implements State { |
||||
|
||||
private final DataBufferUtils.Matcher boundaryMatcher; |
||||
|
||||
private final int boundaryLength; |
||||
|
||||
private final Deque<DataBuffer> queue = new ArrayDeque<>(); |
||||
|
||||
public BodyState() { |
||||
byte[] delimiter = concat(CR_LF, TWO_HYPHENS, InternalParser.this.boundary); |
||||
this.boundaryMatcher = DataBufferUtils.matcher(delimiter); |
||||
this.boundaryLength = delimiter.length; |
||||
} |
||||
|
||||
/** |
||||
* Checks whether the (end of the) needle {@code CR LF - - boundary} |
||||
* can be found in {@code buffer}. If found, the needle can overflow into the |
||||
* previous buffer, so we calculate the length and slice the current |
||||
* and previous buffers accordingly. We then change to {@link HeadersState} |
||||
* and pass on the remainder of {@code buffer}. If the needle is not found, we |
||||
* enqueue {@code buffer}. |
||||
*/ |
||||
@Override |
||||
public void data(DataBuffer buffer) { |
||||
int endIdx = this.boundaryMatcher.match(buffer); |
||||
if (endIdx != -1) { |
||||
DataBuffer boundaryBuffer = buffer.split(endIdx + 1); |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Boundary found @" + endIdx + " in " + buffer); |
||||
} |
||||
int len = endIdx - this.boundaryLength + 1 - boundaryBuffer.readPosition(); |
||||
if (len > 0) { |
||||
// whole boundary in buffer.
|
||||
// slice off the body part, and flush
|
||||
DataBuffer body = boundaryBuffer.split(len); |
||||
DataBufferUtils.release(boundaryBuffer); |
||||
enqueue(body); |
||||
flush(); |
||||
} |
||||
else if (len < 0) { |
||||
// boundary spans multiple buffers, and we've just found the end
|
||||
// iterate over buffers in reverse order
|
||||
DataBufferUtils.release(boundaryBuffer); |
||||
DataBuffer prev; |
||||
while ((prev = this.queue.pollLast()) != null) { |
||||
int prevByteCount = prev.readableByteCount(); |
||||
int prevLen = prevByteCount + len; |
||||
if (prevLen >= 0) { |
||||
// slice body part of previous buffer, and flush it
|
||||
DataBuffer body = prev.split(prevLen + prev.readPosition()); |
||||
DataBufferUtils.release(prev); |
||||
enqueue(body); |
||||
flush(); |
||||
break; |
||||
} |
||||
else { |
||||
// previous buffer only contains boundary bytes
|
||||
DataBufferUtils.release(prev); |
||||
len += prevByteCount; |
||||
} |
||||
} |
||||
} |
||||
else /* if (len == 0) */ { |
||||
// buffer starts with complete delimiter, flush out the previous buffers
|
||||
DataBufferUtils.release(boundaryBuffer); |
||||
flush(); |
||||
} |
||||
|
||||
changeState(new HeadersState(), buffer); |
||||
} |
||||
else { |
||||
enqueue(buffer); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Store the given buffer. Emit buffers that cannot contain boundary bytes, |
||||
* by iterating over the queue in reverse order, and summing buffer sizes. |
||||
* The first buffer that passes the boundary length and subsequent buffers |
||||
* are emitted (in the correct, non-reverse order). |
||||
*/ |
||||
private void enqueue(DataBuffer buf) { |
||||
this.queue.add(buf); |
||||
|
||||
int len = 0; |
||||
Deque<DataBuffer> emit = new ArrayDeque<>(); |
||||
for (Iterator<DataBuffer> iterator = this.queue.descendingIterator(); iterator.hasNext(); ) { |
||||
DataBuffer previous = iterator.next(); |
||||
if (len > this.boundaryLength) { |
||||
// addFirst to negate iterating in reverse order
|
||||
emit.addFirst(previous); |
||||
iterator.remove(); |
||||
} |
||||
len += previous.readableByteCount(); |
||||
} |
||||
emit.forEach(buffer -> InternalParser.this.listener.onBody(buffer, false)); |
||||
} |
||||
|
||||
private void flush() { |
||||
for (Iterator<DataBuffer> iterator = this.queue.iterator(); iterator.hasNext(); ) { |
||||
DataBuffer buffer = iterator.next(); |
||||
boolean last = !iterator.hasNext(); |
||||
InternalParser.this.listener.onBody(buffer, last); |
||||
} |
||||
this.queue.clear(); |
||||
} |
||||
|
||||
@Override |
||||
public void complete() { |
||||
changeState(DisposedState.INSTANCE, null); |
||||
String msg = "Could not find end of body (␍␊--" + |
||||
new String(InternalParser.this.boundary, StandardCharsets.UTF_8) + |
||||
")"; |
||||
InternalParser.this.listener.onError(new HttpMessageConversionException(msg)); |
||||
} |
||||
|
||||
@Override |
||||
public void dispose() { |
||||
this.queue.forEach(DataBufferUtils::release); |
||||
this.queue.clear(); |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "BODY"; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* The state of the parser when finished, either due to seeing the final |
||||
* boundary or to a malformed message. Releases all incoming buffers. |
||||
*/ |
||||
private static final class DisposedState implements State { |
||||
|
||||
public static final DisposedState INSTANCE = new DisposedState(); |
||||
|
||||
private DisposedState() { |
||||
} |
||||
|
||||
@Override |
||||
public void data(DataBuffer buf) { |
||||
DataBufferUtils.release(buf); |
||||
} |
||||
|
||||
@Override |
||||
public void complete() { |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "DISPOSED"; |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
|
||||
/** |
||||
* Listen for part events while parsing the inbound stream of data. |
||||
*/ |
||||
interface PartListener { |
||||
|
||||
/** |
||||
* Handle {@link HttpHeaders} for a part. |
||||
*/ |
||||
void onHeaders(HttpHeaders headers); |
||||
|
||||
/** |
||||
* Handle a piece of data for a body part. |
||||
* @param buffer a chunk of body |
||||
* @param last whether this is the last chunk for the part |
||||
*/ |
||||
void onBody(DataBuffer buffer, boolean last); |
||||
|
||||
/** |
||||
* Handle the completion event for the Multipart message. |
||||
*/ |
||||
void onComplete(); |
||||
|
||||
/** |
||||
* Handle any error thrown during the parsing phase. |
||||
*/ |
||||
void onError(Throwable error); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
|
||||
/* |
||||
* Copyright 2026-present the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.http.converter.multipart; |
||||
|
||||
import java.nio.charset.Charset; |
||||
import java.nio.charset.StandardCharsets; |
||||
|
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
|
||||
/** |
||||
* Various static utility methods for dealing with multipart parsing. |
||||
* @author Arjen Poutsma |
||||
* @author Brian Clozel |
||||
*/ |
||||
abstract class MultipartUtils { |
||||
|
||||
/** |
||||
* Return the character set of the given headers, as defined in the |
||||
* {@link HttpHeaders#getContentType()} header. |
||||
*/ |
||||
static Charset charset(HttpHeaders headers) { |
||||
MediaType contentType = headers.getContentType(); |
||||
if (contentType != null) { |
||||
Charset charset = contentType.getCharset(); |
||||
if (charset != null) { |
||||
return charset; |
||||
} |
||||
} |
||||
return StandardCharsets.UTF_8; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
|
||||
/* |
||||
* Copyright 2002-present the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.http.converter.multipart; |
||||
|
||||
import java.io.IOException; |
||||
import java.io.InputStream; |
||||
|
||||
import org.springframework.http.HttpHeaders; |
||||
|
||||
|
||||
/** |
||||
* Representation for a part in a "multipart/form-data" request. |
||||
* |
||||
* <p>The origin of a multipart request may be a browser form in which case each |
||||
* part is either a {@link FormFieldPart} or a {@link FilePart}. |
||||
* |
||||
* <p>Multipart requests may also be used outside a browser for data of any |
||||
* content type (for example, JSON, PDF, etc). |
||||
* |
||||
* @author Brian Clozel |
||||
* @author Sebastien Deleuze |
||||
* @author Rossen Stoyanchev |
||||
* @since 7.1 |
||||
* @see <a href="https://tools.ietf.org/html/rfc7578">RFC 7578 (multipart/form-data)</a> |
||||
* @see <a href="https://tools.ietf.org/html/rfc2183">RFC 2183 (Content-Disposition)</a> |
||||
* @see <a href="https://www.w3.org/TR/html5/forms.html#multipart-form-data">HTML5 (multipart forms)</a> |
||||
*/ |
||||
public interface Part { |
||||
|
||||
/** |
||||
* Return the name of the part in the multipart form. |
||||
* @return the name of the part, never {@code null} or empty |
||||
*/ |
||||
String name(); |
||||
|
||||
/** |
||||
* Return the headers associated with the part. |
||||
*/ |
||||
HttpHeaders headers(); |
||||
|
||||
/** |
||||
* Return the content for this part. |
||||
* <p>Note that for a {@link FormFieldPart} the content may be accessed |
||||
* more easily via {@link FormFieldPart#value()}. |
||||
*/ |
||||
InputStream content() throws IOException; |
||||
|
||||
/** |
||||
* Delete the underlying storage for this part. |
||||
*/ |
||||
default void delete() throws IOException { |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,394 @@
@@ -0,0 +1,394 @@
|
||||
/* |
||||
* Copyright 2026-present the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.http.converter.multipart; |
||||
|
||||
import java.io.IOException; |
||||
import java.io.InputStream; |
||||
import java.io.OutputStream; |
||||
import java.io.UncheckedIOException; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.nio.file.StandardOpenOption; |
||||
import java.util.ArrayDeque; |
||||
import java.util.List; |
||||
import java.util.Queue; |
||||
|
||||
import org.apache.commons.logging.Log; |
||||
import org.apache.commons.logging.LogFactory; |
||||
|
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.core.io.buffer.DataBufferUtils; |
||||
import org.springframework.core.io.buffer.DefaultDataBuffer; |
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.converter.HttpMessageConversionException; |
||||
import org.springframework.util.FastByteArrayOutputStream; |
||||
import org.springframework.util.LinkedMultiValueMap; |
||||
import org.springframework.util.MultiValueMap; |
||||
|
||||
/** |
||||
* {@link MultipartParser.PartListener Listen} to a stream of part tokens |
||||
* and return a {@code MultiValueMap<String, Part>} as a result. |
||||
* |
||||
* @author Brian Clozel |
||||
* @author Arjen Poutsma |
||||
*/ |
||||
final class PartGenerator implements MultipartParser.PartListener { |
||||
|
||||
private static final Log logger = LogFactory.getLog(PartGenerator.class); |
||||
|
||||
private final MultiValueMap<String, Part> parts = new LinkedMultiValueMap<>(); |
||||
|
||||
private final int maxInMemorySize; |
||||
|
||||
private final long maxDiskUsagePerPart; |
||||
|
||||
private final int maxParts; |
||||
|
||||
private final Path fileStorageDirectory; |
||||
|
||||
private int partCount; |
||||
|
||||
private State state; |
||||
|
||||
|
||||
PartGenerator(int maxInMemorySize, long maxDiskUsagePerPart, int maxParts, Path fileStorageDirectory) { |
||||
this.maxInMemorySize = maxInMemorySize; |
||||
this.maxDiskUsagePerPart = maxDiskUsagePerPart; |
||||
this.maxParts = maxParts; |
||||
this.fileStorageDirectory = fileStorageDirectory; |
||||
this.state = new InitialState(); |
||||
} |
||||
|
||||
/** |
||||
* Return the collected parts. |
||||
*/ |
||||
public MultiValueMap<String, Part> getParts() { |
||||
return this.parts; |
||||
} |
||||
|
||||
@Override |
||||
public void onHeaders(HttpHeaders headers) { |
||||
if (isFormField(headers)) { |
||||
this.state = new FormFieldState(headers); |
||||
} |
||||
else { |
||||
this.state = new InMemoryState(headers); |
||||
} |
||||
} |
||||
|
||||
private static boolean isFormField(HttpHeaders headers) { |
||||
MediaType contentType = headers.getContentType(); |
||||
return (contentType == null || MediaType.TEXT_PLAIN.equalsTypeAndSubtype(contentType)) && |
||||
headers.getContentDisposition().getFilename() == null; |
||||
} |
||||
|
||||
@Override |
||||
public void onBody(DataBuffer buffer, boolean last) { |
||||
try { |
||||
this.state.onBody(buffer, last); |
||||
} |
||||
catch (Throwable ex) { |
||||
deleteParts(); |
||||
throw ex; |
||||
} |
||||
} |
||||
|
||||
void deleteParts() { |
||||
try { |
||||
for (List<Part> partList : this.parts.values()) { |
||||
for (Part part : partList) { |
||||
part.delete(); |
||||
} |
||||
} |
||||
} |
||||
catch (IOException ex) { |
||||
// ignored
|
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void onComplete() { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Finished reading " + this.partCount + " part(s)"); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void onError(Throwable error) { |
||||
deleteParts(); |
||||
throw new HttpMessageConversionException("Cannot decode multipart body", error); |
||||
} |
||||
|
||||
void addPart(Part part) { |
||||
if (this.maxParts != -1 && this.partCount == this.maxParts) { |
||||
throw new HttpMessageConversionException("Maximum number of parts exceeded: " + this.maxParts); |
||||
} |
||||
try { |
||||
this.partCount++; |
||||
this.parts.add(part.name(), part); |
||||
} |
||||
catch (Exception exc) { |
||||
throw new HttpMessageConversionException("Part #" + this.partCount + " is unnamed", exc); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Represents the internal state of the {@link PartGenerator} for creating a single {@link Part}. |
||||
* {@link State} instances are stateful, and created when a new |
||||
* {@link MultipartParser.PartListener#onHeaders(HttpHeaders) headers instance} is accepted. |
||||
* The following rules determine which state the creator will have: |
||||
* <ol> |
||||
* <li>If the part is a {@linkplain #isFormField(HttpHeaders) form field}, |
||||
* the creator will be in the {@link FormFieldState}.</li> |
||||
* <li>Otherwise, the creator will initially be in the |
||||
* {@link InMemoryState}, but will switch over to {@link FileState} |
||||
* when the part byte count exceeds {@link #maxInMemorySize}</li> |
||||
* </ol> |
||||
*/ |
||||
private interface State { |
||||
|
||||
/** |
||||
* Invoked when a {@link MultipartParser.PartListener#onBody(DataBuffer, boolean)} is received. |
||||
*/ |
||||
void onBody(DataBuffer dataBuffer, boolean last); |
||||
|
||||
} |
||||
|
||||
/** |
||||
* The initial state of the creator. Throws an exception for {@link #onBody(DataBuffer, boolean)}. |
||||
*/ |
||||
private static final class InitialState implements State { |
||||
|
||||
private InitialState() { |
||||
} |
||||
|
||||
@Override |
||||
public void onBody(DataBuffer dataBuffer, boolean last) { |
||||
DataBufferUtils.release(dataBuffer); |
||||
throw new HttpMessageConversionException("Body token not expected"); |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "INITIAL"; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* The creator state when a form field is received. |
||||
* Stores all body buffers in memory (up until {@link #maxInMemorySize}). |
||||
*/ |
||||
private final class FormFieldState implements State { |
||||
|
||||
private final FastByteArrayOutputStream value = new FastByteArrayOutputStream(); |
||||
|
||||
private final HttpHeaders headers; |
||||
|
||||
public FormFieldState(HttpHeaders headers) { |
||||
this.headers = headers; |
||||
} |
||||
|
||||
@Override |
||||
public void onBody(DataBuffer dataBuffer, boolean last) { |
||||
int size = this.value.size() + dataBuffer.readableByteCount(); |
||||
if (PartGenerator.this.maxInMemorySize == -1 || |
||||
size < PartGenerator.this.maxInMemorySize) { |
||||
store(dataBuffer); |
||||
} |
||||
else { |
||||
DataBufferUtils.release(dataBuffer); |
||||
throw new HttpMessageConversionException("Form field value exceeded the memory usage limit of " + |
||||
PartGenerator.this.maxInMemorySize + " bytes"); |
||||
} |
||||
if (last) { |
||||
byte[] bytes = this.value.toByteArrayUnsafe(); |
||||
String value = new String(bytes, MultipartUtils.charset(this.headers)); |
||||
FormFieldPart formFieldPart = DefaultParts.formFieldPart(this.headers, value); |
||||
PartGenerator.this.addPart(formFieldPart); |
||||
} |
||||
} |
||||
|
||||
private void store(DataBuffer dataBuffer) { |
||||
try { |
||||
byte[] bytes = new byte[dataBuffer.readableByteCount()]; |
||||
dataBuffer.read(bytes); |
||||
this.value.write(bytes); |
||||
} |
||||
catch (IOException ex) { |
||||
throw new HttpMessageConversionException("Cannot store multipart body", ex); |
||||
} |
||||
finally { |
||||
DataBufferUtils.release(dataBuffer); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "FORM-FIELD"; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* The creator state when not handling a form field. |
||||
* Stores all received buffers in a queue. |
||||
* If the byte count exceeds {@link #maxInMemorySize}, the creator state |
||||
* is changed to {@link FileState}. |
||||
*/ |
||||
private final class InMemoryState implements State { |
||||
|
||||
private final Queue<DataBuffer> content = new ArrayDeque<>(); |
||||
|
||||
private long byteCount; |
||||
|
||||
private final HttpHeaders headers; |
||||
|
||||
|
||||
public InMemoryState(HttpHeaders headers) { |
||||
this.headers = headers; |
||||
} |
||||
|
||||
@Override |
||||
public void onBody(DataBuffer dataBuffer, boolean last) { |
||||
this.byteCount += dataBuffer.readableByteCount(); |
||||
if (PartGenerator.this.maxInMemorySize == -1 || |
||||
this.byteCount <= PartGenerator.this.maxInMemorySize) { |
||||
this.content.add(dataBuffer); |
||||
if (last) { |
||||
emitMemoryPart(); |
||||
} |
||||
} |
||||
else { |
||||
switchToFile(dataBuffer, last); |
||||
} |
||||
} |
||||
|
||||
private void switchToFile(DataBuffer current, boolean last) { |
||||
FileState newState = new FileState(this.headers, PartGenerator.this.fileStorageDirectory); |
||||
this.content.forEach(newState::writeBuffer); |
||||
newState.onBody(current, last); |
||||
PartGenerator.this.state = newState; |
||||
} |
||||
|
||||
private void emitMemoryPart() { |
||||
byte[] bytes = new byte[(int) this.byteCount]; |
||||
int idx = 0; |
||||
for (DataBuffer buffer : this.content) { |
||||
int len = buffer.readableByteCount(); |
||||
buffer.read(bytes, idx, len); |
||||
idx += len; |
||||
DataBufferUtils.release(buffer); |
||||
} |
||||
this.content.clear(); |
||||
DefaultDataBuffer content = DefaultDataBufferFactory.sharedInstance.wrap(bytes); |
||||
Part part = DefaultParts.part(this.headers, content); |
||||
PartGenerator.this.addPart(part); |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "IN-MEMORY"; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* The creator state when writing for a temporary file. |
||||
* {@link InMemoryState} initially switches to this state when the byte |
||||
* count exceeds {@link #maxInMemorySize}. |
||||
*/ |
||||
private final class FileState implements State { |
||||
|
||||
private final HttpHeaders headers; |
||||
|
||||
private final Path file; |
||||
|
||||
private final OutputStream outputStream; |
||||
|
||||
private long byteCount; |
||||
|
||||
|
||||
public FileState(HttpHeaders headers, Path folder) { |
||||
this.headers = headers; |
||||
this.file = createFile(folder); |
||||
this.outputStream = createOutputStream(this.file); |
||||
} |
||||
|
||||
@Override |
||||
public void onBody(DataBuffer dataBuffer, boolean last) { |
||||
this.byteCount += dataBuffer.readableByteCount(); |
||||
if (PartGenerator.this.maxDiskUsagePerPart == -1 || this.byteCount <= PartGenerator.this.maxDiskUsagePerPart) { |
||||
writeBuffer(dataBuffer); |
||||
if (last) { |
||||
Part part = DefaultParts.part(this.headers, this.file); |
||||
PartGenerator.this.addPart(part); |
||||
} |
||||
} |
||||
else { |
||||
try { |
||||
this.outputStream.close(); |
||||
} |
||||
catch (IOException exc) { |
||||
// ignored
|
||||
} |
||||
throw new HttpMessageConversionException("Part exceeded the disk usage limit of " + |
||||
PartGenerator.this.maxDiskUsagePerPart + " bytes"); |
||||
} |
||||
} |
||||
|
||||
private Path createFile(Path directory) { |
||||
try { |
||||
Path tempFile = Files.createTempFile(directory, null, ".multipart"); |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Storing multipart data in file " + tempFile); |
||||
} |
||||
return tempFile; |
||||
} |
||||
catch (IOException ex) { |
||||
throw new UncheckedIOException("Could not create temp file in " + directory, ex); |
||||
} |
||||
} |
||||
|
||||
private OutputStream createOutputStream(Path file) { |
||||
try { |
||||
return Files.newOutputStream(file, StandardOpenOption.WRITE); |
||||
} |
||||
catch (IOException ex) { |
||||
throw new UncheckedIOException("Could not write to temp file " + file, ex); |
||||
} |
||||
} |
||||
|
||||
private void writeBuffer(DataBuffer dataBuffer) { |
||||
try (InputStream in = dataBuffer.asInputStream()) { |
||||
in.transferTo(this.outputStream); |
||||
this.outputStream.flush(); |
||||
} |
||||
catch (IOException exc) { |
||||
throw new UncheckedIOException("Could not write to temp file ", exc); |
||||
} |
||||
finally { |
||||
DataBufferUtils.release(dataBuffer); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "WRITE-FILE"; |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
/** |
||||
* Provides an HttpMessageConverter for Multipart support. |
||||
*/ |
||||
@NullMarked |
||||
package org.springframework.http.converter.multipart; |
||||
|
||||
import org.jspecify.annotations.NullMarked; |
||||
@ -0,0 +1,554 @@
@@ -0,0 +1,554 @@
|
||||
/* |
||||
* Copyright 2026-present the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.http.converter.multipart; |
||||
|
||||
import java.io.ByteArrayInputStream; |
||||
import java.io.IOException; |
||||
import java.io.InputStream; |
||||
import java.io.StringReader; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.util.LinkedHashMap; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
import java.util.function.Predicate; |
||||
|
||||
import javax.xml.transform.Source; |
||||
import javax.xml.transform.stream.StreamSource; |
||||
|
||||
import org.apache.tomcat.util.http.fileupload.FileItem; |
||||
import org.apache.tomcat.util.http.fileupload.FileUpload; |
||||
import org.apache.tomcat.util.http.fileupload.RequestContext; |
||||
import org.apache.tomcat.util.http.fileupload.UploadContext; |
||||
import org.apache.tomcat.util.http.fileupload.disk.DiskFileItemFactory; |
||||
import org.junit.jupiter.api.Nested; |
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import org.springframework.core.ResolvableType; |
||||
import org.springframework.core.io.ClassPathResource; |
||||
import org.springframework.core.io.Resource; |
||||
import org.springframework.http.HttpEntity; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.StreamingHttpOutputMessage; |
||||
import org.springframework.http.converter.ByteArrayHttpMessageConverter; |
||||
import org.springframework.http.converter.HttpMessageConversionException; |
||||
import org.springframework.http.converter.ResourceHttpMessageConverter; |
||||
import org.springframework.http.converter.StringHttpMessageConverter; |
||||
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; |
||||
import org.springframework.http.converter.xml.SourceHttpMessageConverter; |
||||
import org.springframework.util.LinkedMultiValueMap; |
||||
import org.springframework.util.MultiValueMap; |
||||
import org.springframework.web.testfixture.http.MockHttpInputMessage; |
||||
import org.springframework.web.testfixture.http.MockHttpOutputMessage; |
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8; |
||||
import static java.util.Collections.singletonMap; |
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
import static org.springframework.http.MediaType.APPLICATION_JSON; |
||||
import static org.springframework.http.MediaType.MULTIPART_FORM_DATA; |
||||
import static org.springframework.http.MediaType.MULTIPART_MIXED; |
||||
import static org.springframework.http.MediaType.MULTIPART_RELATED; |
||||
import static org.springframework.http.MediaType.TEXT_XML; |
||||
|
||||
|
||||
/** |
||||
* Tests for {@link MultipartHttpMessageConverter}. |
||||
* |
||||
* @author Brian Clozel |
||||
* @author Arjen Poutsma |
||||
* @author Rossen Stoyanchev |
||||
* @author Sam Brannen |
||||
* @author Sebastien Deleuze |
||||
*/ |
||||
class MultipartHttpMessageConverterTests { |
||||
|
||||
private MultipartHttpMessageConverter converter = new MultipartHttpMessageConverter( |
||||
List.of(new StringHttpMessageConverter(), new ByteArrayHttpMessageConverter(), |
||||
new ResourceHttpMessageConverter(), new JacksonJsonHttpMessageConverter()) |
||||
); |
||||
|
||||
|
||||
@Test |
||||
void canRead() { |
||||
assertCanRead(MULTIPART_FORM_DATA); |
||||
assertCanRead(MULTIPART_MIXED); |
||||
assertCanRead(MULTIPART_RELATED); |
||||
assertCanRead(ResolvableType.forClass(LinkedMultiValueMap.class), MULTIPART_FORM_DATA); |
||||
assertCanRead(ResolvableType.forClassWithGenerics(LinkedMultiValueMap.class, String.class, Part.class), MULTIPART_FORM_DATA); |
||||
|
||||
assertCannotRead(ResolvableType.forClassWithGenerics(LinkedMultiValueMap.class, String.class, Object.class), MULTIPART_FORM_DATA); |
||||
} |
||||
|
||||
@Test |
||||
void canWrite() { |
||||
assertCanWrite(MULTIPART_FORM_DATA); |
||||
assertCanWrite(MULTIPART_MIXED); |
||||
assertCanWrite(MULTIPART_RELATED); |
||||
assertCanWrite(new MediaType("multipart", "form-data", UTF_8)); |
||||
assertCanWrite(MediaType.ALL); |
||||
assertCanWrite(null); |
||||
assertCanWrite(ResolvableType.forClassWithGenerics(LinkedMultiValueMap.class, String.class, Object.class), MULTIPART_FORM_DATA); |
||||
} |
||||
|
||||
@Test |
||||
void setSupportedMediaTypes() { |
||||
this.converter.setSupportedMediaTypes(List.of(MULTIPART_FORM_DATA)); |
||||
assertCannotWrite(MULTIPART_MIXED); |
||||
|
||||
this.converter.setSupportedMediaTypes(List.of(MULTIPART_MIXED)); |
||||
assertCanWrite(MULTIPART_MIXED); |
||||
} |
||||
|
||||
@Test |
||||
void addSupportedMediaTypes() { |
||||
this.converter.setSupportedMediaTypes(List.of(MULTIPART_FORM_DATA)); |
||||
assertCannotWrite(MULTIPART_MIXED); |
||||
|
||||
this.converter.addSupportedMediaTypes(MULTIPART_RELATED); |
||||
assertCanWrite(MULTIPART_RELATED); |
||||
} |
||||
|
||||
|
||||
private void assertCanRead(MediaType mediaType) { |
||||
assertCanRead(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), mediaType); |
||||
} |
||||
|
||||
private void assertCanRead(ResolvableType type, MediaType mediaType) { |
||||
assertThat(this.converter.canRead(type, mediaType)).as(type + " : " + mediaType).isTrue(); |
||||
} |
||||
|
||||
private void assertCannotRead(ResolvableType type, MediaType mediaType) { |
||||
assertThat(this.converter.canRead(type, mediaType)).as(type + " : " + mediaType).isFalse(); |
||||
} |
||||
|
||||
private void assertCanWrite(ResolvableType type, MediaType mediaType) { |
||||
assertThat(this.converter.canWrite(type, MultiValueMap.class, mediaType)).as(type + " : " + mediaType).isTrue(); |
||||
} |
||||
|
||||
private void assertCanWrite(MediaType mediaType) { |
||||
assertCanWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), mediaType); |
||||
} |
||||
|
||||
private void assertCannotWrite(MediaType mediaType) { |
||||
Class<?> clazz = MultiValueMap.class; |
||||
assertThat(this.converter.canWrite(clazz, mediaType)).as(clazz.getSimpleName() + " : " + mediaType).isFalse(); |
||||
} |
||||
|
||||
|
||||
@Nested |
||||
class ReadingTests { |
||||
|
||||
@Test |
||||
void readMultipartFiles() throws Exception { |
||||
MockHttpInputMessage response = createMultipartResponse("files.multipart", "----WebKitFormBoundaryG8fJ50opQOML0oGD"); |
||||
MultiValueMap<String, Part> result = converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null); |
||||
|
||||
assertThat(result).containsOnlyKeys("file2"); |
||||
assertThat(result.get("file2")).anyMatch(isFilePart("a.txt")) |
||||
.anyMatch(isFilePart("b.txt")); |
||||
} |
||||
|
||||
@Test |
||||
void readMultipartBrowser() throws Exception { |
||||
MockHttpInputMessage response = createMultipartResponse("firefox.multipart", "---------------------------18399284482060392383840973206"); |
||||
MultiValueMap<String, Part> result = converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null); |
||||
|
||||
assertThat(result).containsOnlyKeys("file1", "file2", "text1", "text2"); |
||||
assertThat(result.get("file1")).anyMatch(isFilePart("a.txt")); |
||||
assertThat(result.get("file2")).anyMatch(isFilePart("a.txt")) |
||||
.anyMatch(isFilePart("b.txt")); |
||||
assertThat(result.get("text1")).anyMatch(isFormData("text1", "a")); |
||||
assertThat(result.get("text2")).anyMatch(isFormData("text2", "b")); |
||||
} |
||||
|
||||
@Test |
||||
void readMultipartInvalid() throws Exception { |
||||
MockHttpInputMessage response = createMultipartResponse("garbage-1.multipart", "boundary"); |
||||
assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null)) |
||||
.isInstanceOf(HttpMessageConversionException.class).hasMessage("Cannot decode multipart body"); |
||||
} |
||||
|
||||
@Test |
||||
void readMultipartMaxPartsExceeded() throws Exception { |
||||
MockHttpInputMessage response = createMultipartResponse("files.multipart", "----WebKitFormBoundaryG8fJ50opQOML0oGD"); |
||||
converter.setMaxParts(1); |
||||
assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null)) |
||||
.isInstanceOf(HttpMessageConversionException.class).hasMessage("Maximum number of parts exceeded: 1"); |
||||
} |
||||
|
||||
@Test |
||||
void readMultipartToFiles() throws Exception { |
||||
MockHttpInputMessage response = createMultipartResponse("files.multipart", "----WebKitFormBoundaryG8fJ50opQOML0oGD"); |
||||
converter.setMaxInMemorySize(1); |
||||
MultiValueMap<String, Part> result = converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null); |
||||
assertThat(result).containsOnlyKeys("file2"); |
||||
} |
||||
|
||||
@Test |
||||
void readMultipartMaxInMemoryExceeded() throws Exception { |
||||
MockHttpInputMessage response = createMultipartResponse("firefox.multipart", "---------------------------18399284482060392383840973206"); |
||||
converter.setMaxInMemorySize(1); |
||||
assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null)) |
||||
.isInstanceOf(HttpMessageConversionException.class).hasMessage("Form field value exceeded the memory usage limit of 1 bytes"); |
||||
} |
||||
|
||||
@Test |
||||
void readMultipartMaxDiskUsageExceeded() throws Exception { |
||||
MockHttpInputMessage response = createMultipartResponse("firefox.multipart", "---------------------------18399284482060392383840973206"); |
||||
converter.setMaxInMemorySize(30); |
||||
converter.setMaxDiskUsagePerPart(35); |
||||
assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null)) |
||||
.isInstanceOf(HttpMessageConversionException.class).hasMessage("Part exceeded the disk usage limit of 35 bytes"); |
||||
} |
||||
|
||||
@Test |
||||
void readMultipartUnnamedPart() throws Exception { |
||||
MockHttpInputMessage response = createMultipartResponse("simple.multipart", "simple-boundary"); |
||||
assertThatThrownBy(() -> converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null)) |
||||
.isInstanceOf(HttpMessageConversionException.class).hasMessage("Part #1 is unnamed"); |
||||
} |
||||
|
||||
|
||||
private MockHttpInputMessage createMultipartResponse(String fileName, String boundary) throws Exception { |
||||
InputStream stream = createStream(fileName); |
||||
MockHttpInputMessage response = new MockHttpInputMessage(stream); |
||||
response.getHeaders().setContentType( |
||||
new MediaType("multipart", "form-data", singletonMap("boundary", boundary))); |
||||
return response; |
||||
} |
||||
|
||||
private InputStream createStream(String fileName) throws IOException { |
||||
Resource resource = new ClassPathResource("/org/springframework/http/multipart/" + fileName); |
||||
return resource.getInputStream(); |
||||
} |
||||
|
||||
private Predicate<Part> isFilePart(String fileName) { |
||||
return part -> part instanceof FilePart filePart && |
||||
filePart.filename().equals(fileName); |
||||
} |
||||
|
||||
private Predicate<Part> isFormData(String name, String value) { |
||||
return part -> part instanceof FormFieldPart formFieldPart && |
||||
formFieldPart.name().equals(name) && |
||||
formFieldPart.value().equals(value); |
||||
} |
||||
|
||||
} |
||||
|
||||
@Nested |
||||
class WritingTests { |
||||
|
||||
@Test |
||||
void writeMultipart() throws Exception { |
||||
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>(); |
||||
parts.add("name 1", "value 1"); |
||||
parts.add("name 2", "value 2+1"); |
||||
parts.add("name 2", "value 2+2"); |
||||
parts.add("name 3", null); |
||||
|
||||
Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg"); |
||||
parts.add("logo", logo); |
||||
|
||||
// SPR-12108
|
||||
Resource utf8 = new ClassPathResource("/org/springframework/http/converter/logo.jpg") { |
||||
@Override |
||||
public String getFilename() { |
||||
return "Hall\u00F6le.jpg"; |
||||
} |
||||
}; |
||||
parts.add("utf8", utf8); |
||||
|
||||
MyBean myBean = new MyBean(); |
||||
myBean.setString("foo"); |
||||
HttpHeaders entityHeaders = new HttpHeaders(); |
||||
entityHeaders.setContentType(APPLICATION_JSON); |
||||
HttpEntity<MyBean> entity = new HttpEntity<>(myBean, entityHeaders); |
||||
parts.add("json", entity); |
||||
|
||||
Map<String, String> parameters = new LinkedHashMap<>(2); |
||||
parameters.put("charset", UTF_8.name()); |
||||
parameters.put("foo", "bar"); |
||||
|
||||
StreamingMockHttpOutputMessage outputMessage = new StreamingMockHttpOutputMessage(); |
||||
converter.write(parts, new MediaType("multipart", "form-data", parameters), outputMessage); |
||||
|
||||
final MediaType contentType = outputMessage.getHeaders().getContentType(); |
||||
assertThat(contentType.getParameters()).containsKeys("charset", "boundary", "foo"); // gh-21568, gh-25839
|
||||
|
||||
// see if Commons FileUpload can read what we wrote
|
||||
FileUpload fileUpload = new FileUpload(); |
||||
fileUpload.setFileItemFactory(new DiskFileItemFactory()); |
||||
RequestContext requestContext = new MockHttpOutputMessageRequestContext(outputMessage); |
||||
List<FileItem> items = fileUpload.parseRequest(requestContext); |
||||
assertThat(items).hasSize(6); |
||||
FileItem item = items.get(0); |
||||
assertThat(item.isFormField()).isTrue(); |
||||
assertThat(item.getFieldName()).isEqualTo("name 1"); |
||||
assertThat(item.getString()).isEqualTo("value 1"); |
||||
|
||||
item = items.get(1); |
||||
assertThat(item.isFormField()).isTrue(); |
||||
assertThat(item.getFieldName()).isEqualTo("name 2"); |
||||
assertThat(item.getString()).isEqualTo("value 2+1"); |
||||
|
||||
item = items.get(2); |
||||
assertThat(item.isFormField()).isTrue(); |
||||
assertThat(item.getFieldName()).isEqualTo("name 2"); |
||||
assertThat(item.getString()).isEqualTo("value 2+2"); |
||||
|
||||
item = items.get(3); |
||||
assertThat(item.isFormField()).isFalse(); |
||||
assertThat(item.getFieldName()).isEqualTo("logo"); |
||||
assertThat(item.getName()).isEqualTo("logo.jpg"); |
||||
assertThat(item.getContentType()).isEqualTo("image/jpeg"); |
||||
assertThat(item.getSize()).isEqualTo(logo.getFile().length()); |
||||
|
||||
item = items.get(4); |
||||
assertThat(item.isFormField()).isFalse(); |
||||
assertThat(item.getFieldName()).isEqualTo("utf8"); |
||||
assertThat(item.getName()).isEqualTo("Hall\u00F6le.jpg"); |
||||
assertThat(item.getContentType()).isEqualTo("image/jpeg"); |
||||
assertThat(item.getSize()).isEqualTo(logo.getFile().length()); |
||||
|
||||
item = items.get(5); |
||||
assertThat(item.getFieldName()).isEqualTo("json"); |
||||
assertThat(item.getContentType()).isEqualTo("application/json"); |
||||
|
||||
assertThat(outputMessage.wasRepeatable()).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
void writeMultipartWithSourceHttpMessageConverter() throws Exception { |
||||
|
||||
converter = new MultipartHttpMessageConverter(List.of( |
||||
new StringHttpMessageConverter(), |
||||
new ResourceHttpMessageConverter(), |
||||
new SourceHttpMessageConverter<>())); |
||||
|
||||
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>(); |
||||
parts.add("name 1", "value 1"); |
||||
parts.add("name 2", "value 2+1"); |
||||
parts.add("name 2", "value 2+2"); |
||||
parts.add("name 3", null); |
||||
|
||||
Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg"); |
||||
parts.add("logo", logo); |
||||
|
||||
// SPR-12108
|
||||
Resource utf8 = new ClassPathResource("/org/springframework/http/converter/logo.jpg") { |
||||
@Override |
||||
public String getFilename() { |
||||
return "Hall\u00F6le.jpg"; |
||||
} |
||||
}; |
||||
parts.add("utf8", utf8); |
||||
|
||||
Source xml = new StreamSource(new StringReader("<root><child/></root>")); |
||||
HttpHeaders entityHeaders = new HttpHeaders(); |
||||
entityHeaders.setContentType(TEXT_XML); |
||||
HttpEntity<Source> entity = new HttpEntity<>(xml, entityHeaders); |
||||
parts.add("xml", entity); |
||||
|
||||
Map<String, String> parameters = new LinkedHashMap<>(2); |
||||
parameters.put("charset", UTF_8.name()); |
||||
parameters.put("foo", "bar"); |
||||
|
||||
StreamingMockHttpOutputMessage outputMessage = new StreamingMockHttpOutputMessage(); |
||||
converter.write(parts, new MediaType("multipart", "form-data", parameters), outputMessage); |
||||
|
||||
final MediaType contentType = outputMessage.getHeaders().getContentType(); |
||||
assertThat(contentType.getParameters()).containsKeys("charset", "boundary", "foo"); // gh-21568, gh-25839
|
||||
|
||||
// see if Commons FileUpload can read what we wrote
|
||||
FileUpload fileUpload = new FileUpload(); |
||||
fileUpload.setFileItemFactory(new DiskFileItemFactory()); |
||||
RequestContext requestContext = new MockHttpOutputMessageRequestContext(outputMessage); |
||||
List<FileItem> items = fileUpload.parseRequest(requestContext); |
||||
assertThat(items).hasSize(6); |
||||
FileItem item = items.get(0); |
||||
assertThat(item.isFormField()).isTrue(); |
||||
assertThat(item.getFieldName()).isEqualTo("name 1"); |
||||
assertThat(item.getString()).isEqualTo("value 1"); |
||||
|
||||
item = items.get(1); |
||||
assertThat(item.isFormField()).isTrue(); |
||||
assertThat(item.getFieldName()).isEqualTo("name 2"); |
||||
assertThat(item.getString()).isEqualTo("value 2+1"); |
||||
|
||||
item = items.get(2); |
||||
assertThat(item.isFormField()).isTrue(); |
||||
assertThat(item.getFieldName()).isEqualTo("name 2"); |
||||
assertThat(item.getString()).isEqualTo("value 2+2"); |
||||
|
||||
item = items.get(3); |
||||
assertThat(item.isFormField()).isFalse(); |
||||
assertThat(item.getFieldName()).isEqualTo("logo"); |
||||
assertThat(item.getName()).isEqualTo("logo.jpg"); |
||||
assertThat(item.getContentType()).isEqualTo("image/jpeg"); |
||||
assertThat(item.getSize()).isEqualTo(logo.getFile().length()); |
||||
|
||||
item = items.get(4); |
||||
assertThat(item.isFormField()).isFalse(); |
||||
assertThat(item.getFieldName()).isEqualTo("utf8"); |
||||
assertThat(item.getName()).isEqualTo("Hall\u00F6le.jpg"); |
||||
assertThat(item.getContentType()).isEqualTo("image/jpeg"); |
||||
assertThat(item.getSize()).isEqualTo(logo.getFile().length()); |
||||
|
||||
item = items.get(5); |
||||
assertThat(item.getFieldName()).isEqualTo("xml"); |
||||
assertThat(item.getContentType()).isEqualTo("text/xml"); |
||||
|
||||
assertThat(outputMessage.wasRepeatable()).isFalse(); |
||||
} |
||||
|
||||
@Test // SPR-13309
|
||||
void writeMultipartOrder() throws Exception { |
||||
MyBean myBean = new MyBean(); |
||||
myBean.setString("foo"); |
||||
|
||||
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>(); |
||||
parts.add("part1", myBean); |
||||
|
||||
HttpHeaders entityHeaders = new HttpHeaders(); |
||||
entityHeaders.setContentType(APPLICATION_JSON); |
||||
HttpEntity<MyBean> entity = new HttpEntity<>(myBean, entityHeaders); |
||||
parts.add("part2", entity); |
||||
|
||||
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); |
||||
converter.setMultipartCharset(UTF_8); |
||||
converter.write(parts, new MediaType("multipart", "form-data", UTF_8), outputMessage); |
||||
|
||||
final MediaType contentType = outputMessage.getHeaders().getContentType(); |
||||
assertThat(contentType.getParameter("boundary")).as("No boundary found").isNotNull(); |
||||
|
||||
// see if Commons FileUpload can read what we wrote
|
||||
FileUpload fileUpload = new FileUpload(); |
||||
fileUpload.setFileItemFactory(new DiskFileItemFactory()); |
||||
RequestContext requestContext = new MockHttpOutputMessageRequestContext(outputMessage); |
||||
List<FileItem> items = fileUpload.parseRequest(requestContext); |
||||
assertThat(items).hasSize(2); |
||||
|
||||
FileItem item = items.get(0); |
||||
assertThat(item.isFormField()).isTrue(); |
||||
assertThat(item.getFieldName()).isEqualTo("part1"); |
||||
assertThat(item.getString()).isEqualTo("{\"string\":\"foo\"}"); |
||||
|
||||
item = items.get(1); |
||||
assertThat(item.isFormField()).isTrue(); |
||||
assertThat(item.getFieldName()).isEqualTo("part2"); |
||||
|
||||
assertThat(item.getString()) |
||||
.contains("{\"string\":\"foo\"}"); |
||||
} |
||||
|
||||
@Test |
||||
void writeMultipartCharset() throws Exception { |
||||
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>(); |
||||
Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg"); |
||||
parts.add("logo", logo); |
||||
|
||||
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); |
||||
converter.write(parts, MULTIPART_FORM_DATA, outputMessage); |
||||
|
||||
MediaType contentType = outputMessage.getHeaders().getContentType(); |
||||
Map<String, String> parameters = contentType.getParameters(); |
||||
assertThat(parameters).containsOnlyKeys("boundary"); |
||||
|
||||
converter.setCharset(StandardCharsets.ISO_8859_1); |
||||
|
||||
outputMessage = new MockHttpOutputMessage(); |
||||
converter.write(parts, MULTIPART_FORM_DATA, outputMessage); |
||||
|
||||
parameters = outputMessage.getHeaders().getContentType().getParameters(); |
||||
assertThat(parameters).containsOnlyKeys("boundary", "charset"); |
||||
assertThat(parameters).containsEntry("charset", "ISO-8859-1"); |
||||
} |
||||
|
||||
} |
||||
|
||||
|
||||
private static class StreamingMockHttpOutputMessage extends MockHttpOutputMessage implements StreamingHttpOutputMessage { |
||||
|
||||
private boolean repeatable; |
||||
|
||||
public boolean wasRepeatable() { |
||||
return this.repeatable; |
||||
} |
||||
|
||||
@Override |
||||
public void setBody(Body body) { |
||||
try { |
||||
this.repeatable = body.repeatable(); |
||||
body.writeTo(getBody()); |
||||
} |
||||
catch (IOException ex) { |
||||
throw new RuntimeException(ex); |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
private static class MockHttpOutputMessageRequestContext implements UploadContext { |
||||
|
||||
private final MockHttpOutputMessage outputMessage; |
||||
|
||||
private final byte[] body; |
||||
|
||||
private MockHttpOutputMessageRequestContext(MockHttpOutputMessage outputMessage) { |
||||
this.outputMessage = outputMessage; |
||||
this.body = this.outputMessage.getBodyAsBytes(); |
||||
} |
||||
|
||||
@Override |
||||
public String getCharacterEncoding() { |
||||
MediaType type = this.outputMessage.getHeaders().getContentType(); |
||||
return (type != null && type.getCharset() != null ? type.getCharset().name() : null); |
||||
} |
||||
|
||||
@Override |
||||
public String getContentType() { |
||||
MediaType type = this.outputMessage.getHeaders().getContentType(); |
||||
return (type != null ? type.toString() : null); |
||||
} |
||||
|
||||
@Override |
||||
public InputStream getInputStream() { |
||||
return new ByteArrayInputStream(body); |
||||
} |
||||
|
||||
@Override |
||||
public long contentLength() { |
||||
return body.length; |
||||
} |
||||
} |
||||
|
||||
|
||||
public static class MyBean { |
||||
|
||||
private String string; |
||||
|
||||
public String getString() { |
||||
return this.string; |
||||
} |
||||
|
||||
public void setString(String string) { |
||||
this.string = string; |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,282 @@
@@ -0,0 +1,282 @@
|
||||
/* |
||||
* Copyright 2002-present the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.http.converter.multipart; |
||||
|
||||
import java.io.IOException; |
||||
import java.io.InputStream; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.util.ArrayDeque; |
||||
import java.util.Deque; |
||||
import java.util.List; |
||||
import java.util.function.Consumer; |
||||
|
||||
import org.jspecify.annotations.NonNull; |
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import org.springframework.core.io.ClassPathResource; |
||||
import org.springframework.core.io.Resource; |
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.core.io.buffer.DataBufferUtils; |
||||
import org.springframework.http.ContentDisposition; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.converter.HttpMessageConversionException; |
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8; |
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
||||
/** |
||||
* Tests for {@link MultipartParser}. |
||||
* |
||||
* @author Brian Clozel |
||||
* @author Arjen Poutsma |
||||
*/ |
||||
class MultipartParserTests { |
||||
|
||||
private static final MediaType TEXT_PLAIN_ASCII = new MediaType("text", "plain", StandardCharsets.US_ASCII); |
||||
|
||||
@Test |
||||
void simple() throws Exception { |
||||
TestListener listener = new TestListener(); |
||||
parse("simple.multipart", "simple-boundary", listener); |
||||
|
||||
listener.assertHeader(headers -> assertThat(headers.isEmpty()).isTrue()) |
||||
.assertBodyChunk("This is implicitly typed plain ASCII text.\r\nIt does NOT end with a linebreak.") |
||||
.assertHeader(headers -> assertThat(headers.getContentType()).isEqualTo(TEXT_PLAIN_ASCII)) |
||||
.assertBodyChunk("This is explicitly typed plain ASCII text.\r\nIt DOES end with a linebreak.\r\n") |
||||
.assertComplete(); |
||||
} |
||||
|
||||
@Test |
||||
void noHeaders() throws Exception { |
||||
TestListener listener = new TestListener(); |
||||
parse("no-header.multipart", "boundary", listener); |
||||
|
||||
listener.assertHeader(headers -> assertThat(headers.isEmpty()).isTrue()) |
||||
.assertBodyChunk("a") |
||||
.assertComplete(); |
||||
} |
||||
|
||||
@Test |
||||
void noEndBoundary() throws Exception { |
||||
TestListener listener = new TestListener(); |
||||
parse("no-end-boundary.multipart", "boundary", listener); |
||||
|
||||
assertThat(listener.error).isInstanceOf(HttpMessageConversionException.class); |
||||
} |
||||
|
||||
@Test |
||||
void garbage() throws Exception { |
||||
TestListener listener = new TestListener(); |
||||
parse("garbage-1.multipart", "boundary", listener); |
||||
|
||||
assertThat(listener.error).isInstanceOf(HttpMessageConversionException.class); |
||||
} |
||||
|
||||
@Test |
||||
void noEndHeader() throws Exception { |
||||
TestListener listener = new TestListener(); |
||||
parse("no-end-header.multipart", "boundary", listener); |
||||
|
||||
assertThat(listener.error).isInstanceOf(HttpMessageConversionException.class); |
||||
} |
||||
|
||||
@Test |
||||
void noEndBody() throws Exception { |
||||
TestListener listener = new TestListener(); |
||||
parse("no-end-body.multipart", "boundary", listener); |
||||
|
||||
assertThat(listener.error).isInstanceOf(HttpMessageConversionException.class); |
||||
} |
||||
|
||||
@Test |
||||
void noBody() throws Exception { |
||||
TestListener listener = new TestListener(); |
||||
parse("no-body.multipart", "boundary", listener); |
||||
|
||||
listener.assertHeader(headers -> assertThat(headers.hasHeaderValues("Part", List.of("1"))).isTrue()) |
||||
.assertHeader(headers -> assertThat(headers.hasHeaderValues("Part", List.of("2"))).isTrue()) |
||||
.assertBodyChunk("a") |
||||
.assertComplete(); |
||||
} |
||||
|
||||
@Test |
||||
void firefox() throws Exception { |
||||
TestListener listener = new TestListener(); |
||||
parse("firefox.multipart", |
||||
"---------------------------18399284482060392383840973206", listener); |
||||
|
||||
listener.assertHeadersFormField("text1") |
||||
.assertBodyChunk("a") |
||||
.assertHeadersFormField("text2") |
||||
.assertBodyChunk("b") |
||||
.assertHeadersFile("file1", "a.txt") |
||||
.assertBodyChunk() |
||||
.assertHeadersFile("file2", "a.txt") |
||||
.assertBodyChunk() |
||||
.assertHeadersFile("file2", "b.txt") |
||||
.assertBodyChunk() |
||||
.assertComplete(); |
||||
} |
||||
|
||||
@Test |
||||
void chrome() throws Exception { |
||||
TestListener listener = new TestListener(); |
||||
parse("chrome.multipart", |
||||
"----WebKitFormBoundaryEveBLvRT65n21fwU", listener); |
||||
|
||||
listener.assertHeadersFormField("text1") |
||||
.assertBodyChunk("a") |
||||
.assertHeadersFormField("text2") |
||||
.assertBodyChunk("b") |
||||
.assertHeadersFile("file1", "a.txt") |
||||
.assertBodyChunk() |
||||
.assertHeadersFile("file2", "a.txt") |
||||
.assertBodyChunk() |
||||
.assertHeadersFile("file2", "b.txt") |
||||
.assertBodyChunk() |
||||
.assertComplete(); |
||||
} |
||||
|
||||
@Test |
||||
void safari() throws Exception { |
||||
TestListener listener = new TestListener(); |
||||
parse("safari.multipart", |
||||
"----WebKitFormBoundaryG8fJ50opQOML0oGD", listener); |
||||
|
||||
listener.assertHeadersFormField("text1") |
||||
.assertBodyChunk("a") |
||||
.assertHeadersFormField("text2") |
||||
.assertBodyChunk("b") |
||||
.assertHeadersFile("file1", "a.txt") |
||||
.assertBodyChunk() |
||||
.assertHeadersFile("file2", "a.txt") |
||||
.assertBodyChunk() |
||||
.assertHeadersFile("file2", "b.txt") |
||||
.assertBodyChunk() |
||||
.assertComplete(); |
||||
} |
||||
|
||||
@Test |
||||
void utf8Headers() throws Exception { |
||||
TestListener listener = new TestListener(); |
||||
parse("utf8.multipart", "simple-boundary", listener); |
||||
|
||||
listener.assertHeader(headers -> |
||||
assertThat(headers.hasHeaderValues("Føø", List.of("Bår"))).isTrue()) |
||||
.assertBodyChunk("This is plain ASCII text.") |
||||
.assertComplete(); |
||||
} |
||||
|
||||
private InputStream createStream(String fileName) throws IOException { |
||||
Resource resource = new ClassPathResource("/org/springframework/http/multipart/" + fileName); |
||||
return resource.getInputStream(); |
||||
} |
||||
|
||||
private void parse(String fileName, String boundary, MultipartParser.PartListener listener) throws Exception { |
||||
try (InputStream input = createStream(fileName)) { |
||||
MultipartParser multipartParser = new MultipartParser(10 * 1024, 4 * 1024); |
||||
multipartParser.parse(input, boundary.getBytes(UTF_8), StandardCharsets.UTF_8, listener); |
||||
} |
||||
} |
||||
|
||||
|
||||
static class TestListener implements MultipartParser.PartListener { |
||||
|
||||
Deque<Object> received = new ArrayDeque<>(); |
||||
|
||||
boolean complete; |
||||
|
||||
Throwable error; |
||||
|
||||
@Override |
||||
public void onHeaders(@NonNull HttpHeaders headers) { |
||||
this.received.add(headers); |
||||
} |
||||
|
||||
@Override |
||||
public void onBody(@NonNull DataBuffer buffer, boolean last) { |
||||
this.received.add(buffer); |
||||
} |
||||
|
||||
@Override |
||||
public void onComplete() { |
||||
this.complete = true; |
||||
} |
||||
|
||||
@Override |
||||
public void onError(@NonNull Throwable error) { |
||||
this.error = error; |
||||
} |
||||
|
||||
TestListener assertHeader(Consumer<HttpHeaders> headersConsumer) { |
||||
Object value = received.pollFirst(); |
||||
assertThat(value).isInstanceOf(HttpHeaders.class); |
||||
headersConsumer.accept((HttpHeaders) value); |
||||
return this; |
||||
} |
||||
|
||||
TestListener assertHeadersFormField(String expectedName) { |
||||
return assertHeader(headers -> { |
||||
ContentDisposition cd = headers.getContentDisposition(); |
||||
assertThat(cd.isFormData()).isTrue(); |
||||
assertThat(cd.getName()).isEqualTo(expectedName); |
||||
}); |
||||
} |
||||
|
||||
TestListener assertHeadersFile(String expectedName, String expectedFilename) { |
||||
return assertHeader(headers -> { |
||||
ContentDisposition cd = headers.getContentDisposition(); |
||||
assertThat(cd.isFormData()).isTrue(); |
||||
assertThat(cd.getName()).isEqualTo(expectedName); |
||||
assertThat(cd.getFilename()).isEqualTo(expectedFilename); |
||||
}); |
||||
} |
||||
|
||||
TestListener assertBodyChunk(Consumer<DataBuffer> bodyConsumer) { |
||||
Object value = received.pollFirst(); |
||||
assertThat(value).isInstanceOf(DataBuffer.class); |
||||
bodyConsumer.accept((DataBuffer) value); |
||||
DataBufferUtils.release((DataBuffer) value); |
||||
return this; |
||||
} |
||||
|
||||
TestListener assertBodyChunk(String bodyContent) { |
||||
return assertBodyChunk(buffer -> { |
||||
String actual = buffer.toString(UTF_8); |
||||
assertThat(actual).isEqualTo(bodyContent); |
||||
}); |
||||
} |
||||
|
||||
TestListener assertBodyChunk() { |
||||
return assertBodyChunk(buffer -> { |
||||
}); |
||||
} |
||||
|
||||
TestListener assertLastBodyChunk() { |
||||
if (!received.isEmpty()) { |
||||
assertThat(received.peek()).isNotInstanceOf(DataBuffer.class); |
||||
} |
||||
return this; |
||||
} |
||||
|
||||
void assertComplete() { |
||||
assertThat(this.complete).isTrue(); |
||||
} |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue