From 58307ebac49336bc6923ed162fbf6f1249407dd5 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Fri, 27 May 2016 20:22:58 -0400 Subject: [PATCH] Support Model-related return values This commit adds support for Model-related return values types such as Map, Model, @ModelAttribute annotated, and non-simple types, which helps to clarify the logic in ViewResolutionResultHandler. --- .../view/ViewResolutionResultHandler.java | 146 +++++++++++---- .../ViewResolutionResultHandlerTests.java | 172 +++++++++++++----- 2 files changed, 244 insertions(+), 74 deletions(-) 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 2ad057b5463..d46e127ad6e 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 @@ -15,22 +15,31 @@ */ package org.springframework.web.reactive.result.view; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Optional; 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.ResolvableType; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.core.convert.ConversionService; import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.ui.Model; import org.springframework.util.Assert; import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.HandlerResult; import org.springframework.web.reactive.HandlerResultHandler; import org.springframework.web.server.ServerWebExchange; @@ -38,10 +47,22 @@ import org.springframework.web.util.HttpRequestPathHelper; /** - * {@code HandlerResultHandler} that performs view resolution by resolving a - * {@link View} instance first and then rendering the response with it. - * If the return value is a String, the configured {@link ViewResolver}s will - * be consulted to resolve that to a {@link View} instance. + * {@code HandlerResultHandler} that encapsulates the view resolution algorithm + * supporting the following return types: + * + * + *

A String-based view name is resolved through the configured + * {@link ViewResolver} instances into a {@link View} to use for rendering. + * If a view is left unspecified (e.g. by returning {@code null} or a + * model-related return value), a default view name is selected. * *

This result handler should be ordered late relative to other result * handlers. See {@link #setOrder(int)} for more details. @@ -96,51 +117,72 @@ public class ViewResolutionResultHandler implements HandlerResultHandler, Ordere return this.order; } - // TODO: Support for Model, ModelAndView, @ModelAttribute, Object with no method annotations - @Override public boolean supports(HandlerResult result) { Class clazz = result.getReturnValueType().getRawClass(); - if (isStringOrViewReference(clazz)) { + if (hasModelAttributeAnnotation(result)) { + return true; + } + if (isSupportedType(clazz)) { return true; } if (this.conversionService.canConvert(clazz, Mono.class)) { clazz = result.getReturnValueType().getGeneric(0).getRawClass(); - return isStringOrViewReference(clazz); + return isSupportedType(clazz); } return false; } - private boolean isStringOrViewReference(Class clazz) { - return (CharSequence.class.isAssignableFrom(clazz) || View.class.isAssignableFrom(clazz)); + private boolean hasModelAttributeAnnotation(HandlerResult result) { + if (result.getHandler() instanceof HandlerMethod) { + MethodParameter returnType = ((HandlerMethod) result.getHandler()).getReturnType(); + if (returnType.hasMethodAnnotation(ModelAttribute.class)) { + return true; + } + } + return false; + } + + private boolean isSupportedType(Class clazz) { + return (CharSequence.class.isAssignableFrom(clazz) || View.class.isAssignableFrom(clazz) || + Model.class.isAssignableFrom(clazz) || Map.class.isAssignableFrom(clazz) || + !BeanUtils.isSimpleProperty(clazz)); } @Override public Mono handleResult(ServerWebExchange exchange, HandlerResult result) { - Mono mono; + Mono valueMono; ResolvableType elementType; ResolvableType returnType = result.getReturnValueType(); if (this.conversionService.canConvert(returnType.getRawClass(), Mono.class)) { Optional optionalValue = result.getReturnValue(); if (optionalValue.isPresent()) { - Mono convertedMono = this.conversionService.convert(optionalValue.get(), Mono.class); - mono = convertedMono.map(o -> o); + Mono converted = this.conversionService.convert(optionalValue.get(), Mono.class); + valueMono = converted.map(o -> o); } else { - mono = Mono.empty(); + valueMono = Mono.empty(); } elementType = returnType.getGeneric(0); } else { - mono = Mono.justOrEmpty(result.getReturnValue()); + valueMono = Mono.justOrEmpty(result.getReturnValue()); elementType = returnType; } - mono = mono.otherwiseIfEmpty(handleMissingReturnValue(exchange, result, elementType)); + Mono viewMono; + if (isViewReturnType(result, elementType)) { + viewMono = valueMono.otherwiseIfEmpty(selectDefaultViewName(exchange, result)); + } + else { + viewMono = valueMono.map(value -> updateModel(result, value)) + .defaultIfEmpty(result.getModel()) + .then(model -> selectDefaultViewName(exchange, result)); + } - return mono.then(returnValue -> { + return viewMono.then(returnValue -> { if (returnValue instanceof View) { Flux body = ((View) returnValue).render(result, null, exchange); return exchange.getResponse().setBody(body); @@ -158,28 +200,26 @@ public class ViewResolutionResultHandler implements HandlerResultHandler, Ordere }); } else { - // Eventually for model-related return values (should not happen now) + // Should not happen return Mono.error(new IllegalStateException("Unexpected return value")); } }); } - private Mono handleMissingReturnValue(ServerWebExchange exchange, HandlerResult result, - ResolvableType elementType) { + private boolean isViewReturnType(HandlerResult result, ResolvableType elementType) { + Class clazz = elementType.getRawClass(); + return (View.class.isAssignableFrom(clazz) || + (CharSequence.class.isAssignableFrom(clazz) && !hasModelAttributeAnnotation(result))); + } - if (isStringOrViewReference(elementType.getRawClass())) { - String defaultViewName = getDefaultViewName(exchange, result); - 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")); - } + private Mono selectDefaultViewName(ServerWebExchange exchange, HandlerResult result) { + String defaultViewName = getDefaultViewName(exchange, result); + if (defaultViewName != null) { + return Mono.just(defaultViewName); } else { - // Eventually for model-related return values (should not happen now) - return Mono.error(new IllegalStateException("Unexpected return value type")); + return Mono.error(new IllegalStateException("Handler [" + result.getHandler() + "] " + + "neither returned a view name nor a View object")); } } @@ -191,6 +231,7 @@ public class ViewResolutionResultHandler implements HandlerResultHandler, Ordere * @return the default view name to use; if {@code null} is returned * processing will result in an IllegalStateException. */ + @SuppressWarnings("UnusedParameters") protected String getDefaultViewName(ServerWebExchange exchange, HandlerResult result) { String path = this.pathHelper.getLookupPathForRequest(exchange); if (path.startsWith("/")) { @@ -202,6 +243,49 @@ public class ViewResolutionResultHandler implements HandlerResultHandler, Ordere return StringUtils.stripFilenameExtension(path); } + private Object updateModel(HandlerResult result, Object value) { + if (value instanceof Model) { + result.getModel().addAllAttributes(((Model) value).asMap()); + } + else if (value instanceof Map) { + //noinspection unchecked + result.getModel().addAllAttributes((Map) value); + } + else if (result.getHandler() instanceof HandlerMethod) { + MethodParameter returnType = ((HandlerMethod) result.getHandler()).getReturnType(); + String name = getNameForReturnValue(value, returnType); + result.getModel().addAttribute(name, value); + } + else { + result.getModel().addAttribute(value); + } + return value; + } + + /** + * 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 + */ + private static String getNameForReturnValue(Object returnValue, 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); + } + } + private Mono handleUnresolvedViewName(String viewName) { return Mono.error(new IllegalStateException( "Could not resolve view with name '" + viewName + "'.")); 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 ff34752123d..492a1b4a551 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.lang.reflect.Method; import java.net.URI; -import java.net.URISyntaxException; import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.time.Duration; @@ -38,6 +37,8 @@ import rx.Single; import org.springframework.core.Ordered; import org.springframework.core.ResolvableType; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.ConfigurableConversionService; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.convert.support.ReactiveStreamsToRxJava1Converter; import org.springframework.core.io.buffer.DataBuffer; @@ -48,7 +49,10 @@ import org.springframework.http.server.reactive.MockServerHttpRequest; import org.springframework.http.server.reactive.MockServerHttpResponse; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.Model; import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.method.HandlerMethod; import org.springframework.web.reactive.HandlerResult; import org.springframework.web.reactive.HandlerResultHandler; import org.springframework.web.server.ServerWebExchange; @@ -56,7 +60,10 @@ 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.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; /** @@ -65,32 +72,30 @@ import static org.mockito.Mockito.mock; */ public class ViewResolutionResultHandlerTests { - private static final Charset UTF_8 = Charset.forName("UTF-8"); - - private MockServerHttpResponse response; private ModelMap model; - private DefaultConversionService conversionService; - @Before public void setUp() throws Exception { this.model = new ExtendedModelMap().addAttribute("id", "123"); - this.conversionService = new DefaultConversionService(); - this.conversionService.addConverter(new ReactiveStreamsToRxJava1Converter()); } @Test public void supportsWithNullReturnValue() throws Exception { - testSupports("handleString", null); - testSupports("handleView", null); - testSupports("handleMonoString", null); - testSupports("handleMonoView", null); - testSupports("handleSingleString", null); - testSupports("handleSingleView", null); + testSupports("handleString", true); + testSupports("handleView", true); + testSupports("handleMonoString", true); + testSupports("handleMonoView", true); + testSupports("handleSingleString", true); + testSupports("handleSingleView", true); + testSupports("handleModel", true); + testSupports("handleMap", true); + testSupports("handleModelAttributeAnnotation", true); + testSupports("handleTestBean", true); + testSupports("handleInteger", false); } @Test @@ -100,15 +105,15 @@ public class ViewResolutionResultHandlerTests { resolver1.setOrder(2); resolver2.setOrder(1); - assertEquals(Arrays.asList(resolver2, resolver1), - new ViewResolutionResultHandler(Arrays.asList(resolver1, resolver2), this.conversionService) - .getViewResolvers()); + assertEquals(Arrays.asList(resolver2, resolver1), new ViewResolutionResultHandler( + Arrays.asList(resolver1, resolver2), new DefaultConversionService()) + .getViewResolvers()); } @Test public void viewReference() throws Exception { Object value = new TestView("account"); - handle("/path", value, ResolvableType.forClass(View.class)); + handle("/path", value, "handleView"); new TestSubscriber().bindTo(this.response.getBody()) .assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf))); @@ -117,7 +122,7 @@ public class ViewResolutionResultHandlerTests { @Test public void viewReferenceInMono() throws Exception { Object value = Mono.just(new TestView("account")); - handle("/path", value, returnTypeFor("handleMonoView")); + handle("/path", value, "handleMonoView"); new TestSubscriber().bindTo(this.response.getBody()) .assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf))); @@ -126,7 +131,7 @@ public class ViewResolutionResultHandlerTests { @Test public void viewName() throws Exception { Object value = "account"; - handle("/path", value, ResolvableType.forClass(String.class), new TestViewResolver("account")); + handle("/path", value, "handleString", new TestViewResolver("account")); TestSubscriber subscriber = new TestSubscriber<>(); subscriber.bindTo(this.response.getBody()) @@ -136,7 +141,7 @@ public class ViewResolutionResultHandlerTests { @Test public void viewNameInMono() throws Exception { Object value = Mono.just("account"); - handle("/path", value, returnTypeFor("handleMonoString"), new TestViewResolver("account")); + handle("/path", value, "handleMonoString", new TestViewResolver("account")); new TestSubscriber().bindTo(this.response.getBody()) .assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf))); @@ -145,7 +150,7 @@ public class ViewResolutionResultHandlerTests { @Test public void viewNameWithMultipleResolvers() throws Exception { String value = "profile"; - handle("/path", value, ResolvableType.forClass(String.class), + handle("/path", value, "handleString", new TestViewResolver("account"), new TestViewResolver("profile")); new TestSubscriber().bindTo(this.response.getBody()) @@ -154,7 +159,7 @@ public class ViewResolutionResultHandlerTests { @Test public void viewNameUnresolved() throws Exception { - handle("/path", "account", ResolvableType.forClass(String.class)) + handle("/path", "account", "handleString") .assertErrorMessage("Could not resolve view with name 'account'."); } @@ -162,15 +167,15 @@ public class ViewResolutionResultHandlerTests { public void viewNameIsNull() throws Exception { ViewResolver resolver = new TestViewResolver("account"); - handle("/account", null, ResolvableType.forClass(String.class), resolver); + handle("/account", null, "handleString", resolver); new TestSubscriber().bindTo(this.response.getBody()) .assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf))); - handle("/account/", null, ResolvableType.forClass(String.class), resolver); + handle("/account/", null, "handleString", resolver); new TestSubscriber().bindTo(this.response.getBody()) .assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf))); - handle("/account.123", null, ResolvableType.forClass(String.class), resolver); + handle("/account.123", null, "handleString", resolver); new TestSubscriber().bindTo(this.response.getBody()) .assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf))); } @@ -178,28 +183,75 @@ public class ViewResolutionResultHandlerTests { @Test public void viewNameIsEmptyMono() throws Exception { Object value = Mono.empty(); - handle("/account", value, returnTypeFor("handleMonoString"), new TestViewResolver("account")); + handle("/account", value, "handleMonoString", new TestViewResolver("account")); new TestSubscriber().bindTo(this.response.getBody()) .assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf))); } + @Test + public void model() throws Exception { + Model value = new ExtendedModelMap().addAttribute("name", "Joe"); + handle("/account", value, "handleModel", new TestViewResolver("account")); + + new TestSubscriber().bindTo(this.response.getBody()) + .assertValuesWith(buf -> assertEquals("account: {id=123, name=Joe}", asString(buf))); + } + + @Test + public void map() throws Exception { + Map value = Collections.singletonMap("name", "Joe"); + handle("/account", value, "handleMap", new TestViewResolver("account")); + + new TestSubscriber().bindTo(this.response.getBody()) + .assertValuesWith(buf -> assertEquals("account: {id=123, name=Joe}", asString(buf))); + } + + @Test + public void modelAttributeAnnotation() throws Exception { + String value = "Joe"; + handle("/account", value, "handleModelAttributeAnnotation", new TestViewResolver("account")); + + new TestSubscriber().bindTo(this.response.getBody()) + .assertValuesWith(buf -> assertEquals("account: {id=123, name=Joe}", asString(buf))); + } + + @Test + public void testBean() throws Exception { + Object value = new TestBean("Joe"); + handle("/account", value, "handleTestBean", new TestViewResolver("account")); + + new TestSubscriber().bindTo(this.response.getBody()) + .assertValuesWith(buf -> assertEquals("account: {id=123, testBean=TestBean[name=Joe]}", asString(buf))); + } + - private void testSupports(String methodName, Object returnValue) throws NoSuchMethodException { + private void testSupports(String methodName, boolean supports) throws NoSuchMethodException { Method method = TestController.class.getMethod(methodName); ResolvableType returnType = ResolvableType.forMethodParameter(method, -1); - HandlerResult result = new HandlerResult(new Object(), returnValue, returnType, this.model); + HandlerResult result = new HandlerResult(new Object(), null, returnType, this.model); List resolvers = Collections.singletonList(mock(ViewResolver.class)); - ViewResolutionResultHandler handler = new ViewResolutionResultHandler(resolvers, this.conversionService); - assertTrue(handler.supports(result)); + ConfigurableConversionService conversionService = new DefaultConversionService(); + conversionService.addConverter(new ReactiveStreamsToRxJava1Converter()); + ViewResolutionResultHandler handler = new ViewResolutionResultHandler(resolvers, conversionService); + if (supports) { + assertTrue(handler.supports(result)); + } + else { + assertFalse(handler.supports(result)); + } } - private TestSubscriber handle(String path, Object value, ResolvableType type, - ViewResolver... resolvers) throws URISyntaxException { + private TestSubscriber handle(String path, Object value, String methodName, + ViewResolver... resolvers) throws Exception { List resolverList = Arrays.asList(resolvers); - HandlerResultHandler handler = new ViewResolutionResultHandler(resolverList, this.conversionService); - HandlerResult handlerResult = new HandlerResult(new Object(), value, type, this.model); + ConversionService conversionService = new DefaultConversionService(); + HandlerResultHandler handler = new ViewResolutionResultHandler(resolverList, conversionService); + Method method = TestController.class.getMethod(methodName); + HandlerMethod handlerMethod = new HandlerMethod(new TestController(), method); + ResolvableType type = ResolvableType.forMethodReturnType(method); + HandlerResult handlerResult = new HandlerResult(handlerMethod, value, type, this.model); ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI(path)); this.response = new MockServerHttpResponse(); @@ -212,13 +264,8 @@ public class ViewResolutionResultHandlerTests { return subscriber.bindTo(mono).await(Duration.ofSeconds(1)); } - private ResolvableType returnTypeFor(String methodName, Class... args) throws NoSuchMethodException { - Method method = TestController.class.getDeclaredMethod(methodName, args); - return ResolvableType.forMethodReturnType(method); - } - private static DataBuffer asDataBuffer(String value) { - ByteBuffer byteBuffer = ByteBuffer.wrap(value.getBytes(UTF_8)); + ByteBuffer byteBuffer = ByteBuffer.wrap(value.getBytes(Charset.forName("UTF-8"))); return new DefaultDataBufferFactory().wrap(byteBuffer); } @@ -226,7 +273,7 @@ public class ViewResolutionResultHandlerTests { ByteBuffer byteBuffer = dataBuffer.asByteBuffer(); final byte[] bytes = new byte[byteBuffer.remaining()]; byteBuffer.get(bytes); - return new String(bytes, UTF_8); + return new String(bytes, Charset.forName("UTF-8")); } @@ -310,6 +357,45 @@ public class ViewResolutionResultHandlerTests { public Single handleSingleView() { return null; } + + public Model handleModel() { + return null; + } + + public Map handleMap() { + return null; + } + + @ModelAttribute("name") + public String handleModelAttributeAnnotation() { + return null; + } + + public TestBean handleTestBean() { + return null; + } + + public int handleInteger() { + return 0; + } + } + + private static class TestBean { + + private final String name; + + public TestBean(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + @Override + public String toString() { + return "TestBean[name=" + this.name + "]"; + } } } \ No newline at end of file