Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Data class property validation #74

Merged
merged 3 commits into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion example/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")) {
Expand Down
177 changes: 136 additions & 41 deletions generation/src/main/kotlin/io/github/nomisrev/openapi/Models.kt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@file:Suppress("UNUSED_VARIABLE")

package io.github.nomisrev.openapi

import com.squareup.kotlinpoet.AnnotationSpec
Expand Down Expand Up @@ -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<Model.Object.Property>.requirements(): List<Requirement> =
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)
}
}
}
}

Expand All @@ -294,19 +366,25 @@ private fun Iterable<Model.Object.Property>.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(")")
Expand All @@ -317,33 +395,50 @@ private fun Iterable<Model.Object.Property>.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)
Expand Down
25 changes: 0 additions & 25 deletions generation/src/test/kotlin/io/github/nomisrev/openapi/ApiTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>): 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
}
Loading