Skip to content

GH-10058: Add Jackson 3 ObjectMapper and MessageParser #10160

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 30, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright 2025-present 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.integration.support.json;

import java.util.LinkedHashMap;
import java.util.Map;

import org.jspecify.annotations.Nullable;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.core.ObjectReadContext;
import tools.jackson.core.json.JsonFactory;

import org.springframework.messaging.Message;
import org.springframework.util.Assert;

/**
* {@link JsonInboundMessageMapper.JsonMessageParser} implementation that parses JSON messages
* and builds a {@link Message} with the specified payload type from provided {@link JsonInboundMessageMapper}.
* Uses <a href="https://github.com/FasterXML">Jackson JSON Processor</a>.
*
* @author Jooyoung Pyoung
*
* @since 7.0
*/
public class JacksonJsonMessageParser extends AbstractJacksonJsonMessageParser<JsonParser> {

private static final JsonFactory JSON_FACTORY = JsonFactory.builder().build();

public JacksonJsonMessageParser() {
this(new JacksonJsonObjectMapper());
}

public JacksonJsonMessageParser(JacksonJsonObjectMapper objectMapper) {
super(objectMapper);
}

@Override
protected JsonParser createJsonParser(String jsonMessage) {
return JSON_FACTORY.createParser(ObjectReadContext.empty(), jsonMessage);
}

@Override
protected Message<?> parseWithHeaders(JsonParser parser, String jsonMessage,
@Nullable Map<String, Object> headersToAdd) {

String error = AbstractJsonInboundMessageMapper.MESSAGE_FORMAT_ERROR + jsonMessage;
Assert.isTrue(JsonToken.START_OBJECT == parser.nextToken(), error);
Map<String, Object> headers = null;
Object payload = null;
while (JsonToken.END_OBJECT != parser.nextToken()) {
Assert.isTrue(JsonToken.PROPERTY_NAME == parser.currentToken(), error);
String currentName = parser.currentName();
boolean isHeadersToken = "headers".equals(currentName);
boolean isPayloadToken = "payload".equals(currentName);
Assert.isTrue(isHeadersToken || isPayloadToken, error);
if (isHeadersToken) {
Assert.isTrue(parser.nextToken() == JsonToken.START_OBJECT, error);
headers = readHeaders(parser, jsonMessage);
}
else {
parser.nextToken();
payload = readPayload(parser, jsonMessage);
}
}
Assert.notNull(headers, error);

return getMessageBuilderFactory()
.withPayload(payload)
.copyHeaders(headers)
.copyHeadersIfAbsent(headersToAdd)
.build();
}

private Map<String, Object> readHeaders(JsonParser parser, String jsonMessage) throws JacksonException {
Map<String, Object> headers = new LinkedHashMap<>();
while (JsonToken.END_OBJECT != parser.nextToken()) {
String headerName = parser.currentName();
parser.nextToken();
Object headerValue = readHeader(parser, headerName, jsonMessage);
headers.put(headerName, headerValue);
}
return headers;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/*
* Copyright 2025-present 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.integration.support.json;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Type;
import java.net.URL;
import java.util.Collection;
import java.util.Map;

import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.JavaType;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;

import org.springframework.integration.mapping.support.JsonHeaders;
import org.springframework.util.Assert;

/**
* Jackson 3 JSON-processor (@link https://github.com/FasterXML)
* {@linkplain JsonObjectMapper} implementation.
* Delegates {@link #toJson} and {@link #fromJson}
* to the {@linkplain ObjectMapper}
* <p>
* It customizes Jackson's default properties with the following ones:
* <ul>
* <li>The well-known modules are registered through the classpath scan</li>
* </ul>
*
* See {@code tools.jackson.databind.json.JsonMapper.builder} for more information.
*
* @author Jooyoung Pyoung
*
* @since 7.0
*
*/
public class JacksonJsonObjectMapper extends AbstractJacksonJsonObjectMapper<JsonNode, JsonParser, JavaType> {

private final ObjectMapper objectMapper;

public JacksonJsonObjectMapper() {
this.objectMapper = JsonMapper.builder()
.findAndAddModules(JacksonJsonObjectMapper.class.getClassLoader())
.build();
}

public JacksonJsonObjectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "objectMapper must not be null");
this.objectMapper = objectMapper;
}

public ObjectMapper getObjectMapper() {
return this.objectMapper;
}

@Override
public String toJson(Object value) throws IOException {
try {
return this.objectMapper.writeValueAsString(value);
}
catch (JacksonException e) {
throw new IOException(e);
}
}

@Override
public void toJson(Object value, Writer writer) throws IOException {
try {
this.objectMapper.writeValue(writer, value);
}
catch (JacksonException e) {
throw new IOException(e);
}
}

@Override
public JsonNode toJsonNode(Object json) throws IOException {
try {
if (json instanceof String) {
return this.objectMapper.readTree((String) json);
}
else if (json instanceof byte[]) {
return this.objectMapper.readTree((byte[]) json);
}
else if (json instanceof File) {
return this.objectMapper.readTree((File) json);
}
else if (json instanceof URL) {
return this.objectMapper.readTree((URL) json);
}
else if (json instanceof InputStream) {
return this.objectMapper.readTree((InputStream) json);
}
else if (json instanceof Reader) {
return this.objectMapper.readTree((Reader) json);
}
}
catch (JacksonException e) {
if (!(json instanceof String) && !(json instanceof byte[])) {
throw new IOException(e);
}
// Otherwise the input might not be valid JSON, fallback to TextNode with ObjectMapper.valueToTree()
}

try {
return this.objectMapper.valueToTree(json);
}
catch (JacksonException e) {
throw new IOException(e);
}
}

@Override
protected <T> T fromJson(Object json, JavaType type) throws IOException {
try {
if (json instanceof String) {
return this.objectMapper.readValue((String) json, type);
}
else if (json instanceof byte[]) {
return this.objectMapper.readValue((byte[]) json, type);
}
else if (json instanceof File) {
return this.objectMapper.readValue((File) json, type);
}
else if (json instanceof URL) {
return this.objectMapper.readValue((URL) json, type);
}
else if (json instanceof InputStream) {
return this.objectMapper.readValue((InputStream) json, type);
}
else if (json instanceof Reader) {
return this.objectMapper.readValue((Reader) json, type);
}
else {
throw new IllegalArgumentException("'json' argument must be an instance of: " + SUPPORTED_JSON_TYPES
+ " , but gotten: " + json.getClass());
}
}
catch (JacksonException e) {
throw new IOException(e);
}
}

@Override
public <T> T fromJson(JsonParser parser, Type valueType) throws IOException {
try {
return this.objectMapper.readValue(parser, constructType(valueType));
}
catch (JacksonException e) {
throw new IOException(e);
}
}

@Override
@SuppressWarnings({"unchecked"})
protected JavaType extractJavaType(Map<String, Object> javaTypes) {
JavaType classType = this.createJavaType(javaTypes, JsonHeaders.TYPE_ID);
if (!classType.isContainerType() || classType.isArrayType()) {
return classType;
}

JavaType contentClassType = this.createJavaType(javaTypes, JsonHeaders.CONTENT_TYPE_ID);
if (classType.getKeyType() == null) {
return this.objectMapper.getTypeFactory()
.constructCollectionType((Class<? extends Collection<?>>) classType.getRawClass(),
contentClassType);
}

JavaType keyClassType = createJavaType(javaTypes, JsonHeaders.KEY_TYPE_ID);
return this.objectMapper.getTypeFactory()
.constructMapType((Class<? extends Map<?, ?>>) classType.getRawClass(), keyClassType, contentClassType);
}

@Override
protected JavaType constructType(Type type) {
return this.objectMapper.constructType(type);
}

}
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@
* The utility to check if Jackson JSON processor is present in the classpath.
*
* @author Artem Bilan
* @author Jooyoung Pyoung
*
* @since 4.3.10
*/
@@ -31,10 +32,18 @@ public final class JacksonPresent {
ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", null) &&
ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", null);

private static final boolean JACKSON_3_PRESENT =
ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", null) &&
ClassUtils.isPresent("tools.jackson.core.JsonGenerator", null);

public static boolean isJackson2Present() {
return JACKSON_2_PRESENT;
}

public static boolean isJackson3Present() {
return JACKSON_3_PRESENT;
}

private JacksonPresent() {
}

Original file line number Diff line number Diff line change
@@ -24,9 +24,11 @@
* @author Artem Bilan
* @author Gary Russell
* @author Vikas Prasad
* @author Jooyoung Pyoung
*
* @since 3.0
*
* @see JacksonJsonObjectMapper
* @see Jackson2JsonObjectMapper
*/
public final class JsonObjectMapperProvider {
@@ -43,6 +45,9 @@ private JsonObjectMapperProvider() {
if (JacksonPresent.isJackson2Present()) {
return new Jackson2JsonObjectMapper();
}
else if (JacksonPresent.isJackson3Present()) {
return new JacksonJsonObjectMapper();
}
else {
throw new IllegalStateException("No jackson-databind.jar is present in the classpath.");
}
@@ -54,7 +59,7 @@ private JsonObjectMapperProvider() {
* @since 4.2.7
*/
public static boolean jsonAvailable() {
return JacksonPresent.isJackson2Present();
return JacksonPresent.isJackson3Present() || JacksonPresent.isJackson2Present();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/*
* Copyright 2025-present 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.integration.support.json;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import tools.jackson.databind.JacksonModule;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.datatype.joda.JodaModule;
import tools.jackson.module.kotlin.KotlinModule;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
* @author Jooyoung Pyoung
*
* @since 7.0
*/
class JacksonJsonObjectMapperTests {

private static final JacksonModule JODA_MODULE = new JodaModule();

private static final JacksonModule KOTLIN_MODULE = new KotlinModule.Builder().build();

private JacksonJsonObjectMapper mapper;

@BeforeEach
void setUp() {
mapper = new JacksonJsonObjectMapper();
}

@Test
void compareAutoDiscoveryVsManualModules() {
ObjectMapper manualMapper = JsonMapper.builder()
.addModules(collectWellKnownModulesIfAvailable())
.build();

Set<String> collectedModuleNames = getModuleNames(collectWellKnownModulesIfAvailable());

Set<String> autoModuleNames = getModuleNames(mapper.getObjectMapper().getRegisteredModules());
assertThat(autoModuleNames).isEqualTo(collectedModuleNames);

Set<String> manualModuleNames = getModuleNames(manualMapper.getRegisteredModules());
assertThat(manualModuleNames).isEqualTo(collectedModuleNames);
}

@Test
void testToJsonNodeWithVariousInputTypes() throws IOException {
String jsonString = "{\"name\":\"test\",\"value\":123}";
JsonNode nodeFromString = mapper.toJsonNode(jsonString);
assertThat(nodeFromString.get("name").asString()).isEqualTo("test");
assertThat(nodeFromString.get("value").asInt()).isEqualTo(123);

byte[] jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8);
JsonNode nodeFromBytes = mapper.toJsonNode(jsonBytes);
assertThat(nodeFromBytes).isEqualTo(nodeFromString);

try (InputStream inputStream = new ByteArrayInputStream(jsonBytes)) {
JsonNode nodeFromInputStream = mapper.toJsonNode(inputStream);
assertThat(nodeFromInputStream).isEqualTo(nodeFromString);
}

try (Reader reader = new StringReader(jsonString)) {
JsonNode nodeFromReader = mapper.toJsonNode(reader);
assertThat(nodeFromReader).isEqualTo(nodeFromString);
}
}

@Test
void testToJsonNodeWithFile() throws IOException {
Path tempFile = Files.createTempFile("test", ".json");
String jsonContent = "{\"message\":\"hello from file\",\"number\":42}";
Files.write(tempFile, jsonContent.getBytes(StandardCharsets.UTF_8));

try {
File file = tempFile.toFile();
JsonNode nodeFromFile = mapper.toJsonNode(file);
assertThat(nodeFromFile.get("message").asString()).isEqualTo("hello from file");
assertThat(nodeFromFile.get("number").asInt()).isEqualTo(42);

URL fileUrl = file.toURI().toURL();
JsonNode nodeFromUrl = mapper.toJsonNode(fileUrl);
assertThat(nodeFromUrl).isEqualTo(nodeFromFile);
}
finally {
Files.deleteIfExists(tempFile);
}
}

@Test
void testToJsonWithWriter() throws IOException {
TestData data = new TestData("John", Optional.of("john@test.com"), Optional.empty());

try (StringWriter writer = new StringWriter()) {
mapper.toJson(data, writer);
String json = writer.toString();
assertThat(json).isEqualTo("{\"name\":\"John\",\"email\":\"john@test.com\",\"age\":null}");
}
}

@Test
void testFromJsonWithUnsupportedType() {
Object unsupportedInput = new Date();

assertThatThrownBy(() -> mapper.fromJson(unsupportedInput, String.class))
.isInstanceOf(IllegalArgumentException.class);
}

@ParameterizedTest
@ValueSource(strings = {"42", "true", "\"hello\"", "3.14", "null"})
void testPrimitiveTypes(String jsonValue) throws IOException {
JsonNode node = mapper.toJsonNode(jsonValue);
assertThat(node).isNotNull();

String serialized = mapper.toJson(node);
JsonNode roundTrip = mapper.toJsonNode(serialized);
assertThat(roundTrip).isEqualTo(node);
}

@Test
void testCollectionTypes() throws IOException {
List<String> stringList = Arrays.asList("a", "b", "c");
String json = mapper.toJson(stringList);
assertThat(json).isEqualTo("[\"a\",\"b\",\"c\"]");

@SuppressWarnings("unchecked")
List<String> deserialized = mapper.fromJson(json, List.class);
assertThat(deserialized).isEqualTo(stringList);

Set<Integer> intSet = Set.of(1, 2, 3);
String setJson = mapper.toJson(intSet);
assertThat(setJson).isNotNull();
}

@Test
void testMapTypes() throws IOException {
Map<String, Object> map = Map.of(
"string", "value",
"number", 42,
"boolean", true,
"nested", Map.of("inner", "value")
);

String json = mapper.toJson(map);
assertThat(json).isNotNull();

@SuppressWarnings("unchecked")
Map<String, Object> deserialized = mapper.fromJson(json, Map.class);
assertThat(deserialized.get("string")).isEqualTo("value");
assertThat(deserialized.get("number")).isEqualTo(42);
assertThat(deserialized.get("boolean")).isEqualTo(true);
}

@Test
public void testOptional() throws IOException {
TestData data = new TestData("John", Optional.of("john@test.com"), Optional.empty());

String json = mapper.toJson(data);
assertThat(json).isEqualTo("{\"name\":\"John\",\"email\":\"john@test.com\",\"age\":null}");

TestData deserialized = mapper.fromJson(json, TestData.class);
assertThat(deserialized).isEqualTo(data);
}

@Test
public void testJavaTime() throws Exception {
LocalDateTime localDateTime = LocalDateTime.of(2000, 1, 1, 0, 0);
ZonedDateTime zonedDateTime = ZonedDateTime.of(localDateTime, ZoneId.of("UTC"));
TimeData data = new TimeData(localDateTime, zonedDateTime);

String json = mapper.toJson(data);
assertThat("{\"localDate\":\"2000-01-01T00:00:00\",\"zoneDate\":\"2000-01-01T00:00:00Z\"}").isEqualTo(json);

TimeData deserialized = mapper.fromJson(json, TimeData.class);
assertThat(deserialized.localDate()).isEqualTo(data.localDate());
assertThat(deserialized.zoneDate().toInstant()).isEqualTo(data.zoneDate().toInstant());
}

@Test
public void testJodaWithJodaModule() throws Exception {
ObjectMapper objectMapper = mapper.getObjectMapper();
Set<String> registeredModules = getModuleNames(objectMapper.getRegisteredModules());
assertThat(registeredModules.contains(JODA_MODULE.getModuleName())).isTrue();

org.joda.time.DateTime jodaDateTime = new DateTime(2000, 1, 1, 0, 0, DateTimeZone.UTC);
JodaData data = new JodaData("John", jodaDateTime);

String json = mapper.toJson(data);
assertThat("{\"name\":\"John\",\"jodaDate\":\"2000-01-01T00:00:00.000Z\"}").isEqualTo(json);

JodaData deserialized = mapper.fromJson(json, JodaData.class);
assertThat(deserialized.name()).isEqualTo(data.name());
assertThat(deserialized.jodaDate()).isEqualTo(data.jodaDate());
}

@Test
public void testJodaWithoutJodaModule() {
ObjectMapper customMapper = JsonMapper.builder().build();
JacksonJsonObjectMapper mapper = new JacksonJsonObjectMapper(customMapper);

Set<String> registeredModules = getModuleNames(mapper.getObjectMapper().getRegisteredModules());
assertThat(registeredModules.contains(JODA_MODULE.getModuleName())).isFalse();

org.joda.time.DateTime jodaDateTime = new DateTime(2000, 1, 1, 0, 0, DateTimeZone.UTC);
JodaData data = new JodaData("John", jodaDateTime);

assertThatThrownBy(() -> mapper.toJson(data))
.isInstanceOf(IOException.class);

String json = "{\"name\":\"John\",\"jodaDate\":\"2000-01-01T00:00:00.000Z\"}";
assertThatThrownBy(() -> mapper.fromJson(json, JodaData.class))
.isInstanceOf(IOException.class);
}

private Set<String> getModuleNames(Collection<JacksonModule> modules) {
return modules.stream()
.map(JacksonModule::getModuleName)
.collect(Collectors.toUnmodifiableSet());
}

private List<JacksonModule> collectWellKnownModulesIfAvailable() {
return List.of(JODA_MODULE, KOTLIN_MODULE);
}

private record TestData(String name, Optional<String> email, Optional<Integer> age) {
}

private record TimeData(LocalDateTime localDate, ZonedDateTime zoneDate) {
}

private record JodaData(String name, org.joda.time.DateTime jodaDate) {
}

}