From 3a0a755144058215794990ffc5dd381014908d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 13 May 2025 12:57:04 +0200 Subject: [PATCH] Introduce Jackson 3 support for spring-messaging This commit introduces a JacksonJsonMessageConverter Jackson 3 variant of MappingJackson2MessageConverter. See gh-33798 --- spring-messaging/spring-messaging.gradle | 1 + .../JacksonJsonMessageConverter.java | 236 ++++++++++++++++++ .../AbstractMessageBrokerConfiguration.java | 23 +- .../SendToMethodReturnValueHandlerTests.java | 6 +- ...criptionMethodReturnValueHandlerTests.java | 4 +- .../MessageBrokerConfigurationTests.java | 8 +- .../user/MultiServerUserRegistryTests.java | 4 +- .../user/UserRegistryMessageHandlerTests.java | 4 +- 8 files changed, 272 insertions(+), 14 deletions(-) create mode 100644 spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java diff --git a/spring-messaging/spring-messaging.gradle b/spring-messaging/spring-messaging.gradle index 6440084bc22..a611cf66982 100644 --- a/spring-messaging/spring-messaging.gradle +++ b/spring-messaging/spring-messaging.gradle @@ -20,6 +20,7 @@ dependencies { optional("jakarta.xml.bind:jakarta.xml.bind-api") optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") optional("org.jetbrains.kotlinx:kotlinx-serialization-json") + optional("tools.jackson.core:jackson-databind") testImplementation(project(":spring-core-test")) testImplementation(testFixtures(project(":spring-core"))) testImplementation("com.thoughtworks.xstream:xstream") diff --git a/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java b/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java new file mode 100644 index 00000000000..8b83bfb8edd --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/converter/JacksonJsonMessageConverter.java @@ -0,0 +1,236 @@ +/* + * 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.messaging.converter; + +import java.io.ByteArrayOutputStream; +import java.io.StringWriter; +import java.io.Writer; +import java.nio.charset.Charset; + +import com.fasterxml.jackson.annotation.JsonView; +import org.jspecify.annotations.Nullable; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonEncoding; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.MimeType; + +/** + * A Jackson 3.x based {@link MessageConverter} implementation. + * + *

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 extends AbstractMessageConverter { + + private static final MimeType[] DEFAULT_MIME_TYPES = new MimeType[] { + new MimeType("application", "json"), new MimeType("application", "*+json")}; + + private final ObjectMapper objectMapper; + + + /** + * 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(DEFAULT_MIME_TYPES); + } + + /** + * Construct a new instance with a {@link JsonMapper} customized + * with the {@link tools.jackson.databind.JacksonModule}s found + * by {@link MapperBuilder#findModules(ClassLoader)} and the + * provided {@link MimeType}s. + * @param supportedMimeTypes the supported MIME types + */ + public JacksonJsonMessageConverter(MimeType... supportedMimeTypes) { + super(supportedMimeTypes); + 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) { + this(objectMapper, DEFAULT_MIME_TYPES); + } + + /** + * Construct a new instance with the provided {@link ObjectMapper} and the + * provided {@link MimeType}s. + * @see JsonMapper#builder() + * @see MapperBuilder#findModules(ClassLoader) + */ + public JacksonJsonMessageConverter(ObjectMapper objectMapper, MimeType... supportedMimeTypes) { + super(supportedMimeTypes); + Assert.notNull(objectMapper, "ObjectMapper must not be null"); + this.objectMapper = objectMapper; + } + + @Override + protected boolean canConvertFrom(Message message, @Nullable Class targetClass) { + return targetClass != null && supportsMimeType(message.getHeaders()); + } + + @Override + protected boolean canConvertTo(Object payload, @Nullable MessageHeaders headers) { + return supportsMimeType(headers); + } + + @Override + protected boolean supports(Class clazz) { + // should not be called, since we override canConvertFrom/canConvertTo instead + throw new UnsupportedOperationException(); + } + + @Override + protected @Nullable Object convertFromInternal(Message message, Class targetClass, @Nullable Object conversionHint) { + JavaType javaType = this.objectMapper.constructType(getResolvedType(targetClass, conversionHint)); + Object payload = message.getPayload(); + Class view = getSerializationView(conversionHint); + try { + if (ClassUtils.isAssignableValue(targetClass, payload)) { + return payload; + } + else if (payload instanceof byte[] bytes) { + if (view != null) { + return this.objectMapper.readerWithView(view).forType(javaType).readValue(bytes); + } + else { + return this.objectMapper.readValue(bytes, javaType); + } + } + else { + // Assuming a text-based source payload + if (view != null) { + return this.objectMapper.readerWithView(view).forType(javaType).readValue(payload.toString()); + } + else { + return this.objectMapper.readValue(payload.toString(), javaType); + } + } + } + catch (JacksonException ex) { + throw new MessageConversionException(message, "Could not read JSON: " + ex.getMessage(), ex); + } + } + + @Override + protected @Nullable Object convertToInternal(Object payload, @Nullable MessageHeaders headers, + @Nullable Object conversionHint) { + + try { + Class view = getSerializationView(conversionHint); + if (byte[].class == getSerializedPayloadClass()) { + ByteArrayOutputStream out = new ByteArrayOutputStream(1024); + JsonEncoding encoding = getJsonEncoding(getMimeType(headers)); + try (JsonGenerator generator = this.objectMapper.createGenerator(out, encoding)) { + if (view != null) { + this.objectMapper.writerWithView(view).writeValue(generator, payload); + } + else { + this.objectMapper.writeValue(generator, payload); + } + payload = out.toByteArray(); + } + } + else { + // Assuming a text-based target payload + Writer writer = new StringWriter(1024); + if (view != null) { + this.objectMapper.writerWithView(view).writeValue(writer, payload); + } + else { + this.objectMapper.writeValue(writer, payload); + } + payload = writer.toString(); + } + } + catch (JacksonException ex) { + throw new MessageConversionException("Could not write JSON: " + ex.getMessage(), ex); + } + return payload; + } + + /** + * 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 param) { + JsonView annotation = (param.getParameterIndex() >= 0 ? + param.getParameterAnnotation(JsonView.class) : param.getMethodAnnotation(JsonView.class)); + if (annotation != null) { + return extractViewClass(annotation, conversionHint); + } + } + else if (conversionHint instanceof JsonView jsonView) { + return extractViewClass(jsonView, conversionHint); + } + else if (conversionHint instanceof Class clazz) { + return clazz; + } + + // No JSON view specified... + 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]; + } + + /** + * Determine the JSON encoding to use for the given content type. + * @param contentType the MIME type from the MessageHeaders, if any + * @return the JSON encoding to use (never {@code null}) + */ + protected JsonEncoding getJsonEncoding(@Nullable MimeType contentType) { + if (contentType != null && contentType.getCharset() != null) { + Charset charset = contentType.getCharset(); + for (JsonEncoding encoding : JsonEncoding.values()) { + if (charset.name().equals(encoding.getJavaName())) { + return encoding; + } + } + } + return JsonEncoding.UTF8; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java index b55bda3dc00..1ca3cea2447 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java @@ -37,6 +37,7 @@ import org.springframework.messaging.converter.ByteArrayMessageConverter; import org.springframework.messaging.converter.CompositeMessageConverter; import org.springframework.messaging.converter.DefaultContentTypeResolver; import org.springframework.messaging.converter.GsonMessageConverter; +import org.springframework.messaging.converter.JacksonJsonMessageConverter; import org.springframework.messaging.converter.JsonbMessageConverter; import org.springframework.messaging.converter.KotlinSerializationJsonMessageConverter; import org.springframework.messaging.converter.MappingJackson2MessageConverter; @@ -103,6 +104,8 @@ public abstract class AbstractMessageBrokerConfiguration implements ApplicationC private static final String MVC_VALIDATOR_NAME = "mvcValidator"; + private static final boolean jacksonPresent; + private static final boolean jackson2Present; private static final boolean gsonPresent; @@ -114,6 +117,7 @@ public abstract class AbstractMessageBrokerConfiguration implements ApplicationC static { ClassLoader classLoader = AbstractMessageBrokerConfiguration.class.getClassLoader(); + jacksonPresent = ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", classLoader); jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); @@ -501,7 +505,10 @@ public abstract class AbstractMessageBrokerConfiguration implements ApplicationC if (kotlinSerializationJsonPresent) { converters.add(new KotlinSerializationJsonMessageConverter()); } - if (jackson2Present) { + if (jacksonPresent) { + converters.add(createJacksonJsonConverter()); + } + else if (jackson2Present) { converters.add(createJacksonConverter()); } else if (gsonPresent) { @@ -514,6 +521,20 @@ public abstract class AbstractMessageBrokerConfiguration implements ApplicationC return new CompositeMessageConverter(converters); } + /** + * Allow to customize Jackson 3.x JSON converter. + */ + protected JacksonJsonMessageConverter createJacksonJsonConverter() { + DefaultContentTypeResolver resolver = new DefaultContentTypeResolver(); + resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON); + JacksonJsonMessageConverter converter = new JacksonJsonMessageConverter(); + converter.setContentTypeResolver(resolver); + return converter; + } + + /** + * Allow to customize Jackson 2.x JSON converter. + */ protected MappingJackson2MessageConverter createJacksonConverter() { DefaultContentTypeResolver resolver = new DefaultContentTypeResolver(); resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON); diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SendToMethodReturnValueHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SendToMethodReturnValueHandlerTests.java index ae55bc99d4d..b12e4722412 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SendToMethodReturnValueHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SendToMethodReturnValueHandlerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * 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. @@ -41,7 +41,7 @@ import org.springframework.core.annotation.SynthesizingMethodParameter; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; import org.springframework.messaging.MessageHeaders; -import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.JacksonJsonMessageConverter; import org.springframework.messaging.converter.StringMessageConverter; import org.springframework.messaging.handler.DestinationPatternsMessageCondition; import org.springframework.messaging.handler.annotation.SendTo; @@ -129,7 +129,7 @@ public class SendToMethodReturnValueHandlerTests { this.handlerAnnotationNotRequired = new SendToMethodReturnValueHandler(messagingTemplate, false); SimpMessagingTemplate jsonMessagingTemplate = new SimpMessagingTemplate(this.messageChannel); - jsonMessagingTemplate.setMessageConverter(new MappingJackson2MessageConverter()); + jsonMessagingTemplate.setMessageConverter(new JacksonJsonMessageConverter()); this.jsonHandler = new SendToMethodReturnValueHandler(jsonMessagingTemplate, true); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SubscriptionMethodReturnValueHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SubscriptionMethodReturnValueHandlerTests.java index 0c412d4c615..d3a144b6920 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SubscriptionMethodReturnValueHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SubscriptionMethodReturnValueHandlerTests.java @@ -33,7 +33,7 @@ import org.springframework.core.MethodParameter; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; import org.springframework.messaging.MessageHeaders; -import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.JacksonJsonMessageConverter; import org.springframework.messaging.converter.StringMessageConverter; import org.springframework.messaging.core.MessageSendingOperations; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -92,7 +92,7 @@ public class SubscriptionMethodReturnValueHandlerTests { this.handler = new SubscriptionMethodReturnValueHandler(messagingTemplate); SimpMessagingTemplate jsonMessagingTemplate = new SimpMessagingTemplate(this.messageChannel); - jsonMessagingTemplate.setMessageConverter(new MappingJackson2MessageConverter()); + jsonMessagingTemplate.setMessageConverter(new JacksonJsonMessageConverter()); this.jsonHandler = new SubscriptionMethodReturnValueHandler(jsonMessagingTemplate); Method method = this.getClass().getDeclaredMethod("getData"); diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/config/MessageBrokerConfigurationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/config/MessageBrokerConfigurationTests.java index e8ad5acb257..6ba2e99affe 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/config/MessageBrokerConfigurationTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/config/MessageBrokerConfigurationTests.java @@ -40,8 +40,8 @@ import org.springframework.messaging.converter.ByteArrayMessageConverter; import org.springframework.messaging.converter.CompositeMessageConverter; import org.springframework.messaging.converter.ContentTypeResolver; import org.springframework.messaging.converter.DefaultContentTypeResolver; +import org.springframework.messaging.converter.JacksonJsonMessageConverter; import org.springframework.messaging.converter.KotlinSerializationJsonMessageConverter; -import org.springframework.messaging.converter.MappingJackson2MessageConverter; import org.springframework.messaging.converter.MessageConverter; import org.springframework.messaging.converter.StringMessageConverter; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -288,9 +288,9 @@ class MessageBrokerConfigurationTests { List converters = compositeConverter.getConverters(); assertThat(converters).hasExactlyElementsOfTypes(StringMessageConverter.class, ByteArrayMessageConverter.class, - KotlinSerializationJsonMessageConverter.class, MappingJackson2MessageConverter.class); + KotlinSerializationJsonMessageConverter.class, JacksonJsonMessageConverter.class); - ContentTypeResolver resolver = ((MappingJackson2MessageConverter) converters.get(3)).getContentTypeResolver(); + ContentTypeResolver resolver = ((JacksonJsonMessageConverter) converters.get(3)).getContentTypeResolver(); assertThat(((DefaultContentTypeResolver) resolver).getDefaultMimeType()).isEqualTo(MimeTypeUtils.APPLICATION_JSON); } @@ -349,7 +349,7 @@ class MessageBrokerConfigurationTests { assertThat(iterator.next()).isInstanceOf(StringMessageConverter.class); assertThat(iterator.next()).isInstanceOf(ByteArrayMessageConverter.class); assertThat(iterator.next()).isInstanceOf(KotlinSerializationJsonMessageConverter.class); - assertThat(iterator.next()).isInstanceOf(MappingJackson2MessageConverter.class); + assertThat(iterator.next()).isInstanceOf(JacksonJsonMessageConverter.class); } @Test diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/user/MultiServerUserRegistryTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/user/MultiServerUserRegistryTests.java index af866b37e8a..01f7e0ae4e5 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/user/MultiServerUserRegistryTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/user/MultiServerUserRegistryTests.java @@ -25,7 +25,7 @@ import java.util.Set; import org.junit.jupiter.api.Test; import org.springframework.messaging.Message; -import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.JacksonJsonMessageConverter; import org.springframework.messaging.converter.MessageConverter; import static org.assertj.core.api.Assertions.assertThat; @@ -43,7 +43,7 @@ class MultiServerUserRegistryTests { private final MultiServerUserRegistry registry = new MultiServerUserRegistry(this.localRegistry); - private final MessageConverter converter = new MappingJackson2MessageConverter(); + private final MessageConverter converter = new JacksonJsonMessageConverter(); @Test diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/user/UserRegistryMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/user/UserRegistryMessageHandlerTests.java index 4c9c4f6717e..a21562df3f0 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/user/UserRegistryMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/user/UserRegistryMessageHandlerTests.java @@ -29,7 +29,7 @@ import org.mockito.ArgumentCaptor; import org.springframework.messaging.Message; import org.springframework.messaging.MessageChannel; import org.springframework.messaging.MessageHeaders; -import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.converter.JacksonJsonMessageConverter; import org.springframework.messaging.converter.MessageConverter; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -59,7 +59,7 @@ class UserRegistryMessageHandlerTests { private MultiServerUserRegistry multiServerRegistry = new MultiServerUserRegistry(this.localRegistry); - private MessageConverter converter = new MappingJackson2MessageConverter(); + private MessageConverter converter = new JacksonJsonMessageConverter(); private UserRegistryMessageHandler handler;