diff --git a/spring-ai-model/pom.xml b/spring-ai-model/pom.xml index 70874f2d865..f374c0a6c40 100644 --- a/spring-ai-model/pom.xml +++ b/spring-ai-model/pom.xml @@ -154,6 +154,13 @@ test + + com.google.code.findbugs + jsr305 + 3.0.2 + test + + diff --git a/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java b/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java index 27fae0fcc55..6ddb656e6f1 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/JsonSchemaGenerator.java @@ -20,6 +20,7 @@ import java.lang.reflect.Parameter; import java.lang.reflect.Type; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.stream.Stream; @@ -213,8 +214,9 @@ private static boolean isMethodParameterRequired(Method method, int index) { || schemaAnnotation.requiredMode() == Schema.RequiredMode.AUTO || schemaAnnotation.required(); } - var nullableAnnotation = parameter.getAnnotation(Nullable.class); - if (nullableAnnotation != null) { + var nullableAnnotationPresent = Arrays.stream(parameter.getAnnotations()) + .anyMatch(ann -> ann.annotationType().getSimpleName().equals("Nullable")); + if (nullableAnnotationPresent) { return false; } diff --git a/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/SpringAiSchemaModule.java b/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/SpringAiSchemaModule.java index 2182fc6b25b..bf23127c6ea 100644 --- a/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/SpringAiSchemaModule.java +++ b/spring-ai-model/src/main/java/org/springframework/ai/util/json/schema/SpringAiSchemaModule.java @@ -16,6 +16,7 @@ package org.springframework.ai.util.json.schema; +import java.lang.annotation.Annotation; import java.util.stream.Stream; import com.fasterxml.jackson.annotation.JsonProperty; @@ -104,7 +105,8 @@ private boolean checkRequired(MemberScope member) { || schemaAnnotation.requiredMode() == Schema.RequiredMode.AUTO || schemaAnnotation.required(); } - var nullableAnnotation = member.getAnnotationConsideringFieldAndGetter(Nullable.class); + var nullableAnnotation = member.getAnnotationConsideringFieldAndGetter(Annotation.class, + ann -> ann.annotationType().getSimpleName().equals("Nullable")); if (nullableAnnotation != null) { return false; } diff --git a/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java b/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java index c0c61f7eab6..3845d399676 100644 --- a/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java +++ b/spring-ai-model/src/test/java/org/springframework/ai/util/json/JsonSchemaGeneratorTests.java @@ -34,7 +34,6 @@ import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.ai.util.json.schema.JsonSchemaGenerator; -import org.springframework.lang.Nullable; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -216,6 +215,171 @@ void generateSchemaForMethodWithNullableAnnotations() throws Exception { assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); } + @Test + void generateSchemaForMethodWithNullableAnnotations_FromJavax() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("nullableMethod_FromJavax", String.class, String.class); + + String schema = JsonSchemaGenerator.generateForMethodInput(method); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "password" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForMethodWithNullableAnnotations_FromJakarta() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("nullableMethod_FromJakarta", String.class, String.class); + + String schema = JsonSchemaGenerator.generateForMethodInput(method); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "password" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForMethodWithNullableAnnotations_FromReactor() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("nullableMethod_FromReactor", String.class, String.class); + + String schema = JsonSchemaGenerator.generateForMethodInput(method); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "password" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForMethodWithNullableAnnotations_FromMicrometerContext() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("nullableMethod_FromMicrometerContext", String.class, + String.class); + + String schema = JsonSchemaGenerator.generateForMethodInput(method); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "password" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForMethodWithNullableAnnotations_FromMicrometerCommonLang() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("nullableMethod_FromMicrometerCommonLang", String.class, + String.class); + + String schema = JsonSchemaGenerator.generateForMethodInput(method); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "password" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForMethodWithNullableAnnotations_FromMicrometerCoreLang() throws Exception { + Method method = TestMethods.class.getDeclaredMethod("nullableMethod_FromMicrometerCoreLang", String.class, + String.class); + + String schema = JsonSchemaGenerator.generateForMethodInput(method); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "password" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + @Test void generateSchemaForMethodWithAdditionalPropertiesAllowed() throws Exception { Method method = TestMethods.class.getDeclaredMethod("simpleMethod", String.class, int.class); @@ -542,7 +706,187 @@ void generateSchemaForTypeWithJacksonAnnotation() { @Test void generateSchemaForTypeWithNullableAnnotation() { - String schema = JsonSchemaGenerator.generateForType(JacksonPerson.class); + String schema = JsonSchemaGenerator.generateForType(NullablePerson.class); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForTypeWithNullableAnnotation_FromJavax() { + String schema = JsonSchemaGenerator.generateForType(NullablePerson_FromJavax.class); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForTypeWithNullableAnnotation_FromJakarta() { + String schema = JsonSchemaGenerator.generateForType(NullablePerson_FromJakarta.class); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForTypeWithNullableAnnotation_FromReactor() { + String schema = JsonSchemaGenerator.generateForType(NullablePerson_FromReactor.class); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForTypeWithNullableAnnotation_FromMicrometerContext() { + String schema = JsonSchemaGenerator.generateForType(NullablePerson_FromMicrometerContext.class); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForTypeWithNullableAnnotation_FromMicrometerCommonLang() { + String schema = JsonSchemaGenerator.generateForType(NullablePerson_FromMicrometerCommonLang.class); + String expectedJsonSchema = """ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + """; + + assertThat(schema).isEqualToIgnoringWhitespace(expectedJsonSchema); + } + + @Test + void generateSchemaForTypeWithNullableAnnotation_FromMicrometerCoreLang() { + String schema = JsonSchemaGenerator.generateForType(NullablePerson_FromMicrometerCoreLang.class); String expectedJsonSchema = """ { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -681,7 +1025,28 @@ public void jacksonMethod( @JsonProperty(required = true) String password) { } - public void nullableMethod(@Nullable String username, String password) { + public void nullableMethod(@org.springframework.lang.Nullable String username, String password) { + } + + public void nullableMethod_FromJavax(@javax.annotation.Nullable String username, String password) { + } + + public void nullableMethod_FromJakarta(@jakarta.annotation.Nullable String username, String password) { + } + + public void nullableMethod_FromReactor(@reactor.util.annotation.Nullable String username, String password) { + } + + public void nullableMethod_FromMicrometerContext(@io.micrometer.context.Nullable String username, + String password) { + } + + public void nullableMethod_FromMicrometerCommonLang(@io.micrometer.common.lang.Nullable String username, + String password) { + } + + public void nullableMethod_FromMicrometerCoreLang(@io.micrometer.core.lang.Nullable String username, + String password) { } public void complexMethod(List items, TestData data, MoreTestData moreData) { @@ -721,7 +1086,32 @@ record OpenApiPerson(@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int id } - record NullablePerson(int id, String name, @Nullable String email) { + record NullablePerson(int id, String name, @org.springframework.lang.Nullable String email) { + + } + + record NullablePerson_FromJavax(int id, String name, @javax.annotation.Nullable String email) { + + } + + record NullablePerson_FromJakarta(int id, String name, @jakarta.annotation.Nullable String email) { + + } + + record NullablePerson_FromReactor(int id, String name, @reactor.util.annotation.Nullable String email) { + + } + + record NullablePerson_FromMicrometerContext(int id, String name, @io.micrometer.context.Nullable String email) { + + } + + record NullablePerson_FromMicrometerCommonLang(int id, String name, + @io.micrometer.common.lang.Nullable String email) { + + } + + record NullablePerson_FromMicrometerCoreLang(int id, String name, @io.micrometer.core.lang.Nullable String email) { }