From 6665634675da1230339e9e5749f0693fd09a68ff Mon Sep 17 00:00:00 2001 From: Sebastien Deleuze Date: Wed, 6 Aug 2014 18:19:20 +0200 Subject: [PATCH] Support Jackson based XML serialization/deserialization This commit adds support for XML serialization/deserialization based on the jackson-dataformat-xml extension. When using @EnableWebMvc or , Jackson will be used by default instead of JAXB2 if jackson-dataformat-xml classes are found in the classpath. This commit introduces MappingJackson2XmlHttpMessageConverter and MappingJackson2XmlView classes, and common parts between JSON and XML processing have been moved to AbstractJackson2HttpMessageConverter and AbstractJackson2View classes. MappingJackson2XmlView supports serialization of a single object. If the model contains multiple entries, MappingJackson2XmlView.setModelKey() should be used to specify the entry to serialize. Pretty print works in XML, but tests are not included since a Woodstox dependency is needed, and it is better to continue testing spring-web and spring-webmvc against JAXB2. Issue: SPR-11785 --- build.gradle | 5 +- .../AbstractJackson2HttpMessageConverter.java | 315 ++++++++++++++++ .../json/Jackson2ObjectMapperFactoryBean.java | 5 +- .../MappingJackson2HttpMessageConverter.java | 256 +------------ .../converter/json/MappingJacksonValue.java | 5 +- ...appingJackson2XmlHttpMessageConverter.java | 40 ++ .../Jackson2ObjectMapperFactoryBeanTests.java | 38 +- ...gJackson2XmlHttpMessageConverterTests.java | 241 ++++++++++++ .../AnnotationDrivenBeanDefinitionParser.java | 11 +- .../WebMvcConfigurationSupport.java | 11 +- .../AbstractJsonpResponseBodyAdvice.java | 4 +- ...tractMappingJacksonResponseBodyAdvice.java | 3 +- .../view/json/AbstractJackson2View.java | 246 +++++++++++++ .../view/json/MappingJackson2JsonView.java | 191 ++-------- .../view/xml/MappingJackson2XmlView.java | 90 +++++ .../view/xml/MappingJackson2XmlViewTests.java | 347 ++++++++++++++++++ src/asciidoc/index.adoc | 48 ++- 17 files changed, 1423 insertions(+), 433 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java create mode 100644 spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java create mode 100644 spring-web/src/test/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverterTests.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/AbstractJackson2View.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/view/xml/MappingJackson2XmlView.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/view/xml/MappingJackson2XmlViewTests.java diff --git a/build.gradle b/build.gradle index de08f29005..5350a7dce1 100644 --- a/build.gradle +++ b/build.gradle @@ -48,6 +48,7 @@ configure(allprojects) { project -> ext.tomcatVersion = "8.0.9" ext.xstreamVersion = "1.4.7" ext.protobufVersion = "2.5.0" + ext.woodstoxVersion = "4.1.6" ext.gradleScriptDir = "${rootProject.projectDir}/gradle" @@ -326,7 +327,7 @@ project("spring-core") { optional("log4j:log4j:1.2.17") testCompile("org.apache.tomcat.embed:tomcat-embed-core:${tomcatVersion}") testCompile("xmlunit:xmlunit:1.5") - testCompile("org.codehaus.woodstox:wstx-asl:3.2.7") { + testCompile("org.codehaus.woodstox:woodstox-core-asl:${woodstoxVersion}") { exclude group: "stax", module: "stax-api" } } @@ -665,6 +666,7 @@ project("spring-web") { optional("org.apache.httpcomponents:httpclient:4.3.4") optional("org.apache.httpcomponents:httpasyncclient:4.0.1") optional("com.fasterxml.jackson.core:jackson-databind:${jackson2Version}") + optional("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:${jackson2Version}") optional("com.google.code.gson:gson:${gsonVersion}") optional("com.rometools:rome:1.5.0") optional("org.eclipse.jetty:jetty-servlet:${jettyVersion}") { @@ -824,6 +826,7 @@ project("spring-webmvc") { exclude group: "xml-apis", module: "xml-apis" } optional("com.fasterxml.jackson.core:jackson-databind:${jackson2Version}") + optional("com.fasterxml.jackson.dataformat:jackson-dataformat-xml:${jackson2Version}") optional("com.rometools:rome:1.5.0") optional("javax.el:javax.el-api:2.2.5") optional("org.apache.tiles:tiles-api:${tiles3Version}") diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java new file mode 100644 index 0000000000..56d3760c96 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java @@ -0,0 +1,315 @@ +/* + * Copyright 2002-2014 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.http.converter.json; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.util.concurrent.atomic.AtomicReference; + +import com.fasterxml.jackson.core.JsonEncoding; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import org.springframework.http.HttpInputMessage; +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.AbstractHttpMessageConverter; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.HttpMessageNotWritableException; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Abstract base class for Jackson based and content type independent + * {@link HttpMessageConverter} implementations. + * + *

Compatible with Jackson 2.1 and higher. + * + * @author Arjen Poutsma + * @author Keith Donald + * @author Rossen Stoyanchev + * @author Juergen Hoeller + * @author Sebastien Deleuze + * @since 4.1 + */ +public abstract class AbstractJackson2HttpMessageConverter extends + AbstractHttpMessageConverter implements GenericHttpMessageConverter { + + public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); + + // Check for Jackson 2.3's overloaded canDeserialize/canSerialize variants with cause reference + private static final boolean jackson23Available = ClassUtils.hasMethod(ObjectMapper.class, + "canDeserialize", JavaType.class, AtomicReference.class); + + + protected ObjectMapper objectMapper; + + private Boolean prettyPrint; + + + protected AbstractJackson2HttpMessageConverter(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + protected AbstractJackson2HttpMessageConverter(ObjectMapper objectMapper, MediaType supportedMediaType) { + super(supportedMediaType); + this.objectMapper = objectMapper; + } + + protected AbstractJackson2HttpMessageConverter(ObjectMapper objectMapper, MediaType... supportedMediaTypes) { + super(supportedMediaTypes); + this.objectMapper = objectMapper; + } + + /** + * Set 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 com.fasterxml.jackson.databind.ser.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; + configurePrettyPrint(); + } + + /** + * Return the underlying {@code ObjectMapper} for this view. + */ + public ObjectMapper getObjectMapper() { + return this.objectMapper; + } + + /** + * Whether to use the {@link DefaultPrettyPrinter} when writing JSON. + * This is a shortcut for setting up an {@code ObjectMapper} as follows: + *

+	 * ObjectMapper mapper = new ObjectMapper();
+	 * mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
+	 * converter.setObjectMapper(mapper);
+	 * 
+ */ + public void setPrettyPrint(boolean prettyPrint) { + this.prettyPrint = prettyPrint; + configurePrettyPrint(); + } + + private void configurePrettyPrint() { + if (this.prettyPrint != null) { + this.objectMapper.configure(SerializationFeature.INDENT_OUTPUT, this.prettyPrint); + } + } + + @Override + public boolean canRead(Class clazz, MediaType mediaType) { + return canRead(clazz, null, mediaType); + } + + @Override + public boolean canRead(Type type, Class contextClass, MediaType mediaType) { + JavaType javaType = getJavaType(type, contextClass); + if (!jackson23Available || !logger.isWarnEnabled()) { + return (this.objectMapper.canDeserialize(javaType) && canRead(mediaType)); + } + AtomicReference causeRef = new AtomicReference(); + if (this.objectMapper.canDeserialize(javaType, causeRef) && canRead(mediaType)) { + return true; + } + Throwable cause = causeRef.get(); + if (cause != null) { + String msg = "Failed to evaluate deserialization for type " + javaType; + if (logger.isDebugEnabled()) { + logger.warn(msg, cause); + } + else { + logger.warn(msg + ": " + cause); + } + } + return false; + } + + @Override + public boolean canWrite(Class clazz, MediaType mediaType) { + if (!jackson23Available || !logger.isWarnEnabled()) { + return (this.objectMapper.canSerialize(clazz) && canWrite(mediaType)); + } + AtomicReference causeRef = new AtomicReference(); + if (this.objectMapper.canSerialize(clazz, causeRef) && canWrite(mediaType)) { + return true; + } + Throwable cause = causeRef.get(); + if (cause != null) { + String msg = "Failed to evaluate serialization for type [" + clazz + "]"; + if (logger.isDebugEnabled()) { + logger.warn(msg, cause); + } + else { + logger.warn(msg + ": " + cause); + } + } + return false; + } + + @Override + protected boolean supports(Class clazz) { + // should not be called, since we override canRead/Write instead + throw new UnsupportedOperationException(); + } + + @Override + protected Object readInternal(Class clazz, HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + + JavaType javaType = getJavaType(clazz, null); + return readJavaType(javaType, inputMessage); + } + + @Override + public Object read(Type type, Class contextClass, HttpInputMessage inputMessage) + throws IOException, HttpMessageNotReadableException { + + JavaType javaType = getJavaType(type, contextClass); + return readJavaType(javaType, inputMessage); + } + + private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) { + try { + return this.objectMapper.readValue(inputMessage.getBody(), javaType); + } + catch (IOException ex) { + throw new HttpMessageNotReadableException("Could not read JSON: " + ex.getMessage(), ex); + } + } + + @Override + protected void writeInternal(Object object, HttpOutputMessage outputMessage) + throws IOException, HttpMessageNotWritableException { + + JsonEncoding encoding = getJsonEncoding(outputMessage.getHeaders().getContentType()); + JsonGenerator generator = this.objectMapper.getFactory().createGenerator(outputMessage.getBody(), encoding); + try { + writePrefix(generator, object); + Class serializationView = null; + Object value = object; + if (value instanceof MappingJacksonValue) { + MappingJacksonValue container = (MappingJacksonValue) object; + value = container.getValue(); + serializationView = container.getSerializationView(); + } + if (serializationView != null) { + this.objectMapper.writerWithView(serializationView).writeValue(generator, value); + } + else { + this.objectMapper.writeValue(generator, value); + } + writeSuffix(generator, object); + generator.flush(); + + } + catch (JsonProcessingException ex) { + throw new HttpMessageNotWritableException("Could not write content: " + ex.getMessage(), ex); + } + } + + /** + * Write a prefix before the main content. + * @param generator the generator to use for writing content. + * @param object the object to write to the output message. + */ + protected void writePrefix(JsonGenerator generator, Object object) throws IOException { + + } + + /** + * Write a suffix after the main content. + * @param generator the generator to use for writing content. + * @param object the object to write to the output message. + */ + protected void writeSuffix(JsonGenerator generator, Object object) throws IOException { + + } + + /** + * Return the Jackson {@link JavaType} for the specified type and context class. + *

The default implementation returns {@code typeFactory.constructType(type, contextClass)}, + * but this can be overridden in subclasses, to allow for custom generic collection handling. + * For instance: + *

+	 * protected JavaType getJavaType(Type type) {
+	 *   if (type instanceof Class && List.class.isAssignableFrom((Class)type)) {
+	 *     return TypeFactory.collectionType(ArrayList.class, MyBean.class);
+	 *   } else {
+	 *     return super.getJavaType(type);
+	 *   }
+	 * }
+	 * 
+ * @param type the type to return the java type for + * @param contextClass a context class for the target type, for example a class + * in which the target type appears in a method signature, can be {@code null} + * signature, can be {@code null} + * @return the java type + */ + protected JavaType getJavaType(Type type, Class contextClass) { + return this.objectMapper.getTypeFactory().constructType(type, contextClass); + } + + /** + * Determine the JSON encoding to use for the given content type. + * @param contentType the media type as requested by the caller + * @return the JSON encoding to use (never {@code null}) + */ + protected JsonEncoding getJsonEncoding(MediaType contentType) { + if (contentType != null && contentType.getCharSet() != null) { + Charset charset = contentType.getCharSet(); + for (JsonEncoding encoding : JsonEncoding.values()) { + if (charset.name().equals(encoding.getJavaName())) { + return encoding; + } + } + } + return JsonEncoding.UTF8; + } + + @Override + protected MediaType getDefaultContentType(Object object) throws IOException { + if (object instanceof MappingJacksonValue) { + object = ((MappingJacksonValue) object).getValue(); + } + return super.getDefaultContentType(object); + } + + @Override + protected Long getContentLength(Object object, MediaType contentType) throws IOException { + if (object instanceof MappingJacksonValue) { + object = ((MappingJacksonValue) object).getValue(); + } + return super.getContentLength(object, contentType); + } + +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBean.java b/spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBean.java index 19abe082e6..3ef14ce92c 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBean.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBean.java @@ -51,7 +51,7 @@ import org.springframework.util.ClassUtils; * to enable or disable Jackson features from within XML configuration. * *

Example usage with - * {@link org.springframework.http.converter.json.MappingJackson2HttpMessageConverter}: + * {@link MappingJackson2HttpMessageConverter}: * *

  * <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter">
@@ -486,7 +486,8 @@ public class Jackson2ObjectMapperFactoryBean implements FactoryBean getObjectType() {
-		return ObjectMapper.class;
+		Assert.notNull(this.objectMapper, "ObjectMapper must not be null");
+		return this.objectMapper.getClass();
 	}
 
 	@Override
diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java
index ec8f942171..98e588d215 100644
--- a/spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java
+++ b/spring-web/src/main/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverter.java
@@ -17,27 +17,11 @@
 package org.springframework.http.converter.json;
 
 import java.io.IOException;
-import java.lang.reflect.Type;
-import java.nio.charset.Charset;
-import java.util.concurrent.atomic.AtomicReference;
 
-import com.fasterxml.jackson.core.JsonEncoding;
 import com.fasterxml.jackson.core.JsonGenerator;
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
-import com.fasterxml.jackson.databind.JavaType;
 import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.SerializationFeature;
 
-import org.springframework.http.HttpInputMessage;
-import org.springframework.http.HttpOutputMessage;
 import org.springframework.http.MediaType;
-import org.springframework.http.converter.AbstractHttpMessageConverter;
-import org.springframework.http.converter.GenericHttpMessageConverter;
-import org.springframework.http.converter.HttpMessageNotReadableException;
-import org.springframework.http.converter.HttpMessageNotWritableException;
-import org.springframework.util.Assert;
-import org.springframework.util.ClassUtils;
 
 /**
  * Implementation of {@link org.springframework.http.converter.HttpMessageConverter HttpMessageConverter} that
@@ -54,58 +38,22 @@ import org.springframework.util.ClassUtils;
  * @author Keith Donald
  * @author Rossen Stoyanchev
  * @author Juergen Hoeller
+ * @author Sebastien Deleuze
  * @since 3.1.2
  */
-public class MappingJackson2HttpMessageConverter extends AbstractHttpMessageConverter
-		implements GenericHttpMessageConverter {
-
-	public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
-
-	// Check for Jackson 2.3's overloaded canDeserialize/canSerialize variants with cause reference
-	private static final boolean jackson23Available =
-			ClassUtils.hasMethod(ObjectMapper.class, "canDeserialize", JavaType.class, AtomicReference.class);
-
-
-	private ObjectMapper objectMapper = new ObjectMapper();
+public class MappingJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
 
 	private String jsonPrefix;
 
-	private Boolean prettyPrint;
-
 
 	/**
 	 * Construct a new {@code MappingJackson2HttpMessageConverter}.
 	 */
 	public MappingJackson2HttpMessageConverter() {
-		super(new MediaType("application", "json", DEFAULT_CHARSET),
+		super(new ObjectMapper(), new MediaType("application", "json", DEFAULT_CHARSET),
 				new MediaType("application", "*+json", DEFAULT_CHARSET));
 	}
 
-
-	/**
-	 * Set 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 com.fasterxml.jackson.databind.ser.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; - configurePrettyPrint(); - } - - /** - * Return the underlying {@code ObjectMapper} for this view. - */ - public ObjectMapper getObjectMapper() { - return this.objectMapper; - } - /** * Specify a custom prefix to use for this view's JSON output. * Default is none. @@ -127,199 +75,29 @@ public class MappingJackson2HttpMessageConverter extends AbstractHttpMessageConv this.jsonPrefix = (prefixJson ? "{} && " : null); } - /** - * Whether to use the {@link DefaultPrettyPrinter} when writing JSON. - * This is a shortcut for setting up an {@code ObjectMapper} as follows: - *

-	 * ObjectMapper mapper = new ObjectMapper();
-	 * mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
-	 * converter.setObjectMapper(mapper);
-	 * 
- */ - public void setPrettyPrint(boolean prettyPrint) { - this.prettyPrint = prettyPrint; - configurePrettyPrint(); - } - - private void configurePrettyPrint() { - if (this.prettyPrint != null) { - this.objectMapper.configure(SerializationFeature.INDENT_OUTPUT, this.prettyPrint); - } - } - @Override - public boolean canRead(Class clazz, MediaType mediaType) { - return canRead(clazz, null, mediaType); - } - - @Override - public boolean canRead(Type type, Class contextClass, MediaType mediaType) { - JavaType javaType = getJavaType(type, contextClass); - if (!jackson23Available || !logger.isWarnEnabled()) { - return (this.objectMapper.canDeserialize(javaType) && canRead(mediaType)); + protected void writePrefix(JsonGenerator generator, Object object) throws IOException { + if (this.jsonPrefix != null) { + generator.writeRaw(this.jsonPrefix); } - AtomicReference causeRef = new AtomicReference(); - if (this.objectMapper.canDeserialize(javaType, causeRef) && canRead(mediaType)) { - return true; - } - Throwable cause = causeRef.get(); - if (cause != null) { - String msg = "Failed to evaluate deserialization for type " + javaType; - if (logger.isDebugEnabled()) { - logger.warn(msg, cause); - } - else { - logger.warn(msg + ": " + cause); - } - } - return false; - } - - @Override - public boolean canWrite(Class clazz, MediaType mediaType) { - if (!jackson23Available || !logger.isWarnEnabled()) { - return (this.objectMapper.canSerialize(clazz) && canWrite(mediaType)); - } - AtomicReference causeRef = new AtomicReference(); - if (this.objectMapper.canSerialize(clazz, causeRef) && canWrite(mediaType)) { - return true; - } - Throwable cause = causeRef.get(); - if (cause != null) { - String msg = "Failed to evaluate serialization for type [" + clazz + "]"; - if (logger.isDebugEnabled()) { - logger.warn(msg, cause); - } - else { - logger.warn(msg + ": " + cause); - } - } - return false; - } - - @Override - protected boolean supports(Class clazz) { - // should not be called, since we override canRead/Write instead - throw new UnsupportedOperationException(); - } - - @Override - protected Object readInternal(Class clazz, HttpInputMessage inputMessage) - throws IOException, HttpMessageNotReadableException { - - JavaType javaType = getJavaType(clazz, null); - return readJavaType(javaType, inputMessage); - } - - @Override - public Object read(Type type, Class contextClass, HttpInputMessage inputMessage) - throws IOException, HttpMessageNotReadableException { - - JavaType javaType = getJavaType(type, contextClass); - return readJavaType(javaType, inputMessage); - } - - private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) { - try { - return this.objectMapper.readValue(inputMessage.getBody(), javaType); - } - catch (IOException ex) { - throw new HttpMessageNotReadableException("Could not read JSON: " + ex.getMessage(), ex); - } - } - - @Override - protected void writeInternal(Object object, HttpOutputMessage outputMessage) - throws IOException, HttpMessageNotWritableException { - - JsonEncoding encoding = getJsonEncoding(outputMessage.getHeaders().getContentType()); - JsonGenerator generator = this.objectMapper.getFactory().createGenerator(outputMessage.getBody(), encoding); - try { - if (this.jsonPrefix != null) { - generator.writeRaw(this.jsonPrefix); - } - Class serializationView = null; - String jsonpFunction = null; - if (object instanceof MappingJacksonValue) { - MappingJacksonValue container = (MappingJacksonValue) object; - object = container.getValue(); - serializationView = container.getSerializationView(); - jsonpFunction = container.getJsonpFunction(); - } - if (jsonpFunction != null) { - generator.writeRaw(jsonpFunction + "("); - } - if (serializationView != null) { - this.objectMapper.writerWithView(serializationView).writeValue(generator, object); - } - else { - this.objectMapper.writeValue(generator, object); - } - if (jsonpFunction != null) { - generator.writeRaw(");"); - generator.flush(); - } - } - catch (JsonProcessingException ex) { - throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getMessage(), ex); - } - } - - /** - * Return the Jackson {@link JavaType} for the specified type and context class. - *

The default implementation returns {@code typeFactory.constructType(type, contextClass)}, - * but this can be overridden in subclasses, to allow for custom generic collection handling. - * For instance: - *

-	 * protected JavaType getJavaType(Type type) {
-	 *   if (type instanceof Class && List.class.isAssignableFrom((Class)type)) {
-	 *     return TypeFactory.collectionType(ArrayList.class, MyBean.class);
-	 *   } else {
-	 *     return super.getJavaType(type);
-	 *   }
-	 * }
-	 * 
- * @param type the type to return the java type for - * @param contextClass a context class for the target type, for example a class - * in which the target type appears in a method signature, can be {@code null} - * signature, can be {@code null} - * @return the java type - */ - protected JavaType getJavaType(Type type, Class contextClass) { - return this.objectMapper.getTypeFactory().constructType(type, contextClass); - } - - /** - * Determine the JSON encoding to use for the given content type. - * @param contentType the media type as requested by the caller - * @return the JSON encoding to use (never {@code null}) - */ - protected JsonEncoding getJsonEncoding(MediaType contentType) { - if (contentType != null && contentType.getCharSet() != null) { - Charset charset = contentType.getCharSet(); - for (JsonEncoding encoding : JsonEncoding.values()) { - if (charset.name().equals(encoding.getJavaName())) { - return encoding; - } - } - } - return JsonEncoding.UTF8; - } - - @Override - protected MediaType getDefaultContentType(Object object) throws IOException { + String jsonpFunction = null; if (object instanceof MappingJacksonValue) { - object = ((MappingJacksonValue) object).getValue(); + jsonpFunction = ((MappingJacksonValue)object).getJsonpFunction(); + } + if (jsonpFunction != null) { + generator.writeRaw(jsonpFunction + "("); } - return super.getDefaultContentType(object); } @Override - protected Long getContentLength(Object object, MediaType contentType) throws IOException { + protected void writeSuffix(JsonGenerator generator, Object object) throws IOException { + String jsonpFunction = null; if (object instanceof MappingJacksonValue) { - object = ((MappingJacksonValue) object).getValue(); + jsonpFunction = ((MappingJacksonValue)object).getJsonpFunction(); + } + if (jsonpFunction != null) { + generator.writeRaw(");"); } - return super.getContentLength(object, contentType); } } diff --git a/spring-web/src/main/java/org/springframework/http/converter/json/MappingJacksonValue.java b/spring-web/src/main/java/org/springframework/http/converter/json/MappingJacksonValue.java index c184ac310a..e421b99ec7 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/json/MappingJacksonValue.java +++ b/spring-web/src/main/java/org/springframework/http/converter/json/MappingJacksonValue.java @@ -18,9 +18,8 @@ package org.springframework.http.converter.json; /** * A simple holder for the POJO to serialize via - * {@link org.springframework.http.converter.json.MappingJackson2HttpMessageConverter - * MappingJackson2HttpMessageConverter} along with further serialization - * instructions to be passed in to the converter. + * {@link MappingJackson2HttpMessageConverter} along with further + * serialization instructions to be passed in to the converter. * *

On the server side this wrapper is added with a * {@code ResponseBodyInterceptor} after content negotiation selects the diff --git a/spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java new file mode 100644 index 0000000000..c2908bb40d --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverter.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2014 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.http.converter.xml; + +import com.fasterxml.jackson.dataformat.xml.XmlMapper; + +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; + +/** + * Implementation of {@link org.springframework.http.converter.HttpMessageConverter HttpMessageConverter} that + * can read and write XML using Jackson 2.x extension component for + * reading and writing XML encoded data. + * + * @author Sebastien Deleuze + * @since 4.1 + */ +public class MappingJackson2XmlHttpMessageConverter extends AbstractJackson2HttpMessageConverter { + + public MappingJackson2XmlHttpMessageConverter() { + super(new XmlMapper(), + new MediaType("application", "xml", MappingJackson2HttpMessageConverter.DEFAULT_CHARSET)); + } + +} diff --git a/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java b/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java index eced2ab722..33361de491 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/json/Jackson2ObjectMapperFactoryBeanTests.java @@ -45,6 +45,7 @@ import com.fasterxml.jackson.databind.ser.Serializers; import com.fasterxml.jackson.databind.ser.std.ClassSerializer; import com.fasterxml.jackson.databind.ser.std.NumberSerializer; import com.fasterxml.jackson.databind.type.SimpleType; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; import org.junit.Before; import org.junit.Test; @@ -70,7 +71,7 @@ public class Jackson2ObjectMapperFactoryBeanTests { } @Test - public void testSettersWithNullValues() { + public void settersWithNullValues() { // Should not crash: factory.setSerializers((JsonSerializer[]) null); factory.setSerializersByType(null); @@ -80,13 +81,13 @@ public class Jackson2ObjectMapperFactoryBeanTests { } @Test(expected = FatalBeanException.class) - public void testUnknownFeature() { + public void unknownFeature() { this.factory.setFeaturesToEnable(Boolean.TRUE); this.factory.afterPropertiesSet(); } @Test - public void testBooleanSetters() { + public void booleanSetters() { this.factory.setAutoDetectFields(false); this.factory.setAutoDetectGettersSetters(false); this.factory.setDefaultViewInclusion(false); @@ -107,7 +108,7 @@ public class Jackson2ObjectMapperFactoryBeanTests { } @Test - public void testSetNotNullSerializationInclusion() { + public void setNotNullSerializationInclusion() { factory.afterPropertiesSet(); assertTrue(factory.getObject().getSerializationConfig().getSerializationInclusion() == JsonInclude.Include.ALWAYS); @@ -117,7 +118,7 @@ public class Jackson2ObjectMapperFactoryBeanTests { } @Test - public void testSetNotDefaultSerializationInclusion() { + public void setNotDefaultSerializationInclusion() { factory.afterPropertiesSet(); assertTrue(factory.getObject().getSerializationConfig().getSerializationInclusion() == JsonInclude.Include.ALWAYS); @@ -127,7 +128,7 @@ public class Jackson2ObjectMapperFactoryBeanTests { } @Test - public void testSetNotEmptySerializationInclusion() { + public void setNotEmptySerializationInclusion() { factory.afterPropertiesSet(); assertTrue(factory.getObject().getSerializationConfig().getSerializationInclusion() == JsonInclude.Include.ALWAYS); @@ -137,7 +138,7 @@ public class Jackson2ObjectMapperFactoryBeanTests { } @Test - public void testDateTimeFormatSetter() { + public void dateTimeFormatSetter() { SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMAT); this.factory.setDateFormat(dateFormat); @@ -148,7 +149,7 @@ public class Jackson2ObjectMapperFactoryBeanTests { } @Test - public void testSimpleDateFormatStringSetter() { + public void simpleDateFormatStringSetter() { SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_FORMAT); this.factory.setSimpleDateFormat(DATE_FORMAT); @@ -159,7 +160,7 @@ public class Jackson2ObjectMapperFactoryBeanTests { } @Test - public void testSetModules() { + public void setModules() { NumberSerializer serializer1 = new NumberSerializer(); SimpleModule module = new SimpleModule(); module.addSerializer(Integer.class, serializer1); @@ -173,7 +174,7 @@ public class Jackson2ObjectMapperFactoryBeanTests { } @Test - public void testSimpleSetup() { + public void simpleSetup() { this.factory.afterPropertiesSet(); assertNotNull(this.factory.getObject()); @@ -190,7 +191,7 @@ public class Jackson2ObjectMapperFactoryBeanTests { } @Test - public void testPropertyNamingStrategy() { + public void propertyNamingStrategy() { PropertyNamingStrategy strategy = new PropertyNamingStrategy.LowerCaseWithUnderscoresStrategy(); this.factory.setPropertyNamingStrategy(strategy); this.factory.afterPropertiesSet(); @@ -200,18 +201,17 @@ public class Jackson2ObjectMapperFactoryBeanTests { } @Test - public void testCompleteSetup() { + public void completeSetup() { NopAnnotationIntrospector annotationIntrospector = NopAnnotationIntrospector.instance; ObjectMapper objectMapper = new ObjectMapper(); + factory.setObjectMapper(objectMapper); assertTrue(this.factory.isSingleton()); assertEquals(ObjectMapper.class, this.factory.getObjectType()); Map, JsonDeserializer> deserializers = new HashMap, JsonDeserializer>(); deserializers.put(Date.class, new DateDeserializer()); - factory.setObjectMapper(objectMapper); - JsonSerializer> serializer1 = new ClassSerializer(); JsonSerializer serializer2 = new NumberSerializer(); @@ -261,4 +261,14 @@ public class Jackson2ObjectMapperFactoryBeanTests { assertTrue(objectMapper.getSerializationConfig().getSerializationInclusion() == JsonInclude.Include.NON_NULL); } + @Test + public void xmlMapper() { + this.factory.setObjectMapper(new XmlMapper()); + this.factory.afterPropertiesSet(); + + assertNotNull(this.factory.getObject()); + assertTrue(this.factory.isSingleton()); + assertEquals(XmlMapper.class, this.factory.getObjectType()); + } + } diff --git a/spring-web/src/test/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverterTests.java new file mode 100644 index 0000000000..a289f31363 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/http/converter/xml/MappingJackson2XmlHttpMessageConverterTests.java @@ -0,0 +1,241 @@ +/* + * Copyright 2002-2014 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.http.converter.xml; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.Charset; + +import com.fasterxml.jackson.annotation.JsonView; +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +import org.springframework.http.HttpOutputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.MockHttpInputMessage; +import org.springframework.http.MockHttpOutputMessage; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.converter.json.MappingJacksonValue; + +/** + * Jackson 2.x XML converter tests. + * + * @author Sebastien Deleuze + */ +public class MappingJackson2XmlHttpMessageConverterTests { + + private final MappingJackson2XmlHttpMessageConverter converter = new MappingJackson2XmlHttpMessageConverter(); + + + @Test + public void canRead() { + assertTrue(converter.canRead(MyBean.class, new MediaType("application", "xml"))); + } + + @Test + public void canWrite() { + assertTrue(converter.canWrite(MyBean.class, new MediaType("application", "xml"))); + } + + @Test + public void read() throws IOException { + String body = + "Foo4242.0FooBartrueAQI="; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes("UTF-8")); + inputMessage.getHeaders().setContentType(new MediaType("application", "xml")); + MyBean result = (MyBean) converter.read(MyBean.class, inputMessage); + assertEquals("Foo", result.getString()); + assertEquals(42, result.getNumber()); + assertEquals(42F, result.getFraction(), 0F); + assertArrayEquals(new String[]{"Foo", "Bar"}, result.getArray()); + assertTrue(result.isBool()); + assertArrayEquals(new byte[]{0x1, 0x2}, result.getBytes()); + } + + @Test + public void write() throws IOException { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + MyBean body = new MyBean(); + body.setString("Foo"); + body.setNumber(42); + body.setFraction(42F); + body.setArray(new String[]{"Foo", "Bar"}); + body.setBool(true); + body.setBytes(new byte[]{0x1, 0x2}); + converter.write(body, null, outputMessage); + Charset utf8 = Charset.forName("UTF-8"); + String result = outputMessage.getBodyAsString(utf8); + assertTrue(result.contains("Foo")); + assertTrue(result.contains("42")); + assertTrue(result.contains("42.0")); + assertTrue(result.contains("FooBar")); + assertTrue(result.contains("true")); + assertTrue(result.contains("AQI=")); + assertEquals("Invalid content-type", new MediaType("application", "xml", utf8), + outputMessage.getHeaders().getContentType()); + } + + @Test(expected = HttpMessageNotReadableException.class) + public void readInvalidXml() throws IOException { + String body = "FooBar"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes("UTF-8")); + inputMessage.getHeaders().setContentType(new MediaType("application", "xml")); + converter.read(MyBean.class, inputMessage); + } + + @Test(expected = HttpMessageNotReadableException.class) + public void readValidXmlWithUnknownProperty() throws IOException { + String body = "stringvalue"; + MockHttpInputMessage inputMessage = new MockHttpInputMessage(body.getBytes("UTF-8")); + inputMessage.getHeaders().setContentType(new MediaType("application", "xml")); + converter.read(MyBean.class, inputMessage); + } + + @Test + public void jsonView() throws Exception { + MockHttpOutputMessage outputMessage = new MockHttpOutputMessage(); + JacksonViewBean bean = new JacksonViewBean(); + bean.setWithView1("with"); + bean.setWithView2("with"); + bean.setWithoutView("without"); + + MappingJacksonValue jacksonValue = new MappingJacksonValue(bean); + jacksonValue.setSerializationView(MyJacksonView1.class); + this.writeInternal(jacksonValue, outputMessage); + + String result = outputMessage.getBodyAsString(Charset.forName("UTF-8")); + assertThat(result, containsString("with")); + assertThat(result, containsString("without")); + assertThat(result, not(containsString("with"))); + } + + private void writeInternal(Object object, HttpOutputMessage outputMessage) + throws NoSuchMethodException, InvocationTargetException, + IllegalAccessException { + Method method = AbstractJackson2HttpMessageConverter.class.getDeclaredMethod( + "writeInternal", Object.class, HttpOutputMessage.class); + method.setAccessible(true); + method.invoke(this.converter, object, outputMessage); + } + + + public static class MyBean { + + private String string; + + private int number; + + private float fraction; + + private String[] array; + + private boolean bool; + + private byte[] bytes; + + public byte[] getBytes() { + return bytes; + } + + public void setBytes(byte[] bytes) { + this.bytes = bytes; + } + + public boolean isBool() { + return bool; + } + + public void setBool(boolean bool) { + this.bool = bool; + } + + public String getString() { + return string; + } + + public void setString(String string) { + this.string = string; + } + + public int getNumber() { + return number; + } + + public void setNumber(int number) { + this.number = number; + } + + public float getFraction() { + return fraction; + } + + public void setFraction(float fraction) { + this.fraction = fraction; + } + + public String[] getArray() { + return array; + } + + public void setArray(String[] array) { + this.array = array; + } + } + + private interface MyJacksonView1 {}; + private interface MyJacksonView2 {}; + + private static class JacksonViewBean { + + @JsonView(MyJacksonView1.class) + private String withView1; + + @JsonView(MyJacksonView2.class) + private String withView2; + + private String withoutView; + + public String getWithView1() { + return withView1; + } + + public void setWithView1(String withView1) { + this.withView1 = withView1; + } + + public String getWithView2() { + return withView2; + } + + public void setWithView2(String withView2) { + this.withView2 = withView2; + } + + public String getWithoutView() { + return withoutView; + } + + public void setWithoutView(String withoutView) { + this.withoutView = withoutView; + } + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java index fbb81ad0b4..104a73b53b 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java @@ -47,6 +47,7 @@ import org.springframework.http.converter.json.GsonHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter; +import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; import org.springframework.http.converter.xml.SourceHttpMessageConverter; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; @@ -159,6 +160,9 @@ class AnnotationDrivenBeanDefinitionParser implements BeanDefinitionParser { ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", AnnotationDrivenBeanDefinitionParser.class.getClassLoader()) && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", AnnotationDrivenBeanDefinitionParser.class.getClassLoader()); + private static final boolean jackson2XmlPresent = + ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", AnnotationDrivenBeanDefinitionParser.class.getClassLoader()); + private static final boolean gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", AnnotationDrivenBeanDefinitionParser.class.getClassLoader()); @@ -401,7 +405,7 @@ class AnnotationDrivenBeanDefinitionParser implements BeanDefinitionParser { props.put("atom", MediaType.APPLICATION_ATOM_XML_VALUE); props.put("rss", "application/rss+xml"); } - if (jaxb2Present) { + if (jaxb2Present || jackson2XmlPresent) { props.put("xml", MediaType.APPLICATION_XML_VALUE); } if (jackson2Present || gsonPresent) { @@ -528,7 +532,10 @@ class AnnotationDrivenBeanDefinitionParser implements BeanDefinitionParser { messageConverters.add(createConverterDefinition(AtomFeedHttpMessageConverter.class, source)); messageConverters.add(createConverterDefinition(RssChannelHttpMessageConverter.class, source)); } - if (jaxb2Present) { + if(jackson2XmlPresent) { + messageConverters.add(createConverterDefinition(MappingJackson2XmlHttpMessageConverter.class, source)); + } + else if (jaxb2Present) { messageConverters.add(createConverterDefinition(Jaxb2RootElementHttpMessageConverter.class, source)); } if (jackson2Present) { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java index 05d48330de..d9d49ddff7 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java @@ -48,6 +48,7 @@ import org.springframework.http.converter.json.GsonHttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter; import org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter; +import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter; import org.springframework.http.converter.xml.SourceHttpMessageConverter; import org.springframework.util.AntPathMatcher; import org.springframework.util.ClassUtils; @@ -171,6 +172,9 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", WebMvcConfigurationSupport.class.getClassLoader()) && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", WebMvcConfigurationSupport.class.getClassLoader()); + private static final boolean jackson2XmlPresent = + ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", WebMvcConfigurationSupport.class.getClassLoader()); + private static final boolean gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", WebMvcConfigurationSupport.class.getClassLoader()); @@ -302,7 +306,7 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv map.put("atom", MediaType.APPLICATION_ATOM_XML); map.put("rss", MediaType.valueOf("application/rss+xml")); } - if (jaxb2Present) { + if (jaxb2Present || jackson2XmlPresent) { map.put("xml", MediaType.APPLICATION_XML); } if (jackson2Present || gsonPresent) { @@ -653,7 +657,10 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv messageConverters.add(new AtomFeedHttpMessageConverter()); messageConverters.add(new RssChannelHttpMessageConverter()); } - if (jaxb2Present) { + if(jackson2XmlPresent) { + messageConverters.add(new MappingJackson2XmlHttpMessageConverter()); + } + else if (jaxb2Present) { messageConverters.add(new Jaxb2RootElementHttpMessageConverter()); } if (jackson2Present) { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractJsonpResponseBodyAdvice.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractJsonpResponseBodyAdvice.java index 765d93d05a..7dc43b4021 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractJsonpResponseBodyAdvice.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractJsonpResponseBodyAdvice.java @@ -29,8 +29,8 @@ import javax.servlet.http.HttpServletRequest; /** * A convenient base class for a {@code ResponseBodyAdvice} to instruct the - * {@link org.springframework.http.converter.json.MappingJackson2HttpMessageConverter - * MappingJackson2HttpMessageConverter} to serialize with JSONP formatting. + * {@link org.springframework.http.converter.json.MappingJackson2HttpMessageConverter} + * to serialize with JSONP formatting. * *

Sub-classes must specify the query parameter name(s) to check for the name * of the JSONP callback function. diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMappingJacksonResponseBodyAdvice.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMappingJacksonResponseBodyAdvice.java index a37c743e2a..dd87024c59 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMappingJacksonResponseBodyAdvice.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMappingJacksonResponseBodyAdvice.java @@ -27,8 +27,7 @@ import org.springframework.http.server.ServerHttpResponse; /** * A convenient base class for {@code ResponseBodyAdvice} implementations * that customize the response before JSON serialization with - * {@link org.springframework.http.converter.json.MappingJackson2HttpMessageConverter - * MappingJackson2HttpMessageConverter}. + * {@link MappingJackson2HttpMessageConverter}. * * @author Rossen Stoyanchev * @since 4.1 diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/AbstractJackson2View.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/AbstractJackson2View.java new file mode 100644 index 0000000000..9adf410643 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/AbstractJackson2View.java @@ -0,0 +1,246 @@ +/* + * Copyright 2002-2014 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.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.fasterxml.jackson.annotation.JsonView; +import com.fasterxml.jackson.core.JsonEncoding; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import org.springframework.http.converter.json.MappingJacksonValue; +import org.springframework.util.Assert; +import org.springframework.web.servlet.view.AbstractView; + +/** + * Abstract base class for Jackson based and content type independent + * {@link AbstractView} implementations. + * + *

Compatible with Jackson 2.1 and higher. + * + * @author Jeremy Grelle + * @author Arjen Poutsma + * @author Rossen Stoyanchev + * @author Juergen Hoeller + * @author Sebastien Deleuze + * @since 4.1 + */ +public abstract class AbstractJackson2View extends AbstractView { + + private ObjectMapper objectMapper; + + private JsonEncoding encoding = JsonEncoding.UTF8; + + private Boolean prettyPrint; + + private boolean disableCaching = true; + + protected boolean updateContentLength = false; + + + protected AbstractJackson2View(ObjectMapper objectMapper, String contentType) { + this.objectMapper = objectMapper; + setContentType(contentType); + setExposePathVariables(false); + } + + /** + * Set the {@code ObjectMapper} for this view. + * If not set, a default {@link ObjectMapper#ObjectMapper() ObjectMapper} will be used. + *

Setting a custom-configured {@code ObjectMapper} is one way to take further control of + * the JSON serialization process. The other option 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; + configurePrettyPrint(); + } + + /** + * Return the {@code ObjectMapper} for this view. + */ + public final ObjectMapper getObjectMapper() { + return this.objectMapper; + } + + /** + * Set the {@code JsonEncoding} for this view. + * 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; + } + + /** + * Return the {@code JsonEncoding} for this view. + */ + public final JsonEncoding getEncoding() { + return this.encoding; + } + + /** + * Whether to use the default pretty printer when writing JSON. + * This is a shortcut for setting up an {@code ObjectMapper} as follows: + *

+	 * ObjectMapper mapper = new ObjectMapper();
+	 * mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
+	 * 
+ *

The default value is {@code false}. + */ + public void setPrettyPrint(boolean prettyPrint) { + this.prettyPrint = prettyPrint; + configurePrettyPrint(); + } + + private void configurePrettyPrint() { + if (this.prettyPrint != null) { + this.objectMapper.configure(SerializationFeature.INDENT_OUTPUT, this.prettyPrint); + } + } + + /** + * Set the attribute in the model that should be rendered by this view. + * When set, all other model attributes will be ignored. + */ + public abstract void setModelKey(String modelKey); + + /** + * Disables caching of the generated JSON. + *

Default is {@code true}, which will prevent the client from caching the generated JSON. + */ + public void setDisableCaching(boolean disableCaching) { + this.disableCaching = disableCaching; + } + + /** + * Whether to update the 'Content-Length' header of the response. When set to + * {@code true}, the response is buffered in order to determine the content + * length and set the 'Content-Length' header of the response. + *

The default setting is {@code false}. + */ + public void setUpdateContentLength(boolean updateContentLength) { + this.updateContentLength = updateContentLength; + } + + @Override + protected void prepareResponse(HttpServletRequest request, HttpServletResponse response) { + setResponseContentType(request, response); + response.setCharacterEncoding(this.encoding.getJavaName()); + if (this.disableCaching) { + response.addHeader("Pragma", "no-cache"); + response.addHeader("Cache-Control", "no-cache, no-store, max-age=0"); + response.addDateHeader("Expires", 1L); + } + } + + @Override + protected void renderMergedOutputModel(Map model, HttpServletRequest request, + HttpServletResponse response) throws Exception { + + OutputStream stream = (this.updateContentLength ? createTemporaryOutputStream() : response.getOutputStream()); + Object value = filterAndWrapModel(model, request); + + writeContent(stream, value); + if (this.updateContentLength) { + writeToResponse(response, (ByteArrayOutputStream) stream); + } + } + + /** + * Filter and optionally wrap the model in {@link MappingJacksonValue} container. + * @param model the model, as passed on to {@link #renderMergedOutputModel} + * @param request current HTTP request + * @return the wrapped or unwrapped value to be rendered + */ + protected Object filterAndWrapModel(Map model, HttpServletRequest request) { + Object value = filterModel(model); + Class serializationView = (Class) model.get(JsonView.class.getName()); + if (serializationView != null) { + MappingJacksonValue container = new MappingJacksonValue(value); + container.setSerializationView(serializationView); + value = container; + } + return value; + } + + /** + * Filter out undesired attributes from the given model. + * The return value can be either another {@link Map} or a single value object. + * @param model the model, as passed on to {@link #renderMergedOutputModel} + * @return the value to be rendered + */ + protected abstract Object filterModel(Map model); + + /** + * Write the actual JSON content to the stream. + * @param stream the output stream to use + * @param object the value to be rendered, as returned from {@link #filterModel} + * @throws IOException if writing failed + */ + protected void writeContent(OutputStream stream, Object object) + throws IOException { + + JsonGenerator generator = this.objectMapper.getFactory().createGenerator(stream, this.encoding); + + writePrefix(generator, object); + Class serializationView = null; + Object value = object; + + if (value instanceof MappingJacksonValue) { + MappingJacksonValue container = (MappingJacksonValue) value; + value = container.getValue(); + serializationView = container.getSerializationView(); + } + if (serializationView != null) { + this.objectMapper.writerWithView(serializationView).writeValue(generator, value); + } + else { + this.objectMapper.writeValue(generator, value); + } + writeSuffix(generator, object); + generator.flush(); + } + + /** + * Write a prefix before the main content. + * @param generator the generator to use for writing content. + * @param object the object to write to the output message. + */ + protected void writePrefix(JsonGenerator generator, Object object) throws IOException { + + } + + /** + * Write a suffix after the main content. + * @param generator the generator to use for writing content. + * @param object the object to write to the output message. + */ + protected void writeSuffix(JsonGenerator generator, Object object) throws IOException { + + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/MappingJackson2JsonView.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/MappingJackson2JsonView.java index 557d26777e..3e51bc6dfc 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/MappingJackson2JsonView.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/MappingJackson2JsonView.java @@ -16,9 +16,7 @@ package org.springframework.web.servlet.view.json; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.OutputStream; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -29,18 +27,14 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import com.fasterxml.jackson.annotation.JsonView; -import com.fasterxml.jackson.core.JsonEncoding; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; import org.springframework.http.converter.json.MappingJacksonValue; -import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; 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 @@ -59,7 +53,7 @@ import org.springframework.web.servlet.view.AbstractView; * @author Sebastien Deleuze * @since 3.1.2 */ -public class MappingJackson2JsonView extends AbstractView { +public class MappingJackson2JsonView extends AbstractJackson2View { /** * Default content type: "application/json". @@ -72,23 +66,12 @@ public class MappingJackson2JsonView extends AbstractView { */ public static final String DEFAULT_JSONP_CONTENT_TYPE = "application/javascript"; - - private ObjectMapper objectMapper = new ObjectMapper(); - - private JsonEncoding encoding = JsonEncoding.UTF8; - private String jsonPrefix; - private Boolean prettyPrint; - private Set modelKeys; private boolean extractValueFromSingleKeyModel = false; - private boolean disableCaching = true; - - private boolean updateContentLength = false; - private Set jsonpParameterNames = new LinkedHashSet(Arrays.asList("jsonp", "callback")); @@ -96,45 +79,7 @@ public class MappingJackson2JsonView extends AbstractView { * Construct a new {@code MappingJackson2JsonView}, setting the content type to {@code application/json}. */ public MappingJackson2JsonView() { - setContentType(DEFAULT_CONTENT_TYPE); - setExposePathVariables(false); - } - - - /** - * Set the {@code ObjectMapper} for this view. - * If not set, a default {@link ObjectMapper#ObjectMapper() ObjectMapper} will be used. - *

Setting a custom-configured {@code ObjectMapper} is one way to take further control of - * the JSON serialization process. The other option 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; - configurePrettyPrint(); - } - - /** - * Return the {@code ObjectMapper} for this view. - */ - public final ObjectMapper getObjectMapper() { - return this.objectMapper; - } - - /** - * Set the {@code JsonEncoding} for this view. - * 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; - } - - /** - * Return the {@code JsonEncoding} for this view. - */ - public final JsonEncoding getEncoding() { - return this.encoding; + super(new ObjectMapper(), DEFAULT_CONTENT_TYPE); } /** @@ -160,29 +105,9 @@ public class MappingJackson2JsonView extends AbstractView { } /** - * Whether to use the default pretty printer when writing JSON. - * This is a shortcut for setting up an {@code ObjectMapper} as follows: - *

-	 * ObjectMapper mapper = new ObjectMapper();
-	 * mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
-	 * 
- *

The default value is {@code false}. - */ - public void setPrettyPrint(boolean prettyPrint) { - this.prettyPrint = prettyPrint; - configurePrettyPrint(); - } - - private void configurePrettyPrint() { - if (this.prettyPrint != null) { - this.objectMapper.configure(SerializationFeature.INDENT_OUTPUT, this.prettyPrint); - } - } - - /** - * Set the attribute in the model that should be rendered by this view. - * When set, all other model attributes will be ignored. + * {@inheritDoc} */ + @Override public void setModelKey(String modelKey) { this.modelKeys = Collections.singleton(modelKey); } @@ -232,24 +157,6 @@ public class MappingJackson2JsonView extends AbstractView { this.extractValueFromSingleKeyModel = extractValueFromSingleKeyModel; } - /** - * Disables caching of the generated JSON. - *

Default is {@code true}, which will prevent the client from caching the generated JSON. - */ - public void setDisableCaching(boolean disableCaching) { - this.disableCaching = disableCaching; - } - - /** - * Whether to update the 'Content-Length' header of the response. When set to - * {@code true}, the response is buffered in order to determine the content - * length and set the 'Content-Length' header of the response. - *

The default setting is {@code false}. - */ - public void setUpdateContentLength(boolean updateContentLength) { - this.updateContentLength = updateContentLength; - } - /** * Set JSONP request parameter names. Each time a request has one of those * parameters, the resulting JSON will be wrapped into a function named as @@ -262,40 +169,6 @@ public class MappingJackson2JsonView extends AbstractView { this.jsonpParameterNames = jsonpParameterNames; } - - @Override - protected void prepareResponse(HttpServletRequest request, HttpServletResponse response) { - setResponseContentType(request, response); - response.setCharacterEncoding(this.encoding.getJavaName()); - if (this.disableCaching) { - response.addHeader("Pragma", "no-cache"); - response.addHeader("Cache-Control", "no-cache, no-store, max-age=0"); - response.addDateHeader("Expires", 1L); - } - } - - @Override - protected void renderMergedOutputModel(Map model, HttpServletRequest request, - HttpServletResponse response) throws Exception { - - OutputStream stream = (this.updateContentLength ? createTemporaryOutputStream() : response.getOutputStream()); - - Class serializationView = (Class) model.get(JsonView.class.getName()); - String jsonpParameterValue = getJsonpParameterValue(request); - Object value = filterModel(model); - if (serializationView != null || jsonpParameterValue != null) { - MappingJacksonValue container = new MappingJacksonValue(value); - container.setSerializationView(serializationView); - container.setJsonpFunction(jsonpParameterValue); - value = container; - } - - writeContent(stream, value, this.jsonPrefix); - if (this.updateContentLength) { - writeToResponse(response, (ByteArrayOutputStream) stream); - } - } - private String getJsonpParameterValue(HttpServletRequest request) { if (this.jsonpParameterNames != null) { for (String name : this.jsonpParameterNames) { @@ -316,6 +189,7 @@ public class MappingJackson2JsonView extends AbstractView { * @param model the model, as passed on to {@link #renderMergedOutputModel} * @return the value to be rendered */ + @Override protected Object filterModel(Map model) { Map result = new HashMap(model.size()); Set modelKeys = (!CollectionUtils.isEmpty(this.modelKeys) ? this.modelKeys : model.keySet()); @@ -328,41 +202,44 @@ public class MappingJackson2JsonView extends AbstractView { return (this.extractValueFromSingleKeyModel && result.size() == 1 ? result.values().iterator().next() : result); } - /** - * Write the actual JSON content to the stream. - * @param stream the output stream to use - * @param value the value to be rendered, as returned from {@link #filterModel} - * @param jsonPrefix the prefix for this view's JSON output - * (as indicated through {@link #setJsonPrefix}/{@link #setPrefixJson}) - * @throws IOException if writing failed - */ - protected void writeContent(OutputStream stream, Object value, String jsonPrefix) - throws IOException { - - JsonGenerator generator = this.objectMapper.getFactory().createGenerator(stream, this.encoding); - if (jsonPrefix != null) { - generator.writeRaw(jsonPrefix); + @Override + protected Object filterAndWrapModel(Map model, HttpServletRequest request) { + Object value = super.filterAndWrapModel(model, request); + String jsonpParameterValue = getJsonpParameterValue(request); + if (jsonpParameterValue != null) { + if(value instanceof MappingJacksonValue) { + ((MappingJacksonValue) value).setJsonpFunction(jsonpParameterValue); + } else { + MappingJacksonValue container = new MappingJacksonValue(value); + container.setJsonpFunction(jsonpParameterValue); + value = container; + } + } + return value; + } + + @Override + protected void writePrefix(JsonGenerator generator, Object object) throws IOException { + if (this.jsonPrefix != null) { + generator.writeRaw(this.jsonPrefix); } - Class serializationView = null; String jsonpFunction = null; - if (value instanceof MappingJacksonValue) { - MappingJacksonValue container = (MappingJacksonValue) value; - value = container.getValue(); - serializationView = container.getSerializationView(); - jsonpFunction = container.getJsonpFunction(); + if (object instanceof MappingJacksonValue) { + jsonpFunction = ((MappingJacksonValue)object).getJsonpFunction(); } if (jsonpFunction != null) { generator.writeRaw(jsonpFunction + "(" ); } - if (serializationView != null) { - this.objectMapper.writerWithView(serializationView).writeValue(generator, value); - } - else { - this.objectMapper.writeValue(generator, value); + } + + @Override + protected void writeSuffix(JsonGenerator generator, Object object) throws IOException { + String jsonpFunction = null; + if (object instanceof MappingJacksonValue) { + jsonpFunction = ((MappingJacksonValue)object).getJsonpFunction(); } if (jsonpFunction != null) { generator.writeRaw(");"); - generator.flush(); } } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/xml/MappingJackson2XmlView.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/xml/MappingJackson2XmlView.java new file mode 100644 index 0000000000..96cb9f71e9 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/xml/MappingJackson2XmlView.java @@ -0,0 +1,90 @@ +/* + * Copyright 2002-2014 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.xml; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonView; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; + +import org.springframework.validation.BindingResult; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.view.json.AbstractJackson2View; + +/** + * Spring MVC {@link View} that renders XML content by serializing the model for the current request + * using Jackson 2's {@link XmlMapper}. + * + *

The Object to be serialized is supplied as a parameter in the model. The first serializable + * entry is used. Users can either specify a specific entry in the model via the + * {@link #setModelKey(String) sourceKey} property. + * + *

Compatible with Jackson 2.1 and higher. + * + * @author Sebastien Deleuze + * @since 4.1 + */ +public class MappingJackson2XmlView extends AbstractJackson2View { + + public static final String DEFAULT_CONTENT_TYPE = "application/xml"; + + + private String modelKey; + + + public MappingJackson2XmlView() { + super(new XmlMapper(), DEFAULT_CONTENT_TYPE); + } + + /** + * {@inheritDoc} + */ + @Override + public void setModelKey(String modelKey) { + this.modelKey = modelKey; + } + + /** + * Filter out undesired attributes from the given model. + * The return value can be either another {@link Map} or a single value object. + * @param model the model, as passed on to {@link #renderMergedOutputModel} + * @return the value to be rendered + */ + @Override + protected Object filterModel(Map model) { + Object value = null; + if (this.modelKey != null) { + value = model.get(this.modelKey); + if (value == null) { + throw new IllegalStateException( + "Model contains no object with key [" + this.modelKey + "]"); + } + } else { + for (Map.Entry entry : model.entrySet()) { + if (!(entry.getValue() instanceof BindingResult) && + !entry.getKey().equals(JsonView.class.getName())) { + if(value != null) { + throw new IllegalStateException("Model contains more than one object to render, only one is supported"); + } + value = entry.getValue(); + } + } + } + return value; + } + +} \ No newline at end of file diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/view/xml/MappingJackson2XmlViewTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/xml/MappingJackson2XmlViewTests.java new file mode 100644 index 0000000000..24d8406b64 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/xml/MappingJackson2XmlViewTests.java @@ -0,0 +1,347 @@ +/* + * Copyright 2002-2014 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.xml; + +import java.io.IOException; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; + +import com.fasterxml.jackson.annotation.JsonView; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.cfg.SerializerFactoryConfig; +import com.fasterxml.jackson.databind.ser.BeanSerializerFactory; +import com.fasterxml.jackson.databind.ser.SerializerFactory; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import static org.junit.Assert.*; +import org.junit.Before; +import org.junit.Test; +import static org.mockito.Mockito.mock; +import org.mozilla.javascript.Context; +import org.mozilla.javascript.ContextFactory; +import org.mozilla.javascript.ScriptableObject; + +import org.springframework.http.MediaType; +import org.springframework.mock.web.test.MockHttpServletRequest; +import org.springframework.mock.web.test.MockHttpServletResponse; +import org.springframework.validation.BindingResult; +import org.springframework.web.servlet.View; + +/** + * @author Sebastien Deleuze + */ +public class MappingJackson2XmlViewTests { + + private MappingJackson2XmlView 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 MappingJackson2XmlView(); + } + + + @Test + public void isExposePathVars() { + assertEquals("Must not expose path variables", false, view.isExposePathVariables()); + } + + @Test + public void renderSimpleMap() throws Exception { + Map model = new HashMap(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", "bar"); + + view.setUpdateContentLength(true); + view.render(model, request, response); + + assertEquals("no-cache", response.getHeader("Pragma")); + assertEquals("no-cache, no-store, max-age=0", response.getHeader("Cache-Control")); + assertNotNull(response.getHeader("Expires")); + + assertEquals(MappingJackson2XmlView.DEFAULT_CONTENT_TYPE, response.getContentType()); + + String jsonResult = response.getContentAsString(); + assertTrue(jsonResult.length() > 0); + assertEquals(jsonResult.length(), response.getContentLength()); + + validateResult(); + } + + @Test + public void renderWithSelectedContentType() throws Exception { + Map model = new HashMap(); + model.put("foo", "bar"); + + view.render(model, request, response); + assertEquals("application/xml", response.getContentType()); + + request.setAttribute(View.SELECTED_CONTENT_TYPE, new MediaType("application", "vnd.example-v2+xml")); + view.render(model, request, response); + + assertEquals("application/vnd.example-v2+xml", response.getContentType()); + } + + @Test + public void renderCaching() throws Exception { + view.setDisableCaching(false); + + Map model = new HashMap(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", "bar"); + + view.render(model, request, response); + + assertNull(response.getHeader("Pragma")); + assertNull(response.getHeader("Cache-Control")); + assertNull(response.getHeader("Expires")); + } + + @Test + public void renderSimpleBean() throws Exception { + Object bean = new TestBeanSimple(); + Map model = new HashMap(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", bean); + + view.setUpdateContentLength(true); + view.render(model, request, response); + + assertTrue(response.getContentAsString().length() > 0); + assertEquals(response.getContentAsString().length(), response.getContentLength()); + + validateResult(); + } + + @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); + assertTrue(response.getContentAsString().contains("custom")); + + validateResult(); + } + + @Test + public void renderWithCustomSerializerLocatedByFactory() throws Exception { + SerializerFactory factory = new DelegatingSerializerFactory(null); + XmlMapper mapper = new XmlMapper(); + mapper.setSerializerFactory(factory); + view.setObjectMapper(mapper); + + Object bean = new TestBeanSimple(); + Map model = new HashMap(); + model.put("foo", bean); + + view.render(model, request, response); + + String result = response.getContentAsString(); + assertTrue(result.length() > 0); + assertTrue(result.contains("custom")); + + validateResult(); + } + + @Test + public void renderOnlySpecifiedModelKey() throws Exception { + + view.setModelKey("bar"); + 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); + assertFalse(result.contains("foo")); + assertTrue(result.contains("bar")); + assertFalse(result.contains("baz")); + + validateResult(); + } + + @Test(expected = IllegalStateException.class) + public void renderModelWithMultipleKeys() throws Exception { + + Map model = new TreeMap(); + model.put("foo", "foo"); + model.put("bar", "bar"); + + view.render(model, request, response); + + fail(); + } + + @Test + public void renderSimpleBeanWithJsonView() throws Exception { + Object bean = new TestBeanSimple(); + Map model = new HashMap(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", bean); + model.put(JsonView.class.getName(), MyJacksonView1.class); + + view.setUpdateContentLength(true); + view.render(model, request, response); + + String content = response.getContentAsString(); + assertTrue(content.length() > 0); + assertEquals(content.length(), response.getContentLength()); + assertTrue(content.contains("foo")); + assertFalse(content.contains("boo")); + assertFalse(content.contains(JsonView.class.getName())); + } + + private void validateResult() throws Exception { + Object xmlResult = + jsContext.evaluateString(jsScope, "(" + response.getContentAsString() + ")", "XML Stream", 1, null); + assertNotNull("XML Result did not eval as valid JavaScript", xmlResult); + assertEquals("application/xml", response.getContentType()); + } + + + public interface MyJacksonView1 { + } + + + public interface MyJacksonView2 { + } + + + @SuppressWarnings("unused") + public static class TestBeanSimple { + + @JsonView(MyJacksonView1.class) + private String property1 = "foo"; + + private boolean test = false; + + @JsonView(MyJacksonView2.class) + private String property2 = "boo"; + + private TestChildBean child = new TestChildBean(); + + public String getProperty1() { + return property1; + } + + public boolean getTest() { + return test; + } + + public String getProperty2() { + return property2; + } + + public Date getNow() { + return new Date(); + } + + public TestChildBean getChild() { + return child; + } + } + + + @JsonSerialize(using=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(Object value, JsonGenerator jgen, SerializerProvider provider) throws IOException { + jgen.writeStartObject(); + jgen.writeFieldName("testBeanSimple"); + jgen.writeString("custom"); + jgen.writeEndObject(); + } + } + + + @SuppressWarnings("serial") + public static class DelegatingSerializerFactory extends BeanSerializerFactory { + + protected DelegatingSerializerFactory(SerializerFactoryConfig config) { + super(config); + } + + @Override + public JsonSerializer createSerializer(SerializerProvider prov, JavaType type) throws JsonMappingException { + if (type.getRawClass() == TestBeanSimple.class) { + return new TestBeanSimpleSerializer(); + } + else { + return super.createSerializer(prov, type); + } + } + } + +} diff --git a/src/asciidoc/index.adoc b/src/asciidoc/index.adoc index 6f87b61d51..4192d79f03 100644 --- a/src/asciidoc/index.adoc +++ b/src/asciidoc/index.adoc @@ -31616,7 +31616,7 @@ response. However, it stops short of writing any error content to the body of th response while your application may need to add developer-friendly content to every error response for example when providing a REST API. You can prepare a `ModelAndView` and render error content through view resolution -- i.e. by configuring a -`ContentNegotiatingViewResolver`, `MappingJacksonJsonView`, and so on. However, you may +`ContentNegotiatingViewResolver`, `MappingJackson2JsonView`, and so on. However, you may prefer to use `@ExceptionHandler` methods instead. If you prefer to write error content via `@ExceptionHandler` methods you can extend @@ -32192,9 +32192,12 @@ This is the complete list of HttpMessageConverters set up by mvc:annotation-driv .. `FormHttpMessageConverter` converts form data to/from a `MultiValueMap`. .. `Jaxb2RootElementHttpMessageConverter` converts Java objects to/from XML -- added if -JAXB2 is present on the classpath. -.. `MappingJackson2HttpMessageConverter` (or `MappingJacksonHttpMessageConverter`) -converts to/from JSON -- added if Jackson 2 (or Jackson) is present on the classpath. +JAXB2 is present and Jackson 2 XML extension is not present on the classpath. +.. `MappingJackson2HttpMessageConverter` converts to/from JSON -- added if Jackson 2 +is present on the classpath. +.. `MappingJackson2XmlHttpMessageConverter` converts to/from XML -- added if +https://github.com/FasterXML/jackson-dataformat-xml[Jackson 2 XML extension] is present +on the classpath. .. `AtomFeedHttpMessageConverter` converts Atom feeds -- added if Rome is present on the classpath. .. `RssChannelHttpMessageConverter` converts RSS feeds -- added if Rome is present on @@ -35255,10 +35258,10 @@ https://spring.io/blog/2009/03/16/adding-an-atom-view-to-an-application-using-sp [[view-xml-marshalling]] === XML Marshalling View -The `MarhsallingView` uses an XML `Marshaller` defined in the `org.springframework.oxm` +The `MarshallingView` uses an XML `Marshaller` defined in the `org.springframework.oxm` package to render the response content as XML. The object to be marshalled can be set explicitly using `MarhsallingView`'s `modelKey` bean property. Alternatively, the view -will iterate over all model properties and marshal only those types that are supported +will iterate over all model properties and marshal the first type that is supported by the `Marshaller`. For more information on the functionality in the `org.springframework.oxm` package refer to the chapter <>. @@ -35268,8 +35271,7 @@ Mappers>>. [[view-json-mapping]] === JSON Mapping View -The `MappingJackson2JsonView` (or `MappingJacksonJsonView` depending on the the Jackson -version you have) uses the Jackson library's `ObjectMapper` to render the response +The `MappingJackson2JsonView` uses the Jackson library's `ObjectMapper` to render the response content as JSON. 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 @@ -35285,6 +35287,23 @@ serializers/deserializers need to be provided for specific types. +[[view-xml-mapping]] +=== XML Mapping View +The `MappingJackson2XmlView` uses the +https://github.com/FasterXML/jackson-dataformat-xml[Jackson XML extension]'s `XmlMapper` +to render the response content as XML. If the model contains multiples entries, the +object to be serialized should be set explicitly using `MappingJackson2XmlView`'s +`modelKey` bean property. If the model contains a single entry, it will be serialized +automatically. + +XML mapping can be customized as needed through the use of JAXB or Jackson's provided +annotations. When further control is needed, a custom `XmlMapper` can be injected +through the `ObjectMapper` property for cases where custom XML +serializers/deserializers need to be provided for specific types. + + + + [[web-integration]] == Integrating with other web frameworks @@ -40459,7 +40478,7 @@ can be injected via constructor or bean properties. By default this converter su [[rest-mapping-json-converter]] -===== MappingJackson2HttpMessageConverter (or MappingJacksonHttpMessageConverter with Jackson 1.x) +===== MappingJackson2HttpMessageConverter An `HttpMessageConverter` implementation that can read and write JSON using Jackson's `ObjectMapper`. JSON mapping can be customized as needed through the use of Jackson's provided annotations. When further control is needed, a custom `ObjectMapper` can be @@ -40468,6 +40487,17 @@ serializers/deserializers need to be provided for specific types. By default thi converter supports ( `application/json`). +[[rest-mapping-xml-converter]] +===== MappingJackson2XmlHttpMessageConverter +An `HttpMessageConverter` implementation that can read and write XML using +https://github.com/FasterXML/jackson-dataformat-xml[Jackson XML] extension's +`XmlMapper`. XML mapping can be customized as needed through the use of JAXB +or Jackson's provided annotations. When further control is needed, a custom `XmlMapper` +can be injected through the `ObjectMapper` property for cases where custom XML +serializers/deserializers need to be provided for specific types. By default this +converter supports ( `application/xml`). + + [[rest-source-converter]] ===== SourceHttpMessageConverter An `HttpMessageConverter` implementation that can read and write