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
This commit is contained in:
devcrocod
2024-11-19 18:23:19 +01:00
committed by Christian Tzolov
parent fe3db2a64d
commit 756a9dcc08
5 changed files with 192 additions and 0 deletions

View File

@@ -177,6 +177,7 @@
<azure-open-ai-client.version>1.0.0-beta.13</azure-open-ai-client.version>
<jtokkit.version>1.1.0</jtokkit.version>
<victools.version>4.31.1</victools.version>
<kotlin.version>1.9.25</kotlin.version>
<!-- NOTE: keep bedrockruntime and awssdk versions aligned -->
<bedrockruntime.version>2.29.29</bedrockruntime.version>

View File

@@ -144,6 +144,13 @@
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
<version>${kotlin.version}</version>
<optional>true</optional>
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@@ -0,0 +1,103 @@
package org.springframework.ai.model;
import com.github.victools.jsonschema.generator.*;
import com.github.victools.jsonschema.generator.Module;
import kotlin.jvm.JvmClassMappingKt;
import kotlin.reflect.*;
import kotlin.reflect.full.KClasses;
import kotlin.reflect.jvm.ReflectJvmMapping;
import org.springframework.core.KotlinDetector;
import java.lang.reflect.Field;
import java.util.HashSet;
import java.util.Set;
public class KotlinModule implements Module {
@Override
public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) {
SchemaGeneratorConfigPart<FieldScope> fieldConfigPart = builder.forFields();
// SchemaGeneratorConfigPart<MethodScope> methodConfigPart = builder.forMethods();
this.applyToConfigBuilderPart(fieldConfigPart);
// this.applyToConfigBuilderPart(methodConfigPart);
}
private void applyToConfigBuilderPart(SchemaGeneratorConfigPart<?> configPart) {
configPart.withNullableCheck(this::isNullable);
configPart.withPropertyNameOverrideResolver(this::getPropertyName);
configPart.withRequiredCheck(this::isRequired);
configPart.withIgnoreCheck(this::shouldIgnore);
}
private Boolean isNullable(MemberScope<?, ?> member) {
KProperty<?> kotlinProperty = getKotlinProperty(member);
if (kotlinProperty != null) {
return kotlinProperty.getReturnType().isMarkedNullable();
}
return null;
}
private String getPropertyName(MemberScope<?, ?> member) {
KProperty<?> kotlinProperty = getKotlinProperty(member);
if (kotlinProperty != null) {
return kotlinProperty.getName();
}
return null;
}
private boolean isRequired(MemberScope<?, ?> member) {
KProperty<?> kotlinProperty = getKotlinProperty(member);
if (kotlinProperty != null) {
KType returnType = kotlinProperty.getReturnType();
boolean isNonNullable = !returnType.isMarkedNullable();
Class<?> declaringClass = member.getDeclaringType().getErasedType();
KClass<?> kotlinClass = JvmClassMappingKt.getKotlinClass(declaringClass);
Set<String> constructorParamsWithoutDefault = getConstructorParametersWithoutDefault(kotlinClass);
boolean isInConstructor = constructorParamsWithoutDefault.contains(kotlinProperty.getName());
return isNonNullable && isInConstructor;
}
return false;
}
private boolean shouldIgnore(MemberScope<?, ?> member) {
return member.getRawMember().isSynthetic(); // Ignore generated properties/methods
}
private KProperty<?> getKotlinProperty(MemberScope<?, ?> member) {
Class<?> declaringClass = member.getDeclaringType().getErasedType();
if (KotlinDetector.isKotlinType(declaringClass)) {
KClass<?> kotlinClass = JvmClassMappingKt.getKotlinClass(declaringClass);
for (KProperty<?> prop : KClasses.getMemberProperties(kotlinClass)) {
Field javaField = ReflectJvmMapping.getJavaField(prop);
if (javaField != null && javaField.equals(member.getRawMember())) {
return prop;
}
}
}
return null;
}
private Set<String> getConstructorParametersWithoutDefault(KClass<?> kotlinClass) {
Set<String> paramsWithoutDefault = new HashSet<>();
KFunction<?> primaryConstructor = KClasses.getPrimaryConstructor(kotlinClass);
if (primaryConstructor != null) {
primaryConstructor.getParameters().forEach(param -> {
if (param.getKind() != KParameter.Kind.INSTANCE && !param.isOptional()) {
String name = param.getName();
if (name != null) {
paramsWithoutDefault.add(name);
}
}
});
}
return paramsWithoutDefault;
}
}

