diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java index 28a691e3d5d..6f4bd8220bc 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerResult.java @@ -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; } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/View.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/View.java new file mode 100644 index 00000000000..91fb8c3dd01 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/View.java @@ -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. + * + *

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. + * + *

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 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 render(HandlerResult result, Optional contentType, ServerWebExchange exchange); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/ViewResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/ViewResolver.java new file mode 100644 index 00000000000..942f0501937 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/ViewResolver.java @@ -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. + * + *

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 resolveViewName(String viewName, Locale locale); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/ViewResolverResultHandler.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/ViewResolverResultHandler.java new file mode 100644 index 00000000000..76c69134f15 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/ViewResolverResultHandler.java @@ -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}. + * + *

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 viewResolvers = new ArrayList<>(4); + + private final ConversionService conversionService; + + + public ViewResolverResultHandler(List 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 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 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 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 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 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 getDefaultViewName(HandlerResult result, ServerWebExchange exchange) { + return Optional.empty(); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/package-info.java new file mode 100644 index 00000000000..602c2ea9924 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/view/package-info.java @@ -0,0 +1,4 @@ +/** + * Support for result handling through view resolution. + */ +package org.springframework.web.reactive.view; diff --git a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpResponse.java b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpResponse.java index cfd7f18305f..4fd1d1e2b2b 100644 --- a/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpResponse.java +++ b/spring-web-reactive/src/test/java/org/springframework/http/server/reactive/MockServerHttpResponse.java @@ -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 setBodyInternal(Publisher body) { + public Mono setBody(Publisher body) { this.body = body; return Flux.from(this.body).after(); } - @Override - protected void writeHeaders() { - } - - @Override - protected void writeCookies() { - } - @Override public void beforeCommit(Supplier> action) { } diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/view/ViewResolverResultHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/view/ViewResolverResultHandlerTests.java new file mode 100644 index 00000000000..36a787e3497 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/view/ViewResolverResultHandlerTests.java @@ -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 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 resolvers = Collections.singletonList(mock(ViewResolver.class)); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + handle(handler, view, ResolvableType.forClass(View.class)); + + new TestSubscriber().bindTo(this.response.getBody()) + .assertValuesWith(buf -> assertEquals("account: {id=123}", asString(buf))); + } + + @Test + public void viewReferenceMono() throws Exception { + TestView view = new TestView("account"); + List resolvers = Collections.singletonList(mock(ViewResolver.class)); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + handle(handler, Mono.just(view), ResolvableType.forClass(Mono.class)); + + new TestSubscriber().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 resolvers = Collections.singletonList(resolver); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + handle(handler, "account", ResolvableType.forClass(String.class)); + + TestSubscriber 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 resolvers = Collections.singletonList(resolver); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + handle(handler, Mono.just("account"), ResolvableType.forClass(Mono.class)); + + new TestSubscriber().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 resolvers = Arrays.asList(resolver1, resolver2); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + handle(handler, "profile", ResolvableType.forClass(String.class)); + + new TestSubscriber().bindTo(this.response.getBody()) + .assertValuesWith(buf -> assertEquals("profile: {id=123}", asString(buf))); + } + + @Test + public void viewNameWithNoMatch() throws Exception { + List resolvers = Collections.singletonList(mock(ViewResolver.class)); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + TestSubscriber subscriber = handle(handler, "account", ResolvableType.forClass(String.class)); + + subscriber.assertNoValues(); + } + + @Test + public void viewNameNotSpecified() throws Exception { + List resolvers = Collections.singletonList(mock(ViewResolver.class)); + ViewResolverResultHandler handler = new ViewResolverResultHandler(resolvers, this.conversionService); + TestSubscriber 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 handle(HandlerResultHandler handler, Object value, ResolvableType type) { + HandlerResult result = new HandlerResult(new Object(), value, type, this.model); + Mono mono = handler.handleResult(this.exchange, result); + TestSubscriber 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 views = new HashMap<>(); + + + public TestViewResolver addView(TestView view) { + this.views.put(view.getName(), view); + return this; + } + + @Override + public Mono 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 getSupportedMediaTypes() { + return null; + } + + @Override + public Flux render(HandlerResult result, Optional 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 handleMonoString() { + return null; + } + + public Single handleSingleString() { + return null; + } + + public View handleView() { + return null; + } + + public Mono handleMonoView() { + return null; + } + + public Single handleSingleView() { + return null; + } + } + +} \ No newline at end of file