(Re)introduce DefaultMultipartMessageReader
This commit introduces the DefaultMultipartMessageReader, a fully reactive multipart parser without third party dependencies. An earlier version of this code was introduced infb642ce, but removed again in77c24aabecause of buffering issues. Closes gh-21659
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -56,9 +56,9 @@ import org.springframework.http.codec.json.Jackson2JsonDecoder;
|
||||
import org.springframework.http.codec.json.Jackson2JsonEncoder;
|
||||
import org.springframework.http.codec.json.Jackson2SmileDecoder;
|
||||
import org.springframework.http.codec.json.Jackson2SmileEncoder;
|
||||
import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader;
|
||||
import org.springframework.http.codec.multipart.MultipartHttpMessageReader;
|
||||
import org.springframework.http.codec.multipart.PartHttpMessageWriter;
|
||||
import org.springframework.http.codec.multipart.SynchronossPartHttpMessageReader;
|
||||
import org.springframework.http.codec.protobuf.ProtobufDecoder;
|
||||
import org.springframework.http.codec.protobuf.ProtobufHttpMessageWriter;
|
||||
import org.springframework.http.codec.xml.Jaxb2XmlDecoder;
|
||||
@@ -92,7 +92,7 @@ public class ServerCodecConfigurerTests {
|
||||
assertStringDecoder(getNextDecoder(readers), true);
|
||||
assertThat(getNextDecoder(readers).getClass()).isEqualTo(ProtobufDecoder.class);
|
||||
assertThat(readers.get(this.index.getAndIncrement()).getClass()).isEqualTo(FormHttpMessageReader.class);
|
||||
assertThat(readers.get(this.index.getAndIncrement()).getClass()).isEqualTo(SynchronossPartHttpMessageReader.class);
|
||||
assertThat(readers.get(this.index.getAndIncrement()).getClass()).isEqualTo(DefaultPartHttpMessageReader.class);
|
||||
assertThat(readers.get(this.index.getAndIncrement()).getClass()).isEqualTo(MultipartHttpMessageReader.class);
|
||||
assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jackson2JsonDecoder.class);
|
||||
assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jackson2SmileDecoder.class);
|
||||
@@ -146,10 +146,10 @@ public class ServerCodecConfigurerTests {
|
||||
assertThat(((StringDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size);
|
||||
assertThat(((ProtobufDecoder) getNextDecoder(readers)).getMaxMessageSize()).isEqualTo(size);
|
||||
assertThat(((FormHttpMessageReader) nextReader(readers)).getMaxInMemorySize()).isEqualTo(size);
|
||||
assertThat(((SynchronossPartHttpMessageReader) nextReader(readers)).getMaxInMemorySize()).isEqualTo(size);
|
||||
assertThat(((DefaultPartHttpMessageReader) nextReader(readers)).getMaxInMemorySize()).isEqualTo(size);
|
||||
|
||||
MultipartHttpMessageReader multipartReader = (MultipartHttpMessageReader) nextReader(readers);
|
||||
SynchronossPartHttpMessageReader reader = (SynchronossPartHttpMessageReader) multipartReader.getPartReader();
|
||||
DefaultPartHttpMessageReader reader = (DefaultPartHttpMessageReader) multipartReader.getPartReader();
|
||||
assertThat((reader).getMaxInMemorySize()).isEqualTo(size);
|
||||
|
||||
assertThat(((Jackson2JsonDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size);
|
||||
@@ -190,7 +190,7 @@ public class ServerCodecConfigurerTests {
|
||||
MultipartHttpMessageReader multipartReader = findCodec(readers, MultipartHttpMessageReader.class);
|
||||
assertThat(multipartReader.isEnableLoggingRequestDetails()).isTrue();
|
||||
|
||||
SynchronossPartHttpMessageReader reader = (SynchronossPartHttpMessageReader) multipartReader.getPartReader();
|
||||
DefaultPartHttpMessageReader reader = (DefaultPartHttpMessageReader) multipartReader.getPartReader();
|
||||
assertThat(reader.isEnableLoggingRequestDetails()).isTrue();
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ public class ServerCodecConfigurerTests {
|
||||
public void cloneConfigurer() {
|
||||
ServerCodecConfigurer clone = this.configurer.clone();
|
||||
|
||||
MultipartHttpMessageReader reader = new MultipartHttpMessageReader(new SynchronossPartHttpMessageReader());
|
||||
MultipartHttpMessageReader reader = new MultipartHttpMessageReader(new DefaultPartHttpMessageReader());
|
||||
Jackson2JsonEncoder encoder = new Jackson2JsonEncoder();
|
||||
clone.defaultCodecs().multipartReader(reader);
|
||||
clone.defaultCodecs().serverSentEventEncoder(encoder);
|
||||
|
||||
Reference in New Issue
Block a user