Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ public final class McpJsonSchemaGenerator {
* spring-ai-model's JsonSchemaGenerator.
*/
static {
Module jacksonModule = new JacksonSchemaModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED);
Module jacksonModule = new JacksonSchemaModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED,
JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE, JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY);
Module openApiModule = new Swagger2Module();
Module springAiSchemaModule = PROPERTY_REQUIRED_BY_DEFAULT ? new McpSpringAiSchemaModule()
: new McpSpringAiSchemaModule(McpSpringAiSchemaModule.Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import java.lang.reflect.Method;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.JsonNode;

Expand Down Expand Up @@ -126,6 +128,31 @@ void generateSchemaForMethodRewritesPeerDefinitionRefsAfterRename() throws Excep
.isEqualTo("#/$defs/Filter_2");
}

// gh-1985: enum constants annotated with @JsonProperty must surface their custom
// values in the generated schema, matching Jackson's deserialization behavior.
@Test
void generateSchemaForMethodWithCustomEnumValues() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("useCustomEnum", CustomEnum.class);

String schema = McpJsonSchemaGenerator.generateForMethodInput(method);
JsonNode schemaNode = jsonHelper.fromJson(schema, JsonNode.class);

assertThat(schemaNode.at("/properties/status/enum")).map(JsonNode::asString)
.containsExactly("value.a", "value.b");
}

// gh-1985: enums serialized through a @JsonValue method must surface those values
// in the generated schema as well.
@Test
void generateSchemaForMethodWithJsonValueEnum() throws Exception {
Method method = TestMethods.class.getDeclaredMethod("useJsonValueEnum", TemperatureUnit.class);

String schema = McpJsonSchemaGenerator.generateForMethodInput(method);
JsonNode schemaNode = jsonHelper.fromJson(schema, JsonNode.class);

assertThat(schemaNode.at("/properties/unit/enum")).map(JsonNode::asString).containsExactly("C", "F");
}

@Test
void generateForMethodInputThrowsWhenMethodIsNull() {
assertThatThrownBy(() -> McpJsonSchemaGenerator.generateForMethodInput(null))
Expand Down Expand Up @@ -165,6 +192,37 @@ public void collidingSimpleNameMethod(OuterA.SearchRequest a, OuterB.SearchReque
public void peerReferenceMethod(PeerA.SearchRequest a, PeerB.SearchRequest b) {
}

public void useCustomEnum(CustomEnum status) {
}

public void useJsonValueEnum(TemperatureUnit unit) {
}

}

enum CustomEnum {

@JsonProperty("value.a")
A, @JsonProperty("value.b")
B

}

public enum TemperatureUnit {

CELSIUS("C"), FAHRENHEIT("F");

private final String symbol;

TemperatureUnit(String symbol) {
this.symbol = symbol;
}

@JsonValue
public String symbol() {
return this.symbol;
}

}

record RecursiveFilter(String field, String operator, List<RecursiveFilter> filters) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
[[upgrading-to-2-0-1]]
== Upgrading to 2.0.1

=== JSON Schemas Honor Custom Enum Values from `@JsonValue` and `@JsonProperty`

JSON schemas generated for tool calling and structured output now use Jackson's serialized form for enums that declare a `@JsonValue` method or annotate all constants with `@JsonProperty`.
Previously the schema advertised the Java constant names while deserialization expected the custom values, causing an `InvalidFormatException` when the model followed the schema.

==== Impact

Generated schemas change only for enums using those annotations: the `enum` values now match what Jackson accepts during deserialization.
Enums without these annotations keep emitting their constant names.

=== Redis Chat Memory Repository Auto-Configuration Module Renaming

The Redis chat memory auto-configurations module renamed to `spring-ai-autoconfigure-model-chat-memory-repository-redis`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@
* If none of these annotations are present, the default behavior is to consider the
* property as required and not to include a description.
* <p>
* Enum values are aligned with Jackson's serialized form when the enum declares a
* {@code @JsonValue} method or all of its constants are annotated with
* {@code @JsonProperty}.
* <p>
*
* @author Thomas Vitale
* @author Sebastien Deleuze
Expand All @@ -95,7 +99,8 @@ public final class JsonSchemaGenerator {
*/
static {
Module jacksonModule = new JacksonSchemaModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED,
JacksonOption.RESPECT_JSONPROPERTY_ORDER);
JacksonOption.RESPECT_JSONPROPERTY_ORDER, JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE,
JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY);
Module openApiModule = new Swagger2Module();
Module springAiSchemaModule = PROPERTY_REQUIRED_BY_DEFAULT ? new SpringAiSchemaModule()
: new SpringAiSchemaModule(SpringAiSchemaModule.Option.PROPERTY_REQUIRED_FALSE_BY_DEFAULT);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ public final class JsonSchemaUtils {
private static final SchemaGenerator schemaGenerator;

static {
JacksonSchemaModule jacksonModule = new JacksonSchemaModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED);
JacksonSchemaModule jacksonModule = new JacksonSchemaModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED,
JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE, JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY);
Swagger2Module swaggerModule = new Swagger2Module();

SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.annotation.JsonValue;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -133,6 +134,39 @@ record TestClassWithToolParam(@ToolParam(required = true, description = "A requi
record TestClassWithNullable(String requiredField, @Nullable String optionalField) {
}

record TestClassWithCustomEnumValues(CustomEnum values) {

enum CustomEnum {

@JsonProperty("value.a")
A, @JsonProperty("value.b")
B

}

}

record TestClassWithJsonValueEnum(TemperatureUnit unit) {

public enum TemperatureUnit {

CELSIUS("C"), FAHRENHEIT("F");

private final String symbol;

TemperatureUnit(String symbol) {
this.symbol = symbol;
}

@JsonValue
public String symbol() {
return this.symbol;
}

}

}

@Nested
class ConverterTest {

Expand All @@ -150,6 +184,24 @@ void convertClassWithDateType() {
assertThat(testClass.getSomeString()).isEqualTo(LocalDate.of(2020, 1, 1));
}

@Test
void convertEnumWithCustomJsonPropertyValues() {
var converter = new BeanOutputConverter<>(TestClassWithCustomEnumValues.class);
// the generated schema must advertise the same enum values that the
// deserializer accepts
assertThat(converter.getJsonSchema()).contains("value.a", "value.b");
var result = converter.convert("{ \"values\": \"value.a\" }");
assertThat(result.values()).isEqualTo(TestClassWithCustomEnumValues.CustomEnum.A);
}

@Test
void convertEnumWithJsonValueMappings() {
var converter = new BeanOutputConverter<>(TestClassWithJsonValueEnum.class);
assertThat(converter.getJsonSchema()).contains("\"C\"", "\"F\"");
var result = converter.convert("{ \"unit\": \"C\" }");
assertThat(result.unit()).isEqualTo(TestClassWithJsonValueEnum.TemperatureUnit.CELSIUS);
}

@Test
void convertTypeReference() {
var converter = new BeanOutputConverter<>(new ParameterizedTypeReference<TestClass>() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.annotation.JsonValue;
import io.swagger.v3.oas.annotations.media.Schema;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -748,6 +749,50 @@ void generateSchemaForEnum() {
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}

@Test
void generateSchemaForEnumWithJsonPropertyValues() {
String schema = JsonSchemaGenerator.generateForType(CustomEnumWrapper.class);
String expectedJsonSchema = """
{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "object",
"properties" : {
"values" : {
"type" : "string",
"enum" : [ "value.a", "value.b" ]
}
},
"required" : [ "values" ],
"additionalProperties" : false
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}

@Test
void generateSchemaForEnumWithJsonValue() {
String schema = JsonSchemaGenerator.generateForType(TemperatureUnit.class);
String expectedJsonSchema = """
{
"$schema" : "https://json-schema.org/draft/2020-12/schema",
"type" : "string",
"enum" : [ "C", "F" ]
}
""";
assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema);
}

@Test
void generateSchemaForMethodInputWithCustomEnumValues() throws Exception {
Method method = CustomEnumMethods.class.getDeclaredMethod("setTemperature", CustomEnumWrapper.CustomEnum.class,
TemperatureUnit.class);
String schema = JsonSchemaGenerator.generateForMethodInput(method);
JsonNode schemaNode = JacksonUtils.getDefaultJsonMapper().readTree(schema);
assertThat(schemaNode.at("/properties/value/enum")).map(JsonNode::asString)
.containsExactly("value.a", "value.b");
assertThat(schemaNode.at("/properties/unit/enum")).map(JsonNode::asString).containsExactly("C", "F");
}

@Test
void generateSchemaForTypeWithJSpecifyNullableField() {
String schema = JsonSchemaGenerator.generateForType(JSpecifyNullablePerson.class);
Expand Down Expand Up @@ -1121,6 +1166,42 @@ record WithMapField(String name, Map<String, Integer> scores) {

}

record CustomEnumWrapper(CustomEnum values) {

enum CustomEnum {

@JsonProperty("value.a")
A, @JsonProperty("value.b")
B

}

}

public enum TemperatureUnit {

CELSIUS("C"), FAHRENHEIT("F");

private final String symbol;

TemperatureUnit(String symbol) {
this.symbol = symbol;
}

@JsonValue
public String symbol() {
return this.symbol;
}

}

static class CustomEnumMethods {

public void setTemperature(CustomEnumWrapper.CustomEnum value, TemperatureUnit unit) {
}

}

@JsonPropertyOrder({ "accountId", "accountName", "currency", "totals" })
record OrderedStatement(@JsonProperty(required = true) String accountId,
@JsonProperty(required = true) String accountName, String currency, Map<String, Double> totals) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.annotation.JsonValue;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ObjectNode;

import org.springframework.ai.util.JsonHelper;
Expand Down Expand Up @@ -204,10 +206,61 @@ private static <T> List<T> generateConcurrently(Callable<T> generator) throws Ex
}
}

// gh-1985: enum constants annotated with @JsonProperty must surface their custom
// values in the generated schema, matching Jackson's deserialization behavior.
@Test
void getJsonSchemaHonorsJsonPropertyEnumValues() {
ObjectNode schema = JsonSchemaUtils.getJsonSchema(CustomEnumHolder.class);

assertThat(schema.at("/properties/status/enum")).map(JsonNode::asString).containsExactly("value.a", "value.b");
}

// gh-1985: enums serialized through a @JsonValue method must surface those values
// in the generated schema as well.
@Test
void getJsonSchemaHonorsJsonValueEnumValues() {
ObjectNode schema = JsonSchemaUtils.getJsonSchema(UnitHolder.class);

assertThat(schema.at("/properties/unit/enum")).map(JsonNode::asString).containsExactly("C", "F");
}

@JsonPropertyOrder({ "accountId", "accountName", "currency", "totals" })
record OrderedStatement(@JsonProperty(required = true) String accountId,
@JsonProperty(required = true) String accountName, String currency, Map<String, Double> totals) {

}

record CustomEnumHolder(CustomEnum status) {

enum CustomEnum {

@JsonProperty("value.a")
A, @JsonProperty("value.b")
B

}

}

record UnitHolder(TemperatureUnit unit) {

}

public enum TemperatureUnit {

CELSIUS("C"), FAHRENHEIT("F");

private final String symbol;

TemperatureUnit(String symbol) {
this.symbol = symbol;
}

@JsonValue
public String symbol() {
return this.symbol;
}

}

}
Loading
Loading