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;
+ }
+
+}
diff --git a/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonJsonObjectMapper.java b/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonJsonObjectMapper.java
new file mode 100644
index 0000000000..bd3f9f9f02
--- /dev/null
+++ b/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonJsonObjectMapper.java
@@ -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}
+ *
+ * It customizes Jackson's default properties with the following ones:
+ *
+ * - The well-known modules are registered through the classpath scan
+ *
+ *
+ * See {@code tools.jackson.databind.json.JsonMapper.builder} for more information.
+ *
+ * @author Jooyoung Pyoung
+ *
+ * @since 7.0
+ *
+ */
+public class JacksonJsonObjectMapper extends AbstractJacksonJsonObjectMapper {
+
+ 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 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 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 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);
+ }
+
+}
diff --git a/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonPresent.java b/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonPresent.java
index 9f0bd0ffdc..68eb436dc8 100644
--- a/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonPresent.java
+++ b/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonPresent.java
@@ -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() {
}
diff --git a/spring-integration-core/src/main/java/org/springframework/integration/support/json/JsonObjectMapperProvider.java b/spring-integration-core/src/main/java/org/springframework/integration/support/json/JsonObjectMapperProvider.java
index 46c6e95f5f..f56dd118c0 100644
--- a/spring-integration-core/src/main/java/org/springframework/integration/support/json/JsonObjectMapperProvider.java
+++ b/spring-integration-core/src/main/java/org/springframework/integration/support/json/JsonObjectMapperProvider.java
@@ -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();
}
}
diff --git a/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTests.java b/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTests.java
new file mode 100644
index 0000000000..b453add34f
--- /dev/null
+++ b/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTests.java
@@ -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 collectedModuleNames = getModuleNames(collectWellKnownModulesIfAvailable());
+
+ Set autoModuleNames = getModuleNames(mapper.getObjectMapper().getRegisteredModules());
+ assertThat(autoModuleNames).isEqualTo(collectedModuleNames);
+
+ Set 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 stringList = Arrays.asList("a", "b", "c");
+ String json = mapper.toJson(stringList);
+ assertThat(json).isEqualTo("[\"a\",\"b\",\"c\"]");
+
+ @SuppressWarnings("unchecked")
+ List deserialized = mapper.fromJson(json, List.class);
+ assertThat(deserialized).isEqualTo(stringList);
+
+ Set intSet = Set.of(1, 2, 3);
+ String setJson = mapper.toJson(intSet);
+ assertThat(setJson).isNotNull();
+ }
+
+ @Test
+ void testMapTypes() throws IOException {
+ Map 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 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 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 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 getModuleNames(Collection modules) {
+ return modules.stream()
+ .map(JacksonModule::getModuleName)
+ .collect(Collectors.toUnmodifiableSet());
+ }
+
+ private List collectWellKnownModulesIfAvailable() {
+ return List.of(JODA_MODULE, KOTLIN_MODULE);
+ }
+
+ private record TestData(String name, Optional email, Optional age) {
+ }
+
+ private record TimeData(LocalDateTime localDate, ZonedDateTime zoneDate) {
+ }
+
+ private record JodaData(String name, org.joda.time.DateTime jodaDate) {
+ }
+
+}