001/*
002 * Copyright 2002-2020 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.jms.support.converter;
018
019import java.io.ByteArrayOutputStream;
020import java.io.IOException;
021import java.io.OutputStreamWriter;
022import java.io.StringWriter;
023import java.io.UnsupportedEncodingException;
024import java.util.HashMap;
025import java.util.Map;
026import javax.jms.BytesMessage;
027import javax.jms.JMSException;
028import javax.jms.Message;
029import javax.jms.Session;
030import javax.jms.TextMessage;
031
032import com.fasterxml.jackson.annotation.JsonView;
033import com.fasterxml.jackson.databind.DeserializationFeature;
034import com.fasterxml.jackson.databind.JavaType;
035import com.fasterxml.jackson.databind.MapperFeature;
036import com.fasterxml.jackson.databind.ObjectMapper;
037import com.fasterxml.jackson.databind.ObjectWriter;
038
039import org.springframework.beans.factory.BeanClassLoaderAware;
040import org.springframework.core.MethodParameter;
041import org.springframework.util.Assert;
042import org.springframework.util.ClassUtils;
043
044/**
045 * Message converter that uses Jackson 2.x to convert messages to and from JSON.
046 * Maps an object to a {@link BytesMessage}, or to a {@link TextMessage} if the
047 * {@link #setTargetType targetType} is set to {@link MessageType#TEXT}.
048 * Converts from a {@link TextMessage} or {@link BytesMessage} to an object.
049 *
050 * <p>It customizes Jackson's default properties with the following ones:
051 * <ul>
052 * <li>{@link MapperFeature#DEFAULT_VIEW_INCLUSION} is disabled</li>
053 * <li>{@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} is disabled</li>
054 * </ul>
055 *
056 * <p>Compatible with Jackson 2.6 and higher, as of Spring 4.3.
057 *
058 * @author Mark Pollack
059 * @author Dave Syer
060 * @author Juergen Hoeller
061 * @author Stephane Nicoll
062 * @since 3.1.4
063 */
064public class MappingJackson2MessageConverter implements SmartMessageConverter, BeanClassLoaderAware {
065
066        /**
067         * The default encoding used for writing to text messages: UTF-8.
068         */
069        public static final String DEFAULT_ENCODING = "UTF-8";
070
071
072        private ObjectMapper objectMapper;
073
074        private MessageType targetType = MessageType.BYTES;
075
076        private String encoding = DEFAULT_ENCODING;
077
078        private String encodingPropertyName;
079
080        private String typeIdPropertyName;
081
082        private Map<String, Class<?>> idClassMappings = new HashMap<String, Class<?>>();
083
084        private Map<Class<?>, String> classIdMappings = new HashMap<Class<?>, String>();
085
086        private ClassLoader beanClassLoader;
087
088
089        public MappingJackson2MessageConverter() {
090                this.objectMapper = new ObjectMapper();
091                this.objectMapper.configure(MapperFeature.DEFAULT_VIEW_INCLUSION, false);
092                this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
093        }
094
095        /**
096         * Specify the {@link ObjectMapper} to use instead of using the default.
097         */
098        public void setObjectMapper(ObjectMapper objectMapper) {
099                Assert.notNull(objectMapper, "ObjectMapper must not be null");
100                this.objectMapper = objectMapper;
101        }
102
103        /**
104         * Specify whether {@link #toMessage(Object, Session)} should marshal to a
105         * {@link BytesMessage} or a {@link TextMessage}.
106         * <p>The default is {@link MessageType#BYTES}, i.e. this converter marshals to
107         * a {@link BytesMessage}. Note that the default version of this converter
108         * supports {@link MessageType#BYTES} and {@link MessageType#TEXT} only.
109         * @see MessageType#BYTES
110         * @see MessageType#TEXT
111         */
112        public void setTargetType(MessageType targetType) {
113                Assert.notNull(targetType, "MessageType must not be null");
114                this.targetType = targetType;
115        }
116
117        /**
118         * Specify the encoding to use when converting to and from text-based
119         * message body content. The default encoding will be "UTF-8".
120         * <p>When reading from a text-based message, an encoding may have been
121         * suggested through a special JMS property which will then be preferred
122         * over the encoding set on this MessageConverter instance.
123         * @see #setEncodingPropertyName
124         */
125        public void setEncoding(String encoding) {
126                this.encoding = encoding;
127        }
128
129        /**
130         * Specify the name of the JMS message property that carries the encoding from
131         * bytes to String and back is BytesMessage is used during the conversion process.
132         * <p>Default is none. Setting this property is optional; if not set, UTF-8 will
133         * be used for decoding any incoming bytes message.
134         * @see #setEncoding
135         */
136        public void setEncodingPropertyName(String encodingPropertyName) {
137                this.encodingPropertyName = encodingPropertyName;
138        }
139
140        /**
141         * Specify the name of the JMS message property that carries the type id for the
142         * contained object: either a mapped id value or a raw Java class name.
143         * <p>Default is none. <b>NOTE: This property needs to be set in order to allow
144         * for converting from an incoming message to a Java object.</b>
145         * @see #setTypeIdMappings
146         */
147        public void setTypeIdPropertyName(String typeIdPropertyName) {
148                this.typeIdPropertyName = typeIdPropertyName;
149        }
150
151        /**
152         * Specify mappings from type ids to Java classes, if desired.
153         * This allows for synthetic ids in the type id message property,
154         * instead of transferring Java class names.
155         * <p>Default is no custom mappings, i.e. transferring raw Java class names.
156         * @param typeIdMappings a Map with type id values as keys and Java classes as values
157         */
158        public void setTypeIdMappings(Map<String, Class<?>> typeIdMappings) {
159                this.idClassMappings = new HashMap<String, Class<?>>();
160                for (Map.Entry<String, Class<?>> entry : typeIdMappings.entrySet()) {
161                        String id = entry.getKey();
162                        Class<?> clazz = entry.getValue();
163                        this.idClassMappings.put(id, clazz);
164                        this.classIdMappings.put(clazz, id);
165                }
166        }
167
168        @Override
169        public void setBeanClassLoader(ClassLoader classLoader) {
170                this.beanClassLoader = classLoader;
171        }
172
173
174        @Override
175        public Message toMessage(Object object, Session session) throws JMSException, MessageConversionException {
176                Message message;
177                try {
178                        switch (this.targetType) {
179                                case TEXT:
180                                        message = mapToTextMessage(object, session, this.objectMapper);
181                                        break;
182                                case BYTES:
183                                        message = mapToBytesMessage(object, session, this.objectMapper);
184                                        break;
185                                default:
186                                        message = mapToMessage(object, session, this.objectMapper, this.targetType);
187                        }
188                }
189                catch (IOException ex) {
190                        throw new MessageConversionException("Could not map JSON object [" + object + "]", ex);
191                }
192                setTypeIdOnMessage(object, message);
193                return message;
194        }
195
196        @Override
197        public Message toMessage(Object object, Session session, Object conversionHint)
198                        throws JMSException, MessageConversionException {
199
200                return toMessage(object, session, getSerializationView(conversionHint));
201        }
202
203        /**
204         * Convert a Java object to a JMS Message using the specified json view
205         * and the supplied session  to create the message object.
206         * @param object the object to convert
207         * @param session the Session to use for creating a JMS Message
208         * @param jsonView the view to use to filter the content
209         * @return the JMS Message
210         * @throws javax.jms.JMSException if thrown by JMS API methods
211         * @throws MessageConversionException in case of conversion failure
212         * @since 4.3
213         */
214        public Message toMessage(Object object, Session session, Class<?> jsonView)
215                        throws JMSException, MessageConversionException {
216
217                if (jsonView != null) {
218                        return toMessage(object, session, this.objectMapper.writerWithView(jsonView));
219                }
220                else {
221                        return toMessage(object, session, this.objectMapper.writer());
222                }
223        }
224
225        @Override
226        public Object fromMessage(Message message) throws JMSException, MessageConversionException {
227                try {
228                        JavaType targetJavaType = getJavaTypeForMessage(message);
229                        return convertToObject(message, targetJavaType);
230                }
231                catch (IOException ex) {
232                        throw new MessageConversionException("Failed to convert JSON message content", ex);
233                }
234        }
235
236        protected Message toMessage(Object object, Session session, ObjectWriter objectWriter)
237                        throws JMSException, MessageConversionException {
238
239                Message message;
240                try {
241                        switch (this.targetType) {
242                                case TEXT:
243                                        message = mapToTextMessage(object, session, objectWriter);
244                                        break;
245                                case BYTES:
246                                        message = mapToBytesMessage(object, session, objectWriter);
247                                        break;
248                                default:
249                                        message = mapToMessage(object, session, objectWriter, this.targetType);
250                        }
251                }
252                catch (IOException ex) {
253                        throw new MessageConversionException("Could not map JSON object [" + object + "]", ex);
254                }
255                setTypeIdOnMessage(object, message);
256                return message;
257        }
258
259
260        /**
261         * Map the given object to a {@link TextMessage}.
262         * @param object the object to be mapped
263         * @param session current JMS session
264         * @param objectMapper the mapper to use
265         * @return the resulting message
266         * @throws JMSException if thrown by JMS methods
267         * @throws IOException in case of I/O errors
268         * @see Session#createBytesMessage
269         * @deprecated as of 4.3, use {@link #mapToTextMessage(Object, Session, ObjectWriter)}
270         */
271        @Deprecated
272        protected TextMessage mapToTextMessage(Object object, Session session, ObjectMapper objectMapper)
273                        throws JMSException, IOException {
274
275                return mapToTextMessage(object, session, objectMapper.writer());
276        }
277
278        /**
279         * Map the given object to a {@link TextMessage}.
280         * @param object the object to be mapped
281         * @param session current JMS session
282         * @param objectWriter the writer to use
283         * @return the resulting message
284         * @throws JMSException if thrown by JMS methods
285         * @throws IOException in case of I/O errors
286         * @since 4.3
287         * @see Session#createBytesMessage
288         */
289        protected TextMessage mapToTextMessage(Object object, Session session, ObjectWriter objectWriter)
290                        throws JMSException, IOException {
291
292                StringWriter writer = new StringWriter(1024);
293                objectWriter.writeValue(writer, object);
294                return session.createTextMessage(writer.toString());
295        }
296
297        /**
298         * Map the given object to a {@link BytesMessage}.
299         * @param object the object to be mapped
300         * @param session current JMS session
301         * @param objectMapper the mapper to use
302         * @return the resulting message
303         * @throws JMSException if thrown by JMS methods
304         * @throws IOException in case of I/O errors
305         * @see Session#createBytesMessage
306         * @deprecated as of 4.3, use {@link #mapToBytesMessage(Object, Session, ObjectWriter)}
307         */
308        @Deprecated
309        protected BytesMessage mapToBytesMessage(Object object, Session session, ObjectMapper objectMapper)
310                        throws JMSException, IOException {
311
312                return mapToBytesMessage(object, session, objectMapper.writer());
313        }
314
315
316        /**
317         * Map the given object to a {@link BytesMessage}.
318         * @param object the object to be mapped
319         * @param session current JMS session
320         * @param objectWriter the writer to use
321         * @return the resulting message
322         * @throws JMSException if thrown by JMS methods
323         * @throws IOException in case of I/O errors
324         * @since 4.3
325         * @see Session#createBytesMessage
326         */
327        protected BytesMessage mapToBytesMessage(Object object, Session session, ObjectWriter objectWriter)
328                        throws JMSException, IOException {
329
330                ByteArrayOutputStream bos = new ByteArrayOutputStream(1024);
331                OutputStreamWriter writer = new OutputStreamWriter(bos, this.encoding);
332                objectWriter.writeValue(writer, object);
333
334                BytesMessage message = session.createBytesMessage();
335                message.writeBytes(bos.toByteArray());
336                if (this.encodingPropertyName != null) {
337                        message.setStringProperty(this.encodingPropertyName, this.encoding);
338                }
339                return message;
340        }
341
342        /**
343         * Template method that allows for custom message mapping.
344         * Invoked when {@link #setTargetType} is not {@link MessageType#TEXT} or
345         * {@link MessageType#BYTES}.
346         * <p>The default implementation throws an {@link IllegalArgumentException}.
347         * @param object the object to marshal
348         * @param session the JMS Session
349         * @param objectMapper the mapper to use
350         * @param targetType the target message type (other than TEXT or BYTES)
351         * @return the resulting message
352         * @throws JMSException if thrown by JMS methods
353         * @throws IOException in case of I/O errors
354         * @deprecated as of 4.3, use {@link #mapToMessage(Object, Session, ObjectWriter, MessageType)}
355         */
356        @Deprecated
357        protected Message mapToMessage(Object object, Session session, ObjectMapper objectMapper, MessageType targetType)
358                        throws JMSException, IOException {
359
360                return mapToMessage(object, session, objectMapper.writer(), targetType);
361        }
362
363        /**
364         * Template method that allows for custom message mapping.
365         * Invoked when {@link #setTargetType} is not {@link MessageType#TEXT} or
366         * {@link MessageType#BYTES}.
367         * <p>The default implementation throws an {@link IllegalArgumentException}.
368         * @param object the object to marshal
369         * @param session the JMS Session
370         * @param objectWriter the writer to use
371         * @param targetType the target message type (other than TEXT or BYTES)
372         * @return the resulting message
373         * @throws JMSException if thrown by JMS methods
374         * @throws IOException in case of I/O errors
375         */
376        protected Message mapToMessage(Object object, Session session, ObjectWriter objectWriter, MessageType targetType)
377                        throws JMSException, IOException {
378
379                throw new IllegalArgumentException("Unsupported message type [" + targetType +
380                                "]. MappingJackson2MessageConverter by default only supports TextMessages and BytesMessages.");
381        }
382
383        /**
384         * Set a type id for the given payload object on the given JMS Message.
385         * <p>The default implementation consults the configured type id mapping and
386         * sets the resulting value (either a mapped id or the raw Java class name)
387         * into the configured type id message property.
388         * @param object the payload object to set a type id for
389         * @param message the JMS Message on which to set the type id property
390         * @throws JMSException if thrown by JMS methods
391         * @see #getJavaTypeForMessage(javax.jms.Message)
392         * @see #setTypeIdPropertyName(String)
393         * @see #setTypeIdMappings(java.util.Map)
394         */
395        protected void setTypeIdOnMessage(Object object, Message message) throws JMSException {
396                if (this.typeIdPropertyName != null) {
397                        String typeId = this.classIdMappings.get(object.getClass());
398                        if (typeId == null) {
399                                typeId = object.getClass().getName();
400                        }
401                        message.setStringProperty(this.typeIdPropertyName, typeId);
402                }
403        }
404
405        /**
406         * Convenience method to dispatch to converters for individual message types.
407         */
408        private Object convertToObject(Message message, JavaType targetJavaType) throws JMSException, IOException {
409                if (message instanceof TextMessage) {
410                        return convertFromTextMessage((TextMessage) message, targetJavaType);
411                }
412                else if (message instanceof BytesMessage) {
413                        return convertFromBytesMessage((BytesMessage) message, targetJavaType);
414                }
415                else {
416                        return convertFromMessage(message, targetJavaType);
417                }
418        }
419
420        /**
421         * Convert a TextMessage to a Java Object with the specified type.
422         * @param message the input message
423         * @param targetJavaType the target type
424         * @return the message converted to an object
425         * @throws JMSException if thrown by JMS
426         * @throws IOException in case of I/O errors
427         */
428        protected Object convertFromTextMessage(TextMessage message, JavaType targetJavaType)
429                        throws JMSException, IOException {
430
431                String body = message.getText();
432                return this.objectMapper.readValue(body, targetJavaType);
433        }
434
435        /**
436         * Convert a BytesMessage to a Java Object with the specified type.
437         * @param message the input message
438         * @param targetJavaType the target type
439         * @return the message converted to an object
440         * @throws JMSException if thrown by JMS
441         * @throws IOException in case of I/O errors
442         */
443        protected Object convertFromBytesMessage(BytesMessage message, JavaType targetJavaType)
444                        throws JMSException, IOException {
445
446                String encoding = this.encoding;
447                if (this.encodingPropertyName != null && message.propertyExists(this.encodingPropertyName)) {
448                        encoding = message.getStringProperty(this.encodingPropertyName);
449                }
450                byte[] bytes = new byte[(int) message.getBodyLength()];
451                message.readBytes(bytes);
452                try {
453                        String body = new String(bytes, encoding);
454                        return this.objectMapper.readValue(body, targetJavaType);
455                }
456                catch (UnsupportedEncodingException ex) {
457                        throw new MessageConversionException("Cannot convert bytes to String", ex);
458                }
459        }
460
461        /**
462         * Template method that allows for custom message mapping.
463         * Invoked when {@link #setTargetType} is not {@link MessageType#TEXT} or
464         * {@link MessageType#BYTES}.
465         * <p>The default implementation throws an {@link IllegalArgumentException}.
466         * @param message the input message
467         * @param targetJavaType the target type
468         * @return the message converted to an object
469         * @throws JMSException if thrown by JMS
470         * @throws IOException in case of I/O errors
471         */
472        protected Object convertFromMessage(Message message, JavaType targetJavaType)
473                        throws JMSException, IOException {
474
475                throw new IllegalArgumentException("Unsupported message type [" + message.getClass() +
476                                "]. MappingJacksonMessageConverter by default only supports TextMessages and BytesMessages.");
477        }
478
479        /**
480         * Determine a Jackson JavaType for the given JMS Message,
481         * typically parsing a type id message property.
482         * <p>The default implementation parses the configured type id property name
483         * and consults the configured type id mapping. This can be overridden with
484         * a different strategy, e.g. doing some heuristics based on message origin.
485         * @param message the JMS Message from which to get the type id property
486         * @throws JMSException if thrown by JMS methods
487         * @see #setTypeIdOnMessage(Object, javax.jms.Message)
488         * @see #setTypeIdPropertyName(String)
489         * @see #setTypeIdMappings(java.util.Map)
490         */
491        protected JavaType getJavaTypeForMessage(Message message) throws JMSException {
492                String typeId = message.getStringProperty(this.typeIdPropertyName);
493                if (typeId == null) {
494                        throw new MessageConversionException(
495                                        "Could not find type id property [" + this.typeIdPropertyName + "] on message [" +
496                                        message.getJMSMessageID() + "] from destination [" + message.getJMSDestination() + "]");
497                }
498                Class<?> mappedClass = this.idClassMappings.get(typeId);
499                if (mappedClass != null) {
500                        return this.objectMapper.getTypeFactory().constructType(mappedClass);
501                }
502                try {
503                        Class<?> typeClass = ClassUtils.forName(typeId, this.beanClassLoader);
504                        return this.objectMapper.getTypeFactory().constructType(typeClass);
505                }
506                catch (Throwable ex) {
507                        throw new MessageConversionException("Failed to resolve type id [" + typeId + "]", ex);
508                }
509        }
510
511        /**
512         * Determine a Jackson serialization view based on the given conversion hint.
513         * @param conversionHint the conversion hint Object as passed into the
514         * converter for the current conversion attempt
515         * @return the serialization view class, or {@code null} if none
516         */
517        protected Class<?> getSerializationView(Object conversionHint) {
518                if (conversionHint instanceof MethodParameter) {
519                        MethodParameter methodParam = (MethodParameter) conversionHint;
520                        JsonView annotation = methodParam.getParameterAnnotation(JsonView.class);
521                        if (annotation == null) {
522                                annotation = methodParam.getMethodAnnotation(JsonView.class);
523                                if (annotation == null) {
524                                        return null;
525                                }
526                        }
527                        return extractViewClass(annotation, conversionHint);
528                }
529                else if (conversionHint instanceof JsonView) {
530                        return extractViewClass((JsonView) conversionHint, conversionHint);
531                }
532                else if (conversionHint instanceof Class) {
533                        return (Class<?>) conversionHint;
534                }
535                else {
536                        return null;
537                }
538        }
539
540        private Class<?> extractViewClass(JsonView annotation, Object conversionHint) {
541                Class<?>[] classes = annotation.value();
542                if (classes.length != 1) {
543                        throw new IllegalArgumentException(
544                                        "@JsonView only supported for handler methods with exactly 1 class argument: " + conversionHint);
545                }
546                return classes[0];
547        }
548
549}