From 6b73700f741618e737ae4b1509a9caf16b83d5fc Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Mon, 7 Nov 2016 14:54:25 +0200 Subject: [PATCH] Reactive support for @ModelAttribute methods Issue: SPR-14542 --- .../annotation/BindingContextFactory.java | 169 ++++++++++++++++ .../ModelAttributeMethodArgumentResolver.java | 18 +- .../RequestMappingHandlerAdapter.java | 62 +++--- .../RequestParamMethodArgumentResolver.java | 17 +- .../view/ViewResolutionResultHandler.java | 14 +- .../reactive/DispatcherHandlerErrorTests.java | 31 +-- .../BindingContextFactoryTests.java | 182 ++++++++++++++++++ ...lAttributeMethodArgumentResolverTests.java | 6 +- ...estMappingDataBindingIntegrationTests.java | 87 ++++++++- 9 files changed, 506 insertions(+), 80 deletions(-) create mode 100644 spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/BindingContextFactory.java create mode 100644 spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/BindingContextFactoryTests.java diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/BindingContextFactory.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/BindingContextFactory.java new file mode 100644 index 0000000000..4d77aa9e1e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/BindingContextFactory.java @@ -0,0 +1,169 @@ +/* + * 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.reactive.result.method.annotation; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.support.WebBindingInitializer; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.result.method.BindingContext; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.reactive.result.method.InvocableHandlerMethod; +import org.springframework.web.reactive.result.method.SyncHandlerMethodArgumentResolver; +import org.springframework.web.reactive.result.method.SyncInvocableHandlerMethod; +import org.springframework.web.server.ServerWebExchange; + +/** + * A helper class for {@link RequestMappingHandlerAdapter} that assists with + * creating a {@code BindingContext} and initialize it, and its model, through + * {@code @InitBinder} and {@code @ModelAttribute} methods. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +class BindingContextFactory { + + private final RequestMappingHandlerAdapter adapter; + + + public BindingContextFactory(RequestMappingHandlerAdapter adapter) { + this.adapter = adapter; + } + + + public RequestMappingHandlerAdapter getAdapter() { + return this.adapter; + } + + private WebBindingInitializer getBindingInitializer() { + return getAdapter().getWebBindingInitializer(); + } + + private List getInitBinderArgumentResolvers() { + return getAdapter().getInitBinderArgumentResolvers(); + } + + private List getArgumentResolvers() { + return getAdapter().getArgumentResolvers(); + } + + private ReactiveAdapterRegistry getAdapterRegistry() { + return getAdapter().getReactiveAdapterRegistry(); + } + + private Stream getInitBinderMethods(HandlerMethod handlerMethod) { + return getAdapter().getInitBinderMethods(handlerMethod.getBeanType()).stream(); + } + + private Stream getModelAttributeMethods(HandlerMethod handlerMethod) { + return getAdapter().getModelAttributeMethods(handlerMethod.getBeanType()).stream(); + } + + + /** + * Create and initialize a BindingContext for the current request. + * @param handlerMethod the request handling method + * @param exchange the current exchange + * @return Mono with the BindingContext instance + */ + public Mono createBindingContext(HandlerMethod handlerMethod, + ServerWebExchange exchange) { + + List invocableMethods = getInitBinderMethods(handlerMethod) + .map(method -> { + Object bean = handlerMethod.getBean(); + SyncInvocableHandlerMethod invocable = new SyncInvocableHandlerMethod(bean, method); + invocable.setSyncArgumentResolvers(getInitBinderArgumentResolvers()); + return invocable; + }) + .collect(Collectors.toList()); + + BindingContext bindingContext = + new InitBinderBindingContext(getBindingInitializer(), invocableMethods); + + return initModel(handlerMethod, bindingContext, exchange).then(Mono.just(bindingContext)); + } + + @SuppressWarnings("Convert2MethodRef") + private Mono initModel(HandlerMethod handlerMethod, BindingContext context, + ServerWebExchange exchange) { + + List> resultMonos = getModelAttributeMethods(handlerMethod) + .map(method -> { + Object bean = handlerMethod.getBean(); + InvocableHandlerMethod invocable = new InvocableHandlerMethod(bean, method); + invocable.setArgumentResolvers(getArgumentResolvers()); + return invocable; + }) + .map(invocable -> invocable.invoke(exchange, context)) + .collect(Collectors.toList()); + + return Mono + .when(resultMonos, resultArr -> processModelMethodMonos(resultArr, context)) + .then(voidMonos -> Mono.when(voidMonos)); + } + + private List> processModelMethodMonos(Object[] resultArr, BindingContext context) { + return Arrays.stream(resultArr) + .map(result -> processModelMethodResult((HandlerResult) result, context)) + .collect(Collectors.toList()); + } + + private Mono processModelMethodResult(HandlerResult result, BindingContext context) { + Object value = result.getReturnValue().orElse(null); + if (value == null) { + return Mono.empty(); + } + + ResolvableType type = result.getReturnType(); + ReactiveAdapter adapter = getAdapterRegistry().getAdapterFrom(type.getRawClass(), value); + Class valueType = (adapter != null ? type.resolveGeneric(0) : type.resolve()); + + if (Void.class.equals(valueType) || void.class.equals(valueType)) { + return (adapter != null ? adapter.toMono(value) : Mono.empty()); + } + + String name = getAttributeName(valueType, result.getReturnTypeSource()); + context.getModel().asMap().putIfAbsent(name, value); + return Mono.empty(); + } + + private String getAttributeName(Class valueType, MethodParameter parameter) { + Method method = parameter.getMethod(); + ModelAttribute annot = AnnotatedElementUtils.findMergedAnnotation(method, ModelAttribute.class); + if (annot != null && StringUtils.hasText(annot.value())) { + return annot.value(); + } + // TODO: Conventions does not deal with async wrappers + return ClassUtils.getShortNameAsProperty(valueType); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolver.java index 702f26bd28..9183c69e96 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolver.java @@ -60,20 +60,28 @@ import org.springframework.web.server.ServerWebExchange; */ public class ModelAttributeMethodArgumentResolver implements HandlerMethodArgumentResolver { - private final boolean useDefaultResolution; - private final ReactiveAdapterRegistry adapterRegistry; + private final boolean useDefaultResolution; + /** * Class constructor. + * @param registry for adapting to other reactive types from and to Mono + */ + public ModelAttributeMethodArgumentResolver(ReactiveAdapterRegistry registry) { + this(registry, false); + } + + /** + * Class constructor with a default resolution mode flag. + * @param registry for adapting to other reactive types from and to Mono * @param useDefaultResolution if "true", non-simple method arguments and * return values are considered model attributes with or without a * {@code @ModelAttribute} annotation present. - * @param registry for adapting to other reactive types from and to Mono */ - public ModelAttributeMethodArgumentResolver(boolean useDefaultResolution, - ReactiveAdapterRegistry registry) { + public ModelAttributeMethodArgumentResolver(ReactiveAdapterRegistry registry, + boolean useDefaultResolution) { Assert.notNull(registry, "'ReactiveAdapterRegistry' is required."); this.useDefaultResolution = useDefaultResolution; diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java index d8e8d7349b..c5508ee916 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerAdapter.java @@ -42,6 +42,8 @@ import org.springframework.http.codec.DecoderHttpMessageReader; import org.springframework.http.codec.HttpMessageReader; import org.springframework.util.ReflectionUtils; import org.springframework.web.bind.annotation.InitBinder; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.support.WebBindingInitializer; import org.springframework.web.method.HandlerMethod; import org.springframework.web.method.annotation.ExceptionHandlerMethodResolver; @@ -51,7 +53,6 @@ import org.springframework.web.reactive.result.method.BindingContext; import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; import org.springframework.web.reactive.result.method.InvocableHandlerMethod; import org.springframework.web.reactive.result.method.SyncHandlerMethodArgumentResolver; -import org.springframework.web.reactive.result.method.SyncInvocableHandlerMethod; import org.springframework.web.server.ServerWebExchange; /** @@ -82,8 +83,12 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, BeanFactory private ConfigurableBeanFactory beanFactory; + private final BindingContextFactory bindingContextFactory = new BindingContextFactory(this); + private final Map, Set> initBinderCache = new ConcurrentHashMap<>(64); + private final Map, Set> modelAttributeCache = new ConcurrentHashMap<>(64); + private final Map, ExceptionHandlerMethodResolver> exceptionHandlerCache = new ConcurrentHashMap<>(64); @@ -225,11 +230,12 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, BeanFactory List resolvers = new ArrayList<>(); // Annotation-based argument resolution - resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), false)); + resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory())); resolvers.add(new RequestParamMapMethodArgumentResolver()); resolvers.add(new PathVariableMethodArgumentResolver(getBeanFactory())); resolvers.add(new PathVariableMapMethodArgumentResolver()); resolvers.add(new RequestBodyArgumentResolver(getMessageReaders(), getReactiveAdapterRegistry())); + resolvers.add(new ModelAttributeMethodArgumentResolver(getReactiveAdapterRegistry())); resolvers.add(new RequestHeaderMethodArgumentResolver(getBeanFactory())); resolvers.add(new RequestHeaderMapMethodArgumentResolver()); resolvers.add(new CookieValueMethodArgumentResolver(getBeanFactory())); @@ -240,6 +246,7 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, BeanFactory // Type-based argument resolution resolvers.add(new HttpEntityArgumentResolver(getMessageReaders(), getReactiveAdapterRegistry())); resolvers.add(new ModelArgumentResolver()); + resolvers.add(new ErrorsMethodArgumentResolver(getReactiveAdapterRegistry())); resolvers.add(new ServerWebExchangeArgumentResolver()); resolvers.add(new PrincipalArgumentResolver()); resolvers.add(new WebSessionArgumentResolver()); @@ -251,6 +258,7 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, BeanFactory // Catch-all resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), true)); + resolvers.add(new ModelAttributeMethodArgumentResolver(getReactiveAdapterRegistry(), true)); return resolvers; } @@ -290,34 +298,31 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, BeanFactory @Override public Mono handle(ServerWebExchange exchange, Object handler) { + HandlerMethod handlerMethod = (HandlerMethod) handler; InvocableHandlerMethod invocable = new InvocableHandlerMethod(handlerMethod); invocable.setArgumentResolvers(getArgumentResolvers()); - BindingContext bindingContext = getBindingContext(handlerMethod); - return invocable.invoke(exchange, bindingContext) - .map(result -> result.setExceptionHandler( - ex -> handleException(ex, handlerMethod, bindingContext, exchange))) - .otherwise(ex -> handleException( - ex, handlerMethod, bindingContext, exchange)); + + Mono bindingContextMono = + this.bindingContextFactory.createBindingContext(handlerMethod, exchange); + + return bindingContextMono.then(bindingContext -> + invocable.invoke(exchange, bindingContext) + .doOnNext(result -> result.setExceptionHandler( + ex -> handleException(ex, handlerMethod, bindingContext, exchange))) + .otherwise(ex -> handleException( + ex, handlerMethod, bindingContext, exchange))); } - private BindingContext getBindingContext(HandlerMethod handlerMethod) { - Class handlerType = handlerMethod.getBeanType(); - Set methods = this.initBinderCache.get(handlerType); - if (methods == null) { - methods = MethodIntrospector.selectMethods(handlerType, INIT_BINDER_METHODS); - this.initBinderCache.put(handlerType, methods); - } - List initBinderMethods = new ArrayList<>(); - for (Method method : methods) { - Object bean = handlerMethod.getBean(); - SyncInvocableHandlerMethod initBinderMethod = new SyncInvocableHandlerMethod(bean, method); - initBinderMethod.setSyncArgumentResolvers(getInitBinderArgumentResolvers()); - initBinderMethods.add(initBinderMethod); - } - return new InitBinderBindingContext(getWebBindingInitializer(), initBinderMethods); + Set getInitBinderMethods(Class handlerType) { + return this.initBinderCache.computeIfAbsent(handlerType, aClass -> + MethodIntrospector.selectMethods(handlerType, INIT_BINDER_METHODS)); } + Set getModelAttributeMethods(Class handlerType) { + return this.modelAttributeCache.computeIfAbsent(handlerType, aClass -> + MethodIntrospector.selectMethods(handlerType, MODEL_ATTRIBUTE_METHODS)); + } private Mono handleException(Throwable ex, HandlerMethod handlerMethod, BindingContext bindingContext, ServerWebExchange exchange) { @@ -360,7 +365,14 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, BeanFactory /** * MethodFilter that matches {@link InitBinder @InitBinder} methods. */ - public static final ReflectionUtils.MethodFilter INIT_BINDER_METHODS = - method -> AnnotationUtils.findAnnotation(method, InitBinder.class) != null; + public static final ReflectionUtils.MethodFilter INIT_BINDER_METHODS = method -> + AnnotationUtils.findAnnotation(method, InitBinder.class) != null; + + /** + * MethodFilter that matches {@link ModelAttribute @ModelAttribute} methods. + */ + public static final ReflectionUtils.MethodFilter MODEL_ATTRIBUTE_METHODS = method -> + (AnnotationUtils.findAnnotation(method, RequestMapping.class) == null) && + (AnnotationUtils.findAnnotation(method, ModelAttribute.class) != null); } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestParamMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestParamMethodArgumentResolver.java index 368f52ffb1..d1c32325eb 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestParamMethodArgumentResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestParamMethodArgumentResolver.java @@ -20,8 +20,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import reactor.core.publisher.Mono; - import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.core.MethodParameter; @@ -57,6 +55,17 @@ public class RequestParamMethodArgumentResolver extends AbstractNamedValueSyncAr /** + * Class constructor. + * @param beanFactory a bean factory used for resolving ${...} placeholder + * and #{...} SpEL expressions in default values, or {@code null} if default + * values are not expected to contain expressions + */ + public RequestParamMethodArgumentResolver(ConfigurableBeanFactory beanFactory) { + this(beanFactory, false); + } + + /** + * Class constructor with a default resolution mode flag. * @param beanFactory a bean factory used for resolving ${...} placeholder * and #{...} SpEL expressions in default values, or {@code null} if default * values are not expected to contain expressions @@ -65,7 +74,9 @@ public class RequestParamMethodArgumentResolver extends AbstractNamedValueSyncAr * is treated as a request parameter even if it isn't annotated, the * request parameter name is derived from the method parameter name. */ - public RequestParamMethodArgumentResolver(ConfigurableBeanFactory beanFactory, boolean useDefaultResolution) { + public RequestParamMethodArgumentResolver(ConfigurableBeanFactory beanFactory, + boolean useDefaultResolution) { + super(beanFactory); this.useDefaultResolution = useDefaultResolution; } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java index ed96f42558..64c53f9aca 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java @@ -191,15 +191,11 @@ public class ViewResolutionResultHandler extends AbstractHandlerResultHandler ReactiveAdapter adapter = getAdapterRegistry().getAdapterFrom(parameterType.getRawClass(), optional); if (adapter != null) { - if (optional.isPresent()) { - Mono converted = adapter.toMono(optional); - returnValueMono = converted.map(o -> o); - } - else { - returnValueMono = Mono.empty(); - } - elementType = adapter.getDescriptor().isNoValue() ? - ResolvableType.forClass(Void.class) : parameterType.getGeneric(0); + returnValueMono = optional + .map(value -> adapter.toMono(value).cast(Object.class)) + .orElse(Mono.empty()); + elementType = !adapter.getDescriptor().isNoValue() ? + parameterType.getGeneric(0) : ResolvableType.forClass(Void.class); } else { returnValueMono = Mono.justOrEmpty(result.getReturnValue()); diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java index 0b42ace3a7..9020322c24 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java @@ -108,28 +108,13 @@ public class DispatcherHandlerErrorTests { .verify(); } - @Test - public void unknownMethodArgumentType() throws Exception { - this.request.setUri("/unknown-argument-type"); - Mono publisher = this.dispatcherHandler.handle(this.exchange); - - StepVerifier.create(publisher) - .consumeErrorWith(error -> { - assertThat(error, instanceOf(IllegalStateException.class)); - assertThat(error.getMessage(), startsWith("No resolver for argument [0]")); - }) - .verify(); - } - @Test public void controllerReturnsMonoError() throws Exception { this.request.setUri("/error-signal"); Mono publisher = this.dispatcherHandler.handle(this.exchange); StepVerifier.create(publisher) - .consumeErrorWith(error -> { - assertSame(EXCEPTION, error); - }) + .consumeErrorWith(error -> assertSame(EXCEPTION, error)) .verify(); } @@ -138,10 +123,8 @@ public class DispatcherHandlerErrorTests { this.request.setUri("/raise-exception"); Mono publisher = this.dispatcherHandler.handle(this.exchange); - StepVerifier.create(publisher) - .consumeErrorWith(error -> { - assertSame(EXCEPTION, error); - }) + StepVerifier.create(publisher) + .consumeErrorWith(error -> assertSame(EXCEPTION, error)) .verify(); } @@ -164,9 +147,7 @@ public class DispatcherHandlerErrorTests { Mono publisher = this.dispatcherHandler.handle(this.exchange); StepVerifier.create(publisher) - .consumeErrorWith(error -> { - assertThat(error, instanceOf(NotAcceptableStatusException.class)); - }) + .consumeErrorWith(error -> assertThat(error, instanceOf(NotAcceptableStatusException.class))) .verify(); } @@ -226,10 +207,6 @@ public class DispatcherHandlerErrorTests { @SuppressWarnings("unused") private static class TestController { - @RequestMapping("/unknown-argument-type") - public void unknownArgumentType(Foo arg) { - } - @RequestMapping("/error-signal") @ResponseBody public Publisher errorSignal() { diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/BindingContextFactoryTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/BindingContextFactoryTests.java new file mode 100644 index 0000000000..68014671dc --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/BindingContextFactoryTests.java @@ -0,0 +1,182 @@ +/* + * 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.reactive.result.method.annotation; + +import java.util.Collections; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import rx.Single; + +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.test.MockServerHttpResponse; +import org.springframework.ui.Model; +import org.springframework.util.ObjectUtils; +import org.springframework.validation.Validator; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.WebExchangeDataBinder; +import org.springframework.web.bind.annotation.InitBinder; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.reactive.config.WebReactiveConfigurationSupport; +import org.springframework.web.reactive.result.ResolvableMethod; +import org.springframework.web.reactive.result.method.BindingContext; +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 org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link BindingContextFactory}. + * @author Rossen Stoyanchev + */ +public class BindingContextFactoryTests { + + private BindingContextFactory contextFactory; + + private ServerWebExchange exchange; + + + @Before + public void setUp() throws Exception { + WebReactiveConfigurationSupport configurationSupport = new WebReactiveConfigurationSupport(); + configurationSupport.setApplicationContext(new StaticApplicationContext()); + RequestMappingHandlerAdapter adapter = configurationSupport.requestMappingHandlerAdapter(); + adapter.afterPropertiesSet(); + this.contextFactory = new BindingContextFactory(adapter); + + MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, "/path"); + MockServerHttpResponse response = new MockServerHttpResponse(); + WebSessionManager manager = new DefaultWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, response, manager); + } + + + @SuppressWarnings("unchecked") + @Test + public void basic() throws Exception { + + Validator validator = mock(Validator.class); + TestController controller = new TestController(validator); + + HandlerMethod handlerMethod = ResolvableMethod.on(controller) + .annotated(RequestMapping.class) + .resolveHandlerMethod(); + + BindingContext bindingContext = + this.contextFactory.createBindingContext(handlerMethod, this.exchange) + .blockMillis(5000); + + WebExchangeDataBinder binder = bindingContext.createDataBinder(this.exchange, "name"); + assertEquals(Collections.singletonList(validator), binder.getValidators()); + + Map model = bindingContext.getModel().asMap(); + assertEquals(5, model.size()); + + Object value = model.get("bean"); + assertEquals("Bean", ((TestBean) value).getName()); + + value = model.get("monoBean"); + assertEquals("Mono Bean", ((Mono) value).blockMillis(5000).getName()); + + value = model.get("singleBean"); + assertEquals("Single Bean", ((Single) value).toBlocking().value().getName()); + + value = model.get("voidMethodBean"); + assertEquals("Void Method Bean", ((TestBean) value).getName()); + + value = model.get("voidMonoMethodBean"); + assertEquals("Void Mono Method Bean", ((TestBean) value).getName()); + } + + + @SuppressWarnings("unused") + private static class TestController { + + private Validator[] validators; + + + public TestController(Validator... validators) { + this.validators = validators; + } + + + @InitBinder + public void initDataBinder(WebDataBinder dataBinder) { + if (!ObjectUtils.isEmpty(this.validators)) { + dataBinder.addValidators(this.validators); + } + } + + @ModelAttribute("bean") + public TestBean returnValue() { + return new TestBean("Bean"); + } + + @ModelAttribute("monoBean") + public Mono returnValueMono() { + return Mono.just(new TestBean("Mono Bean")); + } + + @ModelAttribute("singleBean") + public Single returnValueSingle() { + return Single.just(new TestBean("Single Bean")); + } + + @ModelAttribute + public void voidMethodBean(Model model) { + model.addAttribute("voidMethodBean", new TestBean("Void Method Bean")); + } + + @ModelAttribute + public Mono voidMonoMethodBean(Model model) { + return Mono.just("Void Mono Method Bean") + .doOnNext(name -> model.addAttribute("voidMonoMethodBean", new TestBean(name))) + .then(); + } + + @RequestMapping + public void handle() {} + } + + private static class TestBean { + + private final String name; + + TestBean(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + @Override + public String toString() { + return "TestBean[name=" + this.name + "]"; + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolverTests.java index 5efef3e3fc..19061df287 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolverTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolverTests.java @@ -91,7 +91,7 @@ public class ModelAttributeMethodArgumentResolverTests { public void supports() throws Exception { ModelAttributeMethodArgumentResolver resolver = - new ModelAttributeMethodArgumentResolver(false, new ReactiveAdapterRegistry()); + new ModelAttributeMethodArgumentResolver(new ReactiveAdapterRegistry(), false); ResolvableType type = forClass(Foo.class); assertTrue(resolver.supportsParameter(parameter(type))); @@ -110,7 +110,7 @@ public class ModelAttributeMethodArgumentResolverTests { public void supportsWithDefaultResolution() throws Exception { ModelAttributeMethodArgumentResolver resolver = - new ModelAttributeMethodArgumentResolver(true, new ReactiveAdapterRegistry()); + new ModelAttributeMethodArgumentResolver(new ReactiveAdapterRegistry(), true); ResolvableType type = forClass(Foo.class); assertTrue(resolver.supportsParameter(parameterNotAnnotated(type))); @@ -282,7 +282,7 @@ public class ModelAttributeMethodArgumentResolverTests { private ModelAttributeMethodArgumentResolver createResolver() { - return new ModelAttributeMethodArgumentResolver(false, new ReactiveAdapterRegistry()); + return new ModelAttributeMethodArgumentResolver(new ReactiveAdapterRegistry()); } private MethodParameter parameter(ResolvableType type) { diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingDataBindingIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingDataBindingIntegrationTests.java index a85acf2122..963d750bbc 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingDataBindingIntegrationTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingDataBindingIntegrationTests.java @@ -18,8 +18,10 @@ package org.springframework.web.reactive.result.method.annotation; import java.text.SimpleDateFormat; import java.util.Date; +import java.util.Optional; import org.junit.Test; +import reactor.core.publisher.Mono; import org.springframework.beans.propertyeditors.CustomDateEditor; import org.springframework.context.ApplicationContext; @@ -27,12 +29,17 @@ import org.springframework.context.annotation.AnnotationConfigApplicationContext import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; -import org.springframework.stereotype.Controller; +import org.springframework.http.MediaType; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.validation.Errors; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.InitBinder; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.reactive.config.EnableWebReactive; import static org.junit.Assert.assertEquals; @@ -62,6 +69,18 @@ public class RequestMappingDataBindingIntegrationTests extends AbstractRequestMa new HttpHeaders(), null, String.class).getBody()); } + @Test + public void handleForm() throws Exception { + + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("name", "George"); + formData.add("age", "5"); + + assertEquals("Processed form: Foo[id=1, name='George', age=5]", + performPost("/foos/1", MediaType.APPLICATION_FORM_URLENCODED, formData, + MediaType.TEXT_PLAIN, String.class).getBody()); + } + @Configuration @EnableWebReactive @@ -70,21 +89,73 @@ public class RequestMappingDataBindingIntegrationTests extends AbstractRequestMa static class WebConfig { } - @Controller - @SuppressWarnings("unused") + @RestController + @SuppressWarnings({"unused", "OptionalUsedAsFieldOrParameterType"}) private static class TestController { @InitBinder - public void initBinder(WebDataBinder dataBinder, @RequestParam("date-pattern") String pattern) { - CustomDateEditor dateEditor = new CustomDateEditor(new SimpleDateFormat(pattern), false); - dataBinder.registerCustomEditor(Date.class, dateEditor); + public void initBinder(WebDataBinder binder, + @RequestParam("date-pattern") Optional optionalPattern) { + + optionalPattern.ifPresent(pattern -> { + CustomDateEditor dateEditor = new CustomDateEditor(new SimpleDateFormat(pattern), false); + binder.registerCustomEditor(Date.class, dateEditor); + }); } @PostMapping("/date-param") - @ResponseBody public String handleDateParam(@RequestParam Date date) { return "Processed date!"; } + + @ModelAttribute + public Mono addFooAttribute(@PathVariable("id") Optional optiponalId) { + return optiponalId.map(id -> Mono.just(new Foo(id))).orElse(Mono.empty()); + } + + @PostMapping("/foos/{id}") + public String handleForm(@ModelAttribute Foo foo, Errors errors) { + return (errors.hasErrors() ? + "Form not processed" : "Processed form: " + foo); + } + } + + private static class Foo { + + private final Long id; + + private String name; + + private int age; + + public Foo(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getAge() { + return this.age; + } + + public void setAge(int age) { + this.age = age; + } + + @Override + public String toString() { + return "Foo[id=" + this.id + ", name='" + this.name + "', age=" + this.age + "]"; + } } }