(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 in fb642ce, but removed
again in 77c24aa because of buffering issues.

Closes gh-21659
This commit is contained in:
Arjen Poutsma
2020-05-28 16:22:09 +02:00
parent 57f868fcbd
commit 973ee9b852
18 changed files with 2434 additions and 34 deletions

View File

@@ -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)
);
}
}

View File

@@ -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);