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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Lorem Ipsum.
|
||||
Reference in New Issue
Block a user