diff --git a/spring-web/src/main/java/org/springframework/http/codec/ClientCodecConfigurer.java b/spring-web/src/main/java/org/springframework/http/codec/ClientCodecConfigurer.java index 2ff85f15a5a..62b85df1b09 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/ClientCodecConfigurer.java +++ b/spring-web/src/main/java/org/springframework/http/codec/ClientCodecConfigurer.java @@ -17,6 +17,7 @@ package org.springframework.http.codec; import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; /** * Helps to configure a list of client-side HTTP message readers and writers @@ -55,6 +56,13 @@ public interface ClientCodecConfigurer extends CodecConfigurer { */ interface ClientDefaultCodecsConfigurer extends DefaultCodecsConfigurer { + /** + * Configure encoders or writers for use with + * {@link org.springframework.http.codec.multipart.MultipartHttpMessageWriter + * MultipartHttpMessageWriter}. + */ + MultipartCodecsConfigurer multipartCodecs(); + /** * Configure the {@code Decoder} to use for Server-Sent Events. *

By default the {@link #jackson2Decoder} override is used for SSE. @@ -63,5 +71,25 @@ public interface ClientCodecConfigurer extends CodecConfigurer { void serverSentEventDecoder(Decoder decoder); } + /** + * Registry and container for multipart HTTP message writers. + */ + interface MultipartCodecsConfigurer { + + /** + * Add a Part {@code Encoder}, internally wrapped with + * {@link EncoderHttpMessageWriter}. + * @param encoder the encoder to add + */ + MultipartCodecsConfigurer encoder(Encoder encoder); + + /** + * Add a Part {@link HttpMessageWriter}. For writers of type + * {@link EncoderHttpMessageWriter} consider using the shortcut + * {@link #encoder(Encoder)} instead. + * @param writer the writer to add + */ + MultipartCodecsConfigurer writer(HttpMessageWriter writer); + } } diff --git a/spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java b/spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java index daf5d9be509..25971dbf260 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java +++ b/spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java @@ -108,7 +108,7 @@ public interface CodecConfigurer { void reader(HttpMessageReader reader); /** - * Add a custom {@link HttpMessageWriter}. For readers of type + * Add a custom {@link HttpMessageWriter}. For writers of type * {@link EncoderHttpMessageWriter} consider using the shortcut * {@link #encoder(Encoder)} instead. * @param writer the writer to add diff --git a/spring-web/src/main/java/org/springframework/http/codec/DefaultClientCodecConfigurer.java b/spring-web/src/main/java/org/springframework/http/codec/DefaultClientCodecConfigurer.java index a6c055154ee..8d4463602a0 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/DefaultClientCodecConfigurer.java +++ b/spring-web/src/main/java/org/springframework/http/codec/DefaultClientCodecConfigurer.java @@ -16,9 +16,11 @@ package org.springframework.http.codec; +import java.util.ArrayList; import java.util.List; import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; import org.springframework.core.codec.StringDecoder; import org.springframework.http.codec.json.Jackson2JsonDecoder; import org.springframework.http.codec.multipart.MultipartHttpMessageWriter; @@ -48,6 +50,17 @@ class DefaultClientCodecConfigurer extends DefaultCodecConfigurer implements Cli extends AbstractDefaultCodecsConfigurer implements ClientCodecConfigurer.ClientDefaultCodecsConfigurer { + private DefaultMultipartCodecsConfigurer multipartCodecs; + + + @Override + public MultipartCodecsConfigurer multipartCodecs() { + if (this.multipartCodecs == null) { + this.multipartCodecs = new DefaultMultipartCodecsConfigurer(); + } + return this.multipartCodecs; + } + @Override public void serverSentEventDecoder(Decoder decoder) { HttpMessageReader reader = new ServerSentEventHttpMessageReader(decoder); @@ -58,7 +71,10 @@ class DefaultClientCodecConfigurer extends DefaultCodecConfigurer implements Cli protected void addTypedWritersTo(List> result) { super.addTypedWritersTo(result); addWriterTo(result, FormHttpMessageWriter::new); - addWriterTo(result, MultipartHttpMessageWriter::new); + addWriterTo(result, () -> findWriter(MultipartHttpMessageWriter.class, + () -> this.multipartCodecs != null ? + new MultipartHttpMessageWriter(this.multipartCodecs.getWriters()) : + new MultipartHttpMessageWriter())); } @Override @@ -89,7 +105,28 @@ class DefaultClientCodecConfigurer extends DefaultCodecConfigurer implements Cli addReaderTo(result, () -> new DecoderHttpMessageReader<>(StringDecoder.allMimeTypes(false))); } + } + + private static class DefaultMultipartCodecsConfigurer implements MultipartCodecsConfigurer { + + private final List> writers = new ArrayList<>(); + + + @Override + public MultipartCodecsConfigurer encoder(Encoder encoder) { + writer(new EncoderHttpMessageWriter<>(encoder)); + return this; + } + @Override + public MultipartCodecsConfigurer writer(HttpMessageWriter writer) { + this.writers.add(writer); + return this; + } + + public List> getWriters() { + return this.writers; + } } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java index 1dd1799e76e..08d697a8026 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolver.java @@ -134,10 +134,10 @@ class ControllerMethodResolver { // Annotation-based... registrar.add(new RequestParamMethodArgumentResolver(beanFactory, reactiveRegistry, false)); registrar.add(new RequestParamMapMethodArgumentResolver(reactiveRegistry)); - registrar.add(new RequestPartMethodArgumentResolver(reactiveRegistry)); registrar.add(new PathVariableMethodArgumentResolver(beanFactory, reactiveRegistry)); registrar.add(new PathVariableMapMethodArgumentResolver(reactiveRegistry)); registrar.addIfRequestBody(readers -> new RequestBodyArgumentResolver(readers, reactiveRegistry)); + registrar.addIfRequestBody(readers -> new RequestPartMethodArgumentResolver(readers, reactiveRegistry)); registrar.addIfModelAttribute(() -> new ModelAttributeMethodArgumentResolver(reactiveRegistry, false)); registrar.add(new RequestHeaderMethodArgumentResolver(beanFactory, reactiveRegistry)); registrar.add(new RequestHeaderMapMethodArgumentResolver(reactiveRegistry)); diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestPartMethodArgumentResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestPartMethodArgumentResolver.java index 0cd3d7f13a7..0eb1e1a12f8 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestPartMethodArgumentResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestPartMethodArgumentResolver.java @@ -16,80 +16,131 @@ package org.springframework.web.reactive.result.method.annotation; +import java.util.Collections; import java.util.List; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.core.MethodParameter; import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpHeaders; +import org.springframework.http.codec.HttpMessageReader; import org.springframework.http.codec.multipart.Part; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpRequestDecorator; import org.springframework.util.CollectionUtils; import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.ValueConstants; +import org.springframework.web.reactive.BindingContext; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebInputException; /** - * Resolver for method arguments annotated with @{@link RequestPart}. + * Resolver for {@code @RequestPart} arguments where the named part is decoded + * much like an {@code @RequestBody} argument but based on the content of an + * individual part instead. The arguments may be wrapped with a reactive type + * for a single value (e.g. Reactor {@code Mono}, RxJava {@code Single}). + * + *

This resolver also supports arguments of type {@link Part} which may be + * wrapped with are reactive type for a single or multiple values. * - * @author Sebastien Deleuze * @author Rossen Stoyanchev * @since 5.0 */ -public class RequestPartMethodArgumentResolver extends AbstractNamedValueArgumentResolver { - - /** - * Class constructor with a default resolution mode flag. - * @param registry for checking reactive type wrappers - */ - public RequestPartMethodArgumentResolver(ReactiveAdapterRegistry registry) { - super(null, registry); +public class RequestPartMethodArgumentResolver extends AbstractMessageReaderArgumentResolver { + + + public RequestPartMethodArgumentResolver(List> readers, + ReactiveAdapterRegistry registry) { + + super(readers, registry); } @Override public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(RequestPart.class); + return parameter.hasParameterAnnotation(RequestPart.class) || + checkParameterType(parameter, Part.class::isAssignableFrom); } @Override - protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { - RequestPart ann = parameter.getParameterAnnotation(RequestPart.class); - return (ann != null ? new RequestPartNamedValueInfo(ann) : new RequestPartNamedValueInfo()); + public Mono resolveArgument(MethodParameter parameter, BindingContext bindingContext, + ServerWebExchange exchange) { + + RequestPart requestPart = parameter.getParameterAnnotation(RequestPart.class); + boolean isRequired = requestPart == null || requestPart.required(); + String name = getPartName(parameter, requestPart); + + Flux partFlux = getPartValues(name, exchange); + if (isRequired) { + partFlux = partFlux.switchIfEmpty(Flux.error(getMissingPartException(name, parameter))); + } + + ReactiveAdapter adapter = getAdapterRegistry().getAdapter(parameter.getParameterType()); + MethodParameter elementType = adapter != null ? parameter.nested() : parameter; + + if (Part.class.isAssignableFrom(elementType.getNestedParameterType())) { + if (adapter != null) { + partFlux = adapter.isMultiValue() ? partFlux : partFlux.take(1); + return Mono.just(adapter.fromPublisher(partFlux)); + } + else { + return partFlux.next().cast(Object.class); + } + } + + return partFlux.next().flatMap(part -> { + ServerHttpRequest partRequest = new PartServerHttpRequest(exchange.getRequest(), part); + ServerWebExchange partExchange = exchange.mutate().request(partRequest).build(); + return readBody(parameter, isRequired, bindingContext, partExchange); + }); } - @Override - protected Mono resolveName(String name, MethodParameter param, ServerWebExchange exchange) { + private String getPartName(MethodParameter methodParam, RequestPart requestPart) { + String partName = (requestPart != null ? requestPart.name() : ""); + if (partName.isEmpty()) { + partName = methodParam.getParameterName(); + if (partName == null) { + throw new IllegalArgumentException("Request part name for argument type [" + + methodParam.getNestedParameterType().getName() + + "] not specified, and parameter name information not found in class file either."); + } + } + return partName; + } - Mono partsMono = exchange.getMultipartData() + private Flux getPartValues(String name, ServerWebExchange exchange) { + return exchange.getMultipartData() .filter(map -> !CollectionUtils.isEmpty(map.get(name))) - .map(map -> { - List parts = map.get(name); - return parts.size() == 1 ? parts.get(0) : parts; - }); - - ReactiveAdapter adapter = getAdapterRegistry().getAdapter(param.getParameterType()); - return (adapter != null ? Mono.just(adapter.fromPublisher(partsMono)) : partsMono); + .flatMapIterable(map -> map.getOrDefault(name, Collections.emptyList())); } - @Override - protected void handleMissingValue(String name, MethodParameter param, ServerWebExchange exchange) { - String type = param.getNestedParameterType().getSimpleName(); - String reason = "Required " + type + " parameter '" + name + "' is not present"; - throw new ServerWebInputException(reason, param); + private ServerWebInputException getMissingPartException(String name, MethodParameter param) { + String reason = "Required request part '" + name + "' is not present"; + return new ServerWebInputException(reason, param); } - private static class RequestPartNamedValueInfo extends NamedValueInfo { + private static class PartServerHttpRequest extends ServerHttpRequestDecorator { + + private final Part part; + + public PartServerHttpRequest(ServerHttpRequest delegate, Part part) { + super(delegate); + this.part = part; + } - RequestPartNamedValueInfo() { - super("", false, ValueConstants.DEFAULT_NONE); + @Override + public HttpHeaders getHeaders() { + return this.part.headers(); } - RequestPartNamedValueInfo(RequestPart annotation) { - super(annotation.name(), annotation.required(), ValueConstants.DEFAULT_NONE); + @Override + public Flux getBody() { + return this.part.content(); } } diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolverTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolverTests.java index 88129af0b86..240863c7c5a 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolverTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/ControllerMethodResolverTests.java @@ -47,7 +47,8 @@ import org.springframework.web.reactive.result.method.SyncInvocableHandlerMethod import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ServerWebExchange; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; /** * Unit tests for {@link ControllerMethodResolver}. @@ -92,10 +93,10 @@ public class ControllerMethodResolverTests { AtomicInteger index = new AtomicInteger(-1); assertEquals(RequestParamMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(RequestParamMapMethodArgumentResolver.class, next(resolvers, index).getClass()); - assertEquals(RequestPartMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(PathVariableMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(PathVariableMapMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(RequestBodyArgumentResolver.class, next(resolvers, index).getClass()); + assertEquals(RequestPartMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(ModelAttributeMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(RequestHeaderMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(RequestHeaderMapMethodArgumentResolver.class, next(resolvers, index).getClass()); @@ -131,7 +132,6 @@ public class ControllerMethodResolverTests { AtomicInteger index = new AtomicInteger(-1); assertEquals(RequestParamMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(RequestParamMapMethodArgumentResolver.class, next(resolvers, index).getClass()); - assertEquals(RequestPartMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(PathVariableMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(PathVariableMapMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(ModelAttributeMethodArgumentResolver.class, next(resolvers, index).getClass()); @@ -198,7 +198,6 @@ public class ControllerMethodResolverTests { AtomicInteger index = new AtomicInteger(-1); assertEquals(RequestParamMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(RequestParamMapMethodArgumentResolver.class, next(resolvers, index).getClass()); - assertEquals(RequestPartMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(PathVariableMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(PathVariableMapMethodArgumentResolver.class, next(resolvers, index).getClass()); assertEquals(RequestHeaderMethodArgumentResolver.class, next(resolvers, index).getClass()); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MultipartIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MultipartIntegrationTests.java index 6cff377c797..b72cfdc3bd0 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MultipartIntegrationTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/MultipartIntegrationTests.java @@ -16,10 +16,11 @@ package org.springframework.web.reactive.result.method.annotation; -import java.time.Duration; -import java.util.Map; +import java.util.List; import java.util.stream.Collectors; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; import org.junit.Before; import org.junit.Test; import reactor.core.publisher.Flux; @@ -29,13 +30,17 @@ import reactor.test.StepVerifier; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.codec.CharSequenceEncoder; import org.springframework.core.io.ClassPathResource; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.codec.ResourceHttpMessageWriter; +import org.springframework.http.codec.json.Jackson2JsonEncoder; 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.server.reactive.AbstractHttpHandlerIntegrationTests; import org.springframework.http.server.reactive.HttpHandler; @@ -50,6 +55,7 @@ import org.springframework.web.reactive.DispatcherHandler; import org.springframework.web.reactive.config.EnableWebFlux; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.server.adapter.WebHttpHandlerBuilder; @@ -64,7 +70,16 @@ public class MultipartIntegrationTests extends AbstractHttpHandlerIntegrationTes @Before public void setup() throws Exception { super.setup(); - this.webClient = WebClient.create("http://localhost:" + this.port); + + ExchangeStrategies strategies = ExchangeStrategies.builder().defaultCodecs(configurer -> + configurer.multipartCodecs() + .encoder(CharSequenceEncoder.allMimeTypes()) + .writer(new ResourceHttpMessageWriter()) + .encoder(new Jackson2JsonEncoder())).build(); + + this.webClient = WebClient.builder().baseUrl("http://localhost:" + this.port) + .exchangeStrategies(strategies) + .build(); } @@ -102,7 +117,8 @@ public class MultipartIntegrationTests extends AbstractHttpHandlerIntegrationTes .bodyToMono(String.class); StepVerifier.create(result) - .consumeNextWith(body -> assertEquals("Map[barPart,fooPart]", body)) + .consumeNextWith(body -> assertEquals( + "Map[[fieldPart],[fileParts:foo.txt,fileParts:logo.png],[jsonPart]]", body)) .verifyComplete(); } @@ -117,7 +133,8 @@ public class MultipartIntegrationTests extends AbstractHttpHandlerIntegrationTes .bodyToMono(String.class); StepVerifier.create(result) - .consumeNextWith(body -> assertEquals("Flux[barPart,fooPart]", body)) + .consumeNextWith(body -> assertEquals( + "[fieldPart,fileParts:foo.txt,fileParts:logo.png,jsonPart]", body)) .verifyComplete(); } @@ -132,89 +149,142 @@ public class MultipartIntegrationTests extends AbstractHttpHandlerIntegrationTes .bodyToMono(String.class); StepVerifier.create(result) - .consumeNextWith(body -> assertEquals("TestBean[barPart=bar,fooPart=foo.txt]", body)) + .consumeNextWith(body -> assertEquals( + "FormBean[fieldValue,[fileParts:foo.txt,fileParts:logo.png]]", body)) .verifyComplete(); } private MultiValueMap generateBody() { - HttpHeaders fooHeaders = new HttpHeaders(); - fooHeaders.setContentType(MediaType.TEXT_PLAIN); - ClassPathResource fooResource = new ClassPathResource("org/springframework/http/codec/multipart/foo.txt"); - HttpEntity fooPart = new HttpEntity<>(fooResource, fooHeaders); - HttpEntity barPart = new HttpEntity<>("bar"); + MultiValueMap parts = new LinkedMultiValueMap<>(); - parts.add("fooPart", fooPart); - parts.add("barPart", barPart); + parts.add("fieldPart", "fieldValue"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.TEXT_PLAIN); + ClassPathResource resource = new ClassPathResource("foo.txt", MultipartHttpMessageReader.class); + parts.add("fileParts", new HttpEntity<>(resource, headers)); + + headers = new HttpHeaders(); + headers.setContentType(MediaType.IMAGE_PNG); + resource = new ClassPathResource("logo.png", getClass()); + parts.add("fileParts", new HttpEntity<>(resource, headers)); + + headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + parts.add("jsonPart", new HttpEntity<>(new Person("Jason"), headers)); + return parts; } + + @Configuration + @EnableWebFlux + @SuppressWarnings("unused") + static class TestConfiguration { + + @Bean + public MultipartController testController() { + return new MultipartController(); + } + } + @RestController @SuppressWarnings("unused") static class MultipartController { @PostMapping("/requestPart") - void requestPart(@RequestPart FormFieldPart barPart, @RequestPart Mono fooPart) { - assertEquals("bar", barPart.value()); - assertEquals("foo.txt", fooPart.block(Duration.ZERO).filename()); + void requestPart( + @RequestPart FormFieldPart fieldPart, + @RequestPart("fileParts") FilePart fileParts, + @RequestPart("fileParts") Mono filePartsMono, + @RequestPart("fileParts") Flux filePartsFlux, + @RequestPart("jsonPart") Person person, + @RequestPart("jsonPart") Mono personMono) { + + assertEquals("fieldValue", fieldPart.value()); + assertEquals("fileParts:foo.txt", partDescription(fileParts)); + assertEquals("fileParts:foo.txt", partDescription(filePartsMono.block())); + assertEquals("[fileParts:foo.txt,fileParts:logo.png]", partFluxDescription(filePartsFlux).block()); + assertEquals("Jason", person.getName()); + assertEquals("Jason", personMono.block().getName()); } @PostMapping("/requestBodyMap") - Mono requestBodyMap(@RequestBody Mono> parts) { - return parts.map(map -> map.toSingleValueMap().entrySet().stream() - .map(Map.Entry::getKey).sorted().collect(Collectors.joining(",", "Map[", "]"))); + Mono requestBodyMap(@RequestBody Mono> partsMono) { + return partsMono.map(MultipartIntegrationTests::partMapDescription); } @PostMapping("/requestBodyFlux") Mono requestBodyFlux(@RequestBody Flux parts) { - return parts.map(Part::name).collectList() - .map(names -> names.stream().sorted().collect(Collectors.joining(",", "Flux[", "]"))); + return partFluxDescription(parts); } @PostMapping("/modelAttribute") - String modelAttribute(@ModelAttribute TestBean testBean) { - return testBean.toString(); + String modelAttribute(@ModelAttribute FormBean formBean) { + return formBean.toString(); } } - static class TestBean { + private static String partMapDescription(MultiValueMap partsMap) { + return partsMap.keySet().stream().sorted() + .map(key -> partListDescription(partsMap.get(key))) + .collect(Collectors.joining(",", "Map[", "]")); + } + + private static Mono partFluxDescription(Flux partsFlux) { + return partsFlux.log().collectList().map(MultipartIntegrationTests::partListDescription); + } + + private static String partListDescription(List parts) { + return parts.stream().map(MultipartIntegrationTests::partDescription) + .collect(Collectors.joining(",", "[", "]")); + } + + private static String partDescription(Part part) { + return part instanceof FilePart ? part.name() + ":" + ((FilePart) part).filename() : part.name(); + } + + static class FormBean { - private String barPart; + private String fieldPart; - private FilePart fooPart; + private List fileParts; - public String getBarPart() { - return this.barPart; + public String getFieldPart() { + return this.fieldPart; } - public void setBarPart(String barPart) { - this.barPart = barPart; + public void setFieldPart(String fieldPart) { + this.fieldPart = fieldPart; } - public FilePart getFooPart() { - return this.fooPart; + public List getFileParts() { + return this.fileParts; } - public void setFooPart(FilePart fooPart) { - this.fooPart = fooPart; + public void setFileParts(List fileParts) { + this.fileParts = fileParts; } @Override public String toString() { - return "TestBean[barPart=" + getBarPart() + ",fooPart=" + getFooPart().filename() + "]"; + return "FormBean[" + getFieldPart() + "," + partListDescription(getFileParts()) + "]"; } } + private static class Person { - @Configuration - @EnableWebFlux - @SuppressWarnings("unused") - static class TestConfiguration { + private String name; - @Bean - public MultipartController multipartController() { - return new MultipartController(); + @JsonCreator + public Person(@JsonProperty("name") String name) { + this.name = name; + } + + public String getName() { + return name; } }