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 extends Mono> 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