diff --git a/auto-configurations/common/spring-ai-autoconfigure-json-parser/pom.xml b/auto-configurations/common/spring-ai-autoconfigure-json-parser/pom.xml new file mode 100644 index 00000000000..4b91727edda --- /dev/null +++ b/auto-configurations/common/spring-ai-autoconfigure-json-parser/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 1.1.0-SNAPSHOT + ../../../pom.xml + + spring-ai-autoconfigure-json-parser + jar + Spring AI JsonParser Auto Configuration + Spring AI JsonParser Auto Configuration + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.ai + spring-ai-model + ${project.parent.version} + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.springframework.ai + spring-ai-test + ${project.parent.version} + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.mockito + mockito-core + test + + + + diff --git a/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/main/java/org/springframework/ai/util/json/autoconfigure/JsonParserObjectMapperAutoConfiguration.java b/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/main/java/org/springframework/ai/util/json/autoconfigure/JsonParserObjectMapperAutoConfiguration.java new file mode 100644 index 00000000000..ca8f719e28f --- /dev/null +++ b/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/main/java/org/springframework/ai/util/json/autoconfigure/JsonParserObjectMapperAutoConfiguration.java @@ -0,0 +1,193 @@ +/* + * Copyright 2023-2025 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.ai.util.json.autoconfigure; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.cfg.CoercionAction; +import com.fasterxml.jackson.databind.cfg.CoercionInputShape; +import com.fasterxml.jackson.databind.json.JsonMapper; + +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.util.JacksonUtils; +import org.springframework.ai.util.json.JsonParser; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * Auto-configuration for JsonParser and ModelOptionsUtils ObjectMapper. + *

+ * Provides customizable ObjectMappers for JSON parsing operations in tool calling, + * structured output, and model options handling. Users can override these beans to + * customize Jackson behavior. + * + * @author Daniel Albuquerque + */ +@AutoConfiguration +@ConditionalOnClass(ObjectMapper.class) +@EnableConfigurationProperties(JsonParserProperties.class) +public class JsonParserObjectMapperAutoConfiguration { + + /** + * Creates a configured ObjectMapper for JsonParser operations. + *

+ * This ObjectMapper is configured with: + *

+ * + * To customize, provide your own bean:
{@code
+	 * @Bean
+	 * public ObjectMapper jsonParserObjectMapper() {
+	 *     return JsonMapper.builder()
+	 *         .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS)
+	 *         .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
+	 *         .build();
+	 * }
+	 * }
+ * @param properties the JsonParser configuration properties + * @return the configured ObjectMapper + */ + @Bean(name = "jsonParserObjectMapper", defaultCandidate = false) + @ConditionalOnMissingBean(name = "jsonParserObjectMapper") + public ObjectMapper jsonParserObjectMapper(JsonParserProperties properties) { + JsonMapper.Builder builder = JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .addModules(JacksonUtils.instantiateAvailableModules()); + + // Apply properties + if (properties.isAllowUnescapedControlChars()) { + builder.enable(com.fasterxml.jackson.core.json.JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS); + } + + if (!properties.isWriteDatesAsTimestamps()) { + builder.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } + + return builder.build(); + } + + /** + * Creates a configured ObjectMapper for ModelOptionsUtils operations. + *

+ * This ObjectMapper is configured with: + *

+ * + * To customize, provide your own bean:
{@code
+	 * @Bean
+	 * public ObjectMapper modelOptionsObjectMapper() {
+	 *     ObjectMapper mapper = JsonMapper.builder()
+	 *         .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
+	 *         .addModules(JacksonUtils.instantiateAvailableModules())
+	 *         .build()
+	 *         .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true);
+	 *
+	 *     mapper.coercionConfigFor(Enum.class)
+	 *         .setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);
+	 *
+	 *     return mapper;
+	 * }
+	 * }
+ * @param properties the configuration properties + * @return the configured ObjectMapper + */ + @Bean(name = "modelOptionsObjectMapper", defaultCandidate = false) + @ConditionalOnMissingBean(name = "modelOptionsObjectMapper") + public ObjectMapper modelOptionsObjectMapper(JsonParserProperties properties) { + JsonMapper.Builder builder = JsonMapper.builder(); + + // Apply base configuration + if (properties.isFailOnUnknownProperties()) { + builder.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + } + else { + builder.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + } + + if (properties.isFailOnEmptyBeans()) { + builder.enable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + } + else { + builder.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); + } + + builder.addModules(JacksonUtils.instantiateAvailableModules()); + + ObjectMapper mapper = builder.build(); + + // Configure empty string handling + if (properties.isAcceptEmptyStringAsNull()) { + mapper.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true); + } + + // Configure enum coercion (critical for API compatibility) + if (properties.isCoerceEmptyEnumStrings()) { + mapper.coercionConfigFor(Enum.class).setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull); + } + + return mapper; + } + + /** + * Creates a JsonParser bean configured with the custom ObjectMapper. This also sets + * the static configured mapper for backward compatibility with code using static + * methods. + * @param objectMapper the configured ObjectMapper + * @return the JsonParser instance + */ + @Bean + @ConditionalOnMissingBean + public JsonParser jsonParser(@Qualifier("jsonParserObjectMapper") ObjectMapper objectMapper) { + // Set the static mapper for backward compatibility + JsonParser.setConfiguredObjectMapper(objectMapper); + + // Create bean instance + return new JsonParser(objectMapper); + } + + /** + * Initializes ModelOptionsUtils with the Spring-managed ObjectMapper. This setter + * allows ModelOptionsUtils static methods to use the Spring-configured mapper while + * maintaining backward compatibility. + * @param objectMapper the configured ObjectMapper for model options + */ + @Bean + @ConditionalOnMissingBean(name = "modelOptionsUtilsInitializer") + public Object modelOptionsUtilsInitializer(@Qualifier("modelOptionsObjectMapper") ObjectMapper objectMapper) { + // Set the static mapper for backward compatibility + ModelOptionsUtils.setConfiguredObjectMapper(objectMapper); + + // Return a marker object to satisfy bean contract + return new Object(); + } + +} diff --git a/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/main/java/org/springframework/ai/util/json/autoconfigure/JsonParserProperties.java b/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/main/java/org/springframework/ai/util/json/autoconfigure/JsonParserProperties.java new file mode 100644 index 00000000000..8ee7cfa122f --- /dev/null +++ b/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/main/java/org/springframework/ai/util/json/autoconfigure/JsonParserProperties.java @@ -0,0 +1,115 @@ +/* + * Copyright 2023-2025 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.ai.util.json.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for JsonParser and ModelOptionsUtils ObjectMapper. + * + * @author Daniel Albuquerque + */ +@ConfigurationProperties(prefix = JsonParserProperties.CONFIG_PREFIX) +public class JsonParserProperties { + + public static final String CONFIG_PREFIX = "spring.ai.json"; + + /** + * Allow unescaped control characters (like \n) in JSON strings. Useful when LLMs + * generate JSON with literal newlines. + */ + private boolean allowUnescapedControlChars = false; + + /** + * Write dates as ISO-8601 strings instead of timestamp arrays. When false (default), + * dates are written as strings like "2025-07-03". + */ + private boolean writeDatesAsTimestamps = false; + + /** + * Accept empty strings as null objects during deserialization. Used by + * ModelOptionsUtils for handling API responses. + */ + private boolean acceptEmptyStringAsNull = true; + + /** + * Coerce empty strings to null for enum types. Critical for handling API responses + * with empty finish_reason values. + */ + private boolean coerceEmptyEnumStrings = true; + + /** + * Fail on unknown properties during deserialization. When false (default), unknown + * properties are ignored. + */ + private boolean failOnUnknownProperties = false; + + /** + * Fail on empty beans during serialization. When false (default), empty beans are + * serialized as empty objects. + */ + private boolean failOnEmptyBeans = false; + + public boolean isAllowUnescapedControlChars() { + return this.allowUnescapedControlChars; + } + + public void setAllowUnescapedControlChars(boolean allowUnescapedControlChars) { + this.allowUnescapedControlChars = allowUnescapedControlChars; + } + + public boolean isWriteDatesAsTimestamps() { + return this.writeDatesAsTimestamps; + } + + public void setWriteDatesAsTimestamps(boolean writeDatesAsTimestamps) { + this.writeDatesAsTimestamps = writeDatesAsTimestamps; + } + + public boolean isAcceptEmptyStringAsNull() { + return this.acceptEmptyStringAsNull; + } + + public void setAcceptEmptyStringAsNull(boolean acceptEmptyStringAsNull) { + this.acceptEmptyStringAsNull = acceptEmptyStringAsNull; + } + + public boolean isCoerceEmptyEnumStrings() { + return this.coerceEmptyEnumStrings; + } + + public void setCoerceEmptyEnumStrings(boolean coerceEmptyEnumStrings) { + this.coerceEmptyEnumStrings = coerceEmptyEnumStrings; + } + + public boolean isFailOnUnknownProperties() { + return this.failOnUnknownProperties; + } + + public void setFailOnUnknownProperties(boolean failOnUnknownProperties) { + this.failOnUnknownProperties = failOnUnknownProperties; + } + + public boolean isFailOnEmptyBeans() { + return this.failOnEmptyBeans; + } + + public void setFailOnEmptyBeans(boolean failOnEmptyBeans) { + this.failOnEmptyBeans = failOnEmptyBeans; + } + +} diff --git a/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..794aa71557e --- /dev/null +++ b/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.springframework.ai.util.json.autoconfigure.JsonParserObjectMapperAutoConfiguration diff --git a/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/test/java/org/springframework/ai/util/json/autoconfigure/JsonParserIntegrationTests.java b/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/test/java/org/springframework/ai/util/json/autoconfigure/JsonParserIntegrationTests.java new file mode 100644 index 00000000000..f999b02ef56 --- /dev/null +++ b/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/test/java/org/springframework/ai/util/json/autoconfigure/JsonParserIntegrationTests.java @@ -0,0 +1,186 @@ +/* + * Copyright 2023-2025 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.ai.util.json.autoconfigure; + +import java.time.LocalDate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.util.json.JsonParser; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Integration tests for {@link JsonParserObjectMapperAutoConfiguration} with tool calling + * scenarios. + * + * @author Daniel Albuquerque + */ +class JsonParserIntegrationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JsonParserObjectMapperAutoConfiguration.class)); + + @Test + void shouldHandleUnescapedControlCharsInToolArguments() { + this.contextRunner.withPropertyValues("spring.ai.json.allow-unescaped-control-chars=true").run(context -> { + ObjectMapper mapper = context.getBean("jsonParserObjectMapper", ObjectMapper.class); + + // Simulate tool arguments with unescaped newlines (as might come from an + // LLM) + String jsonWithNewlines = """ + { + "text": "Line 1 + Line 2" + } + """; + + // This should not throw JsonParseException + assertThatNoException().isThrownBy(() -> mapper.readValue(jsonWithNewlines, ToolRequest.class)); + + ToolRequest request = mapper.readValue(jsonWithNewlines, ToolRequest.class); + assertThat(request.text).contains("\n"); + }); + } + + @Test + void shouldSerializeDatesAsStringsNotArrays() { + this.contextRunner.withPropertyValues("spring.ai.json.write-dates-as-timestamps=false").run(context -> { + ObjectMapper mapper = context.getBean("jsonParserObjectMapper", ObjectMapper.class); + + ToolRequestWithDate request = new ToolRequestWithDate(); + request.date = LocalDate.of(2025, 7, 3); + request.text = "Test"; + + String json = mapper.writeValueAsString(request); + + // Should be "2025-07-03" not [2025,7,3] + assertThat(json).doesNotContain("[2025"); + assertThat(json).contains("2025-07-03"); + }); + } + + @Test + void shouldUseConfiguredMapperInStaticJsonParserMethods() { + this.contextRunner.withPropertyValues("spring.ai.json.write-dates-as-timestamps=false").run(context -> { + // Force bean creation to set the configured mapper + context.getBean(JsonParser.class); + + ToolRequestWithDate request = new ToolRequestWithDate(); + request.date = LocalDate.of(2025, 7, 3); + request.text = "Test"; + + // Static method should use the Spring-configured mapper + String json = JsonParser.toJson(request); + + assertThat(json).doesNotContain("[2025"); + assertThat(json).contains("2025-07-03"); + }); + } + + @Test + void shouldAllowCustomMapperOverride() { + this.contextRunner.withUserConfiguration(CustomMapperConfig.class).run(context -> { + ObjectMapper mapper = context.getBean("jsonParserObjectMapper", ObjectMapper.class); + + // Test unescaped control chars + String jsonWithNewlines = """ + { + "text": "Line 1 + Line 2" + } + """; + + assertThatNoException().isThrownBy(() -> mapper.readValue(jsonWithNewlines, ToolRequest.class)); + + // Test date serialization + ToolRequestWithDate request = new ToolRequestWithDate(); + request.date = LocalDate.of(2025, 7, 3); + request.text = "Test"; + + String json = mapper.writeValueAsString(request); + assertThat(json).doesNotContain("[2025"); + assertThat(json).contains("2025-07-03"); + }); + } + + @Test + void shouldWorkWithComplexToolArguments() throws JsonProcessingException { + this.contextRunner + .withPropertyValues("spring.ai.json.allow-unescaped-control-chars=true", + "spring.ai.json.write-dates-as-timestamps=false") + .run(context -> { + ObjectMapper mapper = context.getBean("jsonParserObjectMapper", ObjectMapper.class); + + String complexJson = """ + { + "text": "Multi + line + text", + "date": "2025-07-03" + } + """; + + ToolRequestWithDate request = mapper.readValue(complexJson, ToolRequestWithDate.class); + assertThat(request.text).contains("\n"); + assertThat(request.date).isEqualTo(LocalDate.of(2025, 7, 3)); + + // Round-trip + String serialized = mapper.writeValueAsString(request); + ToolRequestWithDate deserialized = mapper.readValue(serialized, ToolRequestWithDate.class); + assertThat(deserialized.text).isEqualTo(request.text); + assertThat(deserialized.date).isEqualTo(request.date); + }); + } + + @Configuration + static class CustomMapperConfig { + + @Bean(name = "jsonParserObjectMapper") + public ObjectMapper jsonParserObjectMapper() { + return JsonMapper.builder() + .enable(com.fasterxml.jackson.core.json.JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS) + .disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .addModules(org.springframework.ai.util.JacksonUtils.instantiateAvailableModules()) + .build(); + } + + } + + static class ToolRequest { + + public String text; + + } + + static class ToolRequestWithDate { + + public String text; + + public LocalDate date; + + } + +} diff --git a/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/test/java/org/springframework/ai/util/json/autoconfigure/JsonParserObjectMapperAutoConfigurationTests.java b/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/test/java/org/springframework/ai/util/json/autoconfigure/JsonParserObjectMapperAutoConfigurationTests.java new file mode 100644 index 00000000000..89f18478059 --- /dev/null +++ b/auto-configurations/common/spring-ai-autoconfigure-json-parser/src/test/java/org/springframework/ai/util/json/autoconfigure/JsonParserObjectMapperAutoConfigurationTests.java @@ -0,0 +1,197 @@ +/* + * Copyright 2023-2025 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.ai.util.json.autoconfigure; + +import java.time.LocalDate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.util.json.JsonParser; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link JsonParserObjectMapperAutoConfiguration}. + * + * @author Daniel Albuquerque + */ +class JsonParserObjectMapperAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JsonParserObjectMapperAutoConfiguration.class)); + + @Test + void shouldAutoConfigureObjectMapper() { + this.contextRunner.run(context -> { + assertThat(context).hasBean("jsonParserObjectMapper"); + assertThat(context).hasBean("modelOptionsObjectMapper"); + ObjectMapper jsonParserMapper = context.getBean("jsonParserObjectMapper", ObjectMapper.class); + assertThat(jsonParserMapper).isNotNull(); + }); + } + + @Test + void shouldAutoConfigureJsonParser() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(JsonParser.class); + JsonParser jsonParser = context.getBean(JsonParser.class); + assertThat(jsonParser).isNotNull(); + }); + } + + @Test + void shouldAllowCustomObjectMapper() { + this.contextRunner.withUserConfiguration(CustomObjectMapperConfig.class).run(context -> { + assertThat(context).hasBean("jsonParserObjectMapper"); + assertThat(context).hasBean("modelOptionsObjectMapper"); + ObjectMapper mapper = context.getBean("jsonParserObjectMapper", ObjectMapper.class); + assertThat(mapper.isEnabled(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)).isFalse(); + }); + } + + @Test + void shouldApplyPropertiesConfiguration() { + this.contextRunner + .withPropertyValues("spring.ai.json.allow-unescaped-control-chars=true", + "spring.ai.json.write-dates-as-timestamps=false", "spring.ai.json.accept-empty-string-as-null=true", + "spring.ai.json.coerce-empty-enum-strings=true") + .run(context -> { + JsonParserProperties properties = context.getBean(JsonParserProperties.class); + assertThat(properties.isAllowUnescapedControlChars()).isTrue(); + assertThat(properties.isWriteDatesAsTimestamps()).isFalse(); + assertThat(properties.isAcceptEmptyStringAsNull()).isTrue(); + assertThat(properties.isCoerceEmptyEnumStrings()).isTrue(); + }); + } + + @Test + void shouldNotWriteDatesAsTimestampsWhenPropertyIsSet() { + this.contextRunner.withPropertyValues("spring.ai.json.write-dates-as-timestamps=false").run(context -> { + ObjectMapper mapper = context.getBean("jsonParserObjectMapper", ObjectMapper.class); + assertThat(mapper.isEnabled(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)).isFalse(); + + // Verify date serialization format + LocalDate date = LocalDate.of(2025, 7, 3); + String json = mapper.writeValueAsString(date); + // Should be "2025-07-03" not [2025,7,3] + assertThat(json).doesNotContain("["); + assertThat(json).contains("2025"); + }); + } + + @Test + void shouldAllowUnescapedControlCharsWhenPropertyIsSet() { + this.contextRunner.withPropertyValues("spring.ai.json.allow-unescaped-control-chars=true").run(context -> { + ObjectMapper mapper = context.getBean("jsonParserObjectMapper", ObjectMapper.class); + // Note: ALLOW_UNESCAPED_CONTROL_CHARS is a JsonReadFeature, not easily + // testable via mapper.isEnabled() + // The actual test for this feature is in the integration tests + assertThat(mapper).isNotNull(); + }); + } + + @Test + void shouldAutoConfigureModelOptionsObjectMapper() { + this.contextRunner.run(context -> { + assertThat(context).hasBean("modelOptionsObjectMapper"); + ObjectMapper mapper = context.getBean("modelOptionsObjectMapper", ObjectMapper.class); + assertThat(mapper).isNotNull(); + assertThat(mapper.isEnabled(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT)).isTrue(); + }); + } + + @Test + void shouldConfigureEnumCoercionForModelOptions() throws JsonProcessingException { + this.contextRunner.run(context -> { + ObjectMapper mapper = context.getBean("modelOptionsObjectMapper", ObjectMapper.class); + + // Test enum coercion - empty string should be null + String jsonWithEmptyEnum = "{\"status\":\"\"}"; + TestEnumClass result = mapper.readValue(jsonWithEmptyEnum, TestEnumClass.class); + assertThat(result.status).isNull(); + }); + } + + @Test + void shouldUseConfiguredMapperInModelOptionsUtils() { + this.contextRunner.run(context -> { + // Trigger bean creation to initialize ModelOptionsUtils + context.getBean("modelOptionsUtilsInitializer"); + + // Test that ModelOptionsUtils uses the Spring-configured mapper + TestObject obj = new TestObject(); + obj.name = "test"; + + String json = ModelOptionsUtils.toJsonString(obj); + assertThat(json).contains("test"); + + TestObject result = ModelOptionsUtils.jsonToObject(json, TestObject.class); + assertThat(result.name).isEqualTo("test"); + }); + } + + @Test + void shouldApplyModelOptionsProperties() { + this.contextRunner + .withPropertyValues("spring.ai.json.accept-empty-string-as-null=false", + "spring.ai.json.coerce-empty-enum-strings=false") + .run(context -> { + ObjectMapper mapper = context.getBean("modelOptionsObjectMapper", ObjectMapper.class); + assertThat(mapper.isEnabled(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT)).isFalse(); + }); + } + + @Configuration + static class CustomObjectMapperConfig { + + @Bean(name = "jsonParserObjectMapper") + public ObjectMapper jsonParserObjectMapper() { + return com.fasterxml.jackson.databind.json.JsonMapper.builder() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); + } + + } + + static class TestObject { + + public String name; + + } + + static class TestEnumClass { + + public TestStatus status; + + } + + enum TestStatus { + + ACTIVE, INACTIVE + + } + +} diff --git a/pom.xml b/pom.xml index 281a1b3cce3..7842e715e43 100644 --- a/pom.xml +++ b/pom.xml @@ -83,6 +83,7 @@ auto-configurations/common/spring-ai-autoconfigure-retry + auto-configurations/common/spring-ai-autoconfigure-json-parser auto-configurations/models/tool/spring-ai-autoconfigure-model-tool diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index 687c74ece68..727bc5f5c6d 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -490,6 +490,13 @@ ${project.version} + + + org.springframework.ai + spring-ai-autoconfigure-json-parser + ${project.version} + + diff --git a/spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java b/spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java index 079c8089ee5..22b18f496e4 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java @@ -67,7 +67,8 @@ */ public abstract class ModelOptionsUtils { - public static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() + // Default static mapper (fallback) + private static final ObjectMapper DEFAULT_OBJECT_MAPPER = JsonMapper.builder() .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) .addModules(JacksonUtils.instantiateAvailableModules()) @@ -78,7 +79,33 @@ public abstract class ModelOptionsUtils { // Configure coercion for empty strings to null for Enum types // This fixes the issue where empty string finish_reason values cause // deserialization failures - OBJECT_MAPPER.coercionConfigFor(Enum.class).setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull); + DEFAULT_OBJECT_MAPPER.coercionConfigFor(Enum.class) + .setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull); + } + + // Spring-managed mapper (when available) + private static volatile ObjectMapper CONFIGURED_OBJECT_MAPPER = null; + + /** + * The default ObjectMapper for backward compatibility. + * @deprecated Use dependency injection instead of accessing the ObjectMapper directly + */ + @Deprecated(since = "1.1.0", forRemoval = false) + public static final ObjectMapper OBJECT_MAPPER = DEFAULT_OBJECT_MAPPER; + + /** + * Returns the active ObjectMapper instance. Priority: Spring-configured > Default + * static + */ + private static ObjectMapper getActiveMapper() { + return CONFIGURED_OBJECT_MAPPER != null ? CONFIGURED_OBJECT_MAPPER : DEFAULT_OBJECT_MAPPER; + } + + /** + * Sets the Spring-managed ObjectMapper. Called by auto-configuration. + */ + public static void setConfiguredObjectMapper(ObjectMapper objectMapper) { + CONFIGURED_OBJECT_MAPPER = objectMapper; } private static final List BEAN_MERGE_FIELD_EXCISIONS = List.of("class"); @@ -98,7 +125,7 @@ public abstract class ModelOptionsUtils { * @return the converted Map. */ public static Map jsonToMap(String json) { - return jsonToMap(json, OBJECT_MAPPER); + return jsonToMap(json, getActiveMapper()); } /** @@ -126,7 +153,7 @@ public static Map jsonToMap(String json, ObjectMapper objectMapp */ public static T jsonToObject(String json, Class type) { try { - return OBJECT_MAPPER.readValue(json, type); + return getActiveMapper().readValue(json, type); } catch (Exception e) { throw new RuntimeException("Failed to json: " + json, e); @@ -140,7 +167,7 @@ public static T jsonToObject(String json, Class type) { */ public static String toJsonString(Object object) { try { - return OBJECT_MAPPER.writeValueAsString(object); + return getActiveMapper().writeValueAsString(object); } catch (JsonProcessingException e) { throw new RuntimeException(e); @@ -154,7 +181,7 @@ public static String toJsonString(Object object) { */ public static String toJsonStringPrettyPrinter(Object object) { try { - return OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(object); + return getActiveMapper().writerWithDefaultPrettyPrinter().writeValueAsString(object); } catch (JsonProcessingException e) { throw new RuntimeException(e); @@ -231,8 +258,9 @@ public static Map objectToMap(Object source) { return new HashMap<>(); } try { - String json = OBJECT_MAPPER.writeValueAsString(source); - return OBJECT_MAPPER.readValue(json, new TypeReference>() { + ObjectMapper mapper = getActiveMapper(); + String json = mapper.writeValueAsString(source); + return mapper.readValue(json, new TypeReference>() { }) .entrySet() @@ -254,8 +282,9 @@ public static Map objectToMap(Object source) { */ public static T mapToClass(Map source, Class clazz) { try { - String json = OBJECT_MAPPER.writeValueAsString(source); - return OBJECT_MAPPER.readValue(json, clazz); + ObjectMapper mapper = getActiveMapper(); + String json = mapper.writeValueAsString(source); + return mapper.readValue(json, clazz); } catch (JsonProcessingException e) { throw new RuntimeException(e); diff --git a/spring-ai-model/src/main/java/org/springframework/ai/util/json/JsonParser.java b/spring-ai-model/src/main/java/org/springframework/ai/util/json/JsonParser.java index 7b155e58c3c..0d6278c1669 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/util/json/JsonParser.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/util/json/JsonParser.java @@ -34,23 +34,54 @@ /** * Utilities to perform parsing operations between JSON and Java. */ -public final class JsonParser { +public class JsonParser { - private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() + // Static mapper for backward compatibility + private static final ObjectMapper DEFAULT_OBJECT_MAPPER = JsonMapper.builder() .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) .addModules(JacksonUtils.instantiateAvailableModules()) .build(); + // Spring-managed mapper (when available) + private static volatile ObjectMapper CONFIGURED_OBJECT_MAPPER = null; + + // Instance mapper for bean usage + private final ObjectMapper objectMapper; + + // Private constructor for utility class usage private JsonParser() { + this.objectMapper = null; + } + + // Constructor for Spring bean + public JsonParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Returns the active ObjectMapper instance. Priority: Spring-configured > Default + * static + */ + private static ObjectMapper getActiveMapper() { + return CONFIGURED_OBJECT_MAPPER != null ? CONFIGURED_OBJECT_MAPPER : DEFAULT_OBJECT_MAPPER; + } + + /** + * Sets the Spring-managed ObjectMapper. Called by auto-configuration. + */ + public static void setConfiguredObjectMapper(ObjectMapper objectMapper) { + CONFIGURED_OBJECT_MAPPER = objectMapper; } /** * Returns a Jackson {@link ObjectMapper} instance tailored for JSON-parsing * operations for tool calling and structured output. + * @deprecated Use dependency injection instead */ + @Deprecated(since = "1.1.0", forRemoval = false) public static ObjectMapper getObjectMapper() { - return OBJECT_MAPPER; + return getActiveMapper(); } /** @@ -61,7 +92,7 @@ public static T fromJson(String json, Class type) { Assert.notNull(type, "type cannot be null"); try { - return OBJECT_MAPPER.readValue(json, type); + return getActiveMapper().readValue(json, type); } catch (JsonProcessingException ex) { throw new IllegalStateException("Conversion from JSON to %s failed".formatted(type.getName()), ex); @@ -76,7 +107,8 @@ public static T fromJson(String json, Type type) { Assert.notNull(type, "type cannot be null"); try { - return OBJECT_MAPPER.readValue(json, OBJECT_MAPPER.constructType(type)); + ObjectMapper mapper = getActiveMapper(); + return mapper.readValue(json, mapper.constructType(type)); } catch (JsonProcessingException ex) { throw new IllegalStateException("Conversion from JSON to %s failed".formatted(type.getTypeName()), ex); @@ -91,7 +123,7 @@ public static T fromJson(String json, TypeReference type) { Assert.notNull(type, "type cannot be null"); try { - return OBJECT_MAPPER.readValue(json, type); + return getActiveMapper().readValue(json, type); } catch (JsonProcessingException ex) { throw new IllegalStateException("Conversion from JSON to %s failed".formatted(type.getType().getTypeName()), @@ -104,7 +136,7 @@ public static T fromJson(String json, TypeReference type) { */ private static boolean isValidJson(String input) { try { - OBJECT_MAPPER.readTree(input); + getActiveMapper().readTree(input); return true; } catch (JsonProcessingException e) { @@ -120,7 +152,7 @@ public static String toJson(@Nullable Object object) { return str; } try { - return OBJECT_MAPPER.writeValueAsString(object); + return getActiveMapper().writeValueAsString(object); } catch (JsonProcessingException ex) { throw new IllegalStateException("Conversion from Object to JSON failed", ex);