Add View, ViewResolver, and ViewResolverResultHandler

This commit adds a View and ViewResolver contracts to support HTML
template based rendering.

ViewResolverResultHandler applies view resolution by iterating the
resolvers to resolve to a view and then use it to render.
This commit is contained in:
Rossen Stoyanchev 2016-02-03 06:45:16 -05:00
parent 14997eccf3
commit 55d37c0522
7 changed files with 524 additions and 11 deletions

View File

@ -58,7 +58,7 @@ public class HandlerResult {
this.handler = handler;
this.returnValue = Optional.ofNullable(returnValue);
this.returnValueType = returnValueType;
this.model = new ExtendedModelMap();
this.model = model;
}

View File

@ -0,0 +1,59 @@
/*
* 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;
import java.util.List;
import java.util.Optional;
import reactor.core.publisher.Flux;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.MediaType;
import org.springframework.web.server.ServerWebExchange;
/**
* Contract to render {@link HandlerResult} to the HTTP response.
*
* <p>In contrast to an {@link org.springframework.core.codec.Encoder Encoder}
* which is a singleton and encodes any object of a given type, a {@code View}
* is typically selected by name and resolved using a {@link ViewResolver}
* which may for example match it to an HTML template. Furthermore a {@code View}
* may render based on multiple attributes contained in the model.
*
* <p>A {@code View} can also choose to select an attribute from the model use
* any existing {@code Encoder} to render alternate media types.
*
* @author Rossen Stoyanchev
*/
public interface View {
/**
* Return the list of media types this encoder supports.
*/
List<MediaType> getSupportedMediaTypes();
/**
* Render the view based on the given {@link HandlerResult}. Implementations
* can access and use the model or only a specific attribute in it.
* @param result the result from handler execution
* @param contentType the content type selected to render with which should
* match one of the {@link #getSupportedMediaTypes() supported media types}.
* @param exchange the current exchange
* @return the output stream
*/
Flux<DataBuffer> render(HandlerResult result, Optional<MediaType> contentType, ServerWebExchange exchange);
}

View File

@ -0,0 +1,30 @@
package org.springframework.web.reactive;
import java.util.Locale;
import reactor.core.publisher.Mono;
/**
* Contract to resolve a view name to a {@link View} instance. The view name may
* correspond to an HTML template or be generated dynamically.
*
* <p>The process of view resolution is driven through a ViewResolver-based
* {@code HandlerResultHandler} implementation called
* {@link org.springframework.web.reactive.view.ViewResolverResultHandler
* ViewResolverResultHandler}.
*
* @author Rossen Stoyanchev
* @see org.springframework.web.reactive.view.ViewResolverResultHandler
*/
public interface ViewResolver {
/**
* Resolve the view name to a View instance.
* @param viewName the name of the view to resolve
* @param locale the locale for the request
* @return the resolved view or an empty stream
*/
Mono<View> resolveViewName(String viewName, Locale locale);
}

View File

@ -0,0 +1,139 @@
/*
* 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.view;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.util.Assert;
import org.springframework.web.reactive.HandlerResult;
import org.springframework.web.reactive.HandlerResultHandler;
import org.springframework.web.reactive.View;
import org.springframework.web.reactive.ViewResolver;
import org.springframework.web.server.ServerWebExchange;
/**
* {@code HandlerResultHandler} that resolves a String return value from a
* handler to a {@link View} which is then used to render the response.
* A handler may also return a {@code View} instance and/or async variants that
* provide a String view name or a {@code View}.
*
* <p>This result handler should be ordered after others that may also interpret
* a String return value for example in combination with {@code @ResponseBody}.
*
* @author Rossen Stoyanchev
*/
public class ViewResolverResultHandler implements HandlerResultHandler {
private final List<ViewResolver> viewResolvers = new ArrayList<>(4);
private final ConversionService conversionService;
public ViewResolverResultHandler(List<ViewResolver> resolvers, ConversionService service) {
Assert.notEmpty(resolvers, "At least one ViewResolver is required.");
Assert.notNull(service, "'conversionService' is required.");
this.viewResolvers.addAll(resolvers);
this.conversionService = service;
}
/**
* Return a read-only list of view resolvers.
*/
public List<ViewResolver> getViewResolvers() {
return Collections.unmodifiableList(this.viewResolvers);
}
// TODO: @ModelAttribute return value, declared Object return value (either String or View)
@Override
public boolean supports(HandlerResult result) {
Class<?> clazz = result.getReturnValueType().getRawClass();
if (isViewNameOrViewReference(clazz)) {
return true;
}
if (this.conversionService.canConvert(clazz, Mono.class)) {
clazz = result.getReturnValueType().getGeneric(0).getRawClass();
return isViewNameOrViewReference(clazz);
}
return false;
}
private boolean isViewNameOrViewReference(Class<?> clazz) {
return (CharSequence.class.isAssignableFrom(clazz) || View.class.isAssignableFrom(clazz));
}
@Override
public Mono<Void> handleResult(ServerWebExchange exchange, HandlerResult result) {
Mono<?> returnValueMono;
if (this.conversionService.canConvert(result.getReturnValueType().getRawClass(), Mono.class)) {
returnValueMono = this.conversionService.convert(result.getReturnValue().get(), Mono.class);
}
else if (result.getReturnValue().isPresent()) {
returnValueMono = Mono.just(result.getReturnValue().get());
}
else {
Optional<String> viewName = getDefaultViewName(result, exchange);
if (viewName.isPresent()) {
returnValueMono = Mono.just(viewName.get());
}
else {
returnValueMono = Mono.error(new IllegalStateException("Handler [" + result.getHandler() + "] " +
"neither returned a view name nor a View object"));
}
}
return returnValueMono.then(returnValue -> {
if (returnValue instanceof View) {
Flux<DataBuffer> body = ((View) returnValue).render(result, Optional.empty(), exchange);
return exchange.getResponse().setBody(body);
}
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()
.then(view -> {
Flux<DataBuffer> body = view.render(result, Optional.empty(), exchange);
return exchange.getResponse().setBody(body);
});
}
else {
// Should not happen
return Mono.error(new IllegalStateException(
"Unexpected return value: " + returnValue.getClass()));
}
});
}
protected Optional<String> getDefaultViewName(HandlerResult result, ServerWebExchange exchange) {
return Optional.empty();
}
}

View File

@ -0,0 +1,4 @@
/**
* Support for result handling through view resolution.
*/
package org.springframework.web.reactive.view;

View File

@ -28,7 +28,7 @@ import org.springframework.http.HttpStatus;
/**
* @author Rossen Stoyanchev
*/
public class MockServerHttpResponse extends AbstractServerHttpResponse {
public class MockServerHttpResponse implements ServerHttpResponse {
private HttpStatus status;
@ -56,19 +56,11 @@ public class MockServerHttpResponse extends AbstractServerHttpResponse {
}
@Override
protected Mono<Void> setBodyInternal(Publisher<DataBuffer> body) {
public Mono<Void> setBody(Publisher<DataBuffer> body) {
this.body = body;
return Flux.from(this.body).after();
}
@Override
protected void writeHeaders() {
}
@Override
protected void writeCookies() {
}
@Override
public void beforeCommit(Supplier<? extends Mono<Void>> action) {
}

View File

@ -0,0 +1,289 @@
/*
* 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.view;
import java.lang.reflect.Method;
import java.net.URI;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.junit.Before;
import org.junit.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.test.TestSubscriber;
import rx.Single;
import org.springframework.core.ResolvableType;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.core.convert.support.ReactiveStreamsToRxJava1Converter;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DefaultDataBufferAllocator;
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.ui.ExtendedModelMap;
import org.springframework.ui.ModelMap;
import org.springframework.web.reactive.HandlerResult;
import org.springframework.web.reactive.HandlerResultHandler;
import org.springframework.web.reactive.View;
import org.springframework.web.reactive.ViewResolver;
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.hamcrest.CoreMatchers.endsWith;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
/**
* Unit tests for {@link ViewResolverResultHandler}.
* @author Rossen Stoyanchev
*/
public class ViewResolverResultHandlerTests {
private static final Charset UTF_8 = Charset.forName("UTF-8");
private MockServerHttpResponse response;
private ServerWebExchange exchange;
private ModelMap model;
private DefaultConversionService conversionService;
@Before
public void setUp() throws Exception {
ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path"));
this.response = new MockServerHttpResponse();
WebSessionManager sessionManager = new DefaultWebSessionManager();
this.exchange = new DefaultServerWebExchange(request, this.response, sessionManager);
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);
}
private void testSupports(String methodName, Object returnValue) throws NoSuchMethodException {
Method method = TestController.class.getMethod(methodName);
ResolvableType returnType = ResolvableType.forMethodParameter(method, -1);
HandlerResult result = new HandlerResult(new Object(), returnValue, returnType, this.model);
List<ViewResolver> resolvers = Collections.singletonList(mock(ViewResolver.class));
ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService);
assertTrue(handler.supports(result));
}
@Test
public void viewReference() throws Exception {
TestView view = new TestView("account");
List<ViewResolver> resolvers = Collections.singletonList(mock(ViewResolver.class));
ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService);
handle(handler, view, ResolvableType.forClass(View.class));
new TestSubscriber<DataBuffer>().bindTo(this.response.getBody())
.assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf)));
}
@Test
public void viewReferenceMono() throws Exception {
TestView view = new TestView("account");
List<ViewResolver> resolvers = Collections.singletonList(mock(ViewResolver.class));
ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService);
handle(handler, Mono.just(view), ResolvableType.forClass(Mono.class));
new TestSubscriber<DataBuffer>().bindTo(this.response.getBody())
.assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf)));
}
@Test
public void viewName() throws Exception {
TestView view = new TestView("account");
TestViewResolver resolver = new TestViewResolver().addView(view);
List<ViewResolver> resolvers = Collections.singletonList(resolver);
ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService);
handle(handler, "account", ResolvableType.forClass(String.class));
TestSubscriber<DataBuffer> subscriber = new TestSubscriber<>();
subscriber.bindTo(this.response.getBody())
.assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf)));
}
@Test
public void viewNameMono() throws Exception {
TestView view = new TestView("account");
TestViewResolver resolver = new TestViewResolver().addView(view);
List<ViewResolver> resolvers = Collections.singletonList(resolver);
ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService);
handle(handler, Mono.just("account"), ResolvableType.forClass(Mono.class));
new TestSubscriber<DataBuffer>().bindTo(this.response.getBody())
.assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf)));
}
@Test
public void viewNameWithMultipleResolvers() throws Exception {
TestView view1 = new TestView("account");
TestView view2 = new TestView("profile");
TestViewResolver resolver1 = new TestViewResolver().addView(view1);
TestViewResolver resolver2 = new TestViewResolver().addView(view2);
List<ViewResolver> resolvers = Arrays.asList(resolver1, resolver2);
ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService);
handle(handler, "profile", ResolvableType.forClass(String.class));
new TestSubscriber<DataBuffer>().bindTo(this.response.getBody())
.assertValuesWith(buf -> assertEquals("profile: {id=123}", asString(buf)));
}
@Test
public void viewNameWithNoMatch() throws Exception {
List<ViewResolver> resolvers = Collections.singletonList(mock(ViewResolver.class));
ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService);
TestSubscriber<Void> subscriber = handle(handler, "account", ResolvableType.forClass(String.class));
subscriber.assertNoValues();
}
@Test
public void viewNameNotSpecified() throws Exception {
List<ViewResolver> resolvers = Collections.singletonList(mock(ViewResolver.class));
ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService);
TestSubscriber<Void> subscriber = handle(handler, null, ResolvableType.forClass(String.class));
subscriber.assertErrorWith(ex ->
assertThat(ex.getMessage(), endsWith("neither returned a view name nor a View object")));
}
private TestSubscriber<Void> handle(HandlerResultHandler handler, Object value, ResolvableType type) {
HandlerResult result = new HandlerResult(new Object(), value, type, this.model);
Mono<Void> mono = handler.handleResult(this.exchange, result);
TestSubscriber<Void> subscriber = new TestSubscriber<>();
return subscriber.bindTo(mono).await(1, TimeUnit.SECONDS);
}
private static DataBuffer asDataBuffer(String value) {
ByteBuffer byteBuffer = ByteBuffer.wrap(value.getBytes(UTF_8));
return new DefaultDataBufferAllocator().wrap(byteBuffer);
}
private static String asString(DataBuffer dataBuffer) {
ByteBuffer byteBuffer = dataBuffer.asByteBuffer();
final byte[] bytes = new byte[byteBuffer.remaining()];
byteBuffer.get(bytes);
return new String(bytes, UTF_8);
}
private static class TestViewResolver implements ViewResolver {
private final Map<String, View> views = new HashMap<>();
public TestViewResolver addView(TestView view) {
this.views.put(view.getName(), view);
return this;
}
@Override
public Mono<View> resolveViewName(String viewName, Locale locale) {
View view = this.views.get(viewName);
return (view != null ? Mono.just(view) : Mono.empty());
}
}
public static final class TestView implements View {
private final String name;
public TestView(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
@Override
public List<MediaType> getSupportedMediaTypes() {
return null;
}
@Override
public Flux<DataBuffer> render(HandlerResult result, Optional<MediaType> contentType,
ServerWebExchange exchange) {
String value = this.name + ": " + result.getModel().toString();
assertNotNull(value);
return Flux.just(asDataBuffer(value));
}
}
@SuppressWarnings("unused")
private static class TestController {
public String handleString() {
return null;
}
public Mono<String> handleMonoString() {
return null;
}
public Single<String> handleSingleString() {
return null;
}
public View handleView() {
return null;
}
public Mono<View> handleMonoView() {
return null;
}
public Single<View> handleSingleView() {
return null;
}
}
}