Skip to content

Commit 73b4482

Browse files
committed
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
1 parent 840d7ab commit 73b4482

File tree

5 files changed

+157
-91
lines changed

5 files changed

+157
-91
lines changed

spring-webflux/src/main/java/org/springframework/web/reactive/result/view/AbstractView.java

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@
1818

1919
import java.nio.charset.Charset;
2020
import java.nio.charset.StandardCharsets;
21-
import java.util.ArrayList;
22-
import java.util.LinkedHashMap;
23-
import java.util.List;
24-
import java.util.Map;
21+
import java.util.*;
2522

2623
import org.apache.commons.logging.Log;
2724
import org.apache.commons.logging.LogFactory;
25+
import org.springframework.core.ReactiveAdapter;
26+
import org.springframework.core.ReactiveAdapterRegistry;
27+
import reactor.core.publisher.Flux;
2828
import reactor.core.publisher.Mono;
2929

3030
import org.springframework.context.ApplicationContext;
@@ -48,9 +48,13 @@ public abstract class AbstractView implements View, ApplicationContextAware {
4848
/** Logger that is available to subclasses */
4949
protected final Log logger = LogFactory.getLog(getClass());
5050

51+
private static final Object NO_VALUE = new Object();
52+
5153

5254
private final List<MediaType> mediaTypes = new ArrayList<>(4);
5355

56+
private final ReactiveAdapterRegistry adapterRegistry;
57+
5458
private Charset defaultCharset = StandardCharsets.UTF_8;
5559

5660
private String requestContextAttribute;
@@ -59,7 +63,12 @@ public abstract class AbstractView implements View, ApplicationContextAware {
5963

6064

6165
public AbstractView() {
66+
this(new ReactiveAdapterRegistry());
67+
}
68+
69+
public AbstractView(ReactiveAdapterRegistry registry) {
6270
this.mediaTypes.add(ViewResolverSupport.DEFAULT_CONTENT_TYPE);
71+
this.adapterRegistry = registry;
6372
}
6473

6574

@@ -146,30 +155,77 @@ public Mono<Void> render(Map<String, ?> model, MediaType contentType,
146155
exchange.getResponse().getHeaders().setContentType(contentType);
147156
}
148157

149-
Map<String, Object> mergedModel = getModelAttributes(model, exchange);
150-
151-
// Expose RequestContext?
152-
if (this.requestContextAttribute != null) {
153-
mergedModel.put(this.requestContextAttribute, createRequestContext(exchange, mergedModel));
154-
}
155-
156-
return renderInternal(mergedModel, contentType, exchange);
158+
return getModelAttributes(model, exchange).then(mergedModel -> {
159+
// Expose RequestContext?
160+
if (this.requestContextAttribute != null) {
161+
mergedModel.put(this.requestContextAttribute, createRequestContext(exchange, mergedModel));
162+
}
163+
return renderInternal(mergedModel, contentType, exchange);
164+
});
157165
}
158166

159167
/**
160168
* Prepare the model to use for rendering.
161169
* <p>The default implementation creates a combined output Map that includes
162170
* model as well as static attributes with the former taking precedence.
163171
*/
164-
protected Map<String, Object> getModelAttributes(Map<String, ?> model, ServerWebExchange exchange) {
172+
protected Mono<Map<String, Object>> getModelAttributes(Map<String, ?> model, ServerWebExchange exchange) {
165173
int size = (model != null ? model.size() : 0);
166174

167175
Map<String, Object> attributes = new LinkedHashMap<>(size);
168176
if (model != null) {
169177
attributes.putAll(model);
170178
}
171179

172-
return attributes;
180+
return resolveAsyncAttributes(attributes).then(Mono.just(attributes));
181+
}
182+
183+
/**
184+
* By default, resolve async attributes supported by the {@link ReactiveAdapterRegistry} to their blocking counterparts.
185+
* <p>View implementations capable of taking advantage of reactive types can override this method if needed.
186+
* @return {@code Mono} to represent when the async attributes have been resolved
187+
*/
188+
protected Mono<Void> resolveAsyncAttributes(Map<String, Object> model) {
189+
190+
List<String> names = new ArrayList<>();
191+
List<Mono<?>> valueMonos = new ArrayList<>();
192+
193+
for (Map.Entry<String, ?> entry : model.entrySet()) {
194+
Object value = entry.getValue();
195+
if (value == null) {
196+
continue;
197+
}
198+
ReactiveAdapter adapter = this.adapterRegistry.getAdapter(null, value);
199+
if (adapter != null) {
200+
names.add(entry.getKey());
201+
if (adapter.isMultiValue()) {
202+
Flux<Object> fluxValue = Flux.from(adapter.toPublisher(value));
203+
valueMonos.add(fluxValue.collectList().defaultIfEmpty(Collections.emptyList()));
204+
}
205+
else {
206+
Mono<Object> monoValue = Mono.from(adapter.toPublisher(value));
207+
valueMonos.add(monoValue.defaultIfEmpty(NO_VALUE));
208+
}
209+
}
210+
}
211+
212+
if (names.isEmpty()) {
213+
return Mono.empty();
214+
}
215+
216+
return Mono.when(valueMonos,
217+
values -> {
218+
for (int i=0; i < values.length; i++) {
219+
if (values[i] != NO_VALUE) {
220+
model.put(names.get(i), values[i]);
221+
}
222+
else {
223+
model.remove(names.get(i));
224+
}
225+
}
226+
return NO_VALUE;
227+
})
228+
.then();
173229
}
174230

175231
/**

spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -224,10 +224,9 @@ else if (CharSequence.class.isAssignableFrom(clazz) && !hasModelAnnotation(param
224224
viewsMono = resolveViews(getDefaultViewName(exchange), locale);
225225
}
226226

227-
return resolveAsyncAttributes(model.asMap())
228-
.doOnSuccess(aVoid -> addBindingResult(result.getBindingContext(), exchange))
229-
.then(viewsMono)
230-
.then(views -> render(views, model.asMap(), exchange));
227+
addBindingResult(result.getBindingContext(), exchange);
228+
229+
return viewsMono.then(views -> render(views, model.asMap(), exchange));
231230
});
232231
}
233232

@@ -274,44 +273,7 @@ private String getNameForReturnValue(Class<?> returnValueType, MethodParameter r
274273
return ClassUtils.getShortNameAsProperty(returnValueType);
275274
}
276275

277-
private Mono<Void> resolveAsyncAttributes(Map<String, Object> model) {
278-
279-
List<String> names = new ArrayList<>();
280-
List<Mono<?>> valueMonos = new ArrayList<>();
281276

282-
for (Map.Entry<String, ?> entry : model.entrySet()) {
283-
ReactiveAdapter adapter = getAdapterRegistry().getAdapter(null, entry.getValue());
284-
if (adapter != null) {
285-
names.add(entry.getKey());
286-
if (adapter.isMultiValue()) {
287-
Flux<Object> value = Flux.from(adapter.toPublisher(entry.getValue()));
288-
valueMonos.add(value.collectList().defaultIfEmpty(Collections.emptyList()));
289-
}
290-
else {
291-
Mono<Object> value = Mono.from(adapter.toPublisher(entry.getValue()));
292-
valueMonos.add(value.defaultIfEmpty(NO_VALUE));
293-
}
294-
}
295-
}
296-
297-
if (names.isEmpty()) {
298-
return Mono.empty();
299-
}
300-
301-
return Mono.when(valueMonos,
302-
values -> {
303-
for (int i=0; i < values.length; i++) {
304-
if (values[i] != NO_VALUE) {
305-
model.put(names.get(i), values[i]);
306-
}
307-
else {
308-
model.remove(names.get(i));
309-
}
310-
}
311-
return NO_VALUE;
312-
})
313-
.then();
314-
}
315277

316278
private void addBindingResult(BindingContext context, ServerWebExchange exchange) {
317279
Map<String, Object> model = context.getModel().asMap();
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package org.springframework.web.reactive.result.view;
2+
3+
import io.reactivex.Observable;
4+
import io.reactivex.Single;
5+
import org.junit.Before;
6+
import org.junit.Test;
7+
import org.springframework.http.MediaType;
8+
import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest;
9+
import org.springframework.mock.http.server.reactive.test.MockServerWebExchange;
10+
import org.springframework.tests.sample.beans.TestBean;
11+
import org.springframework.ui.Model;
12+
import org.springframework.web.server.ServerWebExchange;
13+
import reactor.core.publisher.Flux;
14+
import reactor.core.publisher.Mono;
15+
import reactor.test.StepVerifier;
16+
17+
import java.util.HashMap;
18+
import java.util.List;
19+
import java.util.Map;
20+
import java.util.logging.Level;
21+
22+
import static org.junit.Assert.assertArrayEquals;
23+
import static org.junit.Assert.assertEquals;
24+
import static org.junit.Assert.assertNull;
25+
26+
/**
27+
* Unit tests for {@link AbstractView}.
28+
*
29+
* @author Sebastien Deleuze
30+
*/
31+
public class AbstractViewTests {
32+
33+
private MockServerWebExchange exchange;
34+
35+
@Before
36+
public void setup() {
37+
this.exchange = MockServerHttpRequest.get("/").toExchange();
38+
}
39+
40+
@Test
41+
public void resolveAsyncAttributes() {
42+
43+
TestBean testBean1 = new TestBean("Bean1");
44+
TestBean testBean2 = new TestBean("Bean2");
45+
Map<String, Object> attributes = new HashMap();
46+
attributes.put("attr1", Mono.just(testBean1));
47+
attributes.put("attr2", Flux.just(testBean1, testBean2));
48+
attributes.put("attr3", Single.just(testBean2));
49+
attributes.put("attr4", Observable.just(testBean1, testBean2));
50+
attributes.put("attr5", Mono.empty());
51+
52+
TestView view = new TestView();
53+
StepVerifier.create(view.render(attributes, null, this.exchange)).verifyComplete();
54+
55+
assertEquals(testBean1, view.attributes.get("attr1"));
56+
assertArrayEquals(new TestBean[] {testBean1, testBean2}, ((List<TestBean>)view.attributes.get("attr2")).toArray());
57+
assertEquals(testBean2, view.attributes.get("attr3"));
58+
assertArrayEquals(new TestBean[] {testBean1, testBean2}, ((List<TestBean>)view.attributes.get("attr4")).toArray());
59+
assertNull(view.attributes.get("attr5"));
60+
}
61+
62+
63+
private static class TestView extends AbstractView {
64+
65+
private Map<String, Object> attributes;
66+
67+
@Override
68+
protected Mono<Void> renderInternal(Map<String, Object> renderAttributes, MediaType contentType, ServerWebExchange exchange) {
69+
this.attributes = renderAttributes;
70+
return Mono.empty();
71+
}
72+
73+
public Map<String, Object> getAttributes() {
74+
return this.attributes;
75+
}
76+
}
77+
}

spring-webflux/src/test/java/org/springframework/web/reactive/result/view/RedirectViewTests.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public void noUrlSet() throws Exception {
6161
public void defaultStatusCode() {
6262
String url = "http://url.somewhere.com";
6363
RedirectView view = new RedirectView(url);
64-
view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange);
64+
view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange).block();
6565
assertEquals(HttpStatus.SEE_OTHER, this.exchange.getResponse().getStatusCode());
6666
assertEquals(URI.create(url), this.exchange.getResponse().getHeaders().getLocation());
6767
}
@@ -70,7 +70,7 @@ public void defaultStatusCode() {
7070
public void customStatusCode() {
7171
String url = "http://url.somewhere.com";
7272
RedirectView view = new RedirectView(url, HttpStatus.FOUND);
73-
view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange);
73+
view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange).block();
7474
assertEquals(HttpStatus.FOUND, this.exchange.getResponse().getStatusCode());
7575
assertEquals(URI.create(url), this.exchange.getResponse().getHeaders().getLocation());
7676
}
@@ -79,15 +79,15 @@ public void customStatusCode() {
7979
public void contextRelative() {
8080
String url = "/test.html";
8181
RedirectView view = new RedirectView(url);
82-
view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange);
82+
view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange).block();
8383
assertEquals(URI.create("/context/test.html"), this.exchange.getResponse().getHeaders().getLocation());
8484
}
8585

8686
@Test
8787
public void contextRelativeQueryParam() {
8888
String url = "/test.html?id=1";
8989
RedirectView view = new RedirectView(url);
90-
view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange);
90+
view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange).block();
9191
assertEquals(URI.create("/context/test.html?id=1"), this.exchange.getResponse().getHeaders().getLocation());
9292
}
9393

@@ -111,7 +111,7 @@ public void expandUriTemplateVariablesFromModel() {
111111
String url = "http://url.somewhere.com?foo={foo}";
112112
Map<String, String> model = Collections.singletonMap("foo", "bar");
113113
RedirectView view = new RedirectView(url);
114-
view.render(model, MediaType.TEXT_HTML, this.exchange);
114+
view.render(model, MediaType.TEXT_HTML, this.exchange).block();
115115
assertEquals(URI.create("http://url.somewhere.com?foo=bar"), this.exchange.getResponse().getHeaders().getLocation());
116116
}
117117

@@ -121,7 +121,7 @@ public void expandUriTemplateVariablesFromExchangeAttribute() {
121121
Map<String, String> attributes = Collections.singletonMap("foo", "bar");
122122
this.exchange.getAttributes().put(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, attributes);
123123
RedirectView view = new RedirectView(url);
124-
view.render(new HashMap<>(), MediaType.TEXT_HTML, exchange);
124+
view.render(new HashMap<>(), MediaType.TEXT_HTML, exchange).block();
125125
assertEquals(URI.create("http://url.somewhere.com?foo=bar"), this.exchange.getResponse().getHeaders().getLocation());
126126
}
127127

