diff --git a/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonJsonMessageParser.java b/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonJsonMessageParser.java new file mode 100644 index 0000000000..aa67634f53 --- /dev/null +++ b/spring-integration-core/src/main/java/org/springframework/integration/support/json/JacksonJsonMessageParser.java @@ -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 Jackson JSON Processor. + * + * @author Jooyoung Pyoung + * + * @since 7.0 + */ +public class JacksonJsonMessageParser extends AbstractJacksonJsonMessageParser { + + 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 headersToAdd) { + + 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/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>) 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); + } + +} 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) { + } + +}