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:
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user