Skip to content

Commit

Permalink
Finished model generation
Browse files Browse the repository at this point in the history
  • Loading branch information
nomisRev committed Apr 11, 2024
1 parent e740885 commit 27e3d6d
Show file tree
Hide file tree
Showing 11 changed files with 445 additions and 434 deletions.
5 changes: 4 additions & 1 deletion generation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ configure<com.bnorm.power.PowerAssertGradleExtension> {
kotlin {
// TODO re-enable platforms after finishing core / generation
// Not worth dealing with all extra platforms during initial phase
explicitApi()
// explicitApi()
jvm()
macosArm64 {
binaries {
Expand All @@ -21,7 +21,10 @@ kotlin {

sourceSets {
commonMain {
kotlin.srcDir(project.file("build/generated/openapi/src/commonMain/kotlin"))

dependencies {
implementation("net.pearx.kasechange:kasechange:1.4.1")
implementation(project(":core"))
implementation("com.squareup.okio:okio:3.9.0")
// for build debugging example
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import io.github.nomisrev.openapi.Route.Body
import io.github.nomisrev.openapi.Route.Param
import io.github.nomisrev.openapi.Route.ReturnType
import io.github.nomisrev.openapi.Schema.Type
import io.github.nomisrev.openapi.test.KModel
import io.github.nomisrev.openapi.test.typeName
import kotlin.collections.List
import kotlin.jvm.JvmInline

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ public fun FileSystem.program(
}

createDirectories("$generationPath/models".toPath())
file(
"predef",
setOf(
Model.Import("kotlin.reflect", "KClass"),
Model.Import("kotlinx.serialization", "SerializationException"),
Model.Import("kotlinx.serialization.json", "JsonElement"),
),
predef
)
// file(
// "predef",
// setOf(
// Model.Import("kotlin.reflect", "KClass"),
// Model.Import("kotlinx.serialization", "SerializationException"),
// Model.Import("kotlinx.serialization.json", "JsonElement"),
// ),
// predef
// )

val rawSpec = source(pathSpec.toPath()).buffer().use(BufferedSource::readUtf8)
val openAPI = OpenAPI.fromJson(rawSpec)
Expand All @@ -55,53 +55,3 @@ public fun FileSystem.program(
file(model.typeName, model.imports(), model.toKotlinCode(0))
}
}

// TODO include nicer message about expected format
private val predef = """
class OneOfSerializationException(
val payload: JsonElement,
val errors: Map<KClass<*>, 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 <A> attemptDeserialize(
json: JsonElement,
vararg block: Pair<KClass<*>, (json: JsonElement) -> A>
): A {
val errors = linkedMapOf<KClass<*>, SerializationException>()
block.forEach { (kclass, f) ->
try {
return f(json)
} catch (e: SerializationException) {
errors[kclass] = e
}
}
throw OneOfSerializationException(json, errors)
}
""".trimIndent()
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package io.github.nomisrev.openapi

internal fun String.toPascalCase(): String {
val words = split("_", "-")
val words = split("_", "-", ".")
return when (words.size) {
1 -> words[0].capitalize()
else -> buildString {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,51 +1,6 @@
package io.github.nomisrev.openapi.test

import io.github.nomisrev.openapi.OpenAPI
import kotlinx.serialization.Serializable
import okio.BufferedSink
import okio.BufferedSource
import okio.FileSystem
import okio.Path.Companion.toPath
import okio.buffer
import okio.use

private fun BufferedSink.writeUtf8Line(line: String) {
writeUtf8("$line\n")
}

private fun BufferedSink.writeUtf8Line() {
writeUtf8("\n")
}

public fun FileSystem.test(
pathSpec: String,
`package`: String = "io.github.nomisrev.openapi",
modelPackage: String = "$`package`.models",
generationPath: String =
"build/generated/openapi/src/commonMain/kotlin/${`package`.replace(".", "/")}"
) {
fun file(name: String, imports: Set<String>, code: String) {
write("$generationPath/models/$name.kt".toPath()) {
writeUtf8Line("package $modelPackage")
writeUtf8Line()
// if (imports.isNotEmpty()) {
// writeUtf8Line(imports.joinToString("\n") { "import ${it.`package`}.${it.typeName}" })
// writeUtf8Line()
// }
writeUtf8Line(code)
}
}

deleteRecursively(generationPath.toPath())
createDirectories("$generationPath/models".toPath())
val rawSpec = source(pathSpec.toPath()).buffer().use(BufferedSource::readUtf8)
val openAPI = OpenAPI.fromJson(rawSpec)
openAPI.models().forEach { model ->
file(model.typeName(), setOf(), template { toCode(model) })
}
}

public data class TopLevel(val key: String, val model: KModel)

/**
* Our own "Generated" oriented KModel.
Expand Down Expand Up @@ -131,6 +86,8 @@ public sealed interface KModel {
public data class Enum(
val simpleName: String,
val inner: KModel,
val values: List<String>,
) : KModel
val values: List<Entry>,
) : KModel {
public data class Entry(val rawName: String, val simpleName: String)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import io.github.nomisrev.openapi.isValidClassname
public fun Template.toCode(code: KModel) {
when (code) {
KModel.Binary -> Unit
is KModel.Collection -> Unit
is KModel.Collection -> toCode(code.value)
KModel.JsonObject -> Unit
is KModel.Primitive -> Unit
is KModel.Enum -> toCode(code)
Expand All @@ -20,16 +20,14 @@ public fun Template.Serializable() {
}

public fun Template.toCode(enum: KModel.Enum) {
fun Template.serialNameEnumCase(name: String) {
addImport("kotlinx.serialization.SerialName")
append("@SerialName(\"$name\") `$name`(\"$name\")")
}

Serializable()
block("enum class ${enum.simpleName} {") {
enum.values.indented(separator = ",\n", postfix = ";\n") {
if (it.isValidClassname()) append(it)
else serialNameEnumCase(it)
val isSimple = enum.values.all { it.rawName == it.simpleName && it.rawName.isValidClassname() }
val constructor = if (isSimple) "" else "(val value: ${enum.inner.typeName()})"
if (!isSimple) addImport("kotlinx.serialization.SerialName")
block("enum class ${enum.simpleName}$constructor {") {
enum.values.indented(separator = ",\n", postfix = ";\n") { entry ->
if (isSimple) append(entry.rawName)
else append("@SerialName(\"${entry.rawName}\") ${entry.simpleName}(\"${entry.rawName}\")")
}
}
}
Expand All @@ -53,18 +51,29 @@ public fun Template.description(obj: KModel.Object) {
}
}

public fun Template.addImports(obj: KModel.Object) =
obj.properties.forEach { p ->
when (p.type) {
KModel.Binary -> addImport("io.FileUpload")
KModel.JsonObject -> addImport("kotlinx.serialization.json.JsonElement")
else -> Unit
}
}

public fun Template.toCode(obj: KModel.Object) {
fun properties() =
obj.properties.indented(separator = ",\n") { append(it.toCode()) }

fun nested() =
indented { obj.inline.indented(prefix = " {\n", postfix = "}") { toCode(it) } }

// description(obj)
addImports(obj)
Serializable()
+"data class ${obj.simpleName}("
obj.properties.indented(separator = ",\n") { append(it.toCode()) }
append(")")
indented {
obj.inline.indented(
prefix = " {\n",
postfix = "}"
) { toCode(it) }
}
properties()
+")"
nested()
}

public fun KModel.Object.Property.toCode(): String {
Expand All @@ -77,10 +86,10 @@ public fun KModel.Object.Property.toCode(): String {
public fun KModel.typeName(): String =
when (this) {
KModel.Binary -> "FileUpload"
is KModel.Collection.List -> "List<${value.typeName()}>"
is KModel.Collection.Map -> "Map<${key.typeName()}, ${value.typeName()}>"
is KModel.Collection.Set -> "Set<${value.typeName()}>"
KModel.JsonObject -> "JsonObject"
KModel.JsonObject -> "JsonElement"
is KModel.Collection.List -> "List<${this@typeName.value.typeName()}>"
is KModel.Collection.Map -> "Map<${this@typeName.key.typeName()}, ${this@typeName.value.typeName()}>"
is KModel.Collection.Set -> "Set<${this@typeName.value.typeName()}>"
is KModel.Primitive -> name
is KModel.Enum -> simpleName
is KModel.Object -> simpleName
Expand All @@ -93,7 +102,7 @@ public fun Template.toCode(union: KModel.Union) {
block("sealed interface ${union.simpleName} {") {
union.schemas.joinTo {
+"@JvmInline"
+"value class ${it.caseName}(val value: ${it.caseName}): ${union.simpleName}"
+"value class ${it.caseName}(val value: ${it.model.typeName()}): ${union.simpleName}"
}
block("object Serializer : KSerializer<${union.simpleName}> {") {
+"@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)"
Expand Down Expand Up @@ -131,18 +140,22 @@ private fun Template.serializer(model: KModel): String =
addImport("kotlinx.serialization.builtins.ListSerializer")
"ListSerializer(${serializer(model.value)})"
}

is KModel.Collection.Map -> {
addImport("kotlinx.serialization.builtins.MapSerializer")
"MapSerializer(${serializer(model.key)}, ${serializer(model.value)})"
}

is KModel.Collection.Set -> {
addImport("kotlinx.serialization.builtins.SetSerializer")
"SetSerializer(${serializer(model.value)})"
}

is KModel.Primitive -> {
addImport("kotlinx.serialization.builtins.serializer")
"${model.name}.serializer()"
}

is KModel.Enum -> "${model.simpleName}.serializer()"
is KModel.Object -> "${model.simpleName}.serializer()"
is KModel.Union -> "${model.simpleName}.serializer()"
Expand All @@ -155,9 +168,11 @@ private fun Template.serializer(model: KModel): String =

public fun Template.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",
Expand All @@ -168,17 +183,56 @@ public fun Template.unionImports() {
)
}

//import kotlin.jvm.JvmInline
//import kotlinx.serialization.Serializable
//import kotlinx.serialization.KSerializer
//import kotlinx.serialization.InternalSerializationApi
//import kotlinx.serialization.ExperimentalSerializationApi
//import kotlinx.serialization.builtins.ListSerializer
//import kotlinx.serialization.builtins.serializer
//import kotlinx.serialization.descriptors.PolymorphicKind
//import kotlinx.serialization.descriptors.SerialDescriptor
//import kotlinx.serialization.descriptors.buildSerialDescriptor
//import kotlinx.serialization.encoding.Decoder
//import kotlinx.serialization.encoding.Encoder
//import kotlinx.serialization.json.Json
//import kotlinx.serialization.json.JsonElement
// TODO include nicer message about expected format
internal val predef: String = """
import kotlin.reflect.KClass
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonElement
class OneOfSerializationException(
val payload: JsonElement,
val errors: Map<KClass<*>, 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 <A> attemptDeserialize(
json: JsonElement,
vararg block: Pair<KClass<*>, (json: JsonElement) -> A>
): A {
val errors = linkedMapOf<KClass<*>, SerializationException>()
block.forEach { (kclass, f) ->
try {
return f(json)
} catch (e: SerializationException) {
errors[kclass] = e
}
}
throw OneOfSerializationException(json, errors)
}
""".trimIndent()
Loading

0 comments on commit 27e3d6d

Please sign in to comment.