diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index ddf4930..bd6d430 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -11,17 +11,17 @@ jobs: build_branch: runs-on: ubuntu-latest steps: - - name: Checkout sources - uses: actions/checkout@v4.2.2 + - name: Checkout sources + uses: actions/checkout@v4.2.2 - - name: Setup Java - uses: actions/setup-java@v4.6.0 - with: - distribution: 'temurin' - java-version: 23 + - name: Setup Java + uses: actions/setup-java@v4.6.0 + with: + distribution: 'temurin' + java-version: 23 - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4.2.2 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4.2.2 - - name: Build - run: ./gradlew build + - name: Build + run: ./gradlew build diff --git a/.github/workflows/build-main.yml b/.github/workflows/build-main.yml index f790bec..c9d6605 100644 --- a/.github/workflows/build-main.yml +++ b/.github/workflows/build-main.yml @@ -7,20 +7,20 @@ jobs: build_main: runs-on: ubuntu-latest steps: - - name: Checkout sources - uses: actions/checkout@v4.2.2 + - name: Checkout sources + uses: actions/checkout@v4.2.2 - - name: Setup Java - uses: actions/setup-java@v4.6.0 - with: - distribution: 'temurin' - java-version: 23 + - name: Setup Java + uses: actions/setup-java@v4.6.0 + with: + distribution: 'temurin' + java-version: 23 - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4.2.2 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4.2.2 - - name: Build - run: ./gradlew build sourcesJar dokkaGeneratePublicationHtml publish - env: - ORG_GRADLE_PROJECT_githubActor: ${{ secrets.GITHUBACTOR }} - ORG_GRADLE_PROJECT_githubToken: ${{ secrets.GITHUBTOKEN }} + - name: Build + run: ./gradlew build sourcesJar dokkaGeneratePublicationHtml publish + env: + ORG_GRADLE_PROJECT_githubActor: ${{ secrets.GITHUBACTOR }} + ORG_GRADLE_PROJECT_githubToken: ${{ secrets.GITHUBTOKEN }} diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 6c37346..23a5e72 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -1,7 +1,7 @@ name: Build release on: release: - types: [published] + types: [ published ] jobs: build_release: runs-on: ubuntu-latest @@ -10,49 +10,49 @@ jobs: # added or changed files to the repository. contents: write steps: - - name: Write release version - run: | - VERSION=${GITHUB_REF_NAME#v} - echo Version: $VERSION - echo "VERSION=$VERSION" >> $GITHUB_ENV + - name: Write release version + run: | + VERSION=${GITHUB_REF_NAME#v} + echo Version: $VERSION + echo "VERSION=$VERSION" >> $GITHUB_ENV - - name: Checkout sources - uses: actions/checkout@v4.2.2 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 + - name: Checkout sources + uses: actions/checkout@v4.2.2 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 - - name: Setup Java - uses: actions/setup-java@v4.6.0 - with: - distribution: 'temurin' - java-version: 23 + - name: Setup Java + uses: actions/setup-java@v4.6.0 + with: + distribution: 'temurin' + java-version: 23 - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4.2.2 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4.2.2 - - name: Build - env: - ORG_GRADLE_PROJECT_githubActor: ${{ secrets.GITHUBACTOR }} - ORG_GRADLE_PROJECT_githubToken: ${{ secrets.GITHUBTOKEN }} - ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SIGNING_KEY }} - ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SIGNING_PASSWORD }} - ORG_GRADLE_PROJECT_sonatypeUser: ${{ secrets.SONATYPE_USER }} - ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.SONATYPE_PASSWORD }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - run: ./gradlew -Pversion=$VERSION build sourcesJar dokkaGeneratePublicationHtml publishToSonatype closeAndReleaseSonatypeStagingRepository + - name: Build + env: + ORG_GRADLE_PROJECT_githubActor: ${{ secrets.GITHUBACTOR }} + ORG_GRADLE_PROJECT_githubToken: ${{ secrets.GITHUBTOKEN }} + ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SIGNING_PASSWORD }} + ORG_GRADLE_PROJECT_sonatypeUser: ${{ secrets.SONATYPE_USER }} + ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.SONATYPE_PASSWORD }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: ./gradlew -Pversion=$VERSION build sourcesJar dokkaGeneratePublicationHtml publishToSonatype closeAndReleaseSonatypeStagingRepository - - name: Checkout main branch - uses: actions/checkout@v4.2.2 - with: - ref: main - fetch-depth: 0 + - name: Checkout main branch + uses: actions/checkout@v4.2.2 + with: + ref: main + fetch-depth: 0 - - name: Update README - run: sh .github/scripts/update-readme-version.sh + - name: Update README + run: sh .github/scripts/update-readme-version.sh - - name: Commit README - uses: stefanzweifel/git-auto-commit-action@v5.0.1 - with: - commit_message: Dependency version in README.md updated to ${{ env.VERSION }} - file_pattern: 'README.md' + - name: Commit README + uses: stefanzweifel/git-auto-commit-action@v5.0.1 + with: + commit_message: Dependency version in README.md updated to ${{ env.VERSION }} + file_pattern: 'README.md' diff --git a/.github/workflows/updater.yml b/.github/workflows/updater.yml index 2e19eca..a1e6a9f 100644 --- a/.github/workflows/updater.yml +++ b/.github/workflows/updater.yml @@ -4,7 +4,7 @@ name: GitHub Actions Version Updater on: schedule: # Automatically run on every Sunday - - cron: '0 0 * * 0' + - cron: '0 0 * * 0' jobs: build: diff --git a/.idea/copyright/apache2_0.xml b/.idea/copyright/apache2_0.xml index 82555f1..3e8eec6 100644 --- a/.idea/copyright/apache2_0.xml +++ b/.idea/copyright/apache2_0.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/README.md b/README.md index ee2a00b..049949d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ # xemantic-ai-tool-schema -A Kotlin multiplatform JSON Schema library. Useful for AI and LLMs' -[tool use](https://docs.anthropic.com/en/docs/build-with-claude/tool-use) -([function calling](https://platform.openai.com/docs/guides/function-calling)), -as it generates JSON Schema for Kotlin `@Serializable` classes. +A Kotlin multiplatform JSON Schema library. Useful for AI and LLMs' [tool use](https://docs.anthropic.com/en/docs/build-with-claude/tool-use) ([function calling](https://platform.openai.com/docs/guides/function-calling)), as it generates JSON Schema for Kotlin `@Serializable` classes. [Maven Central Version](https://central.sonatype.com/namespace/com.xemantic.ai) [GitHub Release Date](https://github.com/xemantic/xemantic-ai-tool-schema/releases) @@ -26,31 +23,17 @@ as it generates JSON Schema for Kotlin `@Serializable` classes. ## Why? -This library was created to fulfill the need of agentic AI projects created by -[xemantic](https://xemantic.com/). In particular: +This library was created to fulfill the need of agentic AI projects created by [xemantic](https://xemantic.com/). In particular: -* [anthropic-sdk-kotlin](https://github.com/xemantic/anthropic-sdk-kotlin) - an unofficial Kotlin multiplatform variant - of [Anthropic SDK](https://docs.anthropic.com/en/api/client-sdks). +* [anthropic-sdk-kotlin](https://github.com/xemantic/anthropic-sdk-kotlin) - an unofficial Kotlin multiplatform variant of [Anthropic SDK](https://docs.anthropic.com/en/api/client-sdks). * [claudine](https://github.com/xemantic/claudine) - AI Agent build on top of this SDK. -These projects are heavily dependent on -[tool use](https://docs.anthropic.com/en/docs/build-with-claude/tool-use) -([function calling](https://platform.openai.com/docs/guides/function-calling)) functionality -provided by many Large Language Models. Thanks to `xemantic-ai-tool-schema`, a Kotlin class, -with possible additional constraints, can be automatically instantiated from -the JSON tool use input provided by the LLM. This way any manual steps of defining JSON schema -for the model are avoided, which reduce a chance for errors in the process, and allows to -rapidly develop even complex data structures passed to an AI agent. +These projects are heavily dependent on [tool use](https://docs.anthropic.com/en/docs/build-with-claude/tool-use) ([function calling](https://platform.openai.com/docs/guides/function-calling)) functionality provided by many Large Language Models. Thanks to `xemantic-ai-tool-schema`, a Kotlin class, with possible additional constraints, can be automatically instantiated from the JSON tool use input provided by the LLM. This way any manual steps of defining JSON schema for the model are avoided, which reduce a chance for errors in the process, and allows to rapidly develop even complex data structures passed to an AI agent. -In short the `xemantic-ai-tool-schema` library can generate a -[JSON Schema](https://json-schema.org/) from any Kotlin class marked as `@Serializable`, -according to [kotlinx.serialization](https://kotlinlang.org/docs/serialization.html). +In short the `xemantic-ai-tool-schema` library can generate a [JSON Schema](https://json-schema.org/) from any Kotlin class marked as `@Serializable`, according to [kotlinx.serialization](https://kotlinlang.org/docs/serialization.html). > [!TIP] -> You might be familiar with similar functionality of the -> [Pydantic](https://docs.pydantic.dev/latest/concepts/json_schema/#generating-json-schema) -> Python library, however, the standard Kotlin serialization is already fulfilling model -> metadata provisioning, so this analogy might be misleading. +> You might be familiar with similar functionality of the [Pydantic](https://docs.pydantic.dev/latest/concepts/json_schema/#generating-json-schema) Python library, however, the standard Kotlin serialization is already fulfilling model metadata provisioning, so this analogy might be misleading. ## Usage @@ -58,13 +41,13 @@ In `build.gradle.kts` add: ```kotlin plugins { - kotlin("multiplatform") version "2.1.0" // (or jvm for jvm-only project) - kotlin("plugin.serialization") version "2.1.0" + kotlin("multiplatform") version "2.1.0" // (or jvm for jvm-only project) + kotlin("plugin.serialization") version "2.1.0" } // ... dependencies { - implementation("com.xemantic.ai:xemantic-ai-tool-schema:0.1.4") + implementation("com.xemantic.ai:xemantic-ai-tool-schema:0.1.4") } ``` @@ -76,16 +59,16 @@ Then in your code you can define entities like this: @Title("The full address") @Description("An address of a person or an organization") data class Address( - val street: String, - val city: String, - @Description("A postal code not limited to particular country") - @MinLength(3) - @MaxLength(10) - val postalCode: String, - @Pattern("[a-z]{2}") - val countryCode: String, - @Format(StringFormat.EMAIL) - val email: String? = null + val street: String, + val city: String, + @Description("A postal code not limited to particular country") + @MinLength(3) + @MaxLength(10) + val postalCode: String, + @Pattern("[a-z]{2}") + val countryCode: String, + @Format(StringFormat.EMAIL) + val email: String? = null ) ``` @@ -95,8 +78,7 @@ And when `jsonSchemaOf()` function is invoked: val schema = jsonSchemaOf
() ``` -It will produce a [JsonSchema](src/commonMain/kotlin/JsonSchema.kt) instance, which -serializes to: +It will produce a [JsonSchema](src/commonMain/kotlin/JsonSchema.kt) instance, which serializes to: ```json { @@ -134,34 +116,24 @@ serializes to: } ``` -And this is the input accepted by Large Language Model APIs like -[OpenAI API](https://platform.openai.com/docs/api-reference/introduction) -and [Anthropic API](https://docs.anthropic.com/en/api/getting-started). When requesting a tool use, these LLMs -will send a JSON payload adhering to this schema, therefore -immediately deserializable as the original `@Serializable` Kotlin class. +And this is the input accepted by Large Language Model APIs like [OpenAI API](https://platform.openai.com/docs/api-reference/introduction) and [Anthropic API](https://docs.anthropic.com/en/api/getting-started). +When requesting a tool use, these LLMs will send a JSON payload adhering to this schema, therefore immediately deserializable as the original `@Serializable` Kotlin class. More details and use cases in the [JsonSchemaGeneratorTest](src/commonTest/kotlin/generator/JsonSchemaGeneratorTest.kt). > [!NOTE] -> When calling `toString()` function on any instance of `JsonSchema`, it will also produce a -> pretty printed `String` representation of a valid JSON schema, -> which in turn describes the Kotlin class as a serialized JSON. -> This functionality is useful for testing and debugging. +> When calling `toString()` function on any instance of `JsonSchema`, it will also produce a pretty printed `String` representation of a valid JSON schema, which in turn describes the Kotlin class as a serialized JSON. This functionality is useful for testing and debugging. ### Serializing Java `BigDecimal`s -For JVM-only projects, it is possible to specify `java.math.BigDecimal` serialization. -It will serialize decimal numbers to strings, and add `description` and `pattern` -properties to generated JSON Schema of a `BigDecimal` property. +For JVM-only projects, it is possible to specify `java.math.BigDecimal` serialization. It will serialize decimal numbers to strings, and add `description` and `pattern` properties to generated JSON Schema of a `BigDecimal` property. -See [JavaBigDecimalToSchemaTest](src/jvmTest/kotlin/serialization/JavaBigDecimalToSchemaTest.kt) -for details. +See [JavaBigDecimalToSchemaTest](src/jvmTest/kotlin/serialization/JavaBigDecimalToSchemaTest.kt) for details. ### Serializing BigDecimal/monetary values in multiplatform way -There is an interface called [Money](src/commonTest/kotlin/test/Money.kt) -defined in the tests of this project. It explains how to define and serialize monetary -amounts independently of the underlying decimal number and arithmetics provider. +There is an interface called [Money](src/commonTest/kotlin/test/Money.kt) defined in the tests of this project. It explains how to define and serialize monetary amounts independently of the underlying decimal number and arithmetics provider. +See also [xemantic-ai-money](https://github.com/xemantic/xemantic-ai-money]) project for a ready solution packaged as a library. ## Development @@ -171,9 +143,8 @@ Clone this repo and then in the project dir: ./gradlew build ``` -## Non-recommended usage +## Non-recommended usage > [!WARNING] -> Even though this library provides basic serializable representation of a JSON Schema, it is not -> meant to fully model general purpose JSON Schema. In particular, it should not be used for deserializing -> existing schemas from JSON. +> Even though this library provides basic serializable representation of a JSON Schema, it is not meant to fully model general purpose JSON Schema. +> In particular, it should not be used for deserializing existing schemas from JSON. diff --git a/api/xemantic-ai-tool-schema.api b/api/xemantic-ai-tool-schema.api index 3ef999d..1651726 100644 --- a/api/xemantic-ai-tool-schema.api +++ b/api/xemantic-ai-tool-schema.api @@ -17,7 +17,6 @@ public synthetic class com/xemantic/ai/tool/schema/ArraySchema$$serializer : kot public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/xemantic/ai/tool/schema/ArraySchema;)V public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; } public final class com/xemantic/ai/tool/schema/ArraySchema$Builder : com/xemantic/ai/tool/schema/BaseSchema$Builder { @@ -73,7 +72,6 @@ public synthetic class com/xemantic/ai/tool/schema/BooleanSchema$$serializer : k public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/xemantic/ai/tool/schema/BooleanSchema;)V public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; } public final class com/xemantic/ai/tool/schema/BooleanSchema$Builder : com/xemantic/ai/tool/schema/BaseSchema$Builder { @@ -120,7 +118,6 @@ public synthetic class com/xemantic/ai/tool/schema/IntegerSchema$$serializer : k public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/xemantic/ai/tool/schema/IntegerSchema;)V public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; } public final class com/xemantic/ai/tool/schema/IntegerSchema$Builder : com/xemantic/ai/tool/schema/NumericSchema$Builder { @@ -180,7 +177,6 @@ public synthetic class com/xemantic/ai/tool/schema/NumberSchema$$serializer : ko public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/xemantic/ai/tool/schema/NumberSchema;)V public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; } public final class com/xemantic/ai/tool/schema/NumberSchema$Builder : com/xemantic/ai/tool/schema/NumericSchema$Builder { @@ -233,7 +229,6 @@ public synthetic class com/xemantic/ai/tool/schema/ObjectSchema$$serializer : ko public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/xemantic/ai/tool/schema/ObjectSchema;)V public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; } public final class com/xemantic/ai/tool/schema/ObjectSchema$Builder : com/xemantic/ai/tool/schema/BaseSchema$Builder { @@ -312,7 +307,6 @@ public synthetic class com/xemantic/ai/tool/schema/StringSchema$$serializer : ko public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/xemantic/ai/tool/schema/StringSchema;)V public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V - public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; } public final class com/xemantic/ai/tool/schema/StringSchema$Builder : com/xemantic/ai/tool/schema/BaseSchema$Builder { diff --git a/build.gradle.kts b/build.gradle.kts index f68bb47..50d917b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,15 +25,15 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.swiftexport.ExperimentalSwiftExportDsl plugins { - alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.kotlin.plugin.serialization) - alias(libs.plugins.kotlin.plugin.power.assert) - alias(libs.plugins.kotlinx.binary.compatibility.validator) - alias(libs.plugins.dokka) - alias(libs.plugins.versions) - `maven-publish` - signing - alias(libs.plugins.publish) + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.plugin.serialization) + alias(libs.plugins.kotlin.plugin.power.assert) + alias(libs.plugins.kotlinx.binary.compatibility.validator) + alias(libs.plugins.dokka) + alias(libs.plugins.versions) + `maven-publish` + signing + alias(libs.plugins.publish) } val githubAccount = "xemantic" @@ -49,7 +49,8 @@ val signingPassword: String? by project val sonatypeUser: String? by project val sonatypePassword: String? by project -println(""" +println( +""" +-------------------------------------------- | Project: ${project.name} | Version: ${project.version} @@ -59,126 +60,126 @@ println(""" ) repositories { - mavenCentral() + mavenCentral() } kotlin { - applyDefaultHierarchyTemplate() - - explicitApi() + applyDefaultHierarchyTemplate() - compilerOptions { - apiVersion = kotlinTarget - languageVersion = kotlinTarget - freeCompilerArgs.add("-Xmulti-dollar-interpolation") - extraWarnings.set(true) - progressiveMode = true - } + explicitApi() - jvm { - // set up according to https://jakewharton.com/gradle-toolchains-are-rarely-a-good-idea/ compilerOptions { - apiVersion = kotlinTarget - languageVersion = kotlinTarget - jvmTarget = JvmTarget.fromTarget(javaTarget) - freeCompilerArgs.add("-Xjdk-release=$javaTarget") - progressiveMode = true - } - } - - js { - browser() - nodejs() - binaries.library() - } - - wasmJs { - browser() - nodejs() - //d8() - binaries.library() - } - - wasmWasi { - nodejs() - binaries.library() - } - - // native, see https://kotlinlang.org/docs/native-target-support.html - // tier 1 - macosX64() - macosArm64() - iosSimulatorArm64() - iosX64() - iosArm64() - - // tier 2 - linuxX64() - linuxArm64() - watchosSimulatorArm64() - watchosX64() - watchosArm32() - watchosArm64() - tvosSimulatorArm64() - tvosX64() - tvosArm64() - - // tier 3 - androidNativeArm32() - androidNativeArm64() - androidNativeX86() - androidNativeX64() - mingwX64() - watchosDeviceArm64() - - @OptIn(ExperimentalSwiftExportDsl::class) - swiftExport {} - - sourceSets { - - commonMain { - dependencies { - implementation(libs.kotlinx.serialization.json) - } + apiVersion = kotlinTarget + languageVersion = kotlinTarget + freeCompilerArgs.add("-Xmulti-dollar-interpolation") + extraWarnings.set(true) + progressiveMode = true } - commonTest { - dependencies { - implementation(libs.kotlin.test) - implementation(libs.xemantic.kotlin.test) - implementation(libs.kotest.assertions.json) - implementation(libs.kotlinx.datetime) - implementation(libs.bignum) - } + jvm { + // set up according to https://jakewharton.com/gradle-toolchains-are-rarely-a-good-idea/ + compilerOptions { + apiVersion = kotlinTarget + languageVersion = kotlinTarget + jvmTarget = JvmTarget.fromTarget(javaTarget) + freeCompilerArgs.add("-Xjdk-release=$javaTarget") + progressiveMode = true + } } - val nonJvmTest by creating { - dependsOn(commonTest.get()) + js { + browser() + nodejs() + binaries.library() } - nativeTest { - dependsOn(nonJvmTest) + wasmJs { + browser() + nodejs() + //d8() + binaries.library() } - jsTest { - dependsOn(nonJvmTest) + wasmWasi { + nodejs() + binaries.library() } - wasmJsTest { - dependsOn(nonJvmTest) - } + // native, see https://kotlinlang.org/docs/native-target-support.html + // tier 1 + macosX64() + macosArm64() + iosSimulatorArm64() + iosX64() + iosArm64() + + // tier 2 + linuxX64() + linuxArm64() + watchosSimulatorArm64() + watchosX64() + watchosArm32() + watchosArm64() + tvosSimulatorArm64() + tvosX64() + tvosArm64() + + // tier 3 + androidNativeArm32() + androidNativeArm64() + androidNativeX86() + androidNativeX64() + mingwX64() + watchosDeviceArm64() + + @OptIn(ExperimentalSwiftExportDsl::class) + swiftExport {} + + sourceSets { + + commonMain { + dependencies { + implementation(libs.kotlinx.serialization.json) + } + } - wasmWasiTest { - dependsOn(nonJvmTest) - } + commonTest { + dependencies { + implementation(libs.kotlin.test) + implementation(libs.xemantic.kotlin.test) + implementation(libs.kotest.assertions.json) + implementation(libs.kotlinx.datetime) + implementation(libs.bignum) + } + } + + val nonJvmTest by creating { + dependsOn(commonTest.get()) + } - } + nativeTest { + dependsOn(nonJvmTest) + } + + jsTest { + dependsOn(nonJvmTest) + } + + wasmJsTest { + dependsOn(nonJvmTest) + } + + wasmWasiTest { + dependsOn(nonJvmTest) + } + + } } // skip test for certain targets which are not fully supported by kotest -tasks.named("compileTestKotlinWasmWasi") { enabled = false} +tasks.named("compileTestKotlinWasmWasi") { enabled = false } tasks.named("compileTestKotlinAndroidNativeArm32") { enabled = false } tasks.named("compileTestKotlinAndroidNativeArm64") { enabled = false } tasks.named("compileTestKotlinAndroidNativeX86") { enabled = false } @@ -190,136 +191,136 @@ tasks.named("tvosSimulatorArm64Test") { enabled = false } tasks.named("watchosSimulatorArm64Test") { enabled = false } tasks.withType { - testLogging { - events( - TestLogEvent.SKIPPED, - TestLogEvent.FAILED - ) - showStackTraces = true - exceptionFormat = TestExceptionFormat.FULL - } + testLogging { + events( + TestLogEvent.SKIPPED, + TestLogEvent.FAILED + ) + showStackTraces = true + exceptionFormat = TestExceptionFormat.FULL + } } powerAssert { - functions = listOf( - "com.xemantic.kotlin.test.have" - ) + functions = listOf( + "com.xemantic.kotlin.test.have" + ) } // https://kotlinlang.org/docs/dokka-migration.html#adjust-configuration-options dokka { - pluginsConfiguration.html { - footerMessage.set("(c) 2024 Xemantic") - } + pluginsConfiguration.html { + footerMessage.set("(c) 2024 Xemantic") + } } val javadocJar by tasks.registering(Jar::class) { - archiveClassifier.set("javadoc") - from(tasks.dokkaGeneratePublicationHtml) + archiveClassifier.set("javadoc") + from(tasks.dokkaGeneratePublicationHtml) } publishing { - repositories { - if (!isReleaseBuild) { - maven { - name = "GitHubPackages" - setUrl("https://maven.pkg.github.com/$githubAccount/${rootProject.name}") - credentials { - username = githubActor - password = githubToken + repositories { + if (!isReleaseBuild) { + maven { + name = "GitHubPackages" + setUrl("https://maven.pkg.github.com/$githubAccount/${rootProject.name}") + credentials { + username = githubActor + password = githubToken + } + } } - } } - } - publications { - withType { - artifact(javadocJar) - pom { - name = "xemantic-ai-tool-schema" - description = "Kotlin multiplatform AI/LLM tool use (function calling) JSON Schema generator" - url = "https://github.com/$githubAccount/${rootProject.name}" - inceptionYear = "2024" - organization { - name = "Xemantic" - url = "https://xemantic.com" - } - licenses { - license { - name = "The Apache Software License, Version 2.0" - url = "http://www.apache.org/licenses/LICENSE-2.0.txt" - distribution = "repo" - } - } - scm { - url = "https://github.com/$githubAccount/${rootProject.name}" - connection = "scm:git:git:github.com/$githubAccount/${rootProject.name}.git" - developerConnection = "scm:git:https://github.com/$githubAccount/${rootProject.name}.git" - } - ciManagement { - system = "GitHub" - url = "https://github.com/$githubAccount/${rootProject.name}/actions" - } - issueManagement { - system = "GitHub" - url = "https://github.com/$githubAccount/${rootProject.name}/issues" + publications { + withType { + artifact(javadocJar) + pom { + name = "xemantic-ai-tool-schema" + description = "Kotlin multiplatform AI/LLM tool use (function calling) JSON Schema generator" + url = "https://github.com/$githubAccount/${rootProject.name}" + inceptionYear = "2024" + organization { + name = "Xemantic" + url = "https://xemantic.com" + } + licenses { + license { + name = "The Apache Software License, Version 2.0" + url = "http://www.apache.org/licenses/LICENSE-2.0.txt" + distribution = "repo" + } + } + scm { + url = "https://github.com/$githubAccount/${rootProject.name}" + connection = "scm:git:git:github.com/$githubAccount/${rootProject.name}.git" + developerConnection = "scm:git:https://github.com/$githubAccount/${rootProject.name}.git" + } + ciManagement { + system = "GitHub" + url = "https://github.com/$githubAccount/${rootProject.name}/actions" + } + issueManagement { + system = "GitHub" + url = "https://github.com/$githubAccount/${rootProject.name}/issues" + } + developers { + developer { + id = "morisil" + name = "Kazik Pogoda" + email = "morisil@xemantic.com" + } + } + } } - developers { - developer { - id = "morisil" - name = "Kazik Pogoda" - email = "morisil@xemantic.com" - } - } - } } - } } if (isReleaseBuild) { - // workaround for KMP/gradle signing issue - // https://github.com/gradle/gradle/issues/26091 - tasks { - withType { - dependsOn(withType()) + // workaround for KMP/gradle signing issue + // https://github.com/gradle/gradle/issues/26091 + tasks { + withType { + dependsOn(withType()) + } } - } - // Resolves issues with .asc task output of the sign task of native targets. - // See: https://github.com/gradle/gradle/issues/26132 - // And: https://youtrack.jetbrains.com/issue/KT-46466 - tasks.withType().configureEach { - val pubName = name.removePrefix("sign").removeSuffix("Publication") + // Resolves issues with .asc task output of the sign task of native targets. + // See: https://github.com/gradle/gradle/issues/26132 + // And: https://youtrack.jetbrains.com/issue/KT-46466 + tasks.withType().configureEach { + val pubName = name.removePrefix("sign").removeSuffix("Publication") - // These tasks only exist for native targets, hence findByName() to avoid trying to find them for other targets + // These tasks only exist for native targets, hence findByName() to avoid trying to find them for other targets - // Task ':linkDebugTest' uses this output of task ':signPublication' without declaring an explicit or implicit dependency - tasks.findByName("linkDebugTest$pubName")?.let { - mustRunAfter(it) - } - // Task ':compileTestKotlin' uses this output of task ':signPublication' without declaring an explicit or implicit dependency - tasks.findByName("compileTestKotlin$pubName")?.let { - mustRunAfter(it) + // Task ':linkDebugTest' uses this output of task ':signPublication' without declaring an explicit or implicit dependency + tasks.findByName("linkDebugTest$pubName")?.let { + mustRunAfter(it) + } + // Task ':compileTestKotlin' uses this output of task ':signPublication' without declaring an explicit or implicit dependency + tasks.findByName("compileTestKotlin$pubName")?.let { + mustRunAfter(it) + } } - } - signing { - useInMemoryPgpKeys( - signingKey, - signingPassword - ) - sign(publishing.publications) - } + signing { + useInMemoryPgpKeys( + signingKey, + signingPassword + ) + sign(publishing.publications) + } - nexusPublishing { - repositories { - sonatype { //only for users registered in Sonatype after 24 Feb 2021 - nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/")) - snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")) - username.set(sonatypeUser) - password.set(sonatypePassword) - } + nexusPublishing { + repositories { + sonatype { //only for users registered in Sonatype after 24 Feb 2021 + nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/")) + snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/")) + username.set(sonatypeUser) + password.set(sonatypePassword) + } + } } - } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa7384f..afbb19f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,27 +3,27 @@ kotlinTarget = "2.1" javaTarget = "17" kotlin = "2.1.0" -kotlinxSerialization = "1.7.3" +kotlinxSerialization = "1.8.0" kotlinxDatetime = "0.6.1" -xemanticKotlinTest = "1.0" +xemanticKotlinTest = "1.2" kotest = "6.0.0.M1" bignum = "0.3.10" versionsPlugin = "0.51.0" -dokkaPlugin = "2.0.0-Beta" +dokkaPlugin = "2.0.0" publishPlugin = "2.0.0" -binaryCompatibilityValidatorPlugin = "0.16.3" +binaryCompatibilityValidatorPlugin = "0.17.0" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } -kotlinx-datetime = { module="org.jetbrains.kotlinx:kotlinx-datetime", version.ref="kotlinxDatetime" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } -xemantic-kotlin-test = { module="com.xemantic.kotlin:xemantic-kotlin-test", version.ref="xemanticKotlinTest" } +xemantic-kotlin-test = { module = "com.xemantic.kotlin:xemantic-kotlin-test", version.ref = "xemanticKotlinTest" } kotest-assertions-json = { module = "io.kotest:kotest-assertions-json", version.ref = "kotest" } bignum = { module = "com.ionspin.kotlin:bignum", version.ref = "bignum" } @@ -35,4 +35,4 @@ kotlin-plugin-power-assert = { id = "org.jetbrains.kotlin.plugin.power-assert", dokka = { id = "org.jetbrains.dokka", version.ref = "dokkaPlugin" } versions = { id = "com.github.ben-manes.versions", version.ref = "versionsPlugin" } publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "publishPlugin" } -kotlinx-binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref ="binaryCompatibilityValidatorPlugin" } +kotlinx-binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidatorPlugin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c8..84fcff6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,22 @@ +# +# Copyright 2025 Kazimierz Pogoda / Xemantic +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index a9aec0b..eba9b31 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,23 @@ +/* + * Copyright 2025 Kazimierz Pogoda / Xemantic + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + val groupId = "com.xemantic.ai" val name = "xemantic-ai-tool-schema" rootProject.name = name gradle.beforeProject { - group = groupId + group = groupId } diff --git a/src/commonMain/kotlin/JsonSchema.kt b/src/commonMain/kotlin/JsonSchema.kt index 79c24cd..e9d0d43 100644 --- a/src/commonMain/kotlin/JsonSchema.kt +++ b/src/commonMain/kotlin/JsonSchema.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,6 @@ import com.xemantic.ai.tool.schema.serialization.JsonSchemaSerializer import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json /** @@ -29,26 +28,26 @@ import kotlinx.serialization.json.Json @Serializable(with = JsonSchemaSerializer::class) public sealed interface JsonSchema { - /** - * Refers another [JsonSchema] through [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901). - * - * @param ref a string representing JSON Pointer. Note: in JSON it will be serialized as `$ref`. - * @throws IllegalArgumentException if the [ref] does not start with `#/`. - */ - public class Ref( - @SerialName("\$ref") - public val ref: String - ) : JsonSchema { - - init { - require(ref.startsWith("#/")) { - "The 'ref' must start with '#/'" - } - } - - override fun toString(): String = $$"""{"$ref": "$$ref"}""" + /** + * Refers another [JsonSchema] through [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901). + * + * @param ref a string representing JSON Pointer. Note: in JSON it will be serialized as `$ref`. + * @throws IllegalArgumentException if the [ref] does not start with `#/`. + */ + public class Ref( + @SerialName("\$ref") + public val ref: String + ) : JsonSchema { + + init { + require(ref.startsWith("#/")) { + "The 'ref' must start with '#/'" + } + } + + override fun toString(): String = $$"""{"$ref": "$$ref"}""" - } + } } @@ -56,16 +55,16 @@ public sealed interface JsonSchema { public sealed class BaseSchema( ) : JsonSchema { - public abstract val title: String? - public abstract val description: String? + public abstract val title: String? + public abstract val description: String? - public open class Builder { - public var title: String? = null - public var description: String? = null - } + public open class Builder { + public var title: String? = null + public var description: String? = null + } - override fun toString(): String = - jsonSchemaToStringJson.encodeToString(this) + override fun toString(): String = + jsonSchemaToStringJson.encodeToString(this) } @@ -75,36 +74,36 @@ public sealed class BaseSchema( @Serializable @SerialName("object") public class ObjectSchema private constructor( - override val title: String? = null, - override val description: String? = null, - public val properties: Map? = null, - public val required: List? = null, - public val definitions: Map? = null, - public val additionalProperties: Boolean? = null + override val title: String? = null, + override val description: String? = null, + public val properties: Map? = null, + public val required: List? = null, + public val definitions: Map? = null, + public val additionalProperties: Boolean? = null ) : BaseSchema() { - public class Builder : BaseSchema.Builder() { + public class Builder : BaseSchema.Builder() { - public var properties: Map? = null - public var required: List? = null - public var definitions: Map? = null - public var additionalProperties: Boolean? = null + public var properties: Map? = null + public var required: List? = null + public var definitions: Map? = null + public var additionalProperties: Boolean? = null - public fun build(): ObjectSchema = ObjectSchema( - title, - description, - properties, - required, - definitions, - additionalProperties - ) + public fun build(): ObjectSchema = ObjectSchema( + title, + description, + properties, + required, + definitions, + additionalProperties + ) - } + } } public fun ObjectSchema( - block: ObjectSchema.Builder.() -> Unit + block: ObjectSchema.Builder.() -> Unit ): ObjectSchema = ObjectSchema.Builder().also(block).build() /** @@ -113,50 +112,53 @@ public fun ObjectSchema( @Serializable @SerialName("array") public class ArraySchema private constructor( - override val title: String? = null, - override val description: String? = null, - public val items: JsonSchema, - public val minItems: Long? = null, - public val maxItems: Long? = null, - public val uniqueItems: Boolean? = null, + override val title: String? = null, + override val description: String? = null, + public val items: JsonSchema, + public val minItems: Long? = null, + public val maxItems: Long? = null, + public val uniqueItems: Boolean? = null, ) : BaseSchema() { - public class Builder : BaseSchema.Builder() { + public class Builder : BaseSchema.Builder() { - public var items: JsonSchema? = null - public var minItems: Long? = null - public var maxItems: Long? = null - public var uniqueItems: Boolean? = null + public var items: JsonSchema? = null + public var minItems: Long? = null + public var maxItems: Long? = null + public var uniqueItems: Boolean? = null - public fun build(): ArraySchema = ArraySchema( - title, - description, - requireNotNull(items) { - "cannot build ArraySchema without 'items' property" - }, - minItems, - maxItems, - uniqueItems - ) + public fun build(): ArraySchema = ArraySchema( + title, + description, + requireNotNull(items) { + "cannot build ArraySchema without 'items' property" + }, + minItems, + maxItems, + uniqueItems + ) - } + } } public fun ArraySchema( - block: ArraySchema.Builder.() -> Unit + block: ArraySchema.Builder.() -> Unit ): ArraySchema = ArraySchema.Builder().also(block).build() public enum class ContentEncoding { - @SerialName("quoted-printable") - QUOTED_PRINTABLE, - @SerialName("base16") - BASE16, - @SerialName("base32") - BASE32, - @SerialName("base64") - BASE64 + @SerialName("quoted-printable") + QUOTED_PRINTABLE, + + @SerialName("base16") + BASE16, + + @SerialName("base32") + BASE32, + + @SerialName("base64") + BASE64 } /** @@ -165,66 +167,66 @@ public enum class ContentEncoding { @Serializable @SerialName("string") public class StringSchema private constructor( - override val title: String? = null, - override val description: String? = null, - public val enum: List? = null, - public val minLength: Long? = null, - public val maxLength: Long? = null, - public val pattern: String? = null, - public val format: String? = null, - public val contentEncoding: ContentEncoding? = null, - public val contentMediaType: String? = null + override val title: String? = null, + override val description: String? = null, + public val enum: List? = null, + public val minLength: Long? = null, + public val maxLength: Long? = null, + public val pattern: String? = null, + public val format: String? = null, + public val contentEncoding: ContentEncoding? = null, + public val contentMediaType: String? = null ) : BaseSchema() { - public class Builder : BaseSchema.Builder() { + public class Builder : BaseSchema.Builder() { + + public var enum: List? = null + public var minLength: Long? = null + public var maxLength: Long? = null + public var pattern: String? = null + public var format: String? = null + public var contentMediaType: String? = null + public var contentEncoding: ContentEncoding? = null + + public fun format(format: StringFormat) { + this.format = format.toString() + } + + public fun build(): StringSchema = StringSchema( + title, + description, + enum, + minLength, + maxLength, + pattern, + format, + contentEncoding, + contentMediaType + ) - public var enum: List? = null - public var minLength: Long? = null - public var maxLength: Long? = null - public var pattern: String? = null - public var format: String? = null - public var contentMediaType: String? = null - public var contentEncoding: ContentEncoding? = null - - public fun format(format: StringFormat) { - this.format = format.toString() } - public fun build(): StringSchema = StringSchema( - title, - description, - enum, - minLength, - maxLength, - pattern, - format, - contentEncoding, - contentMediaType - ) - - } - } public fun StringSchema( - block: StringSchema.Builder.() -> Unit + block: StringSchema.Builder.() -> Unit ): StringSchema = StringSchema.Builder().also(block).build() public interface NumericSchema { - public val minimum: T? - public val maximum: T? - public val exclusiveMinimum: T? - public val exclusiveMaximum: T? - public val multipleOf: T? - - public open class Builder : BaseSchema.Builder() { - public var minimum: T? = null - public var maximum: T? = null - public var exclusiveMinimum: T? = null - public var exclusiveMaximum: T? = null - public var multipleOf: T? = null - } + public val minimum: T? + public val maximum: T? + public val exclusiveMinimum: T? + public val exclusiveMaximum: T? + public val multipleOf: T? + + public open class Builder : BaseSchema.Builder() { + public var minimum: T? = null + public var maximum: T? = null + public var exclusiveMinimum: T? = null + public var exclusiveMaximum: T? = null + public var multipleOf: T? = null + } } @@ -234,33 +236,33 @@ public interface NumericSchema { @Serializable @SerialName("number") public class NumberSchema private constructor( - override val title: String? = null, - override val description: String? = null, - override val minimum: Double? = null, - override val maximum: Double? = null, - override val exclusiveMinimum: Double? = null, - override val exclusiveMaximum: Double? = null, - override val multipleOf: Double? = null, + override val title: String? = null, + override val description: String? = null, + override val minimum: Double? = null, + override val maximum: Double? = null, + override val exclusiveMinimum: Double? = null, + override val exclusiveMaximum: Double? = null, + override val multipleOf: Double? = null, ) : BaseSchema(), NumericSchema { - public class Builder : NumericSchema.Builder() { + public class Builder : NumericSchema.Builder() { - public fun build(): NumberSchema = NumberSchema( - title, - description, - minimum, - maximum, - exclusiveMinimum, - exclusiveMaximum, - multipleOf - ) + public fun build(): NumberSchema = NumberSchema( + title, + description, + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum, + multipleOf + ) - } + } } public fun NumberSchema( - block: NumberSchema.Builder.() -> Unit + block: NumberSchema.Builder.() -> Unit ): NumberSchema = NumberSchema.Builder().also(block).build() /** @@ -269,33 +271,33 @@ public fun NumberSchema( @Serializable @SerialName("integer") public class IntegerSchema private constructor( - override val title: String? = null, - override val description: String? = null, - override val minimum: Long? = null, - override val maximum: Long? = null, - override val exclusiveMinimum: Long? = null, - override val exclusiveMaximum: Long? = null, - override val multipleOf: Long? = null, + override val title: String? = null, + override val description: String? = null, + override val minimum: Long? = null, + override val maximum: Long? = null, + override val exclusiveMinimum: Long? = null, + override val exclusiveMaximum: Long? = null, + override val multipleOf: Long? = null, ) : BaseSchema(), NumericSchema { - public class Builder : NumericSchema.Builder() { + public class Builder : NumericSchema.Builder() { - public fun build(): IntegerSchema = IntegerSchema( - title, - description, - minimum, - maximum, - exclusiveMinimum, - exclusiveMaximum, - multipleOf - ) + public fun build(): IntegerSchema = IntegerSchema( + title, + description, + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum, + multipleOf + ) - } + } } public fun IntegerSchema( - block: IntegerSchema.Builder.() -> Unit + block: IntegerSchema.Builder.() -> Unit ): IntegerSchema = IntegerSchema.Builder().also(block).build() /** @@ -304,23 +306,23 @@ public fun IntegerSchema( @Serializable @SerialName("boolean") public class BooleanSchema private constructor( - override val title: String? = null, - override val description: String? = null, + override val title: String? = null, + override val description: String? = null, ) : BaseSchema() { - public class Builder : BaseSchema.Builder() { + public class Builder : BaseSchema.Builder() { - public fun build(): BooleanSchema = BooleanSchema( - title, - description - ) + public fun build(): BooleanSchema = BooleanSchema( + title, + description + ) - } + } } public fun BooleanSchema( - block: BooleanSchema.Builder.() -> Unit + block: BooleanSchema.Builder.() -> Unit ): BooleanSchema = BooleanSchema.Builder().also(block).build() /** @@ -328,236 +330,236 @@ public fun BooleanSchema( */ public enum class StringFormat { - // Dates and times - /** - * A `date-time` format. - * - * E.g., `2018-11-13T20:20:39+00:00`. - */ - DATE_TIME, - - /** - * A `time` format. - * - * E.g., `20:20:39+00:00`. - */ - TIME, - - /** - * A `date` format. - * - * E.g., `2018-11-13`. - */ - DATE, - - /** - * A `duration` format. - * - * E.g., `P3D`. - * See [ISO 8601 ABNF for `duration`](https://datatracker.ietf.org/doc/html/rfc3339#appendix-A). - */ - DURATION, - - // Email addresses - /** - * An `email` format. - * - * See [RFC 5321, section 4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2). - */ - EMAIL, - - /** - * An `idn-email` format. - * - * See [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531). - */ - IDN_EMAIL, - - // Hostnames - /** - * A `hostname` format. - * - * See [RFC 1123, section 2.1](https://datatracker.ietf.org/doc/html/rfc1123#section-2.1) - */ - HOSTNAME, - - /** - * An `idn-hostname` format. - * - * See [RFC5890, section 2.3.2.3](https://datatracker.ietf.org/doc/html/rfc5890#section-2.3.2.3) - */ - IDN_HOSTNAME, - - // IP Addresses - /** - * An `ipv4` format. - * - * IPv4 address, according to dotted-quad ABNF syntax as defined in - * [RFC 2673, section 3.2](https://datatracker.ietf.org/doc/html/rfc2673#section-3.2) - */ - IPV4, - - /** - * An `ipv6` format. - * - * IPv6 address, as defined in - * [RFC 2373, section 2.2](http://tools.ietf.org/html/rfc2373#section-2.2) - */ - IPV6, - - // Resource identifiers - /** - * A `uuid` format. - * - * A Universally Unique Identifier as defined by [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122). - * Example: `3e4666bf-d5e5-4aa7-b8ce-cefe41c7568a` - */ - UUID, - - /** - * A `uri` format. - * - * A universal resource identifier (URI), according to [RFC3986](http://tools.ietf.org/html/rfc3986). - */ - URI, - - /** - * A `uri-reference` format. - * - * A URI Reference (either a URI or a relative-reference), according to - * [RFC3986, section 4.1](http://tools.ietf.org/html/rfc3986#section-4.1). - */ - URI_REFERENCE, - - /** - * An `iri` format. - * - * The internationalized equivalent of a "uri", according to [RFC3987](https://tools.ietf.org/html/rfc3987). - */ - IRI, - - /** - * An `iri-reference` format. - * - * The internationalized equivalent of a "uri-reference", according to [RFC3987](https://tools.ietf.org/html/rfc3987). - */ - IRI_REFERENCE, - - /** - * A `uri-template` format. - * - * A URI Template (of any level) according to [RFC6570](https://tools.ietf.org/html/rfc6570). - */ - URI_TEMPLATE, - - /** - * A `json-pointer` format. - * - * A JSON Pointer, according to [RFC6901](https://tools.ietf.org/html/rfc6901). - * Should be used only when the entire string contains only JSON Pointer content, e.g. `/foo/bar`. - * JSON Pointer URI fragments, e.g. `#/foo/bar/` should use `URI_REFERENCE`. - */ - JSON_POINTER, - - /** - * A `relative-json-pointer` format. - * - * A [relative JSON pointer](https://tools.ietf.org/html/draft-handrews-relative-json-pointer-01). - */ - RELATIVE_JSON_POINTER, - - /** - * A `regex` format. - * - * A regular expression, which should be valid according to the - * [ECMA 262](https://www.ecma-international.org/publications-and-standards/standards/ecma-262/) dialect. - */ - REGEX, - - // Unofficial formats - /** - * An unofficial `color` format. - * - * Represents a color in hexadecimal format (e.g., "#RRGGBB"). - */ - COLOR, - - /** - * An unofficial `phone` format. - * - * Represents a phone number. The exact format may vary depending on the implementation. - */ - PHONE, - - /** - * An unofficial `credit-card` format. - * - * Represents a credit card number. - */ - CREDIT_CARD, - - /** - * An unofficial `isbn` format. - * - * Represents an International Standard Book Number. - */ - ISBN, - - /** - * An unofficial `currency` format. - * - * Represents a currency code (e.g., "USD", "EUR"). - */ - CURRENCY, - - /** - * An unofficial `binary` format. - * - * Represents binary data, typically base64-encoded. - */ - BINARY, - - /** - * An unofficial `md5` format. - * - * Represents an MD5 hash. - */ - MD5, - - /** - * An unofficial `sha1` format. - * - * Represents a SHA-1 hash. - */ - SHA1, - - /** - * An unofficial `sha256` format. - * - * Represents a SHA-256 hash. - */ - SHA256, - - /** - * An unofficial `country-code` format. - * - * Represents a country code (e.g., "US", "GB"). - */ - COUNTRY_CODE, - - /** - * An unofficial `language-code` format. - * - * Represents a language code (e.g., "en", "fr"). - */ - LANGUAGE_CODE; - - override fun toString(): String = name.lowercase().replace('_', '-') + // Dates and times + /** + * A `date-time` format. + * + * E.g., `2018-11-13T20:20:39+00:00`. + */ + DATE_TIME, + + /** + * A `time` format. + * + * E.g., `20:20:39+00:00`. + */ + TIME, + + /** + * A `date` format. + * + * E.g., `2018-11-13`. + */ + DATE, + + /** + * A `duration` format. + * + * E.g., `P3D`. + * See [ISO 8601 ABNF for `duration`](https://datatracker.ietf.org/doc/html/rfc3339#appendix-A). + */ + DURATION, + + // Email addresses + /** + * An `email` format. + * + * See [RFC 5321, section 4.1.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2). + */ + EMAIL, + + /** + * An `idn-email` format. + * + * See [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531). + */ + IDN_EMAIL, + + // Hostnames + /** + * A `hostname` format. + * + * See [RFC 1123, section 2.1](https://datatracker.ietf.org/doc/html/rfc1123#section-2.1) + */ + HOSTNAME, + + /** + * An `idn-hostname` format. + * + * See [RFC5890, section 2.3.2.3](https://datatracker.ietf.org/doc/html/rfc5890#section-2.3.2.3) + */ + IDN_HOSTNAME, + + // IP Addresses + /** + * An `ipv4` format. + * + * IPv4 address, according to dotted-quad ABNF syntax as defined in + * [RFC 2673, section 3.2](https://datatracker.ietf.org/doc/html/rfc2673#section-3.2) + */ + IPV4, + + /** + * An `ipv6` format. + * + * IPv6 address, as defined in + * [RFC 2373, section 2.2](http://tools.ietf.org/html/rfc2373#section-2.2) + */ + IPV6, + + // Resource identifiers + /** + * A `uuid` format. + * + * A Universally Unique Identifier as defined by [RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122). + * Example: `3e4666bf-d5e5-4aa7-b8ce-cefe41c7568a` + */ + UUID, + + /** + * A `uri` format. + * + * A universal resource identifier (URI), according to [RFC3986](http://tools.ietf.org/html/rfc3986). + */ + URI, + + /** + * A `uri-reference` format. + * + * A URI Reference (either a URI or a relative-reference), according to + * [RFC3986, section 4.1](http://tools.ietf.org/html/rfc3986#section-4.1). + */ + URI_REFERENCE, + + /** + * An `iri` format. + * + * The internationalized equivalent of a "uri", according to [RFC3987](https://tools.ietf.org/html/rfc3987). + */ + IRI, + + /** + * An `iri-reference` format. + * + * The internationalized equivalent of a "uri-reference", according to [RFC3987](https://tools.ietf.org/html/rfc3987). + */ + IRI_REFERENCE, + + /** + * A `uri-template` format. + * + * A URI Template (of any level) according to [RFC6570](https://tools.ietf.org/html/rfc6570). + */ + URI_TEMPLATE, + + /** + * A `json-pointer` format. + * + * A JSON Pointer, according to [RFC6901](https://tools.ietf.org/html/rfc6901). + * Should be used only when the entire string contains only JSON Pointer content, e.g. `/foo/bar`. + * JSON Pointer URI fragments, e.g. `#/foo/bar/` should use `URI_REFERENCE`. + */ + JSON_POINTER, + + /** + * A `relative-json-pointer` format. + * + * A [relative JSON pointer](https://tools.ietf.org/html/draft-handrews-relative-json-pointer-01). + */ + RELATIVE_JSON_POINTER, + + /** + * A `regex` format. + * + * A regular expression, which should be valid according to the + * [ECMA 262](https://www.ecma-international.org/publications-and-standards/standards/ecma-262/) dialect. + */ + REGEX, + + // Unofficial formats + /** + * An unofficial `color` format. + * + * Represents a color in hexadecimal format (e.g., "#RRGGBB"). + */ + COLOR, + + /** + * An unofficial `phone` format. + * + * Represents a phone number. The exact format may vary depending on the implementation. + */ + PHONE, + + /** + * An unofficial `credit-card` format. + * + * Represents a credit card number. + */ + CREDIT_CARD, + + /** + * An unofficial `isbn` format. + * + * Represents an International Standard Book Number. + */ + ISBN, + + /** + * An unofficial `currency` format. + * + * Represents a currency code (e.g., "USD", "EUR"). + */ + CURRENCY, + + /** + * An unofficial `binary` format. + * + * Represents binary data, typically base64-encoded. + */ + BINARY, + + /** + * An unofficial `md5` format. + * + * Represents an MD5 hash. + */ + MD5, + + /** + * An unofficial `sha1` format. + * + * Represents a SHA-1 hash. + */ + SHA1, + + /** + * An unofficial `sha256` format. + * + * Represents a SHA-256 hash. + */ + SHA256, + + /** + * An unofficial `country-code` format. + * + * Represents a country code (e.g., "US", "GB"). + */ + COUNTRY_CODE, + + /** + * An unofficial `language-code` format. + * + * Represents a language code (e.g., "en", "fr"). + */ + LANGUAGE_CODE; + + override fun toString(): String = name.lowercase().replace('_', '-') } private val jsonSchemaToStringJson = Json { - prettyPrint = true - @OptIn(ExperimentalSerializationApi::class) - prettyPrintIndent = " " + prettyPrint = true + @OptIn(ExperimentalSerializationApi::class) + prettyPrintIndent = " " } diff --git a/src/commonMain/kotlin/generator/JsonSchemaGenerator.kt b/src/commonMain/kotlin/generator/JsonSchemaGenerator.kt index 8f7a8e4..c66c1ff 100644 --- a/src/commonMain/kotlin/generator/JsonSchemaGenerator.kt +++ b/src/commonMain/kotlin/generator/JsonSchemaGenerator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,35 +16,15 @@ package com.xemantic.ai.tool.schema.generator -import com.xemantic.ai.tool.schema.ArraySchema -import com.xemantic.ai.tool.schema.BooleanSchema -import com.xemantic.ai.tool.schema.IntegerSchema -import com.xemantic.ai.tool.schema.JsonSchema -import com.xemantic.ai.tool.schema.NumberSchema -import com.xemantic.ai.tool.schema.ObjectSchema -import com.xemantic.ai.tool.schema.StringFormat -import com.xemantic.ai.tool.schema.StringSchema -import com.xemantic.ai.tool.schema.meta.ContentMediaType -import com.xemantic.ai.tool.schema.meta.Description -import com.xemantic.ai.tool.schema.meta.Encoding -import com.xemantic.ai.tool.schema.meta.Format -import com.xemantic.ai.tool.schema.meta.FormatString -import com.xemantic.ai.tool.schema.meta.ItemDescription -import com.xemantic.ai.tool.schema.meta.ItemTitle -import com.xemantic.ai.tool.schema.meta.Max -import com.xemantic.ai.tool.schema.meta.MaxInt -import com.xemantic.ai.tool.schema.meta.MaxItems -import com.xemantic.ai.tool.schema.meta.MaxLength -import com.xemantic.ai.tool.schema.meta.Min -import com.xemantic.ai.tool.schema.meta.MinInt -import com.xemantic.ai.tool.schema.meta.MinItems -import com.xemantic.ai.tool.schema.meta.MinLength -import com.xemantic.ai.tool.schema.meta.Pattern -import com.xemantic.ai.tool.schema.meta.Title -import com.xemantic.ai.tool.schema.meta.UniqueItems -import kotlinx.serialization.* -import kotlinx.serialization.descriptors.* -import kotlin.collections.set +import com.xemantic.ai.tool.schema.* +import com.xemantic.ai.tool.schema.meta.* +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.descriptors.StructureKind +import kotlinx.serialization.serializer /** * Generates a JSON schema for the specified type [T]. @@ -62,12 +42,12 @@ import kotlin.collections.set * @see generateSchema */ public inline fun jsonSchemaOf( - outputAdditionalPropertiesFalse: Boolean = false, - suppressDescription: Boolean = false + outputAdditionalPropertiesFalse: Boolean = false, + suppressDescription: Boolean = false ): JsonSchema = generateSchema( - descriptor = serializer().descriptor, - outputAdditionalPropertiesFalse = outputAdditionalPropertiesFalse, - suppressDescription = suppressDescription + descriptor = serializer().descriptor, + outputAdditionalPropertiesFalse = outputAdditionalPropertiesFalse, + suppressDescription = suppressDescription ) /** @@ -86,187 +66,188 @@ public inline fun jsonSchemaOf( */ @OptIn(ExperimentalSerializationApi::class) public fun generateSchema( - descriptor: SerialDescriptor, - outputAdditionalPropertiesFalse: Boolean = false, - suppressDescription: Boolean = false + descriptor: SerialDescriptor, + outputAdditionalPropertiesFalse: Boolean = false, + suppressDescription: Boolean = false ): JsonSchema { - val props = mutableMapOf() - val req = mutableListOf() - val defs = mutableMapOf() - - for (i in 0 until descriptor.elementsCount) { - val elementDescriptor = descriptor.getElementDescriptor(i) - val name = descriptor.getElementName(i) - val meta = descriptor.getElementAnnotations(i) + elementDescriptor.annotations - val property = generatePropertySchema( - descriptor = elementDescriptor, - meta = meta, - defs = defs, - outputAdditionalPropertiesFalse = outputAdditionalPropertiesFalse - ) - props[name] = property - if (!descriptor.isElementOptional(i)) { - req.add(name) + val props = mutableMapOf() + val req = mutableListOf() + val defs = mutableMapOf() + + for (i in 0 until descriptor.elementsCount) { + val elementDescriptor = descriptor.getElementDescriptor(i) + val name = descriptor.getElementName(i) + val meta = descriptor.getElementAnnotations(i) + elementDescriptor.annotations + val property = generatePropertySchema( + descriptor = elementDescriptor, + meta = meta, + defs = defs, + outputAdditionalPropertiesFalse = outputAdditionalPropertiesFalse + ) + props[name] = property + if (!descriptor.isElementOptional(i)) { + req.add(name) + } } - } - return ObjectSchema { - title = descriptor.annotations.find()?.value - description = if (!suppressDescription) descriptor.annotations.find<Description>()?.value else null - properties = props - definitions = if (defs.isNotEmpty()) defs else null - required = req - additionalProperties = if (outputAdditionalPropertiesFalse) false else null - } + return ObjectSchema { + title = descriptor.annotations.find<Title>()?.value + description = if (!suppressDescription) descriptor.annotations.find<Description>()?.value else null + properties = props + definitions = if (defs.isNotEmpty()) defs else null + required = req + additionalProperties = if (outputAdditionalPropertiesFalse) false else null + } } @OptIn(ExperimentalSerializationApi::class) private fun generatePropertySchema( - descriptor: SerialDescriptor, - meta: List<Annotation>, - defs: MutableMap<String, JsonSchema>, - outputAdditionalPropertiesFalse: Boolean + descriptor: SerialDescriptor, + meta: List<Annotation>, + defs: MutableMap<String, JsonSchema>, + outputAdditionalPropertiesFalse: Boolean ): JsonSchema { - return when (descriptor.kind) { - PrimitiveKind.STRING -> stringSchema(meta, descriptor) - PrimitiveKind.INT, PrimitiveKind.LONG -> integerSchema(meta) - PrimitiveKind.FLOAT, PrimitiveKind.DOUBLE -> numberSchema(meta) - PrimitiveKind.BOOLEAN -> booleanSchema(meta) - SerialKind.ENUM -> enumProperty(meta, descriptor) - StructureKind.LIST -> arraySchema(meta, descriptor, defs, outputAdditionalPropertiesFalse) - StructureKind.MAP -> objectSchema(meta) - StructureKind.CLASS -> { - // Workaround: dots are not allowed in JSON Schema name, - // if the @SerialName was not specified for the class, then fully qualified class name will be used, - // and we need to transform it into schema acceptable identifier - val refName = descriptor.serialName.replace('.', '_').trimEnd('?') - defs[refName] = generateSchema(descriptor, outputAdditionalPropertiesFalse) - JsonSchema.Ref("#/definitions/$refName") + return when (descriptor.kind) { + PrimitiveKind.STRING -> stringSchema(meta, descriptor) + PrimitiveKind.INT, PrimitiveKind.LONG -> integerSchema(meta) + PrimitiveKind.FLOAT, PrimitiveKind.DOUBLE -> numberSchema(meta) + PrimitiveKind.BOOLEAN -> booleanSchema(meta) + SerialKind.ENUM -> enumProperty(meta, descriptor) + StructureKind.LIST -> arraySchema(meta, descriptor, defs, outputAdditionalPropertiesFalse) + StructureKind.MAP -> objectSchema(meta) + StructureKind.CLASS -> { + // Workaround: dots are not allowed in JSON Schema name, + // if the @SerialName was not specified for the class, then fully qualified class name will be used, + // and we need to transform it into schema acceptable identifier + val refName = descriptor.serialName.replace('.', '_').trimEnd('?') + defs[refName] = generateSchema(descriptor, outputAdditionalPropertiesFalse) + JsonSchema.Ref("#/definitions/$refName") + } + + else -> objectSchema(meta) // Default case } - else -> objectSchema(meta) // Default case - } } private fun enumProperty( - meta: List<Annotation>, - descriptor: SerialDescriptor + meta: List<Annotation>, + descriptor: SerialDescriptor ) = StringSchema { - title = meta.find<Title>()?.value - description = meta.find<Description>()?.value - enum = descriptor.elementNames().map { it } + title = meta.find<Title>()?.value + description = meta.find<Description>()?.value + enum = descriptor.elementNames().map { it } } @OptIn(ExperimentalSerializationApi::class) private fun SerialDescriptor.elementNames(): List<String> = buildList { - for (i in 0 until elementsCount) { - val name = getElementName(i) - add(name) - } + for (i in 0 until elementsCount) { + val name = getElementName(i) + add(name) + } } private fun integerSchema( - meta: List<Annotation> + meta: List<Annotation> ): JsonSchema { - val min = meta.find<MinInt>() - val max = meta.find<MaxInt>() - return IntegerSchema { - title = meta.find<Title>()?.value - description = meta.find<Description>()?.value - minimum = if (min != null && !min.exclusive) min.value else null - maximum = if (max != null && !max.exclusive) max.value else null - exclusiveMinimum = if (min != null && min.exclusive) min.value else null - exclusiveMaximum = if (max != null && max.exclusive) max.value else null - } + val min = meta.find<MinInt>() + val max = meta.find<MaxInt>() + return IntegerSchema { + title = meta.find<Title>()?.value + description = meta.find<Description>()?.value + minimum = if (min != null && !min.exclusive) min.value else null + maximum = if (max != null && !max.exclusive) max.value else null + exclusiveMinimum = if (min != null && min.exclusive) min.value else null + exclusiveMaximum = if (max != null && max.exclusive) max.value else null + } } private fun numberSchema( - meta: List<Annotation> + meta: List<Annotation> ): JsonSchema { - val min = meta.find<Min>() - val max = meta.find<Max>() - return NumberSchema { - title = meta.find<Title>()?.value - description = meta.find<Description>()?.value - minimum = if (min != null && !min.exclusive) min.value else null - maximum = if (max != null && !max.exclusive) max.value else null - exclusiveMinimum = if (min != null && min.exclusive) min.value else null - exclusiveMaximum = if (max != null && max.exclusive) max.value else null - } + val min = meta.find<Min>() + val max = meta.find<Max>() + return NumberSchema { + title = meta.find<Title>()?.value + description = meta.find<Description>()?.value + minimum = if (min != null && !min.exclusive) min.value else null + maximum = if (max != null && !max.exclusive) max.value else null + exclusiveMinimum = if (min != null && min.exclusive) min.value else null + exclusiveMaximum = if (max != null && max.exclusive) max.value else null + } } private fun booleanSchema( - meta: List<Annotation> + meta: List<Annotation> ) = BooleanSchema { - title = meta.find<Title>()?.value - description = meta.find<Description>()?.value + title = meta.find<Title>()?.value + description = meta.find<Description>()?.value } @OptIn(ExperimentalSerializationApi::class) private fun stringSchema( - meta: List<Annotation>, - descriptor: SerialDescriptor + meta: List<Annotation>, + descriptor: SerialDescriptor ) = StringSchema { - title = meta.find<Title>()?.value - description = meta.find<Description>()?.value - minLength = meta.find<MinLength>()?.value - maxLength = meta.find<MaxLength>()?.value - pattern = meta.find<Pattern>()?.regex - format = meta.find<Format>()?.value?.toString() - ?: meta.find<FormatString>()?.format - ?: if (descriptor.serialName == "kotlinx.datetime.Instant") { - StringFormat.DATE_TIME.toString() - } else null - contentEncoding = meta.find<Encoding>()?.value - contentMediaType = meta.find<ContentMediaType>()?.value + title = meta.find<Title>()?.value + description = meta.find<Description>()?.value + minLength = meta.find<MinLength>()?.value + maxLength = meta.find<MaxLength>()?.value + pattern = meta.find<Pattern>()?.regex + format = meta.find<Format>()?.value?.toString() + ?: meta.find<FormatString>()?.format + ?: if (descriptor.serialName == "kotlinx.datetime.Instant") { + StringFormat.DATE_TIME.toString() + } else null + contentEncoding = meta.find<Encoding>()?.value + contentMediaType = meta.find<ContentMediaType>()?.value } private fun objectSchema( - meta: List<Annotation> + meta: List<Annotation> ) = ObjectSchema { - title = meta.find<Title>()?.value - description = meta.find<Description>()?.value + title = meta.find<Title>()?.value + description = meta.find<Description>()?.value } @OptIn(ExperimentalSerializationApi::class) private fun arraySchema( - meta: List<Annotation>, - descriptor: SerialDescriptor, - defs: MutableMap<String, JsonSchema>, - outputAdditionalPropertiesFalse: Boolean + meta: List<Annotation>, + descriptor: SerialDescriptor, + defs: MutableMap<String, JsonSchema>, + outputAdditionalPropertiesFalse: Boolean ): JsonSchema { - val elementDescriptor = descriptor.getElementDescriptor(0) - val elementMeta = descriptor.getElementAnnotations(0) + - elementDescriptor.annotations + - meta - .filter { it !is Description && it !is Title } - .map { - when (it) { - is ItemDescription -> Description(it.value) - is ItemTitle -> Title(it.value) - else -> it - } - } - - val itemSchema = generatePropertySchema( - descriptor = elementDescriptor, - meta = elementMeta, - defs = defs, - outputAdditionalPropertiesFalse = outputAdditionalPropertiesFalse - ) + val elementDescriptor = descriptor.getElementDescriptor(0) + val elementMeta = descriptor.getElementAnnotations(0) + + elementDescriptor.annotations + + meta + .filter { it !is Description && it !is Title } + .map { + when (it) { + is ItemDescription -> Description(it.value) + is ItemTitle -> Title(it.value) + else -> it + } + } + + val itemSchema = generatePropertySchema( + descriptor = elementDescriptor, + meta = elementMeta, + defs = defs, + outputAdditionalPropertiesFalse = outputAdditionalPropertiesFalse + ) - return ArraySchema { - title = meta.find<Title>()?.value - description = meta.find<Description>()?.value - items = itemSchema - minItems = meta.find<MinItems>()?.value - maxItems = meta.find<MaxItems>()?.value - uniqueItems = if(meta.find<UniqueItems>() != null) true else null - } + return ArraySchema { + title = meta.find<Title>()?.value + description = meta.find<Description>()?.value + items = itemSchema + minItems = meta.find<MinItems>()?.value + maxItems = meta.find<MaxItems>()?.value + uniqueItems = if (meta.find<UniqueItems>() != null) true else null + } } private inline fun <reified T : Annotation> List<Annotation>.find(): T? = - filterIsInstance<T>() - .firstOrNull() + filterIsInstance<T>() + .firstOrNull() diff --git a/src/commonMain/kotlin/meta/JsonSchemaAnnotations.kt b/src/commonMain/kotlin/meta/JsonSchemaAnnotations.kt index cdaf320..54d381b 100644 --- a/src/commonMain/kotlin/meta/JsonSchemaAnnotations.kt +++ b/src/commonMain/kotlin/meta/JsonSchemaAnnotations.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,7 +44,7 @@ import kotlinx.serialization.MetaSerializable @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class Title( - val value: String + val value: String ) /** @@ -67,7 +67,7 @@ public annotation class Title( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class Description( - val value: String + val value: String ) // String annotations @@ -88,7 +88,7 @@ public annotation class Description( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class MinLength( - val value: Long + val value: Long ) /** @@ -108,7 +108,7 @@ public annotation class MinLength( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class MaxLength( - val value: Long + val value: Long ) /** @@ -126,26 +126,26 @@ public annotation class MaxLength( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class Pattern( - val regex: String + val regex: String ) { - public companion object { + public companion object { - /** - * The regular expression pattern of a decimal number. - * - * @see DECIMAL - */ - public const val DECIMAL_REGEX: String = "^-?\\d+(\\.\\d+)?$" + /** + * The regular expression pattern of a decimal number. + * + * @see DECIMAL + */ + public const val DECIMAL_REGEX: String = "^-?\\d+(\\.\\d+)?$" - /** - * The default instance of the Pattern annotation for decimal number. - * - * @see DECIMAL_REGEX - */ - public val DECIMAL: Pattern = Pattern(DECIMAL_REGEX) + /** + * The default instance of the Pattern annotation for decimal number. + * + * @see DECIMAL_REGEX + */ + public val DECIMAL: Pattern = Pattern(DECIMAL_REGEX) - } + } } @@ -163,7 +163,7 @@ public annotation class Pattern( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class Format( - val value: StringFormat + val value: StringFormat ) /** @@ -184,7 +184,7 @@ public annotation class Format( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class FormatString( - val format: String + val format: String ) /** @@ -198,7 +198,7 @@ public annotation class FormatString( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class Encoding( - val value: ContentEncoding + val value: ContentEncoding ) /** @@ -212,7 +212,7 @@ public annotation class Encoding( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class ContentMediaType( - val value: String + val value: String ) // number annotations @@ -240,8 +240,8 @@ public annotation class ContentMediaType( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class Min( - val value: Double, - val exclusive: Boolean = false + val value: Double, + val exclusive: Boolean = false ) /** @@ -268,8 +268,8 @@ public annotation class Min( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class Max( - val value: Double, - val exclusive: Boolean = false + val value: Double, + val exclusive: Boolean = false ) /** @@ -281,7 +281,7 @@ public annotation class Max( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class MultipleOf( - val value: Double + val value: Double ) // integer annotations @@ -311,8 +311,8 @@ public annotation class MultipleOf( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class MinInt( - val value: Long, - val exclusive: Boolean = false + val value: Long, + val exclusive: Boolean = false ) /** @@ -327,8 +327,8 @@ public annotation class MinInt( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class MaxInt( - val value: Long, - val exclusive: Boolean = false + val value: Long, + val exclusive: Boolean = false ) /** @@ -340,7 +340,7 @@ public annotation class MaxInt( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class MultipleOfInt( - val value: Long + val value: Long ) // Array annotations @@ -367,7 +367,7 @@ public annotation class MultipleOfInt( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class ItemTitle( - val value: String + val value: String ) /** @@ -390,7 +390,7 @@ public annotation class ItemTitle( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class ItemDescription( - val value: String + val value: String ) /** @@ -410,7 +410,7 @@ public annotation class ItemDescription( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class MinItems( - val value: Long + val value: Long ) /** @@ -430,7 +430,7 @@ public annotation class MinItems( @Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS, AnnotationTarget.TYPE) @MetaSerializable public annotation class MaxItems( - val value: Long + val value: Long ) /** diff --git a/src/commonMain/kotlin/serialization/JsonSchemaSerializer.kt b/src/commonMain/kotlin/serialization/JsonSchemaSerializer.kt index 39b6360..2803903 100644 --- a/src/commonMain/kotlin/serialization/JsonSchemaSerializer.kt +++ b/src/commonMain/kotlin/serialization/JsonSchemaSerializer.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,50 +27,46 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.* public object JsonSchemaSerializer : KSerializer<JsonSchema> { - @OptIn(ExperimentalSerializationApi::class, InternalSerializationApi::class) - override val descriptor: SerialDescriptor = buildSerialDescriptor( - serialName = "JsonSchema", - kind = PolymorphicKind.SEALED - ) + @OptIn(ExperimentalSerializationApi::class, InternalSerializationApi::class) + override val descriptor: SerialDescriptor = buildSerialDescriptor( + serialName = "JsonSchema", + kind = PolymorphicKind.SEALED + ) - override fun serialize(encoder: Encoder, value: JsonSchema) { - when (value) { - is JsonSchema.Ref -> { - encoder.encodeSerializableValue( - serializer = JsonObject.serializer(), - value = buildJsonObject { - put("\$ref", JsonPrimitive(value.ref)) - } - ) - } - is BaseSchema -> encoder.encodeSerializableValue( - serializer = BaseSchema.serializer(), - value = value - ) + override fun serialize(encoder: Encoder, value: JsonSchema) { + when (value) { + is JsonSchema.Ref -> { + encoder.encodeSerializableValue( + serializer = JsonObject.serializer(), + value = buildJsonObject { + put("\$ref", JsonPrimitive(value.ref)) + } + ) + } + + is BaseSchema -> encoder.encodeSerializableValue( + serializer = BaseSchema.serializer(), + value = value + ) + } } - } - override fun deserialize(decoder: Decoder): JsonSchema { - val input = decoder as? JsonDecoder ?: throw SerializationException( - "Can be used only with Json format" - ) - val tree = input.decodeJsonElement() - val json = tree.jsonObject - val ref = json["\$ref"] - return if (ref != null) { - JsonSchema.Ref(ref.jsonPrimitive.content) - } else { - input.json.decodeFromJsonElement(BaseSchema.serializer(), tree) + override fun deserialize(decoder: Decoder): JsonSchema { + val input = decoder as? JsonDecoder ?: throw SerializationException( + "Can be used only with Json format" + ) + val tree = input.decodeJsonElement() + val json = tree.jsonObject + val ref = json["\$ref"] + return if (ref != null) { + JsonSchema.Ref(ref.jsonPrimitive.content) + } else { + input.json.decodeFromJsonElement(BaseSchema.serializer(), tree) + } } - } } diff --git a/src/commonTest/kotlin/JsonSchemaTest.kt b/src/commonTest/kotlin/JsonSchemaTest.kt index 6ef2d25..5360c42 100644 --- a/src/commonTest/kotlin/JsonSchemaTest.kt +++ b/src/commonTest/kotlin/JsonSchemaTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,345 +16,345 @@ package com.xemantic.ai.tool.schema -import io.kotest.assertions.json.shouldEqualJson -import com.xemantic.kotlin.test.should import com.xemantic.kotlin.test.have +import com.xemantic.kotlin.test.should +import io.kotest.assertions.json.shouldEqualJson import kotlin.test.Test import kotlin.test.assertFailsWith class JsonSchemaTest { - @Test - fun `should create ObjectSchema`() { - ObjectSchema { - title = "Person" - description = "A person schema" - properties = mapOf( - "name" to StringSchema { }, - "age" to IntegerSchema { } - ) - required = listOf("name") - additionalProperties = false - }.toString() shouldEqualJson /* language=json */ """ - { - "type": "object", - "title": "Person", - "description": "A person schema", - "properties": { - "name": { - "type": "string" - }, - "age": { - "type": "integer" - } - }, - "required": ["name"], - "additionalProperties": false - } - """ - } - - @Test - fun `should create ObjectSchema with definitions`() { - ObjectSchema { - title = "Person" - properties = mapOf( - "name" to StringSchema { }, - "address" to JsonSchema.Ref("#/definitions/address") - ) - definitions = mapOf( - "address" to ObjectSchema { - properties = mapOf( - "street" to StringSchema { }, - "city" to StringSchema { } - ) - } - ) - }.toString() shouldEqualJson /* language=json */ $$""" - { - "type": "object", - "title": "Person", - "properties": { - "name": { - "type": "string" - }, - "address": { - "$ref": "#/definitions/address" - } - }, - "definitions": { - "address": { + @Test + fun `should create ObjectSchema`() { + ObjectSchema { + title = "Person" + description = "A person schema" + properties = mapOf( + "name" to StringSchema { }, + "age" to IntegerSchema { } + ) + required = listOf("name") + additionalProperties = false + }.toString() shouldEqualJson /* language=json */ """ + { "type": "object", + "title": "Person", + "description": "A person schema", "properties": { - "street": { + "name": { "type": "string" }, - "city": { + "age": { + "type": "integer" + } + }, + "required": ["name"], + "additionalProperties": false + } + """ + } + + @Test + fun `should create ObjectSchema with definitions`() { + ObjectSchema { + title = "Person" + properties = mapOf( + "name" to StringSchema { }, + "address" to JsonSchema.Ref("#/definitions/address") + ) + definitions = mapOf( + "address" to ObjectSchema { + properties = mapOf( + "street" to StringSchema { }, + "city" to StringSchema { } + ) + } + ) + }.toString() shouldEqualJson /* language=json */ $$""" + { + "type": "object", + "title": "Person", + "properties": { + "name": { "type": "string" + }, + "address": { + "$ref": "#/definitions/address" + } + }, + "definitions": { + "address": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + } + } } } - } - } - } - """ - } - - @Test - fun `should create empty ObjectSchema`() { - ObjectSchema {}.toString() shouldEqualJson /* language=json */ """{"type": "object"}""" - } + } + """ + } - @Test - fun `should create StringSchema`() { - StringSchema { - title = "Username" - description = "A username" - minLength = 3 - maxLength = 20 - pattern = "^[a-zA-Z0-9_]+$" - }.toString() shouldEqualJson /* language=json */ """ - { - "type": "string", - "title": "Username", - "description": "A username", - "minLength": 3, - "maxLength": 20, - "pattern": "^[a-zA-Z0-9_]+$" - } - """ - } + @Test + fun `should create empty ObjectSchema`() { + ObjectSchema {}.toString() shouldEqualJson /* language=json */ """{"type": "object"}""" + } - @Test - fun `should create StringSchema with format`() { - StringSchema { - title = "Email" - description = "An email" - minLength = 3 - maxLength = 100 - format(StringFormat.EMAIL) - }.toString() shouldEqualJson /* language=json */ """ - { - "type": "string", - "title": "Email", - "description": "An email", - "minLength": 3, - "maxLength": 100, - "format": "email" - } - """ - } + @Test + fun `should create StringSchema`() { + StringSchema { + title = "Username" + description = "A username" + minLength = 3 + maxLength = 20 + pattern = "^[a-zA-Z0-9_]+$" + }.toString() shouldEqualJson /* language=json */ """ + { + "type": "string", + "title": "Username", + "description": "A username", + "minLength": 3, + "maxLength": 20, + "pattern": "^[a-zA-Z0-9_]+$" + } + """ + } - @Test - fun `should create StringSchema with enum`() { - StringSchema { - title = "Color" - enum = listOf("red", "green", "blue") - }.toString() shouldEqualJson /* language=json */ """ - { - "type": "string", - "title": "Color", - "enum": ["red", "green", "blue"] - } - """ - } + @Test + fun `should create StringSchema with format`() { + StringSchema { + title = "Email" + description = "An email" + minLength = 3 + maxLength = 100 + format(StringFormat.EMAIL) + }.toString() shouldEqualJson /* language=json */ """ + { + "type": "string", + "title": "Email", + "description": "An email", + "minLength": 3, + "maxLength": 100, + "format": "email" + } + """ + } - @Test - fun `should create StringSchema with contentEncoding`() { - StringSchema { - title = "Image" - description = "User's avatar image" - contentEncoding = ContentEncoding.BASE64 - contentMediaType = "image/png" - }.toString() shouldEqualJson /* language=json */ """ - { - "type": "string", - "title": "Image", - "description": "User's avatar image", - "contentEncoding": "base64", - "contentMediaType": "image/png" - } - """ - } + @Test + fun `should create StringSchema with enum`() { + StringSchema { + title = "Color" + enum = listOf("red", "green", "blue") + }.toString() shouldEqualJson /* language=json */ """ + { + "type": "string", + "title": "Color", + "enum": ["red", "green", "blue"] + } + """ + } - @Test - fun `should create empty StringSchema`() { - StringSchema {}.toString() shouldEqualJson /* language=json */ """{"type": "string"}""" - } + @Test + fun `should create StringSchema with contentEncoding`() { + StringSchema { + title = "Image" + description = "User's avatar image" + contentEncoding = ContentEncoding.BASE64 + contentMediaType = "image/png" + }.toString() shouldEqualJson /* language=json */ """ + { + "type": "string", + "title": "Image", + "description": "User's avatar image", + "contentEncoding": "base64", + "contentMediaType": "image/png" + } + """ + } - @Test - fun `should create ArraySchema`() { - ArraySchema { - title = "Numbers" - description = "An array of numbers" - items = NumberSchema {} - minItems = 1 - maxItems = 10 - uniqueItems = true - }.toString() shouldEqualJson /* language=json */ """ - { - "type": "array", - "title": "Numbers", - "description": "An array of numbers", - "items": { - "type": "number" - }, - "minItems": 1, - "maxItems": 10, - "uniqueItems": true - } - """ - } + @Test + fun `should create empty StringSchema`() { + StringSchema {}.toString() shouldEqualJson /* language=json */ """{"type": "string"}""" + } - @Test - fun `should create ArraySchema with ObjectSchema items`() { - ArraySchema { - title = "Users" - items = ObjectSchema { - properties = mapOf( - "id" to IntegerSchema {}, - "name" to StringSchema {} - ) - } - }.toString() shouldEqualJson /* language=json */ """ - { - "type": "array", - "title": "Users", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer" + @Test + fun `should create ArraySchema`() { + ArraySchema { + title = "Numbers" + description = "An array of numbers" + items = NumberSchema {} + minItems = 1 + maxItems = 10 + uniqueItems = true + }.toString() shouldEqualJson /* language=json */ """ + { + "type": "array", + "title": "Numbers", + "description": "An array of numbers", + "items": { + "type": "number" }, - "name": { - "type": "string" + "minItems": 1, + "maxItems": 10, + "uniqueItems": true + } + """ + } + + @Test + fun `should create ArraySchema with ObjectSchema items`() { + ArraySchema { + title = "Users" + items = ObjectSchema { + properties = mapOf( + "id" to IntegerSchema {}, + "name" to StringSchema {} + ) } - } - } - } - """ - } + }.toString() shouldEqualJson /* language=json */ """ + { + "type": "array", + "title": "Users", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } + } + """ + } - @Test - fun `should create NumberSchema with inclusive range`() { - NumberSchema { - title = "Price" - description = "A price value" - minimum = 0.0 - maximum = 1000.0 - multipleOf = 0.01 - }.toString() shouldEqualJson /* language=json */ """ - { - "type": "number", - "title": "Price", - "description": "A price value", - "minimum": 0.0, - "maximum": 1000.0, - "multipleOf": 0.01 - } - """ - } + @Test + fun `should create NumberSchema with inclusive range`() { + NumberSchema { + title = "Price" + description = "A price value" + minimum = 0.0 + maximum = 1000.0 + multipleOf = 0.01 + }.toString() shouldEqualJson /* language=json */ """ + { + "type": "number", + "title": "Price", + "description": "A price value", + "minimum": 0.0, + "maximum": 1000.0, + "multipleOf": 0.01 + } + """ + } - @Test - fun `should create NumberSchema with exclusive range`() { - NumberSchema { - title = "Price" - description = "A price value" - exclusiveMinimum = 0.0 - exclusiveMaximum = 1000.0 - }.toString() shouldEqualJson /* language=json */ """ - { - "type": "number", - "title": "Price", - "description": "A price value", - "exclusiveMinimum": 0.0, - "exclusiveMaximum": 1000.0 - } - """ - } + @Test + fun `should create NumberSchema with exclusive range`() { + NumberSchema { + title = "Price" + description = "A price value" + exclusiveMinimum = 0.0 + exclusiveMaximum = 1000.0 + }.toString() shouldEqualJson /* language=json */ """ + { + "type": "number", + "title": "Price", + "description": "A price value", + "exclusiveMinimum": 0.0, + "exclusiveMaximum": 1000.0 + } + """ + } - @Test - fun `should create empty NumberSchema`() { - NumberSchema {}.toString() shouldEqualJson /* language=json */ """{"type": "number"}""" - } + @Test + fun `should create empty NumberSchema`() { + NumberSchema {}.toString() shouldEqualJson /* language=json */ """{"type": "number"}""" + } - @Test - fun `should create IntegerSchema with inclusive range`() { - IntegerSchema { - title = "Age" - description = "A person's age" - minimum = 0 - maximum = 120 - multipleOf = 1 - }.toString() shouldEqualJson /* language=json */ """ - { - "type": "integer", - "title": "Age", - "description": "A person's age", - "minimum": 0, - "maximum": 120, - "multipleOf": 1 - } - """ - } + @Test + fun `should create IntegerSchema with inclusive range`() { + IntegerSchema { + title = "Age" + description = "A person's age" + minimum = 0 + maximum = 120 + multipleOf = 1 + }.toString() shouldEqualJson /* language=json */ """ + { + "type": "integer", + "title": "Age", + "description": "A person's age", + "minimum": 0, + "maximum": 120, + "multipleOf": 1 + } + """ + } - @Test - fun `should create IntegerSchema with exclusive range`() { - IntegerSchema { - title = "Age" - description = "A person's age" - exclusiveMinimum = 0 - exclusiveMaximum = 120 - }.toString() shouldEqualJson /* language=json */ """ - { - "type": "integer", - "title": "Age", - "description": "A person's age", - "exclusiveMinimum": 0, - "exclusiveMaximum": 120 - } - """ - } + @Test + fun `should create IntegerSchema with exclusive range`() { + IntegerSchema { + title = "Age" + description = "A person's age" + exclusiveMinimum = 0 + exclusiveMaximum = 120 + }.toString() shouldEqualJson /* language=json */ """ + { + "type": "integer", + "title": "Age", + "description": "A person's age", + "exclusiveMinimum": 0, + "exclusiveMaximum": 120 + } + """ + } - @Test - fun `should create empty IntegerSchema`() { - IntegerSchema {}.toString() shouldEqualJson /* language=json */ """{"type": "integer"}""" - } + @Test + fun `should create empty IntegerSchema`() { + IntegerSchema {}.toString() shouldEqualJson /* language=json */ """{"type": "integer"}""" + } - @Test - fun `should create BooleanSchema`() { - BooleanSchema { - title = "Is Active" - description = "Whether the user is active" - }.toString() shouldEqualJson /* language=json */ """ - { - "type": "boolean", - "title": "Is Active", - "description": "Whether the user is active" - } - """ - } + @Test + fun `should create BooleanSchema`() { + BooleanSchema { + title = "Is Active" + description = "Whether the user is active" + }.toString() shouldEqualJson /* language=json */ """ + { + "type": "boolean", + "title": "Is Active", + "description": "Whether the user is active" + } + """ + } - @Test - fun `should create empty BooleanSchema`() { - BooleanSchema {}.toString() shouldEqualJson /* language=json */ """{"type": "boolean"}""" - } + @Test + fun `should create empty BooleanSchema`() { + BooleanSchema {}.toString() shouldEqualJson /* language=json */ """{"type": "boolean"}""" + } - @Test - fun `should create JsonSchemaRef`() { - JsonSchema.Ref("#/definitions/address").toString() shouldEqualJson /* language=json */ $$""" - { - "$ref": "#/definitions/address" - } - """ - } + @Test + fun `should create JsonSchemaRef`() { + JsonSchema.Ref("#/definitions/address").toString() shouldEqualJson /* language=json */ $$""" + { + "$ref": "#/definitions/address" + } + """ + } - @Test - fun `should throw Exception for invalid JSON Pointer passed to JsonSchemaRef`() { - assertFailsWith<IllegalArgumentException> { - JsonSchema.Ref("invalid_ref") - } should { - have(message == "The 'ref' must start with '#/'") + @Test + fun `should throw Exception for invalid JSON Pointer passed to JsonSchemaRef`() { + assertFailsWith<IllegalArgumentException> { + JsonSchema.Ref("invalid_ref") + } should { + have(message == "The 'ref' must start with '#/'") + } } - } } diff --git a/src/commonTest/kotlin/generator/JsonSchemaGeneratorTest.kt b/src/commonTest/kotlin/generator/JsonSchemaGeneratorTest.kt index 36b8993..12dab1e 100644 --- a/src/commonTest/kotlin/generator/JsonSchemaGeneratorTest.kt +++ b/src/commonTest/kotlin/generator/JsonSchemaGeneratorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,25 +18,7 @@ package com.xemantic.ai.tool.schema.generator import com.xemantic.ai.tool.schema.ContentEncoding import com.xemantic.ai.tool.schema.StringFormat -import com.xemantic.ai.tool.schema.meta.ContentMediaType -import com.xemantic.ai.tool.schema.meta.Description -import com.xemantic.ai.tool.schema.meta.Encoding -import com.xemantic.ai.tool.schema.meta.Format -import com.xemantic.ai.tool.schema.meta.ItemDescription -import com.xemantic.ai.tool.schema.meta.ItemTitle -import com.xemantic.ai.tool.schema.meta.Max -import com.xemantic.ai.tool.schema.meta.MaxInt -import com.xemantic.ai.tool.schema.meta.MaxItems -import com.xemantic.ai.tool.schema.meta.MaxLength -import com.xemantic.ai.tool.schema.meta.Min -import com.xemantic.ai.tool.schema.meta.MinInt -import com.xemantic.ai.tool.schema.meta.MinItems -import com.xemantic.ai.tool.schema.meta.MinLength -import com.xemantic.ai.tool.schema.meta.MultipleOf -import com.xemantic.ai.tool.schema.meta.MultipleOfInt -import com.xemantic.ai.tool.schema.meta.Pattern -import com.xemantic.ai.tool.schema.meta.Title -import com.xemantic.ai.tool.schema.meta.UniqueItems +import com.xemantic.ai.tool.schema.meta.* import com.xemantic.ai.tool.schema.test.BigDecimal import com.xemantic.ai.tool.schema.test.Money import com.xemantic.ai.tool.schema.test.testJson @@ -44,246 +26,42 @@ import io.kotest.assertions.json.shouldEqualJson import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlin.test.Test class JsonSchemaGeneratorTest { - /** - * Address is our first class to test generation of JSON schema. - * - * Kotlin serialization allows us to access both - data of an object and metadata of a class. - * We are using this information to generate JSON schema. - * All the serialization descriptors are build in compile time, therefore no reflection - * is needed to access the metadata. Thanks to this fact, it is supported in kotlin multiplatform - * project, where reflection is generally not supported. - */ - @Serializable - @SerialName("address") - @Title("The full address") - @Description("An address of a person or an organization") - data class Address( - val street: String, - val city: String, - @Description("A postal code not limited to particular country") - @MinLength(3) - @MaxLength(10) - val postalCode: String, - @Pattern("[a-z]{2}") - val countryCode: String - ) - - @Test - fun `generate JSON Schema for Address`() { - val schema = jsonSchemaOf<Address>() - testJson.encodeToString(schema) shouldEqualJson /* language=json */ """ - { - "type": "object", - "title": "The full address", - "description": "An address of a person or an organization", - "properties": { - "street": { - "type": "string" - }, - "city": { - "type": "string" - }, - "postalCode": { - "type": "string", - "description": "A postal code not limited to particular country", - "minLength": 3, - "maxLength": 10 - }, - "countryCode": { - "type": "string", - "pattern": "[a-z]{2}" - } - }, - "required": [ - "street", - "city", - "postalCode", - "countryCode" - ] - } - """ - } - - /** - * The [Person] class adds the 2nd level data structure to JSON schema generation testing, - * referencing the [Address] and the [Mentor]. - * - * It contains all the possible annotations and also attributes of type [Instant], [Money] and [BigDecimal], - * so we can test if generated JSON schema will contain proper `date-time` `type` and regex `pattern` - * keywords. - */ - @Serializable - @Description("Personal data") - data class Person( - @Description("The official name") - val name: String, - val birthDate: Instant, - @Format(StringFormat.EMAIL) - @MinLength(6) - @MaxLength(100) - val email: String? = null, - val address: Address?, - @Description("A list of hobbies of the person") - @MinItems(0) - @MaxItems(10) - @Pattern("[a-z_]") - @ItemTitle("A hobby item") - @ItemDescription("A hobby must be a unique identifier consisting out of lower case letters and underscores") - @UniqueItems - val hobbies: List<String>? = null, - val mentors: List<Mentor>? = null, - val salary: Money, - val tax: BigDecimal, - val status: Status, - @Encoding(ContentEncoding.BASE64) - @ContentMediaType("image/png") - val avatar: String, - @MinInt(0) - @MaxInt(1000) - val tokens: Int, - @MinInt(0, exclusive = true) - @MaxInt(100, exclusive = true) - @MultipleOfInt(100) - val karma: Int, - @Min(0.0) - @Max(100.0) - @MultipleOf(1.0) - val experience: Double, - @Min(0.0, exclusive = true) - @Max(1.0, exclusive = true) - val factor: Double - ) - - @Title("Entry status") - @Description("The enumeration of possible entry status states, e.g. 'verification-pending', 'verified'") - @Suppress("unused") // it is used to generate schema - enum class Status { - @SerialName("verification-pending") - VERIFICATION_PENDING, - @SerialName("verified") - VERIFIED - } - - @Serializable - @SerialName("mentor") - data class Mentor( - val id: String - ) - - @Test - fun `generate JSON Schema for Person`() { - val schema = jsonSchemaOf<Person>() - val schemaJson = testJson.encodeToString(schema) + /** + * Address is our first class to test generation of JSON schema. + * + * Kotlin serialization allows us to access both - data of an object and metadata of a class. + * We are using this information to generate JSON schema. + * All the serialization descriptors are build in compile time, therefore no reflection + * is needed to access the metadata. Thanks to this fact, it is supported in kotlin multiplatform + * project, where reflection is generally not supported. + */ + @Serializable + @SerialName("address") + @Title("The full address") + @Description("An address of a person or an organization") + data class Address( + val street: String, + val city: String, + @Description("A postal code not limited to particular country") + @MinLength(3) + @MaxLength(10) + val postalCode: String, + @Pattern("[a-z]{2}") + val countryCode: String + ) - // then - schemaJson shouldEqualJson /* language=json */ $$""" - { - "type": "object", - "description": "Personal data", - "properties": { - "name": { - "type": "string", - "description": "The official name" - }, - "birthDate": { - "type": "string", - "format": "date-time" - }, - "email": { - "type": "string", - "minLength": 6, - "maxLength": 100, - "format": "email" - }, - "address": { - "$ref": "#/definitions/address" - }, - "hobbies": { - "type": "array", - "description": "A list of hobbies of the person", - "items": { - "type": "string", - "title": "A hobby item", - "description": "A hobby must be a unique identifier consisting out of lower case letters and underscores", - "pattern": "[a-z_]" - }, - "minItems": 0, - "maxItems": 10, - "uniqueItems": true - }, - "mentors": { - "type": "array", - "items": { - "$ref": "#/definitions/mentor" - } - }, - "salary": { - "type": "string", - "description": "A monetary amount", - "pattern": "^-?[0-9]+\\.[0-9]{2}?$" - }, - "tax": { - "type": "string", - "pattern": "^-?\\d+(\\.\\d+)?$" - }, - "status": { - "type": "string", - "title": "Entry status", - "description": "The enumeration of possible entry status states, e.g. 'verification-pending', 'verified'", - "enum": [ - "verification-pending", - "verified" - ] - }, - "avatar": { - "type": "string", - "contentEncoding": "base64", - "contentMediaType": "image/png" - }, - "tokens": { - "type": "integer", - "minimum": 0, - "maximum": 1000 - }, - "karma": { - "type": "integer", - "exclusiveMinimum": 0, - "exclusiveMaximum": 100 - }, - "experience": { - "type": "number", - "minimum": 0.0, - "maximum": 100.0 - }, - "factor": { - "type": "number", - "exclusiveMinimum": 0.0, - "exclusiveMaximum": 1.0 - } - }, - "required": [ - "name", - "birthDate", - "address", - "salary", - "tax", - "status", - "avatar", - "tokens", - "karma", - "experience", - "factor" - ], - "definitions": { - "address": { + @Test + fun `generate JSON Schema for Address`() { + val schema = jsonSchemaOf<Address>() + testJson.encodeToString(schema) shouldEqualJson /* language=json */ """ + { "type": "object", "title": "The full address", - "description": "An address of a person or an organization", + "description": "An address of a person or an organization", "properties": { "street": { "type": "string" @@ -295,7 +73,7 @@ class JsonSchemaGeneratorTest { "type": "string", "description": "A postal code not limited to particular country", "minLength": 3, - "maxLength": 10 + "maxLength": 10 }, "countryCode": { "type": "string", @@ -308,127 +86,250 @@ class JsonSchemaGeneratorTest { "postalCode", "countryCode" ] - }, - "mentor": { + } + """ + } + + /** + * The [Person] class adds the 2nd level data structure to JSON schema generation testing, + * referencing the [Address] and the [Mentor]. + * + * It contains all the possible annotations and also attributes of type [Instant], [Money] and [BigDecimal], + * so we can test if generated JSON schema will contain proper `date-time` `type` and regex `pattern` + * keywords. + */ + @Serializable + @Description("Personal data") + data class Person( + @Description("The official name") + val name: String, + val birthDate: Instant, + @Format(StringFormat.EMAIL) + @MinLength(6) + @MaxLength(100) + val email: String? = null, + val address: Address?, + @Description("A list of hobbies of the person") + @MinItems(0) + @MaxItems(10) + @Pattern("[a-z_]") + @ItemTitle("A hobby item") + @ItemDescription("A hobby must be a unique identifier consisting out of lower case letters and underscores") + @UniqueItems + val hobbies: List<String>? = null, + val mentors: List<Mentor>? = null, + val salary: Money, + val tax: BigDecimal, + val status: Status, + @Encoding(ContentEncoding.BASE64) + @ContentMediaType("image/png") + val avatar: String, + @MinInt(0) + @MaxInt(1000) + val tokens: Int, + @MinInt(0, exclusive = true) + @MaxInt(100, exclusive = true) + @MultipleOfInt(100) + val karma: Int, + @Min(0.0) + @Max(100.0) + @MultipleOf(1.0) + val experience: Double, + @Min(0.0, exclusive = true) + @Max(1.0, exclusive = true) + val factor: Double + ) + + @Title("Entry status") + @Description("The enumeration of possible entry status states, e.g. 'verification-pending', 'verified'") + @Suppress("unused") // it is used to generate schema + enum class Status { + @SerialName("verification-pending") + VERIFICATION_PENDING, + + @SerialName("verified") + VERIFIED + } + + @Serializable + @SerialName("mentor") + data class Mentor( + val id: String + ) + + @Test + fun `generate JSON Schema for Person`() { + val schema = jsonSchemaOf<Person>() + val schemaJson = testJson.encodeToString(schema) + + // then + schemaJson shouldEqualJson /* language=json */ $$""" + { "type": "object", + "description": "Personal data", "properties": { - "id": { - "type": "string" + "name": { + "type": "string", + "description": "The official name" + }, + "birthDate": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "minLength": 6, + "maxLength": 100, + "format": "email" + }, + "address": { + "$ref": "#/definitions/address" + }, + "hobbies": { + "type": "array", + "description": "A list of hobbies of the person", + "items": { + "type": "string", + "title": "A hobby item", + "description": "A hobby must be a unique identifier consisting out of lower case letters and underscores", + "pattern": "[a-z_]" + }, + "minItems": 0, + "maxItems": 10, + "uniqueItems": true + }, + "mentors": { + "type": "array", + "items": { + "$ref": "#/definitions/mentor" + } + }, + "salary": { + "type": "string", + "description": "A monetary amount", + "pattern": "^-?[0-9]+\\.[0-9]{2}?$" + }, + "tax": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "status": { + "type": "string", + "title": "Entry status", + "description": "The enumeration of possible entry status states, e.g. 'verification-pending', 'verified'", + "enum": [ + "verification-pending", + "verified" + ] + }, + "avatar": { + "type": "string", + "contentEncoding": "base64", + "contentMediaType": "image/png" + }, + "tokens": { + "type": "integer", + "minimum": 0, + "maximum": 1000 + }, + "karma": { + "type": "integer", + "exclusiveMinimum": 0, + "exclusiveMaximum": 100 + }, + "experience": { + "type": "number", + "minimum": 0.0, + "maximum": 100.0 + }, + "factor": { + "type": "number", + "exclusiveMinimum": 0.0, + "exclusiveMaximum": 1.0 } }, "required": [ - "id" - ] - } - } - } - """ - } - - /** - * Test class `Foo` containing monetary amounts. - * - * Note: Normally the [Title] and [Description] is already set on the [Money], - * so we want to test if we can override it. - */ - @Serializable - @SerialName("foo") - @Description("A container of monetary amounts") - @Suppress("unused") // it is used to generate schema - class Foo( - @Title("Money 1, without property description") - val money1: Money, - @Title("Money 2, with property description") - @Description("A monetary amount with property description") - var money2: Money - ) - - @Test - fun `should prioritize title and description set on property over the one set for the whole class`() { - val schema = jsonSchemaOf<Foo>() - testJson.encodeToString(schema) shouldEqualJson /* language=json */ $$""" - { - "type": "object", - "description": "A container of monetary amounts", - "properties": { - "money1": { - "type": "string", - "title": "Money 1, without property description", - "description": "A monetary amount", - "pattern": "^-?[0-9]+\\.[0-9]{2}?$" - }, - "money2": { - "type": "string", - "title": "Money 2, with property description", - "description": "A monetary amount with property description", - "pattern": "^-?[0-9]+\\.[0-9]{2}?$" + "name", + "birthDate", + "address", + "salary", + "tax", + "status", + "avatar", + "tokens", + "karma", + "experience", + "factor" + ], + "definitions": { + "address": { + "type": "object", + "title": "The full address", + "description": "An address of a person or an organization", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "postalCode": { + "type": "string", + "description": "A postal code not limited to particular country", + "minLength": 3, + "maxLength": 10 + }, + "countryCode": { + "type": "string", + "pattern": "[a-z]{2}" + } + }, + "required": [ + "street", + "city", + "postalCode", + "countryCode" + ] + }, + "mentor": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } } - }, - "required": [ - "money1", - "money2" - ] - } - """ - } + """ + } - @Test - fun `should suppress description of the top level object JSON Schema`() { - val schema = jsonSchemaOf<Foo>( - suppressDescription = true + /** + * Test class `Foo` containing monetary amounts. + * + * Note: Normally the [Title] and [Description] is already set on the [Money], + * so we want to test if we can override it. + */ + @Serializable + @SerialName("foo") + @Description("A container of monetary amounts") + @Suppress("unused") // it is used to generate schema + class Foo( + @Title("Money 1, without property description") + val money1: Money, + @Title("Money 2, with property description") + @Description("A monetary amount with property description") + var money2: Money ) - testJson.encodeToString(schema) shouldEqualJson /* language=json */ $$""" - { - "type": "object", - "properties": { - "money1": { - "type": "string", - "title": "Money 1, without property description", - "description": "A monetary amount", - "pattern": "^-?[0-9]+\\.[0-9]{2}?$" - }, - "money2": { - "type": "string", - "title": "Money 2, with property description", - "description": "A monetary amount with property description", - "pattern": "^-?[0-9]+\\.[0-9]{2}?$" - } - }, - "required": [ - "money1", - "money2" - ] - } - """ - } - - @Serializable - @Suppress("unused") // it is used to generate schema - class Bar( - val foo: Foo - ) - /** - * It seems that `additionalProperties: false` is preferred in the Open AI API documentation. - */ - @Test - fun `should output additionalProperties keyword`() { - val schema = jsonSchemaOf<Bar>( - outputAdditionalPropertiesFalse = true - ) - testJson.encodeToString(schema) shouldEqualJson /* language=json */ $$""" - { - "type": "object", - "properties": { - "foo": { - "$ref": "#/definitions/foo" - } - }, - "required": [ - "foo" - ], - "definitions": { - "foo": { + @Test + fun `should prioritize title and description set on property over the one set for the whole class`() { + val schema = jsonSchemaOf<Foo>() + testJson.encodeToString(schema) shouldEqualJson /* language=json */ $$""" + { "type": "object", "description": "A container of monetary amounts", "properties": { @@ -448,13 +349,94 @@ class JsonSchemaGeneratorTest { "required": [ "money1", "money2" + ] + } + """ + } + + @Test + fun `should suppress description of the top level object JSON Schema`() { + val schema = jsonSchemaOf<Foo>( + suppressDescription = true + ) + testJson.encodeToString(schema) shouldEqualJson /* language=json */ $$""" + { + "type": "object", + "properties": { + "money1": { + "type": "string", + "title": "Money 1, without property description", + "description": "A monetary amount", + "pattern": "^-?[0-9]+\\.[0-9]{2}?$" + }, + "money2": { + "type": "string", + "title": "Money 2, with property description", + "description": "A monetary amount with property description", + "pattern": "^-?[0-9]+\\.[0-9]{2}?$" + } + }, + "required": [ + "money1", + "money2" + ] + } + """ + } + + @Serializable + @Suppress("unused") // it is used to generate schema + class Bar( + val foo: Foo + ) + + /** + * It seems that `additionalProperties: false` is preferred in the Open AI API documentation. + */ + @Test + fun `should output additionalProperties keyword`() { + val schema = jsonSchemaOf<Bar>( + outputAdditionalPropertiesFalse = true + ) + testJson.encodeToString(schema) shouldEqualJson /* language=json */ $$""" + { + "type": "object", + "properties": { + "foo": { + "$ref": "#/definitions/foo" + } + }, + "required": [ + "foo" ], + "definitions": { + "foo": { + "type": "object", + "description": "A container of monetary amounts", + "properties": { + "money1": { + "type": "string", + "title": "Money 1, without property description", + "description": "A monetary amount", + "pattern": "^-?[0-9]+\\.[0-9]{2}?$" + }, + "money2": { + "type": "string", + "title": "Money 2, with property description", + "description": "A monetary amount with property description", + "pattern": "^-?[0-9]+\\.[0-9]{2}?$" + } + }, + "required": [ + "money1", + "money2" + ], + "additionalProperties": false + } + }, "additionalProperties": false } - }, - "additionalProperties": false - } - """ - } + """ + } } diff --git a/src/commonTest/kotlin/serialization/JsonSchemaSerializerTest.kt b/src/commonTest/kotlin/serialization/JsonSchemaSerializerTest.kt index 6f9b1e9..171a2ff 100644 --- a/src/commonTest/kotlin/serialization/JsonSchemaSerializerTest.kt +++ b/src/commonTest/kotlin/serialization/JsonSchemaSerializerTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,171 +26,171 @@ import kotlin.test.Test class JsonSchemaSerializerTest { - @Test - fun `should decode JsonSchema reference`() { - /* language=json */ - val json = $$""" - { - "$ref": "#/definitions/foo" - } - """ - testJson.decodeFromString<JsonSchema>(json) should { - be<JsonSchema.Ref>() - have(ref == "#/definitions/foo") + @Test + fun `should decode JsonSchema reference`() { + /* language=json */ + val json = $$""" + { + "$ref": "#/definitions/foo" + } + """ + testJson.decodeFromString<JsonSchema>(json) should { + be<JsonSchema.Ref>() + have(ref == "#/definitions/foo") + } } - } - @Test - fun `decode JSON Schema from JSON`() { - /* language=json */ - val json = $$""" - { - "type": "object", - "description": "Personal data", - "properties": { - "name": { - "type": "string", - "description": "The official name" - }, - "birthDate": { - "type": "string", - "format": "date-time" - }, - "email": { - "type": "string", - "minLength": 6, - "maxLength": 100, - "format": "email" - }, - "address": { - "$ref": "#/definitions/address" - }, - "hobbies": { - "type": "array", - "description": "A list of hobbies of the person", - "items": { - "type": "string", - "title": "A hobby item", - "description": "A hobby must be a unique identifier consisting out of lower case letters and underscores", - "pattern": "[a-z_]" - }, - "minItems": 0, - "maxItems": 10, - "uniqueItems": true - }, - "mentors": { - "type": "array", - "items": { - "$ref": "#/definitions/mentor" - } - }, - "salary": { - "type": "string", - "description": "A monetary amount", - "pattern": "^-?[0-9]+\\.[0-9]{2}?$" - }, - "tax": { - "type": "string", - "pattern": "^-?\\d+(\\.\\d+)?$" - }, - "status": { - "type": "string", - "title": "Entry status", - "description": "The enumeration of possible entry status states, e.g. 'verification-pending', 'verified'", - "enum": [ - "verification-pending", - "verified" - ] - }, - "avatar": { - "type": "string", - "contentEncoding": "base64", - "contentMediaType": "image/png" - }, - "tokens": { - "type": "integer", - "minimum": 0, - "maximum": 1000 - }, - "karma": { - "type": "integer", - "exclusiveMinimum": 0, - "exclusiveMaximum": 100 - }, - "experience": { - "type": "number", - "minimum": 0.0, - "maximum": 100.0 - }, - "factor": { - "type": "number", - "exclusiveMinimum": 0.0, - "exclusiveMaximum": 1.0 - } - }, - "required": [ - "name", - "birthDate", - "address", - "salary", - "tax", - "status", - "avatar", - "tokens", - "karma", - "experience", - "factor" - ], - "definitions": { - "address": { + @Test + fun `decode JSON Schema from JSON`() { + /* language=json */ + val json = $$""" + { "type": "object", - "title": "The full address", - "description": "An address of a person or an organization", + "description": "Personal data", "properties": { - "street": { - "type": "string" + "name": { + "type": "string", + "description": "The official name" + }, + "birthDate": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string", + "minLength": 6, + "maxLength": 100, + "format": "email" + }, + "address": { + "$ref": "#/definitions/address" + }, + "hobbies": { + "type": "array", + "description": "A list of hobbies of the person", + "items": { + "type": "string", + "title": "A hobby item", + "description": "A hobby must be a unique identifier consisting out of lower case letters and underscores", + "pattern": "[a-z_]" + }, + "minItems": 0, + "maxItems": 10, + "uniqueItems": true + }, + "mentors": { + "type": "array", + "items": { + "$ref": "#/definitions/mentor" + } + }, + "salary": { + "type": "string", + "description": "A monetary amount", + "pattern": "^-?[0-9]+\\.[0-9]{2}?$" }, - "city": { - "type": "string" + "tax": { + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?$" }, - "postalCode": { + "status": { "type": "string", - "description": "A postal code not limited to particular country", - "minLength": 3, - "maxLength": 10 + "title": "Entry status", + "description": "The enumeration of possible entry status states, e.g. 'verification-pending', 'verified'", + "enum": [ + "verification-pending", + "verified" + ] }, - "countryCode": { + "avatar": { "type": "string", - "pattern": "[a-z]{2}" + "contentEncoding": "base64", + "contentMediaType": "image/png" + }, + "tokens": { + "type": "integer", + "minimum": 0, + "maximum": 1000 + }, + "karma": { + "type": "integer", + "exclusiveMinimum": 0, + "exclusiveMaximum": 100 + }, + "experience": { + "type": "number", + "minimum": 0.0, + "maximum": 100.0 + }, + "factor": { + "type": "number", + "exclusiveMinimum": 0.0, + "exclusiveMaximum": 1.0 } }, "required": [ - "street", - "city", - "postalCode", - "countryCode" - ] - }, - "mentor": { - "type": "object", - "properties": { - "id": { - "type": "string" + "name", + "birthDate", + "address", + "salary", + "tax", + "status", + "avatar", + "tokens", + "karma", + "experience", + "factor" + ], + "definitions": { + "address": { + "type": "object", + "title": "The full address", + "description": "An address of a person or an organization", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "postalCode": { + "type": "string", + "description": "A postal code not limited to particular country", + "minLength": 3, + "maxLength": 10 + }, + "countryCode": { + "type": "string", + "pattern": "[a-z]{2}" + } + }, + "required": [ + "street", + "city", + "postalCode", + "countryCode" + ] + }, + "mentor": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] } - }, - "required": [ - "id" - ] + } } - } - } - """ + """ - // when - val schema = testJson.decodeFromString<JsonSchema>(json) + // when + val schema = testJson.decodeFromString<JsonSchema>(json) - // then - schema.toString() shouldEqualJson json - } + // then + schema.toString() shouldEqualJson json + } } diff --git a/src/commonTest/kotlin/test/BigDecimals.kt b/src/commonTest/kotlin/test/BigDecimals.kt index 80733da..2ed4a94 100644 --- a/src/commonTest/kotlin/test/BigDecimals.kt +++ b/src/commonTest/kotlin/test/BigDecimals.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,25 +31,25 @@ import kotlinx.serialization.encoding.Encoder * version of it, and add our own serializer, so we can add our own custom annotations. */ typealias BigDecimal = - @Serializable(BigDecimalSerializer::class) - com.ionspin.kotlin.bignum.decimal.BigDecimal + @Serializable(BigDecimalSerializer::class) + com.ionspin.kotlin.bignum.decimal.BigDecimal object BigDecimalSerializer : KSerializer<BigDecimal> { - @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) - override val descriptor = buildSerialDescriptor( - serialName = "BigDecimal", - kind = PrimitiveKind.STRING - ) { - annotations = listOf(Pattern.DECIMAL) - } - - override fun serialize(encoder: Encoder, value: BigDecimal) { - encoder.encodeString(value.toString(10)) - } - - override fun deserialize(decoder: Decoder): BigDecimal { - return BigDecimal.parseString(decoder.decodeString(), 10) - } + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + override val descriptor = buildSerialDescriptor( + serialName = "BigDecimal", + kind = PrimitiveKind.STRING + ) { + annotations = listOf(Pattern.DECIMAL) + } + + override fun serialize(encoder: Encoder, value: BigDecimal) { + encoder.encodeString(value.toString(10)) + } + + override fun deserialize(decoder: Decoder): BigDecimal { + return BigDecimal.parseString(decoder.decodeString(), 10) + } } diff --git a/src/commonTest/kotlin/test/Money.kt b/src/commonTest/kotlin/test/Money.kt index 3d2b7e6..0c5da36 100644 --- a/src/commonTest/kotlin/test/Money.kt +++ b/src/commonTest/kotlin/test/Money.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,11 +18,7 @@ package com.xemantic.ai.tool.schema.test import com.xemantic.ai.tool.schema.meta.Description import com.xemantic.ai.tool.schema.meta.Pattern -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.InternalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.Serializer +import kotlinx.serialization.* import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.buildSerialDescriptor import kotlinx.serialization.encoding.Decoder @@ -41,33 +37,33 @@ import kotlinx.serialization.encoding.Encoder @Description("A monetary amount") @Serializable(MoneySerializer::class) interface Money { - operator fun plus(amount: Money): Money - operator fun compareTo(other: Money): Int + operator fun plus(amount: Money): Money + operator fun compareTo(other: Money): Int } expect fun Money(amount: String): Money object MoneySerializer : KSerializer<Money> { - // It's a hack to autogenerate a serializer which will retain annotations of serialized class - @OptIn(ExperimentalSerializationApi::class) - @Serializer(forClass = Money::class) - private object AutoSerializer + // It's a hack to autogenerate a serializer which will retain annotations of serialized class + @OptIn(ExperimentalSerializationApi::class) + @Serializer(forClass = Money::class) + private object AutoSerializer - @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) - override val descriptor = buildSerialDescriptor( - serialName = AutoSerializer.descriptor.serialName, - kind = PrimitiveKind.STRING - ) { - annotations = AutoSerializer.descriptor.annotations - } + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + override val descriptor = buildSerialDescriptor( + serialName = AutoSerializer.descriptor.serialName, + kind = PrimitiveKind.STRING + ) { + annotations = AutoSerializer.descriptor.annotations + } - override fun serialize(encoder: Encoder, value: Money) { - encoder.encodeString(value.toString()) - } + override fun serialize(encoder: Encoder, value: Money) { + encoder.encodeString(value.toString()) + } - override fun deserialize( - decoder: Decoder - ) = Money(decoder.decodeString()) + override fun deserialize( + decoder: Decoder + ) = Money(decoder.decodeString()) } diff --git a/src/commonTest/kotlin/test/TestSupport.kt b/src/commonTest/kotlin/test/TestSupport.kt index 1574eea..723d27e 100644 --- a/src/commonTest/kotlin/test/TestSupport.kt +++ b/src/commonTest/kotlin/test/TestSupport.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,7 @@ import kotlinx.serialization.json.Json * A pretty printing [Json] for tests. */ val testJson = Json { - prettyPrint = true - @OptIn(ExperimentalSerializationApi::class) - prettyPrintIndent = " " + prettyPrint = true + @OptIn(ExperimentalSerializationApi::class) + prettyPrintIndent = " " } diff --git a/src/jvmMain/kotlin/BigDecimalSerializer.kt b/src/jvmMain/kotlin/BigDecimalSerializer.kt index 1dee37b..23c6e65 100644 --- a/src/jvmMain/kotlin/BigDecimalSerializer.kt +++ b/src/jvmMain/kotlin/BigDecimalSerializer.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,23 +49,23 @@ import java.math.BigDecimal */ public object BigDecimalSerializer : KSerializer<BigDecimal> { - @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) - public override val descriptor: SerialDescriptor = buildSerialDescriptor( - serialName = "BigDecimal", - kind = PrimitiveKind.STRING - ) { - annotations = listOf( - Description("A decimal number"), - Pattern.DECIMAL - ) - } + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + public override val descriptor: SerialDescriptor = buildSerialDescriptor( + serialName = "BigDecimal", + kind = PrimitiveKind.STRING + ) { + annotations = listOf( + Description("A decimal number"), + Pattern.DECIMAL + ) + } - override fun serialize(encoder: Encoder, value: BigDecimal) { - encoder.encodeString(value.stripTrailingZeros().toPlainString()) - } + override fun serialize(encoder: Encoder, value: BigDecimal) { + encoder.encodeString(value.stripTrailingZeros().toPlainString()) + } - override fun deserialize( - decoder: Decoder - ): BigDecimal = BigDecimal(decoder.decodeString()) + override fun deserialize( + decoder: Decoder + ): BigDecimal = BigDecimal(decoder.decodeString()) } diff --git a/src/jvmTest/kotlin/JvmPackage.kt b/src/jvmTest/kotlin/Package.jvm.kt similarity index 93% rename from src/jvmTest/kotlin/JvmPackage.kt rename to src/jvmTest/kotlin/Package.jvm.kt index 6980c24..8c208f1 100644 --- a/src/jvmTest/kotlin/JvmPackage.kt +++ b/src/jvmTest/kotlin/Package.jvm.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/jvmTest/kotlin/serialization/JavaBigDecimalToSchemaTest.kt b/src/jvmTest/kotlin/serialization/JavaBigDecimalToSchemaTest.kt index ec903b2..fcc36c8 100644 --- a/src/jvmTest/kotlin/serialization/JavaBigDecimalToSchemaTest.kt +++ b/src/jvmTest/kotlin/serialization/JavaBigDecimalToSchemaTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,54 +25,53 @@ import com.xemantic.ai.tool.schema.test.testJson import io.kotest.assertions.json.shouldEqualJson import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers -import kotlinx.serialization.encodeToString import java.math.BigDecimal import kotlin.test.Test class JavaBigDecimalToSchemaTest { - @Serializable - data class FinancialReport( - val netSalesRevenue: BigDecimal, - // here we are adding title - @Title("Cost of Goods Sold (COGS)") - val costOfGoodsSold: BigDecimal, - // here the description is altered from the default declared for BigDecimal - @Description("A decimal number of gross profit calculated as Net Sales Revenue - Cost of Goods Sold") - val grossProfit: BigDecimal - ) + @Serializable + data class FinancialReport( + val netSalesRevenue: BigDecimal, + // here we are adding title + @Title("Cost of Goods Sold (COGS)") + val costOfGoodsSold: BigDecimal, + // here the description is altered from the default declared for BigDecimal + @Description("A decimal number of gross profit calculated as Net Sales Revenue - Cost of Goods Sold") + val grossProfit: BigDecimal + ) - @Test - fun `should represent Java BigDecimal as String with pattern and description JSON Schema`() { - val schema = jsonSchemaOf<FinancialReport>() - testJson.encodeToString(schema) shouldEqualJson /* language=json */ $$""" - { - "type": "object", - "properties": { - "netSalesRevenue": { - "type": "string", - "description": "A decimal number", - "pattern": "^-?\\d+(\\.\\d+)?$" - }, - "costOfGoodsSold": { - "type": "string", - "title": "Cost of Goods Sold (COGS)", - "description": "A decimal number", - "pattern": "^-?\\d+(\\.\\d+)?$" - }, - "grossProfit": { - "type": "string", - "description": "A decimal number of gross profit calculated as Net Sales Revenue - Cost of Goods Sold", - "pattern": "^-?\\d+(\\.\\d+)?$" + @Test + fun `should represent Java BigDecimal as String with pattern and description JSON Schema`() { + val schema = jsonSchemaOf<FinancialReport>() + testJson.encodeToString(schema) shouldEqualJson /* language=json */ $$""" + { + "type": "object", + "properties": { + "netSalesRevenue": { + "type": "string", + "description": "A decimal number", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "costOfGoodsSold": { + "type": "string", + "title": "Cost of Goods Sold (COGS)", + "description": "A decimal number", + "pattern": "^-?\\d+(\\.\\d+)?$" + }, + "grossProfit": { + "type": "string", + "description": "A decimal number of gross profit calculated as Net Sales Revenue - Cost of Goods Sold", + "pattern": "^-?\\d+(\\.\\d+)?$" + } + }, + "required": [ + "netSalesRevenue", + "costOfGoodsSold", + "grossProfit" + ] } - }, - "required": [ - "netSalesRevenue", - "costOfGoodsSold", - "grossProfit" - ] - } - """ - } + """ + } } diff --git a/src/jvmTest/kotlin/test/JvmMoney.kt b/src/jvmTest/kotlin/test/Money.jvm.kt similarity index 77% rename from src/jvmTest/kotlin/test/JvmMoney.kt rename to src/jvmTest/kotlin/test/Money.jvm.kt index e510d84..25b7900 100644 --- a/src/jvmTest/kotlin/test/JvmMoney.kt +++ b/src/jvmTest/kotlin/test/Money.jvm.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,22 +26,22 @@ import java.math.BigDecimal * performance/stability advantage for calculation-heavy tasks. */ class JvmMoney( - private val value: BigDecimal + private val value: BigDecimal ) : Money { - override fun plus( - amount: Money - ) = JvmMoney(value + (amount as JvmMoney).value) + override fun plus( + amount: Money + ) = JvmMoney(value + (amount as JvmMoney).value) - override fun compareTo( - other: Money - ) = value.compareTo( - (other as JvmMoney).value - ) + override fun compareTo( + other: Money + ) = value.compareTo( + (other as JvmMoney).value + ) } @Suppress("TestFunctionName") actual fun Money( - amount: String + amount: String ): Money = JvmMoney(BigDecimal(amount)) diff --git a/src/nonJvmTest/kotlin/NonJvmMoney.kt b/src/nonJvmTest/kotlin/Money.nonJvm.kt similarity index 67% rename from src/nonJvmTest/kotlin/NonJvmMoney.kt rename to src/nonJvmTest/kotlin/Money.nonJvm.kt index 43db19e..29af958 100644 --- a/src/nonJvmTest/kotlin/NonJvmMoney.kt +++ b/src/nonJvmTest/kotlin/Money.nonJvm.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 Kazimierz Pogoda / Xemantic + * Copyright 2024-2025 Kazimierz Pogoda / Xemantic * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,26 +22,26 @@ import com.ionspin.kotlin.bignum.decimal.BigDecimal * The `nonJvm` flavor of test money. */ class NonJvmMoney( - private val value: BigDecimal + private val value: BigDecimal ) : Money { - override fun plus( - amount: Money - ) = NonJvmMoney(value + (amount as NonJvmMoney).value) + override fun plus( + amount: Money + ) = NonJvmMoney(value + (amount as NonJvmMoney).value) - override fun compareTo( - other: Money - ) = value.compareTo( - (other as NonJvmMoney).value - ) + override fun compareTo( + other: Money + ) = value.compareTo( + (other as NonJvmMoney).value + ) - override fun toString(): String { - return value.toString(10) - } + override fun toString(): String { + return value.toString(10) + } } @Suppress("TestFunctionName") actual fun Money( - amount: String + amount: String ): Money = NonJvmMoney(BigDecimal.parseString(amount))