1   /*
2    * Copyright 2002-2014 the original author or authors.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *      http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  
17  package org.springframework.jms.support.converter;
18  
19  import java.io.ByteArrayOutputStream;
20  import java.io.IOException;
21  import java.io.OutputStreamWriter;
22  import java.io.StringWriter;
23  import java.io.UnsupportedEncodingException;
24  import java.util.HashMap;
25  import java.util.Map;
26  import javax.jms.BytesMessage;
27  import javax.jms.JMSException;
28  import javax.jms.Message;
29  import javax.jms.Session;
30  import javax.jms.TextMessage;
31  
32  import com.fasterxml.jackson.databind.DeserializationFeature;
33  import com.fasterxml.jackson.databind.JavaType;
34  import com.fasterxml.jackson.databind.MapperFeature;
35  import com.fasterxml.jackson.databind.ObjectMapper;
36  
37  import org.springframework.beans.factory.BeanClassLoaderAware;
38  import org.springframework.util.Assert;
39  import org.springframework.util.ClassUtils;
40  
41  /**
42   * Message converter that uses Jackson 2.x to convert messages to and from JSON.
43   * Maps an object to a {@link BytesMessage}, or to a {@link TextMessage} if the
44   * {@link #setTargetType targetType} is set to {@link MessageType#TEXT}.
45   * Converts from a {@link TextMessage} or {@link BytesMessage} to an object.
46   *
47   * <p>It customizes Jackson's default properties with the following ones:
48   * <ul>
49   * <li>{@link MapperFeature#DEFAULT_VIEW_INCLUSION} is disabled</li>
50   * <li>{@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} is disabled</li>
51   * </ul>
52   *
53   * <p>Tested against Jackson 2.2; compatible with Jackson 2.0 and higher.
54   *
55   * @author Mark Pollack
56   * @author Dave Syer
57   * @author Juergen Hoeller
58   * @since 3.1.4
59   */
60  public class MappingJackson2MessageConverter implements MessageConverter, BeanClassLoaderAware {
61  
62  	/**
63  	 * The default encoding used for writing to text messages: UTF-8.
64  	 */
65  	public static final String DEFAULT_ENCODING = "UTF-8";
66  
67  
68  	private ObjectMapper objectMapper;
69  
70  	private MessageType targetType = MessageType.BYTES;
71  
72  	private String encoding = DEFAULT_ENCODING;
73  
74  	private String encodingPropertyName;
75  
76  	private String typeIdPropertyName;
77  
78  	private Map<String, Class<?>> idClassMappings = new HashMap<String, Class<?>>();
79  
80  	private Map<Class<?>, String> classIdMappings = new HashMap<Class<?>, String>();
81  
82  	private ClassLoader beanClassLoader;
83  
84  
85  	public MappingJackson2MessageConverter() {
86  		this.objectMapper = new ObjectMapper();
87  		this.objectMapper.configure(MapperFeature.DEFAULT_VIEW_INCLUSION, false);
88  		this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
89  	}
90  
91  	/**
92  	 * Specify the {@link ObjectMapper} to use instead of using the default.
93  	 */
94  	public void setObjectMapper(ObjectMapper objectMapper) {
95  		Assert.notNull(objectMapper, "ObjectMapper must not be null");
96  		this.objectMapper = objectMapper;
97  	}
98  
99  	/**
100 	 * Specify whether {@link #toMessage(Object, Session)} should marshal to a
101 	 * {@link BytesMessage} or a {@link TextMessage}.
102 	 * <p>The default is {@link MessageType#BYTES}, i.e. this converter marshals to
103 	 * a {@link BytesMessage}. Note that the default version of this converter
104 	 * supports {@link MessageType#BYTES} and {@link MessageType#TEXT} only.
105 	 * @see MessageType#BYTES
106 	 * @see MessageType#TEXT
107 	 */
108 	public void setTargetType(MessageType targetType) {
109 		Assert.notNull(targetType, "MessageType must not be null");
110 		this.targetType = targetType;
111 	}
112 
113 	/**
114 	 * Specify the encoding to use when converting to and from text-based
115 	 * message body content. The default encoding will be "UTF-8".
116 	 * <p>When reading from a a text-based message, an encoding may have been
117 	 * suggested through a special JMS property which will then be preferred
118 	 * over the encoding set on this MessageConverter instance.
119 	 * @see #setEncodingPropertyName
120 	 */
121 	public void setEncoding(String encoding) {
122 		this.encoding = encoding;
123 	}
124 
125 	/**
126 	 * Specify the name of the JMS message property that carries the encoding from
127 	 * bytes to String and back is BytesMessage is used during the conversion process.
128 	 * <p>Default is none. Setting this property is optional; if not set, UTF-8 will
129 	 * be used for decoding any incoming bytes message.
130 	 * @see #setEncoding
131 	 */
132 	public void setEncodingPropertyName(String encodingPropertyName) {
133 		this.encodingPropertyName = encodingPropertyName;
134 	}
135 
136 	/**
137 	 * Specify the name of the JMS message property that carries the type id for the
138 	 * contained object: either a mapped id value or a raw Java class name.
139 	 * <p>Default is none. <b>NOTE: This property needs to be set in order to allow
140 	 * for converting from an incoming message to a Java object.</b>
141 	 * @see #setTypeIdMappings
142 	 */
143 	public void setTypeIdPropertyName(String typeIdPropertyName) {
144 		this.typeIdPropertyName = typeIdPropertyName;
145 	}
146 
147 	/**
148 	 * Specify mappings from type ids to Java classes, if desired.
149 	 * This allows for synthetic ids in the type id message property,
150 	 * instead of transferring Java class names.
151 	 * <p>Default is no custom mappings, i.e. transferring raw Java class names.
152 	 * @param typeIdMappings a Map with type id values as keys and Java classes as values
153 	 */
154 	public void setTypeIdMappings(Map<String, Class<?>> typeIdMappings) {
155 		this.idClassMappings = new HashMap<String, Class<?>>();
156 		for (Map.Entry<String, Class<?>> entry : typeIdMappings.entrySet()) {
157 			String id = entry.getKey();
158 			Class<?> clazz = entry.getValue();
159 			this.idClassMappings.put(id, clazz);
160 			this.classIdMappings.put(clazz, id);
161 		}
162 	}
163 
164 	@Override
165 	public void setBeanClassLoader(ClassLoader classLoader) {
166 		this.beanClassLoader = classLoader;
167 	}
168 
169 
170 	@Override
171 	public Message toMessage(Object object, Session session) throws JMSException, MessageConversionException {
172 		Message message;
173 		try {
174 			switch (this.targetType) {
175 				case TEXT:
176 					message = mapToTextMessage(object, session, this.objectMapper);
177 					break;
178 				case BYTES:
179 					message = mapToBytesMessage(object, session, this.objectMapper);
180 					break;
181 				default:
182 					message = mapToMessage(object, session, this.objectMapper, this.targetType);
183 			}
184 		}
185 		catch (IOException ex) {
186 			throw new MessageConversionException("Could not map JSON object [" + object + "]", ex);
187 		}
188 		setTypeIdOnMessage(object, message);
189 		return message;
190 	}
191 
192 	@Override
193 	public Object fromMessage(Message message) throws JMSException, MessageConversionException {
194 		try {
195 			JavaType targetJavaType = getJavaTypeForMessage(message);
196 			return convertToObject(message, targetJavaType);
197 		}
198 		catch (IOException ex) {
199 			throw new MessageConversionException("Failed to convert JSON message content", ex);
200 		}
201 	}
202 
203 
204 	/**
205 	 * Map the given object to a {@link TextMessage}.
206 	 * @param object the object to be mapped
207 	 * @param session current JMS session
208 	 * @param objectMapper the mapper to use
209 	 * @return the resulting message
210 	 * @throws JMSException if thrown by JMS methods
211 	 * @throws IOException in case of I/O errors
212 	 * @see Session#createBytesMessage
213 	 */
214 	protected TextMessage mapToTextMessage(Object object, Session session, ObjectMapper objectMapper)
215 			throws JMSException, IOException {
216 
217 		StringWriter writer = new StringWriter();
218 		objectMapper.writeValue(writer, object);
219 		return session.createTextMessage(writer.toString());
220 	}
221 
222 	/**
223 	 * Map the given object to a {@link BytesMessage}.
224 	 * @param object the object to be mapped
225 	 * @param session current JMS session
226 	 * @param objectMapper the mapper to use
227 	 * @return the resulting message
228 	 * @throws JMSException if thrown by JMS methods
229 	 * @throws IOException in case of I/O errors
230 	 * @see Session#createBytesMessage
231 	 */
232 	protected BytesMessage mapToBytesMessage(Object object, Session session, ObjectMapper objectMapper)
233 			throws JMSException, IOException {
234 
235 		ByteArrayOutputStream bos = new ByteArrayOutputStream(1024);
236 		OutputStreamWriter writer = new OutputStreamWriter(bos, this.encoding);
237 		objectMapper.writeValue(writer, object);
238 
239 		BytesMessage message = session.createBytesMessage();
240 		message.writeBytes(bos.toByteArray());
241 		if (this.encodingPropertyName != null) {
242 			message.setStringProperty(this.encodingPropertyName, this.encoding);
243 		}
244 		return message;
245 	}
246 
247 	/**
248 	 * Template method that allows for custom message mapping.
249 	 * Invoked when {@link #setTargetType} is not {@link MessageType#TEXT} or
250 	 * {@link MessageType#BYTES}.
251 	 * <p>The default implementation throws an {@link IllegalArgumentException}.
252 	 * @param object the object to marshal
253 	 * @param session the JMS Session
254 	 * @param objectMapper the mapper to use
255 	 * @param targetType the target message type (other than TEXT or BYTES)
256 	 * @return the resulting message
257 	 * @throws JMSException if thrown by JMS methods
258 	 * @throws IOException in case of I/O errors
259 	 */
260 	protected Message mapToMessage(Object object, Session session, ObjectMapper objectMapper, MessageType targetType)
261 			throws JMSException, IOException {
262 
263 		throw new IllegalArgumentException("Unsupported message type [" + targetType +
264 				"]. MappingJackson2MessageConverter by default only supports TextMessages and BytesMessages.");
265 	}
266 
267 	/**
268 	 * Set a type id for the given payload object on the given JMS Message.
269 	 * <p>The default implementation consults the configured type id mapping and
270 	 * sets the resulting value (either a mapped id or the raw Java class name)
271 	 * into the configured type id message property.
272 	 * @param object the payload object to set a type id for
273 	 * @param message the JMS Message to set the type id on
274 	 * @throws JMSException if thrown by JMS methods
275 	 * @see #getJavaTypeForMessage(javax.jms.Message)
276 	 * @see #setTypeIdPropertyName(String)
277 	 * @see #setTypeIdMappings(java.util.Map)
278 	 */
279 	protected void setTypeIdOnMessage(Object object, Message message) throws JMSException {
280 		if (this.typeIdPropertyName != null) {
281 			String typeId = this.classIdMappings.get(object.getClass());
282 			if (typeId == null) {
283 				typeId = object.getClass().getName();
284 			}
285 			message.setStringProperty(this.typeIdPropertyName, typeId);
286 		}
287 	}
288 
289 
290 	/**
291 	 * Convenience method to dispatch to converters for individual message types.
292 	 */
293 	private Object convertToObject(Message message, JavaType targetJavaType) throws JMSException, IOException {
294 		if (message instanceof TextMessage) {
295 			return convertFromTextMessage((TextMessage) message, targetJavaType);
296 		}
297 		else if (message instanceof BytesMessage) {
298 			return convertFromBytesMessage((BytesMessage) message, targetJavaType);
299 		}
300 		else {
301 			return convertFromMessage(message, targetJavaType);
302 		}
303 	}
304 
305 	/**
306 	 * Convert a TextMessage to a Java Object with the specified type.
307 	 * @param message the input message
308 	 * @param targetJavaType the target type
309 	 * @return the message converted to an object
310 	 * @throws JMSException if thrown by JMS
311 	 * @throws IOException in case of I/O errors
312 	 */
313 	protected Object convertFromTextMessage(TextMessage message, JavaType targetJavaType)
314 			throws JMSException, IOException {
315 
316 		String body = message.getText();
317 		return this.objectMapper.readValue(body, targetJavaType);
318 	}
319 
320 	/**
321 	 * Convert a BytesMessage to a Java Object with the specified type.
322 	 * @param message the input message
323 	 * @param targetJavaType the target type
324 	 * @return the message converted to an object
325 	 * @throws JMSException if thrown by JMS
326 	 * @throws IOException in case of I/O errors
327 	 */
328 	protected Object convertFromBytesMessage(BytesMessage message, JavaType targetJavaType)
329 			throws JMSException, IOException {
330 
331 		String encoding = this.encoding;
332 		if (this.encodingPropertyName != null && message.propertyExists(this.encodingPropertyName)) {
333 			encoding = message.getStringProperty(this.encodingPropertyName);
334 		}
335 		byte[] bytes = new byte[(int) message.getBodyLength()];
336 		message.readBytes(bytes);
337 		try {
338 			String body = new String(bytes, encoding);
339 			return this.objectMapper.readValue(body, targetJavaType);
340 		}
341 		catch (UnsupportedEncodingException ex) {
342 			throw new MessageConversionException("Cannot convert bytes to String", ex);
343 		}
344 	}
345 
346 	/**
347 	 * Template method that allows for custom message mapping.
348 	 * Invoked when {@link #setTargetType} is not {@link MessageType#TEXT} or
349 	 * {@link MessageType#BYTES}.
350 	 * <p>The default implementation throws an {@link IllegalArgumentException}.
351 	 * @param message the input message
352 	 * @param targetJavaType the target type
353 	 * @return the message converted to an object
354 	 * @throws JMSException if thrown by JMS
355 	 * @throws IOException in case of I/O errors
356 	 */
357 	protected Object convertFromMessage(Message message, JavaType targetJavaType)
358 			throws JMSException, IOException {
359 
360 		throw new IllegalArgumentException("Unsupported message type [" + message.getClass() +
361 				"]. MappingJacksonMessageConverter by default only supports TextMessages and BytesMessages.");
362 	}
363 
364 	/**
365 	 * Determine a Jackson JavaType for the given JMS Message,
366 	 * typically parsing a type id message property.
367 	 * <p>The default implementation parses the configured type id property name
368 	 * and consults the configured type id mapping. This can be overridden with
369 	 * a different strategy, e.g. doing some heuristics based on message origin.
370 	 * @param message the JMS Message to set the type id on
371 	 * @throws JMSException if thrown by JMS methods
372 	 * @see #setTypeIdOnMessage(Object, javax.jms.Message)
373 	 * @see #setTypeIdPropertyName(String)
374 	 * @see #setTypeIdMappings(java.util.Map)
375 	 */
376 	protected JavaType getJavaTypeForMessage(Message message) throws JMSException {
377 		String typeId = message.getStringProperty(this.typeIdPropertyName);
378 		if (typeId == null) {
379 			throw new MessageConversionException("Could not find type id property [" + this.typeIdPropertyName + "]");
380 		}
381 		Class<?> mappedClass = this.idClassMappings.get(typeId);
382 		if (mappedClass != null) {
383 			return this.objectMapper.getTypeFactory().constructType(mappedClass);
384 		}
385 		try {
386 			Class<?> typeClass = ClassUtils.forName(typeId, this.beanClassLoader);
387 			return this.objectMapper.getTypeFactory().constructType(typeClass);
388 		}
389 		catch (Throwable ex) {
390 			throw new MessageConversionException("Failed to resolve type id [" + typeId + "]", ex);
391 		}
392 	}
393 
394 }