From 398729cda1da9ddea58eb1a506c4bad8f77e63c3 Mon Sep 17 00:00:00 2001 From: Arjen Poutsma Date: Wed, 24 Jun 2009 13:55:36 +0000 Subject: [PATCH] SPR-5853 - JSON formatting view for Spring MVC --- org.springframework.web.servlet/ivy.xml | 60 ++-- .../view/json/BindingJacksonJsonView.java | 150 ++++++++++ .../web/servlet/view/json/package-info.java | 23 ++ .../view/json/BindingJacksonJsonViewTest.java | 270 ++++++++++++++++++ org.springframework.web.servlet/template.mf | 1 + .../BindingJacksonHttpMessageConverter.java | 34 ++- 6 files changed, 507 insertions(+), 31 deletions(-) create mode 100644 org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/view/json/BindingJacksonJsonView.java create mode 100644 org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/view/json/package-info.java create mode 100644 org.springframework.web.servlet/src/test/java/org/springframework/web/servlet/view/json/BindingJacksonJsonViewTest.java diff --git a/org.springframework.web.servlet/ivy.xml b/org.springframework.web.servlet/ivy.xml index 6703453ac5..880dc5128a 100644 --- a/org.springframework.web.servlet/ivy.xml +++ b/org.springframework.web.servlet/ivy.xml @@ -1,9 +1,9 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="http://incubator.apache.org/ivy/schemas/ivy.xsd" + version="1.3"> @@ -17,6 +17,7 @@ + @@ -31,57 +32,62 @@ + conf="optional, feed->compile"/> + conf="optional, itext->compile"/> + conf="optional, freemarker->compile"/> + conf="provided->compile"/> + conf="optional, jexcelapi->compile"/> + conf="optional, jasper-reports->compile"/> + conf="optional, commons-fileupload->compile"/> + conf="compile->compile"/> + conf="optional, poi->compile"/> + conf="optional, tiles->compile"/> + conf="optional, tiles->compile"/> + conf="optional, tiles->compile"/> + conf="optional, velocity->compile"/> + conf="optional, velocity->compile"/> + + conf="compile->compile"/> + conf="compile->compile"/> + conf="optional, velocity, freemarker, jasper-reports->compile"/> + conf="compile->compile"/> + conf="optional, oxm->compile"/> - - + conf="compile->compile"/> + + - + + conf="test->compile"/> + diff --git a/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/view/json/BindingJacksonJsonView.java b/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/view/json/BindingJacksonJsonView.java new file mode 100644 index 0000000000..bb5d95f456 --- /dev/null +++ b/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/view/json/BindingJacksonJsonView.java @@ -0,0 +1,150 @@ +/* + * Copyright 2002-2009 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.servlet.view.json; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.codehaus.jackson.JsonEncoding; +import org.codehaus.jackson.JsonGenerator; +import org.codehaus.jackson.map.ObjectMapper; +import org.codehaus.jackson.map.SerializerFactory; + +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.validation.BindingResult; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.view.AbstractView; + +/** + * Spring-MVC {@link View} that renders JSON content by serializing the model for the current request using Jackson's {@link ObjectMapper}. + * + *

By default, the entire contents of the model map (with the exception of framework-specific classes) will be + * encoded as JSON. For cases where the contents of the map need to be filtered, users may specify a specific set of + * model attributes to encode via the {@link #setRenderedAttributes(Set) includeAttributes} property. + * + * @author Jeremy Grelle + * @author Arjen Poutsma + * @see org.springframework.http.converter.json.BindingJacksonHttpMessageConverter + * @since 3.0 + */ +public class BindingJacksonJsonView extends AbstractView { + + /** + * Default content type. Overridable as bean property. + */ + public static final String DEFAULT_CONTENT_TYPE = "application/json"; + + private ObjectMapper objectMapper = new ObjectMapper(); + + private JsonEncoding encoding = JsonEncoding.UTF8; + + private boolean prefixJson = false; + + private Set renderedAttributes; + + /** + * Construct a new {@code JacksonJsonView}, setting the content type to {@code application/json}. + */ + public BindingJacksonJsonView() { + setContentType(DEFAULT_CONTENT_TYPE); + } + + /** + * Sets the {@code ObjectMapper} for this view. If not set, a default {@link ObjectMapper#ObjectMapper() ObjectMapper} + * is used. + * + *

Setting a custom-configured {@code ObjectMapper} is one way to take further control of the JSON serialization + * process. For example, an extended {@link SerializerFactory} can be configured that provides custom serializers for + * specific types. The other option for refining the serialization process is to use Jackson's provided annotations on + * the types to be serialized, in which case a custom-configured ObjectMapper is unnecessary. + */ + public void setObjectMapper(ObjectMapper objectMapper) { + Assert.notNull(objectMapper, "'objectMapper' must not be null"); + this.objectMapper = objectMapper; + } + + /** + * Sets the {@code JsonEncoding} for this converter. By default, {@linkplain JsonEncoding#UTF8 UTF-8} is used. + */ + public void setEncoding(JsonEncoding encoding) { + Assert.notNull(encoding, "'encoding' must not be null"); + this.encoding = encoding; + } + + /** + * Indicates whether the JSON output by this view should be prefixed with "{@code {} &&}". Default is false. + * + *

Prefixing the JSON string in this manner is used to help prevent JSON Hijacking. The prefix renders the string + * syntactically invalid as a script so that it cannot be hijacked. This prefix does not affect the evaluation of JSON, + * but if JSON validation is performed on the string, the prefix would need to be ignored. + */ + public void setPrefixJson(boolean prefixJson) { + this.prefixJson = prefixJson; + } + + /** + * Sets the attributes in the model that should be rendered by this view. When set, all other model attributes will be + * ignored. + */ + public void setRenderedAttributes(Set renderedAttributes) { + this.renderedAttributes = renderedAttributes; + } + + @Override + protected void prepareResponse(HttpServletRequest request, HttpServletResponse response) { + response.setContentType(getContentType()); + response.setCharacterEncoding(encoding.getJavaName()); + } + + @Override + protected void renderMergedOutputModel(Map model, + HttpServletRequest request, + HttpServletResponse response) throws Exception { + model = filterModel(model); + JsonGenerator generator = + objectMapper.getJsonFactory().createJsonGenerator(response.getOutputStream(), encoding); + if (prefixJson) { + generator.writeRaw("{} && "); + } + objectMapper.writeValue(generator, model); + } + + /** + * Filters out undesired attributes from the given model. + * + *

Default implementation removes {@link BindingResult} instances and entries not included in the {@link + * #setRenderedAttributes(Set) renderedAttributes} property. + */ + protected Map filterModel(Map model) { + Map result = new HashMap(model.size()); + if (CollectionUtils.isEmpty(renderedAttributes)) { + renderedAttributes = model.keySet(); + } + for (Map.Entry entry : model.entrySet()) { + if (!(entry instanceof BindingResult) && renderedAttributes.contains(entry.getKey())) { + result.put(entry.getKey(), entry.getValue()); + } + } + return result; + } + +} diff --git a/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/view/json/package-info.java b/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/view/json/package-info.java new file mode 100644 index 0000000000..1a33e52857 --- /dev/null +++ b/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/view/json/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2002-2009 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. + */ + +/** + * + * Support classes for providing a View implementation based on JSON serialization. + * + */ +package org.springframework.web.servlet.view.json; + diff --git a/org.springframework.web.servlet/src/test/java/org/springframework/web/servlet/view/json/BindingJacksonJsonViewTest.java b/org.springframework.web.servlet/src/test/java/org/springframework/web/servlet/view/json/BindingJacksonJsonViewTest.java new file mode 100644 index 0000000000..63a91288e9 --- /dev/null +++ b/org.springframework.web.servlet/src/test/java/org/springframework/web/servlet/view/json/BindingJacksonJsonViewTest.java @@ -0,0 +1,270 @@ +/* + * Copyright 2002-2009 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.servlet.view.json; + +import java.io.IOException; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.codehaus.jackson.JsonGenerator; +import org.codehaus.jackson.annotate.JsonUseSerializer; +import org.codehaus.jackson.map.JsonSerializer; +import org.codehaus.jackson.map.ObjectMapper; +import org.codehaus.jackson.map.SerializationConfig; +import org.codehaus.jackson.map.SerializerFactory; +import org.codehaus.jackson.map.SerializerProvider; +import org.codehaus.jackson.map.ser.BeanSerializerFactory; +import static org.junit.Assert.*; +import org.junit.Before; +import org.junit.Test; +import org.mozilla.javascript.Context; +import org.mozilla.javascript.ContextFactory; +import org.mozilla.javascript.ScriptableObject; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** + * @author Jeremy Grelle + * @author Arjen Poutsma + */ +public class BindingJacksonJsonViewTest { + + private BindingJacksonJsonView view; + + private MockHttpServletRequest request; + + private MockHttpServletResponse response; + + private Context jsContext; + + private ScriptableObject jsScope; + + @Before + public void setUp() { + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + + jsContext = ContextFactory.getGlobal().enterContext(); + jsScope = jsContext.initStandardObjects(); + + view = new BindingJacksonJsonView(); + } + + @Test + public void renderSimpleMap() throws Exception { + + Map model = new HashMap(); + model.put("foo", "bar"); + + view.render(model, request, response); + + assertEquals(BindingJacksonJsonView.DEFAULT_CONTENT_TYPE, response.getContentType()); + + String jsonResult = response.getContentAsString(); + assertTrue(jsonResult.length() > 0); + + validateResult(); + } + + @Test + public void renderSimpleMapPrefixed() throws Exception { + view.setPrefixJson(true); + renderSimpleMap(); + } + + @Test + public void renderSimpleBean() throws Exception { + + Object bean = new TestBeanSimple(); + Map model = new HashMap(); + model.put("foo", bean); + + view.render(model, request, response); + + assertTrue(response.getContentAsString().length() > 0); + + validateResult(); + } + + @Test + public void renderSimpleBeanPrefixed() throws Exception { + + view.setPrefixJson(true); + renderSimpleBean(); + } + + @Test + public void renderWithCustomSerializerLocatedByAnnotation() throws Exception { + + Object bean = new TestBeanSimpleAnnotated(); + Map model = new HashMap(); + model.put("foo", bean); + + view.render(model, request, response); + + assertTrue(response.getContentAsString().length() > 0); + assertEquals("{\"foo\":{\"testBeanSimple\":\"custom\"}}", response.getContentAsString()); + + validateResult(); + } + + @Test + public void renderWithCustomSerializerLocatedByFactory() throws Exception { + + SerializerFactory factory = new DelegatingSerializerFactory(); + ObjectMapper mapper = new ObjectMapper(factory); + view.setObjectMapper(mapper); + + Object bean = new TestBeanSimple(); + Map model = new HashMap(); + model.put("foo", bean); + model.put("bar", new TestChildBean()); + + view.render(model, request, response); + + String result = response.getContentAsString(); + assertTrue(result.length() > 0); + assertTrue(result.contains("\"foo\":{\"testBeanSimple\":\"custom\"}")); + + validateResult(); + } + + @Test + public void renderOnlyIncludedAttributes() throws Exception { + + Set attrs = new HashSet(); + attrs.add("foo"); + attrs.add("baz"); + attrs.add("nil"); + + view.setRenderedAttributes(attrs); + Map model = new HashMap(); + model.put("foo", "foo"); + model.put("bar", "bar"); + model.put("baz", "baz"); + + view.render(model, request, response); + + String result = response.getContentAsString(); + assertTrue(result.length() > 0); + assertTrue(result.contains("\"foo\":\"foo\"")); + assertTrue(result.contains("\"baz\":\"baz\"")); + + validateResult(); + } + + private void validateResult() throws Exception { + Object jsResult = + jsContext.evaluateString(jsScope, "(" + response.getContentAsString() + ")", "JSON Stream", 1, null); + assertNotNull("Json Result did not eval as valid JavaScript", jsResult); + } + + public static class TestBeanSimple { + + private String value = "foo"; + + private boolean test = false; + + private long number = 42; + + private TestChildBean child = new TestChildBean(); + + public String getValue() { + return value; + } + + public boolean getTest() { + return test; + } + + public long getNumber() { + return number; + } + + public Date getNow() { + return new Date(); + } + + public TestChildBean getChild() { + return child; + } + } + + @JsonUseSerializer(TestBeanSimpleSerializer.class) + public static class TestBeanSimpleAnnotated extends TestBeanSimple { + + } + + public static class TestChildBean { + + private String value = "bar"; + + private String baz = null; + + private TestBeanSimple parent = null; + + public String getValue() { + return value; + } + + public String getBaz() { + return baz; + } + + public TestBeanSimple getParent() { + return parent; + } + + public void setParent(TestBeanSimple parent) { + this.parent = parent; + } + } + + public static class TestBeanSimpleSerializer extends JsonSerializer { + + @Override + public void serialize(TestBeanSimple value, JsonGenerator jgen, SerializerProvider provider) + throws IOException { + + jgen.writeStartObject(); + jgen.writeFieldName("testBeanSimple"); + jgen.writeString("custom"); + jgen.writeEndObject(); + + } + } + + public static class DelegatingSerializerFactory extends SerializerFactory { + + private SerializerFactory delegate = BeanSerializerFactory.instance; + + @Override + @SuppressWarnings("unchecked") + public JsonSerializer createSerializer(Class type, SerializationConfig config) { + if (type == TestBeanSimple.class) { + return (JsonSerializer) new TestBeanSimpleSerializer(); + } + else { + return delegate.createSerializer(type, config); + } + } + } +} diff --git a/org.springframework.web.servlet/template.mf b/org.springframework.web.servlet/template.mf index b0e2244e56..cc92296082 100644 --- a/org.springframework.web.servlet/template.mf +++ b/org.springframework.web.servlet/template.mf @@ -23,6 +23,7 @@ Import-Template: org.apache.tiles.*;version="[2.0.5, 3.0.0)";resolution:=optional, org.apache.velocity.*;version="[1.5.0, 2.0.0)";resolution:=optional, org.apache.velocity.tools.*;version="[1.4.0, 3.0.0)";resolution:=optional, + org.codehaus.jackson.*;version="[1.0.0, 1.1.0)";resolution:=optional, org.springframework.beans.*;version="[3.0.0, 3.0.1)", org.springframework.context.*;version="[3.0.0, 3.0.1)", org.springframework.core.*;version="[3.0.0, 3.0.1)", diff --git a/org.springframework.web/src/main/java/org/springframework/http/converter/json/BindingJacksonHttpMessageConverter.java b/org.springframework.web/src/main/java/org/springframework/http/converter/json/BindingJacksonHttpMessageConverter.java index 8c540cd259..cd222d7e1c 100644 --- a/org.springframework.web/src/main/java/org/springframework/http/converter/json/BindingJacksonHttpMessageConverter.java +++ b/org.springframework.web/src/main/java/org/springframework/http/converter/json/BindingJacksonHttpMessageConverter.java @@ -43,6 +43,7 @@ import org.springframework.util.Assert; * method. * * @author Arjen Poutsma + * @see org.springframework.web.servlet.view.json.BindingJacksonJsonView * @since 3.0 */ public class BindingJacksonHttpMessageConverter extends AbstractHttpMessageConverter { @@ -51,26 +52,48 @@ public class BindingJacksonHttpMessageConverter extends AbstractHttpMessageCo private JsonEncoding encoding = JsonEncoding.UTF8; - /** Construct a new {@code BindingJacksonHttpMessageConverter}, */ + private boolean prefixJson = false; + + /** + * Construct a new {@code BindingJacksonHttpMessageConverter}, + */ public BindingJacksonHttpMessageConverter() { super(new MediaType("application", "json")); } /** - * Sets the {@code ObjectMapper} for this converter. By default, a default {@link ObjectMapper#ObjectMapper() - * ObjectMapper} is used. + * Sets the {@code ObjectMapper} for this view. If not set, a default {@link ObjectMapper#ObjectMapper() ObjectMapper} + * is used. + * + *

Setting a custom-configured {@code ObjectMapper} is one way to take further control of the JSON serialization + * process. For example, an extended {@link org.codehaus.jackson.map.SerializerFactory} can be configured that provides + * custom serializers for specific types. The other option for refining the serialization process is to use Jackson's + * provided annotations on the types to be serialized, in which case a custom-configured ObjectMapper is unnecessary. */ public void setObjectMapper(ObjectMapper objectMapper) { Assert.notNull(objectMapper, "'objectMapper' must not be null"); this.objectMapper = objectMapper; } - /** Sets the {@code JsonEncoding} for this converter. By default, {@linkplain JsonEncoding#UTF8 UTF-8} is used. */ + /** + * Sets the {@code JsonEncoding} for this converter. By default, {@linkplain JsonEncoding#UTF8 UTF-8} is used. + */ public void setEncoding(JsonEncoding encoding) { Assert.notNull(encoding, "'encoding' must not be null"); this.encoding = encoding; } + /** + * Indicates whether the JSON output by this view should be prefixed with "{} &&". Default is false. + * + *

Prefixing the JSON string in this manner is used to help prevent JSON Hijacking. The prefix renders the string + * syntactically invalid as a script so that it cannot be hijacked. This prefix does not affect the evaluation of JSON, + * but if JSON validation is performed on the string, the prefix would need to be ignored. + */ + public void setPrefixJson(boolean prefixJson) { + this.prefixJson = prefixJson; + } + public boolean supports(Class clazz) { return objectMapper.canSerialize(clazz); } @@ -92,6 +115,9 @@ public class BindingJacksonHttpMessageConverter extends AbstractHttpMessageCo throws IOException, HttpMessageNotWritableException { JsonGenerator jsonGenerator = objectMapper.getJsonFactory().createJsonGenerator(outputMessage.getBody(), encoding); + if (prefixJson) { + jsonGenerator.writeRaw("{} && "); + } objectMapper.writeValue(jsonGenerator, t); } }