@@ -130,7 +130,7 @@ public void propagateQueryParams() throws Exception {
130130
RedirectView view = new RedirectView("http://url.somewhere.com?foo=bar#bazz");
131131
view.setPropagateQuery(true);
132132
this.exchange = MockServerHttpRequest.get("http://url.somewhere.com?a=b&c=d").toExchange();
133-
view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange);
133+
view.render(new HashMap<>(), MediaType.TEXT_HTML, this.exchange).block();
134134
assertEquals(HttpStatus.SEE_OTHER, this.exchange.getResponse().getStatusCode());
135135
assertEquals(URI.create("http://url.somewhere.com?foo=bar&a=b&c=d#bazz"),
136136
this.exchange.getResponse().getHeaders().getLocation());

spring-webflux/src/test/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandlerTests.java

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
import reactor.core.publisher.Mono;
3333
import reactor.test.StepVerifier;
3434
import rx.Completable;
35-
import rx.Observable;
3635
import rx.Single;
3736

3837
import org.springframework.core.MethodParameter;
@@ -249,34 +248,6 @@ public void contentNegotiationWith406() throws Exception {
249248
.verify();
250249
}
251250

252-
@Test
253-
public void modelWithAsyncAttributes() throws Exception {
254-
this.bindingContext.getModel()
255-
.addAttribute("attr1", Mono.just(new TestBean("Bean1")))
256-
.addAttribute("attr2", Flux.just(new TestBean("Bean1"), new TestBean("Bean2")))
257-
.addAttribute("attr3", Single.just(new TestBean("Bean2")))
258-
.addAttribute("attr4", Observable.just(new TestBean("Bean1"), new TestBean("Bean2")))
259-
.addAttribute("attr5", Mono.empty());
260-
261-
MethodParameter returnType = on(TestController.class).resolveReturnType(void.class);
262-
HandlerResult result = new HandlerResult(new Object(), null, returnType, this.bindingContext);
263-
ViewResolutionResultHandler handler = resultHandler(new TestViewResolver("account"));
264-
265-
MockServerWebExchange exchange = get("/account").toExchange();
266-
267-
handler.handleResult(exchange, result).block(Duration.ofMillis(5000));
268-
assertResponseBody(exchange, "account: {" +
269-
"attr1=TestBean[name=Bean1], " +
270-
"attr2=[TestBean[name=Bean1], TestBean[name=Bean2]], " +
271-
"attr3=TestBean[name=Bean2], " +
272-
"attr4=[TestBean[name=Bean1], TestBean[name=Bean2]], " +
273-
"org.springframework.validation.BindingResult.attr1=" +
274-
"org.springframework.validation.BeanPropertyBindingResult: 0 errors, " +
275-
"org.springframework.validation.BindingResult.attr3=" +
276-
"org.springframework.validation.BeanPropertyBindingResult: 0 errors" +
277-
"}");
278-
}
279-
280251

281252
private ViewResolutionResultHandler resultHandler(ViewResolver... resolvers) {
282253
return resultHandler(Collections.emptyList(), resolvers);

0 commit comments

Comments
 (0)