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))); } }