From 68d4d54ea8d69d211f42ca4d3129f93003daf9a8 Mon Sep 17 00:00:00 2001 From: Jooyoung Pyoung Date: Tue, 24 Jun 2025 06:31:29 +0900 Subject: [PATCH 1/4] GH-10058: Add Jackson 3 ObjectMapper and MessageParser Related to: https://github.com/spring-projects/spring-integration/issues/10058 * Add Jackson3JsonObjectMapper, Jackson3JsonMessageParser to prepare for Jackson 2 to 3 migration. Signed-off-by: Jooyoung Pyoung --- .../json/Jackson3JsonMessageParser.java | 100 +++++++++ .../json/Jackson3JsonObjectMapper.java | 210 ++++++++++++++++++ .../support/json/JacksonPresent.java | 9 + .../json/JsonObjectMapperProvider.java | 4 + 4 files changed, 323 insertions(+) create mode 100644 spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonMessageParser.java create mode 100644 spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonObjectMapper.java diff --git a/spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonMessageParser.java b/spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonMessageParser.java new file mode 100644 index 0000000000..550762361a --- /dev/null +++ b/spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonMessageParser.java @@ -0,0 +1,100 @@ +/* + * 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 Jackson JSON Processor. + * + * @author Jooyoung Pyoung + * + * @since 7.0 + */ +public class Jackson3JsonMessageParser extends AbstractJacksonJsonMessageParser { + private static final JsonFactory JSON_FACTORY = JsonFactory.builder().build(); + + public Jackson3JsonMessageParser() { + this(new Jackson3JsonObjectMapper()); + } + + public Jackson3JsonMessageParser(Jackson3JsonObjectMapper objectMapper) { + super(objectMapper); + } + + @Override + protected JsonParser createJsonParser(String jsonMessage) throws JacksonException { + return JSON_FACTORY.createParser(ObjectReadContext.empty(), jsonMessage); + } + + @Override + protected Message parseWithHeaders(JsonParser parser, String jsonMessage, + @Nullable Map headersToAdd) throws JacksonException { + + String error = AbstractJsonInboundMessageMapper.MESSAGE_FORMAT_ERROR + jsonMessage; + Assert.isTrue(JsonToken.START_OBJECT == parser.nextToken(), error); + Map 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 readHeaders(JsonParser parser, String jsonMessage) throws JacksonException { + Map 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/Jackson3JsonObjectMapper.java b/spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonObjectMapper.java new file mode 100644 index 0000000000..06bb522d30 --- /dev/null +++ b/spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonObjectMapper.java @@ -0,0 +1,210 @@ +/* + * 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.InputStream; +import java.io.Reader; +import java.io.Writer; +import java.lang.reflect.Type; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.JacksonModule; +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; +import org.springframework.util.ClassUtils; + +/** + * 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 Jackson3JsonObjectMapper extends AbstractJacksonJsonObjectMapper { + + private static final boolean JODA_MODULE_PRESENT = + ClassUtils.isPresent("tools.jackson.datatype.joda.JodaModule", null); + + private static final boolean KOTLIN_MODULE_PRESENT = + ClassUtils.isPresent("kotlin.Unit", null) && + ClassUtils.isPresent("tools.jackson.module.kotlin.KotlinModule", null); + + private final ObjectMapper objectMapper; + + public Jackson3JsonObjectMapper() { + List jacksonModules = collectWellKnownModulesIfAvailable(); + this.objectMapper = JsonMapper.builder() + .addModules(jacksonModules) + .build(); + } + + public Jackson3JsonObjectMapper(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 JacksonException { + return this.objectMapper.writeValueAsString(value); + } + + @Override + public void toJson(Object value, Writer writer) throws JacksonException { + this.objectMapper.writeValue(writer, value); + } + + @Override + public JsonNode toJsonNode(Object json) throws JacksonException { + 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 e; + } + // Otherwise the input might not be valid JSON, fallback to TextNode with ObjectMapper.valueToTree() + } + + return this.objectMapper.valueToTree(json); + } + + @Override + protected T fromJson(Object json, JavaType type) throws RuntimeException { + 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()); + } + } + + @Override + public T fromJson(JsonParser parser, Type valueType) throws JacksonException { + return this.objectMapper.readValue(parser, constructType(valueType)); + } + + @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>) classType.getRawClass(), + contentClassType); + } + + JavaType keyClassType = createJavaType(javaTypes, JsonHeaders.KEY_TYPE_ID); + return this.objectMapper.getTypeFactory() + .constructMapType((Class>) classType.getRawClass(), keyClassType, contentClassType); + } + + @Override + protected JavaType constructType(Type type) { + return this.objectMapper.constructType(type); + } + + private List collectWellKnownModulesIfAvailable() { + List modules = new ArrayList<>(); + if (JODA_MODULE_PRESENT) { + modules.add(JodaModuleProvider.MODULE); + } + if (KOTLIN_MODULE_PRESENT) { + modules.add(KotlinModuleProvider.MODULE); + } + return modules; + } + + private static final class JodaModuleProvider { + + static final tools.jackson.databind.JacksonModule MODULE = + new tools.jackson.datatype.joda.JodaModule(); + + } + + private static final class KotlinModuleProvider { + + static final tools.jackson.databind.JacksonModule MODULE = + new tools.jackson.module.kotlin.KotlinModule.Builder().build(); + + } + +} 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..1b82d1d401 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,6 +24,7 @@ * @author Artem Bilan * @author Gary Russell * @author Vikas Prasad + * @author Jooyoung Pyoung * * @since 3.0 * @@ -43,6 +44,9 @@ private JsonObjectMapperProvider() { if (JacksonPresent.isJackson2Present()) { return new Jackson2JsonObjectMapper(); } + else if (JacksonPresent.isJackson3Present()) { + return new Jackson3JsonObjectMapper(); + } else { throw new IllegalStateException("No jackson-databind.jar is present in the classpath."); } From 21ad9f13fecd2f3cd31eff6214e334c5eafd8003 Mon Sep 17 00:00:00 2001 From: Jooyoung Pyoung Date: Thu, 26 Jun 2025 02:07:06 +0900 Subject: [PATCH 2/4] Rename Jackson3 classes and handle exception * Rename `Jackson3JsonObjectMapper` to `JacksonJsonObjectMapper` * Rename `Jackson3JsonMessageParser` to `JacksonJsonMessageParser` * Wrap `JacksonException` in `IOException` to maintain API contract * Replace manual module collection with `findAndAddModules()` Signed-off-by: Jooyoung Pyoung --- ...ser.java => JacksonJsonMessageParser.java} | 13 +- ...pper.java => JacksonJsonObjectMapper.java} | 124 ++++--- .../json/JsonObjectMapperProvider.java | 2 +- .../json/JacksonJsonObjectMapperTest.java | 304 ++++++++++++++++++ 4 files changed, 366 insertions(+), 77 deletions(-) rename spring-integration-core/src/main/java/org/springframework/integration/support/json/{Jackson3JsonMessageParser.java => JacksonJsonMessageParser.java} (88%) rename spring-integration-core/src/main/java/org/springframework/integration/support/json/{Jackson3JsonObjectMapper.java => JacksonJsonObjectMapper.java} (60%) create mode 100644 spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTest.java diff --git a/spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonMessageParser.java b/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonJsonMessageParser.java similarity index 88% rename from spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonMessageParser.java rename to spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonJsonMessageParser.java index 550762361a..aa67634f53 100644 --- a/spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonMessageParser.java +++ b/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonJsonMessageParser.java @@ -38,25 +38,26 @@ * * @since 7.0 */ -public class Jackson3JsonMessageParser extends AbstractJacksonJsonMessageParser { +public class JacksonJsonMessageParser extends AbstractJacksonJsonMessageParser { + private static final JsonFactory JSON_FACTORY = JsonFactory.builder().build(); - public Jackson3JsonMessageParser() { - this(new Jackson3JsonObjectMapper()); + public JacksonJsonMessageParser() { + this(new JacksonJsonObjectMapper()); } - public Jackson3JsonMessageParser(Jackson3JsonObjectMapper objectMapper) { + public JacksonJsonMessageParser(JacksonJsonObjectMapper objectMapper) { super(objectMapper); } @Override - protected JsonParser createJsonParser(String jsonMessage) throws JacksonException { + protected JsonParser createJsonParser(String jsonMessage) { return JSON_FACTORY.createParser(ObjectReadContext.empty(), jsonMessage); } @Override protected Message parseWithHeaders(JsonParser parser, String jsonMessage, - @Nullable Map headersToAdd) throws JacksonException { + @Nullable Map headersToAdd) { String error = AbstractJsonInboundMessageMapper.MESSAGE_FORMAT_ERROR + jsonMessage; Assert.isTrue(JsonToken.START_OBJECT == parser.nextToken(), error); diff --git a/spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonObjectMapper.java b/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonJsonObjectMapper.java similarity index 60% rename from spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonObjectMapper.java rename to spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonJsonObjectMapper.java index 06bb522d30..46801fad86 100644 --- a/spring-integration-core/src/main/java/org/springframework/integration/support/json/Jackson3JsonObjectMapper.java +++ b/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonJsonObjectMapper.java @@ -17,19 +17,17 @@ 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.ArrayList; import java.util.Collection; -import java.util.List; import java.util.Map; import tools.jackson.core.JacksonException; import tools.jackson.core.JsonParser; -import tools.jackson.databind.JacksonModule; import tools.jackson.databind.JavaType; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @@ -37,7 +35,6 @@ import org.springframework.integration.mapping.support.JsonHeaders; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; /** * Jackson 3 JSON-processor (@link https://github.com/FasterXML) @@ -57,25 +54,17 @@ * @since 7.0 * */ -public class Jackson3JsonObjectMapper extends AbstractJacksonJsonObjectMapper { - - private static final boolean JODA_MODULE_PRESENT = - ClassUtils.isPresent("tools.jackson.datatype.joda.JodaModule", null); - - private static final boolean KOTLIN_MODULE_PRESENT = - ClassUtils.isPresent("kotlin.Unit", null) && - ClassUtils.isPresent("tools.jackson.module.kotlin.KotlinModule", null); +public class JacksonJsonObjectMapper extends AbstractJacksonJsonObjectMapper { private final ObjectMapper objectMapper; - public Jackson3JsonObjectMapper() { - List jacksonModules = collectWellKnownModulesIfAvailable(); + public JacksonJsonObjectMapper() { this.objectMapper = JsonMapper.builder() - .addModules(jacksonModules) + .findAndAddModules(JacksonJsonObjectMapper.class.getClassLoader()) .build(); } - public Jackson3JsonObjectMapper(ObjectMapper objectMapper) { + public JacksonJsonObjectMapper(ObjectMapper objectMapper) { Assert.notNull(objectMapper, "objectMapper must not be null"); this.objectMapper = objectMapper; } @@ -85,17 +74,27 @@ public ObjectMapper getObjectMapper() { } @Override - public String toJson(Object value) throws JacksonException { - return this.objectMapper.writeValueAsString(value); + 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 JacksonException { - this.objectMapper.writeValue(writer, value); + 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 JacksonException { + public JsonNode toJsonNode(Object json) throws IOException { try { if (json instanceof String) { return this.objectMapper.readTree((String) json); @@ -118,7 +117,7 @@ else if (json instanceof Reader) { } catch (JacksonException e) { if (!(json instanceof String) && !(json instanceof byte[])) { - throw e; + throw new IOException(e); } // Otherwise the input might not be valid JSON, fallback to TextNode with ObjectMapper.valueToTree() } @@ -127,34 +126,44 @@ else if (json instanceof Reader) { } @Override - protected T fromJson(Object json, JavaType type) throws RuntimeException { - 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); + 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()); + } } - 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 JacksonException { - return this.objectMapper.readValue(parser, constructType(valueType)); + 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 @@ -182,29 +191,4 @@ protected JavaType constructType(Type type) { return this.objectMapper.constructType(type); } - private List collectWellKnownModulesIfAvailable() { - List modules = new ArrayList<>(); - if (JODA_MODULE_PRESENT) { - modules.add(JodaModuleProvider.MODULE); - } - if (KOTLIN_MODULE_PRESENT) { - modules.add(KotlinModuleProvider.MODULE); - } - return modules; - } - - private static final class JodaModuleProvider { - - static final tools.jackson.databind.JacksonModule MODULE = - new tools.jackson.datatype.joda.JodaModule(); - - } - - private static final class KotlinModuleProvider { - - static final tools.jackson.databind.JacksonModule MODULE = - new tools.jackson.module.kotlin.KotlinModule.Builder().build(); - - } - } 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 1b82d1d401..750e70276e 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 @@ -45,7 +45,7 @@ private JsonObjectMapperProvider() { return new Jackson2JsonObjectMapper(); } else if (JacksonPresent.isJackson3Present()) { - return new Jackson3JsonObjectMapper(); + return new JacksonJsonObjectMapper(); } else { throw new IllegalStateException("No jackson-databind.jar is present in the classpath."); diff --git a/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTest.java b/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTest.java new file mode 100644 index 0000000000..64bb280276 --- /dev/null +++ b/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTest.java @@ -0,0 +1,304 @@ +/* + * 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.ArrayList; +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 org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * @author Jooyoung Pyoung + * + * @since 7.0 + */ +class JacksonJsonObjectMapperTest { + + private static final boolean JODA_MODULE_PRESENT = + ClassUtils.isPresent("tools.jackson.datatype.joda.JodaModule", null); + + private static final boolean KOTLIN_MODULE_PRESENT = + ClassUtils.isPresent("kotlin.Unit", null) && + ClassUtils.isPresent("tools.jackson.module.kotlin.KotlinModule", null); + + 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(JodaModuleProvider.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(JodaModuleProvider.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 static final class JodaModuleProvider { + + private static final JacksonModule MODULE = new JodaModule(); + + } + + private static final class KotlinModuleProvider { + + private static final JacksonModule MODULE = new KotlinModule.Builder().build(); + + } + + private List collectWellKnownModulesIfAvailable() { + List modules = new ArrayList<>(); + if (JODA_MODULE_PRESENT) { + modules.add(JodaModuleProvider.MODULE); + } + if (KOTLIN_MODULE_PRESENT) { + modules.add(KotlinModuleProvider.MODULE); + } + return modules; + } + + 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) { + } + +} From 347eea960645dfb4ff03ba48f4086d6084b38f66 Mon Sep 17 00:00:00 2001 From: Jooyoung Pyoung Date: Thu, 26 Jun 2025 02:48:00 +0900 Subject: [PATCH 3/4] Remove comment from test Signed-off-by: Jooyoung Pyoung --- .../integration/support/json/JacksonJsonObjectMapperTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTest.java b/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTest.java index 64bb280276..bc7b99c3f2 100644 --- a/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTest.java +++ b/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTest.java @@ -221,7 +221,6 @@ public void testJavaTime() throws Exception { 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()); From ad33a822ccb19f038388dae0278c37da4cd45e9f Mon Sep 17 00:00:00 2001 From: Jooyoung Pyoung Date: Sun, 29 Jun 2025 23:42:25 +0900 Subject: [PATCH 4/4] Apply code review feedback * Add try-catch for valueToTree() call * Remove conditional module checks in tests * Update jsonAvailable() to check Jackson 3 Signed-off-by: Jooyoung Pyoung --- .../support/json/JacksonJsonObjectMapper.java | 9 ++++- .../json/JsonObjectMapperProvider.java | 3 +- ...java => JacksonJsonObjectMapperTests.java} | 37 +++---------------- 3 files changed, 15 insertions(+), 34 deletions(-) rename spring-integration-core/src/test/java/org/springframework/integration/support/json/{JacksonJsonObjectMapperTest.java => JacksonJsonObjectMapperTests.java} (89%) 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 index 46801fad86..bd3f9f9f02 100644 --- 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 @@ -89,7 +89,7 @@ public void toJson(Object value, Writer writer) throws IOException { this.objectMapper.writeValue(writer, value); } catch (JacksonException e) { - throw new IOException(e); + throw new IOException(e); } } @@ -122,7 +122,12 @@ else if (json instanceof Reader) { // Otherwise the input might not be valid JSON, fallback to TextNode with ObjectMapper.valueToTree() } - return this.objectMapper.valueToTree(json); + try { + return this.objectMapper.valueToTree(json); + } + catch (JacksonException e) { + throw new IOException(e); + } } @Override 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 750e70276e..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 @@ -28,6 +28,7 @@ * * @since 3.0 * + * @see JacksonJsonObjectMapper * @see Jackson2JsonObjectMapper */ public final class JsonObjectMapperProvider { @@ -58,7 +59,7 @@ else if (JacksonPresent.isJackson3Present()) { * @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/JacksonJsonObjectMapperTest.java b/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTests.java similarity index 89% rename from spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTest.java rename to spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTests.java index bc7b99c3f2..b453add34f 100644 --- a/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTest.java +++ b/spring-integration-core/src/test/java/org/springframework/integration/support/json/JacksonJsonObjectMapperTests.java @@ -30,7 +30,6 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Date; @@ -53,8 +52,6 @@ import tools.jackson.datatype.joda.JodaModule; import tools.jackson.module.kotlin.KotlinModule; -import org.springframework.util.ClassUtils; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -63,14 +60,11 @@ * * @since 7.0 */ -class JacksonJsonObjectMapperTest { +class JacksonJsonObjectMapperTests { - private static final boolean JODA_MODULE_PRESENT = - ClassUtils.isPresent("tools.jackson.datatype.joda.JodaModule", null); + private static final JacksonModule JODA_MODULE = new JodaModule(); - private static final boolean KOTLIN_MODULE_PRESENT = - ClassUtils.isPresent("kotlin.Unit", null) && - ClassUtils.isPresent("tools.jackson.module.kotlin.KotlinModule", null); + private static final JacksonModule KOTLIN_MODULE = new KotlinModule.Builder().build(); private JacksonJsonObjectMapper mapper; @@ -230,7 +224,7 @@ public void testJavaTime() throws Exception { public void testJodaWithJodaModule() throws Exception { ObjectMapper objectMapper = mapper.getObjectMapper(); Set registeredModules = getModuleNames(objectMapper.getRegisteredModules()); - assertThat(registeredModules.contains(JodaModuleProvider.MODULE.getModuleName())).isTrue(); + 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); @@ -249,7 +243,7 @@ public void testJodaWithoutJodaModule() { JacksonJsonObjectMapper mapper = new JacksonJsonObjectMapper(customMapper); Set registeredModules = getModuleNames(mapper.getObjectMapper().getRegisteredModules()); - assertThat(registeredModules.contains(JodaModuleProvider.MODULE.getModuleName())).isFalse(); + 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); @@ -268,27 +262,8 @@ private Set getModuleNames(Collection modules) { .collect(Collectors.toUnmodifiableSet()); } - private static final class JodaModuleProvider { - - private static final JacksonModule MODULE = new JodaModule(); - - } - - private static final class KotlinModuleProvider { - - private static final JacksonModule MODULE = new KotlinModule.Builder().build(); - - } - private List collectWellKnownModulesIfAvailable() { - List modules = new ArrayList<>(); - if (JODA_MODULE_PRESENT) { - modules.add(JodaModuleProvider.MODULE); - } - if (KOTLIN_MODULE_PRESENT) { - modules.add(KotlinModuleProvider.MODULE); - } - return modules; + return List.of(JODA_MODULE, KOTLIN_MODULE); } private record TestData(String name, Optional email, Optional age) {