From a0ed3f052eabd952c13dc953be0958a0204017fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 13 May 2025 12:57:40 +0200 Subject: [PATCH] Introduce Jackson 3 support for spring-jms This commit introduces a JacksonJsonMessageConverter Jackson 3 variant of MappingJackson2MessageConverter. See gh-33798 --- spring-jms/spring-jms.gradle | 1 + .../JacksonJsonMessageConverter.java | 488 ++++++++++++++++++ .../MessagingMessageListenerAdapterTests.java | 6 +- .../JacksonJsonMessageConverterTests.java | 336 ++++++++++++ 4 files changed, 828 insertions(+), 3 deletions(-) create mode 100644 spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java create mode 100644 spring-jms/src/test/java/org/springframework/jms/support/converter/JacksonJsonMessageConverterTests.java diff --git a/spring-jms/spring-jms.gradle b/spring-jms/spring-jms.gradle index 31da2fb3024..9014b09663d 100644 --- a/spring-jms/spring-jms.gradle +++ b/spring-jms/spring-jms.gradle @@ -14,6 +14,7 @@ dependencies { optional("io.micrometer:micrometer-jakarta9") optional("jakarta.resource:jakarta.resource-api") optional("jakarta.transaction:jakarta.transaction-api") + optional("tools.jackson.core:jackson-databind") testImplementation(testFixtures(project(":spring-beans"))) testImplementation(testFixtures(project(":spring-tx"))) testImplementation("jakarta.jms:jakarta.jms-api") diff --git a/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java b/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java new file mode 100644 index 00000000000..75867407bd9 --- /dev/null +++ b/spring-jms/src/main/java/org/springframework/jms/support/converter/JacksonJsonMessageConverter.java @@ -0,0 +1,488 @@ +/* + * Copyright 2002-2025 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 + * + * https://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.jms.support.converter; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.StringWriter; +import java.io.UnsupportedEncodingException; +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonView; +import jakarta.jms.BytesMessage; +import jakarta.jms.JMSException; +import jakarta.jms.Message; +import jakarta.jms.Session; +import jakarta.jms.TextMessage; +import org.jspecify.annotations.Nullable; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectWriter; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.core.MethodParameter; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Message converter that uses Jackson 3.x to convert messages to and from JSON. + * + *

Maps an object to a {@link BytesMessage}, or to a {@link TextMessage} if the + * {@link #setTargetType targetType} is set to {@link MessageType#TEXT}. + * Converts from a {@link TextMessage} or {@link BytesMessage} to an object. + * + *

The default constructor loads {@link tools.jackson.databind.JacksonModule}s + * found by {@link MapperBuilder#findModules(ClassLoader)}. + * + * @author Sebastien Deleuze + * @since 7.0 + */ +public class JacksonJsonMessageConverter implements SmartMessageConverter, BeanClassLoaderAware { + + /** + * The default encoding used for writing to text messages: UTF-8. + */ + public static final String DEFAULT_ENCODING = "UTF-8"; + + + private final ObjectMapper objectMapper; + + private MessageType targetType = MessageType.BYTES; + + private @Nullable String encoding; + + private @Nullable String encodingPropertyName; + + private @Nullable String typeIdPropertyName; + + private Map> idClassMappings = new HashMap<>(); + + private final Map, String> classIdMappings = new HashMap<>(); + + private @Nullable ClassLoader beanClassLoader; + + + /** + * Construct a new instance with a {@link JsonMapper} customized with the + * {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)}. + */ + public JacksonJsonMessageConverter() { + this.objectMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build(); + } + + /** + * Construct a new instance with the provided {@link ObjectMapper}. + * @see JsonMapper#builder() + * @see MapperBuilder#findModules(ClassLoader) + */ + public JacksonJsonMessageConverter(ObjectMapper objectMapper) { + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + this.objectMapper = objectMapper; + } + + /** + * Specify whether {@link #toMessage(Object, Session)} should marshal to a + * {@link BytesMessage} or a {@link TextMessage}. + *

The default is {@link MessageType#BYTES}, i.e. this converter marshals to + * a {@link BytesMessage}. Note that the default version of this converter + * supports {@link MessageType#BYTES} and {@link MessageType#TEXT} only. + * @see MessageType#BYTES + * @see MessageType#TEXT + */ + public void setTargetType(MessageType targetType) { + Assert.notNull(targetType, "MessageType must not be null"); + this.targetType = targetType; + } + + /** + * Specify the encoding to use when converting to and from text-based + * message body content. The default encoding will be "UTF-8". + *

When reading from a text-based message, an encoding may have been + * suggested through a special JMS property which will then be preferred + * over the encoding set on this MessageConverter instance. + * @see #setEncodingPropertyName + */ + public void setEncoding(String encoding) { + this.encoding = encoding; + } + + /** + * Specify the name of the JMS message property that carries the encoding from + * bytes to String and back is BytesMessage is used during the conversion process. + *

Default is none. Setting this property is optional; if not set, UTF-8 will + * be used for decoding any incoming bytes message. + * @see #setEncoding + */ + public void setEncodingPropertyName(String encodingPropertyName) { + this.encodingPropertyName = encodingPropertyName; + } + + /** + * Specify the name of the JMS message property that carries the type id for the + * contained object: either a mapped id value or a raw Java class name. + *

Default is none. NOTE: This property needs to be set in order to allow + * for converting from an incoming message to a Java object. + * @see #setTypeIdMappings + */ + public void setTypeIdPropertyName(String typeIdPropertyName) { + this.typeIdPropertyName = typeIdPropertyName; + } + + /** + * Specify mappings from type ids to Java classes, if desired. + * This allows for synthetic ids in the type id message property, + * instead of transferring Java class names. + *

Default is no custom mappings, i.e. transferring raw Java class names. + * @param typeIdMappings a Map with type id values as keys and Java classes as values + */ + public void setTypeIdMappings(Map> typeIdMappings) { + this.idClassMappings = new HashMap<>(); + typeIdMappings.forEach((id, clazz) -> { + this.idClassMappings.put(id, clazz); + this.classIdMappings.put(clazz, id); + }); + } + + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.beanClassLoader = classLoader; + } + + + @Override + public Message toMessage(Object object, Session session) throws JMSException, MessageConversionException { + Message message; + try { + message = switch (this.targetType) { + case TEXT -> mapToTextMessage(object, session, this.objectMapper.writer()); + case BYTES -> mapToBytesMessage(object, session, this.objectMapper.writer()); + default -> mapToMessage(object, session, this.objectMapper.writer(), this.targetType); + }; + } + catch (IOException ex) { + throw new MessageConversionException("Could not map JSON object [" + object + "]", ex); + } + setTypeIdOnMessage(object, message); + return message; + } + + @Override + public Message toMessage(Object object, Session session, @Nullable Object conversionHint) + throws JMSException, MessageConversionException { + + return toMessage(object, session, getSerializationView(conversionHint)); + } + + /** + * Convert a Java object to a JMS Message using the specified json view + * and the supplied session to create the message object. + * @param object the object to convert + * @param session the Session to use for creating a JMS Message + * @param jsonView the view to use to filter the content + * @return the JMS Message + * @throws JMSException if thrown by JMS API methods + * @throws MessageConversionException in case of conversion failure + */ + public Message toMessage(Object object, Session session, @Nullable Class jsonView) + throws JMSException, MessageConversionException { + + if (jsonView != null) { + return toMessage(object, session, this.objectMapper.writerWithView(jsonView)); + } + else { + return toMessage(object, session, this.objectMapper.writer()); + } + } + + @Override + public Object fromMessage(Message message) throws JMSException, MessageConversionException { + try { + JavaType targetJavaType = getJavaTypeForMessage(message); + return convertToObject(message, targetJavaType); + } + catch (IOException ex) { + throw new MessageConversionException("Failed to convert JSON message content", ex); + } + } + + protected Message toMessage(Object object, Session session, ObjectWriter objectWriter) + throws JMSException, MessageConversionException { + + Message message; + try { + message = switch (this.targetType) { + case TEXT -> mapToTextMessage(object, session, objectWriter); + case BYTES -> mapToBytesMessage(object, session, objectWriter); + default -> mapToMessage(object, session, objectWriter, this.targetType); + }; + } + catch (IOException ex) { + throw new MessageConversionException("Could not map JSON object [" + object + "]", ex); + } + setTypeIdOnMessage(object, message); + return message; + } + + + /** + * Map the given object to a {@link TextMessage}. + * @param object the object to be mapped + * @param session current JMS session + * @param objectWriter the writer to use + * @return the resulting message + * @throws JMSException if thrown by JMS methods + * @throws IOException in case of I/O errors + * @see Session#createBytesMessage + */ + protected TextMessage mapToTextMessage(Object object, Session session, ObjectWriter objectWriter) + throws JMSException, IOException { + + StringWriter writer = new StringWriter(1024); + objectWriter.writeValue(writer, object); + return session.createTextMessage(writer.toString()); + } + + /** + * Map the given object to a {@link BytesMessage}. + * @param object the object to be mapped + * @param session current JMS session + * @param objectWriter the writer to use + * @return the resulting message + * @throws JMSException if thrown by JMS methods + * @throws IOException in case of I/O errors + * @see Session#createBytesMessage + */ + protected BytesMessage mapToBytesMessage(Object object, Session session, ObjectWriter objectWriter) + throws JMSException, IOException { + + ByteArrayOutputStream bos = new ByteArrayOutputStream(1024); + if (this.encoding != null) { + OutputStreamWriter writer = new OutputStreamWriter(bos, this.encoding); + objectWriter.writeValue(writer, object); + } + else { + // Jackson usually defaults to UTF-8 but can also go straight to bytes, for example, for Smile. + // We use a direct byte array argument for the latter case to work as well. + objectWriter.writeValue(bos, object); + } + + BytesMessage message = session.createBytesMessage(); + message.writeBytes(bos.toByteArray()); + if (this.encodingPropertyName != null) { + message.setStringProperty(this.encodingPropertyName, + (this.encoding != null ? this.encoding : DEFAULT_ENCODING)); + } + return message; + } + + /** + * Template method that allows for custom message mapping. + * Invoked when {@link #setTargetType} is not {@link MessageType#TEXT} or + * {@link MessageType#BYTES}. + *

The default implementation throws an {@link IllegalArgumentException}. + * @param object the object to marshal + * @param session the JMS Session + * @param objectWriter the writer to use + * @param targetType the target message type (other than TEXT or BYTES) + * @return the resulting message + * @throws JMSException if thrown by JMS methods + * @throws IOException in case of I/O errors + */ + protected Message mapToMessage(Object object, Session session, ObjectWriter objectWriter, MessageType targetType) + throws JMSException, IOException { + + throw new IllegalArgumentException("Unsupported message type [" + targetType + + "]. MappingJackson2MessageConverter by default only supports TextMessages and BytesMessages."); + } + + /** + * Set a type id for the given payload object on the given JMS Message. + *

The default implementation consults the configured type id mapping and + * sets the resulting value (either a mapped id or the raw Java class name) + * into the configured type id message property. + * @param object the payload object to set a type id for + * @param message the JMS Message on which to set the type id property + * @throws JMSException if thrown by JMS methods + * @see #getJavaTypeForMessage(Message) + * @see #setTypeIdPropertyName(String) + * @see #setTypeIdMappings(Map) + */ + protected void setTypeIdOnMessage(Object object, Message message) throws JMSException { + if (this.typeIdPropertyName != null) { + String typeId = this.classIdMappings.get(object.getClass()); + if (typeId == null) { + typeId = object.getClass().getName(); + } + message.setStringProperty(this.typeIdPropertyName, typeId); + } + } + + /** + * Convenience method to dispatch to converters for individual message types. + */ + private Object convertToObject(Message message, JavaType targetJavaType) throws JMSException, IOException { + if (message instanceof TextMessage textMessage) { + return convertFromTextMessage(textMessage, targetJavaType); + } + else if (message instanceof BytesMessage bytesMessage) { + return convertFromBytesMessage(bytesMessage, targetJavaType); + } + else { + return convertFromMessage(message, targetJavaType); + } + } + + /** + * Convert a TextMessage to a Java Object with the specified type. + * @param message the input message + * @param targetJavaType the target type + * @return the message converted to an object + * @throws JMSException if thrown by JMS + * @throws IOException in case of I/O errors + */ + protected Object convertFromTextMessage(TextMessage message, JavaType targetJavaType) + throws JMSException, IOException { + + String body = message.getText(); + return this.objectMapper.readValue(body, targetJavaType); + } + + /** + * Convert a BytesMessage to a Java Object with the specified type. + * @param message the input message + * @param targetJavaType the target type + * @return the message converted to an object + * @throws JMSException if thrown by JMS + * @throws IOException in case of I/O errors + */ + protected Object convertFromBytesMessage(BytesMessage message, JavaType targetJavaType) + throws JMSException, IOException { + + String encoding = this.encoding; + if (this.encodingPropertyName != null && message.propertyExists(this.encodingPropertyName)) { + encoding = message.getStringProperty(this.encodingPropertyName); + } + byte[] bytes = new byte[(int) message.getBodyLength()]; + message.readBytes(bytes); + if (encoding != null) { + try { + String body = new String(bytes, encoding); + return this.objectMapper.readValue(body, targetJavaType); + } + catch (UnsupportedEncodingException ex) { + throw new MessageConversionException("Cannot convert bytes to String", ex); + } + } + else { + // Jackson internally performs encoding detection, falling back to UTF-8. + return this.objectMapper.readValue(bytes, targetJavaType); + } + } + + /** + * Template method that allows for custom message mapping. + * Invoked when {@link #setTargetType} is not {@link MessageType#TEXT} or + * {@link MessageType#BYTES}. + *

The default implementation throws an {@link IllegalArgumentException}. + * @param message the input message + * @param targetJavaType the target type + * @return the message converted to an object + * @throws JMSException if thrown by JMS + * @throws IOException in case of I/O errors + */ + protected Object convertFromMessage(Message message, JavaType targetJavaType) + throws JMSException, IOException { + + throw new IllegalArgumentException("Unsupported message type [" + message.getClass() + + "]. MappingJacksonMessageConverter by default only supports TextMessages and BytesMessages."); + } + + /** + * Determine a Jackson JavaType for the given JMS Message, + * typically parsing a type id message property. + *

The default implementation parses the configured type id property name + * and consults the configured type id mapping. This can be overridden with + * a different strategy, for example, doing some heuristics based on message origin. + * @param message the JMS Message from which to get the type id property + * @throws JMSException if thrown by JMS methods + * @see #setTypeIdOnMessage(Object, Message) + * @see #setTypeIdPropertyName(String) + * @see #setTypeIdMappings(Map) + */ + protected JavaType getJavaTypeForMessage(Message message) throws JMSException { + String typeId = message.getStringProperty(this.typeIdPropertyName); + if (typeId == null) { + throw new MessageConversionException( + "Could not find type id property [" + this.typeIdPropertyName + "] on message [" + + message.getJMSMessageID() + "] from destination [" + message.getJMSDestination() + "]"); + } + Class mappedClass = this.idClassMappings.get(typeId); + if (mappedClass != null) { + return this.objectMapper.constructType(mappedClass); + } + try { + Class typeClass = ClassUtils.forName(typeId, this.beanClassLoader); + return this.objectMapper.constructType(typeClass); + } + catch (Throwable ex) { + throw new MessageConversionException("Failed to resolve type id [" + typeId + "]", ex); + } + } + + /** + * Determine a Jackson serialization view based on the given conversion hint. + * @param conversionHint the conversion hint Object as passed into the + * converter for the current conversion attempt + * @return the serialization view class, or {@code null} if none + */ + protected @Nullable Class getSerializationView(@Nullable Object conversionHint) { + if (conversionHint instanceof MethodParameter methodParam) { + JsonView annotation = methodParam.getParameterAnnotation(JsonView.class); + if (annotation == null) { + annotation = methodParam.getMethodAnnotation(JsonView.class); + if (annotation == null) { + return null; + } + } + return extractViewClass(annotation, conversionHint); + } + else if (conversionHint instanceof JsonView jsonView) { + return extractViewClass(jsonView, conversionHint); + } + else if (conversionHint instanceof Class clazz) { + return clazz; + } + else { + return null; + } + } + + private Class extractViewClass(JsonView annotation, Object conversionHint) { + Class[] classes = annotation.value(); + if (classes.length != 1) { + throw new IllegalArgumentException( + "@JsonView only supported for handler methods with exactly 1 class argument: " + conversionHint); + } + return classes[0]; + } + +} diff --git a/spring-jms/src/test/java/org/springframework/jms/listener/adapter/MessagingMessageListenerAdapterTests.java b/spring-jms/src/test/java/org/springframework/jms/listener/adapter/MessagingMessageListenerAdapterTests.java index 3ab88d2c92a..e9299b1e829 100644 --- a/spring-jms/src/test/java/org/springframework/jms/listener/adapter/MessagingMessageListenerAdapterTests.java +++ b/spring-jms/src/test/java/org/springframework/jms/listener/adapter/MessagingMessageListenerAdapterTests.java @@ -36,7 +36,7 @@ import org.springframework.beans.factory.support.StaticListableBeanFactory; import org.springframework.jms.StubTextMessage; import org.springframework.jms.support.JmsHeaders; import org.springframework.jms.support.QosSettings; -import org.springframework.jms.support.converter.MappingJackson2MessageConverter; +import org.springframework.jms.support.converter.JacksonJsonMessageConverter; import org.springframework.jms.support.converter.MessageConverter; import org.springframework.jms.support.converter.MessageType; import org.springframework.jms.support.converter.MessagingMessageConverter; @@ -299,7 +299,7 @@ class MessagingMessageListenerAdapterTests { @Test void replyJackson() throws JMSException { TextMessage reply = testReplyWithJackson("replyJackson", - "{\"counter\":42,\"name\":\"Response\",\"description\":\"lengthy description\"}"); + "{\"name\":\"Response\",\"description\":\"lengthy description\",\"counter\":42}"); verify(reply).setObjectProperty("foo", "bar"); } @@ -327,7 +327,7 @@ class MessagingMessageListenerAdapterTests { given(session.createProducer(replyDestination)).willReturn(messageProducer); MessagingMessageListenerAdapter listener = getPayloadInstance("Response", methodName, Message.class); - MappingJackson2MessageConverter messageConverter = new MappingJackson2MessageConverter(); + JacksonJsonMessageConverter messageConverter = new JacksonJsonMessageConverter(); messageConverter.setTargetType(MessageType.TEXT); listener.setMessageConverter(messageConverter); listener.setDefaultResponseDestination(replyDestination); diff --git a/spring-jms/src/test/java/org/springframework/jms/support/converter/JacksonJsonMessageConverterTests.java b/spring-jms/src/test/java/org/springframework/jms/support/converter/JacksonJsonMessageConverterTests.java new file mode 100644 index 00000000000..1416fa3ab04 --- /dev/null +++ b/spring-jms/src/test/java/org/springframework/jms/support/converter/JacksonJsonMessageConverterTests.java @@ -0,0 +1,336 @@ +/* + * Copyright 2002-2025 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 + * + * https://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.jms.support.converter; + +import java.io.ByteArrayInputStream; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonView; +import jakarta.jms.BytesMessage; +import jakarta.jms.JMSException; +import jakarta.jms.Session; +import jakarta.jms.TextMessage; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.stubbing.Answer; + +import org.springframework.core.MethodParameter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * @author Sebastien Deleuze + */ +class JacksonJsonMessageConverterTests { + + private JacksonJsonMessageConverter converter = new JacksonJsonMessageConverter(); + + private Session sessionMock = mock(); + + + @BeforeEach + void setup() { + converter.setEncodingPropertyName("__encoding__"); + converter.setTypeIdPropertyName("__typeid__"); + } + + + @Test + void toBytesMessage() throws Exception { + BytesMessage bytesMessageMock = mock(); + Date toBeMarshalled = new Date(); + + given(sessionMock.createBytesMessage()).willReturn(bytesMessageMock); + + converter.toMessage(toBeMarshalled, sessionMock); + + verify(bytesMessageMock).setStringProperty("__encoding__", "UTF-8"); + verify(bytesMessageMock).setStringProperty("__typeid__", Date.class.getName()); + verify(bytesMessageMock).writeBytes(isA(byte[].class)); + } + + @Test + void fromBytesMessage() throws Exception { + BytesMessage bytesMessageMock = mock(); + Map unmarshalled = Collections.singletonMap("foo", "bar"); + + byte[] bytes = "{\"foo\":\"bar\"}".getBytes(); + final ByteArrayInputStream byteStream = new ByteArrayInputStream(bytes); + + given(bytesMessageMock.getStringProperty("__typeid__")).willReturn(Object.class.getName()); + given(bytesMessageMock.propertyExists("__encoding__")).willReturn(false); + given(bytesMessageMock.getBodyLength()).willReturn(Long.valueOf(bytes.length)); + given(bytesMessageMock.readBytes(any(byte[].class))).willAnswer( + (Answer) invocation -> byteStream.read((byte[]) invocation.getArguments()[0])); + + Object result = converter.fromMessage(bytesMessageMock); + assertThat(unmarshalled).as("Invalid result").isEqualTo(result); + } + + @Test + void toTextMessageWithObject() throws Exception { + converter.setTargetType(MessageType.TEXT); + TextMessage textMessageMock = mock(); + Date toBeMarshalled = new Date(); + + given(sessionMock.createTextMessage(isA(String.class))).willReturn(textMessageMock); + + converter.toMessage(toBeMarshalled, sessionMock); + verify(textMessageMock).setStringProperty("__typeid__", Date.class.getName()); + } + + @Test + void toTextMessageWithMap() throws Exception { + converter.setTargetType(MessageType.TEXT); + TextMessage textMessageMock = mock(); + Map toBeMarshalled = new HashMap<>(); + toBeMarshalled.put("foo", "bar"); + + given(sessionMock.createTextMessage(isA(String.class))).willReturn(textMessageMock); + + converter.toMessage(toBeMarshalled, sessionMock); + verify(textMessageMock).setStringProperty("__typeid__", HashMap.class.getName()); + } + + @Test + void fromTextMessage() throws Exception { + TextMessage textMessageMock = mock(); + MyBean unmarshalled = new MyBean("bar"); + + String text = "{\"foo\":\"bar\"}"; + given(textMessageMock.getStringProperty("__typeid__")).willReturn(MyBean.class.getName()); + given(textMessageMock.getText()).willReturn(text); + + MyBean result = (MyBean)converter.fromMessage(textMessageMock); + assertThat(unmarshalled).as("Invalid result").isEqualTo(result); + } + + @Test + void fromTextMessageWithUnknownProperty() throws Exception { + TextMessage textMessageMock = mock(); + MyBean unmarshalled = new MyBean("bar"); + + String text = "{\"foo\":\"bar\", \"unknownProperty\":\"value\"}"; + given(textMessageMock.getStringProperty("__typeid__")).willReturn(MyBean.class.getName()); + given(textMessageMock.getText()).willReturn(text); + + MyBean result = (MyBean)converter.fromMessage(textMessageMock); + assertThat(unmarshalled).as("Invalid result").isEqualTo(result); + } + + @Test + void fromTextMessageAsObject() throws Exception { + TextMessage textMessageMock = mock(); + Map unmarshalled = Collections.singletonMap("foo", "bar"); + + String text = "{\"foo\":\"bar\"}"; + given(textMessageMock.getStringProperty("__typeid__")).willReturn(Object.class.getName()); + given(textMessageMock.getText()).willReturn(text); + + Object result = converter.fromMessage(textMessageMock); + assertThat(unmarshalled).as("Invalid result").isEqualTo(result); + } + + @Test + void fromTextMessageAsMap() throws Exception { + TextMessage textMessageMock = mock(); + Map unmarshalled = Collections.singletonMap("foo", "bar"); + + String text = "{\"foo\":\"bar\"}"; + given(textMessageMock.getStringProperty("__typeid__")).willReturn(HashMap.class.getName()); + given(textMessageMock.getText()).willReturn(text); + + Object result = converter.fromMessage(textMessageMock); + assertThat(unmarshalled).as("Invalid result").isEqualTo(result); + } + + @Test + void toTextMessageWithReturnType() throws JMSException, NoSuchMethodException { + Method method = this.getClass().getDeclaredMethod("summary"); + MethodParameter returnType = new MethodParameter(method, -1); + testToTextMessageWithReturnType(returnType); + verify(sessionMock).createTextMessage("{\"name\":\"test\"}"); + } + + @Test + void toTextMessageWithNullReturnType() throws JMSException, NoSuchMethodException { + testToTextMessageWithReturnType(null); + verify(sessionMock).createTextMessage("{\"description\":\"lengthy description\",\"name\":\"test\"}"); + } + + @Test + void toTextMessageWithReturnTypeAndNoJsonView() throws JMSException, NoSuchMethodException { + Method method = this.getClass().getDeclaredMethod("none"); + MethodParameter returnType = new MethodParameter(method, -1); + + testToTextMessageWithReturnType(returnType); + verify(sessionMock).createTextMessage("{\"description\":\"lengthy description\",\"name\":\"test\"}"); + } + + @Test + void toTextMessageWithReturnTypeAndMultipleJsonViews() throws NoSuchMethodException { + Method method = this.getClass().getDeclaredMethod("invalid"); + MethodParameter returnType = new MethodParameter(method, -1); + + assertThatIllegalArgumentException().isThrownBy(() -> + testToTextMessageWithReturnType(returnType)); + } + + private void testToTextMessageWithReturnType(MethodParameter returnType) throws JMSException { + converter.setTargetType(MessageType.TEXT); + TextMessage textMessageMock = mock(); + + MyAnotherBean bean = new MyAnotherBean("test", "lengthy description"); + given(sessionMock.createTextMessage(isA(String.class))).willReturn(textMessageMock); + converter.toMessage(bean, sessionMock, returnType); + verify(textMessageMock).setStringProperty("__typeid__", MyAnotherBean.class.getName()); + } + + @Test + void toTextMessageWithJsonViewClass() throws JMSException { + converter.setTargetType(MessageType.TEXT); + TextMessage textMessageMock = mock(); + + MyAnotherBean bean = new MyAnotherBean("test", "lengthy description"); + given(sessionMock.createTextMessage(isA(String.class))).willReturn(textMessageMock); + + + converter.toMessage(bean, sessionMock, Summary.class); + verify(textMessageMock).setStringProperty("__typeid__", MyAnotherBean.class.getName()); + verify(sessionMock).createTextMessage("{\"name\":\"test\"}"); + } + + @Test + void toTextMessageWithAnotherJsonViewClass() throws JMSException { + converter.setTargetType(MessageType.TEXT); + TextMessage textMessageMock = mock(); + + MyAnotherBean bean = new MyAnotherBean("test", "lengthy description"); + given(sessionMock.createTextMessage(isA(String.class))).willReturn(textMessageMock); + + + converter.toMessage(bean, sessionMock, Full.class); + verify(textMessageMock).setStringProperty("__typeid__", MyAnotherBean.class.getName()); + verify(sessionMock).createTextMessage("{\"description\":\"lengthy description\",\"name\":\"test\"}"); + } + + + @JsonView(Summary.class) + public MyAnotherBean summary() { + return new MyAnotherBean(); + } + + public MyAnotherBean none() { + return new MyAnotherBean(); + } + + @JsonView({Summary.class, Full.class}) + public MyAnotherBean invalid() { + return new MyAnotherBean(); + } + + + public static class MyBean { + + private String foo; + + public MyBean() { + } + + public MyBean(String foo) { + this.foo = foo; + } + + public String getFoo() { + return foo; + } + + public void setFoo(String foo) { + this.foo = foo; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MyBean bean = (MyBean) o; + return Objects.equals(this.foo, bean.foo); + } + + @Override + public int hashCode() { + return foo != null ? foo.hashCode() : 0; + } + } + + + private interface Summary {} + + private interface Full extends Summary {} + + + @SuppressWarnings("unused") + private static class MyAnotherBean { + + @JsonView(Summary.class) + private String name; + + @JsonView(Full.class) + private String description; + + private MyAnotherBean() { + } + + public MyAnotherBean(String name, String description) { + this.name = name; + this.description = description; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + } + +}