diff --git a/spring-context/src/main/java/org/springframework/ui/ConcurrentModel.java b/spring-context/src/main/java/org/springframework/ui/ConcurrentModel.java new file mode 100644 index 00000000000..aaccfd52324 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/ui/ConcurrentModel.java @@ -0,0 +1,149 @@ +/* + * 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.ui; + +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.core.Conventions; +import org.springframework.util.Assert; + +/** + * Implementation of {@link Model} based on a {@link ConcurrentHashMap} for use + * in concurrent scenarios. Exposed to handler methods by Spring Web Reactive + * typically via a declaration of the {@link Model} interface. There is typically + * no need to create it within user code. If necessary a controller method can + * return a regular {@code java.util.Map}, or more likely a + * {@code java.util.ConcurrentMap}. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +@SuppressWarnings("serial") +public class ConcurrentModel extends ConcurrentHashMap implements Model { + + /** + * Construct a new, empty {@code ConcurrentModel}. + */ + public ConcurrentModel() { + } + + /** + * Construct a new {@code ModelMap} containing the supplied attribute + * under the supplied name. + * @see #addAttribute(String, Object) + */ + public ConcurrentModel(String attributeName, Object attributeValue) { + addAttribute(attributeName, attributeValue); + } + + /** + * Construct a new {@code ModelMap} containing the supplied attribute. + * Uses attribute name generation to generate the key for the supplied model + * object. + * @see #addAttribute(Object) + */ + public ConcurrentModel(Object attributeValue) { + addAttribute(attributeValue); + } + + + /** + * Add the supplied attribute under the supplied name. + * @param attributeName the name of the model attribute (never {@code null}) + * @param attributeValue the model attribute value (can be {@code null}) + */ + public ConcurrentModel addAttribute(String attributeName, Object attributeValue) { + Assert.notNull(attributeName, "Model attribute name must not be null"); + put(attributeName, attributeValue); + return this; + } + + /** + * Add the supplied attribute to this {@code Map} using a + * {@link org.springframework.core.Conventions#getVariableName generated name}. + *

Note: Empty {@link Collection Collections} are not added to + * the model when using this method because we cannot correctly determine + * the true convention name. View code should check for {@code null} rather + * than for empty collections as is already done by JSTL tags. + * @param attributeValue the model attribute value (never {@code null}) + */ + public ConcurrentModel addAttribute(Object attributeValue) { + Assert.notNull(attributeValue, "Model object must not be null"); + if (attributeValue instanceof Collection && ((Collection) attributeValue).isEmpty()) { + return this; + } + return addAttribute(Conventions.getVariableName(attributeValue), attributeValue); + } + + /** + * Copy all attributes in the supplied {@code Collection} into this + * {@code Map}, using attribute name generation for each element. + * @see #addAttribute(Object) + */ + public ConcurrentModel addAllAttributes(Collection attributeValues) { + if (attributeValues != null) { + for (Object attributeValue : attributeValues) { + addAttribute(attributeValue); + } + } + return this; + } + + /** + * Copy all attributes in the supplied {@code Map} into this {@code Map}. + * @see #addAttribute(String, Object) + */ + public ConcurrentModel addAllAttributes(Map attributes) { + if (attributes != null) { + putAll(attributes); + } + return this; + } + + /** + * Copy all attributes in the supplied {@code Map} into this {@code Map}, + * with existing objects of the same name taking precedence (i.e. not getting + * replaced). + */ + public ConcurrentModel mergeAttributes(Map attributes) { + if (attributes != null) { + for (Map.Entry entry : attributes.entrySet()) { + String key = entry.getKey(); + if (!containsKey(key)) { + put(key, entry.getValue()); + } + } + } + return this; + } + + /** + * Does this model contain an attribute of the given name? + * @param attributeName the name of the model attribute (never {@code null}) + * @return whether this model contains a corresponding attribute + */ + public boolean containsAttribute(String attributeName) { + return containsKey(attributeName); + } + + @Override + public Map asMap() { + return this; + } + +} diff --git a/spring-context/src/main/java/org/springframework/validation/support/BindingAwareConcurrentModel.java b/spring-context/src/main/java/org/springframework/validation/support/BindingAwareConcurrentModel.java new file mode 100644 index 00000000000..201160c86c5 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/validation/support/BindingAwareConcurrentModel.java @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2015 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.validation.support; + +import java.util.Map; + +import org.springframework.ui.ConcurrentModel; +import org.springframework.validation.BindingResult; + +/** + * Sub-class of {@link ConcurrentModel} that automatically removes + * the {@link BindingResult} object when its corresponding + * target attribute is replaced through regular {@link Map} operations. + * + *

This is the class exposed to controller methods by Spring Web Reactive, + * typically consumed through a declaration of the + * {@link org.springframework.ui.Model} interface. There is typically + * no need to create it within user code. If necessary a controller method can + * return a regular {@code java.util.Map}, or more likely a + * {@code java.util.ConcurrentMap}. + * + * @author Rossen Stoyanchev + * @since 5.0 + * @see BindingResult + */ +@SuppressWarnings("serial") +public class BindingAwareConcurrentModel extends ConcurrentModel { + + @Override + public Object put(String key, Object value) { + removeBindingResultIfNecessary(key, value); + return super.put(key, value); + } + + @Override + public void putAll(Map map) { + map.entrySet().forEach(e -> removeBindingResultIfNecessary(e.getKey(), e.getValue())); + super.putAll(map); + } + + private void removeBindingResultIfNecessary(String key, Object value) { + if (!key.startsWith(BindingResult.MODEL_KEY_PREFIX)) { + String resultKey = BindingResult.MODEL_KEY_PREFIX + key; + BindingResult result = (BindingResult) get(resultKey); + if (result != null && result.getTarget() != value) { + remove(resultKey); + } + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java index 910426d2403..b004a3faab5 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java @@ -23,12 +23,12 @@ import reactor.core.publisher.Mono; import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; -import org.springframework.ui.ExtendedModelMap; -import org.springframework.ui.ModelMap; +import org.springframework.ui.Model; import org.springframework.util.Assert; +import org.springframework.web.reactive.result.method.BindingContext; /** - * Represent the result of the invocation of a handler. + * Represent the result of the invocation of a handler or a handler method. * * @author Rossen Stoyanchev * @since 5.0 @@ -37,12 +37,11 @@ public class HandlerResult { private final Object handler; - @SuppressWarnings("OptionalUsedAsFieldOrParameterType") - private final Optional returnValue; + private final Object returnValue; private final ResolvableType returnType; - private final ModelMap model; + private final BindingContext bindingContext; private Function> exceptionHandler; @@ -62,15 +61,17 @@ public class HandlerResult { * @param handler the handler that handled the request * @param returnValue the return value from the handler possibly {@code null} * @param returnType the return value type - * @param model the model used for request handling + * @param context the binding context used for request handling */ - public HandlerResult(Object handler, Object returnValue, MethodParameter returnType, ModelMap model) { + public HandlerResult(Object handler, Object returnValue, MethodParameter returnType, + BindingContext context) { + Assert.notNull(handler, "'handler' is required"); Assert.notNull(returnType, "'returnType' is required"); this.handler = handler; - this.returnValue = Optional.ofNullable(returnValue); + this.returnValue = returnValue; this.returnType = ResolvableType.forMethodParameter(returnType); - this.model = (model != null ? model : new ExtendedModelMap()); + this.bindingContext = (context != null ? context : new BindingContext()); } @@ -85,7 +86,7 @@ public class HandlerResult { * Return the value returned from the handler wrapped as {@link Optional}. */ public Optional getReturnValue() { - return this.returnValue; + return Optional.ofNullable(this.returnValue); } /** @@ -104,11 +105,18 @@ public class HandlerResult { } /** - * Return the model used during request handling with attributes that may be - * used to render HTML templates with. + * Return the BindingContext used for request handling. */ - public ModelMap getModel() { - return this.model; + public BindingContext getBindingContext() { + return this.bindingContext; + } + + /** + * Return the model used for request handling. This is a shortcut for + * {@code getBindingContext().getModel()}. + */ + public Model getModel() { + return this.bindingContext.getModel(); } /** diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/BindingContext.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/BindingContext.java index f699df1e25a..6d74469000f 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/BindingContext.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/BindingContext.java @@ -15,9 +15,8 @@ */ package org.springframework.web.reactive.result.method; -import org.springframework.beans.TypeConverter; -import org.springframework.ui.ModelMap; -import org.springframework.validation.support.BindingAwareModelMap; +import org.springframework.ui.Model; +import org.springframework.validation.support.BindingAwareConcurrentModel; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.WebExchangeDataBinder; import org.springframework.web.bind.support.WebBindingInitializer; @@ -33,12 +32,10 @@ import org.springframework.web.server.ServerWebExchange; */ public class BindingContext { - private final ModelMap model = new BindingAwareModelMap(); + private final Model model = new BindingAwareConcurrentModel(); private final WebBindingInitializer initializer; - private final TypeConverter simpleValueTypeConverter; - public BindingContext() { this(null); @@ -46,7 +43,6 @@ public class BindingContext { public BindingContext(WebBindingInitializer initializer) { this.initializer = initializer; - this.simpleValueTypeConverter = initTypeConverter(initializer); } private static WebExchangeDataBinder initTypeConverter(WebBindingInitializer initializer) { @@ -61,7 +57,7 @@ public class BindingContext { /** * Return the default model. */ - public ModelMap getModel() { + public Model getModel() { return this.model; } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java index 3ad4aaffb6f..d82f9faef8e 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/InvocableHandlerMethod.java @@ -32,7 +32,6 @@ import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.GenericTypeResolver; import org.springframework.core.MethodParameter; import org.springframework.core.ParameterNameDiscoverer; -import org.springframework.ui.ModelMap; import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.ReflectionUtils; @@ -101,9 +100,8 @@ public class InvocableHandlerMethod extends HandlerMethod { return resolveArguments(exchange, bindingContext, providedArgs).then(args -> { try { Object value = doInvoke(args); - ModelMap model = bindingContext.getModel(); - HandlerResult handlerResult = new HandlerResult(this, value, getReturnType(), model); - return Mono.just(handlerResult); + HandlerResult result = new HandlerResult(this, value, getReturnType(), bindingContext); + return Mono.just(result); } catch (InvocationTargetException ex) { return Mono.error(ex.getTargetException()); diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java index 5bb4b236a96..58414125575 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractMessageReaderArgumentResolver.java @@ -43,6 +43,7 @@ import org.springframework.web.reactive.result.method.BindingContext; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.UnsupportedMediaTypeStatusException; +import org.springframework.web.bind.WebExchangeBindException; /** * Abstract base class for argument resolvers that resolve method arguments @@ -216,7 +217,7 @@ public abstract class AbstractMessageReaderArgumentResolver { WebExchangeDataBinder binder = binding.createDataBinder(exchange, target, name); binder.validate(validationHints); if (binder.getBindingResult().hasErrors()) { - throw new ServerWebInputException("Validation failed", param); + throw new WebExchangeBindException(param, binder.getBindingResult()); } } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractNamedValueArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractNamedValueArgumentResolver.java index 30a370d6544..3a38916ab25 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractNamedValueArgumentResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/AbstractNamedValueArgumentResolver.java @@ -27,7 +27,7 @@ import org.springframework.beans.factory.config.BeanExpressionContext; import org.springframework.beans.factory.config.BeanExpressionResolver; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.core.MethodParameter; -import org.springframework.ui.ModelMap; +import org.springframework.ui.Model; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.ValueConstants; import org.springframework.web.reactive.result.method.BindingContext; @@ -87,7 +87,7 @@ public abstract class AbstractNamedValueArgumentResolver implements HandlerMetho "Specified name must not resolve to null: [" + namedValueInfo.name + "]")); } - ModelMap model = bindingContext.getModel(); + Model model = bindingContext.getModel(); return resolveName(resolvedName.toString(), nestedParameter, exchange) .map(arg -> { @@ -186,7 +186,7 @@ public abstract class AbstractNamedValueArgumentResolver implements HandlerMetho } private Mono getDefaultValue(NamedValueInfo namedValueInfo, MethodParameter parameter, - BindingContext bindingContext, ModelMap model, ServerWebExchange exchange) { + BindingContext bindingContext, Model model, ServerWebExchange exchange) { Object value = null; try { @@ -263,7 +263,7 @@ public abstract class AbstractNamedValueArgumentResolver implements HandlerMetho */ @SuppressWarnings("UnusedParameters") protected void handleResolvedValue(Object arg, String name, MethodParameter parameter, - ModelMap model, ServerWebExchange exchange) { + Model model, ServerWebExchange exchange) { } 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 00000000000..4d77aa9e1e9 --- /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/ErrorsMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ErrorsMethodArgumentResolver.java new file mode 100644 index 00000000000..8bba67f9181 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ErrorsMethodArgumentResolver.java @@ -0,0 +1,118 @@ +/* + * 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 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.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import org.springframework.validation.BindingResult; +import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.reactive.result.method.BindingContext; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; + +/** + * Resolve {@link Errors} or {@link BindingResult} method arguments. + * An {@code Errors} argument is expected to appear immediately after the + * model attribute in the method signature. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class ErrorsMethodArgumentResolver implements HandlerMethodArgumentResolver { + + private final ReactiveAdapterRegistry adapterRegistry; + + + /** + * Class constructor. + * @param registry for adapting to other reactive types from and to Mono + */ + public ErrorsMethodArgumentResolver(ReactiveAdapterRegistry registry) { + Assert.notNull(registry, "'ReactiveAdapterRegistry' is required."); + this.adapterRegistry = registry; + } + + + /** + * Return the configured {@link ReactiveAdapterRegistry}. + */ + public ReactiveAdapterRegistry getAdapterRegistry() { + return this.adapterRegistry; + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + Class clazz = parameter.getParameterType(); + return Errors.class.isAssignableFrom(clazz); + } + + + @Override + public Mono resolveArgument(MethodParameter parameter, BindingContext context, + ServerWebExchange exchange) { + + String name = getModelAttributeName(parameter); + Object errors = context.getModel().asMap().get(BindingResult.MODEL_KEY_PREFIX + name); + + Mono errorsMono; + if (Mono.class.isAssignableFrom(errors.getClass())) { + errorsMono = (Mono) errors; + } + else if (Errors.class.isAssignableFrom(errors.getClass())) { + errorsMono = Mono.just(errors); + } + else { + throw new IllegalStateException( + "Unexpected Errors/BindingResult type: " + errors.getClass().getName()); + } + + return errorsMono.cast(Object.class); + } + + private String getModelAttributeName(MethodParameter parameter) { + + Assert.isTrue(parameter.getParameterIndex() > 0, + "Errors argument must be immediately after a model attribute argument."); + + int index = parameter.getParameterIndex() - 1; + MethodParameter attributeParam = new MethodParameter(parameter.getMethod(), index); + Class attributeType = attributeParam.getParameterType(); + + ResolvableType type = ResolvableType.forMethodParameter(attributeParam); + ReactiveAdapter adapterTo = getAdapterRegistry().getAdapterTo(type.resolve()); + + Assert.isNull(adapterTo, "Errors/BindingResult cannot be used with an async model attribute. " + + "Either declare the model attribute without the async wrapper type " + + "or handle WebExchangeBindException through the async type."); + + ModelAttribute annot = parameter.getParameterAnnotation(ModelAttribute.class); + if (annot != null && StringUtils.hasText(annot.value())) { + return annot.value(); + } + // TODO: Conventions does not deal with async wrappers + return ClassUtils.getShortNameAsProperty(attributeType); + } + +} 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 new file mode 100644 index 00000000000..9183c69e96e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolver.java @@ -0,0 +1,211 @@ +/* + * 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.annotation.Annotation; +import java.util.Map; + +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoProcessor; + +import org.springframework.beans.BeanUtils; +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ReactiveAdapter.Descriptor; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import org.springframework.validation.BindingResult; +import org.springframework.validation.Errors; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.WebExchangeBindException; +import org.springframework.web.bind.WebExchangeDataBinder; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.reactive.result.method.BindingContext; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; + +/** + * Resolve {@code @ModelAttribute} annotated method arguments. + * + *

Model attributes are sourced from the model, or created using a default + * constructor and then added to the model. Once created the attribute is + * populated via data binding to the request (form data, query params). + * Validation also may be applied if the argument is annotated with + * {@code @javax.validation.Valid} or Spring's own + * {@code @org.springframework.validation.annotation.Validated}. + * + *

When this handler is created with {@code useDefaultResolution=true} + * any non-simple type argument and return value is regarded as a model + * attribute with or without the presence of an {@code @ModelAttribute}. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class ModelAttributeMethodArgumentResolver implements HandlerMethodArgumentResolver { + + 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. + */ + public ModelAttributeMethodArgumentResolver(ReactiveAdapterRegistry registry, + boolean useDefaultResolution) { + + Assert.notNull(registry, "'ReactiveAdapterRegistry' is required."); + this.useDefaultResolution = useDefaultResolution; + this.adapterRegistry = registry; + } + + + /** + * Return the configured {@link ReactiveAdapterRegistry}. + */ + public ReactiveAdapterRegistry getAdapterRegistry() { + return this.adapterRegistry; + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + if (parameter.hasParameterAnnotation(ModelAttribute.class)) { + return true; + } + if (this.useDefaultResolution) { + Class clazz = parameter.getParameterType(); + ReactiveAdapter adapter = getAdapterRegistry().getAdapterFrom(clazz); + if (adapter != null) { + Descriptor descriptor = adapter.getDescriptor(); + if (descriptor.isNoValue() || descriptor.isMultiValue()) { + return false; + } + clazz = ResolvableType.forMethodParameter(parameter).getGeneric(0).getRawClass(); + } + return !BeanUtils.isSimpleProperty(clazz); + } + return false; + } + + @Override + public Mono resolveArgument(MethodParameter parameter, BindingContext context, + ServerWebExchange exchange) { + + ResolvableType type = ResolvableType.forMethodParameter(parameter); + ReactiveAdapter adapterTo = getAdapterRegistry().getAdapterTo(type.resolve()); + Class valueType = (adapterTo != null ? type.resolveGeneric(0) : parameter.getParameterType()); + String name = getAttributeName(valueType, parameter); + Mono valueMono = getAttributeMono(name, valueType, parameter, context, exchange); + + Map model = context.getModel().asMap(); + MonoProcessor bindingResultMono = MonoProcessor.create(); + model.put(BindingResult.MODEL_KEY_PREFIX + name, bindingResultMono); + + return valueMono.then(value -> { + WebExchangeDataBinder binder = context.createDataBinder(exchange, value, name); + return binder.bind(exchange) + .doOnError(bindingResultMono::onError) + .doOnSuccess(aVoid -> { + validateIfApplicable(binder, parameter); + BindingResult errors = binder.getBindingResult(); + model.put(BindingResult.MODEL_KEY_PREFIX + name, errors); + model.put(name, value); + bindingResultMono.onNext(errors); + }) + .then(Mono.fromCallable(() -> { + BindingResult errors = binder.getBindingResult(); + if (adapterTo != null) { + return adapterTo.fromPublisher(errors.hasErrors() ? + Mono.error(new WebExchangeBindException(parameter, errors)) : + Mono.just(value)); + } + else { + if (errors.hasErrors() && checkErrorsArgument(parameter)) { + throw new WebExchangeBindException(parameter, errors); + } + return value; + } + })); + }); + } + + private String getAttributeName(Class valueType, MethodParameter parameter) { + ModelAttribute annot = parameter.getParameterAnnotation(ModelAttribute.class); + if (annot != null && StringUtils.hasText(annot.value())) { + return annot.value(); + } + // TODO: Conventions does not deal with async wrappers + return ClassUtils.getShortNameAsProperty(valueType); + } + + private Mono getAttributeMono(String attributeName, Class attributeType, + MethodParameter param, BindingContext context, ServerWebExchange exchange) { + + Object attribute = context.getModel().asMap().get(attributeName); + if (attribute == null) { + attribute = createAttribute(attributeName, attributeType, param, context, exchange); + } + if (attribute != null) { + ReactiveAdapter adapterFrom = getAdapterRegistry().getAdapterFrom(null, attribute); + if (adapterFrom != null) { + return adapterFrom.toMono(attribute); + } + } + return Mono.justOrEmpty(attribute); + } + + protected Object createAttribute(String attributeName, Class attributeType, + MethodParameter parameter, BindingContext context, ServerWebExchange exchange) { + + return BeanUtils.instantiateClass(attributeType); + } + + protected boolean checkErrorsArgument(MethodParameter methodParam) { + int i = methodParam.getParameterIndex(); + Class[] paramTypes = methodParam.getMethod().getParameterTypes(); + return paramTypes.length <= (i + 1) || !Errors.class.isAssignableFrom(paramTypes[i + 1]); + } + + protected void validateIfApplicable(WebExchangeDataBinder binder, MethodParameter parameter) { + Annotation[] annotations = parameter.getParameterAnnotations(); + for (Annotation ann : annotations) { + Validated validAnnot = AnnotationUtils.getAnnotation(ann, Validated.class); + if (validAnnot != null || ann.annotationType().getSimpleName().startsWith("Valid")) { + Object hints = (validAnnot != null ? validAnnot.value() : AnnotationUtils.getValue(ann)); + Object hintArray = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints}); + binder.validate(hintArray); + } + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/PathVariableMethodArgumentResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/PathVariableMethodArgumentResolver.java index edc497f4d85..64f83c57001 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/PathVariableMethodArgumentResolver.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/PathVariableMethodArgumentResolver.java @@ -22,7 +22,7 @@ import java.util.Optional; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.core.MethodParameter; import org.springframework.core.convert.converter.Converter; -import org.springframework.ui.ModelMap; +import org.springframework.ui.Model; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.ValueConstants; @@ -93,7 +93,7 @@ public class PathVariableMethodArgumentResolver extends AbstractNamedValueSyncAr @Override @SuppressWarnings("unchecked") protected void handleResolvedValue(Object arg, String name, MethodParameter parameter, - ModelMap model, ServerWebExchange exchange) { + Model model, ServerWebExchange exchange) { // TODO: View.PATH_VARIABLES ? } 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 4eff18628f5..c5508ee916d 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) { @@ -329,7 +334,7 @@ public class RequestMappingHandlerAdapter implements HandlerAdapter, BeanFactory logger.debug("Invoking @ExceptionHandler method: " + invocable.getMethod()); } invocable.setArgumentResolvers(getArgumentResolvers()); - bindingContext.getModel().clear(); + bindingContext.getModel().asMap().clear(); return invocable.invoke(exchange, bindingContext, ex); } catch (Throwable invocationEx) { @@ -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 368f52ffb1d..d1c32325eb7 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 494c3128eae..64c53f9acae 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 @@ -16,20 +16,19 @@ package org.springframework.web.reactive.result.view; -import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.beans.BeanUtils; -import org.springframework.core.Conventions; -import org.springframework.core.GenericTypeResolver; import org.springframework.core.MethodParameter; import org.springframework.core.Ordered; import org.springframework.core.ReactiveAdapter; @@ -38,12 +37,16 @@ import org.springframework.core.ResolvableType; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.http.MediaType; import org.springframework.ui.Model; +import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.WebExchangeDataBinder; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.reactive.HandlerResult; import org.springframework.web.reactive.HandlerResultHandler; import org.springframework.web.reactive.accept.RequestedContentTypeResolver; import org.springframework.web.reactive.result.AbstractHandlerResultHandler; +import org.springframework.web.reactive.result.method.BindingContext; import org.springframework.web.server.NotAcceptableStatusException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.util.HttpRequestPathHelper; @@ -77,6 +80,11 @@ import org.springframework.web.util.HttpRequestPathHelper; public class ViewResolutionResultHandler extends AbstractHandlerResultHandler implements HandlerResultHandler, Ordered { + private static final Object NO_VALUE = new Object(); + + private static final Mono NO_VALUE_MONO = Mono.just(NO_VALUE); + + private final List viewResolvers = new ArrayList<>(4); private final List defaultViews = new ArrayList<>(4); @@ -172,89 +180,78 @@ public class ViewResolutionResultHandler extends AbstractHandlerResultHandler } @Override + @SuppressWarnings("unchecked") public Mono handleResult(ServerWebExchange exchange, HandlerResult result) { - Mono valueMono; + Mono returnValueMono; ResolvableType elementType; - ResolvableType returnType = result.getReturnType(); + ResolvableType parameterType = result.getReturnType(); Optional optional = result.getReturnValue(); - ReactiveAdapter adapter = getAdapterRegistry().getAdapterFrom(returnType.getRawClass(), optional); + ReactiveAdapter adapter = getAdapterRegistry().getAdapterFrom(parameterType.getRawClass(), optional); + if (adapter != null) { - if (optional.isPresent()) { - Mono converted = adapter.toMono(optional); - valueMono = converted.map(o -> o); - } - else { - valueMono = Mono.empty(); - } - elementType = adapter.getDescriptor().isNoValue() ? - ResolvableType.forClass(Void.class) : returnType.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 { - valueMono = Mono.justOrEmpty(result.getReturnValue()); - elementType = returnType; + returnValueMono = Mono.justOrEmpty(result.getReturnValue()); + elementType = parameterType; } - Mono viewMono; - if (isViewNameOrReference(elementType, result)) { - Mono viewName = getDefaultViewNameMono(exchange, result); - viewMono = valueMono.otherwiseIfEmpty(viewName); - } - else { - viewMono = valueMono.map(value -> updateModel(value, result)) - .defaultIfEmpty(result.getModel()) - .then(model -> getDefaultViewNameMono(exchange, result)); - } - Map model = result.getModel(); - return viewMono.then(view -> { - updateResponseStatus(result.getReturnTypeSource(), exchange); - if (view instanceof View) { - return ((View) view).render(model, null, exchange); - } - else if (view instanceof CharSequence) { - String viewName = view.toString(); - Locale locale = Locale.getDefault(); // TODO - return resolveAndRender(viewName, locale, model, exchange); + return returnValueMono + .otherwiseIfEmpty(exchange.isNotModified() ? Mono.empty() : NO_VALUE_MONO) + .then(returnValue -> { - } - else { - // Should not happen - return Mono.error(new IllegalStateException("Unexpected view type")); - } - }); - } + updateResponseStatus(result.getReturnTypeSource(), exchange); - private boolean isViewNameOrReference(ResolvableType elementType, HandlerResult result) { - Class clazz = elementType.getRawClass(); - return (View.class.isAssignableFrom(clazz) || - (CharSequence.class.isAssignableFrom(clazz) && !hasModelAttributeAnnotation(result))); - } + Mono> viewsMono; + Model model = result.getModel(); + Locale locale = Locale.getDefault(); // TODO - private Mono getDefaultViewNameMono(ServerWebExchange exchange, HandlerResult result) { - if (exchange.isNotModified()) { - return Mono.empty(); - } - String defaultViewName = getDefaultViewName(result, exchange); - if (defaultViewName != null) { - return Mono.just(defaultViewName); - } - else { - return Mono.error(new IllegalStateException( - "Handler [" + result.getHandler() + "] " + - "neither returned a view name nor a View object")); - } + Class clazz = elementType.getRawClass(); + if (clazz == null) { + clazz = returnValue.getClass(); + } + + if (returnValue == NO_VALUE || Void.class.equals(clazz) || void.class.equals(clazz)) { + viewsMono = resolveViews(getDefaultViewName(result, exchange), locale); + } + else if (Model.class.isAssignableFrom(clazz)) { + model.addAllAttributes(((Model) returnValue).asMap()); + viewsMono = resolveViews(getDefaultViewName(result, exchange), locale); + } + else if (Map.class.isAssignableFrom(clazz)) { + model.addAllAttributes((Map) returnValue); + viewsMono = resolveViews(getDefaultViewName(result, exchange), locale); + } + else if (View.class.isAssignableFrom(clazz)) { + viewsMono = Mono.just(Collections.singletonList((View) returnValue)); + } + else if (CharSequence.class.isAssignableFrom(clazz) && !hasModelAttributeAnnotation(result)) { + viewsMono = resolveViews(returnValue.toString(), locale); + } + else { + String name = getNameForReturnValue(clazz, result.getReturnTypeSource()); + model.addAttribute(name, returnValue); + viewsMono = resolveViews(getDefaultViewName(result, exchange), locale); + } + + return resolveAsyncAttributes(model.asMap()) + .doOnSuccess(aVoid -> addBindingResult(result, exchange)) + .then(viewsMono) + .then(views -> render(views, model.asMap(), exchange)); + }); } /** - * Translate the given request into a default view name. This is useful when - * the application leaves the view name unspecified. - *

The default implementation strips the leading and trailing slash from - * the as well as any extension and uses that as the view name. - * @return the default view name to use; if {@code null} is returned - * processing will result in an IllegalStateException. + * Select a default view name when a controller leaves the view unspecified. + * The default implementation strips the leading and trailing slash from the + * as well as any extension and uses that as the view name. */ - @SuppressWarnings("UnusedParameters") protected String getDefaultViewName(HandlerResult result, ServerWebExchange exchange) { String path = this.pathHelper.getLookupPathForRequest(exchange); if (path.startsWith("/")) { @@ -266,79 +263,105 @@ public class ViewResolutionResultHandler extends AbstractHandlerResultHandler return StringUtils.stripFilenameExtension(path); } - @SuppressWarnings("unchecked") - private Object updateModel(Object value, HandlerResult result) { - if (value instanceof Model) { - result.getModel().addAllAttributes(((Model) value).asMap()); - } - else if (value instanceof Map) { - result.getModel().addAllAttributes((Map) value); - } - else { - MethodParameter returnType = result.getReturnTypeSource(); - String name = getNameForReturnValue(value, returnType); - result.getModel().addAttribute(name, value); - } - return value; + private Mono> resolveViews(String viewName, Locale locale) { + return Flux.fromIterable(getViewResolvers()) + .concatMap(resolver -> resolver.resolveViewName(viewName, locale)) + .collectList() + .map(views -> { + if (views.isEmpty()) { + throw new IllegalStateException( + "Could not resolve view with name '" + viewName + "'."); + } + views.addAll(getDefaultViews()); + return views; + }); } /** - * Derive the model attribute name for the given return value using one of: - *

    - *
  1. The method {@code ModelAttribute} annotation value - *
  2. The declared return type if it is more specific than {@code Object} - *
  3. The actual return value type - *
- * @param returnValue the value returned from a method invocation - * @param returnType the return type of the method - * @return the model name, never {@code null} nor empty + * Return the name of a model attribute return value based on the method + * {@code @ModelAttribute} annotation, if present, or derived from the type + * of the return value otherwise. */ - private static String getNameForReturnValue(Object returnValue, MethodParameter returnType) { + private String getNameForReturnValue(Class returnValueType, MethodParameter returnType) { ModelAttribute annotation = returnType.getMethodAnnotation(ModelAttribute.class); if (annotation != null && StringUtils.hasText(annotation.value())) { return annotation.value(); } - else { - Method method = returnType.getMethod(); - Class containingClass = returnType.getContainingClass(); - Class resolvedType = GenericTypeResolver.resolveReturnType(method, containingClass); - return Conventions.getVariableNameForReturnType(method, resolvedType, returnValue); - } + // TODO: Conventions does not deal with async wrappers + return ClassUtils.getShortNameAsProperty(returnValueType); } - private Mono resolveAndRender(String viewName, Locale locale, - Map model, ServerWebExchange exchange) { + private Mono resolveAsyncAttributes(Map model) { - return Flux.fromIterable(getViewResolvers()) - .concatMap(resolver -> resolver.resolveViewName(viewName, locale)) - .switchIfEmpty(Mono.error( - new IllegalStateException( - "Could not resolve view with name '" + viewName + "'."))) - .collectList() - .then(views -> { - views.addAll(getDefaultViews()); + List names = new ArrayList<>(); + List> valueMonos = new ArrayList<>(); - List producibleTypes = getProducibleMediaTypes(views); - MediaType bestMediaType = selectMediaType(exchange, () -> producibleTypes); + for (Map.Entry entry : model.entrySet()) { + ReactiveAdapter adapter = getAdapterRegistry().getAdapterFrom(null, entry.getValue()); + if (adapter != null) { + names.add(entry.getKey()); + valueMonos.add(adapter.toMono(entry.getValue()).defaultIfEmpty(NO_VALUE)); + } + } - if (bestMediaType != null) { - for (View view : views) { - for (MediaType supported : view.getSupportedMediaTypes()) { - if (supported.isCompatibleWith(bestMediaType)) { - return view.render(model, bestMediaType, exchange); - } - } + if (names.isEmpty()) { + return Mono.empty(); + } + + return Mono.when(valueMonos, + values -> { + for (int i=0; i < values.length; i++) { + if (values[i] != NO_VALUE) { + model.put(names.get(i), values[i]); + } + else { + model.remove(names.get(i)); } } + return NO_VALUE; + }) + .then(); + } - return Mono.error(new NotAcceptableStatusException(producibleTypes)); + private void addBindingResult(HandlerResult result, ServerWebExchange exchange) { + BindingContext context = result.getBindingContext(); + Map model = context.getModel().asMap(); + model.keySet().stream() + .filter(name -> isBindingCandidate(name, model.get(name))) + .filter(name -> !model.containsKey(BindingResult.MODEL_KEY_PREFIX + name)) + .forEach(name -> { + WebExchangeDataBinder binder = context.createDataBinder(exchange, model.get(name), name); + model.put(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult()); }); } - private List getProducibleMediaTypes(List views) { - List result = new ArrayList<>(); - views.forEach(view -> result.addAll(view.getSupportedMediaTypes())); - return result; + private boolean isBindingCandidate(String name, Object value) { + return !name.startsWith(BindingResult.MODEL_KEY_PREFIX) && value != null && + !value.getClass().isArray() && !(value instanceof Collection) && + !(value instanceof Map) && !BeanUtils.isSimpleValueType(value.getClass()); + } + + private Mono render(List views, Map model, + ServerWebExchange exchange) { + + List mediaTypes = getMediaTypes(views); + MediaType bestMediaType = selectMediaType(exchange, () -> mediaTypes); + if (bestMediaType != null) { + for (View view : views) { + for (MediaType mediaType : view.getSupportedMediaTypes()) { + if (mediaType.isCompatibleWith(bestMediaType)) { + return view.render(model, mediaType, exchange); + } + } + } + } + throw new NotAcceptableStatusException(mediaTypes); + } + + private List getMediaTypes(List views) { + return views.stream() + .flatMap(view -> view.getSupportedMediaTypes().stream()) + .collect(Collectors.toList()); } } 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 0b42ace3a78..9020322c240 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 00000000000..68014671dcc --- /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/ErrorsArgumentResolverTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ErrorsArgumentResolverTests.java new file mode 100644 index 00000000000..b4b414ec3cc --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ErrorsArgumentResolverTests.java @@ -0,0 +1,161 @@ +/* + * 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 org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoProcessor; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.ResolvableType; +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.validation.BindingResult; +import org.springframework.validation.Errors; +import org.springframework.web.bind.WebExchangeDataBinder; +import org.springframework.web.bind.annotation.ModelAttribute; +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.MockWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +import static junit.framework.TestCase.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.springframework.core.ResolvableType.forClass; +import static org.springframework.core.ResolvableType.forClassWithGenerics; + +/** + * Unit tests for {@link ErrorsMethodArgumentResolver}. + * @author Rossen Stoyanchev + */ +public class ErrorsArgumentResolverTests { + + private ErrorsMethodArgumentResolver resolver ; + + private final BindingContext bindingContext = new BindingContext(); + + private BindingResult bindingResult; + + private ServerWebExchange exchange; + + private final ResolvableMethod testMethod = ResolvableMethod.onClass(this.getClass()).name("handle"); + + + @Before + public void setUp() throws Exception { + this.resolver = new ErrorsMethodArgumentResolver(new ReactiveAdapterRegistry()); + + MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.POST, "/path"); + MockServerHttpResponse response = new MockServerHttpResponse(); + WebSessionManager manager = new MockWebSessionManager(); + this.exchange = new DefaultServerWebExchange(request, response, manager); + + Foo foo = new Foo(); + WebExchangeDataBinder binder = this.bindingContext.createDataBinder(this.exchange, foo, "foo"); + this.bindingResult = binder.getBindingResult(); + } + + + @Test + public void supports() throws Exception { + + MethodParameter parameter = parameter(forClass(Errors.class)); + assertTrue(this.resolver.supportsParameter(parameter)); + + parameter = parameter(forClass(BindingResult.class)); + assertTrue(this.resolver.supportsParameter(parameter)); + + parameter = parameter(forClassWithGenerics(Mono.class, Errors.class)); + assertFalse(this.resolver.supportsParameter(parameter)); + + parameter = parameter(forClass(String.class)); + assertFalse(this.resolver.supportsParameter(parameter)); + } + + @Test + public void resolveErrors() throws Exception { + testResolve(this.bindingResult); + } + + @Test + public void resolveErrorsMono() throws Exception { + MonoProcessor monoProcessor = MonoProcessor.create(); + monoProcessor.onNext(this.bindingResult); + testResolve(monoProcessor); + } + + @Test(expected = IllegalArgumentException.class) + public void resolveErrorsAfterMonoModelAttribute() throws Exception { + MethodParameter parameter = parameter(forClass(BindingResult.class)); + this.resolver.resolveArgument(parameter, this.bindingContext, this.exchange).blockMillis(5000); + } + + + private void testResolve(Object bindingResult) { + + String key = BindingResult.MODEL_KEY_PREFIX + "foo"; + this.bindingContext.getModel().asMap().put(key, bindingResult); + + MethodParameter parameter = parameter(forClass(Errors.class)); + + Object actual = this.resolver.resolveArgument(parameter, this.bindingContext, this.exchange) + .blockMillis(5000); + + assertSame(this.bindingResult, actual); + } + + + private MethodParameter parameter(ResolvableType type) { + return this.testMethod.resolveParam(type); + } + + + private static class Foo { + + private String name; + + public Foo() { + } + + public Foo(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @SuppressWarnings("unused") + void handle( + @ModelAttribute Foo foo, + Errors errors, + @ModelAttribute Mono fooMono, + BindingResult bindingResult, + Mono errorsMono, + String string) {} + +} 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 new file mode 100644 index 00000000000..19061df2879 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ModelAttributeMethodArgumentResolverTests.java @@ -0,0 +1,339 @@ +/* + * 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.Map; +import java.util.function.Function; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import rx.RxReactiveStreams; +import rx.Single; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.ResolvableType; +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.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.validation.BindingResult; +import org.springframework.validation.annotation.Validated; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.bind.WebExchangeBindException; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.support.ConfigurableWebBindingInitializer; +import org.springframework.web.reactive.result.ResolvableMethod; +import org.springframework.web.reactive.result.method.BindingContext; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.MockWebSessionManager; + +import static junit.framework.TestCase.assertNotNull; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.springframework.core.ResolvableType.forClass; +import static org.springframework.core.ResolvableType.forClassWithGenerics; +import static org.springframework.util.Assert.isTrue; + + +/** + * Unit tests for {@link ModelAttributeMethodArgumentResolver}. + * @author Rossen Stoyanchev + */ +public class ModelAttributeMethodArgumentResolverTests { + + private ServerWebExchange exchange; + + private final MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.POST, "/path"); + + private final MultiValueMap formData = new LinkedMultiValueMap<>(); + + private BindingContext bindingContext; + + private ResolvableMethod testMethod = ResolvableMethod.onClass(this.getClass()).name("handle"); + + + @Before + public void setUp() throws Exception { + MockServerHttpResponse response = new MockServerHttpResponse(); + this.exchange = new DefaultServerWebExchange(this.request, response, new MockWebSessionManager()); + this.exchange = this.exchange.mutate().setFormData(Mono.just(this.formData)).build(); + + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + ConfigurableWebBindingInitializer initializer = new ConfigurableWebBindingInitializer(); + initializer.setValidator(validator); + this.bindingContext = new BindingContext(initializer); + } + + + @Test + public void supports() throws Exception { + + ModelAttributeMethodArgumentResolver resolver = + new ModelAttributeMethodArgumentResolver(new ReactiveAdapterRegistry(), false); + + ResolvableType type = forClass(Foo.class); + assertTrue(resolver.supportsParameter(parameter(type))); + + type = forClassWithGenerics(Mono.class, Foo.class); + assertTrue(resolver.supportsParameter(parameter(type))); + + type = forClass(Foo.class); + assertFalse(resolver.supportsParameter(parameterNotAnnotated(type))); + + type = forClassWithGenerics(Mono.class, Foo.class); + assertFalse(resolver.supportsParameter(parameterNotAnnotated(type))); + } + + @Test + public void supportsWithDefaultResolution() throws Exception { + + ModelAttributeMethodArgumentResolver resolver = + new ModelAttributeMethodArgumentResolver(new ReactiveAdapterRegistry(), true); + + ResolvableType type = forClass(Foo.class); + assertTrue(resolver.supportsParameter(parameterNotAnnotated(type))); + + type = forClassWithGenerics(Mono.class, Foo.class); + assertTrue(resolver.supportsParameter(parameterNotAnnotated(type))); + + type = forClass(String.class); + assertFalse(resolver.supportsParameter(parameterNotAnnotated(type))); + + type = forClassWithGenerics(Mono.class, String.class); + assertFalse(resolver.supportsParameter(parameterNotAnnotated(type))); + } + + @Test + public void createAndBind() throws Exception { + testBindFoo(forClass(Foo.class), value -> { + assertEquals(Foo.class, value.getClass()); + return (Foo) value; + }); + } + + @Test + public void createAndBindToMono() throws Exception { + testBindFoo(forClassWithGenerics(Mono.class, Foo.class), mono -> { + assertTrue(mono.getClass().getName(), mono instanceof Mono); + Object value = ((Mono) mono).blockMillis(5000); + assertEquals(Foo.class, value.getClass()); + return (Foo) value; + }); + } + + @Test + public void createAndBindToSingle() throws Exception { + testBindFoo(forClassWithGenerics(Single.class, Foo.class), single -> { + assertTrue(single.getClass().getName(), single instanceof Single); + Object value = ((Single) single).toBlocking().value(); + assertEquals(Foo.class, value.getClass()); + return (Foo) value; + }); + } + + @Test + public void bindExisting() throws Exception { + Foo foo = new Foo(); + foo.setName("Jim"); + this.bindingContext.getModel().addAttribute(foo); + + testBindFoo(forClass(Foo.class), value -> { + assertEquals(Foo.class, value.getClass()); + return (Foo) value; + }); + + assertSame(foo, this.bindingContext.getModel().asMap().get("foo")); + } + + @Test + public void bindExistingMono() throws Exception { + Foo foo = new Foo(); + foo.setName("Jim"); + this.bindingContext.getModel().addAttribute("foo", Mono.just(foo)); + + testBindFoo(forClass(Foo.class), value -> { + assertEquals(Foo.class, value.getClass()); + return (Foo) value; + }); + + assertSame(foo, this.bindingContext.getModel().asMap().get("foo")); + } + + @Test + public void bindExistingSingle() throws Exception { + Foo foo = new Foo(); + foo.setName("Jim"); + this.bindingContext.getModel().addAttribute("foo", Single.just(foo)); + + testBindFoo(forClass(Foo.class), value -> { + assertEquals(Foo.class, value.getClass()); + return (Foo) value; + }); + + assertSame(foo, this.bindingContext.getModel().asMap().get("foo")); + } + + @Test + public void bindExistingMonoToMono() throws Exception { + Foo foo = new Foo(); + foo.setName("Jim"); + this.bindingContext.getModel().addAttribute("foo", Mono.just(foo)); + + testBindFoo(forClassWithGenerics(Mono.class, Foo.class), mono -> { + assertTrue(mono.getClass().getName(), mono instanceof Mono); + Object value = ((Mono) mono).blockMillis(5000); + assertEquals(Foo.class, value.getClass()); + return (Foo) value; + }); + } + + @Test + public void validationError() throws Exception { + testValidationError(forClass(Foo.class), resolvedArgumentMono -> resolvedArgumentMono); + } + + @Test + @SuppressWarnings("unchecked") + public void validationErrorToMono() throws Exception { + testValidationError(forClassWithGenerics(Mono.class, Foo.class), + resolvedArgumentMono -> { + Object value = resolvedArgumentMono.blockMillis(5000); + assertNotNull(value); + isTrue(value instanceof Mono); + return (Mono) value; + }); + } + + @Test + @SuppressWarnings("unchecked") + public void validationErrorToSingle() throws Exception { + testValidationError(forClassWithGenerics(Single.class, Foo.class), + resolvedArgumentMono -> { + Object value = resolvedArgumentMono.blockMillis(5000); + assertNotNull(value); + isTrue(value instanceof Single); + return Mono.from(RxReactiveStreams.toPublisher((Single) value)); + }); + } + + + private void testBindFoo(ResolvableType type, Function valueExtractor) { + + this.formData.add("name", "Robert"); + this.formData.add("age", "25"); + + Object value = createResolver() + .resolveArgument(parameter(type), this.bindingContext, this.exchange) + .blockMillis(5000); + + Foo foo = valueExtractor.apply(value); + assertEquals("Robert", foo.getName()); + + String key = "foo"; + String bindingResultKey = BindingResult.MODEL_KEY_PREFIX + key; + + Map map = bindingContext.getModel().asMap(); + assertEquals(map.toString(), 2, map.size()); + assertSame(foo, map.get(key)); + assertNotNull(map.get(bindingResultKey)); + assertTrue(map.get(bindingResultKey) instanceof BindingResult); + } + + private void testValidationError(ResolvableType type, Function, Mono> valueMonoExtractor) { + + this.formData.add("age", "invalid"); + + HandlerMethodArgumentResolver resolver = createResolver(); + Mono mono = resolver.resolveArgument(parameter(type), this.bindingContext, this.exchange); + + mono = valueMonoExtractor.apply(mono); + + StepVerifier.create(mono) + .consumeErrorWith(ex -> { + assertTrue(ex instanceof WebExchangeBindException); + WebExchangeBindException bindException = (WebExchangeBindException) ex; + assertEquals(1, bindException.getErrorCount()); + assertTrue(bindException.hasFieldErrors("age")); + }) + .verify(); + } + + + private ModelAttributeMethodArgumentResolver createResolver() { + return new ModelAttributeMethodArgumentResolver(new ReactiveAdapterRegistry()); + } + + private MethodParameter parameter(ResolvableType type) { + return this.testMethod.resolveParam(type, + parameter -> parameter.hasParameterAnnotation(ModelAttribute.class)); + } + + private MethodParameter parameterNotAnnotated(ResolvableType type) { + return this.testMethod.resolveParam(type, + parameter -> !parameter.hasParameterAnnotations()); + } + + + private static class Foo { + + private String name; + + private int age; + + public Foo() { + } + + public Foo(String name) { + this.name = name; + } + + 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; + } + } + + @SuppressWarnings("unused") + void handle( + @ModelAttribute @Validated Foo foo, + @ModelAttribute @Validated Mono mono, + @ModelAttribute @Validated Single single, + Foo fooNotAnnotated, + String stringNotAnnotated, + Mono monoNotAnnotated, + Mono monoStringNotAnnotated) {} + +} 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 a85acf2122a..963d750bbc0 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 + "]"; + } } } diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java index fa83247de20..6924780ab58 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/ResponseBodyResultHandlerTests.java @@ -37,11 +37,10 @@ import org.springframework.http.codec.HttpMessageWriter; import org.springframework.http.codec.ResourceHttpMessageWriter; import org.springframework.http.codec.json.Jackson2JsonEncoder; import org.springframework.http.codec.xml.Jaxb2XmlEncoder; +import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; import org.springframework.mock.http.server.reactive.test.MockServerHttpResponse; -import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.stereotype.Controller; -import org.springframework.ui.ExtendedModelMap; import org.springframework.util.ObjectUtils; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; @@ -121,13 +120,13 @@ public class ResponseBodyResultHandlerTests { public void writeResponseStatus() throws NoSuchMethodException { Object controller = new TestRestController(); HandlerMethod hm = handlerMethod(controller, "handleToString"); - HandlerResult handlerResult = new HandlerResult(hm, null, hm.getReturnType(), new ExtendedModelMap()); + HandlerResult handlerResult = new HandlerResult(hm, null, hm.getReturnType()); StepVerifier.create(this.resultHandler.handleResult(this.exchange, handlerResult)).expectComplete().verify(); assertEquals(HttpStatus.NO_CONTENT, this.response.getStatusCode()); hm = handlerMethod(controller, "handleToMonoVoid"); - handlerResult = new HandlerResult(hm, null, hm.getReturnType(), new ExtendedModelMap()); + handlerResult = new HandlerResult(hm, null, hm.getReturnType()); StepVerifier.create(this.resultHandler.handleResult(this.exchange, handlerResult)).expectComplete().verify(); assertEquals(HttpStatus.CREATED, this.response.getStatusCode()); @@ -135,7 +134,7 @@ public class ResponseBodyResultHandlerTests { private void testSupports(Object controller, String method, boolean result) throws NoSuchMethodException { HandlerMethod hm = handlerMethod(controller, method); - HandlerResult handlerResult = new HandlerResult(hm, null, hm.getReturnType(), new ExtendedModelMap()); + HandlerResult handlerResult = new HandlerResult(hm, null, hm.getReturnType()); assertEquals(result, this.resultHandler.supports(handlerResult)); } diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java index f13b8dee7b0..57f2b4206f9 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java @@ -18,7 +18,6 @@ package org.springframework.web.reactive.result.view; import java.net.URISyntaxException; import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Arrays; import java.util.Collections; @@ -26,6 +25,7 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.TreeMap; import org.junit.Before; import org.junit.Test; @@ -40,52 +40,52 @@ import org.springframework.core.Ordered; import org.springframework.core.ResolvableType; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DefaultDataBufferFactory; -import org.springframework.core.io.buffer.support.DataBufferTestUtils; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; import org.springframework.mock.http.server.reactive.test.MockServerHttpResponse; -import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ConcurrentModel; import org.springframework.ui.Model; -import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.reactive.HandlerResult; import org.springframework.web.reactive.accept.HeaderContentTypeResolver; import org.springframework.web.reactive.accept.RequestedContentTypeResolver; import org.springframework.web.reactive.result.ResolvableMethod; +import org.springframework.web.reactive.result.method.BindingContext; import org.springframework.web.server.NotAcceptableStatusException; 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 java.nio.charset.StandardCharsets.UTF_8; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; import static org.mockito.Mockito.mock; +import static org.springframework.core.ResolvableType.forClass; +import static org.springframework.core.ResolvableType.forClassWithGenerics; +import static org.springframework.core.io.buffer.support.DataBufferTestUtils.dumpString; import static org.springframework.http.MediaType.APPLICATION_JSON; /** * Unit tests for {@link ViewResolutionResultHandler}. - * * @author Rossen Stoyanchev */ public class ViewResolutionResultHandlerTests { - private MockServerHttpRequest request; + private final MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, "/path"); - private MockServerHttpResponse response = new MockServerHttpResponse(); + private final MockServerHttpResponse response = new MockServerHttpResponse(); private ServerWebExchange exchange; - private ModelMap model = new ExtendedModelMap(); + private final BindingContext bindingContext = new BindingContext(); @Before public void setUp() throws Exception { - this.request = new MockServerHttpRequest(HttpMethod.GET, "/path"); WebSessionManager manager = new DefaultWebSessionManager(); this.exchange = new DefaultServerWebExchange(this.request, this.response, manager); } @@ -93,21 +93,30 @@ public class ViewResolutionResultHandlerTests { @Test public void supports() throws Exception { + testSupports(forClass(String.class), true); + testSupports(forClass(View.class), true); + testSupports(forClassWithGenerics(Mono.class, String.class), true); + testSupports(forClassWithGenerics(Mono.class, View.class), true); + testSupports(forClassWithGenerics(Single.class, String.class), true); + testSupports(forClassWithGenerics(Single.class, View.class), true); + testSupports(forClassWithGenerics(Mono.class, Void.class), true); + testSupports(forClass(Completable.class), true); + testSupports(forClass(Model.class), true); + testSupports(forClass(Map.class), true); + testSupports(forClass(TestBean.class), true); + testSupports(forClass(Integer.class), false); + testSupports(resolvableMethod().annotated(ModelAttribute.class), true); + } - testSupports(ResolvableType.forClass(String.class), true); - testSupports(ResolvableType.forClass(View.class), true); - testSupports(ResolvableType.forClassWithGenerics(Mono.class, String.class), true); - testSupports(ResolvableType.forClassWithGenerics(Mono.class, View.class), true); - testSupports(ResolvableType.forClassWithGenerics(Single.class, String.class), true); - testSupports(ResolvableType.forClassWithGenerics(Single.class, View.class), true); - testSupports(ResolvableType.forClassWithGenerics(Mono.class, Void.class), true); - testSupports(ResolvableType.forClass(Completable.class), true); - testSupports(ResolvableType.forClass(Model.class), true); - testSupports(ResolvableType.forClass(Map.class), true); - testSupports(ResolvableType.forClass(TestBean.class), true); - testSupports(ResolvableType.forClass(Integer.class), false); + private void testSupports(ResolvableType type, boolean result) { + testSupports(resolvableMethod().returning(type), result); + } - testSupports(ResolvableMethod.onClass(TestController.class).annotated(ModelAttribute.class), true); + private void testSupports(ResolvableMethod resolvableMethod, boolean result) { + ViewResolutionResultHandler resultHandler = resultHandler(mock(ViewResolver.class)); + MethodParameter returnType = resolvableMethod.resolveReturnType(); + HandlerResult handlerResult = new HandlerResult(new Object(), null, returnType, this.bindingContext); + assertEquals(result, resultHandler.supports(handlerResult)); } @Test @@ -116,7 +125,7 @@ public class ViewResolutionResultHandlerTests { TestViewResolver resolver2 = new TestViewResolver("profile"); resolver1.setOrder(2); resolver2.setOrder(1); - List resolvers = createResultHandler(resolver1, resolver2).getViewResolvers(); + List resolvers = resultHandler(resolver1, resolver2).getViewResolvers(); assertEquals(Arrays.asList(resolver2, resolver1), resolvers); } @@ -127,47 +136,52 @@ public class ViewResolutionResultHandlerTests { ResolvableType returnType; ViewResolver resolver = new TestViewResolver("account"); - returnType = ResolvableType.forClass(View.class); + returnType = forClass(View.class); returnValue = new TestView("account"); testHandle("/path", returnType, returnValue, "account: {id=123}"); assertEquals(HttpStatus.NO_CONTENT, this.exchange.getResponse().getStatusCode()); - returnType = ResolvableType.forClassWithGenerics(Mono.class, View.class); + returnType = forClassWithGenerics(Mono.class, View.class); returnValue = Mono.just(new TestView("account")); testHandle("/path", returnType, returnValue, "account: {id=123}"); assertEquals(HttpStatus.SEE_OTHER, this.exchange.getResponse().getStatusCode()); - returnType = ResolvableType.forClass(String.class); + returnType = forClass(String.class); returnValue = "account"; testHandle("/path", returnType, returnValue, "account: {id=123}", resolver); assertEquals(HttpStatus.CREATED, this.exchange.getResponse().getStatusCode()); - returnType = ResolvableType.forClassWithGenerics(Mono.class, String.class); + returnType = forClassWithGenerics(Mono.class, String.class); returnValue = Mono.just("account"); testHandle("/path", returnType, returnValue, "account: {id=123}", resolver); assertEquals(HttpStatus.PARTIAL_CONTENT, this.exchange.getResponse().getStatusCode()); - returnType = ResolvableType.forClass(Model.class); - returnValue = new ExtendedModelMap().addAttribute("name", "Joe"); + returnType = forClass(Model.class); + returnValue = new ConcurrentModel().addAttribute("name", "Joe"); testHandle("/account", returnType, returnValue, "account: {id=123, name=Joe}", resolver); - returnType = ResolvableType.forClass(Map.class); + returnType = forClass(Map.class); returnValue = Collections.singletonMap("name", "Joe"); testHandle("/account", returnType, returnValue, "account: {id=123, name=Joe}", resolver); - returnType = ResolvableType.forClass(TestBean.class); + returnType = forClass(TestBean.class); returnValue = new TestBean("Joe"); - String responseBody = "account: {id=123, testBean=TestBean[name=Joe]}"; + String responseBody = "account: {" + + "id=123, " + + "org.springframework.validation.BindingResult.testBean=" + + "org.springframework.validation.BeanPropertyBindingResult: 0 errors, " + + "testBean=TestBean[name=Joe]" + + "}"; testHandle("/account", returnType, returnValue, responseBody, resolver); - testHandle("/account", ResolvableMethod.onClass(TestController.class).annotated(ModelAttribute.class), + testHandle("/account", resolvableMethod().annotated(ModelAttribute.class), 99L, "account: {id=123, num=99}", resolver); } @Test public void handleWithMultipleResolvers() throws Exception { Object returnValue = "profile"; - ResolvableType returnType = ResolvableType.forClass(String.class); + ResolvableType returnType = forClass(String.class); ViewResolver[] resolvers = {new TestViewResolver("account"), new TestViewResolver("profile")}; testHandle("/account", returnType, returnValue, "profile: {id=123}", resolvers); @@ -175,76 +189,78 @@ public class ViewResolutionResultHandlerTests { @Test public void defaultViewName() throws Exception { - testDefaultViewName(null, ResolvableType.forClass(String.class)); - testDefaultViewName(Mono.empty(), ResolvableType.forClassWithGenerics(Mono.class, String.class)); - testDefaultViewName(Mono.empty(), ResolvableType.forClassWithGenerics(Mono.class, Void.class)); - testDefaultViewName(Completable.complete(), ResolvableType.forClass(Completable.class)); + testDefaultViewName(null, forClass(String.class)); + testDefaultViewName(Mono.empty(), forClassWithGenerics(Mono.class, String.class)); + testDefaultViewName(Mono.empty(), forClassWithGenerics(Mono.class, Void.class)); + testDefaultViewName(Completable.complete(), forClass(Completable.class)); } - private void testDefaultViewName(Object returnValue, ResolvableType type) - throws URISyntaxException { - - ModelMap model = new ExtendedModelMap().addAttribute("id", "123"); - HandlerResult result = new HandlerResult(new Object(), returnValue, returnType(type), model); - ViewResolutionResultHandler handler = createResultHandler(new TestViewResolver("account")); + private void testDefaultViewName(Object returnValue, ResolvableType type) throws URISyntaxException { + this.bindingContext.getModel().addAttribute("id", "123"); + HandlerResult result = new HandlerResult(new Object(), returnValue, returnType(type), this.bindingContext); + ViewResolutionResultHandler handler = resultHandler(new TestViewResolver("account")); this.request.setUri("/account"); - handler.handleResult(this.exchange, result).block(Duration.ofSeconds(5)); + handler.handleResult(this.exchange, result).blockMillis(5000); assertResponseBody("account: {id=123}"); this.request.setUri("/account/"); - handler.handleResult(this.exchange, result).block(Duration.ofSeconds(5)); + handler.handleResult(this.exchange, result).blockMillis(5000); assertResponseBody("account: {id=123}"); this.request.setUri("/account.123"); - handler.handleResult(this.exchange, result).block(Duration.ofSeconds(5)); + handler.handleResult(this.exchange, result).blockMillis(5000); assertResponseBody("account: {id=123}"); } @Test public void unresolvedViewName() throws Exception { String returnValue = "account"; - ResolvableType type = ResolvableType.forClass(String.class); - HandlerResult handlerResult = new HandlerResult(new Object(), returnValue, returnType(type), this.model); + MethodParameter returnType = returnType(forClass(String.class)); + HandlerResult result = new HandlerResult(new Object(), returnValue, returnType, this.bindingContext); this.request.setUri("/path"); - Mono mono = createResultHandler().handleResult(this.exchange, handlerResult); + Mono mono = resultHandler().handleResult(this.exchange, result); StepVerifier.create(mono) .expectNextCount(0) - .expectErrorMatches(err -> err.getMessage().equals("Could not resolve view with name 'account'.")) + .expectErrorMessage("Could not resolve view with name 'account'.") .verify(); } @Test public void contentNegotiation() throws Exception { TestBean value = new TestBean("Joe"); - ResolvableType type = ResolvableType.forClass(TestBean.class); - HandlerResult handlerResult = new HandlerResult(new Object(), value, returnType(type), this.model); + MethodParameter returnType = returnType(forClass(TestBean.class)); + HandlerResult handlerResult = new HandlerResult(new Object(), value, returnType, this.bindingContext); this.request.setHeader("Accept", "application/json"); this.request.setUri("/account"); TestView defaultView = new TestView("jsonView", APPLICATION_JSON); - createResultHandler(Collections.singletonList(defaultView), new TestViewResolver("account")) + resultHandler(Collections.singletonList(defaultView), new TestViewResolver("account")) .handleResult(this.exchange, handlerResult) .block(Duration.ofSeconds(5)); assertEquals(APPLICATION_JSON, this.response.getHeaders().getContentType()); - assertResponseBody("jsonView: {testBean=TestBean[name=Joe]}"); + assertResponseBody("jsonView: {" + + "org.springframework.validation.BindingResult.testBean=" + + "org.springframework.validation.BeanPropertyBindingResult: 0 errors, " + + "testBean=TestBean[name=Joe]" + + "}"); } @Test public void contentNegotiationWith406() throws Exception { TestBean value = new TestBean("Joe"); - ResolvableType type = ResolvableType.forClass(TestBean.class); - HandlerResult handlerResult = new HandlerResult(new Object(), value, returnType(type), this.model); + MethodParameter returnType = returnType(forClass(TestBean.class)); + HandlerResult handlerResult = new HandlerResult(new Object(), value, returnType, this.bindingContext); this.request.setHeader("Accept", "application/json"); this.request.setUri("/account"); - ViewResolutionResultHandler resultHandler = createResultHandler(new TestViewResolver("account")); + ViewResolutionResultHandler resultHandler = resultHandler(new TestViewResolver("account")); Mono mono = resultHandler.handleResult(this.exchange, handlerResult); StepVerifier.create(mono) .expectNextCount(0) @@ -252,16 +268,39 @@ public class ViewResolutionResultHandlerTests { .verify(); } + @Test + public void modelWithAsyncAttributes() throws Exception { + this.bindingContext.getModel() + .addAttribute("bean1", Mono.just(new TestBean("Bean1"))) + .addAttribute("bean2", Single.just(new TestBean("Bean2"))) + .addAttribute("empty", Mono.empty()); + + ResolvableType type = forClass(void.class); + HandlerResult result = new HandlerResult(new Object(), null, returnType(type), this.bindingContext); + ViewResolutionResultHandler handler = resultHandler(new TestViewResolver("account")); + + this.request.setUri("/account"); + handler.handleResult(this.exchange, result).blockMillis(5000); + assertResponseBody("account: {" + + "bean1=TestBean[name=Bean1], " + + "bean2=TestBean[name=Bean2], " + + "org.springframework.validation.BindingResult.bean1=" + + "org.springframework.validation.BeanPropertyBindingResult: 0 errors, " + + "org.springframework.validation.BindingResult.bean2=" + + "org.springframework.validation.BeanPropertyBindingResult: 0 errors" + + "}"); + } + private MethodParameter returnType(ResolvableType type) { - return ResolvableMethod.onClass(TestController.class).returning(type).resolveReturnType(); + return resolvableMethod().returning(type).resolveReturnType(); } - private ViewResolutionResultHandler createResultHandler(ViewResolver... resolvers) { - return createResultHandler(Collections.emptyList(), resolvers); + private ViewResolutionResultHandler resultHandler(ViewResolver... resolvers) { + return resultHandler(Collections.emptyList(), resolvers); } - private ViewResolutionResultHandler createResultHandler(List defaultViews, ViewResolver... resolvers) { + private ViewResolutionResultHandler resultHandler(List defaultViews, ViewResolver... resolvers) { List resolverList = Arrays.asList(resolvers); RequestedContentTypeResolver contentTypeResolver = new HeaderContentTypeResolver(); ViewResolutionResultHandler handler = new ViewResolutionResultHandler(resolverList, contentTypeResolver); @@ -269,39 +308,32 @@ public class ViewResolutionResultHandlerTests { return handler; } - private void testSupports(ResolvableType type, boolean result) { - testSupports(ResolvableMethod.onClass(TestController.class).returning(type), result); - } - - private void testSupports(ResolvableMethod resolvableMethod, boolean result) { - ViewResolutionResultHandler resultHandler = createResultHandler(mock(ViewResolver.class)); - MethodParameter returnType = resolvableMethod.resolveReturnType(); - HandlerResult handlerResult = new HandlerResult(new Object(), null, returnType, this.model); - assertEquals(result, resultHandler.supports(handlerResult)); + private ResolvableMethod resolvableMethod() { + return ResolvableMethod.onClass(TestController.class); } private void testHandle(String path, ResolvableType returnType, Object returnValue, String responseBody, ViewResolver... resolvers) throws URISyntaxException { - testHandle(path, ResolvableMethod.onClass(TestController.class).returning(returnType), - returnValue, responseBody, resolvers); + testHandle(path, resolvableMethod().returning(returnType), returnValue, responseBody, resolvers); } private void testHandle(String path, ResolvableMethod resolvableMethod, Object returnValue, String responseBody, ViewResolver... resolvers) throws URISyntaxException { - ModelMap model = new ExtendedModelMap().addAttribute("id", "123"); + Model model = this.bindingContext.getModel(); + model.asMap().clear(); + model.addAttribute("id", "123"); MethodParameter returnType = resolvableMethod.resolveReturnType(); - HandlerResult result = new HandlerResult(new Object(), returnValue, returnType, model); + HandlerResult result = new HandlerResult(new Object(), returnValue, returnType, this.bindingContext); this.request.setUri(path); - createResultHandler(resolvers).handleResult(this.exchange, result).block(Duration.ofSeconds(5)); + resultHandler(resolvers).handleResult(this.exchange, result).block(Duration.ofSeconds(5)); assertResponseBody(responseBody); } private void assertResponseBody(String responseBody) { StepVerifier.create(this.response.getBody()) - .consumeNextWith(buf -> assertEquals(responseBody, - DataBufferTestUtils.dumpString(buf, StandardCharsets.UTF_8))) + .consumeNextWith(buf -> assertEquals(responseBody, dumpString(buf, UTF_8))) .expectComplete() .verify(); } @@ -361,15 +393,14 @@ public class ViewResolutionResultHandlerTests { } @Override - public Mono render(Map model, MediaType mediaType, - ServerWebExchange exchange) { - String value = this.name + ": " + model.toString(); - assertNotNull(value); + public Mono render(Map model, MediaType mediaType, ServerWebExchange exchange) { ServerHttpResponse response = exchange.getResponse(); if (mediaType != null) { response.getHeaders().setContentType(mediaType); } - ByteBuffer byteBuffer = ByteBuffer.wrap(value.getBytes(StandardCharsets.UTF_8)); + model = new TreeMap<>(model); + String value = this.name + ": " + model.toString(); + ByteBuffer byteBuffer = ByteBuffer.wrap(value.getBytes(UTF_8)); DataBuffer dataBuffer = new DefaultDataBufferFactory().wrap(byteBuffer); return response.writeWith(Flux.just(dataBuffer)); } @@ -412,6 +443,8 @@ public class ViewResolutionResultHandlerTests { Mono monoVoid() { return null; } + void voidMethod() {} + Single singleString() { return null; } Single singleView() { return null; } diff --git a/spring-web/src/main/java/org/springframework/web/bind/WebExchangeBindException.java b/spring-web/src/main/java/org/springframework/web/bind/WebExchangeBindException.java new file mode 100644 index 00000000000..99182fa8850 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/bind/WebExchangeBindException.java @@ -0,0 +1,288 @@ +/* + * 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.PropertyEditor; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.PropertyEditorRegistry; +import org.springframework.core.MethodParameter; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import org.springframework.validation.Errors; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.server.ServerWebInputException; + +/** + * A specialization of {@link ServerWebInputException} thrown when after data + * binding and validation failure. Implements {@link BindingResult} (and its + * super-interface {@link Errors}) to allow for direct analysis of binding and + * validation errors. + * + * @author Rossen Stoyanchev + * @since 5.0 + */ +@SuppressWarnings("serial") +public class WebExchangeBindException extends ServerWebInputException implements BindingResult { + + private final BindingResult bindingResult; + + + public WebExchangeBindException(MethodParameter parameter, BindingResult bindingResult) { + super("Validation failure", parameter); + this.bindingResult = bindingResult; + } + + + /** + * Return the BindingResult that this BindException wraps. + * Will typically be a BeanPropertyBindingResult. + * @see BeanPropertyBindingResult + */ + public final BindingResult getBindingResult() { + return this.bindingResult; + } + + + @Override + public String getObjectName() { + return this.bindingResult.getObjectName(); + } + + @Override + public void setNestedPath(String nestedPath) { + this.bindingResult.setNestedPath(nestedPath); + } + + @Override + public String getNestedPath() { + return this.bindingResult.getNestedPath(); + } + + @Override + public void pushNestedPath(String subPath) { + this.bindingResult.pushNestedPath(subPath); + } + + @Override + public void popNestedPath() throws IllegalStateException { + this.bindingResult.popNestedPath(); + } + + + @Override + public void reject(String errorCode) { + this.bindingResult.reject(errorCode); + } + + @Override + public void reject(String errorCode, String defaultMessage) { + this.bindingResult.reject(errorCode, defaultMessage); + } + + @Override + public void reject(String errorCode, Object[] errorArgs, String defaultMessage) { + this.bindingResult.reject(errorCode, errorArgs, defaultMessage); + } + + @Override + public void rejectValue(String field, String errorCode) { + this.bindingResult.rejectValue(field, errorCode); + } + + @Override + public void rejectValue(String field, String errorCode, String defaultMessage) { + this.bindingResult.rejectValue(field, errorCode, defaultMessage); + } + + @Override + public void rejectValue(String field, String errorCode, Object[] errorArgs, String defaultMessage) { + this.bindingResult.rejectValue(field, errorCode, errorArgs, defaultMessage); + } + + @Override + public void addAllErrors(Errors errors) { + this.bindingResult.addAllErrors(errors); + } + + + @Override + public boolean hasErrors() { + return this.bindingResult.hasErrors(); + } + + @Override + public int getErrorCount() { + return this.bindingResult.getErrorCount(); + } + + @Override + public List getAllErrors() { + return this.bindingResult.getAllErrors(); + } + + @Override + public boolean hasGlobalErrors() { + return this.bindingResult.hasGlobalErrors(); + } + + @Override + public int getGlobalErrorCount() { + return this.bindingResult.getGlobalErrorCount(); + } + + @Override + public List getGlobalErrors() { + return this.bindingResult.getGlobalErrors(); + } + + @Override + public ObjectError getGlobalError() { + return this.bindingResult.getGlobalError(); + } + + @Override + public boolean hasFieldErrors() { + return this.bindingResult.hasFieldErrors(); + } + + @Override + public int getFieldErrorCount() { + return this.bindingResult.getFieldErrorCount(); + } + + @Override + public List getFieldErrors() { + return this.bindingResult.getFieldErrors(); + } + + @Override + public FieldError getFieldError() { + return this.bindingResult.getFieldError(); + } + + @Override + public boolean hasFieldErrors(String field) { + return this.bindingResult.hasFieldErrors(field); + } + + @Override + public int getFieldErrorCount(String field) { + return this.bindingResult.getFieldErrorCount(field); + } + + @Override + public List getFieldErrors(String field) { + return this.bindingResult.getFieldErrors(field); + } + + @Override + public FieldError getFieldError(String field) { + return this.bindingResult.getFieldError(field); + } + + @Override + public Object getFieldValue(String field) { + return this.bindingResult.getFieldValue(field); + } + + @Override + public Class getFieldType(String field) { + return this.bindingResult.getFieldType(field); + } + + @Override + public Object getTarget() { + return this.bindingResult.getTarget(); + } + + @Override + public Map getModel() { + return this.bindingResult.getModel(); + } + + @Override + public Object getRawFieldValue(String field) { + return this.bindingResult.getRawFieldValue(field); + } + + @Override + @SuppressWarnings("rawtypes") + public PropertyEditor findEditor(String field, Class valueType) { + return this.bindingResult.findEditor(field, valueType); + } + + @Override + public PropertyEditorRegistry getPropertyEditorRegistry() { + return this.bindingResult.getPropertyEditorRegistry(); + } + + @Override + public void addError(ObjectError error) { + this.bindingResult.addError(error); + } + + @Override + public String[] resolveMessageCodes(String errorCode) { + return this.bindingResult.resolveMessageCodes(errorCode); + } + + @Override + public String[] resolveMessageCodes(String errorCode, String field) { + return this.bindingResult.resolveMessageCodes(errorCode, field); + } + + @Override + public void recordSuppressedField(String field) { + this.bindingResult.recordSuppressedField(field); + } + + @Override + public String[] getSuppressedFields() { + return this.bindingResult.getSuppressedFields(); + } + + + /** + * Returns diagnostic information about the errors held in this object. + */ + @Override + @SuppressWarnings("OptionalGetWithoutIsPresent") + public String getMessage() { + MethodParameter parameter = getMethodParameter().get(); + StringBuilder sb = new StringBuilder("Validation failed for argument at index ") + .append(parameter.getParameterIndex()).append(" in method: ") + .append(parameter.getMethod().toGenericString()) + .append(", with ").append(this.bindingResult.getErrorCount()).append(" error(s): "); + for (ObjectError error : this.bindingResult.getAllErrors()) { + sb.append("[").append(error).append("] "); + } + return sb.toString(); + } + + @Override + public boolean equals(Object other) { + return (this == other || this.bindingResult.equals(other)); + } + + @Override + public int hashCode() { + return this.bindingResult.hashCode(); + } + +}