Skip to content

Commit 3a0a755

Browse files
committed
Introduce Jackson 3 support for spring-messaging
This commit introduces a JacksonJsonMessageConverter Jackson 3 variant of MappingJackson2MessageConverter. See gh-33798
1 parent d0cd7af commit 3a0a755

File tree

8 files changed

+272
-14
lines changed

8 files changed

+272
-14
lines changed

spring-messaging/spring-messaging.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies {
2020
optional("jakarta.xml.bind:jakarta.xml.bind-api")
2121
optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
2222
optional("org.jetbrains.kotlinx:kotlinx-serialization-json")
23+
optional("tools.jackson.core:jackson-databind")
2324
testImplementation(project(":spring-core-test"))
2425
testImplementation(testFixtures(project(":spring-core")))
2526
testImplementation("com.thoughtworks.xstream:xstream")
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.messaging.converter;
18+
19+
import java.io.ByteArrayOutputStream;
20+
import java.io.StringWriter;
21+
import java.io.Writer;
22+
import java.nio.charset.Charset;
23+
24+
import com.fasterxml.jackson.annotation.JsonView;
25+
import org.jspecify.annotations.Nullable;
26+
import tools.jackson.core.JacksonException;
27+
import tools.jackson.core.JsonEncoding;
28+
import tools.jackson.core.JsonGenerator;
29+
import tools.jackson.databind.JavaType;
30+
import tools.jackson.databind.ObjectMapper;
31+
import tools.jackson.databind.cfg.MapperBuilder;
32+
import tools.jackson.databind.json.JsonMapper;
33+
34+
import org.springframework.core.MethodParameter;
35+
import org.springframework.messaging.Message;
36+
import org.springframework.messaging.MessageHeaders;
37+
import org.springframework.util.Assert;
38+
import org.springframework.util.ClassUtils;
39+
import org.springframework.util.MimeType;
40+
41+
/**
42+
* A Jackson 3.x based {@link MessageConverter} implementation.
43+
*
44+
* <p>The default constructor loads {@link tools.jackson.databind.JacksonModule}s
45+
* found by {@link MapperBuilder#findModules(ClassLoader)}.
46+
*
47+
* @author Sebastien Deleuze
48+
* @since 7.0
49+
*/
50+
public class JacksonJsonMessageConverter extends AbstractMessageConverter {
51+
52+
private static final MimeType[] DEFAULT_MIME_TYPES = new MimeType[] {
53+
new MimeType("application", "json"), new MimeType("application", "*+json")};
54+
55+
private final ObjectMapper objectMapper;
56+
57+
58+
/**
59+
* Construct a new instance with a {@link JsonMapper} customized with the
60+
* {@link tools.jackson.databind.JacksonModule}s found by
61+
* {@link MapperBuilder#findModules(ClassLoader)}.
62+
*/
63+
public JacksonJsonMessageConverter() {
64+
this(DEFAULT_MIME_TYPES);
65+
}
66+
67+
/**
68+
* Construct a new instance with a {@link JsonMapper} customized
69+
* with the {@link tools.jackson.databind.JacksonModule}s found
70+
* by {@link MapperBuilder#findModules(ClassLoader)} and the
71+
* provided {@link MimeType}s.
72+
* @param supportedMimeTypes the supported MIME types
73+
*/
74+
public JacksonJsonMessageConverter(MimeType... supportedMimeTypes) {
75+
super(supportedMimeTypes);
76+
this.objectMapper = JsonMapper.builder().findAndAddModules(JacksonJsonMessageConverter.class.getClassLoader()).build();
77+
}
78+
79+
/**
80+
* Construct a new instance with the provided {@link ObjectMapper}.
81+
* @see JsonMapper#builder()
82+
* @see MapperBuilder#findModules(ClassLoader)
83+
*/
84+
public JacksonJsonMessageConverter(ObjectMapper objectMapper) {
85+
this(objectMapper, DEFAULT_MIME_TYPES);
86+
}
87+
88+
/**
89+
* Construct a new instance with the provided {@link ObjectMapper} and the
90+
* provided {@link MimeType}s.
91+
* @see JsonMapper#builder()
92+
* @see MapperBuilder#findModules(ClassLoader)
93+
*/
94+
public JacksonJsonMessageConverter(ObjectMapper objectMapper, MimeType... supportedMimeTypes) {
95+
super(supportedMimeTypes);
96+
Assert.notNull(objectMapper, "ObjectMapper must not be null");
97+
this.objectMapper = objectMapper;
98+
}
99+
100+
@Override
101+
protected boolean canConvertFrom(Message<?> message, @Nullable Class<?> targetClass) {
102+
return targetClass != null && supportsMimeType(message.getHeaders());
103+
}
104+
105+
@Override
106+
protected boolean canConvertTo(Object payload, @Nullable MessageHeaders headers) {
107+
return supportsMimeType(headers);
108+
}
109+
110+
@Override
111+
protected boolean supports(Class<?> clazz) {
112+
// should not be called, since we override canConvertFrom/canConvertTo instead
113+
throw new UnsupportedOperationException();
114+
}
115+
116+
@Override
117+
protected @Nullable Object convertFromInternal(Message<?> message, Class<?> targetClass, @Nullable Object conversionHint) {
118+
JavaType javaType = this.objectMapper.constructType(getResolvedType(targetClass, conversionHint));
119+
Object payload = message.getPayload();
120+
Class<?> view = getSerializationView(conversionHint);
121+
try {
122+
if (ClassUtils.isAssignableValue(targetClass, payload)) {
123+
return payload;
124+
}
125+
else if (payload instanceof byte[] bytes) {
126+
if (view != null) {
127+
return this.objectMapper.readerWithView(view).forType(javaType).readValue(bytes);
128+
}
129+
else {
130+
return this.objectMapper.readValue(bytes, javaType);
131+
}
132+
}
133+
else {
134+
// Assuming a text-based source payload
135+
if (view != null) {
136+
return this.objectMapper.readerWithView(view).forType(javaType).readValue(payload.toString());
137+
}
138+
else {
139+
return this.objectMapper.readValue(payload.toString(), javaType);
140+
}
141+
}
142+
}
143+
catch (JacksonException ex) {
144+
throw new MessageConversionException(message, "Could not read JSON: " + ex.getMessage(), ex);
145+
}
146+
}
147+
148+
@Override
149+
protected @Nullable Object convertToInternal(Object payload, @Nullable MessageHeaders headers,
150+
@Nullable Object conversionHint) {
151+
152+
try {
153+
Class<?> view = getSerializationView(conversionHint);
154+
if (byte[].class == getSerializedPayloadClass()) {
155+
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
156+
JsonEncoding encoding = getJsonEncoding(getMimeType(headers));
157+
try (JsonGenerator generator = this.objectMapper.createGenerator(out, encoding)) {
158+
if (view != null) {
159+
this.objectMapper.writerWithView(view).writeValue(generator, payload);
160+
}
161+
else {
162+
this.objectMapper.writeValue(generator, payload);
163+
}
164+
payload = out.toByteArray();
165+
}
166+
}
167+
else {
168+
// Assuming a text-based target payload
169+
Writer writer = new StringWriter(1024);
170+
if (view != null) {
171+
this.objectMapper.writerWithView(view).writeValue(writer, payload);
172+
}
173+
else {
174+
this.objectMapper.writeValue(writer, payload);
175+
}
176+
payload = writer.toString();
177+
}
178+
}
179+
catch (JacksonException ex) {
180+
throw new MessageConversionException("Could not write JSON: " + ex.getMessage(), ex);
181+
}
182+
return payload;
183+
}
184+
185+
/**
186+
* Determine a Jackson serialization view based on the given conversion hint.
187+
* @param conversionHint the conversion hint Object as passed into the
188+
* converter for the current conversion attempt
189+
* @return the serialization view class, or {@code null} if none
190+
*/
191+
protected @Nullable Class<?> getSerializationView(@Nullable Object conversionHint) {
192+
if (conversionHint instanceof MethodParameter param) {
193+
JsonView annotation = (param.getParameterIndex() >= 0 ?
194+
param.getParameterAnnotation(JsonView.class) : param.getMethodAnnotation(JsonView.class));
195+
if (annotation != null) {
196+
return extractViewClass(annotation, conversionHint);
197+
}
198+
}
199+
else if (conversionHint instanceof JsonView jsonView) {
200+
return extractViewClass(jsonView, conversionHint);
201+
}
202+
else if (conversionHint instanceof Class<?> clazz) {
203+
return clazz;
204+
}
205+
206+
// No JSON view specified...
207+
return null;
208+
}
209+
210+
private Class<?> extractViewClass(JsonView annotation, Object conversionHint) {
211+
Class<?>[] classes = annotation.value();
212+
if (classes.length != 1) {
213+
throw new IllegalArgumentException(
214+
"@JsonView only supported for handler methods with exactly 1 class argument: " + conversionHint);
215+
}
216+
return classes[0];
217+
}
218+
219+
/**
220+
* Determine the JSON encoding to use for the given content type.
221+
* @param contentType the MIME type from the MessageHeaders, if any
222+
* @return the JSON encoding to use (never {@code null})
223+
*/
224+
protected JsonEncoding getJsonEncoding(@Nullable MimeType contentType) {
225+
if (contentType != null && contentType.getCharset() != null) {
226+
Charset charset = contentType.getCharset();
227+
for (JsonEncoding encoding : JsonEncoding.values()) {
228+
if (charset.name().equals(encoding.getJavaName())) {
229+
return encoding;
230+
}
231+
}
232+
}
233+
return JsonEncoding.UTF8;
234+
}
235+
236+
}

spring-messaging/src/main/java/org/springframework/messaging/simp/config/AbstractMessageBrokerConfiguration.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.springframework.messaging.converter.CompositeMessageConverter;
3838
import org.springframework.messaging.converter.DefaultContentTypeResolver;
3939
import org.springframework.messaging.converter.GsonMessageConverter;
40+
import org.springframework.messaging.converter.JacksonJsonMessageConverter;
4041
import org.springframework.messaging.converter.JsonbMessageConverter;
4142
import org.springframework.messaging.converter.KotlinSerializationJsonMessageConverter;
4243
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
@@ -103,6 +104,8 @@ public abstract class AbstractMessageBrokerConfiguration implements ApplicationC
103104

104105
private static final String MVC_VALIDATOR_NAME = "mvcValidator";
105106

107+
private static final boolean jacksonPresent;
108+
106109
private static final boolean jackson2Present;
107110

108111
private static final boolean gsonPresent;
@@ -114,6 +117,7 @@ public abstract class AbstractMessageBrokerConfiguration implements ApplicationC
114117

115118
static {
116119
ClassLoader classLoader = AbstractMessageBrokerConfiguration.class.getClassLoader();
120+
jacksonPresent = ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", classLoader);
117121
jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) &&
118122
ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader);
119123
gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader);
@@ -501,7 +505,10 @@ public CompositeMessageConverter brokerMessageConverter() {
501505
if (kotlinSerializationJsonPresent) {
502506
converters.add(new KotlinSerializationJsonMessageConverter());
503507
}
504-
if (jackson2Present) {
508+
if (jacksonPresent) {
509+
converters.add(createJacksonJsonConverter());
510+
}
511+
else if (jackson2Present) {
505512
converters.add(createJacksonConverter());
506513
}
507514
else if (gsonPresent) {
@@ -514,6 +521,20 @@ else if (jsonbPresent) {
514521
return new CompositeMessageConverter(converters);
515522
}
516523

524+
/**
525+
* Allow to customize Jackson 3.x JSON converter.
526+
*/
527+
protected JacksonJsonMessageConverter createJacksonJsonConverter() {
528+
DefaultContentTypeResolver resolver = new DefaultContentTypeResolver();
529+
resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON);
530+
JacksonJsonMessageConverter converter = new JacksonJsonMessageConverter();
531+
converter.setContentTypeResolver(resolver);
532+
return converter;
533+
}
534+
535+
/**
536+
* Allow to customize Jackson 2.x JSON converter.
537+
*/
517538
protected MappingJackson2MessageConverter createJacksonConverter() {
518539
DefaultContentTypeResolver resolver = new DefaultContentTypeResolver();
519540
resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON);

spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SendToMethodReturnValueHandlerTests.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -41,7 +41,7 @@
4141
import org.springframework.messaging.Message;
4242
import org.springframework.messaging.MessageChannel;
4343
import org.springframework.messaging.MessageHeaders;
44-
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
44+
import org.springframework.messaging.converter.JacksonJsonMessageConverter;
4545
import org.springframework.messaging.converter.StringMessageConverter;
4646
import org.springframework.messaging.handler.DestinationPatternsMessageCondition;
4747
import org.springframework.messaging.handler.annotation.SendTo;
@@ -129,7 +129,7 @@ void setup() {
129129
this.handlerAnnotationNotRequired = new SendToMethodReturnValueHandler(messagingTemplate, false);
130130

131131
SimpMessagingTemplate jsonMessagingTemplate = new SimpMessagingTemplate(this.messageChannel);
132-
jsonMessagingTemplate.setMessageConverter(new MappingJackson2MessageConverter());
132+
jsonMessagingTemplate.setMessageConverter(new JacksonJsonMessageConverter());
133133
this.jsonHandler = new SendToMethodReturnValueHandler(jsonMessagingTemplate, true);
134134
}
135135

spring-messaging/src/test/java/org/springframework/messaging/simp/annotation/support/SubscriptionMethodReturnValueHandlerTests.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
import org.springframework.messaging.Message;
3434
import org.springframework.messaging.MessageChannel;
3535
import org.springframework.messaging.MessageHeaders;
36-
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
36+
import org.springframework.messaging.converter.JacksonJsonMessageConverter;
3737
import org.springframework.messaging.converter.StringMessageConverter;
3838
import org.springframework.messaging.core.MessageSendingOperations;
3939
import org.springframework.messaging.handler.annotation.MessageMapping;
@@ -92,7 +92,7 @@ void setup() throws Exception {
9292
this.handler = new SubscriptionMethodReturnValueHandler(messagingTemplate);
9393

9494
SimpMessagingTemplate jsonMessagingTemplate = new SimpMessagingTemplate(this.messageChannel);
95-
jsonMessagingTemplate.setMessageConverter(new MappingJackson2MessageConverter());
95+
jsonMessagingTemplate.setMessageConverter(new JacksonJsonMessageConverter());
9696
this.jsonHandler = new SubscriptionMethodReturnValueHandler(jsonMessagingTemplate);
9797

9898
Method method = this.getClass().getDeclaredMethod("getData");

spring-messaging/src/test/java/org/springframework/messaging/simp/config/MessageBrokerConfigurationTests.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@
4040
import org.springframework.messaging.converter.CompositeMessageConverter;
4141
import org.springframework.messaging.converter.ContentTypeResolver;
4242
import org.springframework.messaging.converter.DefaultContentTypeResolver;
43+
import org.springframework.messaging.converter.JacksonJsonMessageConverter;
4344
import org.springframework.messaging.converter.KotlinSerializationJsonMessageConverter;
44-
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
4545
import org.springframework.messaging.converter.MessageConverter;
4646
import org.springframework.messaging.converter.StringMessageConverter;
4747
import org.springframework.messaging.handler.annotation.MessageMapping;
@@ -288,9 +288,9 @@ void configureMessageConvertersDefault() {
288288

289289
List<MessageConverter> converters = compositeConverter.getConverters();
290290
assertThat(converters).hasExactlyElementsOfTypes(StringMessageConverter.class, ByteArrayMessageConverter.class,
291-
KotlinSerializationJsonMessageConverter.class, MappingJackson2MessageConverter.class);
291+
KotlinSerializationJsonMessageConverter.class, JacksonJsonMessageConverter.class);
292292

293-
ContentTypeResolver resolver = ((MappingJackson2MessageConverter) converters.get(3)).getContentTypeResolver();
293+
ContentTypeResolver resolver = ((JacksonJsonMessageConverter) converters.get(3)).getContentTypeResolver();
294294
assertThat(((DefaultContentTypeResolver) resolver).getDefaultMimeType()).isEqualTo(MimeTypeUtils.APPLICATION_JSON);
295295
}
296296

@@ -349,7 +349,7 @@ protected boolean configureMessageConverters(List<MessageConverter> messageConve
349349
assertThat(iterator.next()).isInstanceOf(StringMessageConverter.class);
350350
assertThat(iterator.next()).isInstanceOf(ByteArrayMessageConverter.class);
351351
assertThat(iterator.next()).isInstanceOf(KotlinSerializationJsonMessageConverter.class);
352-
assertThat(iterator.next()).isInstanceOf(MappingJackson2MessageConverter.class);
352+
assertThat(iterator.next()).isInstanceOf(JacksonJsonMessageConverter.class);
353353
}
354354

355355
@Test

spring-messaging/src/test/java/org/springframework/messaging/simp/user/MultiServerUserRegistryTests.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import org.junit.jupiter.api.Test;
2626

2727
import org.springframework.messaging.Message;
28-
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
28+
import org.springframework.messaging.converter.JacksonJsonMessageConverter;
2929
import org.springframework.messaging.converter.MessageConverter;
3030

3131
import static org.assertj.core.api.Assertions.assertThat;
@@ -43,7 +43,7 @@ class MultiServerUserRegistryTests {
4343

4444
private final MultiServerUserRegistry registry = new MultiServerUserRegistry(this.localRegistry);
4545

46-
private final MessageConverter converter = new MappingJackson2MessageConverter();
46+
private final MessageConverter converter = new JacksonJsonMessageConverter();
4747

4848

4949
@Test

0 commit comments

Comments
 (0)