Introduce PartEvent

This commit introduces the PartEvent API. PartEvents are either
- FormPartEvents, representing a form field, or
- FilePartEvents, representing a file upload.

The PartEventHttpMessageReader is a HttpMessageReader that splits
multipart data into a stream of PartEvents. Form fields generate one
FormPartEvent; file uploads produce at least one FilePartEvent. The last
element that makes up a particular part will have isLast set to true.

The PartEventHttpMessageWriter is a HttpMessageWriter that writes a
Publisher<PartEvent> to a outgoing HTTP message. This writer is
particularly useful for relaying a multipart request on the server.

Closes gh-28006
This commit is contained in:
Arjen Poutsma
2022-02-10 11:04:30 +01:00
parent 081c6463e9
commit be7fa3aaa8
22 changed files with 1436 additions and 56 deletions

View File

@@ -0,0 +1,317 @@
/*
* Copyright 2002-2022 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.List;
import java.util.function.Consumer;
import io.netty.buffer.PooledByteBufAllocator;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
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.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
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.assertj.core.api.AssertionsForClassTypes.entry;
import static org.springframework.core.ResolvableType.forClass;
/**
* @author Arjen Poutsma
*/
class PartEventHttpMessageReaderTests {
private static final int BUFFER_SIZE = 64;
private static final DataBufferFactory bufferFactory = new NettyDataBufferFactory(new PooledByteBufAllocator());
private static final MediaType TEXT_PLAIN_ASCII = new MediaType("text", "plain", StandardCharsets.US_ASCII);
private final PartEventHttpMessageReader reader = new PartEventHttpMessageReader();
@Test
public void canRead() {
assertThat(this.reader.canRead(forClass(PartEvent.class), MediaType.MULTIPART_FORM_DATA)).isTrue();
assertThat(this.reader.canRead(forClass(PartEvent.class), null)).isTrue();
}
@Test
public void simple() {
MockServerHttpRequest request = createRequest(
new ClassPathResource("simple.multipart", getClass()), "simple-boundary");
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.assertNext(form(headers -> assertThat(headers).isEmpty(), "This is implicitly typed plain ASCII text.\r\nIt does NOT end with a linebreak."))
.assertNext(form(headers -> assertThat(headers.getContentType()).isEqualTo(TEXT_PLAIN_ASCII),
"This is explicitly typed plain ASCII text.\r\nIt DOES end with a linebreak.\r\n"))
.verifyComplete();
}
@Test
public void noHeaders() {
MockServerHttpRequest request = createRequest(
new ClassPathResource("no-header.multipart", getClass()), "boundary");
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.assertNext(data(headers -> assertThat(headers).isEmpty(), bodyText("a"), true))
.verifyComplete();
}
@Test
public void noEndBoundary() {
MockServerHttpRequest request = createRequest(
new ClassPathResource("no-end-boundary.multipart", getClass()), "boundary");
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.expectError(DecodingException.class)
.verify();
}
@Test
public void garbage() {
MockServerHttpRequest request = createRequest(
new ClassPathResource("garbage-1.multipart", getClass()), "boundary");
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.expectError(DecodingException.class)
.verify();
}
@Test
public void noEndHeader() {
MockServerHttpRequest request = createRequest(
new ClassPathResource("no-end-header.multipart", getClass()), "boundary");
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.expectError(DecodingException.class)
.verify();
}
@Test
public void noEndBody() {
MockServerHttpRequest request = createRequest(
new ClassPathResource("no-end-body.multipart", getClass()), "boundary");
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.expectError(DecodingException.class)
.verify();
}
@Test
public void noBody() {
MockServerHttpRequest request = createRequest(
new ClassPathResource("no-body.multipart", getClass()), "boundary");
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.assertNext(form(headers -> assertThat(headers).contains(entry("Part", List.of("1"))), ""))
.assertNext(data(headers -> assertThat(headers).contains(entry("Part", List.of("2"))), bodyText("a"), true))
.verifyComplete();
}
@Test
public void cancel() {
MockServerHttpRequest request = createRequest(
new ClassPathResource("simple.multipart", getClass()), "simple-boundary");
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result, 3)
.assertNext(form(headers -> assertThat(headers).isEmpty(),
"This is implicitly typed plain ASCII text.\r\nIt does NOT end with a linebreak."))
.thenCancel()
.verify();
}
@Test
public void firefox() {
MockServerHttpRequest request = createRequest(new ClassPathResource("firefox.multipart", getClass()),
"---------------------------18399284482060392383840973206");
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.assertNext(data(headersFormField("text1"), bodyText("a"), true))
.assertNext(data(headersFormField("text2"), bodyText("b"), true))
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, true))
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, true))
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, true))
.verifyComplete();
}
@Test
public void chrome() {
MockServerHttpRequest request = createRequest(new ClassPathResource("chrome.multipart", getClass()),
"----WebKitFormBoundaryEveBLvRT65n21fwU");
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.assertNext(data(headersFormField("text1"), bodyText("a"), true))
.assertNext(data(headersFormField("text2"), bodyText("b"), true))
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, true))
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, true))
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, true))
.verifyComplete();
}
@Test
public void safari() {
MockServerHttpRequest request = createRequest(new ClassPathResource("safari.multipart", getClass()),
"----WebKitFormBoundaryG8fJ50opQOML0oGD");
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.assertNext(data(headersFormField("text1"), bodyText("a"), true))
.assertNext(data(headersFormField("text2"), bodyText("b"), true))
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, true))
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, true))
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, false))
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, true))
.verifyComplete();
}
@Test
public void utf8Headers() {
MockServerHttpRequest request = createRequest(
new ClassPathResource("utf8.multipart", getClass()), "\"simple-boundary\"");
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.assertNext(data(headers -> assertThat(headers).containsEntry("Føø", List.of("Bår")),
bodyText("This is plain ASCII text."), true))
.verifyComplete();
}
@Test
public void exceedHeaderLimit() {
Flux<DataBuffer> body = DataBufferUtils
.readByteChannel((new ClassPathResource("files.multipart", getClass()))::readableChannel, bufferFactory,
282);
MediaType contentType = new MediaType("multipart", "form-data",
singletonMap("boundary", "----WebKitFormBoundaryG8fJ50opQOML0oGD"));
MockServerHttpRequest request = MockServerHttpRequest.post("/")
.contentType(contentType)
.body(body);
this.reader.setMaxHeadersSize(230);
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap());
StepVerifier.create(result)
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, true))
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, true))
.verifyComplete();
}
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 static Consumer<PartEvent> form(Consumer<HttpHeaders> headersConsumer, String value) {
return data -> {
headersConsumer.accept(data.headers());
String actual = data.content().toString(UTF_8);
assertThat(actual).isEqualTo(value);
assertThat(data.isLast()).isTrue();
};
}
private static Consumer<PartEvent> data(Consumer<HttpHeaders> headersConsumer, Consumer<DataBuffer> bufferConsumer, boolean isLast) {
return data -> {
headersConsumer.accept(data.headers());
bufferConsumer.accept(data.content());
assertThat(data.isLast()).isEqualTo(isLast);
};
}
private static Consumer<HttpHeaders> headersFormField(String expectedName) {
return headers -> {
ContentDisposition cd = headers.getContentDisposition();
assertThat(cd.isFormData()).isTrue();
assertThat(cd.getName()).isEqualTo(expectedName);
};
}
private static Consumer<HttpHeaders> headersFile(String expectedName, String expectedFilename) {
return headers -> {
ContentDisposition cd = headers.getContentDisposition();
assertThat(cd.isFormData()).isTrue();
assertThat(cd.getName()).isEqualTo(expectedName);
assertThat(cd.getFilename()).isEqualTo(expectedFilename);
};
}
private static Consumer<DataBuffer> bodyText(String expected) {
return buffer -> {
String s = buffer.toString(UTF_8);
DataBufferUtils.release(buffer);
assertThat(s).isEqualTo(expected);
};
}
}

