Browse Source
This commit introduces the DefaultMultipartMessageReader, a fully reactive multipart parser without third party dependencies. An earlier version of this code was introduced inpull/25311/headfb642ce, but removed again in77c24aabecause of buffering issues. Closes gh-21659
18 changed files with 2433 additions and 33 deletions
@ -0,0 +1,248 @@
@@ -0,0 +1,248 @@
|
||||
/* |
||||
* Copyright 2002-2020 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.codec.multipart; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.nio.file.Paths; |
||||
import java.util.Collections; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
|
||||
import reactor.core.publisher.Flux; |
||||
import reactor.core.publisher.Mono; |
||||
import reactor.core.scheduler.Scheduler; |
||||
import reactor.core.scheduler.Schedulers; |
||||
|
||||
import org.springframework.core.ResolvableType; |
||||
import org.springframework.core.codec.DecodingException; |
||||
import org.springframework.core.io.buffer.DataBufferLimitException; |
||||
import org.springframework.http.HttpMessage; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.ReactiveHttpInputMessage; |
||||
import org.springframework.http.codec.HttpMessageReader; |
||||
import org.springframework.http.codec.LoggingCodecSupport; |
||||
import org.springframework.lang.Nullable; |
||||
import org.springframework.util.Assert; |
||||
|
||||
/** |
||||
* Default {@code HttpMessageReader} for parsing {@code "multipart/form-data"} |
||||
* requests to a stream of {@link Part}s. |
||||
* |
||||
* <p>In default, non-streaming mode, this message reader stores the |
||||
* {@linkplain Part#content() contents} of parts smaller than |
||||
* {@link #setMaxInMemorySize(int) maxInMemorySize} in memory, and parts larger |
||||
* than that to a temporary file in |
||||
* {@link #setFileStorageDirectory(Path) fileStorageDirectory}. |
||||
* <p>In {@linkplain #setStreaming(boolean) streaming} mode, the contents of the |
||||
* part is streamed directly from the parsed input buffer stream, and not stored |
||||
* in memory nor file. |
||||
* |
||||
* <p>This reader can be provided to {@link MultipartHttpMessageReader} in order |
||||
* to aggregate all parts into a Map. |
||||
* |
||||
* @author Arjen Poutsma |
||||
* @since 5.3 |
||||
*/ |
||||
public class DefaultPartHttpMessageReader extends LoggingCodecSupport implements HttpMessageReader<Part> { |
||||
|
||||
private static final String IDENTIFIER = "spring-multipart"; |
||||
|
||||
private int maxInMemorySize = 256 * 1024; |
||||
|
||||
private int maxHeadersSize = 8 * 1024; |
||||
|
||||
private long maxDiskUsagePerPart = -1; |
||||
|
||||
private int maxParts = -1; |
||||
|
||||
private boolean streaming; |
||||
|
||||
private Scheduler blockingOperationScheduler = Schedulers.newBoundedElastic(Schedulers.DEFAULT_BOUNDED_ELASTIC_SIZE, |
||||
Schedulers.DEFAULT_BOUNDED_ELASTIC_QUEUESIZE, IDENTIFIER, 60, true); |
||||
|
||||
private Mono<Path> fileStorageDirectory = Mono.defer(this::defaultFileStorageDirectory).cache(); |
||||
|
||||
|
||||
/** |
||||
* Configure the maximum amount of memory that is allowed per headers section of each part. |
||||
* When the limit |
||||
* @param byteCount the maximum amount of memory for headers |
||||
*/ |
||||
public void setMaxHeadersSize(int byteCount) { |
||||
this.maxHeadersSize = byteCount; |
||||
} |
||||
|
||||
/** |
||||
* Get the {@link #setMaxInMemorySize configured} maximum in-memory size. |
||||
*/ |
||||
public int getMaxInMemorySize() { |
||||
return this.maxInMemorySize; |
||||
} |
||||
|
||||
/** |
||||
* 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. |
||||
* <p>Note that this property is ignored when |
||||
* {@linkplain #setStreaming(boolean) streaming} is enabled. |
||||
* @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 |
||||
* {@linkplain #setStreaming(boolean) streaming} is enabled, , or 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; |
||||
} |
||||
|
||||
/** |
||||
* Sets the directory used to store parts larger than |
||||
* {@link #setMaxInMemorySize(int) maxInMemorySize}. By default, a directory |
||||
* named {@code spring-webflux-multipart} is created under the system |
||||
* temporary directory. |
||||
* <p>Note that this property is ignored when |
||||
* {@linkplain #setStreaming(boolean) streaming} is enabled, or when |
||||
* {@link #setMaxInMemorySize(int) maxInMemorySize} is set to -1. |
||||
* @throws IOException if an I/O error occurs, or the parent directory |
||||
* does not exist |
||||
*/ |
||||
public void setFileStorageDirectory(Path fileStorageDirectory) throws IOException { |
||||
Assert.notNull(fileStorageDirectory, "FileStorageDirectory must not be null"); |
||||
if (!Files.exists(fileStorageDirectory)) { |
||||
Files.createDirectory(fileStorageDirectory); |
||||
} |
||||
this.fileStorageDirectory = Mono.just(fileStorageDirectory); |
||||
} |
||||
|
||||
/** |
||||
* Sets the Reactor {@link Scheduler} to be used for creating files and |
||||
* directories, and writing to files. By default, a bounded scheduler is |
||||
* created with default properties. |
||||
* <p>Note that this property is ignored when |
||||
* {@linkplain #setStreaming(boolean) streaming} is enabled, or when |
||||
* {@link #setMaxInMemorySize(int) maxInMemorySize} is set to -1. |
||||
* @see Schedulers#newBoundedElastic |
||||
*/ |
||||
public void setBlockingOperationScheduler(Scheduler blockingOperationScheduler) { |
||||
Assert.notNull(blockingOperationScheduler, "FileCreationScheduler must not be null"); |
||||
this.blockingOperationScheduler = blockingOperationScheduler; |
||||
} |
||||
|
||||
/** |
||||
* When set to {@code true}, the {@linkplain Part#content() part content} |
||||
* is streamed directly from the parsed input buffer stream, and not stored |
||||
* in memory nor file. |
||||
* When {@code false}, parts are backed by |
||||
* in-memory and/or file storage. Defaults to {@code false}. |
||||
* |
||||
* <p><strong>NOTE</strong> that with streaming enabled, the |
||||
* {@code Flux<Part>} that is produced by this message reader must be |
||||
* consumed in the original order, i.e. the order of the HTTP message. |
||||
* Additionally, the {@linkplain Part#content() body contents} must either |
||||
* be completely consumed or canceled before moving to the next part. |
||||
* |
||||
* <p>Also note that enabling this property effectively ignores |
||||
* {@link #setMaxInMemorySize(int) maxInMemorySize}, |
||||
* {@link #setMaxDiskUsagePerPart(long) maxDiskUsagePerPart}, |
||||
* {@link #setFileStorageDirectory(Path) fileStorageDirectory}, and |
||||
* {@link #setBlockingOperationScheduler(Scheduler) fileCreationScheduler}. |
||||
*/ |
||||
public void setStreaming(boolean streaming) { |
||||
this.streaming = streaming; |
||||
} |
||||
|
||||
@Override |
||||
public List<MediaType> getReadableMediaTypes() { |
||||
return Collections.singletonList(MediaType.MULTIPART_FORM_DATA); |
||||
} |
||||
|
||||
@Override |
||||
public boolean canRead(ResolvableType elementType, @Nullable MediaType mediaType) { |
||||
return Part.class.equals(elementType.toClass()) && |
||||
(mediaType == null || MediaType.MULTIPART_FORM_DATA.isCompatibleWith(mediaType)); |
||||
} |
||||
|
||||
@Override |
||||
public Mono<Part> readMono(ResolvableType elementType, ReactiveHttpInputMessage message, |
||||
Map<String, Object> hints) { |
||||
return Mono.error(new UnsupportedOperationException("Cannot read multipart request body into single Part")); |
||||
} |
||||
|
||||
@Override |
||||
public Flux<Part> read(ResolvableType elementType, ReactiveHttpInputMessage message, Map<String, Object> hints) { |
||||
return Flux.defer(() -> { |
||||
byte[] boundary = boundary(message); |
||||
if (boundary == null) { |
||||
return Flux.error(new DecodingException("No multipart boundary found in Content-Type: \"" + |
||||
message.getHeaders().getContentType() + "\"")); |
||||
} |
||||
Flux<MultipartParser.Token> tokens = MultipartParser.parse(message.getBody(), boundary, |
||||
this.maxHeadersSize); |
||||
|
||||
return PartGenerator.createParts(tokens, this.maxParts, this.maxInMemorySize, this.maxDiskUsagePerPart, |
||||
this.streaming, this.fileStorageDirectory, this.blockingOperationScheduler); |
||||
}); |
||||
} |
||||
|
||||
@Nullable |
||||
private static byte[] boundary(HttpMessage message) { |
||||
MediaType contentType = message.getHeaders().getContentType(); |
||||
if (contentType != null) { |
||||
String boundary = contentType.getParameter("boundary"); |
||||
if (boundary != null) { |
||||
return boundary.getBytes(StandardCharsets.ISO_8859_1); |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
@SuppressWarnings("BlockingMethodInNonBlockingContext") |
||||
private Mono<Path> defaultFileStorageDirectory() { |
||||
return Mono.fromCallable(() -> { |
||||
Path tempDirectory = Paths.get(System.getProperty("java.io.tmpdir"), IDENTIFIER); |
||||
if (!Files.exists(tempDirectory)) { |
||||
Files.createDirectory(tempDirectory); |
||||
} |
||||
return tempDirectory; |
||||
}).subscribeOn(this.blockingOperationScheduler); |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,210 @@
@@ -0,0 +1,210 @@
|
||||
/* |
||||
* Copyright 2002-2020 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.codec.multipart; |
||||
|
||||
import java.nio.file.Path; |
||||
|
||||
import reactor.core.publisher.Flux; |
||||
import reactor.core.publisher.Mono; |
||||
|
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.core.io.buffer.DataBufferFactory; |
||||
import org.springframework.core.io.buffer.DataBufferUtils; |
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory; |
||||
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 |
||||
* @since 5.3 |
||||
*/ |
||||
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} with the given parameters. |
||||
* 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 content the content of the part |
||||
* @return {@link Part} or {@link FilePart}, depending on {@link HttpHeaders#getContentDisposition()} |
||||
*/ |
||||
public static Part part(HttpHeaders headers, Flux<DataBuffer> content) { |
||||
Assert.notNull(headers, "Headers must not be null"); |
||||
Assert.notNull(content, "Content must not be null"); |
||||
|
||||
String filename = headers.getContentDisposition().getFilename(); |
||||
if (filename != null) { |
||||
return new DefaultFilePart(headers, content); |
||||
} |
||||
else { |
||||
return new DefaultPart(headers, content); |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Abstract base class. |
||||
*/ |
||||
private static abstract 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 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; |
||||
|
||||
private final DataBufferFactory bufferFactory = new DefaultDataBufferFactory(); |
||||
|
||||
public DefaultFormFieldPart(HttpHeaders headers, String value) { |
||||
super(headers); |
||||
this.value = value; |
||||
} |
||||
|
||||
@Override |
||||
public Flux<DataBuffer> content() { |
||||
return Flux.defer(() -> { |
||||
byte[] bytes = this.value.getBytes(MultipartUtils.charset(headers())); |
||||
return Flux.just(this.bufferFactory.wrap(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 { |
||||
|
||||
private final Flux<DataBuffer> content; |
||||
|
||||
public DefaultPart(HttpHeaders headers, Flux<DataBuffer> content) { |
||||
super(headers); |
||||
this.content = content; |
||||
} |
||||
|
||||
@Override |
||||
public Flux<DataBuffer> content() { |
||||
return this.content; |
||||
} |
||||
|
||||
@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 class DefaultFilePart extends DefaultPart implements FilePart { |
||||
|
||||
public DefaultFilePart(HttpHeaders headers, Flux<DataBuffer> content) { |
||||
super(headers, content); |
||||
} |
||||
|
||||
@Override |
||||
public String filename() { |
||||
String filename = this.headers().getContentDisposition().getFilename(); |
||||
Assert.state(filename != null, "No filename found"); |
||||
return filename; |
||||
} |
||||
|
||||
@Override |
||||
public Mono<Void> transferTo(Path dest) { |
||||
return DataBufferUtils.write(content(), 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 + ")}"; |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,578 @@
@@ -0,0 +1,578 @@
|
||||
/* |
||||
* Copyright 2002-2020 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.codec.multipart; |
||||
|
||||
import java.nio.charset.StandardCharsets; |
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
import java.util.concurrent.atomic.AtomicBoolean; |
||||
import java.util.concurrent.atomic.AtomicInteger; |
||||
import java.util.concurrent.atomic.AtomicReference; |
||||
|
||||
import org.apache.commons.logging.Log; |
||||
import org.apache.commons.logging.LogFactory; |
||||
import org.reactivestreams.Subscription; |
||||
import reactor.core.publisher.BaseSubscriber; |
||||
import reactor.core.publisher.Flux; |
||||
import reactor.core.publisher.FluxSink; |
||||
|
||||
import org.springframework.core.codec.DecodingException; |
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.core.io.buffer.DataBufferLimitException; |
||||
import org.springframework.core.io.buffer.DataBufferUtils; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.lang.Nullable; |
||||
|
||||
/** |
||||
* Subscribes to a buffer stream and produces a flux of {@link Token} instances. |
||||
* |
||||
* @author Arjen Poutsma |
||||
* @since 5.3 |
||||
*/ |
||||
final class MultipartParser extends BaseSubscriber<DataBuffer> { |
||||
|
||||
private static final byte CR = '\r'; |
||||
|
||||
private static final byte LF = '\n'; |
||||
|
||||
private static final byte[] CR_LF = {CR, LF}; |
||||
|
||||
private static final byte HYPHEN = '-'; |
||||
|
||||
private static final byte[] TWO_HYPHENS = {HYPHEN, HYPHEN}; |
||||
|
||||
private static final String HEADER_ENTRY_SEPARATOR = "\\r\\n"; |
||||
|
||||
private static final Log logger = LogFactory.getLog(MultipartParser.class); |
||||
|
||||
private final AtomicReference<State> state; |
||||
|
||||
private final FluxSink<Token> sink; |
||||
|
||||
private final byte[] boundary; |
||||
|
||||
private final int maxHeadersSize; |
||||
|
||||
private final AtomicBoolean requestOutstanding = new AtomicBoolean(); |
||||
|
||||
|
||||
private MultipartParser(FluxSink<Token> sink, byte[] boundary, int maxHeadersSize) { |
||||
this.sink = sink; |
||||
this.boundary = boundary; |
||||
this.maxHeadersSize = maxHeadersSize; |
||||
this.state = new AtomicReference<>(new PreambleState()); |
||||
} |
||||
|
||||
/** |
||||
* Parses the given stream of {@link DataBuffer} objects into a stream of {@link Token} objects. |
||||
* @param buffers the input buffers |
||||
* @param boundary the multipart boundary, as found in the {@code Content-Type} header |
||||
* @param maxHeadersSize the maximum buffered header size |
||||
* @return a stream of parsed tokens |
||||
*/ |
||||
public static Flux<Token> parse(Flux<DataBuffer> buffers, byte[] boundary, int maxHeadersSize) { |
||||
return Flux.create(sink -> { |
||||
MultipartParser parser = new MultipartParser(sink, boundary, maxHeadersSize); |
||||
sink.onCancel(parser::onSinkCancel); |
||||
sink.onRequest(n -> parser.requestBuffer()); |
||||
buffers.subscribe(parser); |
||||
}); |
||||
} |
||||
|
||||
@Override |
||||
protected void hookOnSubscribe(Subscription subscription) { |
||||
requestBuffer(); |
||||
} |
||||
|
||||
@Override |
||||
protected void hookOnNext(DataBuffer value) { |
||||
this.requestOutstanding.set(false); |
||||
this.state.get().onNext(value); |
||||
} |
||||
|
||||
@Override |
||||
protected void hookOnComplete() { |
||||
this.state.get().onComplete(); |
||||
} |
||||
|
||||
@Override |
||||
protected void hookOnError(Throwable throwable) { |
||||
State oldState = this.state.getAndSet(DisposedState.INSTANCE); |
||||
oldState.dispose(); |
||||
this.sink.error(throwable); |
||||
} |
||||
|
||||
private void onSinkCancel() { |
||||
State oldState = this.state.getAndSet(DisposedState.INSTANCE); |
||||
oldState.dispose(); |
||||
cancel(); |
||||
} |
||||
|
||||
boolean changeState(State oldState, State newState, @Nullable DataBuffer remainder) { |
||||
if (this.state.compareAndSet(oldState, newState)) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Changed state: " + oldState + " -> " + newState); |
||||
} |
||||
oldState.dispose(); |
||||
if (remainder != null) { |
||||
if (remainder.readableByteCount() > 0) { |
||||
newState.onNext(remainder); |
||||
} |
||||
else { |
||||
DataBufferUtils.release(remainder); |
||||
requestBuffer(); |
||||
} |
||||
} |
||||
return true; |
||||
} |
||||
else { |
||||
DataBufferUtils.release(remainder); |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
void emitHeaders(HttpHeaders headers) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Emitting headers: " + headers); |
||||
} |
||||
this.sink.next(new HeadersToken(headers)); |
||||
} |
||||
|
||||
void emitBody(DataBuffer buffer) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Emitting body: " + buffer); |
||||
} |
||||
this.sink.next(new BodyToken(buffer)); |
||||
} |
||||
|
||||
void emitError(Throwable t) { |
||||
cancel(); |
||||
this.sink.error(t); |
||||
} |
||||
|
||||
void emitComplete() { |
||||
cancel(); |
||||
this.sink.complete(); |
||||
} |
||||
|
||||
private void requestBuffer() { |
||||
if (upstream() != null && |
||||
!this.sink.isCancelled() && |
||||
this.sink.requestedFromDownstream() > 0 && |
||||
this.requestOutstanding.compareAndSet(false, true)) { |
||||
request(1); |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Represents the output of {@link #parse(Flux, byte[], int)}. |
||||
*/ |
||||
public abstract static class Token { |
||||
|
||||
public abstract HttpHeaders headers(); |
||||
|
||||
public abstract DataBuffer buffer(); |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Represents a token that contains {@link HttpHeaders}. |
||||
*/ |
||||
public final static class HeadersToken extends Token { |
||||
|
||||
private final HttpHeaders headers; |
||||
|
||||
public HeadersToken(HttpHeaders headers) { |
||||
this.headers = headers; |
||||
} |
||||
|
||||
@Override |
||||
public HttpHeaders headers() { |
||||
return this.headers; |
||||
} |
||||
|
||||
@Override |
||||
public DataBuffer buffer() { |
||||
throw new IllegalStateException(); |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Represents a token that contains {@link DataBuffer}. |
||||
*/ |
||||
public final static class BodyToken extends Token { |
||||
|
||||
private final DataBuffer buffer; |
||||
|
||||
public BodyToken(DataBuffer buffer) { |
||||
this.buffer = buffer; |
||||
} |
||||
|
||||
@Override |
||||
public HttpHeaders headers() { |
||||
throw new IllegalStateException(); |
||||
} |
||||
|
||||
@Override |
||||
public DataBuffer buffer() { |
||||
return this.buffer; |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* 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, and also when the |
||||
* sink is {@linkplain #onSinkCancel() cancelled}. |
||||
*/ |
||||
private interface State { |
||||
|
||||
void onNext(DataBuffer buf); |
||||
|
||||
void onComplete(); |
||||
|
||||
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; |
||||
|
||||
|
||||
public PreambleState() { |
||||
this.firstBoundary = DataBufferUtils.matcher( |
||||
MultipartUtils.concat(TWO_HYPHENS, MultipartParser.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 onNext(DataBuffer buf) { |
||||
int endIdx = this.firstBoundary.match(buf); |
||||
if (endIdx != -1) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("First boundary found @" + endIdx + " in " + buf); |
||||
} |
||||
DataBuffer headersBuf = MultipartUtils.sliceFrom(buf, endIdx); |
||||
DataBufferUtils.release(buf); |
||||
|
||||
changeState(this, new HeadersState(), headersBuf); |
||||
} |
||||
else { |
||||
DataBufferUtils.release(buf); |
||||
requestBuffer(); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void onComplete() { |
||||
if (changeState(this, DisposedState.INSTANCE, null)) { |
||||
emitError(new DecodingException("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(MultipartUtils.concat(CR_LF, CR_LF)); |
||||
|
||||
private final AtomicInteger byteCount = new AtomicInteger(); |
||||
|
||||
private final List<DataBuffer> buffers = new ArrayList<>(); |
||||
|
||||
|
||||
/** |
||||
* First checks whether the multipart boundary leading to this state |
||||
* was the final boundary, or whether {@link #maxHeadersSize} is |
||||
* exceeded. Then looks for the header-body boundary |
||||
* ({@code CR LF CR LF}) in the given buffer. If found, convert |
||||
* 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. |
||||
*/ |
||||
@Override |
||||
public void onNext(DataBuffer buf) { |
||||
long prevCount = this.byteCount.get(); |
||||
long count = this.byteCount.addAndGet(buf.readableByteCount()); |
||||
if (prevCount < 2 && count >= 2) { |
||||
if (isLastBoundary(buf)) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Last boundary found in " + buf); |
||||
} |
||||
|
||||
if (changeState(this, DisposedState.INSTANCE, buf)) { |
||||
emitComplete(); |
||||
} |
||||
return; |
||||
} |
||||
} |
||||
else if (count > MultipartParser.this.maxHeadersSize) { |
||||
if (changeState(this, DisposedState.INSTANCE, buf)) { |
||||
emitError(new DataBufferLimitException("Part headers exceeded the memory usage limit of " + |
||||
MultipartParser.this.maxHeadersSize + " bytes")); |
||||
} |
||||
return; |
||||
} |
||||
int endIdx = this.endHeaders.match(buf); |
||||
if (endIdx != -1) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("End of headers found @" + endIdx + " in " + buf); |
||||
} |
||||
DataBuffer headerBuf = MultipartUtils.sliceTo(buf, endIdx); |
||||
this.buffers.add(headerBuf); |
||||
DataBuffer bodyBuf = MultipartUtils.sliceFrom(buf, endIdx); |
||||
DataBufferUtils.release(buf); |
||||
|
||||
emitHeaders(parseHeaders()); |
||||
// TODO: no need to check result of changeState, no further statements
|
||||
changeState(this, new BodyState(), bodyBuf); |
||||
} |
||||
else { |
||||
this.buffers.add(buf); |
||||
requestBuffer(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* 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); |
||||
} |
||||
|
||||
/** |
||||
* 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(StandardCharsets.ISO_8859_1); |
||||
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 onComplete() { |
||||
if (changeState(this, DisposedState.INSTANCE, null)) { |
||||
emitError(new DecodingException("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 BodyToken} until the boundary is found (or |
||||
* rather: {@code CR LF - - boundary}. |
||||
*/ |
||||
private final class BodyState implements State { |
||||
|
||||
private final DataBufferUtils.Matcher boundary; |
||||
|
||||
private final AtomicReference<DataBuffer> previous = new AtomicReference<>(); |
||||
|
||||
public BodyState() { |
||||
this.boundary = DataBufferUtils.matcher( |
||||
MultipartUtils.concat(CR_LF, TWO_HYPHENS, MultipartParser.this.boundary)); |
||||
} |
||||
|
||||
/** |
||||
* 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 |
||||
* make {@code buffer} the previous buffer. |
||||
*/ |
||||
@Override |
||||
public void onNext(DataBuffer buffer) { |
||||
int endIdx = this.boundary.match(buffer); |
||||
if (endIdx != -1) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Boundary found @" + endIdx + " in " + buffer); |
||||
} |
||||
int len = endIdx - buffer.readPosition() - this.boundary.delimiter().length + 1; |
||||
if (len > 0) { |
||||
// buffer contains complete delimiter, let's slice it and flush it
|
||||
DataBuffer body = buffer.retainedSlice(buffer.readPosition(), len); |
||||
enqueue(body); |
||||
enqueue(null); |
||||
} |
||||
else if (len < 0) { |
||||
// buffer starts with the end of the delimiter, let's slice the previous buffer and flush it
|
||||
DataBuffer previous = this.previous.get(); |
||||
int prevLen = previous.readableByteCount() + len; |
||||
if (prevLen > 0) { |
||||
DataBuffer body = previous.retainedSlice(previous.readPosition(), prevLen); |
||||
DataBufferUtils.release(previous); |
||||
this.previous.set(body); |
||||
enqueue(null); |
||||
} |
||||
else { |
||||
DataBufferUtils.release(previous); |
||||
this.previous.set(null); |
||||
} |
||||
} |
||||
else /* if (sliceLength == 0) */ { |
||||
// buffer starts with complete delimiter, flush out the previous buffer
|
||||
enqueue(null); |
||||
} |
||||
|
||||
DataBuffer remainder = MultipartUtils.sliceFrom(buffer, endIdx); |
||||
DataBufferUtils.release(buffer); |
||||
|
||||
changeState(this, new HeadersState(), remainder); |
||||
} |
||||
else { |
||||
enqueue(buffer); |
||||
requestBuffer(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Stores the given buffer and sends out the previous buffer. |
||||
*/ |
||||
private void enqueue(@Nullable DataBuffer buf) { |
||||
DataBuffer previous = this.previous.getAndSet(buf); |
||||
if (previous != null) { |
||||
emitBody(previous); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void onComplete() { |
||||
if (changeState(this, DisposedState.INSTANCE, null)) { |
||||
emitError(new DecodingException("Could not find end of body")); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void dispose() { |
||||
DataBuffer previous = this.previous.getAndSet(null); |
||||
if (previous != null) { |
||||
DataBufferUtils.release(previous); |
||||
} |
||||
} |
||||
|
||||
@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 onNext(DataBuffer buf) { |
||||
DataBufferUtils.release(buf); |
||||
} |
||||
|
||||
@Override |
||||
public void onComplete() { |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "DISPOSED"; |
||||
} |
||||
} |
||||
|
||||
|
||||
} |
||||
@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
|
||||
/* |
||||
* Copyright 2002-2020 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.codec.multipart; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.channels.Channel; |
||||
import java.nio.charset.Charset; |
||||
import java.nio.charset.StandardCharsets; |
||||
|
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
|
||||
/** |
||||
* Various static utility methods for dealing with multipart parsing. |
||||
* @author Arjen Poutsma |
||||
* @since 5.3 |
||||
*/ |
||||
abstract class MultipartUtils { |
||||
|
||||
/** |
||||
* Return the character set of the given headers, as defined in the |
||||
* {@link HttpHeaders#getContentType()} header. |
||||
*/ |
||||
public 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; |
||||
} |
||||
|
||||
/** |
||||
* Concatenates the given array of byte arrays. |
||||
*/ |
||||
public 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; |
||||
} |
||||
|
||||
/** |
||||
* Slices the given buffer to the given index (exclusive). |
||||
*/ |
||||
public static DataBuffer sliceTo(DataBuffer buf, int idx) { |
||||
int pos = buf.readPosition(); |
||||
int len = idx - pos + 1; |
||||
return buf.retainedSlice(pos, len); |
||||
} |
||||
|
||||
/** |
||||
* Slices the given buffer from the given index (inclusive). |
||||
*/ |
||||
public static DataBuffer sliceFrom(DataBuffer buf, int idx) { |
||||
int len = buf.writePosition() - idx - 1; |
||||
return buf.retainedSlice(idx + 1, len); |
||||
} |
||||
|
||||
public static void closeChannel(Channel channel) { |
||||
try { |
||||
if (channel.isOpen()) { |
||||
channel.close(); |
||||
} |
||||
} |
||||
catch (IOException ignore) { |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,822 @@
@@ -0,0 +1,822 @@
|
||||
/* |
||||
* Copyright 2002-2020 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.codec.multipart; |
||||
|
||||
import java.io.IOException; |
||||
import java.io.UncheckedIOException; |
||||
import java.nio.ByteBuffer; |
||||
import java.nio.channels.WritableByteChannel; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.nio.file.StandardOpenOption; |
||||
import java.util.Collection; |
||||
import java.util.LinkedList; |
||||
import java.util.List; |
||||
import java.util.Queue; |
||||
import java.util.concurrent.ConcurrentLinkedQueue; |
||||
import java.util.concurrent.atomic.AtomicBoolean; |
||||
import java.util.concurrent.atomic.AtomicInteger; |
||||
import java.util.concurrent.atomic.AtomicLong; |
||||
import java.util.concurrent.atomic.AtomicReference; |
||||
|
||||
import org.apache.commons.logging.Log; |
||||
import org.apache.commons.logging.LogFactory; |
||||
import org.reactivestreams.Subscription; |
||||
import reactor.core.publisher.BaseSubscriber; |
||||
import reactor.core.publisher.Flux; |
||||
import reactor.core.publisher.FluxSink; |
||||
import reactor.core.publisher.Mono; |
||||
import reactor.core.scheduler.Scheduler; |
||||
|
||||
import org.springframework.core.codec.DecodingException; |
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.core.io.buffer.DataBufferFactory; |
||||
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.MediaType; |
||||
import org.springframework.util.FastByteArrayOutputStream; |
||||
|
||||
/** |
||||
* Subscribes to a token stream (i.e. the result of |
||||
* {@link MultipartParser#parse(Flux, byte[], int)}, and produces a flux of {@link Part} objects. |
||||
* |
||||
* @author Arjen Poutsma |
||||
* @since 5.3 |
||||
*/ |
||||
final class PartGenerator extends BaseSubscriber<MultipartParser.Token> { |
||||
|
||||
private static final DataBufferFactory bufferFactory = new DefaultDataBufferFactory(); |
||||
|
||||
private static final Log logger = LogFactory.getLog(PartGenerator.class); |
||||
|
||||
private final AtomicReference<State> state = new AtomicReference<>(new InitialState()); |
||||
|
||||
private final AtomicInteger partCount = new AtomicInteger(); |
||||
|
||||
private final AtomicBoolean requestOutstanding = new AtomicBoolean(); |
||||
|
||||
private final FluxSink<Part> sink; |
||||
|
||||
private final int maxParts; |
||||
|
||||
private final boolean streaming; |
||||
|
||||
private final int maxInMemorySize; |
||||
|
||||
private final long maxDiskUsagePerPart; |
||||
|
||||
private final Mono<Path> fileStorageDirectory; |
||||
|
||||
private final Scheduler blockingOperationScheduler; |
||||
|
||||
|
||||
private PartGenerator(FluxSink<Part> sink, int maxParts, int maxInMemorySize, long maxDiskUsagePerPart, |
||||
boolean streaming, Mono<Path> fileStorageDirectory, Scheduler blockingOperationScheduler) { |
||||
|
||||
this.sink = sink; |
||||
this.maxParts = maxParts; |
||||
this.maxInMemorySize = maxInMemorySize; |
||||
this.maxDiskUsagePerPart = maxDiskUsagePerPart; |
||||
this.streaming = streaming; |
||||
this.fileStorageDirectory = fileStorageDirectory; |
||||
this.blockingOperationScheduler = blockingOperationScheduler; |
||||
} |
||||
|
||||
/** |
||||
* Creates parts from a given stream of tokens. |
||||
*/ |
||||
public static Flux<Part> createParts(Flux<MultipartParser.Token> tokens, int maxParts, int maxInMemorySize, |
||||
long maxDiskUsagePerPart, boolean streaming, Mono<Path> fileStorageDirectory, |
||||
Scheduler blockingOperationScheduler) { |
||||
|
||||
return Flux.create(sink -> { |
||||
PartGenerator generator = new PartGenerator(sink, maxParts, maxInMemorySize, maxDiskUsagePerPart, streaming, |
||||
fileStorageDirectory, blockingOperationScheduler); |
||||
|
||||
sink.onCancel(generator::onSinkCancel); |
||||
sink.onRequest(l -> generator.requestToken()); |
||||
tokens.subscribe(generator); |
||||
}); |
||||
} |
||||
|
||||
@Override |
||||
protected void hookOnSubscribe(Subscription subscription) { |
||||
requestToken(); |
||||
} |
||||
|
||||
@Override |
||||
protected void hookOnNext(MultipartParser.Token token) { |
||||
this.requestOutstanding.set(false); |
||||
State state = this.state.get(); |
||||
if (token instanceof MultipartParser.HeadersToken) { |
||||
// finish previous part
|
||||
state.partComplete(false); |
||||
|
||||
if (tooManyParts()) { |
||||
return; |
||||
} |
||||
|
||||
newPart(state, token.headers()); |
||||
} |
||||
else { |
||||
state.body(token.buffer()); |
||||
} |
||||
} |
||||
|
||||
private void newPart(State currentState, HttpHeaders headers) { |
||||
if (isFormField(headers)) { |
||||
changeStateInternal(new FormFieldState(headers)); |
||||
requestToken(); |
||||
} |
||||
else if (!this.streaming) { |
||||
changeStateInternal(new InMemoryState(headers)); |
||||
requestToken(); |
||||
} |
||||
else { |
||||
Flux<DataBuffer> streamingContent = Flux.create(contentSink -> { |
||||
State newState = new StreamingState(contentSink); |
||||
if (changeState(currentState, newState)) { |
||||
contentSink.onRequest(l -> requestToken()); |
||||
requestToken(); |
||||
} |
||||
}); |
||||
emitPart(DefaultParts.part(headers, streamingContent)); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
protected void hookOnComplete() { |
||||
this.state.get().partComplete(true); |
||||
} |
||||
|
||||
@Override |
||||
protected void hookOnError(Throwable throwable) { |
||||
this.state.get().error(throwable); |
||||
changeStateInternal(DisposedState.INSTANCE); |
||||
this.sink.error(throwable); |
||||
} |
||||
|
||||
private void onSinkCancel() { |
||||
changeStateInternal(DisposedState.INSTANCE); |
||||
cancel(); |
||||
} |
||||
|
||||
boolean changeState(State oldState, State newState) { |
||||
if (this.state.compareAndSet(oldState, newState)) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Changed state: " + oldState + " -> " + newState); |
||||
} |
||||
oldState.dispose(); |
||||
return true; |
||||
} |
||||
else { |
||||
logger.warn("Could not switch from " + oldState + |
||||
" to " + newState + "; current state:" |
||||
+ this.state.get()); |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
private void changeStateInternal(State newState) { |
||||
if (this.state.get() == DisposedState.INSTANCE) { |
||||
return; |
||||
} |
||||
State oldState = this.state.getAndSet(newState); |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Changed state: " + oldState + " -> " + newState); |
||||
} |
||||
oldState.dispose(); |
||||
} |
||||
|
||||
void emitPart(Part part) { |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Emitting: " + part); |
||||
} |
||||
this.sink.next(part); |
||||
} |
||||
|
||||
void emitComplete() { |
||||
this.sink.complete(); |
||||
} |
||||
|
||||
|
||||
void emitError(Throwable t) { |
||||
cancel(); |
||||
this.sink.error(t); |
||||
} |
||||
|
||||
void requestToken() { |
||||
if (upstream() != null && |
||||
!this.sink.isCancelled() && |
||||
this.sink.requestedFromDownstream() > 0 && |
||||
this.requestOutstanding.compareAndSet(false, true)) { |
||||
request(1); |
||||
} |
||||
} |
||||
|
||||
private boolean tooManyParts() { |
||||
int count = this.partCount.incrementAndGet(); |
||||
if (this.maxParts > 0 && count > this.maxParts) { |
||||
emitError(new DecodingException("Too many parts (" + count + "/" + this.maxParts + " allowed)")); |
||||
return true; |
||||
} |
||||
else { |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
private static boolean isFormField(HttpHeaders headers) { |
||||
MediaType contentType = headers.getContentType(); |
||||
return (contentType == null || MediaType.TEXT_PLAIN.equalsTypeAndSubtype(contentType)) |
||||
&& headers.getContentDisposition().getFilename() == null; |
||||
} |
||||
|
||||
/** |
||||
* 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.HeadersToken} is accepted (see |
||||
* {@link #newPart(State, HttpHeaders)}. |
||||
* 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>If {@linkplain #streaming} is enabled, the creator will be in the |
||||
* {@link StreamingState}.</li> |
||||
* <li>Otherwise, the creator will initially be in the |
||||
* {@link InMemoryState}, but will switch over to {@link CreateFileState} |
||||
* when the part byte count exceeds {@link #maxInMemorySize}, |
||||
* then to {@link WritingFileState} (to write the memory contents), |
||||
* and finally {@link IdleFileState}, which switches back to |
||||
* {@link WritingFileState} when more body data comes in.</li> |
||||
* </ol> |
||||
*/ |
||||
private interface State { |
||||
|
||||
/** |
||||
* Invoked when a {@link MultipartParser.BodyToken} is received. |
||||
*/ |
||||
void body(DataBuffer dataBuffer); |
||||
|
||||
/** |
||||
* Invoked when all tokens for the part have been received. |
||||
* @param finalPart {@code true} if this was the last part (and |
||||
* {@link #emitComplete()} should be called; {@code false} otherwise |
||||
*/ |
||||
void partComplete(boolean finalPart); |
||||
|
||||
/** |
||||
* Invoked when an error has been received. |
||||
*/ |
||||
default void error(Throwable throwable) { |
||||
} |
||||
|
||||
/** |
||||
* Cleans up any state. |
||||
*/ |
||||
default void dispose() { |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* The initial state of the creator. Throws an exception for {@link #body(DataBuffer)}. |
||||
*/ |
||||
private final class InitialState implements State { |
||||
|
||||
private InitialState() { |
||||
} |
||||
|
||||
@Override |
||||
public void body(DataBuffer dataBuffer) { |
||||
DataBufferUtils.release(dataBuffer); |
||||
emitError(new IllegalStateException("Body token not expected")); |
||||
} |
||||
|
||||
@Override |
||||
public void partComplete(boolean finalPart) { |
||||
if (finalPart) { |
||||
emitComplete(); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "INITIAL"; |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* The creator state when a {@linkplain #isFormField(HttpHeaders) 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 body(DataBuffer dataBuffer) { |
||||
int size = this.value.size() + dataBuffer.readableByteCount(); |
||||
if (PartGenerator.this.maxInMemorySize == -1 || |
||||
size < PartGenerator.this.maxInMemorySize) { |
||||
store(dataBuffer); |
||||
requestToken(); |
||||
} |
||||
else { |
||||
DataBufferUtils.release(dataBuffer); |
||||
emitError(new DataBufferLimitException("Form field value exceeded the memory usage limit of " + |
||||
PartGenerator.this.maxInMemorySize + " bytes")); |
||||
} |
||||
} |
||||
|
||||
private void store(DataBuffer dataBuffer) { |
||||
try { |
||||
byte[] bytes = new byte[dataBuffer.readableByteCount()]; |
||||
dataBuffer.read(bytes); |
||||
this.value.write(bytes); |
||||
} |
||||
catch (IOException ex) { |
||||
emitError(ex); |
||||
} |
||||
finally { |
||||
DataBufferUtils.release(dataBuffer); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void partComplete(boolean finalPart) { |
||||
byte[] bytes = this.value.toByteArrayUnsafe(); |
||||
String value = new String(bytes, MultipartUtils.charset(this.headers)); |
||||
emitPart(DefaultParts.formFieldPart(this.headers, value)); |
||||
if (finalPart) { |
||||
emitComplete(); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "FORM-FIELD"; |
||||
} |
||||
|
||||
} |
||||
|
||||
|
||||
/** |
||||
* The creator state when {@link #streaming} is {@code true} (and not |
||||
* handling a form field). Relays all received buffers to a sink. |
||||
*/ |
||||
private final class StreamingState implements State { |
||||
|
||||
private final FluxSink<DataBuffer> bodySink; |
||||
|
||||
public StreamingState(FluxSink<DataBuffer> bodySink) { |
||||
this.bodySink = bodySink; |
||||
} |
||||
|
||||
@Override |
||||
public void body(DataBuffer dataBuffer) { |
||||
if (!this.bodySink.isCancelled()) { |
||||
this.bodySink.next(dataBuffer); |
||||
if (this.bodySink.requestedFromDownstream() > 0) { |
||||
requestToken(); |
||||
} |
||||
} |
||||
else { |
||||
DataBufferUtils.release(dataBuffer); |
||||
// even though the body sink is canceled, the (outer) part sink
|
||||
// might not be, so request another token
|
||||
requestToken(); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void partComplete(boolean finalPart) { |
||||
if (!this.bodySink.isCancelled()) { |
||||
this.bodySink.complete(); |
||||
} |
||||
if (finalPart) { |
||||
emitComplete(); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void error(Throwable throwable) { |
||||
if (!this.bodySink.isCancelled()) { |
||||
this.bodySink.error(throwable); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "STREAMING"; |
||||
} |
||||
|
||||
} |
||||
|
||||
|
||||
/** |
||||
* The creator state when {@link #streaming} is {@code false} (and 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 CreateFileState}, and eventually to |
||||
* {@link CreateFileState}. |
||||
*/ |
||||
private final class InMemoryState implements State { |
||||
|
||||
private final AtomicLong byteCount = new AtomicLong(); |
||||
|
||||
private final Queue<DataBuffer> content = new ConcurrentLinkedQueue<>(); |
||||
|
||||
private final HttpHeaders headers; |
||||
|
||||
private volatile boolean releaseOnDispose = true; |
||||
|
||||
|
||||
public InMemoryState(HttpHeaders headers) { |
||||
this.headers = headers; |
||||
} |
||||
|
||||
@Override |
||||
public void body(DataBuffer dataBuffer) { |
||||
long prevCount = this.byteCount.get(); |
||||
long count = this.byteCount.addAndGet(dataBuffer.readableByteCount()); |
||||
if (PartGenerator.this.maxInMemorySize == -1 || |
||||
count <= PartGenerator.this.maxInMemorySize) { |
||||
storeBuffer(dataBuffer); |
||||
} |
||||
else if (prevCount <= PartGenerator.this.maxInMemorySize) { |
||||
switchToFile(dataBuffer, count); |
||||
} |
||||
else { |
||||
DataBufferUtils.release(dataBuffer); |
||||
emitError(new IllegalStateException("Body token not expected")); |
||||
} |
||||
} |
||||
|
||||
private void storeBuffer(DataBuffer dataBuffer) { |
||||
this.content.add(dataBuffer); |
||||
requestToken(); |
||||
} |
||||
|
||||
private void switchToFile(DataBuffer current, long byteCount) { |
||||
List<DataBuffer> content = new LinkedList<>(this.content); |
||||
content.add(current); |
||||
this.releaseOnDispose = false; |
||||
|
||||
CreateFileState newState = new CreateFileState(this.headers, content, byteCount); |
||||
if (changeState(this, newState)) { |
||||
newState.createFile(); |
||||
} |
||||
else { |
||||
content.forEach(DataBufferUtils::release); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void partComplete(boolean finalPart) { |
||||
emitMemoryPart(); |
||||
if (finalPart) { |
||||
emitComplete(); |
||||
} |
||||
} |
||||
|
||||
private void emitMemoryPart() { |
||||
byte[] bytes = new byte[(int) this.byteCount.get()]; |
||||
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(); |
||||
Flux<DataBuffer> content = Flux.just(bufferFactory.wrap(bytes)); |
||||
emitPart(DefaultParts.part(this.headers, content)); |
||||
} |
||||
|
||||
@Override |
||||
public void dispose() { |
||||
if (this.releaseOnDispose) { |
||||
this.content.forEach(DataBufferUtils::release); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "IN-MEMORY"; |
||||
} |
||||
|
||||
} |
||||
|
||||
|
||||
/** |
||||
* The creator state when waiting for a temporary file to be created. |
||||
* {@link InMemoryState} initially switches to this state when the byte |
||||
* count exceeds {@link #maxInMemorySize}, and then calls |
||||
* {@link #createFile()} to switch to {@link WritingFileState}. |
||||
*/ |
||||
private final class CreateFileState implements State { |
||||
|
||||
private final HttpHeaders headers; |
||||
|
||||
private final Collection<DataBuffer> content; |
||||
|
||||
private final long byteCount; |
||||
|
||||
private volatile boolean completed; |
||||
|
||||
private volatile boolean finalPart; |
||||
|
||||
private volatile boolean releaseOnDispose = true; |
||||
|
||||
|
||||
public CreateFileState(HttpHeaders headers, Collection<DataBuffer> content, long byteCount) { |
||||
this.headers = headers; |
||||
this.content = content; |
||||
this.byteCount = byteCount; |
||||
} |
||||
|
||||
@Override |
||||
public void body(DataBuffer dataBuffer) { |
||||
DataBufferUtils.release(dataBuffer); |
||||
emitError(new IllegalStateException("Body token not expected")); |
||||
} |
||||
|
||||
@Override |
||||
public void partComplete(boolean finalPart) { |
||||
this.completed = true; |
||||
this.finalPart = finalPart; |
||||
} |
||||
|
||||
public void createFile() { |
||||
PartGenerator.this.fileStorageDirectory |
||||
.map(this::createFileState) |
||||
.subscribeOn(PartGenerator.this.blockingOperationScheduler) |
||||
.subscribe(this::fileCreated, PartGenerator.this::emitError); |
||||
} |
||||
|
||||
private WritingFileState createFileState(Path directory) { |
||||
try { |
||||
Path tempFile = Files.createTempFile(directory, null, ".multipart"); |
||||
if (logger.isTraceEnabled()) { |
||||
logger.trace("Storing multipart data in file " + tempFile); |
||||
} |
||||
WritableByteChannel channel = Files.newByteChannel(tempFile, StandardOpenOption.WRITE); |
||||
return new WritingFileState(this, tempFile, channel); |
||||
} |
||||
catch (IOException ex) { |
||||
throw new UncheckedIOException("Could not create temp file in " + directory, ex); |
||||
} |
||||
} |
||||
|
||||
private void fileCreated(WritingFileState newState) { |
||||
this.releaseOnDispose = false; |
||||
|
||||
if (changeState(this, newState)) { |
||||
|
||||
newState.writeBuffers(this.content); |
||||
|
||||
if (this.completed) { |
||||
newState.partComplete(this.finalPart); |
||||
} |
||||
} |
||||
else { |
||||
MultipartUtils.closeChannel(newState.channel); |
||||
this.content.forEach(DataBufferUtils::release); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void dispose() { |
||||
if (this.releaseOnDispose) { |
||||
this.content.forEach(DataBufferUtils::release); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "CREATE-FILE"; |
||||
} |
||||
|
||||
|
||||
} |
||||
|
||||
private final class IdleFileState implements State { |
||||
|
||||
private final HttpHeaders headers; |
||||
|
||||
private final Path file; |
||||
|
||||
private final WritableByteChannel channel; |
||||
|
||||
private final AtomicLong byteCount; |
||||
|
||||
private volatile boolean closeOnDispose = true; |
||||
|
||||
|
||||
public IdleFileState(WritingFileState state) { |
||||
this.headers = state.headers; |
||||
this.file = state.file; |
||||
this.channel = state.channel; |
||||
this.byteCount = state.byteCount; |
||||
} |
||||
|
||||
@Override |
||||
public void body(DataBuffer dataBuffer) { |
||||
long count = this.byteCount.addAndGet(dataBuffer.readableByteCount()); |
||||
if (PartGenerator.this.maxDiskUsagePerPart == -1 || count <= PartGenerator.this.maxDiskUsagePerPart) { |
||||
|
||||
this.closeOnDispose = false; |
||||
WritingFileState newState = new WritingFileState(this); |
||||
if (changeState(this, newState)) { |
||||
newState.writeBuffer(dataBuffer); |
||||
} |
||||
else { |
||||
MultipartUtils.closeChannel(this.channel); |
||||
DataBufferUtils.release(dataBuffer); |
||||
} |
||||
} |
||||
else { |
||||
DataBufferUtils.release(dataBuffer); |
||||
emitError(new DataBufferLimitException( |
||||
"Part exceeded the disk usage limit of " + PartGenerator.this.maxDiskUsagePerPart + |
||||
" bytes")); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public void partComplete(boolean finalPart) { |
||||
MultipartUtils.closeChannel(this.channel); |
||||
Flux<DataBuffer> content = partContent(); |
||||
emitPart(DefaultParts.part(this.headers, content)); |
||||
if (finalPart) { |
||||
emitComplete(); |
||||
} |
||||
} |
||||
|
||||
private Flux<DataBuffer> partContent() { |
||||
return DataBufferUtils.readByteChannel(() -> Files.newByteChannel(this.file, StandardOpenOption.READ), |
||||
bufferFactory, 1024) |
||||
.subscribeOn(PartGenerator.this.blockingOperationScheduler); |
||||
} |
||||
|
||||
@Override |
||||
public void dispose() { |
||||
if (this.closeOnDispose) { |
||||
MultipartUtils.closeChannel(this.channel); |
||||
} |
||||
} |
||||
|
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "IDLE-FILE"; |
||||
} |
||||
|
||||
} |
||||
|
||||
private final class WritingFileState implements State { |
||||
|
||||
|
||||
private final HttpHeaders headers; |
||||
|
||||
private final Path file; |
||||
|
||||
private final WritableByteChannel channel; |
||||
|
||||
private final AtomicLong byteCount; |
||||
|
||||
private volatile boolean completed; |
||||
|
||||
private volatile boolean finalPart; |
||||
|
||||
|
||||
public WritingFileState(CreateFileState state, Path file, WritableByteChannel channel) { |
||||
this.headers = state.headers; |
||||
this.file = file; |
||||
this.channel = channel; |
||||
this.byteCount = new AtomicLong(state.byteCount); |
||||
} |
||||
|
||||
public WritingFileState(IdleFileState state) { |
||||
this.headers = state.headers; |
||||
this.file = state.file; |
||||
this.channel = state.channel; |
||||
this.byteCount = state.byteCount; |
||||
} |
||||
|
||||
@Override |
||||
public void body(DataBuffer dataBuffer) { |
||||
DataBufferUtils.release(dataBuffer); |
||||
emitError(new IllegalStateException("Body token not expected")); |
||||
} |
||||
|
||||
@Override |
||||
public void partComplete(boolean finalPart) { |
||||
this.completed = true; |
||||
this.finalPart = finalPart; |
||||
} |
||||
|
||||
public void writeBuffer(DataBuffer dataBuffer) { |
||||
Mono.just(dataBuffer) |
||||
.flatMap(this::writeInternal) |
||||
.subscribeOn(PartGenerator.this.blockingOperationScheduler) |
||||
.subscribe(null, |
||||
PartGenerator.this::emitError, |
||||
this::writeComplete); |
||||
} |
||||
|
||||
public void writeBuffers(Iterable<DataBuffer> dataBuffers) { |
||||
Flux.fromIterable(dataBuffers) |
||||
.concatMap(this::writeInternal) |
||||
.then() |
||||
.subscribeOn(PartGenerator.this.blockingOperationScheduler) |
||||
.subscribe(null, |
||||
PartGenerator.this::emitError, |
||||
this::writeComplete); |
||||
} |
||||
|
||||
private void writeComplete() { |
||||
IdleFileState newState = new IdleFileState(this); |
||||
if (this.completed) { |
||||
newState.partComplete(this.finalPart); |
||||
} |
||||
else if (changeState(this, newState)) { |
||||
requestToken(); |
||||
} |
||||
else { |
||||
MultipartUtils.closeChannel(this.channel); |
||||
} |
||||
} |
||||
|
||||
@SuppressWarnings("BlockingMethodInNonBlockingContext") |
||||
private Mono<Void> writeInternal(DataBuffer dataBuffer) { |
||||
try { |
||||
ByteBuffer byteBuffer = dataBuffer.asByteBuffer(); |
||||
while (byteBuffer.hasRemaining()) { |
||||
this.channel.write(byteBuffer); |
||||
} |
||||
return Mono.empty(); |
||||
} |
||||
catch (IOException ex) { |
||||
return Mono.error(ex); |
||||
} |
||||
finally { |
||||
DataBufferUtils.release(dataBuffer); |
||||
} |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "WRITE-FILE"; |
||||
} |
||||
} |
||||
|
||||
|
||||
private static final class DisposedState implements State { |
||||
|
||||
public static final DisposedState INSTANCE = new DisposedState(); |
||||
|
||||
private DisposedState() { |
||||
} |
||||
|
||||
@Override |
||||
public void body(DataBuffer dataBuffer) { |
||||
DataBufferUtils.release(dataBuffer); |
||||
} |
||||
|
||||
@Override |
||||
public void partComplete(boolean finalPart) { |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return "DISPOSED"; |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,373 @@
@@ -0,0 +1,373 @@
|
||||
/* |
||||
* Copyright 2002-2020 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.codec.multipart; |
||||
|
||||
import java.io.IOException; |
||||
import java.lang.annotation.ElementType; |
||||
import java.lang.annotation.Retention; |
||||
import java.lang.annotation.RetentionPolicy; |
||||
import java.lang.annotation.Target; |
||||
import java.nio.file.Files; |
||||
import java.nio.file.Path; |
||||
import java.util.concurrent.CountDownLatch; |
||||
import java.util.concurrent.TimeUnit; |
||||
import java.util.stream.Stream; |
||||
|
||||
import io.netty.buffer.PooledByteBufAllocator; |
||||
import org.junit.jupiter.api.Test; |
||||
import org.junit.jupiter.params.ParameterizedTest; |
||||
import org.junit.jupiter.params.provider.Arguments; |
||||
import org.junit.jupiter.params.provider.MethodSource; |
||||
import org.reactivestreams.Subscription; |
||||
import reactor.core.Exceptions; |
||||
import reactor.core.publisher.BaseSubscriber; |
||||
import reactor.core.publisher.Flux; |
||||
import reactor.core.publisher.Mono; |
||||
import reactor.test.StepVerifier; |
||||
|
||||
import org.springframework.core.codec.DecodingException; |
||||
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.DataBufferFactory; |
||||
import org.springframework.core.io.buffer.DataBufferUtils; |
||||
import org.springframework.core.io.buffer.NettyDataBufferFactory; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.lang.Nullable; |
||||
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; |
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8; |
||||
import static java.util.Collections.emptyMap; |
||||
import static java.util.Collections.singletonMap; |
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
import static org.junit.jupiter.params.provider.Arguments.arguments; |
||||
import static org.springframework.core.ResolvableType.forClass; |
||||
import static org.springframework.core.io.buffer.DataBufferUtils.release; |
||||
|
||||
/** |
||||
* @author Arjen Poutsma |
||||
*/ |
||||
public class DefaultPartHttpMessageReaderTests { |
||||
|
||||
private static final String LOREM_IPSUM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer iaculis metus id vestibulum nullam."; |
||||
|
||||
private static final String MUSPI_MEROL = new StringBuilder(LOREM_IPSUM).reverse().toString(); |
||||
|
||||
private static final int BUFFER_SIZE = 64; |
||||
|
||||
private static final DataBufferFactory bufferFactory = new NettyDataBufferFactory(new PooledByteBufAllocator()); |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void canRead(String displayName, DefaultPartHttpMessageReader reader) { |
||||
assertThat(reader.canRead(forClass(Part.class), MediaType.MULTIPART_FORM_DATA)).isTrue(); |
||||
} |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void simple(String displayName, DefaultPartHttpMessageReader reader) throws InterruptedException { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("simple.multipart", getClass()), "simple-boundary"); |
||||
|
||||
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap()); |
||||
|
||||
CountDownLatch latch = new CountDownLatch(2); |
||||
StepVerifier.create(result) |
||||
.consumeNextWith(part -> testPart(part, null, |
||||
"This is implicitly typed plain ASCII text.\r\nIt does NOT end with a linebreak.", latch)).as("Part 1") |
||||
.consumeNextWith(part -> testPart(part, null, |
||||
"This is explicitly typed plain ASCII text.\r\nIt DOES end with a linebreak.\r\n", latch)).as("Part 2") |
||||
.verifyComplete(); |
||||
|
||||
latch.await(); |
||||
} |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void noHeaders(String displayName, DefaultPartHttpMessageReader reader) { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("no-header.multipart", getClass()), "boundary"); |
||||
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.consumeNextWith(part -> { |
||||
assertThat(part.headers()).isEmpty(); |
||||
part.content().subscribe(DataBufferUtils::release); |
||||
}) |
||||
.verifyComplete(); |
||||
} |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void noEndBoundary(String displayName, DefaultPartHttpMessageReader reader) { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("no-end-boundary.multipart", getClass()), "boundary"); |
||||
|
||||
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.expectError(DecodingException.class) |
||||
.verify(); |
||||
} |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void garbage(String displayName, DefaultPartHttpMessageReader reader) { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("garbage-1.multipart", getClass()), "boundary"); |
||||
|
||||
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.expectError(DecodingException.class) |
||||
.verify(); |
||||
} |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void noEndHeader(String displayName, DefaultPartHttpMessageReader reader) { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("no-end-header.multipart", getClass()), "boundary"); |
||||
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.expectError(DecodingException.class) |
||||
.verify(); |
||||
} |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void noEndBody(String displayName, DefaultPartHttpMessageReader reader) { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("no-end-body.multipart", getClass()), "boundary"); |
||||
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.expectError(DecodingException.class) |
||||
.verify(); |
||||
} |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void cancelPart(String displayName, DefaultPartHttpMessageReader reader) { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("simple.multipart", getClass()), "simple-boundary"); |
||||
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result, 1) |
||||
.consumeNextWith(part -> part.content().subscribe(DataBufferUtils::release)) |
||||
.thenCancel() |
||||
.verify(); |
||||
} |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void cancelBody(String displayName, DefaultPartHttpMessageReader reader) throws Exception { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("simple.multipart", getClass()), "simple-boundary"); |
||||
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap()); |
||||
|
||||
CountDownLatch latch = new CountDownLatch(1); |
||||
StepVerifier.create(result, 1) |
||||
.consumeNextWith(part -> part.content().subscribe(new CancelSubscriber())) |
||||
.thenRequest(1) |
||||
.consumeNextWith(part -> testPart(part, null, |
||||
"This is explicitly typed plain ASCII text.\r\nIt DOES end with a linebreak.\r\n", latch)).as("Part 2") |
||||
.verifyComplete(); |
||||
|
||||
latch.await(3, TimeUnit.SECONDS); |
||||
} |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void cancelBodyThenPart(String displayName, DefaultPartHttpMessageReader reader) { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("simple.multipart", getClass()), "simple-boundary"); |
||||
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result, 1) |
||||
.consumeNextWith(part -> part.content().subscribe(new CancelSubscriber())) |
||||
.thenCancel() |
||||
.verify(); |
||||
} |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void firefox(String displayName, DefaultPartHttpMessageReader reader) throws InterruptedException { |
||||
testBrowser(reader, new ClassPathResource("firefox.multipart", getClass()), |
||||
"---------------------------18399284482060392383840973206"); |
||||
} |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void chrome(String displayName, DefaultPartHttpMessageReader reader) throws InterruptedException { |
||||
testBrowser(reader, new ClassPathResource("chrome.multipart", getClass()), |
||||
"----WebKitFormBoundaryEveBLvRT65n21fwU"); |
||||
} |
||||
|
||||
@ParameterizedDefaultPartHttpMessageReaderTest |
||||
public void safari(String displayName, DefaultPartHttpMessageReader reader) throws InterruptedException { |
||||
testBrowser(reader, new ClassPathResource("safari.multipart", getClass()), |
||||
"----WebKitFormBoundaryG8fJ50opQOML0oGD"); |
||||
} |
||||
|
||||
@Test |
||||
public void tooManyParts() throws InterruptedException { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("simple.multipart", getClass()), "simple-boundary"); |
||||
|
||||
DefaultPartHttpMessageReader reader = new DefaultPartHttpMessageReader(); |
||||
reader.setMaxParts(1); |
||||
|
||||
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap()); |
||||
|
||||
CountDownLatch latch = new CountDownLatch(1); |
||||
StepVerifier.create(result) |
||||
.consumeNextWith(part -> testPart(part, null, |
||||
"This is implicitly typed plain ASCII text.\r\nIt does NOT end with a linebreak.", latch)).as("Part 1") |
||||
.expectError(DecodingException.class) |
||||
.verify(); |
||||
|
||||
latch.await(); |
||||
} |
||||
|
||||
private void testBrowser(DefaultPartHttpMessageReader reader, Resource resource, String boundary) |
||||
throws InterruptedException { |
||||
|
||||
MockServerHttpRequest request = createRequest(resource, boundary); |
||||
|
||||
Flux<Part> result = reader.read(forClass(Part.class), request, emptyMap()); |
||||
CountDownLatch latch = new CountDownLatch(3); |
||||
StepVerifier.create(result) |
||||
.consumeNextWith(part -> testBrowserFormField(part, "text1", "a")).as("text1") |
||||
.consumeNextWith(part -> testBrowserFormField(part, "text2", "b")).as("text2") |
||||
.consumeNextWith(part -> testBrowserFile(part, "file1", "a.txt", LOREM_IPSUM, latch)).as("file1") |
||||
.consumeNextWith(part -> testBrowserFile(part, "file2", "a.txt", LOREM_IPSUM, latch)).as("file2-1") |
||||
.consumeNextWith(part -> testBrowserFile(part, "file2", "b.txt", MUSPI_MEROL, latch)).as("file2-2") |
||||
.verifyComplete(); |
||||
latch.await(); |
||||
} |
||||
|
||||
private MockServerHttpRequest createRequest(Resource resource, String boundary) { |
||||
Flux<DataBuffer> body = DataBufferUtils |
||||
.readByteChannel(resource::readableChannel, bufferFactory, BUFFER_SIZE); |
||||
|
||||
MediaType contentType = new MediaType("multipart", "form-data", singletonMap("boundary", boundary)); |
||||
return MockServerHttpRequest.post("/") |
||||
.contentType(contentType) |
||||
.body(body); |
||||
} |
||||
|
||||
private void testPart(Part part, @Nullable String expectedName, String expectedContents, CountDownLatch latch) { |
||||
if (expectedName != null) { |
||||
assertThat(part.name()).isEqualTo(expectedName); |
||||
} |
||||
|
||||
Mono<String> content = DataBufferUtils.join(part.content()) |
||||
.map(buffer -> { |
||||
byte[] bytes = new byte[buffer.readableByteCount()]; |
||||
buffer.read(bytes); |
||||
release(buffer); |
||||
return new String(bytes, UTF_8); |
||||
}); |
||||
|
||||
content.subscribe(s -> assertThat(s).isEqualTo(expectedContents), |
||||
throwable -> { |
||||
throw new AssertionError(throwable.getMessage(), throwable); |
||||
}, |
||||
latch::countDown); |
||||
} |
||||
|
||||
|
||||
private static void testBrowserFormField(Part part, String name, String value) { |
||||
assertThat(part).isInstanceOf(FormFieldPart.class); |
||||
assertThat(part.name()).isEqualTo(name); |
||||
FormFieldPart formField = (FormFieldPart) part; |
||||
assertThat(formField.value()).isEqualTo(value); |
||||
} |
||||
|
||||
private static void testBrowserFile(Part part, String name, String filename, String contents, CountDownLatch latch) { |
||||
try { |
||||
assertThat(part).isInstanceOf(FilePart.class); |
||||
assertThat(part.name()).isEqualTo(name); |
||||
FilePart filePart = (FilePart) part; |
||||
assertThat(filePart.filename()).isEqualTo(filename); |
||||
|
||||
Path tempFile = Files.createTempFile("DefaultMultipartMessageReaderTests", null); |
||||
|
||||
filePart.transferTo(tempFile) |
||||
.subscribe(null, |
||||
throwable -> { |
||||
throw Exceptions.bubble(throwable); |
||||
}, |
||||
() -> { |
||||
try { |
||||
verifyContents(tempFile, contents); |
||||
} |
||||
finally { |
||||
latch.countDown(); |
||||
} |
||||
|
||||
}); |
||||
} |
||||
catch (Exception ex) { |
||||
throw new AssertionError(ex); |
||||
} |
||||
} |
||||
|
||||
private static void verifyContents(Path tempFile, String contents) { |
||||
try { |
||||
String result = String.join("", Files.readAllLines(tempFile)); |
||||
assertThat(result).isEqualTo(contents); |
||||
} |
||||
catch (IOException ex) { |
||||
throw new AssertionError(ex); |
||||
} |
||||
} |
||||
|
||||
|
||||
private static class CancelSubscriber extends BaseSubscriber<DataBuffer> { |
||||
|
||||
@Override |
||||
protected void hookOnSubscribe(Subscription subscription) { |
||||
request(1); |
||||
} |
||||
|
||||
@Override |
||||
protected void hookOnNext(DataBuffer buffer) { |
||||
DataBufferUtils.release(buffer); |
||||
cancel(); |
||||
} |
||||
|
||||
} |
||||
|
||||
@Retention(RetentionPolicy.RUNTIME) |
||||
@Target(ElementType.METHOD) |
||||
@ParameterizedTest(name = "[{index}] {0}") |
||||
@MethodSource("org.springframework.http.codec.multipart.DefaultPartHttpMessageReaderTests#messageReaders()") |
||||
public @interface ParameterizedDefaultPartHttpMessageReaderTest { |
||||
} |
||||
|
||||
public static Stream<Arguments> messageReaders() { |
||||
DefaultPartHttpMessageReader streaming = new DefaultPartHttpMessageReader(); |
||||
streaming.setStreaming(true); |
||||
|
||||
DefaultPartHttpMessageReader inMemory = new DefaultPartHttpMessageReader(); |
||||
inMemory.setStreaming(false); |
||||
inMemory.setMaxInMemorySize(1000); |
||||
|
||||
DefaultPartHttpMessageReader onDisk = new DefaultPartHttpMessageReader(); |
||||
onDisk.setStreaming(false); |
||||
onDisk.setMaxInMemorySize(100); |
||||
|
||||
return Stream.of( |
||||
arguments("streaming", streaming), |
||||
arguments("in-memory", inMemory), |
||||
arguments("on-disk", onDisk) |
||||
); |
||||
} |
||||
|
||||
|
||||
} |
||||
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
------WebKitFormBoundaryG8fJ50opQOML0oGD |
||||
Content-Disposition: form-data; name="file2"; filename="a.txt" |
||||
Content-Type: text/plain |
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer iaculis metus id vestibulum nullam. |
||||
|
||||
------WebKitFormBoundaryG8fJ50opQOML0oGD |
||||
Content-Disposition: form-data; name="file2"; filename="b.txt" |
||||
Content-Type: text/plain |
||||
|
||||
.mallun mulubitsev di sutem silucai regetnI .tile gnicsipida rutetcesnoc ,tema tis rolod muspi meroL |
||||
|
||||
------WebKitFormBoundaryG8fJ50opQOML0oGD-- |
||||
Binary file not shown.
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
--boundary |
||||
Header: Value |
||||
|
||||
a |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
--boundary |
||||
Header: Value |
||||
|
||||
a |
||||
--boundary |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
--boundary |
||||
Header-1: Value1 |
||||
Header-2: Value2 |
||||
Header-3: Value3 |
||||
Header-4: Value4 |
||||
--boundary-- |
||||
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
--boundary |
||||
|
||||
a |
||||
--boundary-- |
||||
@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
|
||||
This is the preamble. It is to be ignored, though it |
||||
is a handy place for mail composers to include an |
||||
explanatory note to non-MIME compliant readers. |
||||
--simple-boundary |
||||
|
||||
This is implicitly typed plain ASCII text. |
||||
It does NOT end with a linebreak. |
||||
--simple-boundary |
||||
Content-type: text/plain; charset=us-ascii |
||||
|
||||
This is explicitly typed plain ASCII text. |
||||
It DOES end with a linebreak. |
||||
|
||||
--simple-boundary-- |
||||
This is the epilogue. It is also to be ignored. |
||||
|
||||
Loading…
Reference in new issue