Polish HttpMessageWriterView and view resolution
This commit is contained in:
parent
10aa56aa8d
commit
e49d797104
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue