From 46599e7d039b95acb141308c7dfa5d3398404bc1 Mon Sep 17 00:00:00 2001 From: Sebastien Deleuze Date: Fri, 28 Oct 2016 17:51:15 +0200 Subject: [PATCH] Add FormHttpMessageReader/Writer Issue: SPR-14540 --- .../http/codec/FormHttpMessageReader.java | 119 ++++++++++++++++ .../http/codec/FormHttpMessageWriter.java | 133 ++++++++++++++++++ .../codec/FormHttpMessageReaderTests.java | 85 +++++++++++ .../codec/FormHttpMessageWriterTests.java | 73 ++++++++++ 4 files changed, 410 insertions(+) create mode 100644 spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageReader.java create mode 100644 spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageWriter.java create mode 100644 spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageReaderTests.java create mode 100644 spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageWriterTests.java diff --git a/spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageReader.java b/spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageReader.java new file mode 100644 index 00000000000..e056c694ca8 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageReader.java @@ -0,0 +1,119 @@ +/* + * Copyright 2002-2016 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.codec; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.MediaType; +import org.springframework.http.ReactiveHttpInputMessage; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Implementation of {@link HttpMessageReader} to read 'normal' HTML + * forms with {@code "application/x-www-form-urlencoded"} media type. + * + * @author Sebastien Deleuze + */ +public class FormHttpMessageReader implements HttpMessageReader> { + + public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + private static final ResolvableType formType = ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class); + + private Charset charset = DEFAULT_CHARSET; + + + @Override + public boolean canRead(ResolvableType elementType, MediaType mediaType) { + return (mediaType == null || MediaType.APPLICATION_FORM_URLENCODED.isCompatibleWith(mediaType)) && + formType.isAssignableFrom(elementType); + } + + @Override + public Flux> read(ResolvableType elementType, + ReactiveHttpInputMessage inputMessage, Map hints) { + return Flux.from(readMono(elementType, inputMessage, hints)); + } + + @Override + public Mono> readMono(ResolvableType elementType, + ReactiveHttpInputMessage inputMessage, Map hints) { + + MediaType contentType = inputMessage.getHeaders().getContentType(); + Charset charset = (contentType.getCharset() != null ? contentType.getCharset() : this.charset); + + return inputMessage.getBody() + .reduce(DataBuffer::write) + .map(buffer -> { + CharBuffer charBuffer = charset.decode(buffer.asByteBuffer()); + DataBufferUtils.release(buffer); + String body = charBuffer.toString(); + String[] pairs = StringUtils.tokenizeToStringArray(body, "&"); + MultiValueMap result = new LinkedMultiValueMap<>(pairs.length); + try { + for (String pair : pairs) { + int idx = pair.indexOf('='); + if (idx == -1) { + result.add(URLDecoder.decode(pair, charset.name()), null); + } + else { + String name = URLDecoder.decode(pair.substring(0, idx), charset.name()); + String value = URLDecoder.decode(pair.substring(idx + 1), charset.name()); + result.add(name, value); + } + } + } + catch (UnsupportedEncodingException ex) { + throw new IllegalStateException(ex); + } + + return result; + }); + } + + @Override + public List getReadableMediaTypes() { + return Collections.singletonList(MediaType.APPLICATION_FORM_URLENCODED); + } + + /** + * Set the default character set to use for reading form data when the request + * Content-Type header does not explicitly specify it. + *

By default this is set to "UTF-8". + */ + public void setCharset(Charset charset) { + Assert.notNull(charset, "'charset' must not be null"); + this.charset = charset; + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageWriter.java b/spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageWriter.java new file mode 100644 index 00000000000..21b89b94510 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/codec/FormHttpMessageWriter.java @@ -0,0 +1,133 @@ +/* + * Copyright 2002-2016 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.codec; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.MediaType; +import org.springframework.http.ReactiveHttpOutputMessage; +import org.springframework.util.Assert; +import org.springframework.util.MultiValueMap; + +/** + * Implementation of {@link HttpMessageWriter} to write 'normal' HTML + * forms with {@code "application/x-www-form-urlencoded"} media type. + * + * @author Sebastien Deleuze + * @since 5.0 + * @see MultiValueMap + */ +public class FormHttpMessageWriter implements HttpMessageWriter> { + + public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + private static final ResolvableType formType = ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class); + + private Charset charset = DEFAULT_CHARSET; + + + @Override + public boolean canWrite(ResolvableType elementType, MediaType mediaType) { + return (mediaType == null || MediaType.APPLICATION_FORM_URLENCODED.isCompatibleWith(mediaType)) && + formType.isAssignableFrom(elementType); + } + + @Override + public Mono write(Publisher> inputStream, + ResolvableType elementType, MediaType mediaType, ReactiveHttpOutputMessage outputMessage, + Map hints) { + + MediaType contentType = outputMessage.getHeaders().getContentType(); + Charset charset; + if (contentType != null) { + outputMessage.getHeaders().setContentType(contentType); + charset = (contentType != null && contentType.getCharset() != null ? contentType.getCharset() : this.charset); + } + else { + outputMessage.getHeaders().setContentType(MediaType.APPLICATION_FORM_URLENCODED); + charset = this.charset; + } + return Flux + .from(inputStream) + .single() + .map(form -> generateForm(form)) + .then(value -> { + ByteBuffer byteBuffer = charset.encode(value); + DataBuffer buffer = outputMessage.bufferFactory().wrap(byteBuffer); + outputMessage.getHeaders().setContentLength(byteBuffer.remaining()); + return outputMessage.writeWith(Mono.just(buffer)); + }); + + } + + private String generateForm(MultiValueMap form) { + StringBuilder builder = new StringBuilder(); + try { + for (Iterator nameIterator = form.keySet().iterator(); nameIterator.hasNext();) { + String name = nameIterator.next(); + for (Iterator valueIterator = form.get(name).iterator(); valueIterator.hasNext();) { + String value = valueIterator.next(); + builder.append(URLEncoder.encode(name, charset.name())); + if (value != null) { + builder.append('='); + builder.append(URLEncoder.encode(value, charset.name())); + if (valueIterator.hasNext()) { + builder.append('&'); + } + } + } + if (nameIterator.hasNext()) { + builder.append('&'); + } + } + } + catch (UnsupportedEncodingException ex) { + throw new IllegalStateException(ex); + } + return builder.toString(); + } + + @Override + public List getWritableMediaTypes() { + return Collections.singletonList(MediaType.APPLICATION_FORM_URLENCODED); + } + + /** + * Set the default character set to use for writing form data when the response + * Content-Type header does not explicitly specify it. + *

By default this is set to "UTF-8". + */ + public void setCharset(Charset charset) { + Assert.notNull(charset, "'charset' must not be null"); + this.charset = charset; + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageReaderTests.java b/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageReaderTests.java new file mode 100644 index 00000000000..0a7f19af4b7 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageReaderTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-2016 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.codec; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; +import org.junit.Test; + +import org.springframework.core.ResolvableType; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; +import org.springframework.util.MultiValueMap; + +/** + * @author Sebastien Deleuze + */ +public class FormHttpMessageReaderTests { + + private final FormHttpMessageReader reader = new FormHttpMessageReader(); + + @Test + public void canRead() { + assertTrue(this.reader.canRead(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class), + MediaType.APPLICATION_FORM_URLENCODED)); + assertFalse(this.reader.canRead(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), + MediaType.APPLICATION_FORM_URLENCODED)); + assertFalse(this.reader.canRead(ResolvableType.forClassWithGenerics(MultiValueMap.class, Object.class, String.class), + MediaType.APPLICATION_FORM_URLENCODED)); + assertFalse(this.reader.canRead(ResolvableType.forClassWithGenerics(Map.class, String.class, String.class), + MediaType.APPLICATION_FORM_URLENCODED)); + assertFalse(this.reader.canRead(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class), + MediaType.MULTIPART_FORM_DATA)); + } + + @Test + public void readFormAsMono() { + String body = "name+1=value+1&name+2=value+2%2B1&name+2=value+2%2B2&name+3"; + MockServerHttpRequest request = new MockServerHttpRequest(); + request.setBody(body); + request.getHeaders().setContentType(MediaType.APPLICATION_FORM_URLENCODED); + MultiValueMap result = this.reader.readMono(null, request, null).block(); + + assertEquals("Invalid result", 3, result.size()); + assertEquals("Invalid result", "value 1", result.getFirst("name 1")); + List values = result.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", result.getFirst("name 3")); + } + + @Test + public void readFormAsFlux() { + String body = "name+1=value+1&name+2=value+2%2B1&name+2=value+2%2B2&name+3"; + MockServerHttpRequest request = new MockServerHttpRequest(); + request.setBody(body); + request.getHeaders().setContentType(MediaType.APPLICATION_FORM_URLENCODED); + MultiValueMap result = this.reader.read(null, request, null).single().block(); + + assertEquals("Invalid result", 3, result.size()); + assertEquals("Invalid result", "value 1", result.getFirst("name 1")); + List values = result.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", result.getFirst("name 3")); + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageWriterTests.java b/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageWriterTests.java new file mode 100644 index 00000000000..675d859f181 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/codec/FormHttpMessageWriterTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2016 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.http.codec; + +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.test.MockServerHttpResponse; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +/** + * @author Sebastien Deleuze + */ +public class FormHttpMessageWriterTests { + + private final FormHttpMessageWriter writer = new FormHttpMessageWriter(); + + @Test + public void canWrite() { + assertTrue(this.writer.canWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class), + MediaType.APPLICATION_FORM_URLENCODED)); + assertFalse(this.writer.canWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), + MediaType.APPLICATION_FORM_URLENCODED)); + assertFalse(this.writer.canWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, Object.class, String.class), + MediaType.APPLICATION_FORM_URLENCODED)); + assertFalse(this.writer.canWrite(ResolvableType.forClassWithGenerics(Map.class, String.class, String.class), + MediaType.APPLICATION_FORM_URLENCODED)); + assertFalse(this.writer.canWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class), + MediaType.MULTIPART_FORM_DATA)); + } + + @Test + public void writeForm() { + 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); + MockServerHttpResponse response = new MockServerHttpResponse(); + this.writer.write(Mono.just(body), null, MediaType.APPLICATION_FORM_URLENCODED, response, null).block(); + + String responseBody = response.getBodyAsString().block(); + assertEquals("Invalid result", "name+1=value+1&name+2=value+2%2B1&name+2=value+2%2B2&name+3", + responseBody); + assertEquals("Invalid content-type", MediaType.APPLICATION_FORM_URLENCODED, + response.getHeaders().getContentType()); + assertEquals("Invalid content-length", responseBody.getBytes().length, + response.getHeaders().getContentLength()); + } + +}