diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/HttpMessageConverterView.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/HttpMessageConverterView.java new file mode 100644 index 0000000000..b95dfe3f60 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/view/HttpMessageConverterView.java @@ -0,0 +1,153 @@ +/* + * Copyright 2002-2016 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.reactive.result.view; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.http.MediaType; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.ui.ModelMap; +import org.springframework.util.Assert; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.server.ServerWebExchange; + + +/** + * A {@link View} that delegates to an {@link HttpMessageConverter}. + * + * @author Rossen Stoyanchev + */ +public class HttpMessageConverterView implements View { + + private final HttpMessageConverter converter; + + private final Set modelKeys = new HashSet<>(4); + + private final List mediaTypes; + + + public HttpMessageConverterView(HttpMessageConverter converter) { + Assert.notNull(converter, "'converter' is required."); + this.converter = converter; + this.mediaTypes = converter.getWritableMediaTypes(); + } + + + public HttpMessageConverter getConverter() { + return this.converter; + } + + /** + * By default model attributes are filtered with + * {@link HttpMessageConverter#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). + *

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. + */ + public void setModelKeys(Set modelKeys) { + this.modelKeys.clear(); + if (modelKeys != null) { + this.modelKeys.addAll(modelKeys); + } + } + + /** + * Return the configured model keys. + */ + public final Set getModelKeys() { + return this.modelKeys; + } + + @Override + public List getSupportedMediaTypes() { + return this.mediaTypes; + } + + @Override + public Mono render(HandlerResult result, MediaType contentType, ServerWebExchange exchange) { + Object value = extractObjectToRender(result); + return applyConverter(value, contentType, exchange); + } + + protected Object extractObjectToRender(HandlerResult result) { + ModelMap model = result.getModel(); + Map map = new HashMap<>(model.size()); + for (Map.Entry entry : model.entrySet()) { + if (isEligibleAttribute(entry.getKey(), entry.getValue())) { + map.put(entry.getKey(), entry.getValue()); + } + } + if (map.isEmpty()) { + return null; + } + else if (map.size() == 1) { + return map.values().iterator().next(); + } + else if (getConverter().canWrite(ResolvableType.forClass(Map.class), null)) { + return map; + } + else { + throw new IllegalStateException( + "Multiple matching attributes found: " + map + ". " + + "However Map rendering is not supported by " + getConverter()); + } + } + + /** + * Whether the given model attribute key-value pair is eligible for encoding. + *

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 getConverter().canWrite(type, null); + } + if (getModelKeys().contains(attributeName)) { + if (getConverter().canWrite(type, null)) { + return true; + } + throw new IllegalStateException( + "Model object [" + attributeValue + "] retrieved via key " + + "[" + attributeName + "] is not supported by " + getConverter()); + } + return false; + } + + @SuppressWarnings("unchecked") + private Mono applyConverter(Object value, MediaType contentType, ServerWebExchange exchange) { + if (value == null) { + return Mono.empty(); + } + Publisher stream = Mono.just((T) value); + ResolvableType type = ResolvableType.forClass(value.getClass()); + ServerHttpResponse response = exchange.getResponse(); + return ((HttpMessageConverter) getConverter()).write(stream, type, contentType, response); + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/HttpMessageConverterViewTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/HttpMessageConverterViewTests.java new file mode 100644 index 0000000000..f1b4fa1b82 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/view/HttpMessageConverterViewTests.java @@ -0,0 +1,180 @@ +/* + * Copyright 2002-2016 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.reactive.result.view; + + +import java.net.URI; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.support.JacksonJsonEncoder; +import org.springframework.core.codec.support.Jaxb2Encoder; +import org.springframework.core.codec.support.Pojo; +import org.springframework.core.codec.support.StringEncoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.support.DataBufferTestUtils; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.converter.reactive.CodecHttpMessageConverter; +import org.springframework.http.converter.reactive.HttpMessageConverter; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; +import org.springframework.util.MimeType; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.DefaultWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; + +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; + + +/** + * Unit tests for {@link HttpMessageConverterView}. + * @author Rossen Stoyanchev + */ +public class HttpMessageConverterViewTests { + + private HttpMessageConverterView view; + + private HandlerResult result; + + private ModelMap model; + + + @Before + public void setup() throws Exception { + HttpMessageConverter converter = new CodecHttpMessageConverter<>(new JacksonJsonEncoder()); + this.view = new HttpMessageConverterView(converter); + this.model = new ExtendedModelMap(); + this.result = new HandlerResult(new Object(), null, ResolvableType.NONE, model); + } + + + @Test + public void supportedMediaTypes() throws Exception { + List mimeTypes = Arrays.asList( + new MimeType("application", "json", StandardCharsets.UTF_8), + new MimeType("application", "*+json", StandardCharsets.UTF_8)); + + assertEquals(mimeTypes, this.view.getSupportedMediaTypes()); + } + + @Test + public void extractObject() 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.result)); + } + + @Test + public void extractObjectNoMatch() throws Exception { + this.view.setModelKeys(Collections.singleton("foo2")); + this.model.addAttribute("foo1", "bar1"); + + assertNull(this.view.extractObjectToRender(this.result)); + } + + @Test + public void extractObjectMultipleMatches() 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.result); + 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")); + } + + @Test + public void extractObjectMultipleMatchesNotSupported() throws Exception { + HttpMessageConverter converter = new CodecHttpMessageConverter<>(new StringEncoder()); + HttpMessageConverterView view = new HttpMessageConverterView(converter); + view.setModelKeys(new HashSet<>(Arrays.asList("foo1", "foo2"))); + this.model.addAttribute("foo1", "bar1"); + this.model.addAttribute("foo2", "bar2"); + + try { + view.extractObjectToRender(this.result); + fail(); + } + catch (IllegalStateException ex) { + String message = ex.getMessage(); + assertTrue(message, message.contains("Map rendering is not supported")); + } + } + + @Test + public void extractObjectNotSupported() throws Exception { + HttpMessageConverter converter = new CodecHttpMessageConverter<>(new Jaxb2Encoder()); + HttpMessageConverterView view = new HttpMessageConverterView(converter); + view.setModelKeys(new HashSet<>(Collections.singletonList("foo1"))); + this.model.addAttribute("foo1", "bar1"); + + try { + view.extractObjectToRender(this.result); + fail(); + } + catch (IllegalStateException ex) { + String message = ex.getMessage(); + assertTrue(message, message.contains("[foo1] is not supported")); + } + } + + @Test + public void render() throws Exception { + this.model.addAttribute("pojo", new Pojo("foo", "bar")); + this.view.setModelKeys(Collections.singleton("pojo")); + + MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + MockServerHttpResponse response = new MockServerHttpResponse(); + WebSessionManager manager = new DefaultWebSessionManager(); + ServerWebExchange exchange = new DefaultServerWebExchange(request, response, manager); + + this.view.render(result, MediaType.APPLICATION_JSON, exchange); + + new TestSubscriber().bindTo(response.getBody()) + .assertValuesWith(buf -> assertEquals("{\"foo\":\"foo\",\"bar\":\"bar\"}", + DataBufferTestUtils.dumpString(buf, Charset.forName("UTF-8")))); + } + +}