diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/AbstractView.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/AbstractView.java index c0440c7072..bb8b13ddf7 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/AbstractView.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/AbstractView.java @@ -18,13 +18,13 @@ package org.springframework.web.reactive.result.view; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ReactiveAdapterRegistry; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.context.ApplicationContext; @@ -48,9 +48,13 @@ public abstract class AbstractView implements View, ApplicationContextAware { /** Logger that is available to subclasses */ protected final Log logger = LogFactory.getLog(getClass()); + private static final Object NO_VALUE = new Object(); + private final List mediaTypes = new ArrayList<>(4); + private final ReactiveAdapterRegistry adapterRegistry; + private Charset defaultCharset = StandardCharsets.UTF_8; private String requestContextAttribute; @@ -59,7 +63,12 @@ public abstract class AbstractView implements View, ApplicationContextAware { public AbstractView() { + this(new ReactiveAdapterRegistry()); + } + + public AbstractView(ReactiveAdapterRegistry registry) { this.mediaTypes.add(ViewResolverSupport.DEFAULT_CONTENT_TYPE); + this.adapterRegistry = registry; } @@ -146,14 +155,13 @@ public abstract class AbstractView implements View, ApplicationContextAware { exchange.getResponse().getHeaders().setContentType(contentType); } - Map mergedModel = getModelAttributes(model, exchange); - - // Expose RequestContext? - if (this.requestContextAttribute != null) { - mergedModel.put(this.requestContextAttribute, createRequestContext(exchange, mergedModel)); - } - - return renderInternal(mergedModel, contentType, exchange); + return getModelAttributes(model, exchange).then(mergedModel -> { + // Expose RequestContext? + if (this.requestContextAttribute != null) { + mergedModel.put(this.requestContextAttribute, createRequestContext(exchange, mergedModel)); + } + return renderInternal(mergedModel, contentType, exchange); + }); } /** @@ -161,7 +169,7 @@ public abstract class AbstractView implements View, ApplicationContextAware { *

The default implementation creates a combined output Map that includes * model as well as static attributes with the former taking precedence. */ - protected Map getModelAttributes(Map model, ServerWebExchange exchange) { + protected Mono> getModelAttributes(Map model, ServerWebExchange exchange) { int size = (model != null ? model.size() : 0); Map attributes = new LinkedHashMap<>(size); @@ -169,7 +177,55 @@ public abstract class AbstractView implements View, ApplicationContextAware { attributes.putAll(model); } - return attributes; + return resolveAsyncAttributes(attributes).then(Mono.just(attributes)); + } + + /** + * By default, resolve async attributes supported by the {@link ReactiveAdapterRegistry} to their blocking counterparts. + *

View implementations capable of taking advantage of reactive types can override this method if needed. + * @return {@code Mono} to represent when the async attributes have been resolved + */ + protected Mono resolveAsyncAttributes(Map model) { + + List names = new ArrayList<>(); + List> valueMonos = new ArrayList<>(); + + for (Map.Entry entry : model.entrySet()) { + Object value = entry.getValue(); + if (value == null) { + continue; + } + ReactiveAdapter adapter = this.adapterRegistry.getAdapter(null, value); + if (adapter != null) { + names.add(entry.getKey()); + if (adapter.isMultiValue()) { + Flux fluxValue = Flux.from(adapter.toPublisher(value)); + valueMonos.add(fluxValue.collectList().defaultIfEmpty(Collections.emptyList())); + } + else { + Mono monoValue = Mono.from(adapter.toPublisher(value)); + valueMonos.add(monoValue.defaultIfEmpty(NO_VALUE)); + } + } + } + + if (names.isEmpty()) { + return Mono.empty(); + } + + return Mono.when(valueMonos, + values -> { + for (int i=0; i < values.length; i++) { + if (values[i] != NO_VALUE) { + model.put(names.get(i), values[i]); + } + else { + model.remove(names.get(i)); + } + } + return NO_VALUE; + }) + .then(); } /** diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java index 51d53bed68..31ea08126c 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java @@ -224,10 +224,9 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport viewsMono = resolveViews(getDefaultViewName(exchange), locale); } - return resolveAsyncAttributes(model.asMap()) - .doOnSuccess(aVoid -> addBindingResult(result.getBindingContext(), exchange)) - .then(viewsMono) - .then(views -> render(views, model.asMap(), exchange)); + addBindingResult(result.getBindingContext(), exchange); + + return viewsMono.then(views -> render(views, model.asMap(), exchange)); }); } @@ -274,44 +273,7 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport return ClassUtils.getShortNameAsProperty(returnValueType); } - private Mono resolveAsyncAttributes(Map model) { - List names = new ArrayList<>(); - List> valueMonos = new ArrayList<>(); - - for (Map.Entry entry : model.entrySet()) { - ReactiveAdapter adapter = getAdapterRegistry().getAdapter(null, entry.getValue()); - if (adapter != null) { - names.add(entry.getKey()); - if (adapter.isMultiValue()) { - Flux value = Flux.from(adapter.toPublisher(entry.getValue())); - valueMonos.add(value.collectList().defaultIfEmpty(Collections.emptyList())); - } - else { - Mono value = Mono.from(adapter.toPublisher(entry.getValue())); - valueMonos.add(value.defaultIfEmpty(NO_VALUE)); - } - } - } - - if (names.isEmpty()) { - return Mono.empty(); - } - - return Mono.when(valueMonos, - values -> { - for (int i=0; i < values.length; i++) { - if (values[i] != NO_VALUE) { - model.put(names.get(i), values[i]); - } - else { - model.remove(names.get(i)); - } - } - return NO_VALUE; - }) - .then(); - } private void addBindingResult(BindingContext context, ServerWebExchange exchange) { Map model = context.getModel().asMap(); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/AbstractViewTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/AbstractViewTests.java new file mode 100644 index 0000000000..a825f3165d --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/AbstractViewTests.java @@ -0,0 +1,77 @@ +package org.springframework.web.reactive.result.view; + +import io.reactivex.Observable; +import io.reactivex.Single; +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.test.MockServerWebExchange; +import org.springframework.tests.sample.beans.TestBean; +import org.springframework.ui.Model; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * Unit tests for {@link AbstractView}. + * + * @author Sebastien Deleuze + */ +public class AbstractViewTests { + + private MockServerWebExchange exchange; + + @Before + public void setup() { + this.exchange = MockServerHttpRequest.get("/").toExchange(); + } + + @Test + public void resolveAsyncAttributes() { + + TestBean testBean1 = new TestBean("Bean1"); + TestBean testBean2 = new TestBean("Bean2"); + Map attributes = new HashMap(); + attributes.put("attr1", Mono.just(testBean1)); + attributes.put("attr2", Flux.just(testBean1, testBean2)); + attributes.put("attr3", Single.just(testBean2)); + attributes.put("attr4", Observable.just(testBean1, testBean2)); + attributes.put("attr5", Mono.empty()); + + TestView view = new TestView(); + StepVerifier.create(view.render(attributes, null, this.exchange)).verifyComplete(); + + assertEquals(testBean1, view.attributes.get("attr1")); + assertArrayEquals(new TestBean[] {testBean1, testBean2}, ((List)view.attributes.get("attr2")).toArray()); + assertEquals(testBean2, view.attributes.get("attr3")); + assertArrayEquals(new TestBean[] {testBean1, testBean2}, ((List)view.attributes.get("attr4")).toArray()); + assertNull(view.attributes.get("attr5")); + } + + + private static class TestView extends AbstractView { + + private Map attributes; + + @Override + protected Mono renderInternal(Map renderAttributes, MediaType contentType, ServerWebExchange exchange) { + this.attributes = renderAttributes; + return Mono.empty(); + } + + public Map getAttributes() { + return this.attributes; + } + } +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/RedirectViewTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/RedirectViewTests.java index 90c742239a..d0ba047b50 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/RedirectViewTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/RedirectViewTests.java @@ -61,7 +61,7 @@ public class RedirectViewTests { public void defaultStatusCode() { String url = "http://url.somewhere.com"; RedirectView view = new RedirectView(url); - view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange); + view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange).block(); assertEquals(HttpStatus.SEE_OTHER, this.exchange.getResponse().getStatusCode()); assertEquals(URI.create(url), this.exchange.getResponse().getHeaders().getLocation()); } @@ -70,7 +70,7 @@ public class RedirectViewTests { public void customStatusCode() { String url = "http://url.somewhere.com"; RedirectView view = new RedirectView(url, HttpStatus.FOUND); - view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange); + view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange).block(); assertEquals(HttpStatus.FOUND, this.exchange.getResponse().getStatusCode()); assertEquals(URI.create(url), this.exchange.getResponse().getHeaders().getLocation()); } @@ -79,7 +79,7 @@ public class RedirectViewTests { public void contextRelative() { String url = "/test.html"; RedirectView view = new RedirectView(url); - view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange); + view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange).block(); assertEquals(URI.create("/context/test.html"), this.exchange.getResponse().getHeaders().getLocation()); } @@ -87,7 +87,7 @@ public class RedirectViewTests { public void contextRelativeQueryParam() { String url = "/test.html?id=1"; RedirectView view = new RedirectView(url); - view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange); + view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange).block(); assertEquals(URI.create("/context/test.html?id=1"), this.exchange.getResponse().getHeaders().getLocation()); } @@ -111,7 +111,7 @@ public class RedirectViewTests { String url = "http://url.somewhere.com?foo={foo}"; Map model = Collections.singletonMap("foo", "bar"); RedirectView view = new RedirectView(url); - view.render(model, MediaType.TEXT_HTML, this.exchange); + view.render(model, MediaType.TEXT_HTML, this.exchange).block(); assertEquals(URI.create("http://url.somewhere.com?foo=bar"), this.exchange.getResponse().getHeaders().getLocation()); } @@ -121,7 +121,7 @@ public class RedirectViewTests { Map attributes = Collections.singletonMap("foo", "bar"); this.exchange.getAttributes().put(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, attributes); RedirectView view = new RedirectView(url); - view.render(new HashMap<>(), MediaType.TEXT_HTML, exchange); + view.render(new HashMap<>(), MediaType.TEXT_HTML, exchange).block(); assertEquals(URI.create("http://url.somewhere.com?foo=bar"), this.exchange.getResponse().getHeaders().getLocation()); } @@ -130,7 +130,7 @@ public class RedirectViewTests { RedirectView view = new RedirectView("http://url.somewhere.com?foo=bar#bazz"); view.setPropagateQuery(true); this.exchange = MockServerHttpRequest.get("http://url.somewhere.com?a=b&c=d").toExchange(); - view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange); + view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange).block(); assertEquals(HttpStatus.SEE_OTHER, this.exchange.getResponse().getStatusCode()); assertEquals(URI.create("http://url.somewhere.com?foo=bar&a=b&c=d#bazz"), this.exchange.getResponse().getHeaders().getLocation()); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java index a9482ccf44..293d4f9233 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java @@ -32,7 +32,6 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import rx.Completable; -import rx.Observable; import rx.Single; import org.springframework.core.MethodParameter; @@ -249,34 +248,6 @@ public class ViewResolutionResultHandlerTests { .verify(); } - @Test - public void modelWithAsyncAttributes() throws Exception { - this.bindingContext.getModel() - .addAttribute("attr1", Mono.just(new TestBean("Bean1"))) - .addAttribute("attr2", Flux.just(new TestBean("Bean1"), new TestBean("Bean2"))) - .addAttribute("attr3", Single.just(new TestBean("Bean2"))) - .addAttribute("attr4", Observable.just(new TestBean("Bean1"), new TestBean("Bean2"))) - .addAttribute("attr5", Mono.empty()); - - MethodParameter returnType = on(TestController.class).resolveReturnType(void.class); - HandlerResult result = new HandlerResult(new Object(), null, returnType, this.bindingContext); - ViewResolutionResultHandler handler = resultHandler(new TestViewResolver("account")); - - MockServerWebExchange exchange = get("/account").toExchange(); - - handler.handleResult(exchange, result).block(Duration.ofMillis(5000)); - assertResponseBody(exchange, "account: {" + - "attr1=TestBean[name=Bean1], " + - "attr2=[TestBean[name=Bean1], TestBean[name=Bean2]], " + - "attr3=TestBean[name=Bean2], " + - "attr4=[TestBean[name=Bean1], TestBean[name=Bean2]], " + - "org.springframework.validation.BindingResult.attr1=" + - "org.springframework.validation.BeanPropertyBindingResult: 0 errors, " + - "org.springframework.validation.BindingResult.attr3=" + - "org.springframework.validation.BeanPropertyBindingResult: 0 errors" + - "}"); - } - private ViewResolutionResultHandler resultHandler(ViewResolver... resolvers) { return resultHandler(Collections.emptyList(), resolvers);