Skip to content

Commit 4525c6a

Browse files
committed
Add support for Flux<Part> in BodyExtractors
This commit adds a `toParts` method in `BodyExtractors`, returning a BodyExtractor<Part>.
1 parent 1f5eaf2 commit 4525c6a

File tree

3 files changed

+144
-30
lines changed

3 files changed

+144
-30
lines changed

spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,14 @@
4848
*/
4949
public abstract class BodyExtractors {
5050

51-
private static final ResolvableType FORM_TYPE =
51+
private static final ResolvableType FORM_MAP_TYPE =
5252
ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class);
5353

54-
private static final ResolvableType MULTIPART_TYPE = ResolvableType.forClassWithGenerics(
54+
private static final ResolvableType MULTIPART_MAP_TYPE = ResolvableType.forClassWithGenerics(
5555
MultiValueMap.class, String.class, Part.class);
5656

57+
private static final ResolvableType PART_TYPE = ResolvableType.forClass(Part.class);
58+
5759

5860
/**
5961
* Return a {@code BodyExtractor} that reads into a Reactor {@link Mono}.
@@ -133,15 +135,16 @@ public static <T> BodyExtractor<Flux<T>, ReactiveHttpInputMessage> toFlux(Resolv
133135
public static BodyExtractor<Mono<MultiValueMap<String, String>>, ServerHttpRequest> toFormData() {
134136
return (serverRequest, context) -> {
135137
HttpMessageReader<MultiValueMap<String, String>> messageReader =
136-
formMessageReader(context);
138+
messageReader(FORM_MAP_TYPE, MediaType.APPLICATION_FORM_URLENCODED, context);
137139
return context.serverResponse()
138-
.map(serverResponse -> messageReader.readMono(FORM_TYPE, FORM_TYPE, serverRequest, serverResponse, context.hints()))
139-
.orElseGet(() -> messageReader.readMono(FORM_TYPE, serverRequest, context.hints()));
140+
.map(serverResponse -> messageReader.readMono(FORM_MAP_TYPE, FORM_MAP_TYPE, serverRequest, serverResponse, context.hints()))
141+
.orElseGet(() -> messageReader.readMono(FORM_MAP_TYPE, serverRequest, context.hints()));
140142
};
141143
}
142144

143145
/**
144-
* Return a {@code BodyExtractor} that reads form data into a {@link MultiValueMap}.
146+
* Return a {@code BodyExtractor} that reads multipart (i.e. file upload) form data into a
147+
* {@link MultiValueMap}.
145148
* @return a {@code BodyExtractor} that reads multipart data
146149
*/
147150
// Note that the returned BodyExtractor is parameterized to ServerHttpRequest, not
@@ -150,10 +153,29 @@ public static BodyExtractor<Mono<MultiValueMap<String, String>>, ServerHttpReque
150153
public static BodyExtractor<Mono<MultiValueMap<String, Part>>, ServerHttpRequest> toMultipartData() {
151154
return (serverRequest, context) -> {
152155
HttpMessageReader<MultiValueMap<String, Part>> messageReader =
153-
multipartMessageReader(context);
156+
messageReader(MULTIPART_MAP_TYPE, MediaType.MULTIPART_FORM_DATA, context);
154157
return context.serverResponse()
155-
.map(serverResponse -> messageReader.readMono(MULTIPART_TYPE, MULTIPART_TYPE, serverRequest, serverResponse, context.hints()))
156-
.orElseGet(() -> messageReader.readMono(MULTIPART_TYPE, serverRequest, context.hints()));
158+
.map(serverResponse -> messageReader.readMono(MULTIPART_MAP_TYPE,
159+
MULTIPART_MAP_TYPE, serverRequest, serverResponse, context.hints()))
160+
.orElseGet(() -> messageReader.readMono(MULTIPART_MAP_TYPE, serverRequest, context.hints()));
161+
};
162+
}
163+
164+
/**
165+
* Return a {@code BodyExtractor} that reads multipart (i.e. file upload) form data into a
166+
* {@link MultiValueMap}.
167+
* @return a {@code BodyExtractor} that reads multipart data
168+
*/
169+
// Note that the returned BodyExtractor is parameterized to ServerHttpRequest, not
170+
// ReactiveHttpInputMessage like other methods, since reading form data only typically happens on
171+
// the server-side
172+
public static BodyExtractor<Flux<Part>, ServerHttpRequest> toParts() {
173+
return (serverRequest, context) -> {
174+
HttpMessageReader<Part> messageReader =
175+
messageReader(PART_TYPE, MediaType.MULTIPART_FORM_DATA, context);
176+
return context.serverResponse()
177+
.map(serverResponse -> messageReader.read(PART_TYPE, PART_TYPE, serverRequest, serverResponse, context.hints()))
178+
.orElseGet(() -> messageReader.read(PART_TYPE, serverRequest, context.hints()));
157179
};
158180
}
159181

@@ -191,26 +213,15 @@ private static <T, S extends Publisher<T>> S readWithMessageReaders(
191213
});
192214
}
193215

194-
private static HttpMessageReader<MultiValueMap<String, String>> formMessageReader(BodyExtractor.Context context) {
216+
private static <T> HttpMessageReader<T> messageReader(ResolvableType elementType,
217+
MediaType mediaType, BodyExtractor.Context context) {
195218
return context.messageReaders().get()
196-
.filter(messageReader -> messageReader
197-
.canRead(FORM_TYPE, MediaType.APPLICATION_FORM_URLENCODED))
219+
.filter(messageReader -> messageReader.canRead(elementType, mediaType))
198220
.findFirst()
199-
.map(BodyExtractors::<MultiValueMap<String, String>>cast)
200-
.orElseThrow(() -> new IllegalStateException(
201-
"Could not find HttpMessageReader that supports " +
202-
MediaType.APPLICATION_FORM_URLENCODED_VALUE));
203-
}
204-
205-
private static HttpMessageReader<MultiValueMap<String, Part>> multipartMessageReader(BodyExtractor.Context context) {
206-
return context.messageReaders().get()
207-
.filter(messageReader -> messageReader
208-
.canRead(MULTIPART_TYPE, MediaType.MULTIPART_FORM_DATA))
209-
.findFirst()
210-
.map(BodyExtractors::<MultiValueMap<String, Part>>cast)
221+
.map(BodyExtractors::<T>cast)
211222
.orElseThrow(() -> new IllegalStateException(
212-
"Could not find HttpMessageReader that supports " +
213-
MediaType.MULTIPART_FORM_DATA));
223+
"Could not find HttpMessageReader that supports \"" + mediaType +
224+
"\" and \"" + elementType + "\""));
214225
}
215226

