Skip to content

Refactor JSON utilities#6135

Open
sdeleuze wants to merge 1 commit into
spring-projects:mainfrom
sdeleuze:json-refactoring
Open

Refactor JSON utilities#6135
sdeleuze wants to merge 1 commit into
spring-projects:mainfrom
sdeleuze:json-refactoring

Conversation

@sdeleuze
Copy link
Copy Markdown
Contributor

@sdeleuze sdeleuze commented May 23, 2026

This PR is an important refactoring of the JSON utilities and some related public APIs that was not possible before since depending on previous refactoring. I have spend quite some time to make it as safe as possible, and it should be merged as part of the upcoming 2.0.0-RC1. I am pretty happy about the result as it solves many issues and pave the way for more consistent and well designed APIs. I would welcome your review @ThomasVitale @nicolaskrier in addition to @tzolov one.

For the detail, it introduces JsonHelper and updates JacksonUtils in spring-ai-commons in order to:

  • Allow customizing the underlying Jackson JsonMapper used
  • Stop providing JSON writing methods in JsonParser and McpJsonParser (not consistent with the names).
  • Stop exposing Jackson types on public APIs outside of JacksonUtils and JsonHelper constructor

It replaces the implementation of JsonParser#toTypedObject by a refined JsonHelper#convertToTypedObject one that leverages Jackson conversion capabilities instead of the previous hand-crafted code.

It stops providing JSON related methods in ModelOptionsUtils since model options do not use Jackson anymore, and it updates the codebase to use Spring Frameworks's ParameterizedTypeReference instead of Jackson's TypeReference.

In order to avoid too much breaking changes for the ecosystem, it keeps JsonParser deprecated which now leverages JsonUtils for its implementation.

It removes McpJsonMapperUtils and McpJsonParser classes.

A getDefaultJsonMapper() method is added to JacksonUtils with related Javadoc explaining how to customize the returned JsonMapper.

@sdeleuze sdeleuze added this to the 2.0.0-RC1 milestone May 23, 2026
@sdeleuze sdeleuze requested a review from tzolov May 23, 2026 17:26
@sdeleuze sdeleuze added enhancement New feature or request code cleanup labels May 23, 2026
@sdeleuze sdeleuze self-assigned this May 23, 2026
Comment thread spring-ai-commons/src/main/java/org/springframework/ai/util/JsonUtils.java Outdated
Comment thread spring-ai-commons/src/main/java/org/springframework/ai/util/JsonUtils.java Outdated
Comment thread spring-ai-commons/src/main/java/org/springframework/ai/util/JsonUtils.java Outdated
Comment thread spring-ai-commons/src/main/java/org/springframework/ai/util/JsonUtils.java Outdated
@nicolaskrier
Copy link
Copy Markdown
Contributor

I added a few minor suggestions, otherwise LGTM!

@sdeleuze
Copy link
Copy Markdown
Contributor Author

I am going to turn JsonUtils with static methods into a JsonHelper that can be instantiated with a custom mapper in order to allow a future resolution of #2494.

@sdeleuze
Copy link
Copy Markdown
Contributor Author

Done, and I also added documentation on how to customize the default JsonMapper on JacksonUtils.

* @param type the target type
* @return the converted typed object
*/
public Object convertToTypedObject(Object value, Class<?> type) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tzolov I did my best to improve this typed object conversion without breaking anything, but I would welcome some context on what this is supposed to be.

I was able to replace the manual typed conversion found in JsonParser by JsonMapper#convertValue which is an improvement, but the serialization + deserialization thing at the end, also found in AbstractMcpToolMethodCallback#buildTypedArgument and MethodToolCallback#buildTypedArgument looks very suspicious. Could you please described what it is supposed to do ?

@ThomasVitale
Copy link
Copy Markdown
Contributor

I like the refactoring!

@tzolov
Copy link
Copy Markdown
Contributor

tzolov commented May 25, 2026

@sdeleuze, great improvement. Thanks!

  1. Here is a possible regressions to consider: The old ModelOptionsUtils.JSON_MAPPER had ACCEPT_EMPTY_STRING_AS_NULL_OBJECT enabled to survive LLM API responses that return "" for enum fields like finish_reason. The new JacksonUtils.getDefaultJsonMapper() does not carry this setting. This means any provider returning "" for an enum field will now throw a deserialization exception at runtime rather than producing null.

