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 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 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