From 8cc72b320bc86e5acb380f3588d1ec926ed1e681 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Tue, 31 May 2016 21:51:24 -0400 Subject: [PATCH] View resolution with content negotiation ViewResolutionResultHandler and ResponseBodyResultHandler now share a common base class ContentNegotiatingResultHandlerSupport that supports content negotiation. For view resolution we compare against the supported media types of resolved View instances, which may include default View's delegating to an HttpMessageConverter (e.g. JSON, XML, rendering). --- .../web/reactive/result/view/View.java | 2 +- .../view/ViewResolutionResultHandler.java | 110 ++++++++++++------ .../ViewResolutionResultHandlerTests.java | 72 ++++++++++-- 3 files changed, 139 insertions(+), 45 deletions(-) diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/View.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/View.java index a1689b00ef8..a6e82eb517b 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/View.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/View.java @@ -43,7 +43,7 @@ import org.springframework.web.server.ServerWebExchange; public interface View { /** - * Return the list of media types this encoder supports. + * Return the list of media types this View supports, or an empty list. */ List getSupportedMediaTypes(); 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 07ca656298b..716e5fca999 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 @@ -34,17 +34,20 @@ 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.http.MediaType; 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.reactive.accept.HeaderContentTypeResolver; +import org.springframework.web.reactive.accept.RequestedContentTypeResolver; +import org.springframework.web.reactive.result.ContentNegotiatingResultHandlerSupport; +import org.springframework.web.server.NotAcceptableStatusException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.util.HttpRequestPathHelper; - /** * {@code HandlerResultHandler} that encapsulates the view resolution algorithm * supporting the following return types: @@ -63,32 +66,46 @@ import org.springframework.web.util.HttpRequestPathHelper; * 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. + *

By default this resolver is ordered at {@link Ordered#LOWEST_PRECEDENCE} + * and generally needs to be late in the order since it interprets any String + * return value as a view name while others may interpret the same otherwise + * based on annotations (e.g. for {@code @ResponseBody}). * * @author Rossen Stoyanchev */ -public class ViewResolutionResultHandler implements HandlerResultHandler, Ordered { +public class ViewResolutionResultHandler extends ContentNegotiatingResultHandlerSupport + implements HandlerResultHandler, Ordered { private final List viewResolvers = new ArrayList<>(4); - private final ConversionService conversionService; - - private int order = Ordered.LOWEST_PRECEDENCE; + private final List defaultViews = new ArrayList<>(4); private final HttpRequestPathHelper pathHelper = new HttpRequestPathHelper(); + /** + * Constructor with {@code ViewResolver}s and a {@code ConversionService} only + * and creating a {@link HeaderContentTypeResolver}, i.e. using Accept header + * to determine the requested content type. + * @param resolvers the resolver to use + * @param conversionService for converting other reactive types (e.g. rx.Single) to Mono + */ + public ViewResolutionResultHandler(List resolvers, ConversionService conversionService) { + this(resolvers, conversionService, new HeaderContentTypeResolver()); + } + /** * Constructor with {@code ViewResolver}s tand a {@code ConversionService}. * @param resolvers the resolver to use - * @param service for converting other reactive types (e.g. rx.Single) to Mono + * @param conversionService for converting other reactive types (e.g. rx.Single) to Mono + * @param contentTypeResolver for resolving the requested content type */ - public ViewResolutionResultHandler(List resolvers, ConversionService service) { - Assert.notNull(service, "'conversionService' is required."); + public ViewResolutionResultHandler(List resolvers, ConversionService conversionService, + RequestedContentTypeResolver contentTypeResolver) { + + super(conversionService, contentTypeResolver); this.viewResolvers.addAll(resolvers); AnnotationAwareOrderComparator.sort(this.viewResolvers); - this.conversionService = service; } @@ -100,20 +117,18 @@ public class ViewResolutionResultHandler implements HandlerResultHandler, Ordere } /** - * Set the order for this result handler relative to others. - *

By default this is set to {@link Ordered#LOWEST_PRECEDENCE} and - * generally needs to be used late in the order since it interprets any - * String return value as a view name while others may interpret the same - * otherwise based on annotations (e.g. for {@code @ResponseBody}). - * @param order the order + * Set the default views to consider always when resolving view names and + * trying to satisfy the best matching content type. */ - public void setOrder(int order) { - this.order = order; + public void setDefaultViews(List defaultViews) { + this.defaultViews.clear(); + if (defaultViews != null) { + this.defaultViews.addAll(defaultViews); + } } - @Override - public int getOrder() { - return this.order; + public List getDefaultViews() { + return this.defaultViews; } @Override @@ -125,7 +140,7 @@ public class ViewResolutionResultHandler implements HandlerResultHandler, Ordere if (isSupportedType(clazz)) { return true; } - if (this.conversionService.canConvert(clazz, Mono.class)) { + if (getConversionService().canConvert(clazz, Mono.class)) { clazz = result.getReturnValueType().getGeneric(0).getRawClass(); return isSupportedType(clazz); } @@ -155,10 +170,10 @@ public class ViewResolutionResultHandler implements HandlerResultHandler, Ordere ResolvableType elementType; ResolvableType returnType = result.getReturnValueType(); - if (this.conversionService.canConvert(returnType.getRawClass(), Mono.class)) { + if (getConversionService().canConvert(returnType.getRawClass(), Mono.class)) { Optional optionalValue = result.getReturnValue(); if (optionalValue.isPresent()) { - Mono converted = this.conversionService.convert(optionalValue.get(), Mono.class); + Mono converted = getConversionService().convert(optionalValue.get(), Mono.class); valueMono = converted.map(o -> o); } else { @@ -188,11 +203,8 @@ public class ViewResolutionResultHandler implements HandlerResultHandler, Ordere else if (returnValue instanceof CharSequence) { String viewName = returnValue.toString(); Locale locale = Locale.getDefault(); // TODO - return Flux.fromIterable(getViewResolvers()) - .concatMap(resolver -> resolver.resolveViewName(viewName, locale)) - .next() - .otherwiseIfEmpty(handleUnresolvedViewName(viewName)) - .then(view -> view.render(result, null, exchange)); + return resolveViewAndRender(viewName, locale, result, exchange); + } else { // Should not happen @@ -281,9 +293,39 @@ public class ViewResolutionResultHandler implements HandlerResultHandler, Ordere } } - private Mono handleUnresolvedViewName(String viewName) { - return Mono.error(new IllegalStateException( - "Could not resolve view with name '" + viewName + "'.")); + private Mono resolveViewAndRender(String viewName, Locale locale, + HandlerResult result, ServerWebExchange exchange) { + + return Flux.fromIterable(getViewResolvers()) + .concatMap(resolver -> resolver.resolveViewName(viewName, locale)) + .switchIfEmpty(Mono.error( + new IllegalStateException( + "Could not resolve view with name '" + viewName + "'."))) + .asList() + .then(views -> { + views.addAll(getDefaultViews()); + + List producibleTypes = getProducibleMediaTypes(views); + MediaType bestMediaType = selectMediaType(exchange, producibleTypes); + + if (bestMediaType != null) { + for (View view : views) { + for (MediaType supported : view.getSupportedMediaTypes()) { + if (supported.isCompatibleWith(bestMediaType)) { + return view.render(result, bestMediaType, exchange); + } + } + } + } + + return Mono.error(new NotAcceptableStatusException(producibleTypes)); + }); + } + + private List getProducibleMediaTypes(List views) { + List result = new ArrayList<>(); + views.forEach(view -> result.addAll(view.getSupportedMediaTypes())); + return result; } } 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 35132680eac..6e1d59607df 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 @@ -47,7 +47,6 @@ import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.server.reactive.MockServerHttpRequest; import org.springframework.http.server.reactive.MockServerHttpResponse; -import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.ui.ExtendedModelMap; import org.springframework.ui.Model; @@ -55,7 +54,7 @@ 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.NotAcceptableStatusException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.adapter.DefaultServerWebExchange; import org.springframework.web.server.session.DefaultWebSessionManager; @@ -73,6 +72,8 @@ import static org.mockito.Mockito.mock; */ public class ViewResolutionResultHandlerTests { + private MockServerHttpRequest request; + private MockServerHttpResponse response; private ModelMap model; @@ -81,6 +82,8 @@ public class ViewResolutionResultHandlerTests { @Before public void setUp() throws Exception { this.model = new ExtendedModelMap().addAttribute("id", "123"); + this.request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + this.response = new MockServerHttpResponse(); } @@ -101,8 +104,8 @@ public class ViewResolutionResultHandlerTests { @Test public void order() throws Exception { - TestViewResolver resolver1 = new TestViewResolver(); - TestViewResolver resolver2 = new TestViewResolver(); + TestViewResolver resolver1 = new TestViewResolver(new String[] {}); + TestViewResolver resolver2 = new TestViewResolver(new String[] {}); resolver1.setOrder(2); resolver2.setOrder(1); @@ -226,6 +229,36 @@ public class ViewResolutionResultHandlerTests { .assertValuesWith(buf -> assertEquals("account: {id=123, testBean=TestBean[name=Joe]}", asString(buf))); } + @Test + public void selectBestMediaType() throws Exception { + TestView htmlView = new TestView("account"); + htmlView.setMediaTypes(Collections.singletonList(MediaType.TEXT_HTML)); + + TestView jsonView = new TestView("defaultView"); + jsonView.setMediaTypes(Collections.singletonList(MediaType.APPLICATION_JSON)); + + this.request.getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + + handle("/account", "account", "handleString", + Collections.singletonList(new TestViewResolver(htmlView)), + Collections.singletonList(jsonView)); + + assertEquals(MediaType.APPLICATION_JSON, this.response.getHeaders().getContentType()); + new TestSubscriber().bindTo(this.response.getBody()) + .assertValuesWith(buf -> assertEquals("defaultView: {id=123}", asString(buf))); + } + + @Test + public void selectBestMediaTypeNotAcceptable() throws Exception { + TestView htmlView = new TestView("account"); + htmlView.setMediaTypes(Collections.singletonList(MediaType.TEXT_HTML)); + + this.request.getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + + handle("/account", "account", "handleString", new TestViewResolver(htmlView)) + .assertError(NotAcceptableStatusException.class); + + } private void testSupports(String methodName, boolean supports) throws NoSuchMethodException { Method method = TestController.class.getMethod(methodName); @@ -246,18 +279,24 @@ public class ViewResolutionResultHandlerTests { private TestSubscriber handle(String path, Object value, String methodName, ViewResolver... resolvers) throws Exception { - List resolverList = Arrays.asList(resolvers); + return handle(path, value, methodName, Arrays.asList(resolvers), Collections.emptyList()); + } + + private TestSubscriber handle(String path, Object value, String methodName, + List resolvers, List defaultViews) throws Exception { + ConversionService conversionService = new DefaultConversionService(); - HandlerResultHandler handler = new ViewResolutionResultHandler(resolverList, conversionService); + ViewResolutionResultHandler handler = new ViewResolutionResultHandler(resolvers, conversionService); + handler.setDefaultViews(defaultViews); + 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(); + this.request.setUri(new URI(path)); WebSessionManager sessionManager = new DefaultWebSessionManager(); - ServerWebExchange exchange = new DefaultServerWebExchange(request, this.response, sessionManager); + ServerWebExchange exchange = new DefaultServerWebExchange(this.request, this.response, sessionManager); Mono mono = handler.handleResult(exchange, handlerResult); @@ -289,6 +328,10 @@ public class ViewResolutionResultHandlerTests { Arrays.stream(viewNames).forEach(name -> this.views.put(name, new TestView(name))); } + public TestViewResolver(TestView... views) { + Arrays.stream(views).forEach(view -> this.views.put(view.getName(), view)); + } + public void setOrder(int order) { this.order = order; } @@ -310,6 +353,8 @@ public class ViewResolutionResultHandlerTests { private final String name; + private List mediaTypes = Collections.singletonList(MediaType.TEXT_PLAIN); + public TestView(String name) { this.name = name; @@ -319,9 +364,13 @@ public class ViewResolutionResultHandlerTests { return this.name; } + public void setMediaTypes(List mediaTypes) { + this.mediaTypes = mediaTypes; + } + @Override public List getSupportedMediaTypes() { - return null; + return this.mediaTypes; } @Override @@ -329,6 +378,9 @@ public class ViewResolutionResultHandlerTests { String value = this.name + ": " + result.getModel().toString(); assertNotNull(value); ServerHttpResponse response = exchange.getResponse(); + if (mediaType != null) { + response.getHeaders().setContentType(mediaType); + } return response.writeWith(Flux.just(asDataBuffer(value))); } }