From 73b44828e9dba23fec73286829c4583e6342049a Mon Sep 17 00:00:00 2001 From: Sebastien Deleuze Date: Thu, 30 Mar 2017 21:45:00 +0200 Subject: [PATCH] Resolve async model attributes in AbstractView This change allows the functional WebFlux API to support natively reactive types and also makes it possible for View implementations to disable async attributes resolution if they want for example take advantage of stream rendering. It also makes AbstractView#getModelAttributes() asynchronous. Issue: SPR-15368 --- .../reactive/result/view/AbstractView.java | 84 +++++++++++++++---- .../view/ViewResolutionResultHandler.java | 44 +--------- .../result/view/AbstractViewTests.java | 77 +++++++++++++++++ .../result/view/RedirectViewTests.java | 14 ++-- .../ViewResolutionResultHandlerTests.java | 29 ------- 5 files changed, 157 insertions(+), 91 deletions(-) create mode 100644 spring-webflux/src/test/java/org/springframework/web/reactive/result/view/AbstractViewTests.java 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);