Browse Source
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.pull/1111/head
7 changed files with 524 additions and 11 deletions
@ -0,0 +1,59 @@
@@ -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); |
||||
|
||||
} |
||||
@ -0,0 +1,30 @@
@@ -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); |
||||
|
||||
} |
||||
@ -0,0 +1,139 @@
@@ -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(); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
/** |
||||
* Support for result handling through view resolution. |
||||
*/ |
||||
package org.springframework.web.reactive.view; |
||||
@ -0,0 +1,289 @@
@@ -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; |
||||
} |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue