Skip to content

Commit

Permalink
Add first tests for the typed module (#53)
Browse files Browse the repository at this point in the history
  • Loading branch information
nomisRev committed Jul 9, 2024
1 parent 8884bc9 commit 3fb350a
Show file tree
Hide file tree
Showing 16 changed files with 3,039 additions and 86 deletions.
14 changes: 4 additions & 10 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,9 @@ jobs:

- uses: gradle/actions/setup-gradle@v3

- run: ./gradlew build --full-stacktrace
- run: ./gradlew build koverXmlReport --full-stacktrace

- name: Bundle the build report
if: failure()
run: find . -type d -name 'reports' | zip -@ -r build-reports.zip

- name: Upload the build report
if: failure()
uses: actions/upload-artifact@master
- uses: codecov/codecov-action@v4
with:
name: error-report
path: build-reports.zip
token: ${{ secrets.CODECOV_TOKEN }}
files: build/reports/kover/report.xml
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -216,5 +216,7 @@ gradle-app.setting

#Kotlin
.kotlin
kotlin-js-store
**/kotlin-js-store/

# End of https://www.toptal.com/developers/gitignore/api/macos,windows,gradle,kotlin,java,intellij+all
12 changes: 12 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import com.diffplug.gradle.spotless.SpotlessExtension
import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
Expand All @@ -10,13 +11,24 @@ plugins {
alias(libs.plugins.assert)
alias(libs.plugins.spotless)
alias(libs.plugins.dokka)
alias(libs.plugins.kover)
}

val assertId = libs.plugins.assert.get().pluginId
val spotlessId = libs.plugins.spotless.get().pluginId
val publishId = libs.plugins.publish.get().pluginId
val koverId = libs.plugins.kover.get().pluginId


dependencies {
kover(projects.parser)
kover(projects.typed)
kover(projects.generation)
}

subprojects {
apply(plugin = koverId)

apply(plugin = assertId)
@Suppress("OPT_IN_USAGE")
configure<PowerAssertGradleExtension> {
Expand Down
1 change: 1 addition & 0 deletions example/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -217,5 +217,6 @@ gradle-app.setting
#Kotlin
.kotlin
kotlin-js-store
**/kotlin-js-store/

# End of https://www.toptal.com/developers/gitignore/api/macos,windows,gradle,kotlin,java,intellij+all
2,052 changes: 2,052 additions & 0 deletions example/kotlin-js-store/yarn.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[versions]
dokka = "1.9.20"
kotlin = "2.0.20-Beta1"
kotlin = "2.0.0"
kover = "0.8.2"
knit = "0.5.0"
spotless="6.25.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive

public typealias DefaultValue = ExampleValue

@Serializable(with = ExampleValue.Companion.Serializer::class)
public sealed interface ExampleValue {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public data class Schema(
* 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 default: DefaultValue? = null,
val type: Type? = null,
val format: String? = null,
val items: ReferenceOr<Schema>? = null,
Expand Down
1 change: 1 addition & 0 deletions typed/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ kotlin {
api(libs.ktor.client)
}
}
commonTest { dependencies { implementation(libs.test) } }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ data class Route(
data class Returns(
val types: Map<HttpStatusCode, ReturnType>,
val extensions: Map<String, JsonElement>
) : Map<HttpStatusCode, ReturnType> by types
) : Map<HttpStatusCode, ReturnType> by types {
constructor(
vararg types: Pair<HttpStatusCode, ReturnType>,
extensions: Map<String, JsonElement> = emptyMap()
) : this(types.toMap(), extensions)
}

// Required, isNullable ???
data class ReturnType(val type: Model, val extensions: Map<String, JsonElement>)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,32 +178,64 @@ private class OpenAPITransformer(private val openAPI: OpenAPI) {
value.oneOf!![0].resolve().toModel(context).value
schema.anyOf != null -> schema.toUnion(context, schema.anyOf!!)
// oneOf + properties => oneOf requirements: 'propA OR propB is required'.
schema.oneOf != null && schema.properties != null -> schema.asObject(context)
schema.oneOf != null && schema.properties != null -> schema.toObject(context)
schema.oneOf != null -> schema.toUnion(context, schema.oneOf!!)
schema.allOf != null -> allOf(schema, context)
schema.enum != null -> schema.toEnum(context, schema.enum.orEmpty())
schema.properties != null -> schema.asObject(context)
// If no values, properties, or schemas, were found, lets check the types
schema.type != null -> schema.type(context, schema.type!!)
else -> TODO("Schema: $schema not yet supported. Please report to issue tracker.")
else -> schema.type(context)
}

return when (this) {
is Resolved.Ref -> Resolved.Ref(name, model)
is Resolved.Value -> Resolved.Value(model)
}
}

private tailrec fun Schema.type(context: NamingContext): Model =
when (val type = type) {
is Type.Array ->
when (val single = type.types.singleOrNull()) {
null -> {
require(type.types.isNotEmpty()) { "Array type requires types to be defined. $this" }
Model.Union(
context = context,
cases =
type.types.sorted().map { t ->
val resolved = Resolved.Value(Schema(type = t))
Model.Union.Case(context, resolved.toModel(context).value)
},
default = null,
description = description,
inline = emptyList()
)
}
else -> copy(type = single).type(context)
}
is Type.Basic ->
when (type) {
Type.Basic.Array -> collection(context)
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.String ->
if (format == "binary") Model.OctetStream(description)
else Primitive.String(default?.toString(), description)
Type.Basic.Object -> toObject(context)
Type.Basic.Null -> TODO("Schema.Type.Basic.Null")
}
null -> TODO("Schema: $this not yet supported. Please report to issue tracker.")
}

fun Schema.isOpenEnumeration(): Boolean =
anyOf != null &&
anyOf!!.size == 2 &&
anyOf?.size == 2 &&
anyOf!!.count { it.resolve().value.enum != null } == 1 &&
anyOf!!.count { it.resolve().value.type == Type.Basic.String } == 2

fun ReferenceOr<Schema>.resolve(): Resolved<Schema> =
when (this) {
is ReferenceOr.Value -> Resolved.Value(value)
is ReferenceOr.Reference -> {
val name = ref.drop(schemaRef.length)
val name = ref.drop("#/components/schemas/".length)
val schema =
requireNotNull(openAPI.components.schemas[name]) {
"Schema $name could not be found in ${openAPI.components.schemas}. Is it missing?"
Expand Down Expand Up @@ -261,23 +293,32 @@ private class OpenAPITransformer(private val openAPI: OpenAPI) {
}
}

private fun Schema.asObject(context: NamingContext): Model =
private fun Schema.toObject(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).value, description)
when (val props = additionalProperties!!) {
// TODO: implement Schema validation
is AdditionalProperties.PSchema -> Model.FreeFormJson(description)
is Allowed ->
if (aProps.value) Model.FreeFormJson(description)
if (props.value) Model.FreeFormJson(description)
else
throw IllegalStateException(
"Illegal State: No additional properties allowed on empty object."
"No additional properties allowed on object without properties. $this"
)
}
else -> Model.FreeFormJson(description)
}

/**
* allOf defines an object that is a combination of all the defined allOf schemas. For example: an
* object with age, and name + an object with id == an object with age, name and id.
*
* This is still a WIP. We need to implement a more fine-grained approach to combining schemas,
* such that we can generate the most idiomatic Kotlin code in all cases. Different results are
* likely desired, depending on what kind of schemas need to be comibined. Simple products, or
* more complex combinations including oneOf, anyOf, etc.
*/
private fun allOf(schema: Schema, context: NamingContext): Model {
val allOf = schema.allOf!!.map { it.resolve() }
val ref = allOf.singleOrNull { it is Resolved.Ref && it.value.type == Type.Basic.Object }
Expand Down Expand Up @@ -306,12 +347,17 @@ private class OpenAPITransformer(private val openAPI: OpenAPI) {
)
.toObject(context, properties)
}
(schema.additionalProperties as? Allowed)?.value == true ->
Model.FreeFormJson(schema.description)
else -> schema.toUnion(context, schema.allOf!!)
}
}

fun Schema.toObject(context: NamingContext, properties: Map<String, ReferenceOr<Schema>>): Model =
Model.Object(
fun Schema.toObject(context: NamingContext, properties: Map<String, ReferenceOr<Schema>>): Model {
require((additionalProperties as? Allowed)?.value != true) {
"Additional properties, on a schema with properties, are not yet supported."
}
return Model.Object(
context,
description,
properties.map { (name, ref) ->
Expand Down Expand Up @@ -344,6 +390,7 @@ private class OpenAPITransformer(private val openAPI: OpenAPI) {
nestedModel(resolved, pContext)
}
)
}

private fun Schema.singleDefaultOrNull(): String? = (default as? ExampleValue.Single)?.value

Expand All @@ -353,24 +400,6 @@ private class OpenAPITransformer(private val openAPI: OpenAPI) {
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 })
Expand All @@ -380,8 +409,11 @@ private class OpenAPITransformer(private val openAPI: OpenAPI) {
is ExampleValue.Single -> {
val value = example.value
when {
// Translate empty JS array to empty list
value == "[]" -> emptyList()
value.equals("null", ignoreCase = true) -> emptyList()
// 'null' for a non-nullable collection becomes an empty list
value.equals("null", ignoreCase = true) ->
if (nullable == true) listOf("null") else emptyList()
else -> listOf(value)
}
}
Expand All @@ -391,27 +423,6 @@ private class OpenAPITransformer(private val openAPI: OpenAPI) {
else Collection.List(inner.value, 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).value)
},
null,
description,
emptyList()
)
}
is Type.Basic -> toPrimitive(context, type)
}

fun Schema.toEnum(context: NamingContext, enums: List<String>): Enum.Closed {
require(enums.isNotEmpty()) { "Enum requires at least 1 possible value" }
/* To resolve the inner type, we erase the enum values.
Expand Down Expand Up @@ -451,7 +462,10 @@ private class OpenAPITransformer(private val openAPI: OpenAPI) {
subtypes: List<ReferenceOr<Schema>>
): Model.Union {
val caseToContext =
subtypes.associate { ref -> Pair(ref.resolve(), toUnionCaseContext(context, ref)) }
subtypes.associate { ref ->
val resolved = ref.resolve()
Pair(resolved, toUnionCaseContext(context, resolved))
}
val cases =
caseToContext
.map { (resolved, caseContext) ->
Expand All @@ -470,17 +484,10 @@ private class OpenAPITransformer(private val openAPI: OpenAPI) {
)
}

fun NamingContext.isTop(name: String): Boolean =
when (this) {
is Named -> this.name == name
is NamingContext.Nested -> outer.isTop(name)
else -> false
}

fun toUnionCaseContext(context: NamingContext, case: ReferenceOr<Schema>): NamingContext =
fun toUnionCaseContext(context: NamingContext, case: Resolved<Schema>): NamingContext =
when (case) {
is ReferenceOr.Reference -> Named(case.ref.drop(schemaRef.length))
is ReferenceOr.Value ->
is Resolved.Ref -> Named(case.name)
is Resolved.Value ->
when {
context is Named && case.value.type == Type.Basic.String && case.value.enum != null ->
NamingContext.Nested(
Expand All @@ -502,10 +509,11 @@ private class OpenAPITransformer(private val openAPI: OpenAPI) {
?: TODO("Name Generated for inline objects of unions not yet supported."),
context
)
context is NamingContext.Nested &&
case.value.type == Type.Basic.Array &&
case.value.items?.valueOrNull()?.oneOf != null ->
NamingContext.Nested(Named("Array"), context)
case.value.type == Type.Basic.Array ->
case.value.items
?.resolve()
?.namedOr { if (case.value.uniqueItems == true) Named("Set") else Named("List") }
?.let { NamingContext.Nested(it, context) } ?: context
else -> context
}
}
Expand Down Expand Up @@ -686,5 +694,3 @@ private class OpenAPITransformer(private val openAPI: OpenAPI) {
}
}
}

internal const val schemaRef = "#/components/schemas/"
Loading

0 comments on commit 3fb350a

Please sign in to comment.