From 13a7563ddde4e66fcd5ebda84b9b08ccf552e017 Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Wed, 18 Jan 2017 15:35:55 +0100 Subject: [PATCH] Added form support to Body[Inserter|Extractor] - Added BodyInserter for MultiValueMap form data in BodyInserters - Added BodyExtractor to MultiValueMap in BodyExtractors Issue: SPR-15144 --- .../web/reactive/function/BodyExtractors.java | 29 ++++++++ .../web/reactive/function/BodyInserters.java | 73 +++++++++++++------ .../function/BodyExtractorsTests.java | 38 +++++++++- .../reactive/function/BodyInsertersTests.java | 37 +++++++++- 4 files changed, 152 insertions(+), 25 deletions(-) diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java index 19ac6e4cb2d..d8ad5513539 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java @@ -33,7 +33,9 @@ import org.springframework.http.MediaType; import org.springframework.http.ReactiveHttpInputMessage; import org.springframework.http.codec.HttpMessageReader; import org.springframework.http.codec.UnsupportedMediaTypeException; +import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; /** * Implementations of {@link BodyExtractor} that read various bodies, such a reactive streams. @@ -43,6 +45,10 @@ import org.springframework.util.Assert; */ public abstract class BodyExtractors { + private static final ResolvableType FORM_TYPE = + ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class); + + /** * Return a {@code BodyExtractor} that reads into a Reactor {@link Mono}. * @param elementClass the class of element in the {@code Mono} @@ -93,6 +99,29 @@ public abstract class BodyExtractors { Flux::error); } + /** + * Return a {@code BodyExtractor} that reads form data into a {@link MultiValueMap}. + * @return a {@code BodyExtractor} that reads form data + */ + public static BodyExtractor>, ServerHttpRequest> toFormData() { + return (serverRequest, context) -> { + HttpMessageReader> messageReader = formMessageReader(context); + return messageReader.readMono(FORM_TYPE, serverRequest, context.hints()); + }; + } + + private static HttpMessageReader> formMessageReader(BodyExtractor.Context context) { + return context.messageReaders().get() + .filter(messageReader -> messageReader + .canRead(FORM_TYPE, MediaType.APPLICATION_FORM_URLENCODED)) + .findFirst() + .map(BodyExtractors::>cast) + .orElseThrow(() -> new IllegalStateException( + "Could not find HttpMessageReader that supports " + + MediaType.APPLICATION_FORM_URLENCODED_VALUE)); + } + + /** * Return a {@code BodyExtractor} that returns the body of the message as a {@link Flux} of * {@link DataBuffer}s. diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/BodyInserters.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/BodyInserters.java index 15b94057099..84c9f928819 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/BodyInserters.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/function/BodyInserters.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2017 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. @@ -29,11 +29,13 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.MediaType; import org.springframework.http.ReactiveHttpOutputMessage; +import org.springframework.http.client.reactive.ClientHttpRequest; import org.springframework.http.codec.HttpMessageWriter; import org.springframework.http.codec.ServerSentEvent; import org.springframework.http.codec.UnsupportedMediaTypeException; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; /** * Implementations of {@link BodyInserter} that write various bodies, such a reactive streams, @@ -49,6 +51,10 @@ public abstract class BodyInserters { private static final ResolvableType SERVER_SIDE_EVENT_TYPE = ResolvableType.forClass(ServerSentEvent.class); + private static final ResolvableType FORM_TYPE = + ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class); + + private static final BodyInserter EMPTY = (response, context) -> response.setComplete(); @@ -109,16 +115,16 @@ public abstract class BodyInserters { * If the resource can be resolved to a {@linkplain Resource#getFile() file}, it will be copied * using * zero-copy - * @param resource the resource to write to the response + * @param resource the resource to write to the output message * @param the type of the {@code Resource} * @return a {@code BodyInserter} that writes a {@code Publisher} */ public static BodyInserter fromResource(T resource) { Assert.notNull(resource, "'resource' must not be null"); - return (response, context) -> { + return (outputMessage, context) -> { HttpMessageWriter messageWriter = resourceHttpMessageWriter(context); return messageWriter.write(Mono.just(resource), RESOURCE_TYPE, null, - response, context.hints()); + outputMessage, context.hints()); }; } @@ -143,10 +149,11 @@ public abstract class BodyInserters { Assert.notNull(eventsPublisher, "'eventsPublisher' must not be null"); return (response, context) -> { - HttpMessageWriter> messageWriter = sseMessageWriter(context); - return messageWriter.write(eventsPublisher, SERVER_SIDE_EVENT_TYPE, - MediaType.TEXT_EVENT_STREAM, response, context.hints()); - }; + HttpMessageWriter> messageWriter = + findMessageWriter(context, SERVER_SIDE_EVENT_TYPE, MediaType.TEXT_EVENT_STREAM); + return messageWriter.write(eventsPublisher, SERVER_SIDE_EVENT_TYPE, + MediaType.TEXT_EVENT_STREAM, response, context.hints()); + }; } /** @@ -183,13 +190,45 @@ public abstract class BodyInserters { Assert.notNull(eventsPublisher, "'eventsPublisher' must not be null"); Assert.notNull(eventType, "'eventType' must not be null"); return (outputMessage, context) -> { - HttpMessageWriter messageWriter = sseMessageWriter(context); - return messageWriter.write(eventsPublisher, eventType, - MediaType.TEXT_EVENT_STREAM, outputMessage, context.hints()); + HttpMessageWriter messageWriter = + findMessageWriter(context, SERVER_SIDE_EVENT_TYPE, MediaType.TEXT_EVENT_STREAM); + return messageWriter.write(eventsPublisher, eventType, + MediaType.TEXT_EVENT_STREAM, outputMessage, context.hints()); - }; + }; } + /** + * Return a {@code BodyInserter} that writes the given {@code MultiValueMap} as URL-encoded + * form data. + * @param formData the form data to write to the output message + * @return a {@code BodyInserter} that writes form data + */ + public static BodyInserter, ClientHttpRequest> fromFormData(MultiValueMap formData) { + Assert.notNull(formData, "'formData' must not be null"); + + return (outputMessage, context) -> { + HttpMessageWriter> messageWriter = + findMessageWriter(context, FORM_TYPE, MediaType.APPLICATION_FORM_URLENCODED); + return messageWriter.write(Mono.just(formData), FORM_TYPE, + MediaType.APPLICATION_FORM_URLENCODED, outputMessage, context.hints()); + }; + } + + private static HttpMessageWriter findMessageWriter(BodyInserter.Context context, + ResolvableType type, + MediaType mediaType) { + + return context.messageWriters().get() + .filter(messageWriter -> messageWriter.canWrite(type, mediaType)) + .findFirst() + .map(BodyInserters::cast) + .orElseThrow(() -> new IllegalStateException( + "Could not find HttpMessageWriter that supports " + mediaType)); + } + + + /** * Return a {@code BodyInserter} that writes the given {@code Publisher} to the * body. @@ -204,16 +243,6 @@ public abstract class BodyInserters { return (outputMessage, context) -> outputMessage.writeWith(publisher); } - private static HttpMessageWriter sseMessageWriter(BodyInserter.Context context) { - return context.messageWriters().get() - .filter(messageWriter -> messageWriter - .canWrite(SERVER_SIDE_EVENT_TYPE, MediaType.TEXT_EVENT_STREAM)) - .findFirst() - .map(BodyInserters::cast) - .orElseThrow(() -> new IllegalStateException( - "Could not find HttpMessageWriter that supports " + - MediaType.TEXT_EVENT_STREAM_VALUE)); - } private static , M extends ReactiveHttpOutputMessage> BodyInserter bodyInserterFor(P body, ResolvableType bodyType) { diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/BodyExtractorsTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/BodyExtractorsTests.java index 5b360efc3bc..02f2339a6ce 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/BodyExtractorsTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/BodyExtractorsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2017 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. @@ -41,11 +41,14 @@ import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.MediaType; import org.springframework.http.ReactiveHttpInputMessage; import org.springframework.http.codec.DecoderHttpMessageReader; +import org.springframework.http.codec.FormHttpMessageReader; import org.springframework.http.codec.HttpMessageReader; import org.springframework.http.codec.UnsupportedMediaTypeException; import org.springframework.http.codec.json.Jackson2JsonDecoder; import org.springframework.http.codec.xml.Jaxb2XmlDecoder; +import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; +import org.springframework.util.MultiValueMap; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -68,6 +71,7 @@ public class BodyExtractorsTests { messageReaders.add(new DecoderHttpMessageReader<>(new StringDecoder())); messageReaders.add(new DecoderHttpMessageReader<>(new Jaxb2XmlDecoder())); messageReaders.add(new DecoderHttpMessageReader<>(new Jackson2JsonDecoder())); + messageReaders.add(new FormHttpMessageReader()); this.context = new BodyExtractor.Context() { @Override @@ -79,7 +83,7 @@ public class BodyExtractorsTests { return hints; } }; - this.hints = new HashMap(); + this.hints = new HashMap(); } @Test @@ -202,6 +206,36 @@ public class BodyExtractorsTests { .verify(); } + @Test + public void toFormData() throws Exception { + BodyExtractor>, ServerHttpRequest> extractor = BodyExtractors.toFormData(); + + DefaultDataBufferFactory factory = new DefaultDataBufferFactory(); + DefaultDataBuffer dataBuffer = + factory.wrap(ByteBuffer.wrap("name+1=value+1&name+2=value+2%2B1&name+2=value+2%2B2&name+3".getBytes(StandardCharsets.UTF_8))); + Flux body = Flux.just(dataBuffer); + + MockServerHttpRequest request = MockServerHttpRequest.post("/") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body); + + Mono> result = extractor.extract(request, this.context); + + StepVerifier.create(result) + .consumeNextWith(form -> { + assertEquals("Invalid result", 3, form.size()); + assertEquals("Invalid result", "value 1", form.getFirst("name 1")); + List values = form.get("name 2"); + assertEquals("Invalid result", 2, values.size()); + assertEquals("Invalid result", "value 2+1", values.get(0)); + assertEquals("Invalid result", "value 2+2", values.get(1)); + assertNull("Invalid result", form.getFirst("name 3")); + }) + .expectComplete() + .verify(); + + } + @Test public void toDataBuffers() throws Exception { BodyExtractor, ReactiveHttpInputMessage> extractor = BodyExtractors.toDataBuffers(); diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/BodyInsertersTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/BodyInsertersTests.java index 62db2d422f7..6231457ff5a 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/BodyInsertersTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/function/BodyInsertersTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2017 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. @@ -16,6 +16,7 @@ package org.springframework.web.reactive.function; +import java.net.URI; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -41,8 +42,11 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DefaultDataBuffer; import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpMethod; import org.springframework.http.ReactiveHttpOutputMessage; +import org.springframework.http.client.reactive.ClientHttpRequest; import org.springframework.http.codec.EncoderHttpMessageWriter; +import org.springframework.http.codec.FormHttpMessageWriter; import org.springframework.http.codec.HttpMessageWriter; import org.springframework.http.codec.ResourceHttpMessageWriter; import org.springframework.http.codec.ServerSentEvent; @@ -50,7 +54,10 @@ import org.springframework.http.codec.ServerSentEventHttpMessageWriter; import org.springframework.http.codec.json.Jackson2JsonEncoder; import org.springframework.http.codec.xml.Jaxb2XmlEncoder; import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.mock.http.client.reactive.test.MockClientHttpRequest; import org.springframework.mock.http.server.reactive.test.MockServerHttpResponse; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.Assert.assertArrayEquals; @@ -77,6 +84,7 @@ public class BodyInsertersTests { messageWriters.add(new EncoderHttpMessageWriter<>(jsonEncoder)); messageWriters .add(new ServerSentEventHttpMessageWriter(Collections.singletonList(jsonEncoder))); + messageWriters.add(new FormHttpMessageWriter()); this.context = new BodyInserter.Context() { @Override @@ -198,6 +206,33 @@ public class BodyInsertersTests { StepVerifier.create(result).expectNextCount(0).expectComplete().verify(); } + @Test + public void ofFormData() throws Exception { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.set("name 1", "value 1"); + body.add("name 2", "value 2+1"); + body.add("name 2", "value 2+2"); + body.add("name 3", null); + + BodyInserter, ClientHttpRequest> + inserter = BodyInserters.fromFormData(body); + + MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, URI.create("http://example.com")); + Mono result = inserter.insert(request, this.context); + StepVerifier.create(result).expectComplete().verify(); + + StepVerifier.create(request.getBody()) + .consumeNextWith(dataBuffer -> { + byte[] resultBytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(resultBytes); + assertArrayEquals("name+1=value+1&name+2=value+2%2B1&name+2=value+2%2B2&name+3".getBytes(StandardCharsets.UTF_8), + resultBytes); + }) + .expectComplete() + .verify(); + + } + @Test public void ofDataBuffers() throws Exception { DefaultDataBufferFactory factory = new DefaultDataBufferFactory();