Not sure if this is still an LLM issue (e.g. empty string finish_reason). Maybe for streaming mode we can get a partially initialized json snippets that we map to the same Java classes we use for non-streaming mode. Looks like Jackson3 uses CoercionConfig for this now. E.g.

    jsonMapper = JsonMapper.builder()
        .disable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
        .withCoercionConfig(LogicalType.Enum, cfg ->
            cfg.setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull))
        .addModules(JacksonUtils.instantiateAvailableModules())
        .build();
  1. There are few breaking changes with this PR that would need a documentation.

  2. Regarding the MethodToolCallback#buildTypedArgument (the AbstractMcpToolMethodCallback#buildTypedArgument just copies the same behavior) we tried to fix issues related to handling generic arguments (MethodToolCallback: Incorrect Deserialization of List<T> with Class<?> #2462). Here is the commit: 5f6c618#diff-145d91bd62f255afd310feaf13a2cb29bdd0585e0f64e01cfb5623b3868c276a

Perhaps, with jackson3 we can do something like this insetead:

// JsonHelper.java
public Object convertToTypedObject(Object value, Type type) {
	if (type instanceof Class<?> clazz) {
		return convertToTypedObject(value, clazz);
	}
	return this.jsonMapper.convertValue(value, this.jsonMapper.constructType(type));
}

// MethodToolCallback.java
	private Object buildTypedArgument(@Nullable Object value, Type type) {
		if (value == null) { return null;}

		try {
			return jsonHelper.convertToTypedObject(value, type);
		}
		catch (Exception ex) {
			logger.warn("Conversion from JSON failed", ex);
			Throwable cause = (ex.getCause() instanceof JacksonException) ? ex.getCause() : ex;
			throw new ToolExecutionException(this.getToolDefinition(), cause);
		}
	}

But, lets tackle this in a follow up PR?

@sdeleuze if you want i can handle (1 and 2) while merging the PR and then address 3 in a separate PR?

@sdeleuze sdeleuze assigned tzolov and sdeleuze and unassigned sdeleuze and tzolov May 25, 2026
This commit introduces JsonHelper and updates JacksonUtils in
spring-ai-commons in order to:
 - Allow customizing the underlying Jackson JsonMapper used
 - Stop providing JSON writing methods in JsonParser and McpJsonParser
   (not consistent with the names).
 - Stop exposing Jackson types on public APIs outside of JacksonUtils
   and JsonHelper constructor

It replaces the implementation of JsonParser#toTypedObject by a
refined JsonHelper#convertToTypedObject one that leverages Jackson
conversion capabilities instead of the previous hand-crafted code.

It stops providing JSON related methods in ModelOptionsUtils
since model options do not use Jackson anymore, and it updates the
codebase to use Spring Frameworks's ParameterizedTypeReference
instead of Jackson's TypeReference.

In order to avoid too much breaking changes for the ecosystem,
it keeps JsonParser deprecated which now leverages JsonUtils for
its implementation.

It removes McpJsonMapperUtils and McpJsonParser classes.

A getDefaultJsonMapper() method is added to JacksonUtils
with related Javadoc explaining how to customize the returned
JsonMapper.

Closes spring-projects#6135
Signed-off-by: Sébastien Deleuze <sdeleuze@users.noreply.github.com>
@sdeleuze sdeleuze force-pushed the json-refactoring branch from 0d22388 to ee3ec7f Compare May 25, 2026 17:11
@sdeleuze
Copy link
Copy Markdown
Contributor Author

Glad you like it @ThomasVitale @tzolov !

@tzolov Good catch for the empty string to enum, I was able to add a test to reproduce and fix it with enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT) in JacksonUtils so that should be good enough to solve 1. I pushed the related changes.

I will let you take care of 2 (document the breaking changes) and merge this PR, thanks for taking care of that.
Let see 3 later after merging this PR.

@sdeleuze sdeleuze assigned tzolov and unassigned sdeleuze May 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

code cleanup enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants