Skip to content

Commit 756a9dc

Browse files
devcrocodtzolov
authored andcommitted
feat: Add Kotlin support for JSON schema generation
Add support for generating JSON schemas from Kotlin data classes with proper handling of: - Nullability (nullable vs non-nullable properties) - Required properties (constructor parameters without defaults) - Default values - Property name resolution Includes: - Add kotlin-reflect dependency - Implement KotlinModule for JSON schema generation - Add Kotlin integration tests - Downgrade Kotlin version to 1.9.25
1 parent fe3db2a commit 756a9dc

File tree

5 files changed

+192
-0
lines changed

5 files changed

+192
-0
lines changed

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@
177177
<azure-open-ai-client.version>1.0.0-beta.13</azure-open-ai-client.version>
178178
<jtokkit.version>1.1.0</jtokkit.version>
179179
<victools.version>4.31.1</victools.version>
180+
<kotlin.version>1.9.25</kotlin.version>
180181

181182
<!-- NOTE: keep bedrockruntime and awssdk versions aligned -->
182183
<bedrockruntime.version>2.29.29</bedrockruntime.version>

spring-ai-core/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,13 @@
144144
<optional>true</optional>
145145
</dependency>
146146

147+
<dependency>
148+
<groupId>org.jetbrains.kotlin</groupId>
149+
<artifactId>kotlin-reflect</artifactId>
150+
<version>${kotlin.version}</version>
151+
<optional>true</optional>
152+
</dependency>
153+
147154
<!-- test dependencies -->
148155
<dependency>
149156
<groupId>org.springframework.boot</groupId>
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package org.springframework.ai.model;
2+
3+
import com.github.victools.jsonschema.generator.*;
4+
import com.github.victools.jsonschema.generator.Module;
5+
import kotlin.jvm.JvmClassMappingKt;
6+
import kotlin.reflect.*;
7+
import kotlin.reflect.full.KClasses;
8+
import kotlin.reflect.jvm.ReflectJvmMapping;
9+
import org.springframework.core.KotlinDetector;
10+
11+
import java.lang.reflect.Field;
12+
import java.util.HashSet;
13+
import java.util.Set;
14+
15+
public class KotlinModule implements Module {
16+
17+
@Override
18+
public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) {
19+
SchemaGeneratorConfigPart<FieldScope> fieldConfigPart = builder.forFields();
20+
// SchemaGeneratorConfigPart<MethodScope> methodConfigPart = builder.forMethods();
21+
22+
this.applyToConfigBuilderPart(fieldConfigPart);
23+
// this.applyToConfigBuilderPart(methodConfigPart);
24+
}
25+
26+
private void applyToConfigBuilderPart(SchemaGeneratorConfigPart<?> configPart) {
27+
configPart.withNullableCheck(this::isNullable);
28+
configPart.withPropertyNameOverrideResolver(this::getPropertyName);
29+
configPart.withRequiredCheck(this::isRequired);
30+
configPart.withIgnoreCheck(this::shouldIgnore);
31+
}
32+
33+
private Boolean isNullable(MemberScope<?, ?> member) {
34+
KProperty<?> kotlinProperty = getKotlinProperty(member);
35+
if (kotlinProperty != null) {
36+
return kotlinProperty.getReturnType().isMarkedNullable();
37+
}
38+
return null;
39+
}
40+
41+
private String getPropertyName(MemberScope<?, ?> member) {
42+
KProperty<?> kotlinProperty = getKotlinProperty(member);
43+
if (kotlinProperty != null) {
44+
return kotlinProperty.getName();
45+
}
46+
return null;
47+
}
48+
49+
private boolean isRequired(MemberScope<?, ?> member) {
50+
KProperty<?> kotlinProperty = getKotlinProperty(member);
51+
if (kotlinProperty != null) {
52+
KType returnType = kotlinProperty.getReturnType();
53+
boolean isNonNullable = !returnType.isMarkedNullable();
54+
55+
Class<?> declaringClass = member.getDeclaringType().getErasedType();
56+
KClass<?> kotlinClass = JvmClassMappingKt.getKotlinClass(declaringClass);
57+
58+
Set<String> constructorParamsWithoutDefault = getConstructorParametersWithoutDefault(kotlinClass);
59+
60+
boolean isInConstructor = constructorParamsWithoutDefault.contains(kotlinProperty.getName());
61+
62+
return isNonNullable && isInConstructor;
63+
}
64+
65+
return false;
66+
}
67+
68+
private boolean shouldIgnore(MemberScope<?, ?> member) {
69+
return member.getRawMember().isSynthetic(); // Ignore generated properties/methods
70+
}
71+
72+
private KProperty<?> getKotlinProperty(MemberScope<?, ?> member) {
73+
Class<?> declaringClass = member.getDeclaringType().getErasedType();
74+
if (KotlinDetector.isKotlinType(declaringClass)) {
75+
KClass<?> kotlinClass = JvmClassMappingKt.getKotlinClass(declaringClass);
76+
for (KProperty<?> prop : KClasses.getMemberProperties(kotlinClass)) {
77+
Field javaField = ReflectJvmMapping.getJavaField(prop);
78+
if (javaField != null && javaField.equals(member.getRawMember())) {
79+
return prop;
80+
}
81+
}
82+
}
83+
return null;
84+
}
85+
86+
private Set<String> getConstructorParametersWithoutDefault(KClass<?> kotlinClass) {
87+
Set<String> paramsWithoutDefault = new HashSet<>();
88+
KFunction<?> primaryConstructor = KClasses.getPrimaryConstructor(kotlinClass);
89+
if (primaryConstructor != null) {
90+
primaryConstructor.getParameters().forEach(param -> {
91+
if (param.getKind() != KParameter.Kind.INSTANCE && !param.isOptional()) {
92+
String name = param.getName();
93+
if (name != null) {
94+
paramsWithoutDefault.add(name);
95+
}
96+
}
97+
});
98+
}
99+
100+
return paramsWithoutDefault;
101+
}
102+
103+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import org.springframework.ai.util.JacksonUtils;
5252
import org.springframework.beans.BeanWrapper;
5353
import org.springframework.beans.BeanWrapperImpl;
54+
import org.springframework.core.KotlinDetector;
5455
import org.springframework.util.Assert;
5556
import org.springframework.util.CollectionUtils;
5657
import org.springframework.util.ObjectUtils;
@@ -369,6 +370,10 @@ public static String getJsonSchema(Class<?> clazz, boolean toUpperCaseTypeValues
369370
.with(swaggerModule)
370371
.with(jacksonModule);
371372

373+
if (KotlinDetector.isKotlinReflectPresent()) {
374+
configBuilder.with(new KotlinModule());
375+
}
376+
372377
SchemaGeneratorConfig config = configBuilder.build();
373378
SchemaGenerator generator = new SchemaGenerator(config);
374379
SCHEMA_GENERATOR_CACHE.compareAndSet(null, generator);
@@ -403,6 +408,10 @@ public static String getJsonSchema(Type inputType, boolean toUpperCaseTypeValues
403408
.with(swaggerModule)
404409
.with(jacksonModule);
405410

411+
if (KotlinDetector.isKotlinReflectPresent()) {
412+
configBuilder.with(new KotlinModule());
413+
}
414+
406415
SchemaGeneratorConfig config = configBuilder.build();
407416
SchemaGenerator generator = new SchemaGenerator(config);
408417
SCHEMA_GENERATOR_CACHE.compareAndSet(null, generator);
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package org.springframework.ai.model
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import org.assertj.core.api.Assertions.assertThat
5+
import org.junit.jupiter.api.Test
6+
import java.lang.reflect.Type
7+
8+
class KotlinModelOptionsUtilsTests {
9+
10+
private class Foo(val bar: String, val baz: String?)
11+
private class FooWithDefault(val bar: String, val baz: Int = 10)
12+
13+
private val objectMapper = ObjectMapper()
14+
15+
@Test
16+
fun `test ModelOptionsUtils with Kotlin data class`() {
17+
val portableOptions = Foo("John", "Doe")
18+
19+
val optionsMap = ModelOptionsUtils.objectToMap(portableOptions)
20+
assertThat(optionsMap).containsEntry("bar", "John")
21+
assertThat(optionsMap).containsEntry("baz", "Doe")
22+
23+
val newPortableOptions = ModelOptionsUtils.mapToClass(optionsMap, Foo::class.java)
24+
assertThat(newPortableOptions.bar).isEqualTo("John")
25+
assertThat(newPortableOptions.baz).isEqualTo("Doe")
26+
}
27+
28+
@Test
29+
fun `test Kotlin data class schema generation using getJsonSchema`() {
30+
val inputType: Type = Foo::class.java
31+
32+
val schemaJson = ModelOptionsUtils.getJsonSchema(inputType, false)
33+
34+
val schemaNode = objectMapper.readTree(schemaJson)
35+
36+
val required = schemaNode["required"]
37+
assertThat(required).isNotNull
38+
assertThat(required.toString()).contains("bar")
39+
assertThat(required.toString()).doesNotContain("baz")
40+
41+
val properties = schemaNode["properties"]
42+
assertThat(properties["bar"]["type"].asText()).isEqualTo("string")
43+
44+
val bazTypeNode = properties["baz"]["type"]
45+
if (bazTypeNode.isArray) {
46+
assertThat(bazTypeNode.toString()).contains("string")
47+
assertThat(bazTypeNode.toString()).contains("null")
48+
} else {
49+
assertThat(bazTypeNode.asText()).isEqualTo("string")
50+
}
51+
}
52+
53+
@Test
54+
fun `test data class with default values`() {
55+
val inputType: Type = FooWithDefault::class.java
56+
57+
val schemaJson = ModelOptionsUtils.getJsonSchema(inputType, false)
58+
59+
val schemaNode = objectMapper.readTree(schemaJson)
60+
61+
val required = schemaNode["required"]
62+
assertThat(required).isNotNull
63+
assertThat(required.toString()).contains("bar")
64+
assertThat(required.toString()).doesNotContain("baz")
65+
66+
val properties = schemaNode["properties"]
67+
assertThat(properties["bar"]["type"].asText()).isEqualTo("string")
68+
69+
val bazTypeNode = properties["baz"]["type"]
70+
assertThat(bazTypeNode.asText()).isEqualTo("integer")
71+
}
72+
}

0 commit comments

Comments
 (0)