Skip to content

Commit ab27e8d

Browse files
committed
Simplify ObjectMapper configuration for JSON parsing
This change addresses feedback from PR spring-projects#4833 by providing a simplified approach to customizing ObjectMapper beans used by Spring AI. Key changes: - Refactor JsonParser and ModelOptionsUtils to support ObjectMapper injection - Add simple auto-configuration with @ConditionalOnMissingBean beans - Remove complex ConfigurationProperties in favor of bean overrides - Add new spring-ai-autoconfigure-json module The implementation allows users to easily override ObjectMapper beans by defining their own beans with the same name: - jsonParserObjectMapper: for LLM response parsing - modelOptionsObjectMapper: for model options handling Default configuration includes: - Lenient JSON parsing with unescaped control characters - Date serialization as ISO strings instead of timestamps - Empty string coercion to null for enums - Accept empty strings as null objects Fixes spring-projects#2494
1 parent 920e6f4 commit ab27e8d

File tree

7 files changed

+260
-36
lines changed

7 files changed

+260
-36
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xmlns="http://maven.apache.org/POM/4.0.0"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
<parent>
7+
<groupId>org.springframework.ai</groupId>
8+
<artifactId>spring-ai-parent</artifactId>
9+
<version>2.0.0-SNAPSHOT</version>
10+
<relativePath>../../../pom.xml</relativePath>
11+
</parent>
12+
<artifactId>spring-ai-autoconfigure-json</artifactId>
13+
<packaging>jar</packaging>
14+
<name>Spring AI JSON Auto Configuration</name>
15+
<description>Spring AI JSON Auto Configuration</description>
16+
<url>https://github.com/spring-projects/spring-ai</url>
17+
18+
<scm>
19+
<url>https://github.com/spring-projects/spring-ai</url>
20+
<connection>git://github.com/spring-projects/spring-ai.git</connection>
21+
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
22+
</scm>
23+
24+
25+
<dependencies>
26+
27+
<dependency>
28+
<groupId>org.springframework.ai</groupId>
29+
<artifactId>spring-ai-model</artifactId>
30+
<version>${project.parent.version}</version>
31+
</dependency>
32+
33+
<!-- Boot dependencies -->
34+
<dependency>
35+
<groupId>org.springframework.boot</groupId>
36+
<artifactId>spring-boot-starter</artifactId>
37+
</dependency>
38+
39+
<dependency>
40+
<groupId>org.springframework.boot</groupId>
41+
<artifactId>spring-boot-autoconfigure-processor</artifactId>
42+
<optional>true</optional>
43+
</dependency>
44+
45+
<!-- Jackson dependencies -->
46+
<dependency>
47+
<groupId>com.fasterxml.jackson.core</groupId>
48+
<artifactId>jackson-databind</artifactId>
49+
</dependency>
50+
51+
<!-- Test dependencies -->
52+
<dependency>
53+
<groupId>org.springframework.ai</groupId>
54+
<artifactId>spring-ai-test</artifactId>
55+
<version>${project.parent.version}</version>
56+
<scope>test</scope>
57+
</dependency>
58+
59+
<dependency>
60+
<groupId>org.springframework.boot</groupId>
61+
<artifactId>spring-boot-starter-test</artifactId>
62+
<scope>test</scope>
63+
</dependency>
64+
</dependencies>
65+
66+
</project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.autoconfigure.json;
18+
19+
import com.fasterxml.jackson.core.json.JsonReadFeature;
20+
import com.fasterxml.jackson.databind.DeserializationFeature;
21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import com.fasterxml.jackson.databind.SerializationFeature;
23+
import com.fasterxml.jackson.databind.cfg.CoercionAction;
24+
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
25+
import com.fasterxml.jackson.databind.json.JsonMapper;
26+
27+
import org.springframework.ai.model.ModelOptionsUtils;
28+
import org.springframework.ai.util.JacksonUtils;
29+
import org.springframework.ai.util.json.JsonParser;
30+
import org.springframework.boot.autoconfigure.AutoConfiguration;
31+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
32+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
33+
import org.springframework.context.annotation.Bean;
34+
35+
/**
36+
* {@link AutoConfiguration Auto-configuration} for Spring AI JSON parsing. Provides
37+
* customizable ObjectMapper beans for JSON parsing and model options handling. Users can
38+
* override these beans to customize JSON processing behavior.
39+
*
40+
* @author Daniel Albuquerque
41+
* @since 2.0.0
42+
*/
43+
@AutoConfiguration
44+
@ConditionalOnClass({ JsonParser.class, ObjectMapper.class })
45+
public class SpringAiJsonAutoConfiguration {
46+
47+
/**
48+
* Creates an ObjectMapper bean configured for lenient JSON parsing of LLM responses.
49+
* This ObjectMapper is used by {@link JsonParser} for tool calling and structured
50+
* output.
51+
*
52+
* <p>
53+
* Default configuration:
54+
* <ul>
55+
* <li>Allows unescaped control characters (common in LLM responses)</li>
56+
* <li>Ignores unknown properties</li>
57+
* <li>Serializes dates as ISO strings instead of timestamps</li>
58+
* <li>Allows empty beans to be serialized</li>
59+
* </ul>
60+
* @return configured ObjectMapper for JSON parsing
61+
*/
62+
@Bean
63+
@ConditionalOnMissingBean(name = "jsonParserObjectMapper")
64+
public ObjectMapper jsonParserObjectMapper() {
65+
ObjectMapper mapper = JsonMapper.builder()
66+
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS)
67+
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
68+
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
69+
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
70+
.addModules(JacksonUtils.instantiateAvailableModules())
71+
.build();
72+
73+
// Set this ObjectMapper to be used by JsonParser
74+
JsonParser.setObjectMapper(mapper);
75+
76+
return mapper;
77+
}
78+
79+
/**
80+
* Creates an ObjectMapper bean configured for model options
81+
* serialization/deserialization. This ObjectMapper is used by
82+
* {@link ModelOptionsUtils} for converting model options.
83+
*
84+
* <p>
85+
* Default configuration:
86+
* <ul>
87+
* <li>Ignores unknown properties</li>
88+
* <li>Allows empty beans to be serialized</li>
89+
* <li>Accepts empty strings as null objects</li>
90+
* <li>Coerces empty strings to null for Enum types</li>
91+
* </ul>
92+
* @return configured ObjectMapper for model options
93+
*/
94+
@Bean
95+
@ConditionalOnMissingBean(name = "modelOptionsObjectMapper")
96+
public ObjectMapper modelOptionsObjectMapper() {
97+
ObjectMapper mapper = JsonMapper.builder()
98+
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
99+
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
100+
.addModules(JacksonUtils.instantiateAvailableModules())
101+
.build()
102+
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true);
103+
104+
// Configure coercion for empty strings to null for Enum types
105+
// This fixes the issue where empty string finish_reason values cause
106+
// deserialization failures
107+
mapper.coercionConfigFor(Enum.class).setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);
108+
109+
// Set this ObjectMapper to be used by ModelOptionsUtils
110+
ModelOptionsUtils.setObjectMapper(mapper);
111+
112+
return mapper;
113+
}
114+
115+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
org.springframework.ai.autoconfigure.json.SpringAiJsonAutoConfiguration

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383

8484

8585
<module>auto-configurations/common/spring-ai-autoconfigure-retry</module>
86+
<module>auto-configurations/common/spring-ai-autoconfigure-json</module>
8687

8788
<module>auto-configurations/models/tool/spring-ai-autoconfigure-model-tool</module>
8889

spring-ai-model/src/main/java/org/springframework/ai/model/ModelOptionsUtils.java

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,42 @@
6767
*/
6868
public abstract class ModelOptionsUtils {
6969

70-
public static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder()
71-
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
72-
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
73-
.addModules(JacksonUtils.instantiateAvailableModules())
74-
.build()
75-
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true);
70+
private static final ObjectMapper DEFAULT_OBJECT_MAPPER;
7671

7772
static {
73+
DEFAULT_OBJECT_MAPPER = JsonMapper.builder()
74+
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
75+
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
76+
.addModules(JacksonUtils.instantiateAvailableModules())
77+
.build()
78+
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true);
79+
7880
// Configure coercion for empty strings to null for Enum types
7981
// This fixes the issue where empty string finish_reason values cause
8082
// deserialization failures
81-
OBJECT_MAPPER.coercionConfigFor(Enum.class).setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);
83+
DEFAULT_OBJECT_MAPPER.coercionConfigFor(Enum.class)
84+
.setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);
85+
}
86+
87+
private static final AtomicReference<ObjectMapper> OBJECT_MAPPER = new AtomicReference<>(DEFAULT_OBJECT_MAPPER);
88+
89+
/**
90+
* Sets a custom {@link ObjectMapper} instance to use for model options operations.
91+
* This is typically called by Spring's auto-configuration to inject a configured
92+
* ObjectMapper bean.
93+
* @param objectMapper the ObjectMapper to use
94+
*/
95+
public static void setObjectMapper(ObjectMapper objectMapper) {
96+
org.springframework.util.Assert.notNull(objectMapper, "objectMapper cannot be null");
97+
OBJECT_MAPPER.set(objectMapper);
98+
}
99+
100+
/**
101+
* Returns the {@link ObjectMapper} instance used for model options operations.
102+
* @return the ObjectMapper instance
103+
*/
104+
public static ObjectMapper getObjectMapper() {
105+
return OBJECT_MAPPER.get();
82106
}
83107

