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

@@ -112,7 +112,7 @@ public class DelegatingWebFluxConfigurationTests {
boolean condition = initializer.getValidator() instanceof LocalValidatorFactoryBean;
assertThat(condition).isTrue();
assertThat(initializer.getConversionService()).isSameAs(formatterRegistry.getValue());
assertThat(codecsConfigurer.getValue().getReaders().size()).isEqualTo(14);
assertThat(codecsConfigurer.getValue().getReaders().size()).isEqualTo(15);
}
@Test

View File

@@ -151,7 +151,7 @@ public class WebFluxConfigurationSupportTests {
assertThat(adapter).isNotNull();
List<HttpMessageReader<?>> readers = adapter.getMessageReaders();
assertThat(readers.size()).isEqualTo(14);
assertThat(readers.size()).isEqualTo(15);
ResolvableType multiValueMapType = forClassWithGenerics(MultiValueMap.class, String.class, String.class);

View File

@@ -17,10 +17,12 @@
package org.springframework.web.reactive.function;
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.time.Duration;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Disabled;
@@ -31,15 +33,16 @@ import reactor.core.scheduler.Schedulers;
import reactor.test.StepVerifier;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpEntity;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.FilePartEvent;
import org.springframework.http.codec.multipart.FormFieldPart;
import org.springframework.http.codec.multipart.FormPartEvent;
import org.springframework.http.codec.multipart.Part;
import org.springframework.http.codec.multipart.PartEvent;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.server.AbstractRouterFunctionIntegrationTests;
import org.springframework.web.reactive.function.server.RouterFunction;
@@ -60,7 +63,7 @@ class MultipartIntegrationTests extends AbstractRouterFunctionIntegrationTests {
private final WebClient webClient = WebClient.create();
private ClassPathResource resource = new ClassPathResource("org/springframework/http/codec/multipart/foo.txt");
private final ClassPathResource resource = new ClassPathResource("foo.txt", getClass());
@ParameterizedHttpServerTest
@@ -70,7 +73,7 @@ class MultipartIntegrationTests extends AbstractRouterFunctionIntegrationTests {
Mono<ResponseEntity<Void>> result = webClient
.post()
.uri("http://localhost:" + this.port + "/multipartData")
.bodyValue(generateBody())
.body(generateBody(), PartEvent.class)
.retrieve()
.toEntity(Void.class);
@@ -88,7 +91,7 @@ class MultipartIntegrationTests extends AbstractRouterFunctionIntegrationTests {
Mono<ResponseEntity<Void>> result = webClient
.post()
.uri("http://localhost:" + this.port + "/parts")
.bodyValue(generateBody())
.body(generateBody(), PartEvent.class)
.retrieve()
.toEntity(Void.class);
@@ -120,7 +123,7 @@ class MultipartIntegrationTests extends AbstractRouterFunctionIntegrationTests {
Mono<String> result = webClient
.post()
.uri("http://localhost:" + this.port + "/transferTo")
.bodyValue(generateBody())
.body(generateBody(), PartEvent.class)
.retrieve()
.bodyToMono(String.class);
@@ -140,11 +143,48 @@ class MultipartIntegrationTests extends AbstractRouterFunctionIntegrationTests {
.verify(Duration.ofSeconds(5));
}
private MultiValueMap<String, HttpEntity<?>> generateBody() {
MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("fooPart", resource);
builder.part("barPart", "bar");
return builder.build();
@ParameterizedHttpServerTest
void partData(HttpServer httpServer) throws Exception {
startServer(httpServer);
Mono<ResponseEntity<Void>> result = webClient
.post()
.uri("http://localhost:" + this.port + "/partData")
.body(generateBody(), PartEvent.class)
.retrieve()
.toEntity(Void.class);
StepVerifier
.create(result)
.consumeNextWith(entity -> assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK))
.expectComplete()
.verify(Duration.ofSeconds(5));
}
@ParameterizedHttpServerTest
void proxy(HttpServer httpServer) throws Exception {
startServer(httpServer);
Mono<ResponseEntity<Void>> result = webClient
.post()
.uri("http://localhost:" + this.port + "/proxy")
.body(generateBody(), PartEvent.class)
.retrieve()
.toEntity(Void.class);
StepVerifier
.create(result)
.consumeNextWith(entity -> assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK))
.expectComplete()
.verify(Duration.ofSeconds(5));
}
private Flux<PartEvent> generateBody() {
return Flux.concat(
FilePartEvent.create("fooPart", this.resource),
FormPartEvent.create("barPart", "bar")
);
}
@Override
@@ -154,6 +194,8 @@ class MultipartIntegrationTests extends AbstractRouterFunctionIntegrationTests {
.POST("/multipartData", multipartHandler::multipartData)
.POST("/parts", multipartHandler::parts)
.POST("/transferTo", multipartHandler::transferTo)
.POST("/partData", multipartHandler::partData)
.POST("/proxy", multipartHandler::proxy)
.build();
}
@@ -207,6 +249,44 @@ class MultipartIntegrationTests extends AbstractRouterFunctionIntegrationTests {
.then(ServerResponse.ok().bodyValue(tempFile.toString()))));
}
public Mono<ServerResponse> partData(ServerRequest request) {
return request.bodyToFlux(PartEvent.class)
.bufferUntil(PartEvent::isLast)
.collectList()
.flatMap((List<List<PartEvent>> data) -> {
assertThat(data).hasSize(2);
List<PartEvent> fileData = data.get(0);
assertThat(fileData).hasSize(1);
assertThat(fileData.get(0)).isInstanceOf(FilePartEvent.class);
FilePartEvent filePartEvent = (FilePartEvent) fileData.get(0);
assertThat(filePartEvent.name()).isEqualTo("fooPart");
assertThat(filePartEvent.filename()).isEqualTo("foo.txt");
DataBufferUtils.release(filePartEvent.content());
List<PartEvent> fieldData = data.get(1);
assertThat(fieldData).hasSize(1);
assertThat(fieldData.get(0)).isInstanceOf(FormPartEvent.class);
FormPartEvent formPartEvent = (FormPartEvent) fieldData.get(0);
assertThat(formPartEvent.name()).isEqualTo("barPart");
assertThat(formPartEvent.content().toString(StandardCharsets.UTF_8)).isEqualTo("bar");
DataBufferUtils.release(filePartEvent.content());
return ServerResponse.ok().build();
});
}
public Mono<ServerResponse> proxy(ServerRequest request) {
return Mono.defer(() -> {
WebClient client = WebClient.create("http://localhost:" + request.uri().getPort() + "/multipartData");
return client.post()
.body(request.bodyToFlux(PartEvent.class), PartEvent.class)
.retrieve()
.toEntity(Void.class)
.flatMap(response -> ServerResponse.ok().build());
});
}
private Mono<Path> createTempFile() {
return Mono.defer(() -> {
try {

View File

@@ -35,6 +35,8 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -43,6 +45,7 @@ import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.FormFieldPart;
import org.springframework.http.codec.multipart.MultipartHttpMessageReader;
import org.springframework.http.codec.multipart.Part;
import org.springframework.http.codec.multipart.PartEvent;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.MultiValueMap;
@@ -200,6 +203,22 @@ class MultipartIntegrationTests extends AbstractHttpHandlerIntegrationTests {
.verifyComplete();
}
@ParameterizedHttpServerTest
void partData(HttpServer httpServer) throws Exception {
startServer(httpServer);
Mono<String> result = webClient
.post()
.uri("/partData")
.bodyValue(generateBody())
.retrieve()
.bodyToMono(String.class);
StepVerifier.create(result)
.consumeNextWith(body -> assertThat(body).isEqualTo("fieldPart,foo.txt:fileParts,logo.png:fileParts,jsonPart,"))
.verifyComplete();
}
private MultiValueMap<String, HttpEntity<?>> generateBody() {
MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("fieldPart", "fieldValue");
@@ -277,7 +296,7 @@ class MultipartIntegrationTests extends AbstractHttpHandlerIntegrationTests {
}
private Mono<Path> createTempFile(String suffix) {
return Mono.defer(() -> {
return Mono.defer(() -> {
try {
return Mono.just(Files.createTempFile("MultipartIntegrationTests", suffix));
}
@@ -285,13 +304,38 @@ class MultipartIntegrationTests extends AbstractHttpHandlerIntegrationTests {
return Mono.error(ex);
}
})
.subscribeOn(Schedulers.boundedElastic());
}
.subscribeOn(Schedulers.boundedElastic());
}
@PostMapping("/modelAttribute")
String modelAttribute(@ModelAttribute FormBean formBean) {
return formBean.toString();
}
@PostMapping("/partData")
Flux<String> tokens(@RequestBody Flux<PartEvent> partData) {
return partData.map(data -> {
if (data.isLast()) {
ContentDisposition cd = data.headers().getContentDisposition();
StringBuilder sb = new StringBuilder();
if (cd.getFilename() != null) {
sb.append(cd.getFilename())
.append(':')
.append(cd.getName());
}
else if (cd.getName() != null) {
sb.append(cd.getName());
}
sb.append(',');
DataBufferUtils.release(data.content());
return sb.toString();
}
else {
return "";
}
});
}
}
private static String partMapDescription(MultiValueMap<String, Part> partsMap) {