Add WebExchangeDataBinder

Issue: SPR-14541
This commit is contained in:
Rossen Stoyanchev 2016-10-07 06:15:40 -04:00
parent 580b8b92f8
commit b28b3e8877
9 changed files with 454 additions and 10 deletions

View File

@ -815,6 +815,8 @@ project("spring-web-reactive") {
optional "org.apache.httpcomponents:httpclient:${httpclientVersion}"
optional('org.webjars:webjars-locator:0.32')
testCompile("javax.validation:validation-api:${beanvalVersion}")
testCompile("org.hibernate:hibernate-validator:${hibval5Version}")
testCompile("javax.el:javax.el-api:${elApiVersion}")
testCompile("org.apache.tomcat:tomcat-util:${tomcatVersion}")
testCompile("org.apache.tomcat.embed:tomcat-embed-core:${tomcatVersion}")
testCompile("org.eclipse.jetty:jetty-server:${jettyVersion}")

View File

@ -55,7 +55,10 @@ import org.springframework.http.codec.xml.Jaxb2XmlDecoder;
import org.springframework.http.codec.xml.Jaxb2XmlEncoder;
import org.springframework.util.ClassUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.MessageCodesResolver;
import org.springframework.validation.Validator;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.support.ConfigurableWebBindingInitializer;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.accept.CompositeContentTypeResolver;
@ -255,6 +258,7 @@ public class WebReactiveConfiguration implements ApplicationContextAware {
}
adapter.setMessageReaders(getMessageReaders());
adapter.setWebBindingInitializer(getConfigurableWebBindingInitializer());
adapter.setConversionService(mvcConversionService());
adapter.setValidator(mvcValidator());
@ -325,6 +329,18 @@ public class WebReactiveConfiguration implements ApplicationContextAware {
protected void extendMessageReaders(List<HttpMessageReader<?>> messageReaders) {
}
/**
* Return the {@link ConfigurableWebBindingInitializer} to use for
* initializing all {@link WebDataBinder} instances.
*/
protected ConfigurableWebBindingInitializer getConfigurableWebBindingInitializer() {
ConfigurableWebBindingInitializer initializer = new ConfigurableWebBindingInitializer();
initializer.setConversionService(mvcConversionService());
initializer.setValidator(mvcValidator());
initializer.setMessageCodesResolver(getMessageCodesResolver());
return initializer;
}
@Bean
public FormattingConversionService mvcConversionService() {
FormattingConversionService service = new DefaultFormattingConversionService();
@ -378,6 +394,13 @@ public class WebReactiveConfiguration implements ApplicationContextAware {
return null;
}
/**
* Override this method to provide a custom {@link MessageCodesResolver}.
*/
protected MessageCodesResolver getMessageCodesResolver() {
return null;
}
@Bean
public SimpleHandlerAdapter simpleHandlerAdapter() {
return new SimpleHandlerAdapter();

View File

@ -41,6 +41,7 @@ import org.springframework.http.codec.HttpMessageReader;
import org.springframework.ui.ExtendedModelMap;
import org.springframework.ui.ModelMap;
import org.springframework.validation.Validator;
import org.springframework.web.bind.support.WebBindingInitializer;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.method.annotation.ExceptionHandlerMethodResolver;
import org.springframework.web.reactive.HandlerAdapter;
@ -68,6 +69,8 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, BeanFactory
private ReactiveAdapterRegistry reactiveAdapters = new ReactiveAdapterRegistry();
private WebBindingInitializer webBindingInitializer;
private ConversionService conversionService = new DefaultFormattingConversionService();
private Validator validator;
@ -136,6 +139,21 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, BeanFactory
return this.reactiveAdapters;
}
/**
* Provide a WebBindingInitializer with "global" initialization to apply
* to every DataBinder instance.
*/
public void setWebBindingInitializer(WebBindingInitializer webBindingInitializer) {
this.webBindingInitializer = webBindingInitializer;
}
/**
* Return the configured WebBindingInitializer, or {@code null} if none.
*/
public WebBindingInitializer getWebBindingInitializer() {
return this.webBindingInitializer;
}
/**
* Configure a ConversionService for type conversion of controller method
* arguments as well as for converting from different async types to

View File

@ -49,6 +49,8 @@ import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
import org.springframework.validation.Validator;
import org.springframework.validation.beanvalidation.OptionalValidatorFactoryBean;
import org.springframework.web.bind.WebExchangeDataBinder;
import org.springframework.web.bind.support.WebBindingInitializer;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
import org.springframework.web.reactive.handler.AbstractHandlerMapping;
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
@ -161,6 +163,13 @@ public class WebReactiveConfigurationTests {
Validator validator = context.getBean(name, Validator.class);
assertSame(validator, adapter.getValidator());
assertEquals(OptionalValidatorFactoryBean.class, validator.getClass());
WebBindingInitializer bindingInitializer = adapter.getWebBindingInitializer();
assertNotNull(bindingInitializer);
WebExchangeDataBinder binder = new WebExchangeDataBinder(new Object());
bindingInitializer.initBinder(binder);
assertSame(service, binder.getConversionService());
assertSame(validator, binder.getValidator());
}
@Test

View File

@ -0,0 +1,166 @@
/*
* 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.web.bind;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import reactor.core.publisher.Mono;
import org.springframework.beans.MutablePropertyValues;
import org.springframework.core.ResolvableType;
import org.springframework.http.MediaType;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ServerWebExchange;
/**
* Specialized {@link org.springframework.validation.DataBinder} to perform data
* binding from URL query params or form data in the request data to Java objects.
*
* @author Rossen Stoyanchev
* @since 5.0
*/
public class WebExchangeDataBinder extends WebDataBinder {
private static final ResolvableType MULTIVALUE_MAP_TYPE = ResolvableType.forClass(MultiValueMap.class);
private HttpMessageReader<MultiValueMap<String, String>> formReader = null;
/**
* Create a new instance, with default object name.
* @param target the target object to bind onto (or {@code null} if the
* binder is just used to convert a plain parameter value)
* @see #DEFAULT_OBJECT_NAME
*/
public WebExchangeDataBinder(Object target) {
super(target);
}
/**
* Create a new instance.
* @param target the target object to bind onto (or {@code null} if the
* binder is just used to convert a plain parameter value)
* @param objectName the name of the target object
*/
public WebExchangeDataBinder(Object target, String objectName) {
super(target, objectName);
}
public void setFormReader(HttpMessageReader<MultiValueMap<String, String>> formReader) {
this.formReader = formReader;
}
public HttpMessageReader<MultiValueMap<String, String>> getFormReader() {
return this.formReader;
}
/**
* Bind the URL query parameters or form data of the body of the given request
* to this binder's target. The request body is parsed if the content-type
* is "application/x-www-form-urlencoded".
*
* @param exchange the current exchange.
* @return a {@code Mono<Void>} to indicate the result
*/
public Mono<Void> bind(ServerWebExchange exchange) {
ServerHttpRequest request = exchange.getRequest();
Mono<MultiValueMap<String, String>> queryParams = Mono.just(request.getQueryParams());
Mono<MultiValueMap<String, String>> formParams = getFormParams(exchange);
return Mono.zip(this::mergeParams, queryParams, formParams)
.map(this::getParamsToBind)
.doOnNext(values -> values.putAll(getMultipartFiles(exchange)))
.doOnNext(values -> values.putAll(getExtraValuesToBind(exchange)))
.then(values -> {
doBind(new MutablePropertyValues(values));
return Mono.empty();
});
}
private Mono<MultiValueMap<String, String>> getFormParams(ServerWebExchange exchange) {
ServerHttpRequest request = exchange.getRequest();
MediaType contentType = request.getHeaders().getContentType();
if (this.formReader.canRead(MULTIVALUE_MAP_TYPE, contentType)) {
return this.formReader.readMono(MULTIVALUE_MAP_TYPE, request, Collections.emptyMap());
}
else {
return Mono.just(new LinkedMultiValueMap<>());
}
}
@SuppressWarnings("unchecked")
private MultiValueMap<String, String> mergeParams(Object[] paramMaps) {
MultiValueMap<String, String> result = new LinkedMultiValueMap<>();
Arrays.stream(paramMaps).forEach(map -> result.putAll((MultiValueMap<String, String>) map));
return result;
}
private Map<String, Object> getParamsToBind(MultiValueMap<String, String> params) {
Map<String, Object> valuesToBind = new TreeMap<>();
for (Map.Entry<String, List<String>> entry : params.entrySet()) {
String name = entry.getKey();
List<String> values = entry.getValue();
if (values == null || values.isEmpty()) {
// Do nothing, no values found at all.
}
else {
if (values.size() > 1) {
valuesToBind.put(name, values);
}
else {
valuesToBind.put(name, values.get(0));
}
}
}
return valuesToBind;
}
/**
* Bind all multipart files contained in the given request, if any (in case
* of a multipart request).
* <p>Multipart files will only be added to the property values if they
* are not empty or if we're configured to bind empty multipart files too.
* @param exchange the current exchange
* @return Map of field name String to MultipartFile object
*/
protected Map<String, List<MultipartFile>> getMultipartFiles(ServerWebExchange exchange) {
// TODO
return Collections.emptyMap();
}
/**
* Extension point that subclasses can use to add extra bind values for a
* request. Invoked before {@link #doBind(MutablePropertyValues)}.
* The default implementation is empty.
* @param exchange the current exchange
*/
protected Map<String, ?> getExtraValuesToBind(ServerWebExchange exchange) {
return Collections.emptyMap();
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2012 the original author or authors.
* 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.
@ -22,7 +22,6 @@ import org.springframework.validation.BindingErrorProcessor;
import org.springframework.validation.MessageCodesResolver;
import org.springframework.validation.Validator;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.context.request.WebRequest;
/**
* Convenient {@link WebBindingInitializer} for declarative configuration
@ -182,7 +181,7 @@ public class ConfigurableWebBindingInitializer implements WebBindingInitializer
@Override
public void initBinder(WebDataBinder binder, WebRequest request) {
public void initBinder(WebDataBinder binder) {
binder.setAutoGrowNestedPaths(this.autoGrowNestedPaths);
if (this.directFieldAccess) {
binder.initDirectFieldAccess();

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2015 the original author or authors.
* 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.
@ -47,6 +47,7 @@ public class DefaultDataBinderFactory implements WebDataBinderFactory {
* @throws Exception in case of invalid state or arguments
*/
@Override
@SuppressWarnings("deprecation")
public final WebDataBinder createBinder(NativeWebRequest webRequest, Object target, String objectName)
throws Exception {
@ -74,7 +75,7 @@ public class DefaultDataBinderFactory implements WebDataBinderFactory {
/**
* Extension point to further initialize the created data binder instance
* (e.g. with {@code @InitBinder} methods) after "global" initializaton
* (e.g. with {@code @InitBinder} methods) after "global" initialization
* via {@link WebBindingInitializer}.
* @param dataBinder the data binder instance to customize
* @param webRequest the current request

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2007 the original author or authors.
* 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.
@ -20,19 +20,31 @@ import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.context.request.WebRequest;
/**
* Callback interface for initializing a {@link org.springframework.web.bind.WebDataBinder}
* for performing data binding in the context of a specific web request.
* Callback interface for initializing a {@link WebDataBinder} for performing
* data binding in the context of a specific web request.
*
* @author Juergen Hoeller
* @author Rossen Stoyanchev
* @since 2.5
*/
public interface WebBindingInitializer {
/**
* Initialize the given DataBinder for the given request.
* Initialize the given DataBinder.
* @param binder the DataBinder to initialize
* @since 5.0
*/
void initBinder(WebDataBinder binder);
/**
* Initialize the given DataBinder for the given (Servlet) request.
* @param binder the DataBinder to initialize
* @param request the web request that the data binding happens within
* @deprecated as of 5.0 in favor of {@link #initBinder(WebDataBinder)}
*/
void initBinder(WebDataBinder binder, WebRequest request);
@Deprecated
default void initBinder(WebDataBinder binder, WebRequest request) {
initBinder(binder);
}
}

View File

@ -0,0 +1,214 @@
/*
* 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.web.bind;
import java.beans.PropertyEditorSupport;
import java.util.Collections;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import reactor.core.publisher.Mono;
import org.springframework.core.ResolvableType;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.test.MockServerHttpResponse;
import org.springframework.tests.sample.beans.ITestBean;
import org.springframework.tests.sample.beans.TestBean;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.adapter.DefaultServerWebExchange;
import org.springframework.web.server.session.DefaultWebSessionManager;
import org.springframework.web.server.session.WebSessionManager;
import static junit.framework.TestCase.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.when;
import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED;
/**
* Unit tests for {@link WebExchangeDataBinder}.
*
* @author Rossen Stoyanchev
*/
public class WebExchangeDataBinderTests {
private static final ResolvableType ELEMENT_TYPE = ResolvableType.forClass(MultiValueMap.class);
private WebExchangeDataBinder binder;
private TestBean testBean;
private ServerWebExchange exchange;
@Mock
private HttpMessageReader<MultiValueMap<String, String>> formReader;
private MultiValueMap<String, String> formData;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
this.testBean = new TestBean();
this.binder = new WebExchangeDataBinder(this.testBean, "person");
this.binder.registerCustomEditor(ITestBean.class, new TestBeanPropertyEditor());
this.binder.setFormReader(this.formReader);
MockServerHttpRequest request = new MockServerHttpRequest();
MockServerHttpResponse response = new MockServerHttpResponse();
WebSessionManager sessionManager = new DefaultWebSessionManager();
this.exchange = new DefaultServerWebExchange(request, response, sessionManager);
request.getHeaders().setContentType(APPLICATION_FORM_URLENCODED);
this.formData = new LinkedMultiValueMap<>();
when(this.formReader.canRead(ELEMENT_TYPE, APPLICATION_FORM_URLENCODED)).thenReturn(true);
when(this.formReader.readMono(ELEMENT_TYPE, request, Collections.emptyMap()))
.thenReturn(Mono.just(formData));
}
@Test
public void testBindingWithNestedObjectCreation() throws Exception {
this.formData.add("spouse", "someValue");
this.formData.add("spouse.name", "test");
this.binder.bind(this.exchange).blockMillis(5000);
assertNotNull(this.testBean.getSpouse());
assertEquals("test", testBean.getSpouse().getName());
}
@Test
public void testFieldPrefixCausesFieldReset() throws Exception {
this.formData.add("_postProcessed", "visible");
this.formData.add("postProcessed", "on");
this.binder.bind(this.exchange).blockMillis(5000);
assertTrue(this.testBean.isPostProcessed());
this.formData.remove("postProcessed");
this.binder.bind(this.exchange).blockMillis(5000);
assertFalse(this.testBean.isPostProcessed());
}
@Test
public void testFieldPrefixCausesFieldResetWithIgnoreUnknownFields() throws Exception {
this.binder.setIgnoreUnknownFields(false);
this.formData.add("_postProcessed", "visible");
this.formData.add("postProcessed", "on");
this.binder.bind(this.exchange).blockMillis(5000);
assertTrue(this.testBean.isPostProcessed());
this.formData.remove("postProcessed");
this.binder.bind(this.exchange).blockMillis(5000);
assertFalse(this.testBean.isPostProcessed());
}
@Test
public void testFieldDefault() throws Exception {
this.formData.add("!postProcessed", "off");
this.formData.add("postProcessed", "on");
this.binder.bind(this.exchange).blockMillis(5000);
assertTrue(this.testBean.isPostProcessed());
this.formData.remove("postProcessed");
this.binder.bind(this.exchange).blockMillis(5000);
assertFalse(this.testBean.isPostProcessed());
}
@Test
public void testFieldDefaultPreemptsFieldMarker() throws Exception {
this.formData.add("!postProcessed", "on");
this.formData.add("_postProcessed", "visible");
this.formData.add("postProcessed", "on");
this.binder.bind(this.exchange).blockMillis(5000);
assertTrue(this.testBean.isPostProcessed());
this.formData.remove("postProcessed");
this.binder.bind(this.exchange).blockMillis(5000);
assertTrue(this.testBean.isPostProcessed());
this.formData.remove("!postProcessed");
this.binder.bind(this.exchange).blockMillis(5000);
assertFalse(this.testBean.isPostProcessed());
}
@Test
public void testFieldDefaultNonBoolean() throws Exception {
this.formData.add("!name", "anonymous");
this.formData.add("name", "Scott");
this.binder.bind(this.exchange).blockMillis(5000);
assertEquals("Scott", this.testBean.getName());
this.formData.remove("name");
this.binder.bind(this.exchange).blockMillis(5000);
assertEquals("anonymous", this.testBean.getName());
}
@Test
public void testWithCommaSeparatedStringArray() throws Exception {
this.formData.add("stringArray", "bar");
this.formData.add("stringArray", "abc");
this.formData.add("stringArray", "123,def");
this.binder.bind(this.exchange).blockMillis(5000);
assertEquals("Expected all three items to be bound", 3, this.testBean.getStringArray().length);
this.formData.remove("stringArray");
this.formData.add("stringArray", "123,def");
this.binder.bind(this.exchange).blockMillis(5000);
assertEquals("Expected only 1 item to be bound", 1, this.testBean.getStringArray().length);
}
@Test
public void testBindingWithNestedObjectCreationAndWrongOrder() throws Exception {
this.formData.add("spouse.name", "test");
this.formData.add("spouse", "someValue");
this.binder.bind(this.exchange).blockMillis(5000);
assertNotNull(this.testBean.getSpouse());
assertEquals("test", this.testBean.getSpouse().getName());
}
@Test
public void testBindingWithQueryParams() throws Exception {
MultiValueMap<String, String> queryParams = this.exchange.getRequest().getQueryParams();
queryParams.add("spouse", "someValue");
queryParams.add("spouse.name", "test");
this.binder.bind(this.exchange).blockMillis(5000);
assertNotNull(this.testBean.getSpouse());
assertEquals("test", this.testBean.getSpouse().getName());
}
private static class TestBeanPropertyEditor extends PropertyEditorSupport {
@Override
public void setAsText(String text) {
setValue(new TestBean());
}
}
}