Browse Source

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).
pull/1111/head
Rossen Stoyanchev 10 years ago
parent
commit
8cc72b320b
  1. 2
      spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/View.java
  2. 110
      spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java
  3. 72
      spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java

2
spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/View.java

@ -43,7 +43,7 @@ import org.springframework.web.server.ServerWebExchange; @@ -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<MediaType> getSupportedMediaTypes();

110
spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java

@ -34,17 +34,20 @@ import org.springframework.core.Ordered; @@ -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; @@ -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.
*
* <p>This result handler should be ordered late relative to other result
* handlers. See {@link #setOrder(int)} for more details.
* <p>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<ViewResolver> viewResolvers = new ArrayList<>(4);
private final ConversionService conversionService;
private int order = Ordered.LOWEST_PRECEDENCE;
private final List<View> 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<ViewResolver> 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<ViewResolver> resolvers, ConversionService service) {
Assert.notNull(service, "'conversionService' is required.");
public ViewResolutionResultHandler(List<ViewResolver> 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 @@ -100,20 +117,18 @@ public class ViewResolutionResultHandler implements HandlerResultHandler, Ordere
}
/**
* Set the order for this result handler relative to others.
* <p>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<View> defaultViews) {
this.defaultViews.clear();
if (defaultViews != null) {
this.defaultViews.addAll(defaultViews);
}
}
@Override
public int getOrder() {
return this.order;
public List<View> getDefaultViews() {
return this.defaultViews;
}
@Override
@ -125,7 +140,7 @@ public class ViewResolutionResultHandler implements HandlerResultHandler, Ordere @@ -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 @@ -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<Object> 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 @@ -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 @@ -281,9 +293,39 @@ public class ViewResolutionResultHandler implements HandlerResultHandler, Ordere
}
}
private Mono<View> handleUnresolvedViewName(String viewName) {
return Mono.error(new IllegalStateException(
"Could not resolve view with name '" + viewName + "'."));
private Mono<? extends Void> 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<MediaType> 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<MediaType> getProducibleMediaTypes(List<View> views) {
List<MediaType> result = new ArrayList<>();
views.forEach(view -> result.addAll(view.getSupportedMediaTypes()));
return result;
}
}

72
spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java

@ -47,7 +47,6 @@ import org.springframework.http.HttpMethod; @@ -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; @@ -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; @@ -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 { @@ -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 { @@ -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 { @@ -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<DataBuffer>().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 { @@ -246,18 +279,24 @@ public class ViewResolutionResultHandlerTests {
private TestSubscriber<Void> handle(String path, Object value, String methodName,
ViewResolver... resolvers) throws Exception {
List<ViewResolver> resolverList = Arrays.asList(resolvers);
return handle(path, value, methodName, Arrays.asList(resolvers), Collections.emptyList());
}
private TestSubscriber<Void> handle(String path, Object value, String methodName,
List<ViewResolver> resolvers, List<View> 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<Void> mono = handler.handleResult(exchange, handlerResult);
@ -289,6 +328,10 @@ public class ViewResolutionResultHandlerTests { @@ -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 { @@ -310,6 +353,8 @@ public class ViewResolutionResultHandlerTests {
private final String name;
private List<MediaType> mediaTypes = Collections.singletonList(MediaType.TEXT_PLAIN);
public TestView(String name) {
this.name = name;
@ -319,9 +364,13 @@ public class ViewResolutionResultHandlerTests { @@ -319,9 +364,13 @@ public class ViewResolutionResultHandlerTests {
return this.name;
}
public void setMediaTypes(List<MediaType> mediaTypes) {
this.mediaTypes = mediaTypes;
}
@Override
public List<MediaType> getSupportedMediaTypes() {
return null;
return this.mediaTypes;
}
@Override
@ -329,6 +378,9 @@ public class ViewResolutionResultHandlerTests { @@ -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)));
}
}

Loading…
Cancel
Save