diff --git a/.editorconfig b/.editorconfig index d9fdd62..5b8ad12 100644 --- a/.editorconfig +++ b/.editorconfig @@ -69,8 +69,8 @@ ij_kotlin_method_call_chain_wrap = normal ij_kotlin_method_parameters_new_line_after_left_paren = true ij_kotlin_method_parameters_right_paren_on_new_line = true ij_kotlin_method_parameters_wrap = on_every_item -ij_kotlin_name_count_to_use_star_import = 5 -ij_kotlin_name_count_to_use_star_import_for_members = 3 +ij_kotlin_name_count_to_use_star_import = 99999 +ij_kotlin_name_count_to_use_star_import_for_members = 99999 ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**,io.ktor.** ij_kotlin_parameter_annotation_wrap = off ij_kotlin_space_after_comma = true diff --git a/README.MD b/README.MD index ae7b620..9490d37 100644 --- a/README.MD +++ b/README.MD @@ -1,8 +1,11 @@ -# OpenKTTP +# OpenAPI-kt + +**WORK IN PROGRESS** OpenKTTP is a toolset for working with OpenAPI in Kotlin. This project exists out of several pieces, and they can be combined in different ways to achieve different goals. - Core: A OpenAPI parser, and typed ADT based on KotlinX Serialization - OpenAPI Typed: A version of the `Core` ADT, structures the data in a convenient way to retrieve. -- Generic: A `Generic` ADT that allows working with content regardless of its format. +- Generator: A code generator that generates code from the `OpenAPI Typed` ADT +- Gradle Plugin: Gradle plugin to conveniently generate clients diff --git a/build.gradle.kts b/build.gradle.kts index af5684e..00f65f1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,59 @@ +import com.diffplug.gradle.spotless.SpotlessExtension +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.jvm.tasks.Jar +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.powerassert.gradle.PowerAssertGradleExtension + plugins { alias(libs.plugins.multiplatform) apply false + alias(libs.plugins.assert) alias(libs.plugins.publish) + alias(libs.plugins.spotless) } + +configure { + kotlin { + target("**/*.kt") + ktfmt().kotlinlangStyle().configure { + it.setBlockIndent(2) + it.setContinuationIndent(2) + it.setRemoveUnusedImport(true) + } + trimTrailingWhitespace() + endWithNewline() + } +} + +@Suppress("OPT_IN_USAGE") +configure { + functions = listOf( + "kotlin.test.assertEquals", + "kotlin.test.assertTrue" + ) +} + +subprojects { + tasks { + withType(Jar::class.java) { + manifest { + attributes("Automatic-Module-Name" to "io.github.nomisrev") + } + } + withType { + options.release.set(8) + } + withType { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } + } + withType { + useJUnitPlatform() + testLogging { + exceptionFormat = TestExceptionFormat.FULL + events("SKIPPED", "FAILED") + } + } + } +} \ No newline at end of file diff --git a/example/build.gradle.kts b/example/build.gradle.kts index 060ecc4..ea8b06d 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -1,5 +1,3 @@ -import io.github.nomisrev.openapi.generation.NamingStrategy - plugins { kotlin("multiplatform") version "2.0.0" id("io.github.nomisrev.openapi.plugin") version "1.0.0" @@ -12,6 +10,7 @@ kotlin { commonMain { kotlin.srcDir(project.file("build/generated/openapi/src/commonMain/kotlin")) dependencies { + implementation("io.ktor:ktor-client-core:2.3.6") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") } } diff --git a/example/src/commonMain/kotlin/main.kt b/example/src/commonMain/kotlin/main.kt new file mode 100644 index 0000000..e659132 --- /dev/null +++ b/example/src/commonMain/kotlin/main.kt @@ -0,0 +1,5 @@ +package io.github.nomisrev.openapi + +val x: OpenAPI = TODO() + +suspend fun main() {} diff --git a/generation/build.gradle.kts b/generation/build.gradle.kts index 065412d..c2a3158 100644 --- a/generation/build.gradle.kts +++ b/generation/build.gradle.kts @@ -7,23 +7,13 @@ plugins { id(libs.plugins.publish.get().pluginId) } -@Suppress("OPT_IN_USAGE") -powerAssert { - functions = listOf("kotlin.test.assertEquals", "kotlin.test.assertTrue") -} - kotlin { -// explicitApi() jvm { @OptIn(ExperimentalKotlinGradlePluginApi::class) mainRun { mainClass.set("io.github.nomisrev.openapi.MainKt") } } - macosArm64 { - binaries { - executable { entryPoint = "main" } - } - } - linuxX64() +// macosArm64() +// linuxX64() sourceSets { commonMain { @@ -32,6 +22,7 @@ kotlin { dependencies { api(libs.kasechange) api(libs.okio) + implementation("io.ktor:ktor-client-core:2.3.6") api(projects.parser) } } @@ -40,6 +31,11 @@ kotlin { implementation(libs.test) } } + jvmMain { + dependencies { + implementation("com.squareup:kotlinpoet:1.17.0") + } + } // jsMain { // dependencies { // implementation("com.squareup.okio:okio-nodefilesystem:3.9.0") @@ -53,10 +49,6 @@ kotlin { } } -tasks.withType { - useJUnitPlatform() -} - task("runMacosArm64") { dependsOn("linkDebugExecutableMacosArm64") dependsOn("runDebugExecutableMacosArm64") diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/ApiSorter.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/ApiSorter.kt new file mode 100644 index 0000000..73cd576 --- /dev/null +++ b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/ApiSorter.kt @@ -0,0 +1,88 @@ +package io.github.nomisrev.openapi + +// TODO: make hard-coded. +// We're opinionated about the API structure +internal fun interface ApiSorter { + fun sort(routes: Iterable): Root + + companion object { + val ByPath: ApiSorter = ByPathApiSorter + } +} + +/** + * ADT that models how to generate the API. Our OpenAPI document dictates the structure of the API, + * so all operations are available as their path, with operationId. i.e. for `OpenAI` + * `/chat/completions` with operationId `createChatCompletion`. + * + * interface OpenAI { val chat: Chat } interface Chat { val completions: Completions } interface + * Completions { fun createChatCompletion(...): CreateChatCompletionResponse } + * + * // openAI.chat.completions.createChatCompletion(...) + */ +data class Root( + /* `info.title`, or custom name */ + val name: String, + val operations: List, + val endpoints: List +) + +data class API(val name: String, val routes: List, val nested: List) + +private data class RootBuilder( + val name: String, + val operations: MutableList, + val nested: MutableList +) { + fun build(): Root = Root(name, operations, nested.map { it.build() }) +} + +private data class APIBuilder( + val name: String, + val routes: MutableList, + val nested: MutableList +) { + fun build(): API = API(name, routes, nested.map { it.build() }) +} + +private object ByPathApiSorter : ApiSorter { + override fun sort(routes: Iterable): Root { + val root = RootBuilder("OpenAPI", mutableListOf(), mutableListOf()) + routes.forEach { route -> + // Reduce paths like `/threads/{thread_id}/runs/{run_id}/submit_tool_outputs` + // into [threads, runs, submit_tool_outputs] + val parts = route.path.replace(Regex("\\{.*?\\}"), "").split("/").filter { it.isNotEmpty() } + + val first = + parts.getOrNull(0) + ?: run { + // Root operation + root.operations.add(route) + return@forEach + } + // We need to find `chat` in root.operations, and find completions in chat.nested + val api = + root.nested.firstOrNull { it.name == first } + ?: run { + val new = APIBuilder(first, mutableListOf(), mutableListOf()) + root.nested.add(new) + new + } + + addRoute(api, parts.drop(1), route) + } + return root.build() + } + + private fun addRoute(builder: APIBuilder, parts: List, route: Route) { + if (parts.isEmpty()) builder.routes.add(route) + else { + val part = parts[0] + val api = builder.nested.firstOrNull { it.name == part } + if (api == null) { + val new = APIBuilder(part, mutableListOf(route), mutableListOf()) + builder.nested.add(new) + } else addRoute(api, parts.drop(1), route) + } + } +} diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/GenerateClient.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/GenerateClient.kt deleted file mode 100644 index 9537049..0000000 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/GenerateClient.kt +++ /dev/null @@ -1,96 +0,0 @@ -package io.github.nomisrev.openapi - -import io.github.nomisrev.openapi.generation.DefaultNamingStrategy -import io.github.nomisrev.openapi.generation.ModelPredef -import io.github.nomisrev.openapi.generation.template -import io.github.nomisrev.openapi.generation.toPropertyCode -import okio.FileSystem -import okio.Path -import okio.Path.Companion.toPath - -public fun FileSystem.generateClient( - pathSpec: String, - `package`: String = "io.github.nomisrev.openapi", - modelPackage: String = "$`package`.models", - generationPath: String = - "../example/build/generated/openapi/src/commonMain/kotlin/${`package`.replace(".", "/")}" -) { - fun file(name: String, imports: Set, code: String) { - write(Path("$generationPath/models/$name.kt")) { - writeUtf8("${"package $modelPackage"}\n") - writeUtf8("\n") - if (imports.isNotEmpty()) { - writeUtf8("${imports.joinToString("\n") { "import $it" }}\n") - writeUtf8("\n") - } - writeUtf8("$code\n") - } - } - - deleteRecursively(Path(generationPath), false) - runCatching { createDirectories(Path("$generationPath/models"), mustCreate = false) } - val rawSpec = read(Path(pathSpec)) { readUtf8() } - val openAPI = OpenAPI.fromJson(rawSpec) - file("predef", emptySet(), ModelPredef) - openAPI.models().forEach { model -> - val strategy = DefaultNamingStrategy - val content = template { toPropertyCode(model, strategy) } - val name = strategy.typeName(model) -// if (name in setOf("MessageStreamEvent", "RunStepStreamEvent", "RunStreamEvent", "AssistantStreamEvent")) Unit -// else - file(name, content.imports, content.code) - } - - val routes = openAPI - .routes() - .groupBy { route -> - route.path.takeWhile { it != '{' }.dropLastWhile { it == '/' } - }.mapValues { (path, routes) -> extracted(path, routes) } - .forEach { (path, structure) -> - val strategy = DefaultNamingStrategy - } -} - -private fun extracted(path: String, routes: List): Structure { - val split = regex.split(path, limit = 2) - return if (split.size == 2) Structure.Nested(split[1], extracted(split[1], routes)) - else Structure.Value(routes) -} - -private val regex = "^(.+?)/".toRegex() - -/** - * Structure that helps us define the structure of the routes in OpenAPI. - * We want the generated API to look like the URLs, and their OpenAPI Specification. - * - * So we generate the API according to the structure of the OpenAPI Specification. - * A top-level interface `info.title`, or custom name. - * - * And within the interface all operations are available as their URL, with operationId. - * An example for `OpenAI` `/chat/completion` with operationId `createChatCompletion`. - * - * interface OpenAI { - * val chat: Chat - * } - * - * interface Chat { - * val completions: Completions - * } - * - * interface Completions { - * fun createChatCompletion(): CreateChatCompletionResponse - * } - * - * This requires us to split paths in a sane way, - * such that we can follow the structure of the specification. - */ -sealed interface Structure { - data class Value(val route: List) : Structure - data class Nested( - val name: String, - val route: Structure - ) : Structure -} - -private fun Path(path: String): Path = - path.toPath() \ No newline at end of file diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/Model.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/Model.kt index 78553ab..2c0f203 100644 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/Model.kt +++ b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/Model.kt @@ -1,11 +1,11 @@ package io.github.nomisrev.openapi -import io.github.nomisrev.openapi.Schema.Type import io.github.nomisrev.openapi.http.MediaType import io.github.nomisrev.openapi.http.Method import io.github.nomisrev.openapi.http.StatusCode -import kotlinx.serialization.json.JsonElement +import kotlin.jvm.JvmInline import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement data class Route( val operation: Operation, @@ -16,36 +16,84 @@ data class Route( val returnType: Returns, val extensions: Map ) { - data class Bodies( + /** Request bodies are optional by default! */ + val required: Boolean, val types: Map, val extensions: Map - ) : Map by types + ) : Map by types { + fun jsonOrNull(): Body.Json? = types.getOrElse(MediaType.ApplicationJson) { null } as? Body.Json + + fun octetStreamOrNull(): Body.OctetStream? = + types.getOrElse(MediaType.ApplicationOctetStream) { null } as? Body.OctetStream + + fun xmlOrNull(): Body.Xml? = types.getOrElse(MediaType.ApplicationXml) { null } as? Body.Xml + + fun multipartOrNull(): Body.Multipart? = + types.getOrElse(MediaType.MultipartFormData) { null } as? Body.Multipart + } - // Required, isNullable sealed interface Body { + val description: String? val extensions: Map - data class OctetStream(override val extensions: Map) : Body - data class Json(val type: Model, override val extensions: Map) : Body - data class Xml(val type: Model, override val extensions: Map) : Body + data class OctetStream( + override val description: String?, + override val extensions: Map + ) : Body + + sealed interface Json : Body { + val type: Resolved + + data class FreeForm( + override val description: String?, + override val extensions: Map + ) : Json { + override val type: Resolved = Resolved.Value(Model.FreeFormJson(description)) + } + + data class Defined( + override val type: Resolved, + override val description: String?, + override val extensions: Map + ) : Json + } + + data class Xml( + val type: Model, + override val description: String?, + override val extensions: Map + ) : Body + + sealed interface Multipart : Body { + val parameters: List + + data class FormData(val name: String, val type: Resolved) + + data class Value( + val parameters: List, + override val description: String?, + override val extensions: Map + ) : Body, List by parameters - data class Multipart(val parameters: List, override val extensions: Map) : - Body, List by parameters { - data class FormData(val name: String, val type: Model) + data class Ref( + val value: Resolved.Ref, + override val description: String?, + override val extensions: Map + ) : Multipart { + override val parameters: List = listOf(FormData(value.name, value)) + } } } // A Parameter can be isNullable, required while the model is not! - sealed interface Input { - val name: String - val type: Model - - data class Query(override val name: String, override val type: Model) : Input - data class Path(override val name: String, override val type: Model) : Input - data class Header(override val name: String, override val type: Model) : Input - data class Cookie(override val name: String, override val type: Model) : Input - } + data class Input( + val name: String, + val type: Resolved, + val isRequired: Boolean, + val input: Parameter.Input, + val description: String? + ) data class Returns( val types: Map, @@ -53,31 +101,58 @@ data class Route( ) : Map by types // Required, isNullable ??? - data class ReturnType( - val type: Model, - val extensions: Map - ) + data class ReturnType(val type: Resolved, val extensions: Map) } /** - * Our own "Generated" oriented KModel. - * The goal of this KModel is to make generation as easy as possible, - * so we gather all information ahead of time. + * Allows tracking whether data was referenced by name, or defined inline. This is important to be + * able to maintain the structure of the specification. + */ +// TODO this can be removed. +// Move 'nested' logic to OpenAPITransformer +// Inline `namedOr` logic where used +// Rely on `ReferenceOr` everywhere within `OpenAPITransformer`? +sealed interface Resolved { + val value: A + + data class Ref(val name: String, override val value: A) : Resolved + + @JvmInline value class Value(override val value: A) : Resolved + + fun namedOr(orElse: () -> NamingContext): NamingContext = + when (this) { + is Ref -> NamingContext.Named(name) + is Value -> orElse() + } +} + +/** + * Our own "Generated" oriented KModel. The goal of this KModel is to make generation as easy as + * possible, so we gather all information ahead of time. * - * This KModel can/should be updated overtime to include all information we need for code generation. + * This KModel can/should be updated overtime to include all information we need for code + * generation. * - * The naming mechanism forces the same ordering as defined in the OpenAPI Specification, - * this gives us the best logical structure, and makes it easier to compare code and spec. - * Every type that needs to generate a name has a [NamingContext], see [NamingContext] for more details. + * The naming mechanism forces the same ordering as defined in the OpenAPI Specification, this gives + * us the best logical structure, and makes it easier to compare code and spec. Every type that + * needs to generate a name has a [NamingContext], see [NamingContext] for more details. */ sealed interface Model { + val description: String? sealed interface Primitive : Model { - data class Int(val schema: Schema, val default: kotlin.Int?) : Primitive - data class Double(val schema: Schema, val default: kotlin.Double?) : Primitive - data class Boolean(val schema: Schema, val default: kotlin.Boolean?) : Primitive - data class String(val schema: Schema, val default: kotlin.String?) : Primitive - data object Unit : Primitive + data class Int(val default: kotlin.Int?, override val description: kotlin.String?) : Primitive + + data class Double(val default: kotlin.Double?, override val description: kotlin.String?) : + Primitive + + data class Boolean(val default: kotlin.Boolean?, override val description: kotlin.String?) : + Primitive + + data class String(val default: kotlin.String?, override val description: kotlin.String?) : + Primitive + + data class Unit(override val description: kotlin.String?) : Primitive fun default(): kotlin.String? = when (this) { @@ -89,129 +164,107 @@ sealed interface Model { } } - data object Binary : Model - data object FreeFormJson : Model + data class OctetStream(override val description: String?) : Model + + data class FreeFormJson(override val description: String?) : Model sealed interface Collection : Model { - val value: Model - val schema: Schema + val inner: Resolved data class List( - override val schema: Schema, - override val value: Model, - val default: kotlin.collections.List? + override val inner: Resolved, + val default: kotlin.collections.List?, + override val description: String? ) : Collection data class Set( - override val schema: Schema, - override val value: Model, - val default: kotlin.collections.List? + override val inner: Resolved, + val default: kotlin.collections.List?, + override val description: String? ) : Collection - data class Map( - override val schema: Schema, - override val value: Model - ) : Collection { - val key = Primitive.String(Schema(type = Schema.Type.Basic.String), null) + data class Map(override val inner: Resolved, override val description: String?) : + Collection { + val key = Primitive.String(null, null) } } @Serializable data class Object( - val schema: Schema, val context: NamingContext, - val description: String?, - val properties: List, - val inline: List + override val description: String?, + val properties: List ) : Model { + val inline: List = + properties.mapNotNull { + if (it.model is Resolved.Value) + when (val model = it.model.value) { + is Collection -> + when (model.inner) { + is Resolved.Ref -> null + is Resolved.Value -> model.inner.value + } + else -> model + } + else null + } + @Serializable data class Property( - val schema: Schema, val baseName: String, - val name: String, - val type: Model, + val model: Resolved, /** - * isRequired != not-null. - * This means the value _has to be included_ in the payload, - * but it might be [isNullable]. + * isRequired != not-null. This means the value _has to be included_ in the payload, but it + * might be [isNullable]. */ val isRequired: Boolean, val isNullable: Boolean, val description: String? - ) { - sealed interface DefaultArgument { - data class Enum(val enum: Model, val context: NamingContext, val value: String) : DefaultArgument - data class Union( - val union: NamingContext, - val case: Model, - val value: String - ) : DefaultArgument - - data class Double(val value: kotlin.Double) : DefaultArgument - data class Int(val value: kotlin.Int) : DefaultArgument - data class List(val value: kotlin.collections.List) : DefaultArgument - data class Other(val value: String) : DefaultArgument + ) + } + + data class Union( + val context: NamingContext, + val cases: List, + val default: String?, + override val description: String? + ) : Model { + val inline: List = + cases.mapNotNull { + if (it.model is Resolved.Value) + when (val model = it.model.value) { + is Collection -> + when (model.inner) { + is Resolved.Ref -> null + is Resolved.Value -> model.inner.value + } + else -> model + } + else null } - } + + data class Case(val context: NamingContext, val model: Resolved) } - sealed interface Union : Model { - val schema: Schema + sealed interface Enum : Model { val context: NamingContext - val schemas: List - val inline: List - - fun isOpenEnumeration(): Boolean = - this is AnyOf && - schemas.size == 2 - && schemas.count { it.model is Enum } == 1 - && schemas.count { it.model is Primitive.String } == 1 - - data class UnionEntry(val context: NamingContext, val model: Model) - - /** - * [OneOf] is an untagged union. - * This is in Kotlin represented by a `sealed interface`. - */ - data class OneOf( - override val schema: Schema, - override val context: NamingContext, - override val schemas: List, - override val inline: List, - val default: String? - ) : Union - - /** - * [AnyOf] is an untagged union, with overlapping schema. - * - * Typically: - * - open enumeration: anyOf a `string`, and [Enum] (also type: `string`). - * - [Object] with a [FreeFormJson], where [FreeFormJson] has overlapping schema with the [Object]. - */ - data class AnyOf( - override val schema: Schema, + val values: List + val default: String? + override val description: String? + + data class Closed( override val context: NamingContext, - override val schemas: List, - override val inline: List, - val default: String? - ) : Union - - /** - * [TypeArray] - */ - data class TypeArray( - override val schema: Schema, + val inner: Resolved, + override val values: List, + override val default: String?, + override val description: String? + ) : Enum + + data class Open( override val context: NamingContext, - override val schemas: List, - override val inline: List - ) : Union + override val values: List, + override val default: String?, + override val description: String? + ) : Enum } - - data class Enum( - val schema: Schema, - val context: NamingContext, - val inner: Model, - val values: List, - val default: String? - ) : Model } diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/NamingContext.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/NamingContext.kt index 5e370fd..91a3fe6 100644 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/NamingContext.kt +++ b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/NamingContext.kt @@ -1,29 +1,35 @@ package io.github.nomisrev.openapi /** - * A type name needs to be generated using the surrounding context. - * - inline bodies (postfix `Request`) - * - inline responses (postfix `Response`) - * - inline operation parameters, - * - (inline | top-level) Object param `foo` inline schema => Type.Foo (nested) - * - (inline | top-level) Object param `foo` with top-level schema => top-level name - * - (inline | top-level) Object param `foo` with primitive => Primitive | List | Set | Map | JsonObject + * [NamingContext] is a critical part of how the models and routes are named. Following the context + * is important to generate the correct class names for all schemas that are defined inline, rather + * than named reference. + * + * An example, `/assistants/{assistant_id}/files` This would generate interfaces (and impl classes): + * `Assistants.Files`. Any inline defined schema by an operation is generated as a nested type. + * Let's say for `listAssistantFiles`, an `enum` for property `order` is generated as + * `ListAssistantFilesOrder`. However, the FULL class name is + * `Assistants.Files.ListAssistantFilesOrder`. NamingContext tracks this as: + * ```kotlin + * NamingContext.Nested( + * NamingContext.Nested("files", RouteParam("order", "ListAssistantFilesOrder")), + * Named("assistants") + * ) + * ``` + * + * The same technique is applied for all nesting, such that the correct names are generated. */ -public sealed interface NamingContext { - public val name: String +sealed interface NamingContext { + /** + * This tracks nested, which is important for generating the correct class names. For example, + * /threads/{thread_id}/runs/{run_id}/submit_tool_outputs. + */ + data class Nested(val inner: NamingContext, val outer: NamingContext) : NamingContext - public data class TopLevelSchema(override val name: String) : NamingContext + data class Named(val name: String) : NamingContext - public data class RouteParam( - override val name: String, - val operationId: String?, - val postfix: String - ) : NamingContext + data class RouteParam(val name: String, val operationId: String, val postfix: String) : + NamingContext - public sealed interface Param : NamingContext { - public val outer: NamingContext - } - - public data class Inline(override val name: String, override val outer: NamingContext) : Param - public data class Ref(override val name: String, override val outer: NamingContext) : Param -} \ No newline at end of file + data class RouteBody(val name: String, val postfix: String) : NamingContext +} diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPIInterceptor.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPIInterceptor.kt deleted file mode 100644 index 9ae78d9..0000000 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPIInterceptor.kt +++ /dev/null @@ -1,574 +0,0 @@ -package io.github.nomisrev.openapi - -import io.github.nomisrev.openapi.AdditionalProperties.Allowed -import io.github.nomisrev.openapi.Schema.Type -import io.github.nomisrev.openapi.Model.Collection -import io.github.nomisrev.openapi.Model.Object.Property -import io.github.nomisrev.openapi.Model.Object.Property.DefaultArgument -import io.github.nomisrev.openapi.Model.Primitive -import io.github.nomisrev.openapi.Model.Union.TypeArray -import io.github.nomisrev.openapi.http.MediaType.Companion.ApplicationJson -import io.github.nomisrev.openapi.http.MediaType.Companion.ApplicationOctetStream -import io.github.nomisrev.openapi.http.MediaType.Companion.ApplicationXml -import io.github.nomisrev.openapi.http.MediaType.Companion.MultipartFormData -import io.github.nomisrev.openapi.http.StatusCode - -internal const val schemaRef = "#/components/schemas/" -internal const val responsesRef = "#/components/responses/" -internal const val parametersRef = "#/components/parameters/" -internal const val requestBodiesRef = "#/components/requestBodies/" -internal const val pathItemsRef = "#/components/pathItems/" - -private const val applicationJson = "application/json" -private const val applicationOctectStream = "application/octet-stream" - -private fun Response.isEmpty(): Boolean = - headers.isEmpty() && content.isEmpty() && links.isEmpty() && extensions.isEmpty() - -/** - * DSL you have available within the [OpenAPIInterceptor], - * which allows you to retrieve references, - * or access the top-level information. - */ -interface OpenAPISyntax { - fun Schema.toModel(context: NamingContext): Model - fun Schema.topLevelNameOrNull(): String? - fun Schema.isTopLevel(): Boolean = - topLevelNameOrNull() != null - - fun ReferenceOr.Reference.namedSchema(): Pair - - fun ReferenceOr.get(): Schema - fun ReferenceOr.get(): Response - fun ReferenceOr.get(): Parameter - fun ReferenceOr.get(): RequestBody - fun ReferenceOr.get(): PathItem -} - -/** - * This class exposes the entire behavior of the transformation, - * it works in two phases. - * - * 1. Collecting phase: - * OpenAPI is traversed, - * and calls the [OpenAPISyntax] functions to transform the OpenAPI data into [Model]. - * [OpenAPISyntax] allows special syntax such as [ReferenceOr.valueOrNull], - * which allows resolving a reference everywhere in the DSL. - * **IMPORTANT:** [Model] ADT is structured, and nested in the same way as the OpenAPI. - * Meaning that if a class is _inline_ it should be generated _nested_ like [Model.Object.inline]. - * - * Any of this functionality can be overwritten, - * for example `toEnumName` to modify the `Enum` naming strategy. - * This is how we'll make it configurable ourselves as well. - * - * 2. Interception phase: - * This phase is purely transformational, it comes right after the phase 1 and allows you to apply a - * transformation from `(KModel) -> KModel` with same data as step 1. - * - * This is more convenient to modify simple things like `required`, `isNullable`, etc. - */ -interface OpenAPIInterceptor { - fun OpenAPISyntax.defaultArgument(model: Model, pSchema: Schema, context: NamingContext): DefaultArgument? - - fun OpenAPISyntax.toPropertyContext( - key: String, - propSchema: Schema, - parentSchema: Schema, - original: NamingContext - ): NamingContext.Param - - fun OpenAPISyntax.toObject( - context: NamingContext, - schema: Schema, - required: List, - properties: Map> - ): Model - - fun OpenAPISyntax.toMap( - context: NamingContext, - schema: Schema, - additionalSchema: AdditionalProperties.PSchema - ): Model - - fun OpenAPISyntax.toRawJson(allowed: Allowed): Model - - fun OpenAPISyntax.toPrimitive(context: NamingContext, schema: Schema, basic: Type.Basic): Model - - fun OpenAPISyntax.type( - context: NamingContext, - schema: Schema, - type: Type - ): Model - - fun OpenAPISyntax.toEnum( - context: NamingContext, - schema: Schema, - type: Type, - enums: List - ): Model - - fun OpenAPISyntax.toAnyOf( - context: NamingContext, - schema: Schema, - anyOf: List>, - ): Model - - fun OpenAPISyntax.toOneOf( - context: NamingContext, - schema: Schema, - oneOf: List>, - ): Model - - fun OpenAPISyntax.toUnionCaseContext( - context: NamingContext, - caseSchema: ReferenceOr - ): NamingContext - - fun OpenAPISyntax.toRequestBody(operation: Operation, body: RequestBody?): Route.Bodies - fun OpenAPISyntax.toResponses(operation: Operation): Route.Returns - - // Recover from missing responses - fun OpenAPISyntax.responseNotSupported( - operation: Operation, - response: Response, - code: StatusCode - ): Route.ReturnType = - throw IllegalStateException("OpenAPI requires at least 1 response") - - // model transformers - fun `object`( - context: NamingContext, - schema: Schema, - required: List, - properties: Map>, - model: Model - ): Model = model - - fun map( - context: NamingContext, - pSchema: AdditionalProperties.PSchema, - model: Model - ): Model = model - - fun rawJson(allowed: Allowed, model: Model): Model = model - - fun primitive(context: NamingContext, schema: Schema, basic: Type.Basic, model: Model): Model = - model - - fun OpenAPISyntax.type( - context: NamingContext, - schema: Schema, - type: Type, - model: Model - ): Model = model - - fun enum( - context: NamingContext, - schema: Schema, - type: Type, - enum: List, - model: Model - ): Model = model - - fun anyOf( - context: NamingContext, - schema: Schema, - anyOf: List>, - model: Model - ): Model = model - - fun oneOf( - context: NamingContext, - schema: Schema, - oneOf: List>, - model: Model - ): Model = model - - fun requestBodies( - operationId: String?, - requestBody: RequestBody, - models: List - ): List = models - - fun response( - operation: Operation, - statusCode: StatusCode, - model: Route.ReturnType, - ): Route.ReturnType = model - - companion object Default : OpenAPIInterceptor { - override fun OpenAPISyntax.toPropertyContext( - key: String, - propSchema: Schema, - parentSchema: Schema, - original: NamingContext - ): NamingContext.Param = - propSchema.topLevelNameOrNull()?.let { name -> - NamingContext.Ref(name, original) - } ?: NamingContext.Inline(key, original) - - private fun Schema.singleDefaultOrNull(): String? = - (default as? ExampleValue.Single)?.value - - // text-moderation-latest - // CreateModerationRequestModel.CaseModelEnum(CreateModerationRequestModel.ModelEnum.TextModerationLatest) - override fun OpenAPISyntax.defaultArgument( - model: Model, - pSchema: Schema, - pContext: NamingContext - ): DefaultArgument? = - when { -// pSchema.type is Type.Array -> -// DefaultArgument.List( -// (pSchema.type as Type.Array).types.mapNotNull { -// defaultArgument(Schema(type = it).toModel(pContext), pSchema, pContext) -// }) - - else -> pSchema.default?.toString()?.let { s -> - DefaultArgument.Other(s) - } - } - - override fun OpenAPISyntax.toObject( - context: NamingContext, - schema: Schema, - required: List, - properties: Map> - ): Model = Model.Object( - schema, - context, - schema.description, - properties.map { (name, ref) -> - val pSchema = ref.get() - val pContext = toPropertyContext(name, pSchema, schema, context) - val model = pSchema.toModel(pContext) - // TODO Property interceptor - Property( - pSchema, - name, - pContext.name, - model, - schema.required?.contains(name) == true, - pSchema.nullable ?: schema.required?.contains(name)?.not() ?: true, - pSchema.description - ) - }, - properties.mapNotNull { (name, ref) -> - val pSchema = ref.get() - pSchema.toModel(toPropertyContext(name, pSchema, schema, context)) - }.filterNot { it is Primitive } - ).let { `object`(context, schema, required, properties, it) } - - override fun OpenAPISyntax.toMap( - context: NamingContext, - schema: Schema, - additionalSchema: AdditionalProperties.PSchema - ): Model = - map(context, additionalSchema, Collection.Map(schema, additionalSchema.value.get().toModel(context))) - - override fun OpenAPISyntax.toRawJson(allowed: Allowed): Model = - if (allowed.value) rawJson(allowed, Model.FreeFormJson) - else throw IllegalStateException("Illegal State: No additional properties allowed on empty object.") - - override fun OpenAPISyntax.toPrimitive( - context: NamingContext, - schema: Schema, - basic: Type.Basic - ): Model = - when (basic) { - Type.Basic.Object -> toRawJson(Allowed(true)) - Type.Basic.Boolean -> Primitive.Boolean(schema, schema.default?.toString()?.toBoolean()) - Type.Basic.Integer -> Primitive.Int(schema, schema.default?.toString()?.toIntOrNull()) - Type.Basic.Number -> Primitive.Double(schema, schema.default?.toString()?.toDoubleOrNull()) - Type.Basic.Array -> collection(schema, context) - Type.Basic.String -> - if (schema.format == "binary") Model.Binary - else Primitive.String(schema, schema.default?.toString()) - - Type.Basic.Null -> TODO("Schema.Type.Basic.Null") - }.let { primitive(context, schema, basic, it) } - - private fun OpenAPISyntax.collection( - schema: Schema, - context: NamingContext - ): Collection { - val items = requireNotNull(schema.items) { "Array type requires items to be defined." } - val inner = when (items) { - is ReferenceOr.Reference -> - schema.items!!.get().toModel(NamingContext.TopLevelSchema(items.ref.drop(schemaRef.length))) - - is ReferenceOr.Value -> items.value.toModel(context) - } - val default = when (val example = schema.default) { - is ExampleValue.Multiple -> example.values - is ExampleValue.Single -> - when (val value = example.value) { - "[]" -> emptyList() - else -> listOf(value) - } - - null -> null - } - return if (schema.uniqueItems == true) Collection.Set(schema, inner, default) - else Collection.List(schema, inner, default) - } - - override fun OpenAPISyntax.type( - context: NamingContext, - schema: Schema, - type: Type - ): Model = when (type) { - is Type.Array -> when { - type.types.size == 1 -> type(context, schema.copy(type = type.types.single()), type.types.single()) - else -> TypeArray(schema, context, type.types.sorted().map { - Model.Union.UnionEntry(context, Schema(type = it).toModel(context)) - }, emptyList()) - } - - is Type.Basic -> toPrimitive(context, schema, type) - }.let { type(context, schema, type, it) } - - override fun OpenAPISyntax.toEnum( - context: NamingContext, - schema: Schema, - type: Type, - enums: List - ): Model { - require(enums.isNotEmpty()) { "Enum requires at least 1 possible value" } - /* To resolve the inner type, we erase the enum values. - * Since the schema is still on the same level - we keep the topLevelName */ - val inner = schema.copy(enum = null).toModel(context) - val default = schema.singleDefaultOrNull() - val kenum = Model.Enum(schema, context, inner, enums, default) - return enum(context, schema, type, enums, kenum) - } - - // Support AnyOf = null | A, should become A? - override fun OpenAPISyntax.toAnyOf( - context: NamingContext, - schema: Schema, - anyOf: List>, - ): Model = - anyOf(context, schema, anyOf, toUnion(context, schema, anyOf, Model.Union::AnyOf)) - - override fun OpenAPISyntax.toOneOf( - context: NamingContext, - schema: Schema, - oneOf: List>, - ): Model = - oneOf(context, schema, oneOf, toUnion(context, schema, oneOf, Model.Union::OneOf)) - - override fun OpenAPISyntax.toUnionCaseContext( - context: NamingContext, - caseSchema: ReferenceOr - ): NamingContext = when (caseSchema) { - is ReferenceOr.Reference -> NamingContext.TopLevelSchema(caseSchema.ref.drop(schemaRef.length)) - is ReferenceOr.Value -> when (context) { - is NamingContext.Inline -> when { - caseSchema.value.type != null && caseSchema.value.type is Type.Array -> - throw IllegalStateException("Cannot generate name for $caseSchema, ctx: $context.") - - caseSchema.value.enum != null -> context.copy(name = context.name + "_enum") - else -> context - } - /* - * TODO !!!! - * Top-level OneOf with inline schemas - * => how to generate names? - * - * - ChatCompletionToolChoiceOption - * -> enum (???) - * -> ChatCompletionNamedToolChoice - */ - is NamingContext.TopLevelSchema -> generateName(context, caseSchema.value) - - is NamingContext.RouteParam -> context - is NamingContext.Ref -> context - } - } - - /** - * TODO - * This needs a rock solid implementation, - * and should be super easy to override from Gradle. - * This is what we use to generate names for inline schemas, - * most of the time we can get away with other information, - * but not always. - */ - private fun OpenAPISyntax.generateName( - context: NamingContext.TopLevelSchema, - schema: Schema - ): NamingContext.TopLevelSchema = - when (val type = schema.type) { - Type.Basic.Array -> { - val inner = - requireNotNull(schema.items) { "Array type requires items to be defined." } - inner.get().type - TODO() - } - - Type.Basic.Object -> { - // TODO OpenAI specific - context.copy( - name = schema.properties - ?.firstNotNullOfOrNull { (key, value) -> - if (key == "event") value.get().enum else null - }?.singleOrNull() ?: TODO() - ) - } - - is Type.Array -> TODO() - Type.Basic.Number -> TODO() - Type.Basic.Boolean -> TODO() - Type.Basic.Integer -> TODO() - Type.Basic.Null -> TODO() - Type.Basic.String -> when (val enum = schema.enum) { - null -> context.copy(name = "CaseString") - else -> context.copy(name = enum.joinToString(prefix = "", separator = "Or")) - } - - null -> TODO() - } - - private fun OpenAPISyntax.toUnion( - context: NamingContext, - schema: Schema, - subtypes: List>, - transform: (Schema, NamingContext, List, inline: List, String?) -> A - ): A { - val inline = subtypes.mapNotNull { ref -> - ref.valueOrNull()?.toModel(toUnionCaseContext(context, ref)) - } - return transform( - schema, - context, - subtypes.map { ref -> - val caseContext = toUnionCaseContext(context, ref) - Model.Union.UnionEntry(caseContext, ref.get().toModel(caseContext)) - }, - inline, - // TODO We need to check the parent for default? - inline.firstNotNullOfOrNull { (it as? Primitive.String)?.schema?.singleDefaultOrNull() } - ) - } - - // TODO interceptor - override fun OpenAPISyntax.toRequestBody(operation: Operation, body: RequestBody?): Route.Bodies = - Route.Bodies( - body?.content?.entries?.associate { (contentType, mediaType) -> - when { - ApplicationXml.matches(contentType) -> TODO("Add support for XML.") - ApplicationJson.matches(contentType) -> { - val json = when (val s = mediaType.schema) { - is ReferenceOr.Reference -> { - val (name, schema) = s.namedSchema() - Route.Body.Json( - schema.toModel(NamingContext.TopLevelSchema(name)), - mediaType.extensions - ) - } - - is ReferenceOr.Value -> - Route.Body.Json( - s.value.toModel( - requireNotNull( - operation.operationId?.let { NamingContext.TopLevelSchema("${it}Request") } - ) { "OperationId is required for request body inline schemas. Otherwise we cannot generate OperationIdRequest class name" } - ), mediaType.extensions - ) - - null -> Route.Body.Json( - Model.FreeFormJson, mediaType.extensions - ) - } - Pair(ApplicationJson, json) - } - - MultipartFormData.matches(contentType) -> { - fun ctx(name: String): NamingContext = when (val s = mediaType.schema) { - is ReferenceOr.Reference -> NamingContext.TopLevelSchema(s.namedSchema().first) - is ReferenceOr.Value -> NamingContext.RouteParam( - name, - operation.operationId, - "Request" - ) - - null -> throw IllegalStateException("$mediaType without a schema. Generation doesn't know what to do, please open a ticket!") - } - - val props = requireNotNull(mediaType.schema!!.get().properties) { - "Generating multipart/form-data bodies without properties is not possible." - } - require(props.isNotEmpty()) { "Generating multipart/form-data bodies without properties is not possible." } - Pair(MultipartFormData, Route.Body.Multipart(props.map { (name, ref) -> - Route.Body.Multipart.FormData( - name, - ref.get().toModel(ctx(name)) - ) - }, mediaType.extensions)) - } - - ApplicationOctetStream.matches(contentType) -> - Pair(ApplicationOctetStream, Route.Body.OctetStream(mediaType.extensions)) - - else -> throw IllegalStateException("RequestBody content type: $this not yet supported.") - } - }.orEmpty(), body?.extensions.orEmpty() - ) - - private fun Response.isEmpty(): Boolean = - headers.isEmpty() && content.isEmpty() && links.isEmpty() && extensions.isEmpty() - - // TODO interceptor - override fun OpenAPISyntax.toResponses(operation: Operation): Route.Returns = - Route.Returns( - operation.responses.responses.entries.associate { (code, refOrResponse) -> - val statusCode = StatusCode.orThrow(code) - val response = refOrResponse.get() - when { - response.content.contains(applicationOctectStream) -> - Pair(statusCode, Route.ReturnType(Model.Binary, response.extensions)) - - response.content.contains(applicationJson) -> { - val mediaType = response.content.getValue(applicationJson) - when (val s = mediaType.schema) { - is ReferenceOr.Reference -> { - val (name, schema) = s.namedSchema() - Pair( - statusCode, - Route.ReturnType( - schema.toModel(NamingContext.TopLevelSchema(name)), - operation.responses.extensions - ) - ) - } - - is ReferenceOr.Value -> Pair(statusCode, Route.ReturnType(s.value.toModel( - requireNotNull(operation.operationId?.let { NamingContext.TopLevelSchema("${it}Response") }) { - "OperationId is required for request body inline schemas. Otherwise we cannot generate OperationIdRequest class name" - }), response.extensions - ) - ) - - null -> Pair( - statusCode, - Route.ReturnType(toRawJson(Allowed(true)), response.extensions) - ) - } - } - - response.isEmpty() -> Pair( - statusCode, - Route.ReturnType( - Primitive.String(Schema(type = Schema.Type.Basic.String), null), - response.extensions - ) - ) - - else -> Pair(statusCode, responseNotSupported(operation, response, statusCode)) - }.let { (code, response) -> Pair(code, response(operation, statusCode, response)) } - }, - operation.responses.extensions - ) - } -} \ No newline at end of file diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPITransformer.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPITransformer.kt index d77c05a..6458016 100644 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPITransformer.kt +++ b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPITransformer.kt @@ -2,42 +2,45 @@ package io.github.nomisrev.openapi import io.github.nomisrev.openapi.AdditionalProperties.Allowed import io.github.nomisrev.openapi.Model.Collection +import io.github.nomisrev.openapi.Model.Enum +import io.github.nomisrev.openapi.Model.Object.Property +import io.github.nomisrev.openapi.Model.Primitive +import io.github.nomisrev.openapi.NamingContext.Named +import io.github.nomisrev.openapi.Schema.Type +import io.github.nomisrev.openapi.http.MediaType.Companion.ApplicationJson +import io.github.nomisrev.openapi.http.MediaType.Companion.ApplicationOctetStream +import io.github.nomisrev.openapi.http.MediaType.Companion.ApplicationXml +import io.github.nomisrev.openapi.http.MediaType.Companion.MultipartFormData import io.github.nomisrev.openapi.http.Method +import io.github.nomisrev.openapi.http.StatusCode -public fun OpenAPI.routes(): List = - OpenAPITransformer(this).routes() +fun OpenAPI.routes(): Root = OpenAPITransformer(this).routes().let { ApiSorter.ByPath.sort(it) } -public fun OpenAPI.models(): Set = - with(OpenAPITransformer(this)) { - operationModels() + schemas() - }.mapNotNull { model -> - when (model) { - is Collection -> model.value - is Model.Primitive.Int, - is Model.Primitive.Double, - is Model.Primitive.Boolean, - is Model.Primitive.String, - is Model.Primitive.Unit -> null - - else -> model +fun OpenAPI.models(): Set = + with(OpenAPITransformer(this)) { schemas() } + .mapNotNull { model -> + when (model) { + is Collection -> model.inner.value + is Primitive.Int, + is Primitive.Double, + is Primitive.Boolean, + is Primitive.String, + is Primitive.Unit -> null + else -> model + } } - }.toSet() + .toSet() /** - * This class implements the traverser, - * it goes through the [OpenAPI] file, and gathers all the information. - * It calls the [OpenAPIInterceptor], - * and invokes the relevant methods for the appropriate models and operations. + * This class implements the traverser, it goes through the [OpenAPI] file, and gathers all the + * information. * - * It does the heavy lifting of figuring out what a `Schema` is, - * a `String`, `enum=[alive, dead]`, object, etc. + * It does the heavy lifting of figuring out what a `Schema` is, a `String`, `enum=[alive, dead]`, + * object, etc. */ -private class OpenAPITransformer( - private val openAPI: OpenAPI, - interceptor: OpenAPIInterceptor = OpenAPIInterceptor -) : OpenAPISyntax, OpenAPIInterceptor by interceptor { +private class OpenAPITransformer(private val openAPI: OpenAPI) { - public fun operations(): List> = + fun operations(): List> = openAPI.paths.entries.flatMap { (path, p) -> listOfNotNull( p.get?.let { Triple(path, Method.GET, it) }, @@ -53,171 +56,546 @@ private class OpenAPITransformer( fun routes(): List = operations().map { (path, method, operation) -> + val parts = path.replace(Regex("\\{.*?\\}"), "").split("/").filter { it.isNotEmpty() } + + fun context(context: NamingContext): NamingContext = + if (context is Named) context + else + when (parts.size) { + 0 -> context + 1 -> NamingContext.Nested(context, Named(parts[0])) + else -> + NamingContext.Nested( + context, + parts.drop(1).fold(Named(parts[0])) { acc, part -> + NamingContext.Nested(Named(part), acc) + } + ) + } + Route( operation = operation, path = path, method = method, - body = toRequestBody(operation, operation.requestBody?.get()), - input = operation.input(), - returnType = toResponses(operation), + body = toRequestBody(operation, operation.requestBody?.get(), ::context), + input = operation.input(::context), + returnType = toResponses(operation, ::context), extensions = operation.extensions ) } - fun Operation.input(): List = + fun Operation.input(create: (NamingContext) -> NamingContext): List = parameters.map { p -> val param = p.get() - val model = when (val s = param.schema) { - is ReferenceOr.Reference -> { - val (name, schema) = s.namedSchema() - schema.toModel(NamingContext.TopLevelSchema(name)) - } + val resolved = + param.schema?.resolve() ?: throw IllegalStateException("No Schema for Parameter.") + val context = + resolved + .namedOr { + val operationId = + requireNotNull(operationId) { + "operationId currently required to generate inline schemas for operation parameters." + } + NamingContext.RouteParam(param.name, operationId, "Request") + } + .let(create) + val model = resolved.toModel(context) - is ReferenceOr.Value -> - s.value.toModel(NamingContext.RouteParam(param.name, operationId, "Request")) + Route.Input( + param.name, + model, // TODO the nullable param should be configurable + param.required, + param.input, + param.description + ) + } - null -> throw IllegalStateException("No Schema for Parameter. Fallback to JsonObject?") - } - when (param.input) { - Parameter.Input.Query -> Route.Input.Query(param.name, model) - Parameter.Input.Header -> Route.Input.Header(param.name, model) - Parameter.Input.Path -> Route.Input.Path(param.name, model) - Parameter.Input.Cookie -> Route.Input.Cookie(param.name, model) + /** Gathers all "top-level", or components schemas. */ + fun schemas(): List = + openAPI.components.schemas.map { (name, refOrSchema) -> + when (val resolved = refOrSchema.resolve()) { + is Resolved.Ref -> throw IllegalStateException("Remote schemas not supported yet.") + is Resolved.Value -> Resolved.Ref(name, resolved.value).toModel(Named(name)).value } } - private fun Route.Body.model(): List = - when (this) { - is Route.Body.Json -> listOf(type) - is Route.Body.Multipart -> parameters.map { it.type } - is Route.Body.OctetStream -> listOf(Model.Binary) - is Route.Body.Xml -> TODO("We don't support XML yet") - } + fun Resolved.toModel(context: NamingContext): Resolved { + val model = + when { + value.isOpenEnumeration() -> + value.toOpenEnum(context, value.anyOf!!.firstNotNullOf { it.resolve().value.enum }) - /** - * Gets all the **inline** schemas for operations, - * and gathers them in the nesting that they occur within the document. - * So they can be generated whilst maintaining their order of nesting. - */ - fun operationModels(): List = - operations().flatMap { (_, _, operation) -> - // TODO this can probably be removed in favor of `Set` to remove duplication - // Gather the **inline** request bodies & returnType, ignore top-level - val bodies = operation.requestBody?.get() - ?.let { toRequestBody(operation, it).types.values } - ?.flatMap { it.model() } - .orEmpty() - - val responses = toResponses(operation).map { it.value.type } - - val parameters = operation.parameters.mapNotNull { refOrParam -> - refOrParam.valueOrNull()?.let { param -> - param.schema?.valueOrNull() - ?.toModel(NamingContext.RouteParam(param.name, operation.operationId, "Request")) - } + // We don't modify the existing schema's, or specification + // this is Resolved.Value && value.anyOf != null && value.anyOf?.size == 1 -> + // value.anyOf!![0].resolve().toModel(context) + // this is Resolved.Value && value.oneOf != null && value.oneOf?.size == 1 -> + // value.oneOf!![0].resolve().toModel(context) + value.anyOf != null -> value.toUnion(context, value.anyOf!!) + value.oneOf != null -> value.toUnion(context, value.oneOf!!) + value.allOf != null -> TODO("allOf") + value.enum != null -> value.toEnum(context, value.enum.orEmpty()) + value.properties != null -> value.asObject(context) + // If no values, properties, or schemas, were found, lets check the types + value.type != null -> value.type(context, value.type!!) + else -> TODO("Schema: $value not yet supported. Please report to issue tracker.") } - bodies + responses + parameters - } - - /** Gathers all "top-level", or components schemas. */ - fun schemas(): List { - val schemas = openAPI.components.schemas.entries.map { (schemaName, refOrSchema) -> - val ctx = NamingContext.TopLevelSchema(schemaName) - refOrSchema.valueOrNull()?.toModel(ctx) - ?: throw IllegalStateException("Remote schemas not supported yet.") + return when (this) { + is Resolved.Ref -> Resolved.Ref(name, model) + is Resolved.Value -> Resolved.Value(model) } - return schemas } - override fun Schema.toModel(context: NamingContext): Model = - when { - anyOf != null -> toAnyOf(context, this, anyOf!!) - oneOf != null && oneOf?.size == 1 -> asObject(context) - oneOf != null -> toOneOf(context, this, oneOf !!) - allOf != null -> TODO("allOf") - enum != null -> toEnum( - context, - this, - requireNotNull(type) { "Enum requires an inner type" }, - enum.orEmpty() - ) - - properties != null -> asObject(context) - type != null -> type(context, this, type!!) - else -> TODO("Schema: $this not yet supported. Please report to issue tracker.") - } - - private fun Schema.asObject(context: NamingContext): Model = - when { - properties != null -> toObject(context, this, required ?: emptyList(), properties!!) - additionalProperties != null -> when (val aProps = additionalProperties!!) { - is AdditionalProperties.PSchema -> toMap(context, this, aProps) - is Allowed -> toRawJson(aProps) - } - - else -> toRawJson(Allowed(true)) - } + fun Schema.isOpenEnumeration(): Boolean = + anyOf != null && + anyOf!!.size == 2 && + anyOf!!.count { it.resolve().value.enum != null } == 1 && + anyOf!!.count { it.resolve().value.type == Type.Basic.String } == 2 - override fun ReferenceOr.get(): Schema = + fun ReferenceOr.resolve(): Resolved = when (this) { - is ReferenceOr.Value -> value - is ReferenceOr.Reference -> namedSchema().second + is ReferenceOr.Value -> Resolved.Value(value) + is ReferenceOr.Reference -> { + val name = ref.drop(schemaRef.length) + val schema = + requireNotNull(openAPI.components.schemas[name]) { + "Schema $name could not be found in ${openAPI.components.schemas}. Is it missing?" + } + .valueOrNull() + ?: throw IllegalStateException("Remote schemas are not yet supported.") + Resolved.Ref(name, schema) + } } - override fun Schema.topLevelNameOrNull(): String? = - openAPI.components.schemas.entries.find { (_, ref) -> - ref.get() == this - }?.key - - override fun ReferenceOr.Reference.namedSchema(): Pair { - val name = ref.drop(schemaRef.length) - val schema = requireNotNull(openAPI.components.schemas[name]) { - "Schema $name could not be found in ${openAPI.components.schemas}. Is it missing?" - }.valueOrNull() ?: throw IllegalStateException("Remote schemas are not yet supported.") - return Pair(name, schema) - } - - override tailrec fun ReferenceOr.get(): Response = + tailrec fun ReferenceOr.get(): Response = when (this) { is ReferenceOr.Value -> value is ReferenceOr.Reference -> { - val typeName = ref.drop(responsesRef.length) + val typeName = ref.drop("#/components/responses/".length) requireNotNull(openAPI.components.responses[typeName]) { - "Response $typeName could not be found in ${openAPI.components.responses}. Is it missing?" - }.get() + "Response $typeName could not be found in ${openAPI.components.responses}. Is it missing?" + } + .get() } } - override tailrec fun ReferenceOr.get(): Parameter = + tailrec fun ReferenceOr.get(): Parameter = when (this) { is ReferenceOr.Value -> value is ReferenceOr.Reference -> { - val typeName = ref.drop(parametersRef.length) + val typeName = ref.drop("#/components/parameters/".length) requireNotNull(openAPI.components.parameters[typeName]) { - "Parameter $typeName could not be found in ${openAPI.components.parameters}. Is it missing?" - }.get() + "Parameter $typeName could not be found in ${openAPI.components.parameters}. Is it missing?" + } + .get() } } - override tailrec fun ReferenceOr.get(): RequestBody = + tailrec fun ReferenceOr.get(): RequestBody = when (this) { is ReferenceOr.Value -> value is ReferenceOr.Reference -> { - val typeName = ref.drop(requestBodiesRef.length) + val typeName = ref.drop("#/components/requestBodies/".length) requireNotNull(openAPI.components.requestBodies[typeName]) { - "RequestBody $typeName could not be found in ${openAPI.components.requestBodies}. Is it missing?" - }.get() + "RequestBody $typeName could not be found in ${openAPI.components.requestBodies}. Is it missing?" + } + .get() } } - override tailrec fun ReferenceOr.get(): PathItem = + tailrec fun ReferenceOr.get(): PathItem = when (this) { is ReferenceOr.Value -> value is ReferenceOr.Reference -> { - val typeName = ref.drop(pathItemsRef.length) + val typeName = ref.drop("#/components/pathItems/".length) requireNotNull(openAPI.components.pathItems[typeName]) { - "PathItem $typeName could not be found in ${openAPI.components.pathItems}. Is it missing?" - }.get() + "PathItem $typeName could not be found in ${openAPI.components.pathItems}. Is it missing?" + } + .get() } } + + private fun Schema.asObject(context: NamingContext): Model = + when { + properties != null -> toObject(context, properties!!) + additionalProperties != null -> + when (val aProps = additionalProperties!!) { + is AdditionalProperties.PSchema -> + Collection.Map(aProps.value.resolve().toModel(context), description) + is Allowed -> + if (aProps.value) Model.FreeFormJson(description) + else + throw IllegalStateException( + "Illegal State: No additional properties allowed on empty object." + ) + } + else -> Model.FreeFormJson(description) + } + + fun Schema.toObject(context: NamingContext, properties: Map>): Model = + Model.Object( + context, + description, + properties.map { (name, ref) -> + val resolved = ref.resolve() + val pContext = + when (resolved) { + is Resolved.Ref -> Named(resolved.name) + is Resolved.Value -> NamingContext.Nested(Named(name), context) + } + val model = resolved.toModel(pContext) + Property( + name, + model, + required?.contains(name) == true, + resolved.value.nullable ?: required?.contains(name)?.not() ?: true, + resolved.value.description + ) + } + ) + + private fun Schema.singleDefaultOrNull(): String? = (default as? ExampleValue.Single)?.value + + fun Schema.toOpenEnum(context: NamingContext, values: List): Enum.Open { + require(values.isNotEmpty()) { "OpenEnum requires at least 1 possible value" } + val default = singleDefaultOrNull() + return Enum.Open(context, values, default, description) + } + + fun Schema.toPrimitive(context: NamingContext, basic: Type.Basic): Model = + when (basic) { + Type.Basic.Object -> + if (Allowed(true).value) Model.FreeFormJson(description) + else + throw IllegalStateException( + "Illegal State: No additional properties allowed on empty object." + ) + Type.Basic.Boolean -> Primitive.Boolean(default?.toString()?.toBoolean(), description) + Type.Basic.Integer -> Primitive.Int(default?.toString()?.toIntOrNull(), description) + Type.Basic.Number -> Primitive.Double(default?.toString()?.toDoubleOrNull(), description) + Type.Basic.Array -> collection(context) + Type.Basic.String -> + if (format == "binary") Model.OctetStream(description) + else Primitive.String(default?.toString(), description) + Type.Basic.Null -> TODO("Schema.Type.Basic.Null") + } + + private fun Schema.collection(context: NamingContext): Collection { + val items = requireNotNull(items?.resolve()) { "Array type requires items to be defined." } + val inner = items.toModel(items.namedOr { context }) + val default = + when (val example = default) { + is ExampleValue.Multiple -> example.values + is ExampleValue.Single -> { + val value = example.value + when { + value == "[]" -> emptyList() + value.equals("null", ignoreCase = true) -> emptyList() + else -> listOf(value) + } + } + null -> null + } + return if (uniqueItems == true) Collection.Set(inner, default, description) + else Collection.List(inner, default, description) + } + + fun Schema.type(context: NamingContext, type: Type): Model = + when (type) { + is Type.Array -> + when { + type.types.size == 1 -> + copy(type = type.types.single()).type(context, type.types.single()) + else -> + Model.Union( + context, + type.types.sorted().map { t -> + val resolved = Resolved.Value(Schema(type = t)) + Model.Union.Case(context, resolved.toModel(context)) + }, + null, + description + ) + } + is Type.Basic -> toPrimitive(context, type) + } + + fun Schema.toEnum(context: NamingContext, enums: List): Enum.Closed { + require(enums.isNotEmpty()) { "Enum requires at least 1 possible value" } + /* To resolve the inner type, we erase the enum values. + * Since the schema is still on the same level - we keep the topLevelName */ + val inner = Resolved.Value(copy(enum = null)).toModel(context) + val default = singleDefaultOrNull() + return Enum.Closed(context, inner, enums, default, description) + } + + fun toUnionCaseContext(context: NamingContext, caseSchema: ReferenceOr): NamingContext = + when (caseSchema) { + is ReferenceOr.Reference -> Named(caseSchema.ref.drop(schemaRef.length)) + is ReferenceOr.Value -> + when (context) { + /* + * TODO !!!! + * Top-level OneOf with inline schemas + * => how to generate names? + * + * - ChatCompletionToolChoiceOption + * -> enum (???) + * -> ChatCompletionNamedToolChoice + */ + is Named -> generateName(context, caseSchema.value) + is NamingContext.Nested, + is NamingContext.RouteParam, + is NamingContext.RouteBody -> context + } + } + + /** + * TODO This needs a rock solid implementation, and should be super easy to override from Gradle. + * This is what we use to generate names for inline schemas, most of the time we can get away with + * other information, but not always. + * + * This typically occurs when a top-level oneOf, or anyOf has inline schemas. Since union cases + * don't have names, we need to generate a name for an inline schema. Generically we cannot do + * better than First, Second, etc. + */ + private fun generateName(context: Named, schema: Schema): NamingContext = + when (schema.type) { + Type.Basic.Array -> { + val inner = requireNotNull(schema.items) { "Array type requires items to be defined." } + inner.resolve().value.type + TODO("Name generation for Type Arrays not yet supported") + } + Type.Basic.Object -> { + // OpenAI specific: + // When there is an `event` property, + // rely on the single enum event name as generated name. + NamingContext.Nested( + schema.properties + ?.firstNotNullOfOrNull { (key, value) -> + if (key == "event") value.resolve().value.enum else null + } + ?.singleOrNull() + ?.let(::Named) + ?: TODO("Name Generated for inline objects of unions not yet supported."), + context + ) + } + is Type.Array -> TODO() + Type.Basic.Number -> TODO() + Type.Basic.Boolean -> TODO() + Type.Basic.Integer -> TODO() + Type.Basic.Null -> TODO() + Type.Basic.String -> + when (val enum = schema.enum) { + null -> context.copy(name = "CaseString") + else -> + NamingContext.Nested( + inner = + Named( + enum.joinToString(prefix = "", separator = "Or") { + it.replaceFirstChar(Char::uppercaseChar) + } + ), + context + ) + } + null -> TODO() + } + + /** + * This Comparator will sort union cases by their most complex schema first Such that if we have { + * "text" : String } & { "text" : String, "id" : Int } That we don't accidentally result in the + * first case, when we receive the second case. Primitive.String always comes last. + */ + private val unionSchemaComparator: Comparator = Comparator { o1, o2 -> + val m1 = o1.model.value + val m2 = o2.model.value + val m1Complexity = + when (m1) { + is Model.Object -> m1.properties.size + is Enum -> m1.values.size + is Primitive.String -> -1 + else -> 0 + } + val m2Complexity = + when (m2) { + is Model.Object -> m2.properties.size + is Enum -> m2.values.size + is Primitive.String -> -1 + else -> 0 + } + m2Complexity - m1Complexity + } + + private fun Schema.toUnion( + context: NamingContext, + subtypes: List> + ): Model.Union { + val cases = + subtypes + .map { ref -> + val caseContext = toUnionCaseContext(context, ref) + val resolved = ref.resolve() + Model.Union.Case(caseContext, resolved.toModel(caseContext)) + } + .sortedWith(unionSchemaComparator) + return Model.Union( + context, + cases, + singleDefaultOrNull() + ?: subtypes.firstNotNullOfOrNull { it.resolve().value.singleDefaultOrNull() }, + description + ) + } + + // TODO interceptor + fun toRequestBody( + operation: Operation, + body: RequestBody?, + create: (NamingContext) -> NamingContext + ): Route.Bodies = + Route.Bodies( + body?.required ?: false, + body + ?.content + ?.entries + ?.associate { (contentType, mediaType) -> + when { + ApplicationXml.matches(contentType) -> TODO("Add support for XML.") + ApplicationJson.matches(contentType) -> { + val json = + mediaType.schema?.resolve()?.let { json -> + val context = + json + .namedOr { + requireNotNull(operation.operationId?.let { Named("${it}Request") }) { + "OperationId is required for request body inline schemas. Otherwise we cannot generate OperationIdRequest class name" + } + } + .let(create) + + Route.Body.Json.Defined( + json.toModel(context), + body.description, + mediaType.extensions + ) + } + ?: Route.Body.Json.FreeForm(body.description, mediaType.extensions) + Pair(ApplicationJson, json) + } + MultipartFormData.matches(contentType) -> { + val resolved = + mediaType.schema?.resolve() + ?: throw IllegalStateException( + "$mediaType without a schema. Generation doesn't know what to do, please open a ticket!" + ) + + fun ctx(name: String): NamingContext = + resolved + .namedOr { + val operationId = + requireNotNull(operation.operationId) { + "operationId currently required to generate inline schemas for operation parameters." + } + NamingContext.RouteParam(name, operationId, "Request") + } + .let(create) + + val multipart = + when (resolved) { + is Resolved.Ref -> { + val model = resolved.toModel(Named(resolved.name)) as Resolved.Ref + Route.Body.Multipart.Ref(model, body.description, mediaType.extensions) + } + is Resolved.Value -> + Route.Body.Multipart.Value( + resolved.value.properties!!.map { (name, ref) -> + val resolved = ref.resolve() + Route.Body.Multipart.FormData(name, resolved.toModel(ctx(name))) + }, + body.description, + mediaType.extensions + ) + } + + Pair(MultipartFormData, multipart) + } + ApplicationOctetStream.matches(contentType) -> + Pair( + ApplicationOctetStream, + Route.Body.OctetStream(body.description, mediaType.extensions) + ) + else -> + throw IllegalStateException("RequestBody content type: $this not yet supported.") + } + } + .orEmpty(), + body?.extensions.orEmpty() + ) + + private fun Response.isEmpty(): Boolean = + headers.isEmpty() && content.isEmpty() && links.isEmpty() && extensions.isEmpty() + + // TODO interceptor + fun toResponses(operation: Operation, create: (NamingContext) -> NamingContext): Route.Returns = + Route.Returns( + operation.responses.responses.entries.associate { (code, refOrResponse) -> + val statusCode = StatusCode.orThrow(code) + val response = refOrResponse.get() + when { + response.content.contains("application/octet-stream") -> + Pair( + statusCode, + Route.ReturnType( + Resolved.Value(Model.OctetStream(response.description)), + response.extensions + ) + ) + response.content.contains("application/json") -> { + val mediaType = response.content.getValue("application/json") + val route = + when (val resolved = mediaType.schema?.resolve()) { + is Resolved -> { + val context = + resolved + .namedOr { + val operationId = + requireNotNull(operation.operationId) { + "OperationId is required for request body inline schemas. Otherwise we cannot generate OperationIdRequest class name" + } + NamingContext.RouteBody(operationId, "Response") + } + .let(create) + Route.ReturnType(resolved.toModel(context), response.extensions) + } + null -> + Route.ReturnType( + Resolved.Value( + if (Allowed(true).value) Model.FreeFormJson(response.description) + else + throw IllegalStateException( + "Illegal State: No additional properties allowed on empty object." + ) + ), + response.extensions + ) + } + Pair(statusCode, route) + } + response.isEmpty() -> + Pair( + statusCode, + Route.ReturnType( + Resolved.Value(Primitive.String(null, response.description)), + response.extensions + ) + ) + else -> + throw IllegalStateException("OpenAPI requires at least 1 valid response. $response") + } + }, + operation.responses.extensions + ) } + +internal const val schemaRef = "#/components/schemas/" diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/KotlinGenerator.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/KotlinGenerator.kt deleted file mode 100644 index ba04585..0000000 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/KotlinGenerator.kt +++ /dev/null @@ -1,351 +0,0 @@ -package io.github.nomisrev.openapi.generation - -import io.github.nomisrev.openapi.Model -import io.github.nomisrev.openapi.Model.Collection -import io.github.nomisrev.openapi.NamingContext - -public tailrec fun Templating.toPropertyCode( - model: Model, - naming: NamingStrategy = DefaultNamingStrategy -): Unit = - when (model) { - is Model.Binary -> Unit - is Model.FreeFormJson -> Unit - is Model.Primitive -> Unit - is Collection -> toPropertyCode(model.value, naming) - is Model.Enum -> toEnumCode(model, naming) - is Model.Object -> toObjectCode(model, naming) - is Model.Union -> { - val isOpenEnumeration = model.isOpenEnumeration() - if (isOpenEnumeration) toOpenEnumCode(model, naming) - else toUnionCode(model, naming) - } - } - -fun Templating.toOpenEnumCode(model: Model.Union, naming: NamingStrategy) { - openEnumImports() - val enum = model.schemas.firstNotNullOf { it.model as? Model.Enum } - val rawToName = enum.values.map { rawName -> Pair(rawName, naming.toEnumValueName(rawName)) } - val enumName = naming.toEnumClassName(model.context) - Serializable() - block("sealed interface $enumName {") { - line("val value: String") - line() - rawToName.joinTo { (rawName, valueName) -> - block("data object $valueName : $enumName {") { - line("override val value: String = \"$rawName\"") - line("override fun toString(): String = \"$rawName\"") - } - } - line() - line("@JvmInline") - line("value class Custom(override val value: String) : $enumName") - line() - block("companion object {") { - block("val defined: List<$enumName> =", closeAfter = false) { - block("listOf(", closeAfter = false) { - rawToName.indented(separator = ",\n") { (_, valueName) -> - append(valueName) - } - line(")") - } - } - } - line() - block("object Serializer : KSerializer<$enumName> {") { - line("override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(\"$enumName\", PrimitiveKind.STRING)") - line() - block("override fun serialize(encoder: Encoder, value: $enumName) {") { - line("encoder.encodeString(value.value)") - } - line() - block("override fun deserialize(decoder: Decoder): $enumName {") { - +"val value = decoder.decodeString()" - +"return attemptDeserialize(value," - rawToName.indented(separator = ",\n", postfix = ",\n") { (raw, name) -> - append("Pair($name::class) { defined.find { it.value == value } }") - } - line(" Pair(Custom::class) { Custom(value) }") - +")" - } - } - } -} - -public fun Templating.Serializable() { - addImport("kotlinx.serialization.Serializable") - +"@Serializable" -} - -public fun Templating.toEnumCode(enum: Model.Enum, naming: NamingStrategy) { - val rawToName = enum.values.map { rawName -> Pair(rawName, naming.toEnumValueName(rawName)) } - val isSimple = rawToName.all { (rawName, valueName) -> rawName == valueName } - val constructor = if (isSimple) "" else "(val value: ${naming.typeName(enum.inner)})" - if (!isSimple) addImport("kotlinx.serialization.SerialName") - val enumName = naming.toEnumClassName(enum.context) - Serializable() - block("enum class $enumName$constructor {") { - rawToName.indented(separator = ",\n", postfix = ";\n") { (rawName, valueName) -> - if (isSimple) append(rawName) - else append("@SerialName(\"${rawName}\") $valueName(\"${rawName}\")") - } - } -} - -// TODO fix indentation, and multiline (param) descriptions. -public fun Templating.description(obj: Model.Object) { - val descriptions = obj.properties.mapNotNull { p -> p.description?.let { Pair(p.name, it) } } - when { - obj.description.isNullOrBlank() && descriptions.isEmpty() -> Unit - obj.description.isNullOrBlank() && descriptions.isNotEmpty() -> { - +"/**" - if (!obj.description.isNullOrBlank()) { - +" * ${obj.description}" - +" *" - } - descriptions.indented(prefix = " *", separator = "\n *") { (name, description) -> - append("@param $name $description") - } - +" */" - } - } -} - -public fun Templating.addImports(obj: Model.Object): Unit = - obj.properties.forEach { p -> - addImports(p.type) - } - -public tailrec fun Templating.addImports(model: Model): Boolean = - when (model) { -// is Model.Binary -> addImport("io.FileUpload") - is Model.FreeFormJson -> - addImport("kotlinx.serialization.json.JsonElement") - - is Collection -> addImports(model.value) - else -> false - } - -public fun Templating.toObjectCode(obj: Model.Object, naming: NamingStrategy) { - fun properties() { - if (obj.properties.any { it.isRequired }) addImports("kotlinx.serialization.Required") - obj.properties.indented(separator = ",\n") { append(it.toPropertyCode(obj.context, naming)) } - } - - fun nested() = - indented { - obj.inline.indented(prefix = " {\n", postfix = "\n}") { - toPropertyCode(it, naming) - } - } - -// description(obj) - addImports(obj) - Serializable() - +"data class ${naming.toObjectClassName(obj.context)}(" - properties() - append(")") - nested() -} - -fun Model.default(naming: NamingStrategy): String? = when (this) { - Model.Primitive.Unit -> "Unit" - is Collection.List -> - default?.joinToString(prefix = "listOf(", postfix = ")") { - if (value is Model.Enum) { - "${naming.toEnumClassName(value.context)}.${naming.toEnumValueName(it)}" - } else it - } - - is Collection.Set -> - default?.joinToString(prefix = "setOf(", postfix = ")") { - if (value is Model.Enum) { - "${naming.toEnumClassName(value.context)}.${naming.toEnumValueName(it)}" - } else it - } - - is Model.Enum -> - (default ?: values.singleOrNull())?.let { - "${naming.toEnumClassName(context)}.${naming.toEnumValueName(it)}" - } - - is Model.Primitive -> default() - is Model.Union.AnyOf -> when { - default == null -> null - isOpenEnumeration() -> { - val case = schemas.firstNotNullOf { it.model as? Model.Enum } - val defaultEnum = case.values.find { it == default } - ?.let { naming.toEnumClassName(case.context) } - defaultEnum ?: "Custom(\"${default}\")" - } - - else -> schemas.find { it.model is Model.Primitive.String } - ?.let { case -> - "${naming.toUnionClassName(this)}.${naming.toUnionCaseName(case.model)}(\"${default}\")" - } - } - - is Model.Union.OneOf -> - schemas.find { it.model is Model.Primitive.String } - ?.takeIf { default != null } - ?.let { case -> "${naming.toUnionClassName(this)}.${naming.toUnionCaseName(case.model)}(\"${default}\")" } - - else -> null -} - -public fun Model.Object.Property.toPropertyCode(context: NamingContext, naming: NamingStrategy): String { - val nullable = - if (isNullable) "?" else "" - - // Add explicit default value, or add default ` = null` if nullable and not required. - val default = - type.default(naming)?.let { " = $it" } ?: if (isNullable && !isRequired) " = null" else "" - // This allows KotlinX `encodeDefaults = true` to safe on data - val required = if (isRequired && default != "") "@Required" else "" - val paramName = naming.toParamName(context, name) - val typeName = naming.typeName(type) - return "$required val $paramName: $typeName$nullable$default" -} - -public fun Templating.toUnionCode(union: Model.Union, naming: NamingStrategy) { - unionImports() - val unionClassName = naming.typeName(union) - +"@Serializable(with = $unionClassName.Serializer::class)" - block("sealed interface $unionClassName {") { - union.schemas.joinTo { (_, model) -> - +"@JvmInline" - +"value class ${naming.toUnionCaseName(model)}(val value: ${naming.typeName(model)}): $unionClassName" - } - - union.inline.indented { toPropertyCode(it, naming) } - - block("object Serializer : KSerializer<$unionClassName> {") { - +"@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)" - descriptor(unionClassName, union, naming) - line() - deserializer(unionClassName, union, naming) - line() - serializer(unionClassName, union, naming) - } - } -} - -private fun Templating.descriptor( - unionClassName: String, - union: Model.Union, - naming: NamingStrategy -) { - expression("override val descriptor: SerialDescriptor =") { - block("buildSerialDescriptor(\"$unionClassName\", PolymorphicKind.SEALED) {") { - union.schemas.indented { (ctx, model) -> - append( - "element(\"${naming.toUnionCaseName(model)}\", ${ - serializer( - model, - naming - ) - }.descriptor)" - ) - } - } - } -} - -private fun Templating.serializer( - unionClassName: String, - union: Model.Union, - naming: NamingStrategy -) { - block("override fun serialize(encoder: Encoder, value: $unionClassName) {") { - +"when(value) {" - union.schemas.indented { (ctx, model) -> - val caseName = naming.toUnionCaseName(model) - val serializer = serializer(model, naming) - append("is $caseName -> encoder.encodeSerializableValue($serializer, value.value)") - } - +"}" - } -} - -private fun Templating.deserializer( - unionClassName: String, - union: Model.Union, - naming: NamingStrategy -) { - block("override fun deserialize(decoder: Decoder): $unionClassName {") { - +"val json = decoder.decodeSerializableValue(JsonElement.serializer())" - +"return attemptDeserialize(json," - union.schemas.indented(separator = ",\n") { (ctx, model) -> - val caseName = naming.toUnionCaseName(model) - val serializer = serializer(model, naming) - append( - "Pair($caseName::class) { $caseName(Json.decodeFromJsonElement($serializer, json)) }" - ) - } - +")" - } -} - -private fun Templating.serializer(model: Model, naming: NamingStrategy): String = - when (model) { - is Collection.List -> { - addImport("kotlinx.serialization.builtins.ListSerializer") - "ListSerializer(${serializer(model.value, naming)})" - } - - is Collection.Map -> { - addImport("kotlinx.serialization.builtins.MapSerializer") - "MapSerializer(${serializer(model.key, naming)}, ${serializer(model.value, naming)})" - } - - is Collection.Set -> { - addImport("kotlinx.serialization.builtins.SetSerializer") - "SetSerializer(${serializer(model.value, naming)})" - } - - is Model.Primitive -> { - addImport("kotlinx.serialization.builtins.serializer") - "${naming.typeName(model)}.serializer()" - } - - is Model.Enum -> "${naming.typeName(model)}.serializer()" - is Model.Object -> "${naming.typeName(model)}.serializer()" - is Model.Union -> "${naming.typeName(model)}.serializer()" - is Model.Binary -> TODO("Cannot serializer File?") - is Model.FreeFormJson -> { - addImport("kotlinx.serialization.json.JsonElement") - "JsonElement.serializer()" - } - } - -public fun Templating.openEnumImports() { - addImports( - "kotlin.jvm.JvmInline", - "kotlinx.serialization.Serializable", - "kotlinx.serialization.KSerializer", - "kotlinx.serialization.descriptors.PrimitiveKind", - "kotlinx.serialization.descriptors.SerialDescriptor", - "kotlinx.serialization.descriptors.PrimitiveSerialDescriptor", - "kotlinx.serialization.encoding.Decoder", - "kotlinx.serialization.encoding.Encoder", - "kotlinx.serialization.json.Json", - "kotlinx.serialization.json.JsonElement", - ) -} - -public fun Templating.unionImports() { - addImports( - "kotlin.jvm.JvmInline", - "kotlinx.serialization.Serializable", - "kotlinx.serialization.KSerializer", - "kotlinx.serialization.InternalSerializationApi", - "kotlinx.serialization.ExperimentalSerializationApi", - "kotlinx.serialization.descriptors.PolymorphicKind", - "kotlinx.serialization.descriptors.SerialDescriptor", - "kotlinx.serialization.descriptors.buildSerialDescriptor", - "kotlinx.serialization.encoding.Decoder", - "kotlinx.serialization.encoding.Encoder", - "kotlinx.serialization.json.Json", - "kotlinx.serialization.json.JsonElement", - ) -} diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/KotlinTypeUtils.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/KotlinTypeUtils.kt deleted file mode 100644 index 5c6cb36..0000000 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/KotlinTypeUtils.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.github.nomisrev.openapi.generation -private val classNameRegex = Regex("^[a-zA-Z_$][a-zA-Z\\d_$]*$") - -internal fun String.isValidClassname(): Boolean = - classNameRegex.matches(this) - -internal fun String.sanitize(delimiter: String = ".", prefix: String = ""): String = - splitToSequence(delimiter) - .joinToString(delimiter, prefix) { if (it in KOTLIN_KEYWORDS) "`$it`" else it } - -// This list only contains words that need to be escaped. -private val KOTLIN_KEYWORDS = setOf( - "as", - "break", - "class", - "continue", - "do", - "else", - "false", - "for", - "fun", - "if", - "in", - "interface", - "is", - "null", - "object", - "package", - "return", - "super", - "this", - "throw", - "true", - "try", - "typealias", - "typeof", - "val", - "var", - "when", - "while", -) \ No newline at end of file diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/ModelPredef.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/ModelPredef.kt deleted file mode 100644 index 6442563..0000000 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/ModelPredef.kt +++ /dev/null @@ -1,71 +0,0 @@ -package io.github.nomisrev.openapi.generation - -// TODO include nicer message about expected format -internal val ModelPredef: String = """ -import kotlin.reflect.KClass -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.JsonElement - -class OneOfSerializationException( - val payload: JsonElement, - val errors: Map, SerializationException>, - override val message: String = - ${"\"\"\""} - Failed to deserialize Json: ${'$'}payload. - Errors: ${'$'}{ - errors.entries.joinToString(separator = "\n") { (type, error) -> - "${'$'}type - failed to deserialize: ${'$'}{error.stackTraceToString()}" - } -} - ${"\"\"\""}.trimIndent() -) : SerializationException(message) - -/** - * OpenAI makes a lot of use of oneOf types (sum types, or unions types), but it **never** relies on - * a discriminator field to differentiate between the types. - * - * Typically, what OpenAI does is attach a common field like `type` (a single value enum). I.e. - * `MessageObjectContentInner` has a type field with `image` or `text`. Depending on the `type` - * property, the other properties will be different. - * - * Due to the use of these fields, it **seems** there are no overlapping objects in the schema. So - * to deserialize these types, we can try to deserialize each type and return the first one that - * succeeds. In the case **all** fail, we throw [OneOfSerializationException] which includes all the - * attempted types with their errors. - * - * This method relies on 'peeking', which is not possible in KotlinX Serialization. So to achieve - * peeking, we first deserialize the raw Json to JsonElement, which safely consumes the buffer. And - * then we can attempt to deserialize the JsonElement to the desired type, without breaking the - * internal parser buffer. - */ -internal fun attemptDeserialize( - json: JsonElement, - vararg block: Pair, (json: JsonElement) -> A> -): A { - val errors = linkedMapOf, SerializationException>() - block.forEach { (kclass, f) -> - try { - return f(json) - } catch (e: SerializationException) { - errors[kclass] = e - } - } - throw OneOfSerializationException(json, errors) -} - -internal fun attemptDeserialize( - value: String, - vararg block: Pair, (value: String) -> A?> -): A { - val errors = linkedMapOf, SerializationException>() - block.forEach { (kclass, f) -> - try { - f(value)?.let { res -> return res } - } catch (e: SerializationException) { - errors[kclass] = e - } - } - // TODO Improve this error message - throw RuntimeException("BOOM! Improve this error message") -} -""".trimIndent() \ No newline at end of file diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/NamingStrategy.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/NamingStrategy.kt deleted file mode 100644 index 5462894..0000000 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/NamingStrategy.kt +++ /dev/null @@ -1,161 +0,0 @@ -package io.github.nomisrev.openapi.generation - -import io.github.nomisrev.openapi.Model -import io.github.nomisrev.openapi.Model.Collection -import io.github.nomisrev.openapi.NamingContext -import net.pearx.kasechange.splitter.WordSplitterConfig -import net.pearx.kasechange.splitter.WordSplitterConfigurable -import net.pearx.kasechange.toCamelCase -import net.pearx.kasechange.toPascalCase - -public interface NamingStrategy { - public fun typeName(model: Model): String - public fun toBinary(binary: Model.Binary): String - public fun toFreeFormJson(json: Model.FreeFormJson): String - public fun toCollection(collection: Collection): String - public fun toList(list: Collection.List): String - public fun toSet(set: Collection.Set): String - public fun toMap(map: Collection.Map): String - public fun toEnumClassName(context: NamingContext): String - public fun toEnumValueName(value: String): String - public fun toObjectClassName(context: NamingContext): String - public fun toParamName(objContext: NamingContext, paramName: String): String - public fun toUnionClassName(model: Model.Union): String - public fun toPrimitiveName(model: Model.Primitive): String - - /** - * Union types are a hard piece to generate (oneOf, anyOf). - * Depending on where the union occurs, we need a different name. - * 1. Top-level case schema, use user defined name - * 2. _Inline case schema_, if primitive we generate CasePrimitive - * => Int, Ints for List, IntsList for List>. - * => duplicate schemas can be filtered out. - */ - public fun toUnionCaseName(model: Model, depth: List = emptyList()): String -} - -public object DefaultNamingStrategy : NamingStrategy { - // OpenAI adds '/', so this WordSplitter takes that into account - private val wordSplitter = WordSplitterConfigurable( - WordSplitterConfig( - boundaries = setOf(' ', '-', '_', '.', '/'), - handleCase = true, - treatDigitsAsUppercase = true - ) - ) - - private fun String.toPascalCase(): String = - toPascalCase(wordSplitter) - - private fun String.toCamelCase(): String = - toCamelCase(wordSplitter) - - public override fun typeName(model: Model): String = - when (model) { - is Model.Binary -> toBinary(model) - is Model.FreeFormJson -> toFreeFormJson(model) - is Collection -> toCollection(model) - is Model.Enum -> toEnumClassName(model.context) - is Model.Object -> toObjectClassName(model.context) - is Model.Union -> toUnionClassName(model) - is Model.Primitive -> toPrimitiveName(model) - } - - override fun toBinary(binary: Model.Binary): String = "Unit" - - override fun toFreeFormJson(json: Model.FreeFormJson): String = "JsonElement" - - override fun toCollection(collection: Collection): String = - when (collection) { - is Collection.List -> toList(collection) - is Collection.Map -> toMap(collection) - is Collection.Set -> toSet(collection) - } - - override fun toList(list: Collection.List): String = - "List<${typeName(list.value)}>" - - override fun toSet(set: Collection.Set): String = - "Set<${typeName(set.value)}>" - - override fun toMap(map: Collection.Map): String = - "Map<${typeName(map.key)}, ${typeName(map.value)}>" - - override fun toEnumClassName(context: NamingContext): String = - when (context) { - is NamingContext.Inline -> context.name.toPascalCase() - is NamingContext.Ref -> context.outer.name.toPascalCase() - is NamingContext.TopLevelSchema -> context.name.toPascalCase() - is NamingContext.RouteParam -> { - requireNotNull(context.operationId) { "Need operationId to generate enum name" } - // $MyObject$Param$Request, this allows for multiple custom objects in a single operation - "${context.operationId.toPascalCase()}${context.name.toPascalCase()}${context.postfix.toPascalCase()}" - } - }.dropArraySyntax() - - override fun toEnumValueName(value: String): String { - val pascalCase = value.toPascalCase() - return if (pascalCase.isValidClassname()) pascalCase - else { - val sanitise = pascalCase - .run { if (startsWith("[")) drop(1) else this } - .run { if (startsWith("]")) dropLast(1) else this } - if (sanitise.isValidClassname()) sanitise - else "`$sanitise`" - } - } - - override fun toObjectClassName(context: NamingContext): String = - context.name.dropArraySyntax().toPascalCase() - - // Workaround for OpenAI - private fun String.dropArraySyntax(): String = - replace("[]", "") - - override fun toParamName(objContext: NamingContext, paramName: String): String = - paramName.sanitize().dropArraySyntax().toCamelCase() - - override fun toUnionClassName(model: Model.Union): String { - val context = model.context - return when { - model.isOpenEnumeration() -> toEnumClassName(context) - context is NamingContext.Inline -> "${context.outer.name.toPascalCase()}${context.name.toPascalCase()}" - else -> context.name.toPascalCase() - } - } - - override fun toPrimitiveName(model: Model.Primitive): String = - when (model) { - is Model.Primitive.Boolean -> "Boolean" - is Model.Primitive.Double -> "Double" - is Model.Primitive.Int -> "Int" - is Model.Primitive.String -> "String" - Model.Primitive.Unit -> "Unit" - } - - override fun toUnionCaseName(model: Model, depth: List): String = - when (model) { - is Collection.List -> toUnionCaseName(model.value, depth + listOf(model)) - is Collection.Map -> toUnionCaseName(model.value, depth + listOf(model)) - is Collection.Set -> toUnionCaseName(model.value, depth + listOf(model)) - else -> { - val head = depth.firstOrNull() - val s = when (head) { - is Collection.List -> "s" - is Collection.Set -> "s" - is Collection.Map -> "Map" - else -> "" - } - val postfix = depth.drop(1).joinToString(separator = "") { - when (it) { - is Collection.List -> "List" - is Collection.Map -> "Map" - is Collection.Set -> "Set" - else -> "" - } - } - - "Case${typeName(model)}${s}$postfix" - } - } -} \ No newline at end of file diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/Templating.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/Templating.kt deleted file mode 100644 index 41052a0..0000000 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/generation/Templating.kt +++ /dev/null @@ -1,133 +0,0 @@ -package io.github.nomisrev.openapi.generation - -/** - * Small DSL for two functionalities: - * - automatic indenting - * - being able to add/inject imports - */ -public fun template( - indent: String = " ", - init: Templating.() -> Unit -): Content = - Template(0, StringBuilder(), indent, mutableSetOf()) - .also(init) - .build() - -@DslMarker -public annotation class TemplatingDSL - -public data class Content(val imports: Set, val code: String) - -public interface Templating { - @TemplatingDSL - public fun append(line: String) - - @TemplatingDSL - public fun addImports(vararg imports: String): Boolean - - @TemplatingDSL - public fun indented(init: Templating.() -> Unit) - - @TemplatingDSL - public fun Collection.indented( - index: (Int) -> Int = Int::inc, - separator: String = "\n", - prefix: String = "", - postfix: String = "\n", - transform: (Templating.(T) -> Unit)? = null - ) - - @TemplatingDSL - public operator fun String.unaryPlus(): Unit = line(this) - - @TemplatingDSL - public fun addImport(import: String): Boolean = addImports(import) - - @TemplatingDSL - public fun line(line: String): Unit = append("$line\n") - - @TemplatingDSL - public fun line(): Unit = append("\n") - - @TemplatingDSL - public fun Collection.joinTo( - separator: String = "\n", - prefix: String = "", - postfix: String = "\n", - transform: (Templating.(T) -> Unit)? = null - ) { - indented(index = { it }, separator, prefix, postfix, transform) - } - - @TemplatingDSL - public fun expression( - text: String, - init: Templating.() -> Unit - ): Unit = block(text, false, init) - - @TemplatingDSL - public fun block( - text: String, - closeAfter: Boolean = true, - block: Templating.() -> Unit - ) { - line(text) - indented(block) - if (closeAfter) +"}" - } -} - -@TemplatingDSL -private class Template( - private val index: Int, - private val content: StringBuilder, - private val indentConfig: String, - private val imports: MutableSet -) : Templating { - private val indent: String = indentConfig.repeat(index) - - public override operator fun String.unaryPlus(): Unit = - line(this) - - @TemplatingDSL - public override fun addImport(import: String): Boolean = - imports.add(import) - - @TemplatingDSL - public override fun addImports(vararg imports: String): Boolean = - this.imports.addAll(imports) - - @TemplatingDSL - public override fun append(line: String) { - content.append("$indent$line") - } - - @TemplatingDSL - public override fun Collection.indented( - index: (Int) -> Int, - separator: String, - prefix: String, - postfix: String, - transform: (Templating.(T) -> Unit)? - ) { - val template = Template(index(this@Template.index), content, indentConfig, imports) - fun transform(element: T) = transform?.invoke(template, element) ?: element - if (isNotEmpty()) template.content.append(prefix) - firstOrNull()?.let { head -> - transform(head) - drop(1).forEach { - template.content.append(separator) - transform(it) - } - if (isNotEmpty()) template.content.append(postfix) - } - } - - @TemplatingDSL - public override fun indented(init: Templating.() -> Unit) { - Template(index + 1, content, indentConfig, imports).init() - } - - fun build(): Content = - Content(imports, content.toString()) -} diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/http/MediaType.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/http/MediaType.kt index cf92088..33cf022 100644 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/http/MediaType.kt +++ b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/http/MediaType.kt @@ -3,57 +3,51 @@ package io.github.nomisrev.openapi.http import kotlinx.serialization.Serializable @Serializable -public data class MediaType( - val mainType: String, - val subType: String, - val charset: String? = null -) { +data class MediaType(val mainType: String, val subType: String, val charset: String? = null) { // TODO kotlinx-io-core offers a MPP Charset implementation. // Only offers UTF_8 & ISO_8859_1 -// public fun charset(c: Charset): MediaType = charset(c.name()) + // public fun charset(c: Charset): MediaType = charset(c.name()) - public fun charset(c: String): MediaType = copy(charset = c) + fun charset(c: String): MediaType = copy(charset = c) - public fun noCharset(): MediaType = copy(charset = null) + fun noCharset(): MediaType = copy(charset = null) - public fun matches(mediaType: String): Boolean = - mediaType.startsWith(toString()) + fun matches(mediaType: String): Boolean = mediaType.startsWith(toString()) override fun toString(): String = "$mainType/$subType${charset?.let { c -> "; charset=$c" } ?: ""}" // https://www.iana.org/assignments/media-types/media-types.xhtml - public companion object { - public val ApplicationGzip: MediaType = MediaType("application", "gzip") - public val ApplicationZip: MediaType = MediaType("application", "zip") - public val ApplicationJson: MediaType = MediaType("application", "json") - public val ApplicationOctetStream: MediaType = MediaType("application", "octet-stream") - public val ApplicationPdf: MediaType = MediaType("application", "pdf") - public val ApplicationRtf: MediaType = MediaType("application", "rtf") - public val ApplicationXhtml: MediaType = MediaType("application", "xhtml+xml") - public val ApplicationXml: MediaType = MediaType("application", "xml") - public val ApplicationXWwwFormUrlencoded: MediaType = - MediaType("application", "x-www-form-urlencoded") - - public val ImageGif: MediaType = MediaType("image", "gif") - public val ImageJpeg: MediaType = MediaType("image", "jpeg") - public val ImagePng: MediaType = MediaType("image", "png") - public val ImageTiff: MediaType = MediaType("image", "tiff") - - public val MultipartFormData: MediaType = MediaType("multipart", "form-data") - public val MultipartMixed: MediaType = MediaType("multipart", "mixed") - public val MultipartAlternative: MediaType = MediaType("multipart", "alternative") - - public val TextCacheManifest: MediaType = MediaType("text", "cache-manifest") - public val TextCalendar: MediaType = MediaType("text", "calendar") - public val TextCss: MediaType = MediaType("text", "css") - public val TextCsv: MediaType = MediaType("text", "csv") - public val TextEventStream: MediaType = MediaType("text", "event-stream") - public val TextJavascript: MediaType = MediaType("text", "javascript") - public val TextHtml: MediaType = MediaType("text", "html") - public val TextPlain: MediaType = MediaType("text", "plain") - - public val TextPlainUtf8: MediaType = MediaType("text", "plain", "utf-8") + companion object { + val ApplicationGzip: MediaType = MediaType("application", "gzip") + val ApplicationZip: MediaType = MediaType("application", "zip") + val ApplicationJson: MediaType = MediaType("application", "json") + val ApplicationOctetStream: MediaType = MediaType("application", "octet-stream") + val ApplicationPdf: MediaType = MediaType("application", "pdf") + val ApplicationRtf: MediaType = MediaType("application", "rtf") + val ApplicationXhtml: MediaType = MediaType("application", "xhtml+xml") + val ApplicationXml: MediaType = MediaType("application", "xml") + val ApplicationXWwwFormUrlencoded: MediaType = MediaType("application", "x-www-form-urlencoded") + + val ImageGif: MediaType = MediaType("image", "gif") + val ImageJpeg: MediaType = MediaType("image", "jpeg") + val ImagePng: MediaType = MediaType("image", "png") + val ImageTiff: MediaType = MediaType("image", "tiff") + + val MultipartFormData: MediaType = MediaType("multipart", "form-data") + val MultipartMixed: MediaType = MediaType("multipart", "mixed") + val MultipartAlternative: MediaType = MediaType("multipart", "alternative") + + val TextCacheManifest: MediaType = MediaType("text", "cache-manifest") + val TextCalendar: MediaType = MediaType("text", "calendar") + val TextCss: MediaType = MediaType("text", "css") + val TextCsv: MediaType = MediaType("text", "csv") + val TextEventStream: MediaType = MediaType("text", "event-stream") + val TextJavascript: MediaType = MediaType("text", "javascript") + val TextHtml: MediaType = MediaType("text", "html") + val TextPlain: MediaType = MediaType("text", "plain") + + val TextPlainUtf8: MediaType = MediaType("text", "plain", "utf-8") } } diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/http/Method.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/http/Method.kt index 816f375..d604082 100644 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/http/Method.kt +++ b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/http/Method.kt @@ -3,37 +3,38 @@ package io.github.nomisrev.openapi.http import kotlin.jvm.JvmInline @JvmInline -public value class Method private constructor(public val value: String) { +value class Method private constructor(val value: String) { /** * An HTTP method is idempotent if an identical request can be made once or several times in a row * with the same effect while leaving the server in the same state. + * * @link https://developer.mozilla.org/en-US/docs/Glossary/Idempotent */ - public fun isIdempotent(m: Method): Boolean = idempotent.contains(m) + fun isIdempotent(m: Method): Boolean = idempotent.contains(m) /** * An HTTP method is safe if it doesn't alter the state of the server. + * * @link https://developer.mozilla.org/en-US/docs/Glossary/safe */ - public fun isSafe(m: Method): Boolean = safe.contains(m) - - public companion object { - public val GET: Method = Method("GET") - public val HEAD: Method = Method("HEAD") - public val POST: Method = Method("POST") - public val PUT: Method = Method("PUT") - public val DELETE: Method = Method("DELETE") - public val OPTIONS: Method = Method("OPTIONS") - public val PATCH: Method = Method("PATCH") - public val CONNECT: Method = Method("CONNECT") - public val TRACE: Method = Method("TRACE") - - public val entries: Set = - setOf(GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH, CONNECT, TRACE) + fun isSafe(m: Method): Boolean = safe.contains(m) + + companion object { + val GET: Method = Method("Get") + val HEAD: Method = Method("Head") + val POST: Method = Method("Post") + val PUT: Method = Method("Put") + val DELETE: Method = Method("Delete") + val OPTIONS: Method = Method("Options") + val PATCH: Method = Method("Patch") + val CONNECT: Method = Method("Connect") + val TRACE: Method = Method("Trace") + + val entries: Set = setOf(GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH, CONNECT, TRACE) /** Parse HTTP method by [method] string */ - public operator fun invoke(method: String): Method { + operator fun invoke(method: String): Method { return when (method) { GET.value -> GET POST.value -> POST @@ -54,4 +55,4 @@ public value class Method private constructor(public val value: String) { } override fun toString(): String = "Method($value)" -} \ No newline at end of file +} diff --git a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/http/StatusCode.kt b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/http/StatusCode.kt index 3d235e2..112fb9b 100644 --- a/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/http/StatusCode.kt +++ b/generation/src/commonMain/kotlin/io/github/nomisrev/openapi/http/StatusCode.kt @@ -3,87 +3,90 @@ package io.github.nomisrev.openapi.http import kotlin.jvm.JvmInline @JvmInline -public value class StatusCode(public val code: Int) { - public fun isInformational(): Boolean = code / 100 == 1 - public fun isSuccess(): Boolean = code / 100 == 2 - public fun isRedirect(): Boolean = code / 100 == 3 - public fun isClientError(): Boolean = code / 100 == 4 - public fun isServerError(): Boolean = code / 100 == 5 +value class StatusCode(val code: Int) { + fun isInformational(): Boolean = code / 100 == 1 - public companion object { + fun isSuccess(): Boolean = code / 100 == 2 + + fun isRedirect(): Boolean = code / 100 == 3 + + fun isClientError(): Boolean = code / 100 == 4 + + fun isServerError(): Boolean = code / 100 == 5 + + companion object { /** @throws IllegalArgumentException If the status code is out of range. */ - public fun orThrow(code: Int): StatusCode { + fun orThrow(code: Int): StatusCode { check(code in 100..599) { "Status code outside of the allowed range 100-599: $code" } return StatusCode(code) } - public fun orNull(code: Int): StatusCode? = - if (code < 100 || code > 599) null else StatusCode(code) + fun orNull(code: Int): StatusCode? = if (code < 100 || code > 599) null else StatusCode(code) // https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml - public val Continue: StatusCode = StatusCode(100) - public val SwitchingProtocols: StatusCode = StatusCode(101) - public val Processing: StatusCode = StatusCode(102) - public val EarlyHints: StatusCode = StatusCode(103) + val Continue: StatusCode = StatusCode(100) + val SwitchingProtocols: StatusCode = StatusCode(101) + val Processing: StatusCode = StatusCode(102) + val EarlyHints: StatusCode = StatusCode(103) - public val Ok: StatusCode = StatusCode(200) - public val Created: StatusCode = StatusCode(201) - public val Accepted: StatusCode = StatusCode(202) - public val NonAuthoritativeInformation: StatusCode = StatusCode(203) - public val NoContent: StatusCode = StatusCode(204) - public val ResetContent: StatusCode = StatusCode(205) - public val PartialContent: StatusCode = StatusCode(206) - public val MultiStatus: StatusCode = StatusCode(207) - public val AlreadyReported: StatusCode = StatusCode(208) - public val ImUsed: StatusCode = StatusCode(226) + val Ok: StatusCode = StatusCode(200) + val Created: StatusCode = StatusCode(201) + val Accepted: StatusCode = StatusCode(202) + val NonAuthoritativeInformation: StatusCode = StatusCode(203) + val NoContent: StatusCode = StatusCode(204) + val ResetContent: StatusCode = StatusCode(205) + val PartialContent: StatusCode = StatusCode(206) + val MultiStatus: StatusCode = StatusCode(207) + val AlreadyReported: StatusCode = StatusCode(208) + val ImUsed: StatusCode = StatusCode(226) - public val MultipleChoices: StatusCode = StatusCode(300) - public val MovedPermanently: StatusCode = StatusCode(301) - public val Found: StatusCode = StatusCode(302) - public val SeeOther: StatusCode = StatusCode(303) - public val NotModified: StatusCode = StatusCode(304) - public val UseProxy: StatusCode = StatusCode(305) - public val TemporaryRedirect: StatusCode = StatusCode(307) - public val PermanentRedirect: StatusCode = StatusCode(308) + val MultipleChoices: StatusCode = StatusCode(300) + val MovedPermanently: StatusCode = StatusCode(301) + val Found: StatusCode = StatusCode(302) + val SeeOther: StatusCode = StatusCode(303) + val NotModified: StatusCode = StatusCode(304) + val UseProxy: StatusCode = StatusCode(305) + val TemporaryRedirect: StatusCode = StatusCode(307) + val PermanentRedirect: StatusCode = StatusCode(308) - public val BadRequest: StatusCode = StatusCode(400) - public val Unauthorized: StatusCode = StatusCode(401) - public val PaymentRequired: StatusCode = StatusCode(402) - public val Forbidden: StatusCode = StatusCode(403) - public val NotFound: StatusCode = StatusCode(404) - public val MethodNotAllowed: StatusCode = StatusCode(405) - public val NotAcceptable: StatusCode = StatusCode(406) - public val ProxyAuthenticationRequired: StatusCode = StatusCode(407) - public val RequestTimeout: StatusCode = StatusCode(408) - public val Conflict: StatusCode = StatusCode(409) - public val Gone: StatusCode = StatusCode(410) - public val LengthRequired: StatusCode = StatusCode(411) - public val PreconditionFailed: StatusCode = StatusCode(412) - public val PayloadTooLarge: StatusCode = StatusCode(413) - public val UriTooLong: StatusCode = StatusCode(414) - public val UnsupportedMediaType: StatusCode = StatusCode(415) - public val RangeNotSatisfiable: StatusCode = StatusCode(416) - public val ExpectationFailed: StatusCode = StatusCode(417) - public val MisdirectedRequest: StatusCode = StatusCode(421) - public val UnprocessableEntity: StatusCode = StatusCode(422) - public val Locked: StatusCode = StatusCode(423) - public val FailedDependency: StatusCode = StatusCode(424) - public val UpgradeRequired: StatusCode = StatusCode(426) - public val PreconditionRequired: StatusCode = StatusCode(428) - public val TooManyRequests: StatusCode = StatusCode(429) - public val RequestHeaderFieldsTooLarge: StatusCode = StatusCode(431) - public val UnavailableForLegalReasons: StatusCode = StatusCode(451) + val BadRequest: StatusCode = StatusCode(400) + val Unauthorized: StatusCode = StatusCode(401) + val PaymentRequired: StatusCode = StatusCode(402) + val Forbidden: StatusCode = StatusCode(403) + val NotFound: StatusCode = StatusCode(404) + val MethodNotAllowed: StatusCode = StatusCode(405) + val NotAcceptable: StatusCode = StatusCode(406) + val ProxyAuthenticationRequired: StatusCode = StatusCode(407) + val RequestTimeout: StatusCode = StatusCode(408) + val Conflict: StatusCode = StatusCode(409) + val Gone: StatusCode = StatusCode(410) + val LengthRequired: StatusCode = StatusCode(411) + val PreconditionFailed: StatusCode = StatusCode(412) + val PayloadTooLarge: StatusCode = StatusCode(413) + val UriTooLong: StatusCode = StatusCode(414) + val UnsupportedMediaType: StatusCode = StatusCode(415) + val RangeNotSatisfiable: StatusCode = StatusCode(416) + val ExpectationFailed: StatusCode = StatusCode(417) + val MisdirectedRequest: StatusCode = StatusCode(421) + val UnprocessableEntity: StatusCode = StatusCode(422) + val Locked: StatusCode = StatusCode(423) + val FailedDependency: StatusCode = StatusCode(424) + val UpgradeRequired: StatusCode = StatusCode(426) + val PreconditionRequired: StatusCode = StatusCode(428) + val TooManyRequests: StatusCode = StatusCode(429) + val RequestHeaderFieldsTooLarge: StatusCode = StatusCode(431) + val UnavailableForLegalReasons: StatusCode = StatusCode(451) - public val InternalServerError: StatusCode = StatusCode(500) - public val NotImplemented: StatusCode = StatusCode(501) - public val BadGateway: StatusCode = StatusCode(502) - public val ServiceUnavailable: StatusCode = StatusCode(503) - public val GatewayTimeout: StatusCode = StatusCode(504) - public val HttpVersionNotSupported: StatusCode = StatusCode(505) - public val VariantAlsoNegotiates: StatusCode = StatusCode(506) - public val InsufficientStorage: StatusCode = StatusCode(507) - public val LoopDetected: StatusCode = StatusCode(508) - public val NotExtended: StatusCode = StatusCode(510) - public val NetworkAuthenticationRequired: StatusCode = StatusCode(511) + val InternalServerError: StatusCode = StatusCode(500) + val NotImplemented: StatusCode = StatusCode(501) + val BadGateway: StatusCode = StatusCode(502) + val ServiceUnavailable: StatusCode = StatusCode(503) + val GatewayTimeout: StatusCode = StatusCode(504) + val HttpVersionNotSupported: StatusCode = StatusCode(505) + val VariantAlsoNegotiates: StatusCode = StatusCode(506) + val InsufficientStorage: StatusCode = StatusCode(507) + val LoopDetected: StatusCode = StatusCode(508) + val NotExtended: StatusCode = StatusCode(510) + val NetworkAuthenticationRequired: StatusCode = StatusCode(511) } -} \ No newline at end of file +} diff --git a/generation/src/commonTest/kotlin/io/github/nomisrev/openapi/InlineSchemaTest.kt b/generation/src/commonTest/kotlin/io/github/nomisrev/openapi/InlineSchemaTest.kt index 5e8e6d8..1a87a5c 100644 --- a/generation/src/commonTest/kotlin/io/github/nomisrev/openapi/InlineSchemaTest.kt +++ b/generation/src/commonTest/kotlin/io/github/nomisrev/openapi/InlineSchemaTest.kt @@ -1,18 +1,19 @@ -//package io.github.nomisrev.openapi +// package io.github.nomisrev.openapi // -//import io.github.nomisrev.openapi.Parameter.Input.Query -//import io.github.nomisrev.openapi.ReferenceOr.Value -//import io.github.nomisrev.openapi.Schema.Type -//import io.github.nomisrev.openapi.test.KModel -//import io.github.nomisrev.openapi.test.KModel.Primitive.String -//import io.github.nomisrev.openapi.test.models -//import io.github.nomisrev.openapi.test.template -//import io.github.nomisrev.openapi.test.toCode -//import kotlin.test.Test -//import kotlin.test.assertTrue +// import io.github.nomisrev.openapi.Parameter.Input.Query +// import io.github.nomisrev.openapi.ReferenceOr.Value +// import io.github.nomisrev.openapi.Schema.Type +// import io.github.nomisrev.openapi.test.KModel +// import io.github.nomisrev.openapi.test.KModel.Primitive.String +// import io.github.nomisrev.openapi.test.models +// import io.github.nomisrev.openapi.test.template +// import io.github.nomisrev.openapi.test.toCode +// import kotlin.test.Test +// import kotlin.test.assertTrue // -//class InlineSchemaTest { -// val name = "name" to Value(Schema(nullable = false, type = Type.Basic.String, description = "test")) +// class InlineSchemaTest { +// val name = "name" to Value(Schema(nullable = false, type = Type.Basic.String, description = +// "test")) // val age = "age" to Value(Schema(type = Type.Basic.Integer, default = ExampleValue.Single("1"))) // val enum = "enum" to Value(Schema(type = Type.Basic.String, enum = listOf("deceased", "alive"))) // val oneOf = "oneOf" to Value( @@ -37,7 +38,8 @@ // ) // ) // ) -// val pet = "Pet" to Value(Schema(required = listOf("age"), properties = mapOf(name, age, enum, oneOf, anyOf))) +// val pet = "Pet" to Value(Schema(required = listOf("age"), properties = mapOf(name, age, enum, +// oneOf, anyOf))) // val pets = Value(Schema(type = Schema.Type.Basic.Array, items = pet.second)) // // val kenum = KModel.Enum("Enum", String, listOf("deceased", "alive")) @@ -75,7 +77,8 @@ // ), listOf(kenum, koneOf, kanyOf) // ) // val mediaType = -// mapOf(applicationJson to MediaType(Value(Schema(type = Schema.Type.Basic.Array, items = pet.second)))) +// mapOf(applicationJson to MediaType(Value(Schema(type = Schema.Type.Basic.Array, items = +// pet.second)))) // // @Test // fun toplevelSchema() { @@ -83,7 +86,8 @@ // info = Info(title = "Test Spec", version = "1.0"), // components = Components( // schemas = mapOf( -// "Pet" to Value(Schema(required = listOf("age"), properties = mapOf(name, age, enum, oneOf, anyOf))), +// "Pet" to Value(Schema(required = listOf("age"), properties = mapOf(name, age, enum, +// oneOf, anyOf))), // "TopEnum" to enum.second // ) // ) @@ -187,7 +191,8 @@ // operationId = "listPets", // parameters = listOf(Value(Parameter(name = "limit", input = Query))), // requestBody = Value( -// RequestBody(content = mapOf(applicationJson to MediaType(ReferenceOr.schema("Pets")))) +// RequestBody(content = mapOf(applicationJson to +// MediaType(ReferenceOr.schema("Pets")))) // ), // responses = Responses(200, Response()) // ) @@ -195,7 +200,8 @@ // ), // components = Components( // schemas = mapOf( -// "Pets" to Value(Schema(type = Schema.Type.Basic.Object, properties = mapOf("value" to pets))), +// "Pets" to Value(Schema(type = Schema.Type.Basic.Object, properties = mapOf("value" to +// pets))), // "TopEnum" to enum.second // ) // ) @@ -225,11 +231,12 @@ // info = Info(title = "Test Spec", version = "1.0"), // components = Components( // schemas = mapOf( -// "Pet" to Value(Schema(required = listOf("age"), properties = mapOf(name, age, enum, oneOf, anyOf))), +// "Pet" to Value(Schema(required = listOf("age"), properties = mapOf(name, age, enum, +// oneOf, anyOf))), // "TopEnum" to enum.second // ) // ) // ).models().first() // println(template { toCode(actual) }) // } -//} \ No newline at end of file +// } diff --git a/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/APIs.kt b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/APIs.kt new file mode 100644 index 0000000..ca1315a --- /dev/null +++ b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/APIs.kt @@ -0,0 +1,341 @@ +package io.github.nomisrev.openapi + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.LambdaTypeName +import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.asTypeName +import com.squareup.kotlinpoet.withIndent +import io.github.nomisrev.openapi.NamingContext.Named +import io.github.nomisrev.openapi.NamingContext.Nested + +private fun configure(defaults: Boolean) = + ParameterSpec.builder( + "configure", + LambdaTypeName.get( + receiver = ClassName("io.ktor.client.request", "HttpRequestBuilder"), + returnType = Unit::class.asTypeName() + ) + ) + .apply { if (defaults) defaultValue("{}") } + .build() + +fun Root.toFileSpecs(): List = endpoints() + root() + +private fun Root.endpoints(): List = + endpoints.map { api -> + val className = Nam.toClassName(Named(api.name)) + FileSpec.builder("io.github.nomisrev.openapi", className.simpleName) + .addType(api.toInterface()) + // We need to add a fun Chat(client: HttpClient) for private class ChatKtor(client: + // HttpClient) + .addFunction( + FunSpec.builder(className.simpleName) + .addParameter("client", ClassName("io.ktor.client", "HttpClient")) + .addStatement("return %T(client)", className) + .returns(className) + .build() + ) + .addType(api.toImplementation()) + .build() + } + +private fun Root.root() = + FileSpec.builder("io.github.nomisrev.openapi", "OpenAPI") + .addType( + TypeSpec.interfaceBuilder("OpenAPI") + .addProperties( + endpoints.map { api -> + PropertySpec.builder(Nam.toParamName(Named(api.name)), Nam.toClassName(Named(api.name))) + .build() + } + ) + .build() + ) + .addFunctions(operations.map { it.toFun(implemented = false) }) + .addFunction( + FunSpec.builder("OpenAPI") + .addParameter("client", ClassName("io.ktor.client", "HttpClient")) + .addStatement("return %T(client)", ClassName("io.github.nomisrev.openapi", "OpenAPIKtor")) + .returns(ClassName("io.github.nomisrev.openapi", "OpenAPI")) + .build() + ) + .addType( + TypeSpec.classBuilder(ClassName("io.github.nomisrev.openapi", "OpenAPIKtor")) + .addModifiers(KModifier.PRIVATE) + .addSuperinterface(ClassName("io.github.nomisrev.openapi", "OpenAPI")) + .apiConstructor() + .addProperties( + endpoints.map { api -> + val className = Nam.toClassName(Named(api.name)) + val name = Nam.toParamName(Named(api.name)) + PropertySpec.builder(name, className) + .addModifiers(KModifier.OVERRIDE) + .initializer("%T(client)", className) + .build() + } + ) + .build() + ) + .build() + +private fun TypeSpec.Builder.apiConstructor(): TypeSpec.Builder = + primaryConstructor( + FunSpec.constructorBuilder() + .addParameter(ParameterSpec("client", ClassName("io.ktor.client", "HttpClient"))) + .build() + ) + .addProperty( + PropertySpec.builder("client", ClassName("io.ktor.client", "HttpClient")) + .initializer("client") + .build() + ) + +private fun Route.nestedTypes(): List = inputs() + returns() + bodies() + +private fun Route.inputs(): List = + input.mapNotNull { (it.type as? Resolved.Value)?.value?.toTypeSpec() } + +private fun Route.returns(): List = + returnType.types.values.mapNotNull { (it.type as? Resolved.Value)?.value?.toTypeSpec() } + +private fun Route.bodies(): List = + body.types.values.flatMap { body -> + when (body) { + is Route.Body.Json.Defined -> + listOfNotNull((body.type as? Resolved.Value)?.value?.toTypeSpec()) + is Route.Body.Multipart.Value -> body.parameters.mapNotNull { it.type.value.toTypeSpec() } + is Route.Body.Multipart.Ref, + is Route.Body.Xml, + is Route.Body.Json.FreeForm, + is Route.Body.OctetStream -> emptyList() + } + } + +private fun API.toInterface(outerContext: NamingContext? = null): TypeSpec { + val outer = outerContext?.let { Nested(Named(name), it) } ?: Named(name) + return TypeSpec.interfaceBuilder(Nam.toClassName(outer)) + .addFunctions(routes.map { it.toFun(implemented = false) }) + .addTypes(routes.flatMap { it.nestedTypes() }) + .addTypes(nested.map { it.toInterface(outer) }) + .addProperties( + nested.map { + PropertySpec.builder( + Nam.toParamName(Named(it.name)), + Nam.toClassName(Nested(Named(it.name), outer)) + ) + .build() + } + ) + .build() +} + +private fun API.toImplementation(outerContext: NamingContext? = null): TypeSpec { + fun ClassName.postfix(postfix: String): ClassName = + ClassName(packageName, simpleNames.map { it + postfix }) + + fun ClassName.toContext(): NamingContext { + val names = simpleNames + return when (names.size) { + 1 -> Named(names[0]) + else -> + names.drop(1).fold(Named(names[0])) { acc, part -> + Nested(Named(part), acc) + } + } + } + + val outer = outerContext?.let { Nested(Named(name), it) } ?: Named(name) + val className = Nam.toClassName(outer).postfix("Ktor") + return TypeSpec.classBuilder(className) + .addModifiers(KModifier.PRIVATE) + .addSuperinterface(Nam.toClassName(outer)) + .apiConstructor() + .addFunctions(routes.map { it.toFun(implemented = true) }) + .addTypes(nested.map { it.toImplementation(outer) }) + .addProperties( + nested.map { + PropertySpec.builder( + Nam.toParamName(Named(it.name)), + Nam.toClassName(Nested(Named(it.name), outer)) + ) + .addModifiers(KModifier.OVERRIDE) + .initializer( + "%T(client)", + Nam.toClassName(Nested(Named(it.name + "Ktor"), className.toContext())) + ) + .build() + } + ) + .build() +} + +private fun Route.toFun(implemented: Boolean): FunSpec = + FunSpec.builder(Nam.toParamName(Named(operation.operationId!!))) + .apply { operation.summary?.let { addKdoc(it) } } + .addModifiers(KModifier.SUSPEND, if (implemented) KModifier.OVERRIDE else KModifier.ABSTRACT) + .addParameters(params(defaults = !implemented)) + .addParameters(requestBody(defaults = !implemented)) + .addParameter(configure(defaults = !implemented)) + .returns(returnType()) + .apply { + if (implemented) { + addCode( + CodeBlock.builder() + .addStatement( + "val response = client.%M {", + MemberName("io.ktor.client.request", "request", isExtension = true) + ) + .withIndent { + addStatement("configure()") + addStatement("method = %T.%L", ClassName("io.ktor.http", "HttpMethod"), method.value) + val replace = + input + .mapNotNull { + if (it.input == Parameter.Input.Path) + ".replace(\"{${it.name}}\", ${Nam.toParamName(Named(it.name))})" + else null + } + .joinToString(separator = "") + addStatement( + "url { %M(%S$replace) }", + MemberName("io.ktor.http", "path", isExtension = true), + path + ) + addBody(body) + } + .addStatement("}") + .addStatement( + "return response.%M()", + MemberName("io.ktor.client.call", "body", isExtension = true) + ) + .build() + ) + } + } + .build() + +// For now, we just try to find a JSON content type +fun CodeBlock.Builder.addBody(bodies: Route.Bodies): CodeBlock.Builder = + bodies.firstNotNullOfOrNull { (_, body) -> + when (body) { + is Route.Body.Json -> { + addStatement( + "%M(%T.%L)", + MemberName("io.ktor.http", "contentType"), + ClassName("io.ktor.http", "ContentType", "Application"), + "Json" + ) + addStatement( + "%M(%L)", + MemberName("io.ktor.client.request", "setBody", isExtension = true), + "body" + ) + } + is Route.Body.Xml -> TODO("Xml input body not supported yet.") + is Route.Body.OctetStream -> TODO("OctetStream input body not supported yet.") + is Route.Body.Multipart.Ref, + is Route.Body.Multipart.Value -> { + body as Route.Body.Multipart + addStatement( + "%M(%T.%L)", + MemberName("io.ktor.http", "contentType"), + ClassName("io.ktor.http", "ContentType", "MultiPart"), + "FormData" + ) + addStatement("%M(", MemberName("io.ktor.client.request", "setBody", isExtension = true)) + withIndent { + addStatement( + "%M {", + MemberName("io.ktor.client.request.forms", "formData", isExtension = true) + ) + withIndent { + when (body) { + is Route.Body.Multipart.Value -> + body.parameters.map { addStatement("appendAll(%S, %L)", it.name, it.name) } + is Route.Body.Multipart.Ref -> { + val obj = + requireNotNull(body.value.value as? Model.Object) { + "Only supports objects for FreeForm Multipart" + } + val name = Nam.toParamName(Named(body.value.name)) + obj.properties.forEach { prop -> + addStatement( + "appendAll(%S, $name.%L)", + Nam.toPropName(prop), + Nam.toPropName(prop) + ) + } + } + } + } + addStatement("}") + } + addStatement(")") + } + } + } + ?: this + +private fun Route.params(defaults: Boolean): List = + input.map { input -> + ParameterSpec.builder( + Nam.toParamName(Named(input.name)), + input.type.toTypeName().copy(nullable = !input.isRequired) + ) + .apply { + input.description?.let { addKdoc(it) } + if (defaults) { + defaultValue(input.type.value) + if (!input.isRequired && !input.type.value.hasDefault()) { + defaultValue("null") + } + } + } + .build() + } + +// TODO support binary, and Xml +private fun Route.requestBody(defaults: Boolean): List { + fun parameter( + name: String, + type: Resolved, + nullable: Boolean, + description: String? + ): ParameterSpec = + ParameterSpec.builder(name, type.toTypeName().copy(nullable = nullable)) + .apply { if (defaults && nullable) defaultValue("null") } + .build() + + return (body.jsonOrNull()?.let { json -> + listOf(parameter("body", json.type, !body.required, json.description)) + } + ?: body.multipartOrNull()?.let { multipart -> + multipart.parameters.map { parameter -> + parameter( + Nam.toParamName(Named(parameter.name)), + parameter.type, + !body.required, + parameter.type.value.description + ) + } + }) + .orEmpty() +} + +// TODO generate an ADT to properly support all return types +private fun Route.returnType(): TypeName { + val success = + returnType.types.toSortedMap { s1, s2 -> s1.code.compareTo(s2.code) }.entries.first() + return when (success.value.type.value) { + is Model.OctetStream -> HttpResponse + else -> success.value.type.toTypeName() + } +} diff --git a/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/KotlinPoetExt.kt b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/KotlinPoetExt.kt new file mode 100644 index 0000000..29e09b5 --- /dev/null +++ b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/KotlinPoetExt.kt @@ -0,0 +1,36 @@ +package io.github.nomisrev.openapi + +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec + +fun TypeName.nullable(): TypeName = copy(nullable = true) + +inline fun annotationSpec(): AnnotationSpec = + AnnotationSpec.builder(A::class).build() + +fun TypeSpec.Builder.description(kdoc: String?): TypeSpec.Builder = apply { + kdoc?.let { addKdoc("%L", it) } +} + +fun ParameterSpec.Builder.description(kdoc: String?): ParameterSpec.Builder = apply { + kdoc?.let { addKdoc("%L", it) } +} + +fun TypeSpec.Companion.dataClassBuilder( + className: ClassName, + parameters: List +): TypeSpec.Builder = + classBuilder(className) + .addModifiers(KModifier.DATA) + .primaryConstructor(FunSpec.constructorBuilder().addParameters(parameters).build()) + .addProperties( + parameters.map { param -> + PropertySpec.builder(param.name, param.type).initializer(param.name).build() + } + ) diff --git a/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/KotlinTypeUtils.kt b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/KotlinTypeUtils.kt new file mode 100644 index 0000000..1bb3508 --- /dev/null +++ b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/KotlinTypeUtils.kt @@ -0,0 +1,43 @@ +package io.github.nomisrev.openapi + +private val classNameRegex = Regex("^[a-zA-Z_$][a-zA-Z\\d_$]*$") + +internal fun String.isValidClassname(): Boolean = classNameRegex.matches(this) + +internal fun String.sanitize(delimiter: String = ".", prefix: String = ""): String = + splitToSequence(delimiter).joinToString(delimiter, prefix) { + if (it in KOTLIN_KEYWORDS) "`$it`" else it + } + +// This list only contains words that need to be escaped. +private val KOTLIN_KEYWORDS = + setOf( + "as", + "break", + "class", + "continue", + "do", + "else", + "false", + "for", + "fun", + "if", + "in", + "interface", + "is", + "null", + "object", + "package", + "return", + "super", + "this", + "throw", + "true", + "try", + "typealias", + "typeof", + "val", + "var", + "when", + "while", + ) diff --git a/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Main.kt b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Main.kt index c2c19d4..d0da030 100644 --- a/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Main.kt +++ b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Main.kt @@ -1,8 +1,13 @@ package io.github.nomisrev.openapi +import kotlin.io.path.Path import okio.FileSystem +import okio.Path.Companion.toPath -public fun main() { - FileSystem.SYSTEM - .generateClient("openai.json") +fun generate(path: String, output: String) { + val rawSpec = FileSystem.SYSTEM.read(path.toPath()) { readUtf8() } + val openAPI = OpenAPI.fromJson(rawSpec) + (openAPI.routes().toFileSpecs() + openAPI.models().toFileSpecs() + predef).forEach { + it.writeTo(Path(output)) + } } diff --git a/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Models.kt b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Models.kt new file mode 100644 index 0000000..d51c21f --- /dev/null +++ b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Models.kt @@ -0,0 +1,396 @@ +package io.github.nomisrev.openapi + +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.LIST +import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.asTypeName +import com.squareup.kotlinpoet.withIndent +import io.github.nomisrev.openapi.Model.Collection +import io.github.nomisrev.openapi.NamingContext.Named +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Required +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PolymorphicKind +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +const val `package` = "io.github.nomisrev.openapi" + +fun Iterable.toFileSpecs(): List = mapNotNull { it.toFileSpec() } + +fun Model.toFileSpec(): FileSpec? = + when (this) { + is Collection -> inner.value.toFileSpec() + is Model.Enum.Closed -> + FileSpec.builder(`package`, Nam.toClassName(context).simpleName).addType(toTypeSpec()).build() + is Model.Enum.Open -> + FileSpec.builder(`package`, Nam.toClassName(context).simpleName).addType(toTypeSpec()).build() + is Model.Object -> + FileSpec.builder(`package`, Nam.toClassName(context).simpleName).addType(toTypeSpec()).build() + is Model.Union -> + FileSpec.builder(`package`, Nam.toClassName(context).simpleName).addType(toTypeSpec()).build() + is Model.OctetStream, + is Model.Primitive, + is Model.FreeFormJson -> null + } + +tailrec fun Model.toTypeSpec(): TypeSpec? = + when (this) { + is Model.OctetStream, + is Model.FreeFormJson, + is Model.Primitive -> null + is Collection -> inner.value.toTypeSpec() + is Model.Enum.Closed -> toTypeSpec() + is Model.Enum.Open -> toTypeSpec() + is Model.Object -> toTypeSpec() + is Model.Union -> toTypeSpec() + } + +@OptIn(ExperimentalSerializationApi::class) +private fun Model.Union.toTypeSpec(): TypeSpec = + TypeSpec.interfaceBuilder(Nam.toClassName(context)) + .description(description) + .addModifiers(KModifier.SEALED) + .addAnnotation(annotationSpec()) + .addTypes( + cases.map { case -> + val model = case.model + TypeSpec.classBuilder(Nam.toCaseClassName(this@toTypeSpec, case.model.value).simpleName) + .description(model.value.description) + .addModifiers(KModifier.VALUE) + .addAnnotation(annotationSpec()) + .primaryConstructor( + FunSpec.constructorBuilder() + .addParameter(ParameterSpec.builder("value", model.toTypeName()).build()) + .build() + ) + .addProperty( + PropertySpec.builder("value", model.toTypeName()).initializer("value").build() + ) + .addSuperinterface(Nam.toClassName(context)) + .build() + } + ) + .addTypes(inline.mapNotNull { it.toTypeSpec() }) + .addType( + TypeSpec.objectBuilder("Serializer") + .addSuperinterface( + KSerializer::class.asTypeName().parameterizedBy(Nam.toClassName(context)) + ) + .addProperty( + PropertySpec.builder("descriptor", SerialDescriptor) + .addModifiers(KModifier.OVERRIDE) + .addAnnotation( + AnnotationSpec.builder(ClassName("kotlinx.serialization", "InternalSerializationApi")) + .build() + ) + .initializer( + CodeBlock.builder() + .add( + "%M(%S, %T.SEALED) {\n", + MemberName("kotlinx.serialization.descriptors", "buildSerialDescriptor"), + Nam.toClassName(context).simpleNames.joinToString("."), + PolymorphicKind::class + ) + .withIndent { + cases.forEach { case -> + val (placeholder, values) = case.model.serializer() + add( + "element(%S, $placeholder.descriptor)\n", + Nam.toCaseClassName(this@toTypeSpec, case.model.value) + .simpleNames + .joinToString("."), + *values + ) + } + } + .add("}\n") + .build() + ) + .build() + ) + .addFunction( + FunSpec.builder("serialize") + .addModifiers(KModifier.OVERRIDE) + .addParameter("encoder", ClassName("kotlinx.serialization.encoding", "Encoder")) + .addParameter("value", Nam.toClassName(context)) + .addCode( + CodeBlock.builder() + .add("when(value) {\n") + .apply { + cases.forEach { case -> + val (placeholder, values) = case.model.serializer() + addStatement( + "is %T -> encoder.encodeSerializableValue($placeholder, value.value)", + Nam.toCaseClassName(this@toTypeSpec, case.model.value), + *values + ) + } + } + .add("}\n") + .build() + ) + .build() + ) + .addFunction( + FunSpec.builder("deserialize") + .addModifiers(KModifier.OVERRIDE) + .addParameter("decoder", Decoder::class.asTypeName()) + .returns(Nam.toClassName(context)) + .addCode( + CodeBlock.builder() + .add( + "val json = decoder.decodeSerializableValue(%T.serializer())\n", + JsonElement::class.asTypeName() + ) + .add("return attemptDeserialize(json,\n") + .apply { + cases.forEach { case -> + val (placeholder, values) = case.model.serializer() + add( + "Pair(%T::class) { %T(%T.decodeFromJsonElement($placeholder, json)) },\n", + Nam.toCaseClassName(this@toTypeSpec, case.model.value), + Nam.toCaseClassName(this@toTypeSpec, case.model.value), + Json::class.asTypeName(), + *values + ) + } + } + .add(")\n") + .build() + ) + .build() + ) + .build() + ) + .build() + +private fun List.sorted(): List { + val (required, optional) = partition { it.defaultValue == null } + return required + optional +} + +/* + * Generating data classes with KotlinPoet!? + * https://stackoverflow.com/questions/44483831/generate-data-class-with-kotlinpoet + */ +private fun Model.Object.toTypeSpec(): TypeSpec = + TypeSpec.dataClassBuilder( + Nam.toClassName(context), + properties + .map { prop -> + ParameterSpec.builder( + Nam.toPropName(prop), + prop.model.toTypeName().copy(nullable = prop.isNullable) + ) + .description(prop.description) + .defaultValue(prop.model.value) + .apply { + val hasDefault = prop.model.value.hasDefault() + if (prop.isRequired && hasDefault) addAnnotation(annotationSpec()) + else if (!prop.isRequired && !hasDefault && prop.isNullable) defaultValue("null") + } + .build() + } + .sorted() + ) + .apply { + // Cannot serialize binary, these are used for multipart requests. + // This occurs when request bodies are defined using top-level schemas. + if (properties.none { it.model.value is Model.OctetStream }) + addAnnotation(annotationSpec()) + } + .addTypes(inline.mapNotNull { it.toTypeSpec() }) + .build() + +private fun Model.Enum.Closed.toTypeSpec(): TypeSpec { + val rawToName = values.map { rawName -> Pair(rawName, Nam.toEnumValueName(rawName)) } + val isSimple = rawToName.all { (rawName, valueName) -> rawName == valueName } + val enumName = Nam.toClassName(context) + return TypeSpec.enumBuilder(enumName) + .description(description) + .apply { + if (!isSimple) + primaryConstructor(FunSpec.constructorBuilder().addParameter("value", STRING).build()) + rawToName.forEach { (rawName, valueName) -> + if (isSimple) addEnumConstant(rawName) + else + addEnumConstant( + valueName, + TypeSpec.anonymousClassBuilder() + .addAnnotation( + annotationSpec().toBuilder().addMember("\"$rawName\"").build() + ) + .addSuperclassConstructorParameter("\"$rawName\"") + .build() + ) + } + } + .addAnnotation(annotationSpec()) + .build() +} + +private fun Model.Enum.Open.toTypeSpec(): TypeSpec { + val rawToName = values.map { rawName -> Pair(rawName, Nam.toEnumValueName(rawName)) } + val enumName = Nam.toClassName(context) + return TypeSpec.interfaceBuilder(enumName) + .description(description) + .addModifiers(KModifier.SEALED) + .addProperty(PropertySpec.builder("value", STRING).addModifiers(KModifier.ABSTRACT).build()) + .addAnnotation(annotationSpec()) + .addType( + TypeSpec.classBuilder("OpenCase") + .addModifiers(KModifier.VALUE) + .addAnnotation(annotationSpec()) + .primaryConstructor(FunSpec.constructorBuilder().addParameter("value", STRING).build()) + .addProperty( + PropertySpec.builder("value", STRING) + .initializer("value") + .addModifiers(KModifier.OVERRIDE) + .build() + ) + .addSuperinterface(Nam.toClassName(context)) + .build() + ) + .addTypes( + rawToName.map { (rawName, valueName) -> + TypeSpec.objectBuilder(valueName) + .addModifiers(KModifier.DATA) + .addSuperinterface(Nam.toClassName(context)) + .addProperty( + PropertySpec.builder("value", STRING) + .initializer("\"$rawName\"") + .addModifiers(KModifier.OVERRIDE) + .build() + ) + .build() + } + ) + .addType( + TypeSpec.companionObjectBuilder() + .addProperty( + PropertySpec.builder("defined", LIST.parameterizedBy(Nam.toClassName(context))) + .initializer( + CodeBlock.builder() + .add("listOf(") + .apply { + rawToName.forEachIndexed { index, (_, valueName) -> + add(valueName) + if (index < rawToName.size - 1) add(", ") + } + add(")") + } + .build() + ) + .build() + ) + .addType( + TypeSpec.objectBuilder("Serializer") + .addSuperinterface( + KSerializer::class.asTypeName().parameterizedBy(Nam.toClassName(context)) + ) + .addProperty( + PropertySpec.builder("descriptor", SerialDescriptor) + .addModifiers(KModifier.OVERRIDE) + .addAnnotation( + AnnotationSpec.builder( + ClassName("kotlinx.serialization", "InternalSerializationApi") + ) + .build() + ) + .initializer( + CodeBlock.builder() + .addStatement( + "%M(%S, %T.STRING)", + PrimitiveSerialDescriptor, + enumName, + PrimitiveKind::class.asTypeName() + ) + .build() + ) + .build() + ) + .addFunction( + FunSpec.builder("serialize") + .addModifiers(KModifier.OVERRIDE) + .addParameter("encoder", Encoder::class.asTypeName()) + .addParameter("value", Nam.toClassName(context)) + .addCode("encoder.encodeString(value.value)") + .build() + ) + .addFunction( + FunSpec.builder("deserialize") + .addModifiers(KModifier.OVERRIDE) + .addParameter("decoder", Decoder::class.asTypeName()) + .returns(Nam.toClassName(context)) + .addCode( + CodeBlock.builder() + .addStatement("val value = decoder.decodeString()") + .addStatement("return attemptDeserialize(value,") + .withIndent { + rawToName.forEach { (_, name) -> + val nested = NamingContext.Nested(Named(name), context) + addStatement( + "Pair(%T::class) { defined.find { it.value == value } },", + Nam.toClassName(nested) + ) + } + addStatement("Pair(OpenCase::class) { OpenCase(value) }") + } + .addStatement(")") + .build() + ) + .build() + ) + .build() + ) + .build() + ) + .build() +} + +private fun Resolved.serializer(): Pair> = + with(value) { + val values: MutableList = mutableListOf() + fun Model.placeholder(): String = + when (this) { + is Collection.List -> { + values.add(ListSerializer) + "%M(${inner.value.placeholder()})" + } + is Collection.Map -> { + values.add(MapSerializer) + "%M(${key.placeholder()}, ${inner.value.placeholder()})" + } + is Collection.Set -> { + values.add(SetSerializer) + "%M(${inner.value.placeholder()})" + } + is Model.Primitive -> { + values.add(toTypeName()) + values.add(MemberName("kotlinx.serialization.builtins", "serializer", isExtension = true)) + "%T.%M()" + } + else -> { + values.add(toTypeName()) + "%T.serializer()" + } + } + + return Pair(placeholder(), values.toTypedArray()) + } diff --git a/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Naming.kt b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Naming.kt new file mode 100644 index 0000000..5717e1a --- /dev/null +++ b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Naming.kt @@ -0,0 +1,168 @@ +package io.github.nomisrev.openapi + +import com.squareup.kotlinpoet.BOOLEAN +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.DOUBLE +import com.squareup.kotlinpoet.INT +import com.squareup.kotlinpoet.LIST +import com.squareup.kotlinpoet.MAP +import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.SET +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.UNIT +import com.squareup.kotlinpoet.asTypeName +import io.github.nomisrev.openapi.Model.Collection +import kotlinx.serialization.json.JsonElement +import net.pearx.kasechange.splitter.WordSplitterConfig +import net.pearx.kasechange.splitter.WordSplitterConfigurable +import net.pearx.kasechange.toCamelCase +import net.pearx.kasechange.toPascalCase + +object Nam { + // OpenAI adds '/', so this WordSplitter takes that into account + private val wordSplitter = + WordSplitterConfigurable( + WordSplitterConfig( + boundaries = setOf(' ', '-', '_', '.', '/'), + handleCase = true, + treatDigitsAsUppercase = true + ) + ) + + private fun String.toPascalCase(): String = toPascalCase(wordSplitter) + + private fun String.toCamelCase(): String = toCamelCase(wordSplitter) + + private const val `package`: String = "io.github.nomisrev.openapi" + + fun toClassName(context: NamingContext): ClassName = + when (context) { + is NamingContext.Nested -> { + val outer = toClassName(context.outer) + val inner = toClassName(context.inner) + ClassName(`package`, outer.simpleNames + inner.simpleNames) + } + is NamingContext.Named -> ClassName(`package`, context.name.toPascalCase().dropArraySyntax()) + is NamingContext.RouteParam -> { + requireNotNull(context.operationId) { "Need operationId to generate enum name" } + ClassName( + `package`, + // $OuterClass$MyOperation$Param, this allows for multiple custom objects in a single + // operation + "${context.operationId.toPascalCase()}${context.name.toPascalCase()}".dropArraySyntax() + ) + } + is NamingContext.RouteBody -> + ClassName( + `package`, + "${context.name.toPascalCase()}${context.postfix.toPascalCase()}".dropArraySyntax() + ) + } + + fun toEnumValueName(rawToName: String): String { + val pascalCase = rawToName.toPascalCase() + return if (pascalCase.isValidClassname()) pascalCase + else { + val sanitise = + pascalCase + .run { if (startsWith("[")) drop(1) else this } + .run { if (endsWith("]")) dropLast(1) else this } + if (sanitise.isValidClassname()) sanitise else "`$sanitise`" + } + } + + private fun typeName(model: Model): String = + when (model) { + is Collection -> TODO("Cannot occur") + is Model.OctetStream -> "Binary" + is Model.FreeFormJson -> "JsonElement" + is Model.Enum -> toClassName(model.context).simpleName + is Model.Object -> toClassName(model.context).simpleName + is Model.Union -> toClassName(model.context).simpleName + is Model.Primitive.Boolean -> "Boolean" + is Model.Primitive.Double -> "Double" + is Model.Primitive.Int -> "Int" + is Model.Primitive.String -> "String" + is Model.Primitive.Unit -> "Unit" + } + + fun toPropName(param: Model.Object.Property): String = + param.baseName.sanitize().dropArraySyntax().toCamelCase() + + private fun toParamName(className: ClassName): String = + className.simpleName.replaceFirstChar { it.lowercase() } + + fun toParamName(named: NamingContext.Named): String = toParamName(toClassName(named)) + + // Workaround for OpenAI + private fun String.dropArraySyntax(): String = replace("[]", "") + + fun toCaseClassName( + union: Model.Union, + case: Model, + depth: List = emptyList() + ): ClassName = + when (case) { + is Collection.List -> toCaseClassName(union, case.inner.value, depth + listOf(case)) + is Collection.Map -> toCaseClassName(union, case.inner.value, depth + listOf(case)) + is Collection.Set -> toCaseClassName(union, case.inner.value, depth + listOf(case)) + else -> { + val head = depth.firstOrNull() + val s = + when (head) { + is Collection.List -> "s" + is Collection.Set -> "s" + is Collection.Map -> "Map" + else -> "" + } + val postfix = + depth.drop(1).joinToString(separator = "") { + when (it) { + is Collection.List -> "List" + is Collection.Map -> "Map" + is Collection.Set -> "Set" + else -> "" + } + } + + val unionCaseName = "Case${typeName(case)}${s}$postfix" + ClassName(`package`, toClassName(union.context).simpleNames + unionCaseName) + } + } +} + +val ContentType = ClassName("io.ktor.http", "ContentType") + +val HttpResponse = ClassName("io.ktor.client.statement", "HttpResponse") + +val PrimitiveSerialDescriptor = + MemberName("kotlinx.serialization.descriptors", "PrimitiveSerialDescriptor") + +val ListSerializer = MemberName("kotlinx.serialization.builtins", "ListSerializer") + +val SetSerializer = MemberName("kotlinx.serialization.builtins", "SetSerializer") + +val MapSerializer = MemberName("kotlinx.serialization.builtins", "MapSerializer") + +val SerialDescriptor = ClassName("kotlinx.serialization.descriptors", "SerialDescriptor") + +fun Resolved.toTypeName(): TypeName = value.toTypeName() + +fun Model.toTypeName(): TypeName = + when (this) { + is Model.Primitive.Boolean -> BOOLEAN + is Model.Primitive.Double -> DOUBLE + is Model.Primitive.Int -> INT + is Model.Primitive.String -> STRING + is Model.Primitive.Unit -> UNIT + is Collection.List -> LIST.parameterizedBy(inner.toTypeName()) + is Collection.Set -> SET.parameterizedBy(inner.toTypeName()) + is Collection.Map -> MAP.parameterizedBy(STRING, inner.toTypeName()) + is Model.OctetStream -> ClassName(`package`, "UploadFile") + is Model.FreeFormJson -> JsonElement::class.asTypeName() + is Model.Enum -> Nam.toClassName(context) + is Model.Object -> Nam.toClassName(context) + is Model.Union -> Nam.toClassName(context) + } diff --git a/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Predef.kt b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Predef.kt new file mode 100644 index 0000000..310d4bb --- /dev/null +++ b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/Predef.kt @@ -0,0 +1,237 @@ +package io.github.nomisrev.openapi + +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.LambdaTypeName +import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.TypeVariableName +import com.squareup.kotlinpoet.asTypeName +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonElement + +private val UploadTypeSpec: TypeSpec = + TypeSpec.dataClassBuilder( + ClassName(`package`, "UploadFile"), + listOf( + ParameterSpec.builder("filename", String::class).build(), + ParameterSpec.builder("contentType", ContentType.nullable()).defaultValue("null").build(), + ParameterSpec.builder("size", Long::class.asTypeName().nullable()) + .defaultValue("null") + .build(), + ParameterSpec.builder( + "bodyBuilder", + LambdaTypeName.get( + receiver = ClassName("io.ktor.utils.io.core", "BytePacketBuilder"), + returnType = Unit::class.asTypeName() + ) + ) + .build() + ) + ) + .build() + +private val errors: ParameterizedTypeName = + ClassName("kotlin.collections", "Map") + .parameterizedBy( + ClassName("kotlin.reflect", "KClass").parameterizedBy(TypeVariableName("*")), + ClassName("kotlinx.serialization", "SerializationException") + ) + +private val appendAll: FunSpec = + FunSpec.builder("appendAll") + .addAnnotation( + AnnotationSpec.builder(ClassName("kotlin", "OptIn")) + .addMember("%L::class", "io.ktor.util.InternalAPI") + .build() + ) + .addTypeVariable(TypeVariableName("T", Any::class)) + .receiver(ClassName("io.ktor.client.request.forms", "FormBuilder")) + .addParameter("key", String::class) + .addParameter("value", TypeVariableName("T").nullable()) + .addParameter( + ParameterSpec.builder("headers", ClassName("io.ktor.http", "Headers")) + .defaultValue("%T.Empty", ClassName("io.ktor.http", "Headers")) + .build() + ) + .addCode( + """ + when (value) { + is String -> append(key, value, headers) + is Number -> append(key, value, headers) + is Boolean -> append(key, value, headers) + is ByteArray -> append(key, value, headers) + is %T -> append(key, value, headers) + is %T -> append(key, value, headers) + is %T -> append(key, value, headers) + is UploadFile -> appendUploadedFile(key, value) + is Enum<*> -> append(key, serialNameOrEnumValue(value), headers) + null -> Unit + else -> append(key, value, headers) + } + """ + .trimIndent(), + ClassName("io.ktor.utils.io.core", "ByteReadPacket"), + ClassName("io.ktor.client.request.forms", "InputProvider"), + ClassName("io.ktor.client.request.forms", "ChannelProvider") + ) + .build() + +private val appendUploadedFile: FunSpec = + FunSpec.builder("appendUploadedFile") + .receiver(ClassName("io.ktor.client.request.forms", "FormBuilder")) + .addModifiers(KModifier.PRIVATE) + .addParameter("key", String::class) + .addParameter("file", ClassName(`package`, "UploadFile")) + .addCode( + """ + %M( + key = key, + filename = file.filename, + contentType = file.contentType ?: %T.Application.OctetStream, + size = file.size, + bodyBuilder = file.bodyBuilder + ) + """ + .trimIndent(), + MemberName("io.ktor.client.request.forms", "append", isExtension = true), + ContentType + ) + .build() + +private val serialNameOrEnumValue: FunSpec = + FunSpec.builder("serialNameOrEnumValue") + .addModifiers(KModifier.PRIVATE) + .addAnnotation( + AnnotationSpec.builder(ClassName("kotlin", "OptIn")) + .addMember("%L::class", "kotlinx.serialization.ExperimentalSerializationApi") + .addMember("%L::class", "kotlinx.serialization.InternalSerializationApi") + .build() + ) + .addTypeVariable( + TypeVariableName("T", ClassName("kotlin", "Enum").parameterizedBy(TypeVariableName("T"))) + ) + .returns(String::class) + .addParameter("enum", ClassName("kotlin", "Enum").parameterizedBy(TypeVariableName("T"))) + .addCode( + "return enum::class.%M()?.descriptor?.getElementName(enum.ordinal) ?: enum.toString()", + MemberName("kotlinx.serialization", "serializerOrNull", isExtension = true) + ) + .build() + +val predef: FileSpec = + FileSpec.builder("io.github.nomisrev.openapi", "Predef") + .addType( + TypeSpec.classBuilder("OneOfSerializationException") + .primaryConstructor( + FunSpec.constructorBuilder() + .addParameter("payload", JsonElement::class) + .addParameter("errors", errors) + .build() + ) + .superclass(SerializationException::class) + .addProperty( + PropertySpec.builder("payload", JsonElement::class).initializer("payload").build() + ) + .addProperty(PropertySpec.builder("errors", errors).initializer("errors").build()) + .addProperty( + PropertySpec.builder("message", String::class, KModifier.OVERRIDE) + .initializer( + """ + ${'"'}${'"'}${'"'} + Failed to deserialize Json: ${'$'}payload. + Errors: ${'$'}{ + errors.entries.joinToString(separator = "\n") { (type, error) -> + "${'$'}type - failed to deserialize: ${'$'}{error.stackTraceToString()}" + } + } + ${'"'}${'"'}${'"'}.trimIndent() + """ + .trimIndent() + ) + .build() + ) + .build() + ) + .addFunction( + FunSpec.builder("attemptDeserialize") + .addTypeVariable(TypeVariableName("A")) + .returns(TypeVariableName("A")) + .addParameter("json", JsonElement::class) + .addParameter( + "block", + ClassName("kotlin", "Pair") + .parameterizedBy( + ClassName("kotlin.reflect", "KClass").parameterizedBy(TypeVariableName("*")), + LambdaTypeName.get( + receiver = null, + returnType = TypeVariableName("A"), + parameters = + listOf( + ParameterSpec.unnamed(ClassName("kotlinx.serialization.json", "JsonElement")) + ) + ) + ), + KModifier.VARARG + ) + .addCode( + """ + val errors = linkedMapOf, SerializationException>() + block.forEach { (kclass, f) -> + try { + return f(json) + } catch (e: SerializationException) { + errors[kclass] = e + } + } + throw OneOfSerializationException(json, errors) + """ + .trimIndent() + ) + .build() + ) + .addFunction( + FunSpec.builder("attemptDeserialize") + .addTypeVariable(TypeVariableName("A")) + .returns(TypeVariableName("A")) + .addParameter("value", String::class) + .addParameter( + "block", + ClassName("kotlin", "Pair") + .parameterizedBy( + ClassName("kotlin.reflect", "KClass").parameterizedBy(TypeVariableName("*")), + LambdaTypeName.get( + receiver = null, + returnType = TypeVariableName("A").nullable(), + parameters = listOf(ParameterSpec.unnamed(String::class)) + ) + ), + KModifier.VARARG + ) + .addCode( + """ + val errors = linkedMapOf, SerializationException>() + block.forEach { (kclass, f) -> + try { + f(value)?.let { res -> return res } + } catch (e: SerializationException) { + errors[kclass] = e + } + } + // TODO Improve this error message + throw RuntimeException("BOOM! Improve this error message") + """ + .trimIndent() + ) + .build() + ) + .addType(UploadTypeSpec) + .addFunctions(listOf(appendAll, appendUploadedFile, serialNameOrEnumValue)) + .build() diff --git a/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/default.kt b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/default.kt new file mode 100644 index 0000000..3ae7292 --- /dev/null +++ b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/default.kt @@ -0,0 +1,55 @@ +package io.github.nomisrev.openapi + +import com.squareup.kotlinpoet.ParameterSpec +import io.github.nomisrev.openapi.Model.Collection + +fun Model.hasDefault(): Boolean = defaultValueImpl(this) != null + +fun ParameterSpec.Builder.defaultValue(model: Model): ParameterSpec.Builder = + defaultValueImpl(model)?.let { (code, args) -> defaultValue(code, *args.toTypedArray()) } ?: this + +private fun defaultValueImpl(model: Model): Pair>? = + when (model) { + is Model.OctetStream -> null + is Collection.Map -> null + is Model.FreeFormJson -> null + is Collection.List -> default(model, "listOf", model.default) + is Collection.Set -> default(model, "setOf", model.default) + is Model.Union -> + model.cases + .find { it.model.value is Model.Primitive.String } + ?.let { case -> + model.default?.let { + val typeName = Nam.toCaseClassName(model, case.model.value) + Pair("%T(%S)", listOf(typeName, model.default)) + } + } + is Model.Object -> + if (model.properties.all { it.model.value.hasDefault() }) + Pair("%T()", listOf(Nam.toClassName(model.context))) + else null + is Model.Enum -> + (model.default ?: model.values.singleOrNull())?.let { + Pair("%T.%L", listOf(Nam.toClassName(model.context), Nam.toEnumValueName(it))) + } + is Model.Primitive.Unit -> Pair("Unit", emptyList()) + is Model.Primitive -> model.default()?.let { Pair(it, emptyList()) } + } + +private fun default( + model: Collection, + builder: String, + default: List? +): Pair>? = + when { + default == null -> null + default.isEmpty() -> Pair("emptyList()", emptyList()) + model.inner.value is Model.Enum -> { + val enum = model.inner.value as Model.Enum + val enumClassName = Nam.toClassName(enum.context) + val content = default.joinToString { "%T.%L" } + val args = default.flatMap { listOf(enumClassName, Nam.toEnumValueName(it)) } + Pair("$builder($content)", args) + } + else -> Pair("$builder(${default.joinToString()})", emptyList()) + } diff --git a/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/test.kt b/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/test.kt deleted file mode 100644 index 3d63f2e..0000000 --- a/generation/src/jvmMain/kotlin/io/github/nomisrev/openapi/test.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.github.nomisrev.openapi - - -sealed interface CreateChatCompletionRequestModel { - val value: String - - data class Custom(override val value: String) : CreateChatCompletionRequestModel - enum class Enum(override val value: String) : CreateChatCompletionRequestModel { - Gpt_4_0125_Preview("gpt-4-0125-preview"), - Gpt_4_Turbo_Preview("gpt-4-turbo-preview"), - Gpt_4_Turbo_1106_Preview("gpt-4-1106-preview"); - } -} \ No newline at end of file diff --git a/generation/src/macosArm64Main/kotlin/Main.kt b/generation/src/macosArm64Main/kotlin/Main.kt deleted file mode 100644 index 72e9a09..0000000 --- a/generation/src/macosArm64Main/kotlin/Main.kt +++ /dev/null @@ -1,7 +0,0 @@ -import io.github.nomisrev.openapi.generateClient -import okio.FileSystem - -public fun main() { - FileSystem.SYSTEM - .generateClient("openai.json") -} \ No newline at end of file diff --git a/generic/src/commonMain/kotlin/io/github/nomisrev/generic/Generic.kt b/generic/src/commonMain/kotlin/io/github/nomisrev/generic/Generic.kt index bda2eb2..4abf703 100644 --- a/generic/src/commonMain/kotlin/io/github/nomisrev/generic/Generic.kt +++ b/generic/src/commonMain/kotlin/io/github/nomisrev/generic/Generic.kt @@ -1,5 +1,6 @@ package io.github.nomisrev.generic +import kotlin.jvm.JvmInline import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.KSerializer @@ -19,7 +20,6 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.modules.EmptySerializersModule import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.serializer -import kotlin.jvm.JvmInline public sealed interface Generic { @@ -27,60 +27,41 @@ public sealed interface Generic { override fun toString(): kotlin.String = "Generic.Null" } - @JvmInline - public value class String(public val value: kotlin.String) : Generic + @JvmInline public value class String(public val value: kotlin.String) : Generic - @JvmInline - public value class Char(public val value: kotlin.Char) : Generic + @JvmInline public value class Char(public val value: kotlin.Char) : Generic public sealed interface Number : Generic { public val value: A - @JvmInline - public value class Byte(override val value: kotlin.Byte) : Number + @JvmInline public value class Byte(override val value: kotlin.Byte) : Number - @JvmInline - public value class Short(override val value: kotlin.Short) : Number + @JvmInline public value class Short(override val value: kotlin.Short) : Number - @JvmInline - public value class Int(override val value: kotlin.Int) : Number + @JvmInline public value class Int(override val value: kotlin.Int) : Number - @JvmInline - public value class Long(override val value: kotlin.Long) : Number + @JvmInline public value class Long(override val value: kotlin.Long) : Number - @JvmInline - public value class Float(override val value: kotlin.Float) : Number + @JvmInline public value class Float(override val value: kotlin.Float) : Number - @JvmInline - public value class Double(override val value: kotlin.Double) : Number + @JvmInline public value class Double(override val value: kotlin.Double) : Number } - public data class Value( - override val info: Info, - val element: Generic - ) : Object + public data class Value(override val info: Info, val element: Generic) : Object - @JvmInline - public value class Boolean(public val value: kotlin.Boolean) : Generic + @JvmInline public value class Boolean(public val value: kotlin.Boolean) : Generic public sealed interface Object : Generic { public val info: Info } /** - * Represents a product type. - * A product type has [Info] & a fixed set of [fields] + * Represents a product type. A product type has [Info] & a fixed set of [fields] * * public data class Person(val name: String, val age: Int) * - * Person => - * Schema2.Product( - * ObjectInfo("Person"), - * listOf( - * Pair(FieldName("name"), Schema.string), - * Pair(FieldName("age"), Schema.int) - * ) - * ) + * Person => Schema2.Product( ObjectInfo("Person"), listOf( Pair(FieldName("name"), + * Schema.string), Pair(FieldName("age"), Schema.int) ) ) */ public data class Product( override val info: Info, @@ -88,24 +69,20 @@ public sealed interface Generic { ) : Object { public companion object { public val Empty: Product = Product(Info.unit, emptyList()) + public operator fun invoke(info: Info, vararg fields: Pair): Product = Product(info, fields.toList()) } } /** - * Represents a sum or coproduct type. - * Has [Info], and NonEmptyList of subtypes schemas. - * These subtype schemas contain all details about the subtypes, since they'll all have Schema2 is Schema2.Object. + * Represents a sum or coproduct type. Has [Info], and NonEmptyList of subtypes schemas. These + * subtype schemas contain all details about the subtypes, since they'll all have Schema2 is + * Schema2.Object. * - * Either => - * Schema2.Coproduct( - * Schema2.ObjectInfo("Either", listOf("A", "B")), - * listOf( - * Schema2.Product("Either.Left", listOf("value", schemeA)), - * Schema2.Product("Either.Right", listOf("value", schemeA)), - * ) - * ) + * Either => Schema2.Coproduct( Schema2.ObjectInfo("Either", listOf("A", "B")), listOf( + * Schema2.Product("Either.Left", listOf("value", schemeA)), Schema2.Product("Either.Right", + * listOf("value", schemeA)), ) ) */ public data class Coproduct( override val info: Info, @@ -115,32 +92,20 @@ public sealed interface Generic { ) : Object /** - * Represents a value in an enum class - * A product of [kotlin.Enum.name] and [kotlin.Enum.ordinal] + * Represents a value in an enum class A product of [kotlin.Enum.name] and [kotlin.Enum.ordinal] */ public data class EnumValue(val name: kotlin.String, val ordinal: Int) /** - * Represents an Enum - * Has [Info], and list of its values. + * Represents an Enum Has [Info], and list of its values. * * enum class Test { A, B, C; } * - * Test => - * Schema2.Enum( - * Schema2.ObjectInfo("Test"), - * listOf( - * Schema2.EnumValue("A", 0), - * Schema2.EnumValue("B", 1), - * Schema2.EnumValue("C", 2) - * ) - * ) + * Test => Schema2.Enum( Schema2.ObjectInfo("Test"), listOf( Schema2.EnumValue("A", 0), + * Schema2.EnumValue("B", 1), Schema2.EnumValue("C", 2) ) ) */ - public data class Enum( - override val info: Info, - val values: List, - val index: Int - ) : Object + public data class Enum(override val info: Info, val values: List, val index: Int) : + Object /** * ObjectInfo contains the fullName of an object, and the type-param names. @@ -189,11 +154,7 @@ public sealed interface Generic { name: kotlin.String, enumValues: Array, index: Int - ): Generic = Enum( - Info(name), - enumValues.map { EnumValue(it.name, it.ordinal) }, - index - ) + ): Generic = Enum(Info(name), enumValues.map { EnumValue(it.name, it.ordinal) }, index) public inline fun > enum(value: kotlin.Enum): Generic = enum( @@ -205,7 +166,8 @@ public sealed interface Generic { } @ExperimentalSerializationApi -private class GenericEncoder(override val serializersModule: SerializersModule) : AbstractEncoder() { +private class GenericEncoder(override val serializersModule: SerializersModule) : + AbstractEncoder() { private val genericProperties: MutableMap = mutableMapOf() @@ -232,22 +194,17 @@ private class GenericEncoder(override val serializersModule: SerializersModule) } /** - * Our own custom encodeValue method - * This is called from all encodeX methods, which exists for primitives and enums + * Our own custom encodeValue method This is called from all encodeX methods, which exists for + * primitives and enums */ private fun encodeValue(generic: Generic): Unit = when (state) { State.Init -> { genericValue = generic } - State.EncodeInline -> { - genericValue = Generic.Value( - Generic.Info(descriptor!!.serialName), - generic - ) + genericValue = Generic.Value(Generic.Info(descriptor!!.serialName), generic) } - else -> { genericProperties[descriptor?.elementNames?.toList()?.get(index)!!] = generic } @@ -303,8 +260,7 @@ private class GenericEncoder(override val serializersModule: SerializersModule) ) } - private fun println(message: Any?): Unit = - if (debug) kotlin.io.println(message) else Unit + private fun println(message: Any?): Unit = if (debug) kotlin.io.println(message) else Unit override fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean { state = State.EncodeElement @@ -332,37 +288,39 @@ private class GenericEncoder(override val serializersModule: SerializersModule) State.Init -> { genericValue = encoder.result(serializer) } - State.EncodeInline -> { - genericValue = Generic.Value( - Generic.Info(descriptor!!.serialName), - encoder.result(serializer) - ) + genericValue = + Generic.Value(Generic.Info(descriptor!!.serialName), encoder.result(serializer)) } - else -> { state = State.EncodeSerializableValue - val propertyName: String = descriptor?.elementNames?.toList()?.getOrNull(index) ?: index.toString() + val propertyName: String = + descriptor?.elementNames?.toList()?.getOrNull(index) ?: index.toString() genericProperties[propertyName] = encoder.result(serializer) } } println("encodeSerializableValue: $serializer, $value") } - override fun encodeNullableSerializableValue(serializer: SerializationStrategy, value: T?) { + override fun encodeNullableSerializableValue( + serializer: SerializationStrategy, + value: T? + ) { this.serializer = serializer this.value = value val encoder = GenericEncoder(serializersModule) - val res = if (value != null) { - serializer.serialize(encoder, value) - encoder.result(serializer) - } else Generic.Null + val res = + if (value != null) { + serializer.serialize(encoder, value) + encoder.result(serializer) + } else Generic.Null if (state == State.Init || state == State.EncodeInline) { genericValue = res } else { state = State.EncodeSerializableValue - val propertyName: String = descriptor?.elementNames?.toList()?.getOrNull(index) ?: index.toString() + val propertyName: String = + descriptor?.elementNames?.toList()?.getOrNull(index) ?: index.toString() genericProperties[propertyName] = res } println("encodeNullableSerializableValue: $serializer, $value") @@ -382,51 +340,61 @@ private class GenericEncoder(override val serializersModule: SerializersModule) } public fun result(serializer: SerializationStrategy<*>): Generic = - genericValue ?: when (descriptor?.kind) { - StructureKind.CLASS -> Generic.Product( - Generic.Info(serializer.descriptor.serialName), - genericProperties.toList() - ) - - StructureKind.OBJECT -> Generic.Product( - Generic.Info(serializer.descriptor.serialName), - genericProperties.toList() - ) - - // Probably similar to SEALED. Extracting the values. - PolymorphicKind.OPEN -> genericProperties["value"] - ?: throw RuntimeException("Internal error: no value found for $value in $genericProperties.") - - SerialKind.CONTEXTUAL -> - throw IllegalStateException("There are no contextual serializers for Generic.") - - StructureKind.LIST -> Generic.Product( - Generic.Info(serializer.descriptor.serialName), - genericProperties.toList() - ) - - StructureKind.MAP -> Generic.Product(Generic.Info(serializer.descriptor.serialName), mapGeneric()) - - PolymorphicKind.SEALED -> - Generic.Coproduct( - Generic.Info(serializer.descriptor.serialName), - Generic.Info(requireNotNull(this.serializer?.descriptor?.serialName) - { "Internal error: this.serializer?.descriptor?.serialName was null ${this.serializer}" }), - // genericProperties contains `value` and `type` - // Where `type` is a label of the case representing the sum - // And `value` is the actual instance, we want to extract the fields of the actual instance. - (genericProperties["value"] as Generic.Product).fields, - serializer - .descriptor - .elementDescriptors - .last() // Take the last one. The others are filled with optional generic params - .elementNames - .indexOf(requireNotNull(this.serializer?.descriptor?.serialName) { "Internal error: this.serializer?.descriptor?.serialName was null ${this.serializer}" }) - ) + genericValue + ?: when (descriptor?.kind) { + StructureKind.CLASS -> + Generic.Product( + Generic.Info(serializer.descriptor.serialName), + genericProperties.toList() + ) + StructureKind.OBJECT -> + Generic.Product( + Generic.Info(serializer.descriptor.serialName), + genericProperties.toList() + ) - null -> throw RuntimeException("Internal error: descriptor is null when requesting result from $this.") - else -> throw RuntimeException("Internal error: primitives & enum should be handled.") - } + // Probably similar to SEALED. Extracting the values. + PolymorphicKind.OPEN -> genericProperties["value"] + ?: throw RuntimeException( + "Internal error: no value found for $value in $genericProperties." + ) + SerialKind.CONTEXTUAL -> + throw IllegalStateException("There are no contextual serializers for Generic.") + StructureKind.LIST -> + Generic.Product( + Generic.Info(serializer.descriptor.serialName), + genericProperties.toList() + ) + StructureKind.MAP -> + Generic.Product(Generic.Info(serializer.descriptor.serialName), mapGeneric()) + PolymorphicKind.SEALED -> + Generic.Coproduct( + Generic.Info(serializer.descriptor.serialName), + Generic.Info( + requireNotNull(this.serializer?.descriptor?.serialName) { + "Internal error: this.serializer?.descriptor?.serialName was null ${this.serializer}" + } + ), + // genericProperties contains `value` and `type` + // Where `type` is a label of the case representing the sum + // And `value` is the actual instance, we want to extract the fields of the actual + // instance. + (genericProperties["value"] as Generic.Product).fields, + serializer.descriptor.elementDescriptors + .last() // Take the last one. The others are filled with optional generic params + .elementNames + .indexOf( + requireNotNull(this.serializer?.descriptor?.serialName) { + "Internal error: this.serializer?.descriptor?.serialName was null ${this.serializer}" + } + ) + ) + null -> + throw RuntimeException( + "Internal error: descriptor is null when requesting result from $this." + ) + else -> throw RuntimeException("Internal error: primitives & enum should be handled.") + } private fun mapGeneric(): List> { var index = 0 @@ -440,7 +408,8 @@ private class GenericEncoder(override val serializersModule: SerializersModule) value = generic buffer.add( Pair( - "${index++}", Generic.Product( + "${index++}", + Generic.Product( Generic.Info(Pair::class.qualifiedName!!), "first" to key!!, "second" to generic @@ -464,81 +433,71 @@ private class GenericDecoder( private var currentIndex = 0 override fun decodeElementIndex(descriptor: SerialDescriptor): Int = - if (currentIndex < descriptor.elementsCount) currentIndex++ - else CompositeDecoder.DECODE_DONE + if (currentIndex < descriptor.elementsCount) currentIndex++ else CompositeDecoder.DECODE_DONE inline fun expect(): A = - (generic as? A) ?: throw SerializationException("Expected ${A::class.simpleName}") + (generic as? A) ?: throw SerializationException("Expected ${A::class.simpleName}") - override fun decodeString(): String = - expect().value + override fun decodeString(): String = expect().value - override fun decodeBoolean(): Boolean = - expect().value + override fun decodeBoolean(): Boolean = expect().value - override fun decodeChar(): Char = - expect().value + override fun decodeChar(): Char = expect().value - override fun decodeByte(): Byte = - expect().value + override fun decodeByte(): Byte = expect().value - override fun decodeShort(): Short = - expect().value + override fun decodeShort(): Short = expect().value - override fun decodeInt(): Int = - expect().value + override fun decodeInt(): Int = expect().value - override fun decodeLong(): Long = - expect().value + override fun decodeLong(): Long = expect().value - override fun decodeFloat(): Float = - expect().value + override fun decodeFloat(): Float = expect().value - override fun decodeDouble(): Double = - expect().value + override fun decodeDouble(): Double = expect().value - override fun decodeNotNullMark(): Boolean = - generic != Generic.Null + override fun decodeNotNullMark(): Boolean = generic != Generic.Null override fun decodeNull(): Nothing? = - if (generic == Generic.Null) null - else throw SerializationException("Expected Null") - - override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = - expect().index - -// private var currentIndex = 0 -// private val fields: List = when (generic) { -// is Generic.Product -> generic.fields.map { it.second } -// is Generic.Coproduct -> generic.fields.map { it.second } -// else -> listOf(generic) -// } -// -// override fun decodeElementIndex(descriptor: SerialDescriptor): Int { -// return if (currentIndex < descriptor.elementsCount) currentIndex else CompositeDecoder.DECODE_DONE -// } -// -// override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { -// val elementGeneric = fields[currentIndex++] -// return GenericDecoder(elementGeneric, serializersModule) -// } + if (generic == Generic.Null) null else throw SerializationException("Expected Null") + + override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = expect().index + + // private var currentIndex = 0 + // private val fields: List = when (generic) { + // is Generic.Product -> generic.fields.map { it.second } + // is Generic.Coproduct -> generic.fields.map { it.second } + // else -> listOf(generic) + // } + // + // override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + // return if (currentIndex < descriptor.elementsCount) currentIndex else + // CompositeDecoder.DECODE_DONE + // } + // + // override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { + // val elementGeneric = fields[currentIndex++] + // return GenericDecoder(elementGeneric, serializersModule) + // } override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { - val elementGeneric = when (descriptor.kind) { - is StructureKind.CLASS, is StructureKind.OBJECT -> { - (generic as? Generic.Product)?.fields?.get(currentIndex - 1)?.second - } - is PolymorphicKind.SEALED -> { - (generic as? Generic.Coproduct)?.fields?.get(currentIndex - 1)?.second + val elementGeneric = + when (descriptor.kind) { + is StructureKind.CLASS, + is StructureKind.OBJECT -> { + (generic as? Generic.Product)?.fields?.get(currentIndex - 1)?.second + } + is PolymorphicKind.SEALED -> { + (generic as? Generic.Coproduct)?.fields?.get(currentIndex - 1)?.second + } + else -> generic } - else -> generic - } return GenericDecoder(elementGeneric ?: generic, serializersModule) } -// override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = -// GenericDecoder( -// expect()?.fields?.get(currentIndex - 1)?.second ?: generic, -// serializersModule -// ) -} \ No newline at end of file + // override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = + // GenericDecoder( + // expect()?.fields?.get(currentIndex - 1)?.second ?: generic, + // serializersModule + // ) +} diff --git a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/EnumSpec.kt b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/EnumSpec.kt index 4795c91..7c363f1 100644 --- a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/EnumSpec.kt +++ b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/EnumSpec.kt @@ -7,10 +7,7 @@ import io.kotest.property.checkAll import kotlinx.serialization.Serializable @Serializable -enum class Planet( - val mass: Double, - val radius: Double -) { +enum class Planet(val mass: Double, val radius: Double) { MERCURY(3.303e+23, 2.4397e6), VENUS(4.869e+24, 6.0518e6), EARTH(5.976e+24, 6.37814e6), @@ -18,21 +15,22 @@ enum class Planet( JUPITER(1.9e+27, 7.1492e7), SATURN(5.688e+26, 6.0268e7), URANUS(8.686e+25, 2.5559e7), - NEPTUNE(1.024e+26, 2.4746e7); + NEPTUNE(1.024e+26, 2.4746e7) } -class EnumSpec : StringSpec({ +class EnumSpec : + StringSpec({ + "Encoding enum should contain all values, and correct index" { + checkAll(Arb.of(Planet.values())) { planet -> + val res = Generic.encode(planet) + val expected = + Generic.Enum( + Generic.Info(Planet::class.qualifiedName!!), + Planet.values().map { Generic.EnumValue(it.name, it.ordinal) }, + planet.ordinal + ) - "Encoding enum should contain all values, and correct index" { - checkAll(Arb.of(Planet.values())) { planet -> - val res = Generic.encode(planet) - val expected = Generic.Enum( - Generic.Info(Planet::class.qualifiedName!!), - Planet.values().map { Generic.EnumValue(it.name, it.ordinal) }, - planet.ordinal - ) - - res shouldBe expected + res shouldBe expected + } } - } -}) + }) diff --git a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/InlineSpec.kt b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/InlineSpec.kt index 7335955..c3891ce 100644 --- a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/InlineSpec.kt +++ b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/InlineSpec.kt @@ -1,73 +1,75 @@ package io.github.nomisrev.generic -import io.github.nomisrev.generic.Generic.Value import io.github.nomisrev.generic.Generic.Info +import io.github.nomisrev.generic.Generic.Value import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import io.kotest.property.Arb import io.kotest.property.arbitrary.bind import io.kotest.property.arbitrary.string import io.kotest.property.checkAll +import kotlin.jvm.JvmInline import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.modules.EmptySerializersModule import kotlinx.serialization.modules.SerializersModule -import kotlin.jvm.JvmInline - -@Serializable -@JvmInline value class IBoolean(val value: kotlin.Boolean) - -@Serializable -@JvmInline value class IString(val value: kotlin.String) - -@Serializable -@JvmInline value class IChar(val value: kotlin.Char) - -@Serializable -@JvmInline value class IByte(val value: kotlin.Byte) - -@Serializable -@JvmInline value class IShort(val value: kotlin.Short) -@Serializable -@JvmInline value class IInt(val value: kotlin.Int) - -@Serializable -@JvmInline value class ILong(val value: kotlin.Long) - -@Serializable -@JvmInline value class IFloat(val value: kotlin.Float) - -@Serializable -@JvmInline value class IDouble(val value: kotlin.Double) - -@Serializable -@JvmInline value class ITree(val value: Tree) - -@Serializable -@JvmInline -value class IPerson(val value: Person) - -class InlineSpec : StringSpec({ -// testInline(Arb.bool().map(::IBoolean)) { Inline(Info(IBoolean::class.qualifiedName!!), Boolean(it.value)) } -// testInline(Arb.string().map(::IString)) { Inline(Info(IString::class.qualifiedName!!), String(it.value)) } -// testInline(Arb.char().map(::IChar)) { Inline(Info(IChar::class.qualifiedName!!), Char(it.value)) } -// testInline(Arb.byte().map(::IByte)) { Inline(Info(IByte::class.qualifiedName!!), Byte(it.value)) } -// testInline(Arb.short().map(::IShort)) { Inline(Info(IShort::class.qualifiedName!!), Short(it.value)) } -// testInline(Arb.int().map(::IInt)) { Inline(Info(IInt::class.qualifiedName!!), Int(it.value)) } -// testInline(Arb.long().map(::ILong)) { Inline(Info(ILong::class.qualifiedName!!), Long(it.value)) } -// testInline(Arb.float().map(::IFloat)) { Inline(Info(IFloat::class.qualifiedName!!), Float(it.value)) } -// testInline(Arb.double().map(::IDouble)) { Inline(Info(IDouble::class.qualifiedName!!), Double(it.value)) } -// -// testInline(Arb.bind(Arb.string(), Arb.int(), ::Person).map(::IPerson)) { -// val (name, age, p) = it.value -// Inline(Info(IPerson::class.qualifiedName!!), person(name, age, p)) -// } - - testInline(Arb.bind(Arb.string(), Arb.string(), Arb.string()) { a, b, c -> - ITree(Branch(Leaf(a), Branch(Leaf(b), Leaf(c)))) - }, serializersModule) { Value(Info(ITree::class.qualifiedName!!), tree(it.value)) } -}) +@Serializable @JvmInline value class IBoolean(val value: kotlin.Boolean) + +@Serializable @JvmInline value class IString(val value: kotlin.String) + +@Serializable @JvmInline value class IChar(val value: kotlin.Char) + +@Serializable @JvmInline value class IByte(val value: kotlin.Byte) + +@Serializable @JvmInline value class IShort(val value: kotlin.Short) + +@Serializable @JvmInline value class IInt(val value: kotlin.Int) + +@Serializable @JvmInline value class ILong(val value: kotlin.Long) + +@Serializable @JvmInline value class IFloat(val value: kotlin.Float) + +@Serializable @JvmInline value class IDouble(val value: kotlin.Double) + +@Serializable @JvmInline value class ITree(val value: Tree) + +@Serializable @JvmInline value class IPerson(val value: Person) + +class InlineSpec : + StringSpec({ + // testInline(Arb.bool().map(::IBoolean)) { Inline(Info(IBoolean::class.qualifiedName!!), + // Boolean(it.value)) } + // testInline(Arb.string().map(::IString)) { Inline(Info(IString::class.qualifiedName!!), + // String(it.value)) } + // testInline(Arb.char().map(::IChar)) { Inline(Info(IChar::class.qualifiedName!!), + // Char(it.value)) } + // testInline(Arb.byte().map(::IByte)) { Inline(Info(IByte::class.qualifiedName!!), + // Byte(it.value)) } + // testInline(Arb.short().map(::IShort)) { Inline(Info(IShort::class.qualifiedName!!), + // Short(it.value)) } + // testInline(Arb.int().map(::IInt)) { Inline(Info(IInt::class.qualifiedName!!), Int(it.value)) + // } + // testInline(Arb.long().map(::ILong)) { Inline(Info(ILong::class.qualifiedName!!), + // Long(it.value)) } + // testInline(Arb.float().map(::IFloat)) { Inline(Info(IFloat::class.qualifiedName!!), + // Float(it.value)) } + // testInline(Arb.double().map(::IDouble)) { Inline(Info(IDouble::class.qualifiedName!!), + // Double(it.value)) } + // + // testInline(Arb.bind(Arb.string(), Arb.int(), ::Person).map(::IPerson)) { + // val (name, age, p) = it.value + // Inline(Info(IPerson::class.qualifiedName!!), person(name, age, p)) + // } + + testInline( + Arb.bind(Arb.string(), Arb.string(), Arb.string()) { a, b, c -> + ITree(Branch(Leaf(a), Branch(Leaf(b), Leaf(c)))) + }, + serializersModule + ) { + Value(Info(ITree::class.qualifiedName!!), tree(it.value)) + } + }) inline fun StringSpec.testInline( arb: Arb, @@ -82,7 +84,8 @@ inline fun StringSpec.testInline( "Nested in Id - ${A::class.qualifiedName}" { checkAll(arb) { inlined -> - Generic.encode(Id(inlined), serializersModule = serializersModule) shouldBe expected(inlined).id() + Generic.encode(Id(inlined), serializersModule = serializersModule) shouldBe + expected(inlined).id() } } } diff --git a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/ListSpec.kt b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/ListSpec.kt index 1ad6e6a..6dca097 100644 --- a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/ListSpec.kt +++ b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/ListSpec.kt @@ -12,47 +12,36 @@ import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.serializer -class ListSpec : StringSpec({ - "top-level list" { - checkAll(Arb.list(Arb.int())) { l -> - Generic.encode(l) shouldBe list(l) - } - } +class ListSpec : + StringSpec({ + "top-level list" { checkAll(Arb.list(Arb.int())) { l -> Generic.encode(l) shouldBe list(l) } } - "list inside polymorphic product" { - checkAll(Arb.list(Arb.int())) { l -> - Generic.encode(Id(l)) shouldBe list(l).id() + "list inside polymorphic product" { + checkAll(Arb.list(Arb.int())) { l -> Generic.encode(Id(l)) shouldBe list(l).id() } } - } - "list inside inline" { - checkAll(Arb.list(Arb.int().map(::IInt))) { l -> - Generic.encode(Id(l)) shouldBe list(l).id() + "list inside inline" { + checkAll(Arb.list(Arb.int().map(::IInt))) { l -> Generic.encode(Id(l)) shouldBe list(l).id() } } - } - "list inside sum-type".config(enabled = false) { - checkAll(Arb.list(Arb.int()), Arb.list(Arb.int()), Arb.list(Arb.int())) { a, b, c -> - val tree: Tree> = - Branch(Leaf(a), Branch(Leaf(b), Leaf(c))) + "list inside sum-type" + .config(enabled = false) { + checkAll(Arb.list(Arb.int()), Arb.list(Arb.int()), Arb.list(Arb.int())) { a, b, c -> + val tree: Tree> = Branch(Leaf(a), Branch(Leaf(b), Leaf(c))) - Generic.encode( - tree, -// TODO Caused by SerializationException: Class 'ArrayList' is not registered for polymorphic serialization in the scope of 'Any'. - Tree.serializer(ListSerializer(Int.serializer())), - serializersModule = serializersModule - ) shouldBe branch(leaf(list(a)), branch(leaf(list(b)), leaf(list(c)))) - } - } -}) + Generic.encode( + tree, + // TODO Caused by SerializationException: Class 'ArrayList' is not registered + // for polymorphic serialization in the scope of 'Any'. + Tree.serializer(ListSerializer(Int.serializer())), + serializersModule = serializersModule + ) shouldBe branch(leaf(list(a)), branch(leaf(list(b)), leaf(list(c)))) + } + } + }) -inline fun list( - list: List, - serializer: KSerializer = serializer() -): Generic = +inline fun list(list: List, serializer: KSerializer = serializer()): Generic = Generic.Product( Generic.Info("kotlin.collections.ArrayList"), - list.mapIndexed { index: Int, a: A -> - Pair(index.toString(), Generic.encode(a, serializer)) - } + list.mapIndexed { index: Int, a: A -> Pair(index.toString(), Generic.encode(a, serializer)) } ) diff --git a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/MapSpec.kt b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/MapSpec.kt index a696e47..9a78d83 100644 --- a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/MapSpec.kt +++ b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/MapSpec.kt @@ -12,42 +12,43 @@ import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.serializer -class MapSpec : StringSpec({ - "top-level map" { - checkAll(Arb.map(Arb.string(), Arb.int())) { map -> - Generic.encode(map) shouldBe map(map) +class MapSpec : + StringSpec({ + "top-level map" { + checkAll(Arb.map(Arb.string(), Arb.int())) { map -> Generic.encode(map) shouldBe map(map) } } - } - "map inside polymorphic product" { - checkAll(Arb.map(Arb.string(), Arb.int())) { map -> - Generic.encode(Id(map)) shouldBe map(map).id() + "map inside polymorphic product" { + checkAll(Arb.map(Arb.string(), Arb.int())) { map -> + Generic.encode(Id(map)) shouldBe map(map).id() + } } - } - "map inside inline" { - checkAll(Arb.map(Arb.string().map(::IString), Arb.int().map(::IInt))) { map -> - Generic.encode(Id(map)) shouldBe map(map).id() + "map inside inline" { + checkAll(Arb.map(Arb.string().map(::IString), Arb.int().map(::IInt))) { map -> + Generic.encode(Id(map)) shouldBe map(map).id() + } } - } - "map inside sum-type".config(enabled = false) { - checkAll( - Arb.map(Arb.string(), Arb.int()), - Arb.map(Arb.string(), Arb.int()), - Arb.map(Arb.string(), Arb.int()) - ) { a, b, c -> - val tree: Tree> = - Branch(Leaf(a), Branch(Leaf(b), Leaf(c))) + "map inside sum-type" + .config(enabled = false) { + checkAll( + Arb.map(Arb.string(), Arb.int()), + Arb.map(Arb.string(), Arb.int()), + Arb.map(Arb.string(), Arb.int()) + ) { a, b, c -> + val tree: Tree> = Branch(Leaf(a), Branch(Leaf(b), Leaf(c))) - Generic.encode(tree, -// TODO Caused by SerializationException: Class 'LinkedHashMap' is not registered for polymorphic serialization in the scope of 'Any'. - Tree.serializer(MapSerializer(String.serializer(), Int.serializer())), - serializersModule = serializersModule - ) shouldBe branch(leaf(map(a)), branch(leaf(map(b)), leaf(map(c)))) - } - } -}) + Generic.encode( + tree, + // TODO Caused by SerializationException: Class 'LinkedHashMap' is not + // registered for polymorphic serialization in the scope of 'Any'. + Tree.serializer(MapSerializer(String.serializer(), Int.serializer())), + serializersModule = serializersModule + ) shouldBe branch(leaf(map(a)), branch(leaf(map(b)), leaf(map(c)))) + } + } + }) inline fun map( map: Map, @@ -58,13 +59,14 @@ inline fun map( return Generic.Product( Generic.Info("kotlin.collections.LinkedHashMap"), map.map { (a, b) -> - "${index++}" to Generic.Product( - Generic.Info(Pair::class.qualifiedName!!), - listOf( - "first" to Generic.encode(a, serializerA), - "second" to Generic.encode(b, serializerB) + "${index++}" to + Generic.Product( + Generic.Info(Pair::class.qualifiedName!!), + listOf( + "first" to Generic.encode(a, serializerA), + "second" to Generic.encode(b, serializerB) + ) ) - ) } ) } diff --git a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/PrimitiveSpec.kt b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/PrimitiveSpec.kt index 6507b96..2a87c8d 100644 --- a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/PrimitiveSpec.kt +++ b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/PrimitiveSpec.kt @@ -15,24 +15,24 @@ import io.kotest.property.arbitrary.short import io.kotest.property.arbitrary.string import io.kotest.property.checkAll -class PrimitiveSpec : StringSpec({ - testPrimitive(Arb.bool()) { Generic.Boolean(it) } - testPrimitive(Arb.string()) { Generic.String(it) } - testPrimitive(Arb.char()) { Generic.Char(it) } - testPrimitive(Arb.byte()) { Generic.Number.Byte(it) } - testPrimitive(Arb.short()) { Generic.Number.Short(it) } - testPrimitive(Arb.int()) { Generic.Number.Int(it) } - testPrimitive(Arb.long()) { Generic.Number.Long(it) } - testPrimitive(Arb.float()) { Generic.Number.Float(it) } - testPrimitive(Arb.double()) { Generic.Number.Double(it) } -}) +class PrimitiveSpec : + StringSpec({ + testPrimitive(Arb.bool()) { Generic.Boolean(it) } + testPrimitive(Arb.string()) { Generic.String(it) } + testPrimitive(Arb.char()) { Generic.Char(it) } + testPrimitive(Arb.byte()) { Generic.Number.Byte(it) } + testPrimitive(Arb.short()) { Generic.Number.Short(it) } + testPrimitive(Arb.int()) { Generic.Number.Int(it) } + testPrimitive(Arb.long()) { Generic.Number.Long(it) } + testPrimitive(Arb.float()) { Generic.Number.Float(it) } + testPrimitive(Arb.double()) { Generic.Number.Double(it) } + }) inline fun StringSpec.testPrimitive( arb: Arb, noinline expected: (A) -> Generic -): Unit = - "${A::class.qualifiedName!!}" { - checkAll(arb.orNull()) { a -> - Generic.encode(a) shouldBe if (a == null) Generic.Null else expected(a) - } +): Unit = "${A::class.qualifiedName!!}" { + checkAll(arb.orNull()) { a -> + Generic.encode(a) shouldBe if (a == null) Generic.Null else expected(a) } +} diff --git a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/ProductSpec.kt b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/ProductSpec.kt index 1f6819b..cc90526 100644 --- a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/ProductSpec.kt +++ b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/ProductSpec.kt @@ -3,7 +3,6 @@ package io.github.nomisrev.generic import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe import io.kotest.property.Arb -import io.kotest.property.arbitrary.bool import io.kotest.property.arbitrary.boolean import io.kotest.property.arbitrary.byte import io.kotest.property.arbitrary.char @@ -17,73 +16,68 @@ import io.kotest.property.arbitrary.string import io.kotest.property.checkAll import kotlinx.serialization.Serializable -@Serializable -data class Id(val value: A) +@Serializable data class Id(val value: A) -@Serializable -data class Person(val name: String, val age: Int, val p: Person2? = null) +@Serializable data class Person(val name: String, val age: Int, val p: Person2? = null) -@Serializable -data class Person2(val name: String, val age: Int, val p: Person2? = null) +@Serializable data class Person2(val name: String, val age: Int, val p: Person2? = null) -class ProductSpec : StringSpec({ - - "Pair" { - checkAll(Arb.int(), Arb.string()) { a, b -> - Generic.encode(Pair(a, b)) shouldBe pair(Generic.Number.Int(a), Generic.String(b)) +class ProductSpec : + StringSpec({ + "Pair" { + checkAll(Arb.int(), Arb.string()) { a, b -> + Generic.encode(Pair(a, b)) shouldBe pair(Generic.Number.Int(a), Generic.String(b)) + } } - } - "Nested Pair" { - checkAll(Arb.int(), Arb.string(), Arb.float()) { a, b, c -> - Generic.encode(Pair(a, Pair(b, c))) shouldBe pair(Generic.Number.Int(a), pair(Generic.String(b), Generic.Number.Float(c))) + "Nested Pair" { + checkAll(Arb.int(), Arb.string(), Arb.float()) { a, b, c -> + Generic.encode(Pair(a, Pair(b, c))) shouldBe + pair(Generic.Number.Int(a), pair(Generic.String(b), Generic.Number.Float(c))) + } } - } - "Person" { - val res = Generic.encode(Person(name = "X", age = 98, p = Person2(name = "Y", age = 99, p = null))) - val expected = person("X", 98, Person2("Y", 99)) - res shouldBe expected - } + "Person" { + val res = + Generic.encode(Person(name = "X", age = 98, p = Person2(name = "Y", age = 99, p = null))) + val expected = person("X", 98, Person2("Y", 99)) + res shouldBe expected + } - "Serializable Person, without Module Config" { - val res = Generic.encode(Id(Person(name = "X", age = 98, p = Person2(name = "Y", age = 99, p = null)))) - val expected = person("X", 98, Person2("Y", 99)).id() - res shouldBe expected - } + "Serializable Person, without Module Config" { + val res = + Generic.encode( + Id(Person(name = "X", age = 98, p = Person2(name = "Y", age = 99, p = null))) + ) + val expected = person("X", 98, Person2("Y", 99)).id() + res shouldBe expected + } - testIdProduct(Arb.boolean()) { Generic.Boolean(it) } - testIdProduct(Arb.string()) { Generic.String(it) } - testIdProduct(Arb.char()) { Generic.Char(it) } - testIdProduct(Arb.byte()) { Generic.Number.Byte(it) } - testIdProduct(Arb.short()) { Generic.Number.Short(it) } - testIdProduct(Arb.int()) { Generic.Number.Int(it) } - testIdProduct(Arb.long()) { Generic.Number.Long(it) } - testIdProduct(Arb.float()) { Generic.Number.Float(it) } - testIdProduct(Arb.double()) { Generic.Number.Double(it) } -}) + testIdProduct(Arb.boolean()) { Generic.Boolean(it) } + testIdProduct(Arb.string()) { Generic.String(it) } + testIdProduct(Arb.char()) { Generic.Char(it) } + testIdProduct(Arb.byte()) { Generic.Number.Byte(it) } + testIdProduct(Arb.short()) { Generic.Number.Short(it) } + testIdProduct(Arb.int()) { Generic.Number.Int(it) } + testIdProduct(Arb.long()) { Generic.Number.Long(it) } + testIdProduct(Arb.float()) { Generic.Number.Float(it) } + testIdProduct(Arb.double()) { Generic.Number.Double(it) } + }) fun Generic.id(): Generic.Product = - Generic.Product( - Generic.Info(Id::class.qualifiedName!!), - listOf("value" to this@id) - ) + Generic.Product(Generic.Info(Id::class.qualifiedName!!), listOf("value" to this@id)) inline fun StringSpec.testIdProduct( arb: Arb, noinline expected: (A) -> Generic -): Unit = - "Id - ${A::class.qualifiedName!!}" { - checkAll(arb.map(::Id)) { id -> - Generic.encode(id, serializersModule = serializersModule) shouldBe expected(id.value).id() - } +): Unit = "Id - ${A::class.qualifiedName!!}" { + checkAll(arb.map(::Id)) { id -> + Generic.encode(id, serializersModule = serializersModule) shouldBe expected(id.value).id() } +} fun pair(first: Generic, second: Generic): Generic = - Generic.Product( - Generic.Info("kotlin.Pair"), - listOf("first" to first, "second" to second) - ) + Generic.Product(Generic.Info("kotlin.Pair"), listOf("first" to first, "second" to second)) fun person(name: String, age: Int, p: Person2? = null): Generic = Generic.Product( @@ -96,7 +90,8 @@ fun person(name: String, age: Int, p: Person2? = null): Generic = ) fun person2(name: String, age: Int, p: Person2? = null): Generic = - Generic.Product(Generic.Info(Person2::class.qualifiedName!!), + Generic.Product( + Generic.Info(Person2::class.qualifiedName!!), listOfNotNull( "name" to Generic.String(name), "age" to Generic.Number.Int(age), diff --git a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/SumSpec.kt b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/SumSpec.kt index f273ad2..878199e 100644 --- a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/SumSpec.kt +++ b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/SumSpec.kt @@ -7,8 +7,7 @@ import io.kotest.property.arbitrary.string import io.kotest.property.checkAll import kotlinx.serialization.Serializable -@Serializable -sealed class Tree +@Serializable sealed class Tree @Serializable // TODO: Index 1?? data class Leaf(val value: A) : Tree() @@ -16,18 +15,17 @@ data class Leaf(val value: A) : Tree() @Serializable // TODO: Index 0 ??? data class Branch(val left: Tree, val right: Tree) : Tree() -class SumSpec : StringSpec({ - - "Tree" { - checkAll(Arb.string(), Arb.string(), Arb.string()) { a, b, c -> - val tree: Tree = - Branch(Leaf(a), Branch(Leaf(b), Leaf(c))) - val actual = Generic.encode(tree, serializersModule = serializersModule) - val expected = branch(leaf(a), branch(leaf(b), leaf(c))) - actual shouldBe expected +class SumSpec : + StringSpec({ + "Tree" { + checkAll(Arb.string(), Arb.string(), Arb.string()) { a, b, c -> + val tree: Tree = Branch(Leaf(a), Branch(Leaf(b), Leaf(c))) + val actual = Generic.encode(tree, serializersModule = serializersModule) + val expected = branch(leaf(a), branch(leaf(b), leaf(c))) + actual shouldBe expected + } } - } -}) + }) fun tree(value: Tree): Generic = when (value) { @@ -35,8 +33,7 @@ fun tree(value: Tree): Generic = is Branch -> branch(tree(value.left), tree(value.right)) } -fun leaf(value: String): Generic = - leaf(Generic.String(value)) +fun leaf(value: String): Generic = leaf(Generic.String(value)) fun leaf(value: Generic): Generic = Generic.Coproduct( diff --git a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/predef.kt b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/predef.kt index 57c89b3..20b2881 100644 --- a/generic/src/commonTest/kotlin/io/github/nomisrev/generic/predef.kt +++ b/generic/src/commonTest/kotlin/io/github/nomisrev/generic/predef.kt @@ -11,8 +11,7 @@ import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.subclass -fun Arb.Companion.of(collection: Array): Arb = - element(collection.toList()) +fun Arb.Companion.of(collection: Array): Arb = element(collection.toList()) val serializersModule = SerializersModule { polymorphic(Any::class) { @@ -28,9 +27,16 @@ val serializersModule = SerializersModule { subclass(UInt.serializer()) subclass(ULong.serializer()) - // TODO Caused by SerializationException: Class 'ArrayList' is not registered for polymorphic serialization in the scope of 'Any'. + // TODO Caused by SerializationException: Class 'ArrayList' is not registered for polymorphic + // serialization in the scope of 'Any'. subclass(ListSerializer(PolymorphicSerializer(Any::class).nullable)) - // TODO Caused by SerializationException: Class 'LinkedHashMap' is not registered for polymorphic serialization in the scope of 'Any'. - subclass(MapSerializer(PolymorphicSerializer(Any::class).nullable, PolymorphicSerializer(Any::class).nullable)) + // TODO Caused by SerializationException: Class 'LinkedHashMap' is not registered for + // polymorphic serialization in the scope of 'Any'. + subclass( + MapSerializer( + PolymorphicSerializer(Any::class).nullable, + PolymorphicSerializer(Any::class).nullable + ) + ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7844c2..3ea576c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ kasechange="1.4.1" [libraries] gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } -test = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "json" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" } okio-fakefilesystem = { module = "com.squareup.okio:okio-fakefilesystem", version.ref = "okio" } diff --git a/parser/build.gradle.kts b/parser/build.gradle.kts index 2dc65ba..5fcb71e 100644 --- a/parser/build.gradle.kts +++ b/parser/build.gradle.kts @@ -7,7 +7,6 @@ plugins { kotlin { explicitApi() - jvm() macosArm64() linuxX64() @@ -26,6 +25,3 @@ kotlin { } } -tasks.withType { - useJUnitPlatform() -} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/AdditionalProperties.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/AdditionalProperties.kt index 719a0bf..c417f55 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/AdditionalProperties.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/AdditionalProperties.kt @@ -1,6 +1,7 @@ package io.github.nomisrev.openapi import io.github.nomisrev.openapi.OpenAPI.Companion.Json +import kotlin.jvm.JvmInline import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException @@ -12,12 +13,11 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.boolean import kotlinx.serialization.json.booleanOrNull -import kotlin.jvm.JvmInline @Serializable(with = AdditionalProperties.Companion.Serializer::class) public sealed interface AdditionalProperties { - @JvmInline - public value class Allowed(public val value: Boolean) : AdditionalProperties + @JvmInline public value class Allowed(public val value: Boolean) : AdditionalProperties + @JvmInline public value class PSchema(public val value: ReferenceOr) : AdditionalProperties @@ -30,17 +30,23 @@ public sealed interface AdditionalProperties { val json = decoder.decodeSerializableValue(JsonElement.serializer()) return when { json is JsonPrimitive && json.booleanOrNull != null -> Allowed(json.boolean) - json is JsonObject -> PSchema(Json.decodeFromJsonElement(ReferenceOr.serializer(Schema.serializer()), json)) - else -> throw SerializationException("AdditionalProperties can only be a boolean or a schema") + json is JsonObject -> + PSchema(Json.decodeFromJsonElement(ReferenceOr.serializer(Schema.serializer()), json)) + else -> + throw SerializationException("AdditionalProperties can only be a boolean or a schema") } } override fun serialize(encoder: Encoder, value: AdditionalProperties) { when (value) { is Allowed -> encoder.encodeBoolean(value.value) - is PSchema -> encoder.encodeSerializableValue(ReferenceOr.serializer(Schema.serializer()), value.value) + is PSchema -> + encoder.encodeSerializableValue( + ReferenceOr.serializer(Schema.serializer()), + value.value + ) } } } } -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Callback.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Callback.kt index d5aed98..cbedcbe 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Callback.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Callback.kt @@ -1,7 +1,7 @@ package io.github.nomisrev.openapi -import kotlinx.serialization.Serializable import kotlin.jvm.JvmInline +import kotlinx.serialization.Serializable /** * A map of possible out-of band callbacks related to the parent operation. Each value in the map is @@ -9,6 +9,4 @@ import kotlin.jvm.JvmInline * and the expected responses. The key value used to identify the path item object is an expression, * evaluated at runtime, that identifies a URL to use for the callback operation. */ -@Serializable -@JvmInline -public value class Callback(public val value: Map) \ No newline at end of file +@Serializable @JvmInline public value class Callback(public val value: Map) diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Components.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Components.kt index 8234e46..22cbf22 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Components.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Components.kt @@ -1,7 +1,5 @@ package io.github.nomisrev.openapi -import kotlinx.serialization.InternalSerializationApi -import kotlinx.serialization.KeepGeneratedSerializer import io.github.nomisrev.openapi.Example.Companion.Serializer as ExampleSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonArray @@ -21,26 +19,29 @@ public data class Components( public val schemas: Map> = emptyMap(), public val responses: Map> = emptyMap(), public val parameters: Map> = emptyMap(), - public val examples: Map> = emptyMap(), + public val examples: + Map> = + emptyMap(), public val requestBodies: Map> = emptyMap(), public val headers: Map> = emptyMap(), -// val securitySchemes: Definitions, + // val securitySchemes: Definitions, public val links: Map = emptyMap(), public val callbacks: Map = emptyMap(), public val pathItems: Map> = emptyMap(), /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() ) { public companion object { - internal object Serializer : KSerializerWithExtensions( - OpenAPI.Json, - serializer(), - Components::extensions, - { op, extensions -> op.copy(extensions = extensions) } - ) + internal object Serializer : + KSerializerWithExtensions( + OpenAPI.Json, + serializer(), + Components::extensions, + { op, extensions -> op.copy(extensions = extensions) } + ) } -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Encoding.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Encoding.kt index 43a6672..44caa42 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Encoding.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Encoding.kt @@ -16,8 +16,8 @@ public data class Encoding( * - for other primitive types – text/plain * - for object - application/json * - for array – the default is defined based on the inner type. The value can be a specific media - * type (e.g. application/json), a wildcard media type (e.g. image/*), or a - * comma-separated list of the two types. + * type (e.g. application/json), a wildcard media type (e.g. image/*), or a + * comma-separated list of the two types. */ public val contentType: String, // Could be arrow.endpoint.model.MediaType /** @@ -49,18 +49,19 @@ public data class Encoding( */ public val allowReserved: Boolean, /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() ) { public companion object { - internal object Serializer : KSerializerWithExtensions( - OpenAPI.Json, - serializer(), - Encoding::extensions, - { op, extensions -> op.copy(extensions = extensions) } - ) + internal object Serializer : + KSerializerWithExtensions( + OpenAPI.Json, + serializer(), + Encoding::extensions, + { op, extensions -> op.copy(extensions = extensions) } + ) } -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Example.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Example.kt index 6342143..088a0e6 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Example.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Example.kt @@ -28,18 +28,19 @@ public data class Example( */ public val externalValue: String? = null, /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() ) { public companion object { - internal object Serializer : KSerializerWithExtensions( - OpenAPI.Json, - serializer(), - Example::extensions, - { op, extensions -> op.copy(extensions = extensions) } - ) + internal object Serializer : + KSerializerWithExtensions( + OpenAPI.Json, + serializer(), + Example::extensions, + { op, extensions -> op.copy(extensions = extensions) } + ) } -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExampleValue.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExampleValue.kt index 42f6fde..c76bf74 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExampleValue.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExampleValue.kt @@ -1,5 +1,6 @@ package io.github.nomisrev.openapi +import kotlin.jvm.JvmInline import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException @@ -12,7 +13,6 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive -import kotlin.jvm.JvmInline @Serializable(with = ExampleValue.Companion.Serializer::class) public sealed interface ExampleValue { @@ -39,8 +39,7 @@ public sealed interface ExampleValue { override fun serialize(encoder: Encoder, value: ExampleValue) { when (value) { is Single -> encoder.encodeString(value.value) - is Multiple -> - encoder.encodeSerializableValue(multipleSerializer, value.values) + is Multiple -> encoder.encodeSerializableValue(multipleSerializer, value.values) } } @@ -48,11 +47,14 @@ public sealed interface ExampleValue { return when (val json = decoder.decodeSerializableValue(JsonElement.serializer())) { is JsonArray -> Multiple(decoder.decodeSerializableValue(multipleSerializer)) is JsonPrimitive -> Single(json.content) - else -> throw SerializationException("ExampleValue can only be a primitive or an array, found $json") + else -> + throw SerializationException( + "ExampleValue can only be a primitive or an array, found $json" + ) } } } public operator fun invoke(v: String): ExampleValue = Single(v) } -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExpressionOrValue.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExpressionOrValue.kt index b698ae5..588414b 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExpressionOrValue.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExpressionOrValue.kt @@ -3,7 +3,7 @@ package io.github.nomisrev.openapi import kotlin.jvm.JvmInline public sealed interface ExpressionOrValue { - @JvmInline - public value class Expression(public val value: String) : ExpressionOrValue + @JvmInline public value class Expression(public val value: String) : ExpressionOrValue + @JvmInline public value class Value(public val value: Any?) : ExpressionOrValue -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExternalDocs.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExternalDocs.kt index 0579e7d..40db6e8 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExternalDocs.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExternalDocs.kt @@ -12,4 +12,4 @@ public data class ExternalDocs( public val description: String? = null, /** The URL for the target documentation. Value MUST be in the format of a URL. */ public val url: String -) \ No newline at end of file +) diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExternalDocumentation.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExternalDocumentation.kt index 3136ce8..3d3612a 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExternalDocumentation.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ExternalDocumentation.kt @@ -3,4 +3,4 @@ package io.github.nomisrev.openapi public data class ExternalDocumentation( public val url: String, public val description: String? = null -) \ No newline at end of file +) diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Format.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Format.kt index b1c0131..3bb0161 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Format.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Format.kt @@ -11,4 +11,4 @@ public object Format { public const val date: String = "date" public const val datetime: String = "datetime" public const val password: String = "password" -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Header.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Header.kt index 05cb305..fb44483 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Header.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Header.kt @@ -15,6 +15,8 @@ public data class Header( public val allowEmptyValue: Boolean? = null, public val explode: Boolean? = null, public val example: ExampleValue? = null, - public val examples: Map>? = null, + public val examples: + Map>? = + null, public val schema: ReferenceOr? = null -) \ No newline at end of file +) diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Info.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Info.kt index ee79f80..ccafe1b 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Info.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Info.kt @@ -32,9 +32,9 @@ public data class Info( */ public val version: String, /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() ) { @@ -51,9 +51,9 @@ public data class Info( */ public val email: String? = null, /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() ) @@ -67,10 +67,10 @@ public data class Info( public val url: String? = null, private val identifier: String? = null, /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() ) -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/KSerializerWithExtensions.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/KSerializerWithExtensions.kt index 75e9289..e33d44e 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/KSerializerWithExtensions.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/KSerializerWithExtensions.kt @@ -14,20 +14,25 @@ internal abstract class KSerializerWithExtensions( private val serializer: KSerializer, private val extensions: (T) -> Map, private val withExtensions: (T, Map) -> T -): KSerializer { +) : KSerializer { override val descriptor: SerialDescriptor = serializer.descriptor override fun deserialize(decoder: Decoder): T { val jsObject = decoder.decodeSerializableValue(JsonElement.serializer()) - val value = json.decodeFromJsonElement(serializer, - JsonObject(jsObject.jsonObject.filterNot { (key, _) -> key.startsWith("x-") }) - ) + val value = + json.decodeFromJsonElement( + serializer, + JsonObject(jsObject.jsonObject.filterNot { (key, _) -> key.startsWith("x-") }) + ) val extensions = jsObject.jsonObject.filter { (key, _) -> key.startsWith("x-") } return withExtensions(value, extensions) } override fun serialize(encoder: Encoder, value: T) { val jsObject = json.encodeToJsonElement(serializer, value).jsonObject - "extensions" - encoder.encodeSerializableValue(JsonElement.serializer(), JsonObject(jsObject + extensions(value))) + encoder.encodeSerializableValue( + JsonElement.serializer(), + JsonObject(jsObject + extensions(value)) + ) } } diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Link.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Link.kt index efef2de..6c927b2 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Link.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Link.kt @@ -43,9 +43,9 @@ public data class Link( /** A server object to be used by the target operation. */ public val server: Server?, /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() -) \ No newline at end of file +) diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/MediaType.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/MediaType.kt index 77f7df3..7b054d6 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/MediaType.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/MediaType.kt @@ -26,26 +26,30 @@ public data class MediaType( * if referencing a schema which contains an example, the examples value SHALL override the * example provided by the schema. */ - public val examples: Map> = emptyMap(), + public val examples: + Map> = + emptyMap(), /** * A map between a property name and its encoding information. The key, being the property name, * MUST exist in the schema as a property. The encoding object SHALL only apply to requestBody * objects when the media type is multipart or application/x-www-form-urlencoded. */ - public val encoding: Map = emptyMap(), + public val encoding: Map = + emptyMap(), /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() ) { public companion object { - internal object Serializer : KSerializerWithExtensions( - OpenAPI.Json, - serializer(), - MediaType::extensions, - { op, extensions -> op.copy(extensions = extensions) } - ) + internal object Serializer : + KSerializerWithExtensions( + OpenAPI.Json, + serializer(), + MediaType::extensions, + { op, extensions -> op.copy(extensions = extensions) } + ) } -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPI.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPI.kt index 17d2c6a..2b1a486 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPI.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPI.kt @@ -1,21 +1,20 @@ package io.github.nomisrev.openapi +import io.github.nomisrev.openapi.Components.Companion.Serializer as ComponentsSerializer +import kotlin.jvm.JvmStatic import kotlinx.serialization.EncodeDefault import kotlinx.serialization.EncodeDefault.Mode.ALWAYS import kotlinx.serialization.ExperimentalSerializationApi -import io.github.nomisrev.openapi.Components.Companion.Serializer as ComponentsSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonObject -import kotlin.jvm.JvmStatic - /** This is the root document object for the API specification. */ @OptIn(ExperimentalSerializationApi::class) @@ -34,13 +33,16 @@ public data class OpenAPI( /** The available paths and operations for the API. */ public val paths: Map = emptyMap(), /** - * The incoming webhooks that MAY be received as part of this API and that the API consumer MAY choose to implement. - * Closely related to the callbacks feature, this section describes requests initiated other than by an API call, for example by an out of band registration. - * The key name is a unique string to refer to each webhook, while the (optionally referenced) Path Item Object describes a request that may be initiated by the API provider and the expected responses. + * The incoming webhooks that MAY be received as part of this API and that the API consumer MAY + * choose to implement. Closely related to the callbacks feature, this section describes requests + * initiated other than by an API call, for example by an out of band registration. The key name + * is a unique string to refer to each webhook, while the (optionally referenced) Path Item Object + * describes a request that may be initiated by the API provider and the expected responses. */ public val webhooks: Map> = emptyMap(), /** An element to hold various schemas for the specification. */ - public val components: @Serializable(with = ComponentsSerializer::class) Components = Components(), + public val components: @Serializable(with = ComponentsSerializer::class) Components = + Components(), /** * A declaration of which security mechanisms can be used across the API. The list of values * includes alternative security requirement objects that can be used. Only one of the security @@ -59,21 +61,19 @@ public data class OpenAPI( /** Additional external documentation. */ public val externalDocs: ExternalDocs? = null, /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() ) { - public fun operationsByTag(): Map> = - TODO() -// tags.associateBy(Tag::name) { tag -> -// operations().filter { it.tags.contains(tag.name) } -// } + public fun operationsByTag(): Map> = TODO() + // tags.associateBy(Tag::name) { tag -> + // operations().filter { it.tags.contains(tag.name) } + // } - public fun withComponents(components: Components): OpenAPI = - copy(components = components) + public fun withComponents(components: Components): OpenAPI = copy(components = components) public fun withPathItem(path: String, pathItem: PathItem): OpenAPI { val newPathItem = @@ -85,20 +85,16 @@ public data class OpenAPI( return copy(paths = paths + Pair(path, newPathItem)) } - public fun withServers(servers: List): OpenAPI = - copy(servers = this.servers + servers) + public fun withServers(servers: List): OpenAPI = copy(servers = this.servers + servers) public fun withServers(vararg servers: Server): OpenAPI = copy(servers = this.servers + servers.toList()) - public fun withServer(server: Server): OpenAPI = - copy(servers = this.servers + listOf(server)) + public fun withServer(server: Server): OpenAPI = copy(servers = this.servers + listOf(server)) - public fun withTags(tags: Set): OpenAPI = - copy(tags = this.tags + tags) + public fun withTags(tags: Set): OpenAPI = copy(tags = this.tags + tags) - public fun withTag(tag: Tag): OpenAPI = - copy(tags = this.tags + setOf(tag)) + public fun withTag(tag: Tag): OpenAPI = copy(tags = this.tags + setOf(tag)) public fun withExternalDocs(externalDocs: ExternalDocs): OpenAPI = copy(externalDocs = externalDocs) @@ -106,15 +102,12 @@ public data class OpenAPI( public fun withExtensions(extensions: Map): OpenAPI = copy(extensions = this.extensions + extensions) - public fun toJson(): String = - Json.encodeToString(this) + public fun toJson(): String = Json.encodeToString(this) - public fun toJsObject(): JsonObject = - Json.encodeToJsonElement(this).jsonObject + public fun toJsObject(): JsonObject = Json.encodeToJsonElement(this).jsonObject public companion object { - public fun fromJson(json: String): OpenAPI = - Json.decodeFromString(serializer(), json) + public fun fromJson(json: String): OpenAPI = Json.decodeFromString(serializer(), json) @JvmStatic internal val Json: Json = Json { diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Operation.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Operation.kt index cd3a848..fee9d8b 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Operation.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Operation.kt @@ -73,18 +73,19 @@ public data class Operation( */ public val servers: List = emptyList(), /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() ) { public companion object { - internal object Serializer : KSerializerWithExtensions( - Json, - serializer(), - Operation::extensions, - { op, extensions -> op.copy(extensions = extensions) } - ) + internal object Serializer : + KSerializerWithExtensions( + Json, + serializer(), + Operation::extensions, + { op, extensions -> op.copy(extensions = extensions) } + ) } } diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Parameter.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Parameter.kt index f248fdd..d3f0878 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Parameter.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Parameter.kt @@ -3,7 +3,6 @@ package io.github.nomisrev.openapi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable - /** * Describes a single operation parameter. * @@ -85,26 +84,22 @@ public data class Parameter( * of the _paramExample field. Furthermore, if referencing a schema that contains an example, the * examples value SHALL override the example provided by the schema. */ - public val examples: Map>? = emptyMap() + public val examples: + Map>? = + emptyMap() ) { init { - if (input == Input.Path) require(required) { - "${required}Determines whether this parameter is mandatory. If the parameter location is \"path\", this property is REQUIRED and its value MUST be true. Otherwise, the property MAY be included and its default value is false." - } + if (input == Input.Path) + require(required) { + "${required}Determines whether this parameter is mandatory. If the parameter location is \"path\", this property is REQUIRED and its value MUST be true. Otherwise, the property MAY be included and its default value is false." + } } @Serializable public enum class Input(public val value: String) { - @SerialName("query") - Query("query"), - - @SerialName("header") - Header("header"), - - @SerialName("path") - Path("path"), - - @SerialName("cookie") - Cookie("cookie") + @SerialName("query") Query("query"), + @SerialName("header") Header("header"), + @SerialName("path") Path("path"), + @SerialName("cookie") Cookie("cookie") } -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/PathItem.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/PathItem.kt index a6459d8..d0d9851 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/PathItem.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/PathItem.kt @@ -49,9 +49,9 @@ public data class PathItem( */ public val parameters: List> = emptyList(), /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() ) { @@ -71,4 +71,4 @@ public data class PathItem( servers = emptyList(), parameters = emptyList() ) -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ReferenceOr.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ReferenceOr.kt index 1a45d28..7f9c967 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ReferenceOr.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/ReferenceOr.kt @@ -1,6 +1,7 @@ package io.github.nomisrev.openapi import io.github.nomisrev.openapi.OpenAPI.Companion.Json +import kotlin.jvm.JvmInline import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -12,7 +13,6 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonPrimitive -import kotlin.jvm.JvmInline private const val RefKey = "\$ref" @@ -20,16 +20,15 @@ public fun Reference(prefix: String, ref: String): ReferenceOr.Reference = ReferenceOr.Reference("$prefix$ref") /** - * Defines Union [A] | [Reference]. - * A lot of types like Header, Schema, MediaType, etc. can be either a direct value or a reference to a definition. + * Defines Union [A] | [Reference]. A lot of types like Header, Schema, MediaType, etc. can be + * either a direct value or a reference to a definition. */ @Serializable(with = ReferenceOr.Companion.Serializer::class) public sealed interface ReferenceOr { @Serializable public data class Reference(@SerialName(RefKey) public val ref: String) : ReferenceOr - @JvmInline - public value class Value(public val value: A) : ReferenceOr + @JvmInline public value class Value(public val value: A) : ReferenceOr public fun valueOrNull(): A? = when (this) { @@ -37,12 +36,6 @@ public sealed interface ReferenceOr { is Value -> value } - public fun isValue(): Boolean = - when (this) { - is Reference -> false - is Value -> true - } - public companion object { private const val schema: String = "#/components/schemas/" private const val responses: String = "#/components/responses/" @@ -50,17 +43,17 @@ public sealed interface ReferenceOr { private const val requestBodies: String = "#/components/requestBodies/" private const val pathItems: String = "#/components/pathItems/" - public fun schema(name: String): Reference = - Reference("$schema$name") + public fun schema(name: String): Reference = Reference("$schema$name") - public fun value(value: A): ReferenceOr = - Value(value) + public fun value(value: A): ReferenceOr = Value(value) public operator fun invoke(prefix: String, ref: String): Reference = Reference("$prefix$ref") - internal class Serializer(private val dataSerializer: KSerializer) : KSerializer> { + internal class Serializer(private val dataSerializer: KSerializer) : + KSerializer> { - private val refDescriptor = buildClassSerialDescriptor("Reference") { element(RefKey) } + private val refDescriptor = + buildClassSerialDescriptor("Reference") { element(RefKey) } override val descriptor: SerialDescriptor = buildClassSerialDescriptor("arrow.endpoint.docs.openapi.Referenced") { @@ -77,7 +70,8 @@ public sealed interface ReferenceOr { override fun deserialize(decoder: Decoder): ReferenceOr { val json = decoder.decodeSerializableValue(JsonElement.serializer()) - return if ((json as JsonObject).contains(RefKey)) Reference(json[RefKey]!!.jsonPrimitive.content) + return if ((json as JsonObject).contains(RefKey)) + Reference(json[RefKey]!!.jsonPrimitive.content) else Value(Json.decodeFromJsonElement(dataSerializer, json)) } } diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/RequestBody.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/RequestBody.kt index f5791e7..b861836 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/RequestBody.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/RequestBody.kt @@ -24,9 +24,9 @@ public data class RequestBody( /** Determines if the request body is required in the request. Defaults to false. */ public val required: Boolean = false, /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() -) \ No newline at end of file +) diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Response.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Response.kt index 666bec2..8abf182 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Response.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Response.kt @@ -10,25 +10,29 @@ import kotlinx.serialization.json.JsonPrimitive @Serializable public data class Response( - /** A short description of the response. CommonMark's syntax MAY be used for rich text representation. */ + /** + * A short description of the response. CommonMark's syntax MAY be used for rich text + * representation. + */ public val description: String? = null, /** Maps a header name to its definition. RFC7230 states header names are case-insensitive. */ public val headers: Map> = emptyMap(), /** - * A map containing descriptions of potential response payloads. - * The key is a media type or media type range and the value describes it. - * For responses that match multiple keys, only the most specific key is applicable. i.e. text/plain overrides text + * A map containing descriptions of potential response payloads. The key is a media type or media + * type range and the value describes it. For responses that match multiple keys, only the most + * specific key is applicable. i.e. text/plain overrides text */ - public val content: Map = emptyMap(), + public val content: Map = + emptyMap(), /** * A map of operations links that can be followed from the response. The key of the map is a short * name for the link, following the naming constraints of the names for Component Objects. */ public val links: Map> = emptyMap(), /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() ) { @@ -40,4 +44,4 @@ public data class Response( links + other.links, extensions + other.extensions ) -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Responses.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Responses.kt index 53d99c2..b680457 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Responses.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Responses.kt @@ -37,15 +37,22 @@ public data class Responses( */ public val responses: Map>, /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ public val extensions: Map = emptyMap() ) { - public constructor(statusCode: Int, response: Response) : this(null, mapOf(statusCode to ReferenceOr.Value(response))) - public constructor(head: Pair>, vararg responses: Pair>) : this(null, mapOf(head) + responses) + public constructor( + statusCode: Int, + response: Response + ) : this(null, mapOf(statusCode to ReferenceOr.Value(response))) + + public constructor( + head: Pair>, + vararg responses: Pair> + ) : this(null, mapOf(head) + responses) public operator fun plus(other: Responses): Responses = Responses(other.default ?: default, responses + other.responses) @@ -58,19 +65,24 @@ public data class Responses( override fun deserialize(decoder: Decoder): Responses { val json = decoder.decodeSerializableValue(JsonElement.serializer()).jsonObject - val default = if (json.contains("default")) Json.decodeFromJsonElement(responseSerializer, json.getValue("default")) - else null + val default = + if (json.contains("default")) + Json.decodeFromJsonElement(responseSerializer, json.getValue("default")) + else null val responsesJs = json.filterNot { it.key.startsWith("x-") || it.key == "default" } - val responses = if(responsesJs.isNotEmpty()) Json.decodeFromJsonElement(responsesSerializer, JsonObject(responsesJs)) - else emptyMap() + val responses = + if (responsesJs.isNotEmpty()) + Json.decodeFromJsonElement(responsesSerializer, JsonObject(responsesJs)) + else emptyMap() val extensions = json.filter { it.key.startsWith("x-") } return Responses(default, responses, extensions) } override fun serialize(encoder: Encoder, value: Responses) { - val default = value.default?.let { - Json.encodeToJsonElement(ReferenceOr.serializer(Response.serializer()), it).jsonObject - } + val default = + value.default?.let { + Json.encodeToJsonElement(ReferenceOr.serializer(Response.serializer()), it).jsonObject + } val responses = Json.encodeToJsonElement(responsesSerializer, value.responses).jsonObject val json = JsonObject((default ?: emptyMap()) + responses + value.extensions) encoder.encodeSerializableValue(JsonElement.serializer(), json) @@ -89,6 +101,7 @@ private object ResponsesDescriptor : SerialDescriptor { ReferenceOr.serializer(Response.serializer()).descriptor override fun getElementName(index: Int): String = index.toString() + override fun getElementIndex(name: String): Int = name.toIntOrNull() ?: throw IllegalArgumentException("$name is not a valid list index") diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Schema.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Schema.kt index f7b3b04..e32a32c 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Schema.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Schema.kt @@ -50,8 +50,8 @@ public data class Schema( val maxProperties: Int? = null, val minProperties: Int? = null, /** - * Unlike JSON Schema this value MUST conform to the defined type for this parameter. - * Note: is ignored for required parameters. + * Unlike JSON Schema this value MUST conform to the defined type for this parameter. Note: is + * ignored for required parameters. */ val default: ExampleValue? = null, val type: Type? = null, @@ -73,14 +73,17 @@ public data class Schema( @SerialName("\$anchor") val anchor: String? = null ) { @Serializable - public data class Discriminator(val propertyName: String, val mapping: Map? = null) + public data class Discriminator( + val propertyName: String, + val mapping: Map? = null + ) @Serializable(with = Type.Serializer::class) public sealed interface Type { public data class Array(val types: List) : Type - public enum class Basic(public val value: kotlin.String): Type { + public enum class Basic(public val value: kotlin.String) : Type { @SerialName("array") Array("array"), @SerialName("object") Object("object"), @SerialName("number") Number("number"), @@ -103,21 +106,25 @@ public data class Schema( override fun deserialize(decoder: Decoder): Type { val json = decoder.decodeSerializableValue(JsonElement.serializer()) return when { - json is JsonArray -> Array( - decoder.decodeSerializableValue(ListSerializer(String.serializer())).mapNotNull(Basic.Companion::fromString) - ) - json is JsonPrimitive && json.isString -> Basic.fromString(json.content) - ?: throw SerializationException("Invalid Basic.Type value: ${json.content}") + json is JsonArray -> + Array( + decoder + .decodeSerializableValue(ListSerializer(String.serializer())) + .mapNotNull(Basic.Companion::fromString) + ) + json is JsonPrimitive && json.isString -> Basic.fromString(json.content) + ?: throw SerializationException("Invalid Basic.Type value: ${json.content}") else -> throw SerializationException("Schema.Type can only be a string or an array") } } override fun serialize(encoder: Encoder, value: Type) { - when(value) { - is Array -> encoder.encodeSerializableValue( - ListSerializer(String.serializer()), - value.types.map { it.value } - ) + when (value) { + is Array -> + encoder.encodeSerializableValue( + ListSerializer(String.serializer()), + value.types.map { it.value } + ) is Basic -> encoder.encodeString(value.value) } } diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/SecurityRequirement.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/SecurityRequirement.kt index cd22494..0a8887c 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/SecurityRequirement.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/SecurityRequirement.kt @@ -5,4 +5,4 @@ package io.github.nomisrev.openapi * security schemes declared in it which are all required (that is, there is a logical AND between * the schemes). */ -public typealias SecurityRequirement = Map> \ No newline at end of file +public typealias SecurityRequirement = Map> diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Server.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Server.kt index 8edf776..ad941f1 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Server.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Server.kt @@ -28,14 +28,15 @@ public data class Server( @Serializable public data class Variable( /** - * An enumeration of string values to be used if the substitution options are from a limited set. + * An enumeration of string values to be used if the substitution options are from a limited + * set. */ public val enum: List? = null, /** * The default value to use for substitution, which SHALL be sent if an alternate value is not - * supplied. Note this behavior is different than the Schema Object's treatment of default values, - * because in those cases parameter values are optional. If the enum is defined, the value SHOULD - * exist in the enum's values. + * supplied. Note this behavior is different than the Schema Object's treatment of default + * values, because in those cases parameter values are optional. If the enum is defined, the + * value SHOULD exist in the enum's values. */ public val default: String, /** @@ -45,4 +46,4 @@ public data class Server( public val description: String? = null, public val extensions: Map? = emptyMap() ) -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Style.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Style.kt index 33ba588..94d7d1f 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Style.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Style.kt @@ -12,4 +12,4 @@ public enum class Style { spaceDelimited, pipeDelimited, deepObject -} \ No newline at end of file +} diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Tag.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Tag.kt index f2c8ae7..538a4b5 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Tag.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Tag.kt @@ -2,7 +2,6 @@ package io.github.nomisrev.openapi import kotlinx.serialization.Serializable - /** * Allows adding metadata to a single tag that is used by @Operation@. It is not mandatory to have * a @Tag@ per tag used there. @@ -18,4 +17,4 @@ public data class Tag( public val description: String? = null, /** Additional external documentation for this tag. */ public val externalDocs: ExternalDocs? = null -) \ No newline at end of file +) diff --git a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Xml.kt b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Xml.kt index 76d5a9d..af924f4 100644 --- a/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Xml.kt +++ b/parser/src/commonMain/kotlin/io/github/nomisrev/openapi/Xml.kt @@ -26,16 +26,18 @@ public data class Xml( */ val attribute: Boolean? = null, /** - * MAY be used only for an array definition. Signifies whether the array is wrapped (for example, - * @\\\\@) or unwrapped (@\\@). Default value is + * MAY be used only for an array definition. Signifies whether the array is wrapped (for + * example, @\\\\@) or unwrapped (@\\@). Default + * value is + * * @False@. The definition takes effect only when defined alongside type being array (outside the - * items). + * items). */ val wrapped: Boolean? = null, /** - * Any additional external documentation for this OpenAPI document. - * The key is the name of the extension (beginning with x-), and the value is the data. - * The value can be a [JsonNull], [JsonPrimitive], [JsonArray] or [JsonObject]. + * Any additional external documentation for this OpenAPI document. The key is the name of the + * extension (beginning with x-), and the value is the data. The value can be a [JsonNull], + * [JsonPrimitive], [JsonArray] or [JsonObject]. */ val extensions: Map = emptyMap() -) \ No newline at end of file +) diff --git a/parser/src/jvmTest/kotlin/io/github/nomisrev/openapi/OpenAPISerializerEnd2EndTest.kt b/parser/src/jvmTest/kotlin/io/github/nomisrev/openapi/OpenAPISerializerEnd2EndTest.kt index 138e848..c7f710c 100644 --- a/parser/src/jvmTest/kotlin/io/github/nomisrev/openapi/OpenAPISerializerEnd2EndTest.kt +++ b/parser/src/jvmTest/kotlin/io/github/nomisrev/openapi/OpenAPISerializerEnd2EndTest.kt @@ -1,12 +1,12 @@ -//package io.github.nomisrev.openapi +// package io.github.nomisrev.openapi // -//import io.github.nomisrev.openapi.OpenAPI.Companion.Json -//import java.io.BufferedReader -//import kotlin.test.Ignore -//import kotlin.test.Test -//import kotlin.test.assertEquals +// import io.github.nomisrev.openapi.OpenAPI.Companion.Json +// import java.io.BufferedReader +// import kotlin.test.Ignore +// import kotlin.test.Test +// import kotlin.test.assertEquals // -//class OpenAPISerializerEnd2EndTest { +// class OpenAPISerializerEnd2EndTest { // // fun resourceText(path: String): String = // requireNotNull( @@ -104,4 +104,4 @@ // resourceText("securitySchemes_3_1_0.json"), // ) // } -//} +// } diff --git a/plugin-build/build.gradle.kts b/plugin-build/build.gradle.kts index 148c7d9..7f8d6cf 100644 --- a/plugin-build/build.gradle.kts +++ b/plugin-build/build.gradle.kts @@ -1,29 +1,14 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { id(libs.plugins.jvm.get().pluginId) `java-gradle-plugin` id(libs.plugins.publish.get().pluginId) } -repositories { - mavenCentral() -} - dependencies { implementation(libs.stdlib) implementation(libs.gradle) implementation(projects.generation) -} - -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - -tasks.withType { - compilerOptions.jvmTarget.set(JvmTarget.JVM_1_8) + testImplementation(libs.test) } gradlePlugin { diff --git a/plugin-build/src/main/java/io/github/nomisrev/openapi/plugin/GenerateClientTask.kt b/plugin-build/src/main/java/io/github/nomisrev/openapi/plugin/GenerateClientTask.kt index 611863c..c10596e 100644 --- a/plugin-build/src/main/java/io/github/nomisrev/openapi/plugin/GenerateClientTask.kt +++ b/plugin-build/src/main/java/io/github/nomisrev/openapi/plugin/GenerateClientTask.kt @@ -1,12 +1,10 @@ package io.github.nomisrev.openapi.plugin -import io.github.nomisrev.openapi.generateClient -import okio.FileSystem +import io.github.nomisrev.openapi.generate import org.gradle.api.DefaultTask import org.gradle.api.file.RegularFileProperty import org.gradle.api.plugins.BasePlugin import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.options.Option @@ -17,17 +15,25 @@ abstract class GenerateClientTask : DefaultTask() { } @get:InputFile - @get:Option(option = "spec", description = "The OpenAPI json file. Future will be supported soon.") + @get:Option( + option = "spec", + description = "The OpenAPI json file. Future will be supported soon." + ) abstract val spec: RegularFileProperty - @get:OutputFile - abstract val outputDir: RegularFileProperty - @TaskAction fun sampleAction() { - val specPath = requireNotNull(spec.orNull?.asFile?.toPath()?.toString()) { - "No OpenAPI Specification specified. Please provide a spec file." - } - FileSystem.SYSTEM.generateClient(specPath) + val specPath = + requireNotNull(spec.orNull?.asFile?.toPath()?.toString()) { + "No OpenAPI Specification specified. Please provide a spec file." + } + val output = + project.layout.buildDirectory + .dir("generated/openapi/src/commonMain/kotlin") + .get() + .asFile + .also { it.mkdirs() } + .path + generate(specPath, output) } } diff --git a/plugin-build/src/main/java/io/github/nomisrev/openapi/plugin/OpenAPIPlugin.kt b/plugin-build/src/main/java/io/github/nomisrev/openapi/plugin/OpenAPIPlugin.kt index 9dd14e6..208566d 100644 --- a/plugin-build/src/main/java/io/github/nomisrev/openapi/plugin/OpenAPIPlugin.kt +++ b/plugin-build/src/main/java/io/github/nomisrev/openapi/plugin/OpenAPIPlugin.kt @@ -4,14 +4,11 @@ import org.gradle.api.Plugin import org.gradle.api.Project abstract class OpenAPIPlugin : Plugin { - override fun apply(project: Project) { - val extension = project - .extensions - .create("openApiConfig", OpenApiConfig::class.java, project) + override fun apply(project: Project) { + val extension = project.extensions.create("openApiConfig", OpenApiConfig::class.java, project) - project.tasks.register("generateOpenApiClient", GenerateClientTask::class.java) { - it.spec.set(extension.spec) - it.outputDir.set(extension.output) - } + project.tasks.register("generateOpenApiClient", GenerateClientTask::class.java) { + it.spec.set(extension.spec) } + } } diff --git a/plugin-build/src/main/java/io/github/nomisrev/openapi/plugin/OpenApiConfig.kt b/plugin-build/src/main/java/io/github/nomisrev/openapi/plugin/OpenApiConfig.kt index eabb53a..7862e7b 100644 --- a/plugin-build/src/main/java/io/github/nomisrev/openapi/plugin/OpenApiConfig.kt +++ b/plugin-build/src/main/java/io/github/nomisrev/openapi/plugin/OpenApiConfig.kt @@ -1,19 +1,12 @@ package io.github.nomisrev.openapi.plugin +import javax.inject.Inject import org.gradle.api.Project import org.gradle.api.file.RegularFileProperty -import javax.inject.Inject - -const val DEFAULT_OUTPUT_DIR = "generated" @Suppress("UnnecessaryAbstractClass") abstract class OpenApiConfig @Inject constructor(project: Project) { private val objects = project.objects - val spec: RegularFileProperty = - objects.fileProperty() - - val output: RegularFileProperty = - objects.fileProperty() - .convention(project.layout.buildDirectory.file(DEFAULT_OUTPUT_DIR)) + val spec: RegularFileProperty = objects.fileProperty() } diff --git a/plugin-build/src/test/java/io/github/nomisrev/openapi/plugin/TemplatePluginTest.kt b/plugin-build/src/test/java/io/github/nomisrev/openapi/plugin/TemplatePluginTest.kt index 8428dec..e242eb3 100644 --- a/plugin-build/src/test/java/io/github/nomisrev/openapi/plugin/TemplatePluginTest.kt +++ b/plugin-build/src/test/java/io/github/nomisrev/openapi/plugin/TemplatePluginTest.kt @@ -1,39 +1,34 @@ package io.github.nomisrev.openapi.plugin -import org.gradle.testfixtures.ProjectBuilder -import org.junit.Assert.assertEquals -import org.junit.Test import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import org.gradle.testfixtures.ProjectBuilder class TemplatePluginTest { - @Test - fun `plugin is applied correctly to the project`() { - val project = ProjectBuilder.builder().build() - project.pluginManager.apply("io.github.nomisrev.openapi.plugin") - assert(project.tasks.getByName("generateOpenApiClient") is GenerateClientTask) - } + @Test + fun `plugin is applied correctly to the project`() { + val project = ProjectBuilder.builder().build() + project.pluginManager.apply("io.github.nomisrev.openapi.plugin") + assert(project.tasks.getByName("generateOpenApiClient") is GenerateClientTask) + } - @Test - fun `extension openApiConfig is created correctly`() { - val project = ProjectBuilder.builder().build() - project.pluginManager.apply("io.github.nomisrev.openapi.plugin") - assert(project.extensions.getByName("openApiConfig") is OpenApiConfig) - } + @Test + fun `extension openApiConfig is created correctly`() { + val project = ProjectBuilder.builder().build() + project.pluginManager.apply("io.github.nomisrev.openapi.plugin") + assert(project.extensions.getByName("openApiConfig") is OpenApiConfig) + } - @Test - fun `parameters are passed correctly from extension to task`() { - val project = ProjectBuilder.builder().build() - project.pluginManager.apply("io.github.nomisrev.openapi.plugin") - val input = File(project.projectDir, "input.tmp") - val out = File(project.projectDir, "ouput.tmp") - (project.extensions.getByName("openApiConfig") as OpenApiConfig).apply { - spec.set(input) - output.set(out) - } + @Test + fun `parameters are passed correctly from extension to task`() { + val project = ProjectBuilder.builder().build() + project.pluginManager.apply("io.github.nomisrev.openapi.plugin") + val input = File(project.projectDir, "input.tmp") + (project.extensions.getByName("openApiConfig") as OpenApiConfig).apply { spec.set(input) } - val task = project.tasks.getByName("generateOpenApiClient") as GenerateClientTask + val task = project.tasks.getByName("generateOpenApiClient") as GenerateClientTask - assertEquals(input, task.spec.get().asFile) - assertEquals(out, task.outputDir.get().asFile) - } + assertEquals(input, task.spec.get().asFile) + } }