From 50a034a23d0e038a29f7e79edc3e5049ef66ab8c Mon Sep 17 00:00:00 2001 From: Simon Vergauwen Date: Mon, 15 Jul 2024 21:20:20 +0200 Subject: [PATCH] Data class property validation (#74) --- example/build.gradle.kts | 2 +- .../io/github/nomisrev/openapi/Models.kt | 177 +++++-- .../io/github/nomisrev/openapi/ApiTest.kt | 25 - .../nomisrev/openapi/ConstraintsTest.kt | 9 - .../openapi/DataClassConstraintsTest.kt | 478 ++++++++++++++++++ .../io/github/nomisrev/openapi/EnumTest.kt | 47 ++ .../io/github/nomisrev/openapi/ModelTest.kt | 48 -- .../io/github/nomisrev/openapi/TestSyntax.kt | 63 +++ .../io/github/nomisrev/openapi/Constraints.kt | 4 +- .../nomisrev/openapi/OpenAPITransformer.kt | 10 +- .../github/nomisrev/openapi/ConstraintTest.kt | 2 +- 11 files changed, 737 insertions(+), 128 deletions(-) create mode 100644 generation/src/test/kotlin/io/github/nomisrev/openapi/DataClassConstraintsTest.kt create mode 100644 generation/src/test/kotlin/io/github/nomisrev/openapi/EnumTest.kt create mode 100644 generation/src/test/kotlin/io/github/nomisrev/openapi/TestSyntax.kt diff --git a/example/build.gradle.kts b/example/build.gradle.kts index fe334b8..14fa9c6 100644 --- a/example/build.gradle.kts +++ b/example/build.gradle.kts @@ -1,7 +1,7 @@ plugins { kotlin("multiplatform") version "2.0.0" kotlin("plugin.serialization") version "2.0.0" - id("io.github.nomisrev.openapi-kt-plugin") version "0.0.6" + id("io.github.nomisrev.openapi-kt-plugin") version "0.0.7" } openApiConfig { spec("OpenAI", file("openai.yaml")) { diff --git a/generation/src/main/kotlin/io/github/nomisrev/openapi/Models.kt b/generation/src/main/kotlin/io/github/nomisrev/openapi/Models.kt index 48c833e..fd13ee7 100644 --- a/generation/src/main/kotlin/io/github/nomisrev/openapi/Models.kt +++ b/generation/src/main/kotlin/io/github/nomisrev/openapi/Models.kt @@ -1,3 +1,5 @@ +@file:Suppress("UNUSED_VARIABLE") + package io.github.nomisrev.openapi import com.squareup.kotlinpoet.AnnotationSpec @@ -247,45 +249,115 @@ private fun Model.Object.toTypeSpec(): TypeSpec = properties.requirement() } -data class Requirement(val predicate: String, val message: String) +data class Requirement(val prop: Model.Object.Property, val predicate: String, val message: String) context(OpenAPIContext) private fun Iterable.requirements(): List = - mapNotNull { property -> + flatMap { property -> when (val model = property.model) { is Model.Enum, is Model.Primitive.Boolean, is Model.OctetStream, is Model.Primitive.Unit, is Model.Union, - is Model.Object -> null + is Model.Object -> emptyList() is Collection -> { - val constraint = model.constraint ?: return@mapNotNull null + val constraint = model.constraint ?: return@flatMap emptyList() val paramName = toParamName(Named(property.baseName)) - val predicate = "$paramName.size in ${constraint.minItems}..${constraint.maxItems}" - val message = - "$paramName should have between ${constraint.minItems} and ${constraint.maxItems} elements" - Requirement(predicate, message) + when (constraint.minItems) { + 0 -> + when (constraint.maxItems) { + Int.MAX_VALUE -> emptyList() + else -> + listOf( + Requirement( + property, + "$paramName.size <= ${constraint.maxItems}", + "$paramName should have at most ${constraint.maxItems} elements" + ) + ) + } + else -> + when (constraint.maxItems) { + Int.MAX_VALUE -> + listOf( + Requirement( + property, + "$paramName.size >= ${constraint.minItems}", + "$paramName should have at least ${constraint.minItems} elements" + ) + ) + else -> + listOf( + Requirement( + property, + "$paramName.size in ${constraint.minItems}..${constraint.maxItems}", + "$paramName should have between ${constraint.minItems} and ${constraint.maxItems} elements" + ) + ) + } + } } // TODO Implement Object constraints - is Model.FreeFormJson -> null - is Model.Primitive.Double -> { - val constraint = model.constraint ?: return@mapNotNull null - property.numberRequirement(constraint) { it } - } - is Model.Primitive.Int -> { - val constraint = model.constraint ?: return@mapNotNull null - property.intRequirement(constraint) - } - is Model.Primitive.String -> { - val constraint = model.constraint ?: return@mapNotNull null - val paramName = toParamName(Named(property.baseName)) - val predicate = "$paramName.length in ${constraint.minLength}..${constraint.maxLength}" - val message = - "$paramName should have a length between ${constraint.minLength} and ${constraint.maxLength}" - Requirement(predicate, message) - } + is Model.FreeFormJson -> emptyList() + is Model.Primitive.Double -> + when (val constraint = model.constraint) { + null -> emptyList() + else -> listOfNotNull(property.numberRequirement(constraint) { it }) + } + is Model.Primitive.Int -> + when (val constraint = model.constraint) { + null -> emptyList() + else -> listOfNotNull(property.intRequirement(constraint)) + } + is Model.Primitive.String -> + when (val constraint = model.constraint) { + null -> emptyList() + else -> { + val paramName = toParamName(Named(property.baseName)) + val lengthReq = + when (constraint.minLength) { + 0 -> + when (constraint.maxLength) { + Int.MAX_VALUE -> null + else -> + Requirement( + property, + "$paramName.${"length"} <= ${constraint.maxLength}", + "$paramName should have a ${"length"} of at most ${constraint.maxLength}" + ) + } + else -> + when (constraint.maxLength) { + Int.MAX_VALUE -> + Requirement( + property, + "$paramName.${"length"} >= ${constraint.minLength}", + "$paramName should have a ${"length"} of at least ${constraint.minLength}" + ) + else -> + Requirement( + property, + "$paramName.${"length"} in ${constraint.minLength}..${constraint.maxLength}", + "$paramName should have a ${"length"} between ${constraint.minLength} and ${constraint.maxLength}" + ) + } + } + val patternReq = + constraint.pattern?.let { pattern -> + val dollarEscaped = pattern.replace("$", "${'$'}") + // TODO Allow configuring ignoring incorrect regex + dollarEscaped.toRegex() + val tripleQ = "${'"'}${'"'}${'"'}" + val predicate = "$paramName.matches($tripleQ$dollarEscaped$tripleQ.toRegex())" + val message = "$paramName should match the pattern $dollarEscaped" + Requirement(property, predicate, message) + } + + listOfNotNull(lengthReq, patternReq) + } + } } } @@ -294,19 +366,25 @@ private fun Iterable.requirement() { val requirements = requirements() when (requirements.size) { 0 -> Unit - 1 -> { - val requirement = requirements.single() + 1 -> addInitializerBlock( - CodeBlock.of("require(%L) { %S }", requirement.predicate, requirement.message) + buildCodeBlock { + val r = requirements.single() + val nullable = + if (r.prop.isNullable) "if (${toParamName(Named(r.prop.baseName))} != null) " else "" + addStatement("$nullable require(%L) { %S }", r.predicate, r.message) + } ) - } else -> { addInitializerBlock( buildCodeBlock { addStatement("requireAll(") withIndent { - requirements.forEach { requirement -> - addStatement("{ require(%L) { %S } },", requirement.predicate, requirement.message) + requirements.forEach { r -> + val nullable = + if (r.prop.isNullable) "if (${toParamName(Named(r.prop.baseName))} != null) " + else "" + addStatement("{ $nullable require(%L) { %S } },", r.predicate, r.message) } } addStatement(")") @@ -317,33 +395,50 @@ private fun Iterable.requirement() { } context(OpenAPIContext) -private fun Model.Object.Property.intRequirement(constraint: Constraints.Number): Requirement = - if (!constraint.exclusiveMinimum) { +private fun Model.Object.Property.intRequirement(constraint: Constraints.Number): Requirement? = + if ( + constraint.maximum != Double.POSITIVE_INFINITY && constraint.minimum != Double.NEGATIVE_INFINITY + ) { val paramName = toParamName(Named(baseName)) val rangeTo = if (constraint.exclusiveMaximum) "..<" else ".." val minimum = constraint.minimum.toInt() val maximum = constraint.maximum.toInt() val predicate = "$paramName in $minimum$rangeTo$maximum" val maxM = if (constraint.exclusiveMaximum) "smaller then" else "smaller or equal to" - val message = "$paramName should be larger or equal to $minimum and should be $maxM ${maximum}" - Requirement(predicate, message) + val message = "$paramName should be larger or equal to $minimum and should be $maxM $maximum" + Requirement(this, predicate, message) } else numberRequirement(constraint) { it.toInt() } context(OpenAPIContext) private fun Model.Object.Property.numberRequirement( constraint: Constraints.Number, transform: (Double) -> Number -): Requirement { +): Requirement? { val paramName = toParamName(Named(baseName)) - val min = if (constraint.exclusiveMinimum) "<" else "<=" - val max = if (constraint.exclusiveMaximum) "<" else "<=" val minimum = transform(constraint.minimum) val maximum = transform(constraint.maximum) - val predicate = "$minimum $min $paramName && $paramName $max $maximum" + val min = if (constraint.exclusiveMinimum) "<" else "<=" + val max = if (constraint.exclusiveMaximum) "<" else "<=" val minM = if (constraint.exclusiveMinimum) "larger then" else "larger or equal to" val maxM = if (constraint.exclusiveMaximum) "smaller then" else "smaller or equal to" - val message = "$paramName should be $minM $minimum and should be $maxM ${maximum}" - return Requirement(predicate, message) + return when (constraint.minimum) { + Double.NEGATIVE_INFINITY -> + when (constraint.maximum) { + Double.POSITIVE_INFINITY -> null + else -> Requirement(this, "$paramName $max $maximum", "$paramName should be $maxM $maximum") + } + else -> + when (constraint.maximum) { + Double.POSITIVE_INFINITY -> + Requirement(this, "$minimum $min $paramName", "$paramName should be $minM $minimum") + else -> + Requirement( + this, + "$minimum $min $paramName && $paramName $max $maximum", + "$paramName should be $minM $minimum and should be $maxM ${maximum}" + ) + } + } } context(OpenAPIContext) diff --git a/generation/src/test/kotlin/io/github/nomisrev/openapi/ApiTest.kt b/generation/src/test/kotlin/io/github/nomisrev/openapi/ApiTest.kt index 00f30a1..9ac4f3d 100644 --- a/generation/src/test/kotlin/io/github/nomisrev/openapi/ApiTest.kt +++ b/generation/src/test/kotlin/io/github/nomisrev/openapi/ApiTest.kt @@ -2,13 +2,9 @@ package io.github.nomisrev.openapi -import com.tschuchort.compiletesting.JvmCompilationResult -import com.tschuchort.compiletesting.KotlinCompilation -import com.tschuchort.compiletesting.SourceFile import io.ktor.http.* import io.ktor.http.HttpMethod.Companion.Get import kotlin.test.Test -import kotlin.test.assertEquals import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi class ApiTest { @@ -63,24 +59,3 @@ class ApiTest { .compiles() } } - -fun API.compiles(): JvmCompilationResult { - val ctx = OpenAPIContext(GenerationConfig("", "", "io.test", "TestApi", true)) - val filesAsSources = - with(ctx) { - Root("TestApi", emptyList(), listOf(this@compiles)).toFileSpecs().map { - SourceFile.kotlin("${it.name}.kt", it.asCode()) - } - } - val result = - KotlinCompilation() - .apply { - val predef = SourceFile.kotlin("Predef.kt", with(ctx) { predef() }.asCode()) - sources = filesAsSources + predef - inheritClassPath = true - messageOutputStream = System.out - } - .compile() - assertEquals(result.exitCode, KotlinCompilation.ExitCode.OK) - return result -} diff --git a/generation/src/test/kotlin/io/github/nomisrev/openapi/ConstraintsTest.kt b/generation/src/test/kotlin/io/github/nomisrev/openapi/ConstraintsTest.kt index 76c040b..a3c4e2f 100644 --- a/generation/src/test/kotlin/io/github/nomisrev/openapi/ConstraintsTest.kt +++ b/generation/src/test/kotlin/io/github/nomisrev/openapi/ConstraintsTest.kt @@ -146,12 +146,3 @@ class ConstraintsTest { assertTrue(code.containsSingle(heightRequirements)) } } - -/** Check if every text in [texts] occurs only a single time in [this]. */ -private fun String.containsSingle(texts: List): Boolean = texts.all(::containsSingle) - -/** Check if [text] occurs only a single time in [this]. */ -private fun String.containsSingle(text: String): Boolean { - val indexOf = indexOf(text) - return indexOf != -1 && lastIndexOf(text) == indexOf -} diff --git a/generation/src/test/kotlin/io/github/nomisrev/openapi/DataClassConstraintsTest.kt b/generation/src/test/kotlin/io/github/nomisrev/openapi/DataClassConstraintsTest.kt new file mode 100644 index 0000000..83a778a --- /dev/null +++ b/generation/src/test/kotlin/io/github/nomisrev/openapi/DataClassConstraintsTest.kt @@ -0,0 +1,478 @@ +package io.github.nomisrev.openapi + +import io.github.nomisrev.openapi.Constraints.Collection +import io.github.nomisrev.openapi.Constraints.Number +import io.github.nomisrev.openapi.NamingContext.Named +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.jupiter.api.Test + +class DataClassConstraintsTest { + private fun prop(name: String, type: Model, isNullable: Boolean = false) = + Model.Object.Property( + name, + type, + isRequired = true, + isNullable = isNullable, + description = null + ) + + private fun id(constraint: Constraints.Text) = + prop("id", Model.Primitive.String(null, null, constraint)) + + val id = id(Constraints.Text(1, 10, null)) + + private val idRequirements = + listOf("id.length in 1..10", "id should have a length between 1 and 10") + + private val patternRequirements = + listOf( + "id.matches(\"\"\"[a-zA-Z0-9]+\"\"\".toRegex()))", + "id should match the pattern [a-zA-Z0-9]+" + ) + + private val age = + age( + Number( + maximum = 100.0, + minimum = 0.0, + exclusiveMaximum = false, + exclusiveMinimum = false, + multipleOf = null + ) + ) + + private fun age(constraints: Number) = prop("age", Model.Primitive.Int(null, null, constraints)) + + private val ageRequirements = + listOf( + "age in 0..100", + "age should be larger or equal to 0 and should be smaller or equal to 100" + ) + + private fun height(constraints: Number) = + prop("height", Model.Primitive.Double(null, "Height in cm", constraints)) + + private val height = + height( + Number( + maximum = 300.0, + minimum = 30.0, + exclusiveMaximum = false, + exclusiveMinimum = false, + multipleOf = null + ) + ) + + private val heightRequirements = + listOf( + "30.0 <= height && height <= 300.0", + "height should be larger or equal to 30.0 and should be smaller or equal to 300.0" + ) + + private fun tags(constraints: Collection) = + prop( + "tags", + Model.Collection.List(Model.Primitive.String(null, null, null), null, null, constraints) + ) + + private val tags = tags(Collection(minItems = 3, maxItems = 10)) + + private val tagsRequirements = + listOf("tags.size in 3..10", "tags should have between 3 and 10 elements") + + private fun categories(constraints: Collection) = + prop( + "categories", + Model.Collection.Set(Model.Primitive.String(null, null, null), null, null, constraints) + ) + + private val categories = categories(Collection(minItems = 3, maxItems = 10)) + + private val categoriesRequirements = + listOf("categories.size in 3..10", "categories should have between 3 and 10 elements") + + @Test + fun textMinAndMax() { + val code = Model.Object(Named("User"), null, listOf(id), listOf(id.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle(idRequirements)) + } + + @Test + fun textMin() { + val id = + prop("id", Model.Primitive.String(null, null, Constraints.Text(1, Int.MAX_VALUE, null))) + val code = Model.Object(Named("User"), null, listOf(id), listOf(id.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("id.length >= 1")) + assertTrue(code.containsSingle("id should have a length of at least 1")) + } + + @Test + fun singlePropNullable() { + val id = + prop( + "id", + Model.Primitive.String(null, null, Constraints.Text(1, Int.MAX_VALUE, null)), + isNullable = true + ) + val code = Model.Object(Named("User"), null, listOf(id), listOf(id.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("if (id != null)")) + assertTrue(code.containsSingle("id.length >= 1")) + assertTrue(code.containsSingle("id should have a length of at least 1")) + } + + @Test + fun textMax() { + val id = prop("id", Model.Primitive.String(null, null, Constraints.Text(0, 100, null))) + val code = Model.Object(Named("User"), null, listOf(id), listOf(id.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("id.length <= 100")) + assertTrue(code.containsSingle("id should have a length of at most 100")) + } + + @Test + fun textPattern() { + val id = + prop( + "id", + Model.Primitive.String(null, null, Constraints.Text(0, Int.MAX_VALUE, "[a-zA-Z0-9]+")) + ) + val code = Model.Object(Named("User"), null, listOf(id), listOf(id.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle(patternRequirements)) + } + + @Test + fun complexTextConstraints() { + val id = id(Constraints.Text(minLength = 1, maxLength = 10, pattern = "[a-zA-Z0-9]+")) + val code = Model.Object(Named("User"), null, listOf(id), listOf(id.model)).compiles() + assertTrue(code.containsSingle("requireAll")) + assertTrue(code.containsSingle(idRequirements + patternRequirements)) + } + + @Test + fun intOpenClosedMinMaxRange() { + val age = age(Number(false, 0.0, true, 100.0, null)) + val code = Model.Object(Named("User"), null, listOf(age), listOf(age.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("age in 0..<100")) + assertTrue( + code.containsSingle("age should be larger or equal to 0 and should be smaller then 100") + ) + } + + @Test + fun intClosedMinMaxRange() { + val code = Model.Object(Named("User"), null, listOf(age), listOf(age.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle(ageRequirements)) + } + + @Test + fun intMin() { + val age = age(Number(false, 0.0, false, Double.POSITIVE_INFINITY, null)) + val code = Model.Object(Named("User"), null, listOf(age), listOf(age.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("0 <= age")) + assertTrue(code.containsSingle("age should be larger or equal to 0")) + } + + @Test + fun intMax() { + val age = age(Number(false, Double.NEGATIVE_INFINITY, false, 100.0, null)) + val code = Model.Object(Named("User"), null, listOf(age), listOf(age.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("age <= 100")) + assertTrue(code.containsSingle("age should be smaller or equal to 100")) + } + + @Test + fun intOpenMinMax() { + val age = age(Number(true, Double.NEGATIVE_INFINITY, true, 100.0, null)) + val code = Model.Object(Named("User"), null, listOf(age), listOf(age.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("age < 100")) + assertTrue(code.containsSingle("age should be smaller then 100")) + } + + @Test + fun intOpenMinClosedMax() { + val age = age(Number(true, Double.NEGATIVE_INFINITY, false, 100.0, null)) + val code = Model.Object(Named("User"), null, listOf(age), listOf(age.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("age <= 100")) + assertTrue(code.containsSingle("age should be smaller or equal to 100")) + } + + @Test + fun intClosedMinOpenMax() { + val age = age(Number(false, Double.NEGATIVE_INFINITY, true, 100.0, null)) + val code = Model.Object(Named("User"), null, listOf(age), listOf(age.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("age < 100")) + assertTrue(code.containsSingle("age should be smaller then 100")) + } + + @Test + fun listMinMax() { + val code = Model.Object(Named("User"), null, listOf(tags), listOf(tags.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle(tagsRequirements)) + } + + @Test + fun listMin() { + val tags = tags(Collection(3, Int.MAX_VALUE)) + val code = Model.Object(Named("User"), null, listOf(tags), listOf(tags.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("tags.size >= 3")) + assertTrue(code.containsSingle("tags should have at least 3 elements")) + } + + @Test + fun listMax() { + val tags = tags(Collection(0, 100)) + val code = Model.Object(Named("User"), null, listOf(tags), listOf(tags.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("tags.size <= 100")) + assertTrue(code.containsSingle("tags should have at most 100 elements")) + } + + @Test + fun setMinMax() { + val code = + Model.Object(Named("User"), null, listOf(categories), listOf(categories.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle(categoriesRequirements)) + } + + @Test + fun setMin() { + val categories = categories(Collection(3, Int.MAX_VALUE)) + val code = + Model.Object(Named("User"), null, listOf(categories), listOf(categories.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("categories.size >= 3")) + assertTrue(code.containsSingle("categories should have at least 3 elements")) + } + + @Test + fun setMax() { + val categories = categories(Collection(0, 100)) + val code = + Model.Object(Named("User"), null, listOf(categories), listOf(categories.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("categories.size <= 100")) + assertTrue(code.containsSingle("categories should have at most 100 elements")) + } + + @Test + fun `double min lte max lte`() { + val code = Model.Object(Named("User"), null, listOf(height), listOf(height.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle(heightRequirements)) + } + + @Test + fun `double min lt max lte`() { + val height = height(Number(true, 0.0, false, 100.0, null)) + val code = Model.Object(Named("User"), null, listOf(height), listOf(height.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("0.0 < height && height <= 100.0")) + assertTrue( + code.containsSingle( + "height should be larger then 0.0 and should be smaller or equal to 100.0" + ) + ) + } + + @Test + fun `double min lte max lt`() { + val height = height(Number(false, 0.0, true, 100.0, null)) + val code = Model.Object(Named("User"), null, listOf(height), listOf(height.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("0.0 <= height && height < 100.0")) + assertTrue( + code.containsSingle( + "height should be larger or equal to 0.0 and should be smaller then 100.0" + ) + ) + } + + @Test + fun `double min lt max lt`() { + val height = height(Number(true, 0.0, true, 100.0, null)) + val code = Model.Object(Named("User"), null, listOf(height), listOf(height.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("0.0 < height && height < 100.0")) + assertTrue( + code.containsSingle("height should be larger then 0.0 and should be smaller then 100.0") + ) + } + + @Test + fun `double min lt (max open)`() { + val height = + height( + Number( + exclusiveMinimum = true, + minimum = 0.0, + exclusiveMaximum = false, + maximum = Double.POSITIVE_INFINITY, + multipleOf = null + ) + ) + val code = Model.Object(Named("User"), null, listOf(height), listOf(height.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("0.0 < height")) + assertTrue(code.containsSingle("height should be larger then 0.0")) + } + + @Test + fun `double min lte (max open)`() { + val height = + height( + Number( + exclusiveMinimum = false, + minimum = 0.0, + exclusiveMaximum = false, + maximum = Double.POSITIVE_INFINITY, + multipleOf = null + ) + ) + val code = Model.Object(Named("User"), null, listOf(height), listOf(height.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("0.0 <= height")) + assertTrue(code.containsSingle("height should be larger or equal to 0.0")) + } + + @Test + fun `double min lt (max closed)`() { + val height = + height( + Number( + exclusiveMinimum = true, + minimum = 0.0, + exclusiveMaximum = true, + maximum = Double.POSITIVE_INFINITY, + multipleOf = null + ) + ) + val code = Model.Object(Named("User"), null, listOf(height), listOf(height.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("0.0 < height")) + assertTrue(code.containsSingle("height should be larger then 0.0")) + } + + @Test + fun `double min lte (max closed)`() { + val height = + height( + Number( + exclusiveMinimum = false, + minimum = 0.0, + exclusiveMaximum = true, + maximum = Double.POSITIVE_INFINITY, + multipleOf = null + ) + ) + val code = Model.Object(Named("User"), null, listOf(height), listOf(height.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("0.0 <= height")) + assertTrue(code.containsSingle("height should be larger or equal to 0.0")) + } + + @Test + fun `double (min closed) max lt`() { + val height = + height( + Number( + exclusiveMinimum = true, + minimum = Double.NEGATIVE_INFINITY, + exclusiveMaximum = true, + maximum = 100.0, + multipleOf = null + ) + ) + val code = Model.Object(Named("User"), null, listOf(height), listOf(height.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("height < 100.0")) + assertTrue(code.containsSingle("height should be smaller then 100.0")) + } + + @Test + fun `double (min open) max lt`() { + val height = + height( + Number( + exclusiveMinimum = false, + minimum = Double.NEGATIVE_INFINITY, + exclusiveMaximum = true, + maximum = 100.0, + multipleOf = null + ) + ) + val code = Model.Object(Named("User"), null, listOf(height), listOf(height.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("height < 100.0")) + assertTrue(code.containsSingle("height should be smaller then 100.0")) + } + + @Test + fun `double (min open) max lte`() { + val height = + height( + Number( + exclusiveMinimum = false, + minimum = Double.NEGATIVE_INFINITY, + exclusiveMaximum = false, + maximum = 300.0, + multipleOf = null + ) + ) + val code = Model.Object(Named("User"), null, listOf(height), listOf(height.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("height <= 300.0")) + assertTrue(code.containsSingle("height should be smaller or equal to 300.0")) + } + + @Test + fun `double (min closed) max lte`() { + val height = + height( + Number( + exclusiveMinimum = true, + minimum = Double.NEGATIVE_INFINITY, + exclusiveMaximum = false, + maximum = 100.0, + multipleOf = null + ) + ) + val code = Model.Object(Named("User"), null, listOf(height), listOf(height.model)).compiles() + assertFalse(code.containsSingle("requireAll")) + assertTrue(code.containsSingle("height <= 100.0")) + assertTrue(code.containsSingle("height should be smaller or equal to 100.0")) + } + + @Test + fun allConstraints() { + val nullable = prop("tags", tags.model, isNullable = true) + val code = + Model.Object( + Named("User"), + null, + listOf(id, age, height, nullable), + listOf(id.model, age.model, height.model, nullable.model) + ) + .compiles() + assertTrue(code.containsSingle("requireAll")) + assertTrue(code.containsSingle(idRequirements)) + assertTrue(code.containsSingle(ageRequirements)) + assertTrue(code.containsSingle(heightRequirements)) + assertTrue(code.containsSingle("if (tags != null)")) + assertTrue(code.containsSingle(tagsRequirements)) + } +} diff --git a/generation/src/test/kotlin/io/github/nomisrev/openapi/EnumTest.kt b/generation/src/test/kotlin/io/github/nomisrev/openapi/EnumTest.kt new file mode 100644 index 0000000..249f3df --- /dev/null +++ b/generation/src/test/kotlin/io/github/nomisrev/openapi/EnumTest.kt @@ -0,0 +1,47 @@ +package io.github.nomisrev.openapi + +import io.github.nomisrev.openapi.NamingContext.Named +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EnumTest { + @Test + fun enum() { + val code = + Model.Enum.Closed( + Named("AutoOrManual"), + Model.Primitive.String(null, null, null), + listOf("Auto", "Manual"), + "Auto", + null + ) + .compiles() + assertFalse(code.contains("@SerialName(\"Auto\")")) + } + + @Test + fun enumNonValidClassNames() { + val code = + Model.Enum.Closed( + Named("AutoOrManual"), + Model.Primitive.String(null, null, null), + listOf("auto", "manual"), + "auto", + null + ) + .compiles() + assertTrue(code.contains("@SerialName(\"auto\")")) + assertTrue(code.contains("@SerialName(\"manual\")")) + } + + @Test + fun openEnum() { + val code = + Model.Enum.Open(Named("AutoOrManual"), listOf("auto", "manual"), "auto", null).compiles() + assertTrue(code.contains("sealed interface AutoOrManual")) + assertTrue(code.contains("data object Auto")) + assertTrue(code.contains("data object Manual")) + assertTrue(code.contains("value class OpenCase")) + } +} diff --git a/generation/src/test/kotlin/io/github/nomisrev/openapi/ModelTest.kt b/generation/src/test/kotlin/io/github/nomisrev/openapi/ModelTest.kt index 57edbea..d52de9a 100644 --- a/generation/src/test/kotlin/io/github/nomisrev/openapi/ModelTest.kt +++ b/generation/src/test/kotlin/io/github/nomisrev/openapi/ModelTest.kt @@ -1,14 +1,7 @@ -@file:OptIn(ExperimentalCompilerApi::class) - package io.github.nomisrev.openapi -import com.squareup.kotlinpoet.FileSpec -import com.tschuchort.compiletesting.KotlinCompilation -import com.tschuchort.compiletesting.SourceFile import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertFalse -import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi class ModelTest { @Test @@ -53,45 +46,4 @@ class ModelTest { ) .compiles() } - - @Test - fun enum() { - Model.Enum.Closed( - NamingContext.Named("AutoOrManual"), - Model.Primitive.String(null, null, null), - listOf("auto", "manual"), - "auto", - null - ) - .compiles() - } - - @Test - fun openEnum() { - Model.Enum.Open(NamingContext.Named("AutoOrManual"), listOf("auto", "manual"), "auto", null) - .compiles() - } } - -fun Model.compiles(): String { - val ctx = OpenAPIContext(GenerationConfig("", "", "io.test", "TestApi", true)) - val file = - with(ctx) { - requireNotNull(toFileSpecOrNull()) { "No File was generated for ${this@compiles}" } - } - val code = file.asCode() - val source = SourceFile.kotlin("${file.name}.kt", file.asCode()) - val result = - KotlinCompilation() - .apply { - val predef = SourceFile.kotlin("Predef.kt", with(ctx) { predef() }.asCode()) - sources = listOf(source, predef) - inheritClassPath = true - messageOutputStream = System.out - } - .compile() - assertEquals(result.exitCode, KotlinCompilation.ExitCode.OK, code) - return code -} - -fun FileSpec.asCode(): String = buildString { writeTo(this) } diff --git a/generation/src/test/kotlin/io/github/nomisrev/openapi/TestSyntax.kt b/generation/src/test/kotlin/io/github/nomisrev/openapi/TestSyntax.kt new file mode 100644 index 0000000..22024d9 --- /dev/null +++ b/generation/src/test/kotlin/io/github/nomisrev/openapi/TestSyntax.kt @@ -0,0 +1,63 @@ +@file:OptIn(ExperimentalCompilerApi::class) + +package io.github.nomisrev.openapi + +import com.squareup.kotlinpoet.FileSpec +import com.tschuchort.compiletesting.JvmCompilationResult +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import kotlin.test.assertEquals +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi + +/** Check if every text in [texts] occurs only a single time in [this]. */ +fun String.containsSingle(texts: List): Boolean = texts.all(::containsSingle) + +/** Check if [text] occurs only a single time in [this]. */ +fun String.containsSingle(text: String): Boolean { + val indexOf = indexOf(text) + return indexOf != -1 && lastIndexOf(text) == indexOf +} + +private fun FileSpec.asCode(): String = buildString { writeTo(this) } + +fun Model.compiles(): String { + val ctx = OpenAPIContext(GenerationConfig("", "", "io.test", "TestApi", true)) + val file = + with(ctx) { + requireNotNull(toFileSpecOrNull()) { "No File was generated for ${this@compiles}" } + } + val code = file.asCode() + val source = SourceFile.kotlin("${file.name}.kt", file.asCode()) + val result = + KotlinCompilation() + .apply { + val predef = SourceFile.kotlin("Predef.kt", with(ctx) { predef() }.asCode()) + sources = listOf(source, predef) + inheritClassPath = true + messageOutputStream = System.out + } + .compile() + assertEquals(result.exitCode, KotlinCompilation.ExitCode.OK, code) + return code +} + +fun API.compiles(): JvmCompilationResult { + val ctx = OpenAPIContext(GenerationConfig("", "", "io.test", "TestApi", true)) + val filesAsSources = + with(ctx) { + Root("TestApi", emptyList(), listOf(this@compiles)).toFileSpecs().map { + SourceFile.kotlin("${it.name}.kt", it.asCode()) + } + } + val result = + KotlinCompilation() + .apply { + val predef = SourceFile.kotlin("Predef.kt", with(ctx) { predef() }.asCode()) + sources = filesAsSources + predef + inheritClassPath = true + messageOutputStream = System.out + } + .compile() + assertEquals(result.exitCode, KotlinCompilation.ExitCode.OK) + return result +} diff --git a/typed/src/commonMain/kotlin/io/github/nomisrev/openapi/Constraints.kt b/typed/src/commonMain/kotlin/io/github/nomisrev/openapi/Constraints.kt index 8b0ac67..c2397f8 100644 --- a/typed/src/commonMain/kotlin/io/github/nomisrev/openapi/Constraints.kt +++ b/typed/src/commonMain/kotlin/io/github/nomisrev/openapi/Constraints.kt @@ -22,11 +22,11 @@ sealed interface Constraints { } } - data class Text(val maxLength: Int, val minLength: Int, val pattern: String?) : Constraints { + data class Text(val minLength: Int, val maxLength: Int, val pattern: String?) : Constraints { companion object { operator fun invoke(schema: Schema): Text? = if (schema.maxLength != null || schema.minLength != null || schema.pattern != null) - Text(schema.maxLength ?: Int.MAX_VALUE, schema.minLength ?: 0, schema.pattern) + Text(schema.minLength ?: 0, schema.maxLength ?: Int.MAX_VALUE, schema.pattern) else null } } diff --git a/typed/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPITransformer.kt b/typed/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPITransformer.kt index cc7f9b7..7296982 100644 --- a/typed/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPITransformer.kt +++ b/typed/src/commonMain/kotlin/io/github/nomisrev/openapi/OpenAPITransformer.kt @@ -224,7 +224,15 @@ private class OpenAPITransformer(private val openAPI: OpenAPI) { 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.") + null -> + when { + // If no type is defined, but we find properties, or additionalProperties, we assume it's + // an object. + properties != null || additionalProperties != null -> toObject(context) + // If 'items' is defined, we assume it's an array. + items != null -> collection(context) + else -> TODO("Schema: $this not yet supported. Please report to issue tracker.") + } } private fun Schema.default( diff --git a/typed/src/commonTest/kotlin/io/github/nomisrev/openapi/ConstraintTest.kt b/typed/src/commonTest/kotlin/io/github/nomisrev/openapi/ConstraintTest.kt index d357645..8ab4f8d 100644 --- a/typed/src/commonTest/kotlin/io/github/nomisrev/openapi/ConstraintTest.kt +++ b/typed/src/commonTest/kotlin/io/github/nomisrev/openapi/ConstraintTest.kt @@ -56,7 +56,7 @@ class ConstraintTest { fun text() { assertEquals( Model.Primitive.string( - constraint = Constraints.Text(maxLength = 10, minLength = 1, pattern = null) + constraint = Constraints.Text(minLength = 1, maxLength = 10, pattern = null) ), Schema(type = Schema.Type.Basic.String, maxLength = 10, minLength = 1, pattern = null) .toModel("Text")