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:
committed by
Christian Tzolov
parent
fe3db2a64d
commit
756a9dcc08
1
pom.xml
1
pom.xml
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user