216227
private static MediaType contentType(HttpMessage message) {

spring-webflux/src/test/java/org/springframework/web/reactive/function/BodyExtractorsTests.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636

3737
import org.springframework.core.codec.ByteBufferDecoder;
3838
import org.springframework.core.codec.StringDecoder;
39+
import org.springframework.core.io.ClassPathResource;
40+
import org.springframework.core.io.Resource;
3941
import org.springframework.core.io.buffer.DataBuffer;
4042
import org.springframework.core.io.buffer.DefaultDataBuffer;
4143
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
@@ -45,10 +47,16 @@
4547
import org.springframework.http.codec.FormHttpMessageReader;
4648
import org.springframework.http.codec.HttpMessageReader;
4749
import org.springframework.http.codec.json.Jackson2JsonDecoder;
50+
import org.springframework.http.codec.multipart.FilePart;
51+
import org.springframework.http.codec.multipart.FormFieldPart;
52+
import org.springframework.http.codec.multipart.MultipartHttpMessageReader;
53+
import org.springframework.http.codec.multipart.Part;
54+
import org.springframework.http.codec.multipart.SynchronossPartHttpMessageReader;
4855
import org.springframework.http.codec.xml.Jaxb2XmlDecoder;
4956
import org.springframework.http.server.reactive.ServerHttpRequest;
5057
import org.springframework.http.server.reactive.ServerHttpResponse;
5158
import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest;
59+
import org.springframework.util.FileCopyUtils;
5260
import org.springframework.util.MultiValueMap;
5361

5462
import static org.junit.Assert.*;
@@ -72,6 +80,11 @@ public void createContext() {
7280
messageReaders.add(new DecoderHttpMessageReader<>(StringDecoder.allMimeTypes(true)));
7381
messageReaders.add(new DecoderHttpMessageReader<>(new Jaxb2XmlDecoder()));
7482
messageReaders.add(new DecoderHttpMessageReader<>(new Jackson2JsonDecoder()));
83+
messageReaders.add(new FormHttpMessageReader());
84+
SynchronossPartHttpMessageReader partReader = new SynchronossPartHttpMessageReader();
85+
messageReaders.add(partReader);
86+
messageReaders.add(new MultipartHttpMessageReader(partReader));
87+
7588
messageReaders.add(new FormHttpMessageReader());
7689

7790
this.context = new BodyExtractor.Context() {
@@ -249,6 +262,64 @@ public void toFormData() throws Exception {
249262
.verify();
250263
}
251264

265+
@Test
266+
public void toParts() throws Exception {
267+
BodyExtractor<Flux<Part>, ServerHttpRequest> extractor = BodyExtractors.toParts();
268+
269+
String bodyContents = "-----------------------------9051914041544843365972754266\r\n" +
270+
"Content-Disposition: form-data; name=\"text\"\r\n" +
271+
"\r\n" +
272+
"text default\r\n" +
273+
"-----------------------------9051914041544843365972754266\r\n" +
274+
"Content-Disposition: form-data; name=\"file1\"; filename=\"a.txt\"\r\n" +
275+
"Content-Type: text/plain\r\n" +
276+
"\r\n" +
277+
"Content of a.txt.\r\n" +
278+
"\r\n" +
279+
"-----------------------------9051914041544843365972754266\r\n" +
280+
"Content-Disposition: form-data; name=\"file2\"; filename=\"a.html\"\r\n" +
281+
"Content-Type: text/html\r\n" +
282+
"\r\n" +
283+
"<!DOCTYPE html><title>Content of a.html.</title>\r\n" +
284+
"\r\n" +
285+
"-----------------------------9051914041544843365972754266--\r\n";
286+
287+
DefaultDataBufferFactory factory = new DefaultDataBufferFactory();
288+
DefaultDataBuffer dataBuffer =
289+
factory.wrap(ByteBuffer.wrap(bodyContents.getBytes(StandardCharsets.UTF_8)));
290+
Flux<DataBuffer> body = Flux.just(dataBuffer);
291+
292+
MockServerHttpRequest request = MockServerHttpRequest.post("/")
293+
.header("Content-Type", "multipart/form-data; boundary=---------------------------9051914041544843365972754266")
294+
.body(body);
295+
296+
Flux<Part> result = extractor.extract(request, this.context);
297+
298+
StepVerifier.create(result)
299+
.consumeNextWith(part -> {
300+
assertEquals("text", part.getName());
301+
assertTrue(part instanceof FormFieldPart);
302+
FormFieldPart formFieldPart = (FormFieldPart) part;
303+
assertEquals("text default", formFieldPart.getValue());
304+
})
305+
.consumeNextWith(part -> {
306+
assertEquals("file1", part.getName());
307+
assertTrue(part instanceof FilePart);
308+
FilePart filePart = (FilePart) part;
309+
assertEquals("a.txt", filePart.getFilename());
310+
assertEquals(MediaType.TEXT_PLAIN, filePart.getHeaders().getContentType());
311+
})
312+
.consumeNextWith(part -> {
313+
assertEquals("file2", part.getName());
314+
assertTrue(part instanceof FilePart);
315+
FilePart filePart = (FilePart) part;
316+
assertEquals("a.html", filePart.getFilename());
317+
assertEquals(MediaType.TEXT_HTML, filePart.getHeaders().getContentType());
318+
})
319+
.expectComplete()
320+
.verify();
321+
}
322+
252323
@Test
253324
public void toDataBuffers() throws Exception {
254325
BodyExtractor<Flux<DataBuffer>, ReactiveHttpInputMessage> extractor = BodyExtractors.toDataBuffers();

spring-webflux/src/test/java/org/springframework/web/reactive/function/MultipartIntegrationTests.java

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.web.reactive.function;
1818

19+
import java.util.List;
1920
import java.util.Map;
2021

2122
import org.junit.Test;
@@ -48,10 +49,25 @@ public class MultipartIntegrationTests extends AbstractRouterFunctionIntegration
4849
private final WebClient webClient = WebClient.create();
4950

5051
@Test
51-
public void multipart() {
52+
public void multipartData() {
5253
Mono<ClientResponse> result = webClient
5354
.post()
54-
.uri("http://localhost:" + this.port + "/")
55+
.uri("http://localhost:" + this.port + "/multipartData")
56+
.contentType(MediaType.MULTIPART_FORM_DATA)
57+
.body(BodyInserters.fromMultipartData(generateBody()))
58+
.exchange();
59+
60+
StepVerifier
61+
.create(result)
62+
.consumeNextWith(response -> assertEquals(HttpStatus.OK, response.statusCode()))
63+
.verifyComplete();
64+
}
65+
66+
@Test
67+
public void parts() {
68+
Mono<ClientResponse> result = webClient
69+
.post()
70+
.uri("http://localhost:" + this.port + "/parts")
5571
.contentType(MediaType.MULTIPART_FORM_DATA)
5672
.body(BodyInserters.fromMultipartData(generateBody()))
5773
.exchange();
@@ -77,12 +93,13 @@ private MultiValueMap<String, Object> generateBody() {
7793
@Override
7894
protected RouterFunction<ServerResponse> routerFunction() {
7995
MultipartHandler multipartHandler = new MultipartHandler();
80-
return route(POST("/"), multipartHandler::handle);
96+
return route(POST("/multipartData"), multipartHandler::multipartData)
97+
.andRoute(POST("/parts"), multipartHandler::parts);
8198
}
8299

83100
private static class MultipartHandler {
84101

85-
public Mono<ServerResponse> handle(ServerRequest request) {
102+
public Mono<ServerResponse> multipartData(ServerRequest request) {
86103
return request
87104
.body(BodyExtractors.toMultipartData())
88105
.flatMap(map -> {
@@ -98,6 +115,21 @@ public Mono<ServerResponse> handle(ServerRequest request) {
98115
return ServerResponse.ok().build();
99116
});
100117
}
118+
119+
public Mono<ServerResponse> parts(ServerRequest request) {
120+
return request.body(BodyExtractors.toParts()).collectList()
121+
.flatMap(parts -> {
122+
try {
123+
assertEquals(2, parts.size());
124+
assertEquals("foo.txt", ((FilePart) parts.get(0)).getFilename());
125+
assertEquals("bar", ((FormFieldPart) parts.get(1)).getValue());
126+
}
127+
catch(Exception e) {
128+
return Mono.error(e);
129+
}
130+
return ServerResponse.ok().build();
131+
});
132+
}
101133
}
102134

103135
}

0 commit comments

Comments
 (0)