View File

@@ -61,6 +61,7 @@ import org.springframework.http.codec.json.Jackson2SmileEncoder;
import org.springframework.http.codec.json.KotlinSerializationJsonDecoder;
import org.springframework.http.codec.json.KotlinSerializationJsonEncoder;
import org.springframework.http.codec.multipart.MultipartHttpMessageWriter;
import org.springframework.http.codec.multipart.PartEventHttpMessageWriter;
import org.springframework.http.codec.protobuf.ProtobufDecoder;
import org.springframework.http.codec.protobuf.ProtobufHttpMessageWriter;
import org.springframework.http.codec.xml.Jaxb2XmlDecoder;
@@ -106,7 +107,7 @@ public class ClientCodecConfigurerTests {
@Test
public void defaultWriters() {
List<HttpMessageWriter<?>> writers = this.configurer.getWriters();
assertThat(writers.size()).isEqualTo(13);
assertThat(writers.size()).isEqualTo(14);
assertThat(getNextEncoder(writers).getClass()).isEqualTo(ByteArrayEncoder.class);
assertThat(getNextEncoder(writers).getClass()).isEqualTo(ByteBufferEncoder.class);
assertThat(getNextEncoder(writers).getClass()).isEqualTo(DataBufferEncoder.class);
@@ -115,6 +116,7 @@ public class ClientCodecConfigurerTests {
assertStringEncoder(getNextEncoder(writers), true);
assertThat(writers.get(index.getAndIncrement()).getClass()).isEqualTo(ProtobufHttpMessageWriter.class);
assertThat(writers.get(this.index.getAndIncrement()).getClass()).isEqualTo(MultipartHttpMessageWriter.class);
assertThat(writers.get(this.index.getAndIncrement()).getClass()).isEqualTo(PartEventHttpMessageWriter.class);
assertThat(getNextEncoder(writers).getClass()).isEqualTo(KotlinSerializationJsonEncoder.class);
assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jackson2JsonEncoder.class);
assertThat(getNextEncoder(writers).getClass()).isEqualTo(Jackson2SmileEncoder.class);

View File

@@ -60,6 +60,7 @@ import org.springframework.http.codec.json.KotlinSerializationJsonDecoder;
import org.springframework.http.codec.json.KotlinSerializationJsonEncoder;
import org.springframework.http.codec.multipart.DefaultPartHttpMessageReader;
import org.springframework.http.codec.multipart.MultipartHttpMessageReader;
import org.springframework.http.codec.multipart.PartEventHttpMessageReader;
import org.springframework.http.codec.multipart.PartHttpMessageWriter;
import org.springframework.http.codec.protobuf.ProtobufDecoder;
import org.springframework.http.codec.protobuf.ProtobufHttpMessageWriter;
@@ -85,7 +86,7 @@ public class ServerCodecConfigurerTests {
@Test
public void defaultReaders() {
List<HttpMessageReader<?>> readers = this.configurer.getReaders();
assertThat(readers.size()).isEqualTo(15);
assertThat(readers.size()).isEqualTo(16);
assertThat(getNextDecoder(readers).getClass()).isEqualTo(ByteArrayDecoder.class);
assertThat(getNextDecoder(readers).getClass()).isEqualTo(ByteBufferDecoder.class);
assertThat(getNextDecoder(readers).getClass()).isEqualTo(DataBufferDecoder.class);
@@ -96,6 +97,7 @@ public class ServerCodecConfigurerTests {
assertThat(readers.get(this.index.getAndIncrement()).getClass()).isEqualTo(FormHttpMessageReader.class);
assertThat(readers.get(this.index.getAndIncrement()).getClass()).isEqualTo(DefaultPartHttpMessageReader.class);
assertThat(readers.get(this.index.getAndIncrement()).getClass()).isEqualTo(MultipartHttpMessageReader.class);
assertThat(readers.get(this.index.getAndIncrement()).getClass()).isEqualTo(PartEventHttpMessageReader.class);
assertThat(getNextDecoder(readers).getClass()).isEqualTo(KotlinSerializationJsonDecoder.class);
assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jackson2JsonDecoder.class);
assertThat(getNextDecoder(readers).getClass()).isEqualTo(Jackson2SmileDecoder.class);
@@ -159,6 +161,7 @@ public class ServerCodecConfigurerTests {
MultipartHttpMessageReader multipartReader = (MultipartHttpMessageReader) nextReader(readers);
DefaultPartHttpMessageReader reader = (DefaultPartHttpMessageReader) multipartReader.getPartReader();
assertThat((reader).getMaxInMemorySize()).isEqualTo(size);
assertThat(((PartEventHttpMessageReader) nextReader(readers)).getMaxInMemorySize()).isEqualTo(size);
assertThat(((KotlinSerializationJsonDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size);
assertThat(((Jackson2JsonDecoder) getNextDecoder(readers)).getMaxInMemorySize()).isEqualTo(size);