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