84108
private static final List<String> BEAN_MERGE_FIELD_EXCISIONS = List.of("class");
@@ -98,7 +122,7 @@ public abstract class ModelOptionsUtils {
98122
* @return the converted Map.
99123
*/
100124
public static Map<String, Object> jsonToMap(String json) {
101-
return jsonToMap(json, OBJECT_MAPPER);
125+
return jsonToMap(json, OBJECT_MAPPER.get());
102126
}
103127

104128
/**
@@ -126,7 +150,7 @@ public static Map<String, Object> jsonToMap(String json, ObjectMapper objectMapp
126150
*/
127151
public static <T> T jsonToObject(String json, Class<T> type) {
128152
try {
129-
return OBJECT_MAPPER.readValue(json, type);
153+
return OBJECT_MAPPER.get().readValue(json, type);
130154
}
131155
catch (Exception e) {
132156
throw new RuntimeException("Failed to json: " + json, e);
@@ -140,7 +164,7 @@ public static <T> T jsonToObject(String json, Class<T> type) {
140164
*/
141165
public static String toJsonString(Object object) {
142166
try {
143-
return OBJECT_MAPPER.writeValueAsString(object);
167+
return OBJECT_MAPPER.get().writeValueAsString(object);
144168
}
145169
catch (JsonProcessingException e) {
146170
throw new RuntimeException(e);
@@ -154,7 +178,7 @@ public static String toJsonString(Object object) {
154178
*/
155179
public static String toJsonStringPrettyPrinter(Object object) {
156180
try {
157-
return OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(object);
181+
return OBJECT_MAPPER.get().writerWithDefaultPrettyPrinter().writeValueAsString(object);
158182
}
159183
catch (JsonProcessingException e) {
160184
throw new RuntimeException(e);
@@ -231,8 +255,9 @@ public static Map<String, Object> objectToMap(Object source) {
231255
return new HashMap<>();
232256
}
233257
try {
234-
String json = OBJECT_MAPPER.writeValueAsString(source);
235-
return OBJECT_MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {
258+
ObjectMapper mapper = OBJECT_MAPPER.get();
259+
String json = mapper.writeValueAsString(source);
260+
return mapper.readValue(json, new TypeReference<Map<String, Object>>() {
236261

237262
})
238263
.entrySet()
@@ -254,8 +279,9 @@ public static Map<String, Object> objectToMap(Object source) {
254279
*/
255280
public static <T> T mapToClass(Map<String, Object> source, Class<T> clazz) {
256281
try {
257-
String json = OBJECT_MAPPER.writeValueAsString(source);
258-
return OBJECT_MAPPER.readValue(json, clazz);
282+
ObjectMapper mapper = OBJECT_MAPPER.get();
283+
String json = mapper.writeValueAsString(source);
284+
return mapper.readValue(json, clazz);
259285
}
260286
catch (JsonProcessingException e) {
261287
throw new RuntimeException(e);

spring-ai-model/src/main/java/org/springframework/ai/util/json/JsonParser.java

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.lang.reflect.Type;
2020
import java.math.BigDecimal;
21+
import java.util.concurrent.atomic.AtomicReference;
2122

2223
import com.fasterxml.jackson.core.JsonProcessingException;
2324
import com.fasterxml.jackson.core.type.TypeReference;
@@ -36,21 +37,34 @@
3637
*/
3738
public final class JsonParser {
3839

39-
private static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder()
40+
private static final ObjectMapper DEFAULT_OBJECT_MAPPER = JsonMapper.builder()
4041
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
4142
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
4243
.addModules(JacksonUtils.instantiateAvailableModules())
4344
.build();
4445

46+
private static final AtomicReference<ObjectMapper> OBJECT_MAPPER = new AtomicReference<>(DEFAULT_OBJECT_MAPPER);
47+
4548
private JsonParser() {
4649
}
4750

51+
/**
52+
* Sets a custom {@link ObjectMapper} instance to use for JSON parsing operations.
53+
* This is typically called by Spring's auto-configuration to inject a configured
54+
* ObjectMapper bean.
55+
* @param objectMapper the ObjectMapper to use
56+
*/
57+
public static void setObjectMapper(ObjectMapper objectMapper) {
58+
Assert.notNull(objectMapper, "objectMapper cannot be null");
59+
OBJECT_MAPPER.set(objectMapper);
60+
}
61+
4862
/**
4963
* Returns a Jackson {@link ObjectMapper} instance tailored for JSON-parsing
5064
* operations for tool calling and structured output.
5165
*/
5266
public static ObjectMapper getObjectMapper() {
53-
return OBJECT_MAPPER;
67+
return OBJECT_MAPPER.get();
5468
}
5569

5670
/**
@@ -61,7 +75,7 @@ public static <T> T fromJson(String json, Class<T> type) {
6175
Assert.notNull(type, "type cannot be null");
6276

6377
try {
64-
return OBJECT_MAPPER.readValue(json, type);
78+
return OBJECT_MAPPER.get().readValue(json, type);
6579
}
6680
catch (JsonProcessingException ex) {
6781
throw new IllegalStateException("Conversion from JSON to %s failed".formatted(type.getName()), ex);
@@ -76,7 +90,8 @@ public static <T> T fromJson(String json, Type type) {
7690
Assert.notNull(type, "type cannot be null");
7791

7892
try {
79-
return OBJECT_MAPPER.readValue(json, OBJECT_MAPPER.constructType(type));
93+
ObjectMapper mapper = OBJECT_MAPPER.get();
94+
return mapper.readValue(json, mapper.constructType(type));
8095
}
8196
catch (JsonProcessingException ex) {
8297
throw new IllegalStateException("Conversion from JSON to %s failed".formatted(type.getTypeName()), ex);
@@ -91,7 +106,7 @@ public static <T> T fromJson(String json, TypeReference<T> type) {
91106
Assert.notNull(type, "type cannot be null");
92107

93108
try {
94-
return OBJECT_MAPPER.readValue(json, type);
109+
return OBJECT_MAPPER.get().readValue(json, type);
95110
}
96111
catch (JsonProcessingException ex) {
97112
throw new IllegalStateException("Conversion from JSON to %s failed".formatted(type.getType().getTypeName()),
@@ -104,7 +119,7 @@ public static <T> T fromJson(String json, TypeReference<T> type) {
104119
*/
105120
private static boolean isValidJson(String input) {
106121
try {
107-
OBJECT_MAPPER.readTree(input);
122+
OBJECT_MAPPER.get().readTree(input);
108123
return true;
109124
}
110125
catch (JsonProcessingException e) {
@@ -120,7 +135,7 @@ public static String toJson(@Nullable Object object) {
120135
return str;
121136
}
122137
try {
123-
return OBJECT_MAPPER.writeValueAsString(object);
138+
return OBJECT_MAPPER.get().writeValueAsString(object);
124139
}
125140
catch (JsonProcessingException ex) {
126141
throw new IllegalStateException("Conversion from Object to JSON failed", ex);

0 commit comments

Comments
 (0)