View File

@@ -51,6 +51,7 @@ import com.github.victools.jsonschema.module.swagger2.Swagger2Module;
import org.springframework.ai.util.JacksonUtils;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.core.KotlinDetector;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
@@ -369,6 +370,10 @@ public abstract class ModelOptionsUtils {
.with(swaggerModule)
.with(jacksonModule);
if (KotlinDetector.isKotlinReflectPresent()) {
configBuilder.with(new KotlinModule());
}
SchemaGeneratorConfig config = configBuilder.build();
SchemaGenerator generator = new SchemaGenerator(config);
SCHEMA_GENERATOR_CACHE.compareAndSet(null, generator);
@@ -403,6 +408,10 @@ public abstract class ModelOptionsUtils {
.with(swaggerModule)
.with(jacksonModule);
if (KotlinDetector.isKotlinReflectPresent()) {
configBuilder.with(new KotlinModule());
}
SchemaGeneratorConfig config = configBuilder.build();
SchemaGenerator generator = new SchemaGenerator(config);
SCHEMA_GENERATOR_CACHE.compareAndSet(null, generator);

View File

@@ -0,0 +1,72 @@
package org.springframework.ai.model
import com.fasterxml.jackson.databind.ObjectMapper
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.lang.reflect.Type
class KotlinModelOptionsUtilsTests {
private class Foo(val bar: String, val baz: String?)
private class FooWithDefault(val bar: String, val baz: Int = 10)
private val objectMapper = ObjectMapper()
@Test
fun `test ModelOptionsUtils with Kotlin data class`() {
val portableOptions = Foo("John", "Doe")
val optionsMap = ModelOptionsUtils.objectToMap(portableOptions)
assertThat(optionsMap).containsEntry("bar", "John")
assertThat(optionsMap).containsEntry("baz", "Doe")
val newPortableOptions = ModelOptionsUtils.mapToClass(optionsMap, Foo::class.java)
assertThat(newPortableOptions.bar).isEqualTo("John")
assertThat(newPortableOptions.baz).isEqualTo("Doe")
}
@Test
fun `test Kotlin data class schema generation using getJsonSchema`() {
val inputType: Type = Foo::class.java
val schemaJson = ModelOptionsUtils.getJsonSchema(inputType, false)
val schemaNode = objectMapper.readTree(schemaJson)
val required = schemaNode["required"]
assertThat(required).isNotNull
assertThat(required.toString()).contains("bar")
assertThat(required.toString()).doesNotContain("baz")
val properties = schemaNode["properties"]
assertThat(properties["bar"]["type"].asText()).isEqualTo("string")
val bazTypeNode = properties["baz"]["type"]
if (bazTypeNode.isArray) {
assertThat(bazTypeNode.toString()).contains("string")
assertThat(bazTypeNode.toString()).contains("null")
} else {
assertThat(bazTypeNode.asText()).isEqualTo("string")
}
}
@Test
fun `test data class with default values`() {
val inputType: Type = FooWithDefault::class.java
val schemaJson = ModelOptionsUtils.getJsonSchema(inputType, false)
val schemaNode = objectMapper.readTree(schemaJson)
val required = schemaNode["required"]
assertThat(required).isNotNull
assertThat(required.toString()).contains("bar")
assertThat(required.toString()).doesNotContain("baz")
val properties = schemaNode["properties"]
assertThat(properties["bar"]["type"].asText()).isEqualTo("string")
val bazTypeNode = properties["baz"]["type"]
assertThat(bazTypeNode.asText()).isEqualTo("integer")
}
}