Polish HttpMessageWriterView and view resolution

This commit is contained in:
Rossen Stoyanchev 2017-04-05 17:02:42 -04:00
parent 10aa56aa8d
commit e49d797104
3 changed files with 125 additions and 147 deletions

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2016 the original author or authors.
* Copyright 2002-2017 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.
@ -17,11 +17,12 @@
package org.springframework.web.reactive.result.view;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
@ -37,53 +38,65 @@ import org.springframework.web.server.ServerWebExchange;
/**
* A {@link View} that delegates to an {@link HttpMessageWriter}.
* {@code View} that writes model attribute(s) with an {@link HttpMessageWriter}.
*
* @author Rossen Stoyanchev
* @since 5.0
*/
public class HttpMessageWriterView implements View {
private final HttpMessageWriter<?> messageWriter;
private final HttpMessageWriter<?> writer;
private final Set<String> modelKeys = new HashSet<>(4);
private final List<MediaType> mediaTypes;
private final boolean canWriteMap;
/**
* Create a {@code View} with the given {@code Encoder} wrapping it as an
* {@link EncoderHttpMessageWriter}.
* Constructor with an {@code Encoder}.
*/
public HttpMessageWriterView(Encoder<?> encoder) {
this(new EncoderHttpMessageWriter<>(encoder));
}
/**
* Create a View that delegates to the given message messageWriter.
* Constructor with a fully initialized {@link HttpMessageWriter}.
*/
public HttpMessageWriterView(HttpMessageWriter<?> messageWriter) {
Assert.notNull(messageWriter, "'messageWriter' is required.");
this.messageWriter = messageWriter;
this.mediaTypes = messageWriter.getWritableMediaTypes();
public HttpMessageWriterView(HttpMessageWriter<?> writer) {
Assert.notNull(writer, "'writer' is required.");
this.writer = writer;
this.canWriteMap = writer.canWrite(ResolvableType.forClass(Map.class), null);
}
/**
* Return the configured message messageWriter.
* Return the configured message writer.
*/
public HttpMessageWriter<?> getMessageWriter() {
return this.messageWriter;
return this.writer;
}
/**
* By default model attributes are filtered with
* {@link HttpMessageWriter#canWrite} to find the ones that can be
* rendered. Use this property to further narrow the list and consider only
* attribute(s) under specific model key(s).
* <p>If more than one matching attribute is found, than a Map is rendered,
* or if the {@code Encoder} does not support rendering a {@code Map} then
* an exception is raised.
* {@inheritDoc}
* <p>The implementation of this method for {@link HttpMessageWriterView}
* delegates to {@link HttpMessageWriter#getWritableMediaTypes()}.
*/
@Override
public List<MediaType> getSupportedMediaTypes() {
return this.writer.getWritableMediaTypes();
}
/**
* Set the attributes in the model that should be rendered by this view.
* When set, all other model attributes will be ignored. The matching
* attributes are further narrowed with {@link HttpMessageWriter#canWrite}.
* The matching attributes are processed as follows:
* <ul>
* <li>0: nothing is written to the response body.
* <li>1: the matching attribute is passed to the writer.
* <li>2..N: if the writer supports {@link Map}, write all matches;
* otherwise raise an {@link IllegalStateException}.
* </ul>
*/
public void setModelKeys(Set<String> modelKeys) {
this.modelKeys.clear();
@ -99,73 +112,50 @@ public class HttpMessageWriterView implements View {
return this.modelKeys;
}
@Override
public List<MediaType> getSupportedMediaTypes() {
return this.mediaTypes;
}
@Override
public Mono<Void> render(Map<String, ?> model, MediaType contentType,
ServerWebExchange exchange) {
Object value = extractObjectToRender(model);
return applyMessageWriter(value, contentType, exchange);
@SuppressWarnings("unchecked")
public Mono<Void> render(Map<String, ?> model, MediaType contentType, ServerWebExchange exchange) {
return getObjectToRender(model)
.map(value -> {
Publisher stream = Mono.justOrEmpty(value);
ResolvableType type = ResolvableType.forClass(value.getClass());
ServerHttpResponse response = exchange.getResponse();
return this.writer.write(stream, type, contentType, response, Collections.emptyMap());
})
.orElseGet(() -> exchange.getResponse().setComplete());
}
protected Object extractObjectToRender(Map<String, ?> model) {
Map<String, Object> map = new HashMap<>(model.size());
for (Map.Entry<String, ?> entry : model.entrySet()) {
if (isEligibleAttribute(entry.getKey(), entry.getValue())) {
map.put(entry.getKey(), entry.getValue());
}
private Optional<Object> getObjectToRender(Map<String, ?> model) {
Map<String, ?> result = model.entrySet().stream()
.filter(this::isMatch)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
if (result.isEmpty()) {
return Optional.empty();
}
if (map.isEmpty()) {
return null;
else if (result.size() == 1) {
return Optional.of(result.values().iterator().next());
}
else if (map.size() == 1) {
return map.values().iterator().next();
}
else if (getMessageWriter().canWrite(ResolvableType.forClass(Map.class), null)) {
return map;
else if (this.canWriteMap) {
return Optional.of(result);
}
else {
throw new IllegalStateException(
"Multiple matching attributes found: " + map + ". " +
"However Map rendering is not supported by " + getMessageWriter());
throw new IllegalStateException("Multiple matches found: " + result + " but " +
"Map rendering is not supported by " + getMessageWriter().getClass().getName());
}
}
/**
* Whether the given model attribute key-value pair is eligible for encoding.
* <p>The default implementation checks against the configured
* {@link #setModelKeys model keys} and whether the Encoder supports the
* value type.
*/
protected boolean isEligibleAttribute(String attributeName, Object attributeValue) {
ResolvableType type = ResolvableType.forClass(attributeValue.getClass());
if (getModelKeys().isEmpty()) {
return getMessageWriter().canWrite(type, null);
private boolean isMatch(Map.Entry<String, ?> entry) {
if (entry.getValue() == null) {
return false;
}
if (getModelKeys().contains(attributeName)) {
if (getMessageWriter().canWrite(type, null)) {
return true;
}
throw new IllegalStateException(
"Model object [" + attributeValue + "] retrieved via key " +
"[" + attributeName + "] is not supported by " + getMessageWriter());
if (!getModelKeys().isEmpty() && !getModelKeys().contains(entry.getKey())) {
return false;
}
return false;
}
@SuppressWarnings("unchecked")
private <T> Mono<Void> applyMessageWriter(Object value, MediaType contentType, ServerWebExchange exchange) {
if (value == null) {
return Mono.empty();
}
Publisher<? extends T> stream = Mono.just((T) value);
ResolvableType type = ResolvableType.forClass(value.getClass());
ServerHttpResponse response = exchange.getResponse();
return ((HttpMessageWriter<T>) getMessageWriter()).write(stream, type, contentType,
response, Collections.emptyMap());
ResolvableType type = ResolvableType.forInstance(entry.getValue());
return getMessageWriter().canWrite(type, null);
}
}

View File

@ -55,13 +55,13 @@ import org.springframework.web.server.support.HttpRequestPathHelper;
* {@code HandlerResultHandler} that encapsulates the view resolution algorithm
* supporting the following return types:
* <ul>
* <li>String-based view name
* <li>Reference to a {@link View}
* <li>{@link Model}
* <li>{@link Map}
* <li>Return types annotated with {@code @ModelAttribute}
* <li>{@link BeanUtils#isSimpleProperty Non-simple} return types are
* treated as a model attribute
* <li>{@link Void} or no value -- default view name</li>
* <li>{@link String} -- view name unless {@code @ModelAttribute}-annotated
* <li>{@link View} -- View to render with
* <li>{@link Model} -- attributes to add to the model
* <li>{@link Map} -- attributes to add to the model
* <li>{@link ModelAttribute @ModelAttribute} -- attribute for the model
* <li>Non-simple value -- attribute for the model
* </ul>
*
* <p>A String-based view name is resolved through the configured
@ -71,8 +71,9 @@ import org.springframework.web.server.support.HttpRequestPathHelper;
*
* <p>By default this resolver is ordered at {@link Ordered#LOWEST_PRECEDENCE}
* and generally needs to be late in the order since it interprets any String
* return value as a view name while others may interpret the same otherwise
* based on annotations (e.g. for {@code @ResponseBody}).
* return value as a view name or any non-simple value type as a model attribute
* while other result handlers may interpret the same otherwise based on the
* presence of annotations, e.g. for {@code @ResponseBody}.
*
* @author Rossen Stoyanchev
* @since 5.0
@ -174,11 +175,16 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport
ReactiveAdapter adapter = getAdapter(result);
if (adapter != null) {
Assert.isTrue(!adapter.isMultiValue(), "Only single-value async return type supported.");
Assert.isTrue(!adapter.isMultiValue(), "Multi-value " +
"reactive types not supported in view resolution: " + result.getReturnType());
valueMono = result.getReturnValue()
.map(value -> Mono.from(adapter.toPublisher(value))).orElse(Mono.empty());
.map(value -> Mono.from(adapter.toPublisher(value)))
.orElse(Mono.empty());
valueType = adapter.isNoValue() ?
ResolvableType.forClass(Void.class) : result.getReturnType().getGeneric(0);
ResolvableType.forClass(Void.class) :
result.getReturnType().getGeneric(0);
}
else {
valueMono = Mono.justOrEmpty(result.getReturnValue());
@ -224,7 +230,7 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport
viewsMono = resolveViews(getDefaultViewName(exchange), locale);
}
addBindingResult(result.getBindingContext(), exchange);
updateBindingContext(result.getBindingContext(), exchange);
return viewsMono.then(views -> render(views, model.asMap(), exchange));
});
@ -259,11 +265,6 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport
});
}
/**
* Return the name of a model attribute return value based on the method
* {@code @ModelAttribute} annotation, if present, or derived from the type
* of the return value otherwise.
*/
private String getNameForReturnValue(Class<?> returnValueType, MethodParameter returnType) {
ModelAttribute annotation = returnType.getMethodAnnotation(ModelAttribute.class);
if (annotation != null && StringUtils.hasText(annotation.value())) {
@ -273,9 +274,7 @@ public class ViewResolutionResultHandler extends HandlerResultHandlerSupport
return ClassUtils.getShortNameAsProperty(returnValueType);
}
private void addBindingResult(BindingContext context, ServerWebExchange exchange) {
private void updateBindingContext(BindingContext context, ServerWebExchange exchange) {
Map<String, Object> model = context.getModel().asMap();
model.keySet().stream()
.filter(name -> isBindingCandidate(name, model.get(name)))

View File

@ -20,16 +20,15 @@ import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.Test;
import reactor.test.StepVerifier;
import org.springframework.core.codec.CharSequenceEncoder;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.support.DataBufferTestUtils;
import org.springframework.http.MediaType;
import org.springframework.http.codec.json.Jackson2JsonEncoder;
@ -38,12 +37,9 @@ import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest;
import org.springframework.mock.http.server.reactive.test.MockServerWebExchange;
import org.springframework.ui.ExtendedModelMap;
import org.springframework.ui.ModelMap;
import org.springframework.util.MimeType;
import static junit.framework.TestCase.assertTrue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.fail;
@ -55,62 +51,65 @@ public class HttpMessageWriterViewTests {
private HttpMessageWriterView view = new HttpMessageWriterView(new Jackson2JsonEncoder());
private ModelMap model = new ExtendedModelMap();
private final ModelMap model = new ExtendedModelMap();
private final MockServerWebExchange exchange = MockServerHttpRequest.get("/").toExchange();
@Test
public void supportedMediaTypes() throws Exception {
List<MimeType> mimeTypes = Arrays.asList(
new MimeType("application", "json", StandardCharsets.UTF_8),
new MimeType("application", "*+json", StandardCharsets.UTF_8));
assertEquals(mimeTypes, this.view.getSupportedMediaTypes());
assertEquals(Arrays.asList(
MediaType.parseMediaType("application/json;charset=UTF-8"),
MediaType.parseMediaType("application/*+json;charset=UTF-8")),
this.view.getSupportedMediaTypes());
}
@Test
public void extractObject() throws Exception {
public void singleMatch() throws Exception {
this.view.setModelKeys(Collections.singleton("foo2"));
this.model.addAttribute("foo1", "bar1");
this.model.addAttribute("foo2", "bar2");
this.model.addAttribute("foo3", "bar3");
assertEquals("bar2", this.view.extractObjectToRender(this.model));
assertEquals("\"bar2\"", doRender());
}
@Test
public void extractObjectNoMatch() throws Exception {
public void noMatch() throws Exception {
this.view.setModelKeys(Collections.singleton("foo2"));
this.model.addAttribute("foo1", "bar1");
assertNull(this.view.extractObjectToRender(this.model));
assertEquals("", doRender());
}
@Test
public void extractObjectMultipleMatches() throws Exception {
public void noMatchBecauseNotSupported() throws Exception {
this.view = new HttpMessageWriterView(new Jaxb2XmlEncoder());
this.view.setModelKeys(new HashSet<>(Collections.singletonList("foo1")));
this.model.addAttribute("foo1", "bar1");
assertEquals("", doRender());
}
@Test
public void multipleMatches() throws Exception {
this.view.setModelKeys(new HashSet<>(Arrays.asList("foo1", "foo2")));
this.model.addAttribute("foo1", "bar1");
this.model.addAttribute("foo2", "bar2");
this.model.addAttribute("foo3", "bar3");
Object value = this.view.extractObjectToRender(this.model);
assertNotNull(value);
assertEquals(HashMap.class, value.getClass());
Map<?, ?> map = (Map<?, ?>) value;
assertEquals(2, map.size());
assertEquals("bar1", map.get("foo1"));
assertEquals("bar2", map.get("foo2"));
assertEquals("{\"foo1\":\"bar1\",\"foo2\":\"bar2\"}", doRender());
}
@Test
public void extractObjectMultipleMatchesNotSupported() throws Exception {
HttpMessageWriterView view = new HttpMessageWriterView(CharSequenceEncoder.allMimeTypes());
view.setModelKeys(new HashSet<>(Arrays.asList("foo1", "foo2")));
public void multipleMatchesNotSupported() throws Exception {
this.view = new HttpMessageWriterView(CharSequenceEncoder.allMimeTypes());
this.view.setModelKeys(new HashSet<>(Arrays.asList("foo1", "foo2")));
this.model.addAttribute("foo1", "bar1");
this.model.addAttribute("foo2", "bar2");
try {
view.extractObjectToRender(this.model);
doRender();
fail();
}
catch (IllegalStateException ex) {
@ -119,22 +118,6 @@ public class HttpMessageWriterViewTests {
}
}
@Test
public void extractObjectNotSupported() throws Exception {
HttpMessageWriterView view = new HttpMessageWriterView(new Jaxb2XmlEncoder());
view.setModelKeys(new HashSet<>(Collections.singletonList("foo1")));
this.model.addAttribute("foo1", "bar1");
try {
view.extractObjectToRender(this.model);
fail();
}
catch (IllegalStateException ex) {
String message = ex.getMessage();
assertTrue(message, message.contains("[foo1] is not supported"));
}
}
@Test
public void render() throws Exception {
Map<String, String> pojoData = new LinkedHashMap<>();
@ -143,18 +126,24 @@ public class HttpMessageWriterViewTests {
this.model.addAttribute("pojoData", pojoData);
this.view.setModelKeys(Collections.singleton("pojoData"));
MockServerWebExchange exchange = MockServerHttpRequest.get("/path").toExchange();
this.view.render(this.model, MediaType.APPLICATION_JSON, exchange).block(Duration.ZERO);
this.view.render(this.model, MediaType.APPLICATION_JSON, exchange).block(Duration.ofSeconds(5));
StepVerifier.create(exchange.getResponse().getBody())
.consumeNextWith( buf -> assertEquals("{\"foo\":\"f\",\"bar\":\"b\"}",
DataBufferTestUtils.dumpString(buf, StandardCharsets.UTF_8))
)
StepVerifier.create(this.exchange.getResponse().getBody())
.consumeNextWith(buf -> assertEquals("{\"foo\":\"f\",\"bar\":\"b\"}", dumpString(buf)))
.expectComplete()
.verify();
}
private String dumpString(DataBuffer buf) {
return DataBufferTestUtils.dumpString(buf, StandardCharsets.UTF_8);
}
private String doRender() {
this.view.render(this.model, MediaType.APPLICATION_JSON, this.exchange).block(Duration.ZERO);
return this.exchange.getResponse().getBodyAsString().block(Duration.ZERO);
}
@SuppressWarnings("unused")
private String handle() {