diff --git a/README.md b/README.md index 94d5625..834eec8 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ dependencies { Suppose you have a data class like this: -```Kotlin +```kotlin data class UserProfile( val fullName: String, val age: Int? @@ -44,7 +44,7 @@ data class UserProfile( Using the Konform type-safe DSL you can quickly write up a validation -```Kotlin +```kotlin val validateUser = Validation { UserProfile::fullName { minLength(2) @@ -60,14 +60,14 @@ val validateUser = Validation { and apply it to your data -```Kotlin +```kotlin val invalidUser = UserProfile("A", -1) val validationResult = validateUser(invalidUser) ``` since the validation fails the `validationResult` will be of type `Invalid` and you can get a list of validation errors by indexed access: -```Kotlin +```kotlin validationResult[UserProfile::fullName] // yields listOf("must have at least 2 characters") @@ -77,7 +77,7 @@ validationResult[UserProfile::age] or you can get all validation errors with details as a list: -```Kotlin +```kotlin validationResult.errors // yields listOf( // ValidationError(dataPath=.fullName, message=must have at least 2 characters), @@ -87,19 +87,19 @@ validationResult.errors In case the validation went through successfully you get a result of type `Valid` with the validated value in the `value` field. -```Kotlin +```kotlin val validUser = UserProfile("Alice", 25) val validationResult = validateUser(validUser) // yields Valid(UserProfile("Alice", 25)) ``` -### Advanced use +### Detailed usage #### Hints You can add custom hints to validations -```Kotlin +```kotlin val validateUser = Validation { UserProfile::age ifPresent { minimum(0) hint "Registering before birth is not supported" @@ -109,7 +109,7 @@ val validateUser = Validation { You can use `{value}` to include the `.toString()`-ed data in the hint -```Kotlin +```kotlin val validateUser = Validation { UserProfile::fullName { minLength(2) hint "'{value}' is too short a name, must be at least 2 characters long." @@ -119,9 +119,9 @@ val validateUser = Validation { #### Custom validations -You can add custom validations by using `addConstraint` +You can add custom validations on properties by using `addConstraint` -```Kotlin +```kotlin val validateUser = Validation { UserProfile::fullName { addConstraint("Name cannot contain a tab") { !it.contains("\t") } @@ -129,30 +129,51 @@ val validateUser = Validation { } ``` -#### Nested validations +You can transform data and then add a validation on the result -You can define validations for nested classes and use them for new validations +```kotlin +val validateUser = Validation { + validate("trimmedName", { it.fullName.trim() }) { + minLength(5) + } + // This also required and ifPresent for nullable values + required("yourName", /* ...*/) { + // your validations, giving an error out if the result is null + } + ifPresent("yourName", /* ... */) { + // your validations, only running if the result is not null + } +} +``` + +#### Split validations -```Kotlin -val ageCheck = Validation { - UserProfile::age required { - minimum(18) +You can define validations separately and run them from other validations + +```kotlin +val ageCheck = Validation { + required { + minimum(21) } } val validateUser = Validation { - UserProfile::fullName { - minLength(2) - maxLength(100) + UserProfile::age { + run(ageCheck) } - run(ageCheck) + // You can also transform the data and then run a validation against the result + validate("ageMinus10", { it.age?.let { age -> age - 10 } }) { + run(ageCheck) + } } ``` +#### Collections + It is also possible to validate nested data classes and properties that are collections (List, Map, etc...) -```Kotlin +```kotlin data class Person(val name: String, val email: String?, val age: Int) data class Event( @@ -206,7 +227,7 @@ val validateEvent = Validation { Errors in the `ValidationResult` can also be accessed using the index access method. In case of `Iterables` and `Arrays` you use the numerical index and in case of `Maps` you use the key as string. -```Kotlin +```kotlin // get the error messages for the first attendees age if any result[Event::attendees, 0, Person::age] diff --git a/build.gradle.kts b/build.gradle.kts index 1f7fa1d..f745172 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,9 @@ +import org.gradle.internal.extensions.stdlib.capitalized import org.jetbrains.kotlin.cli.common.toBooleanLenient import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.testing.internal.KotlinTestReport val projectName = "konform" val projectGroup = "io.konform" @@ -95,6 +97,14 @@ kotlin { //endregion sourceSets { + val kotestSupported = + listOf( + appleTest, + jsTest, + jvmTest, + nativeTest, + wasmJsTest, + ) // Shared dependencies commonMain.dependencies { api(kotlin("stdlib")) @@ -104,9 +114,13 @@ kotlin { implementation(kotlin("test")) // implementation(kotlin("test-annotations-common")) // implementation(kotlin("test-common")) - // implementation(libs.kotest.assertions.core) - // implementation(libs.kotest.framework.datatest) - // implementation(libs.kotest.framework.engine) + } + kotestSupported.forEach { + it.dependencies { + implementation(libs.kotest.assertions.core) + // implementation(libs.kotest.framework.datatest) + // implementation(libs.kotest.framework.engine) + } } jvmTest.dependencies { // implementation(libs.kotest.runner.junit5) @@ -132,6 +146,24 @@ tasks.named("jvmTest") { exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL } } +// Disable test tasks for the unsupported source sets +val kotestUnsupported = + listOf( + "wasmWasi", + ) +kotestUnsupported.forEach { + // Disable tests for targets kotest doesn't support yet + + val capitalized = it.capitalized() + tasks.named("compileTestKotlin$capitalized") { + enabled = false + } + + tasks.named("${it}Test") { + enabled = false + } +} + //endregion //region Publishing configuration diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt index aece51e..c88e97d 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt @@ -7,6 +7,7 @@ import io.konform.validation.internal.OptionalValidation import io.konform.validation.internal.RequiredValidation import io.konform.validation.internal.ValidationBuilderImpl import kotlin.jvm.JvmName +import kotlin.reflect.KFunction1 import kotlin.reflect.KProperty1 @DslMarker @@ -24,48 +25,106 @@ public abstract class ValidationBuilder { public abstract infix fun Constraint.hint(hint: String): Constraint - public abstract operator fun KProperty1.invoke(init: ValidationBuilder.() -> Unit) - internal abstract fun onEachIterable( - prop: KProperty1>, + name: String, + prop: (T) -> Iterable, init: ValidationBuilder.() -> Unit, ) - @JvmName("onEachIterable") - public infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit): Unit = onEachIterable(this, init) - internal abstract fun onEachArray( - prop: KProperty1>, + name: String, + prop: (T) -> Array, init: ValidationBuilder.() -> Unit, ) - @JvmName("onEachArray") - public infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit): Unit = onEachArray(this, init) - internal abstract fun onEachMap( - prop: KProperty1>, + name: String, + prop: (T) -> Map, init: ValidationBuilder>.() -> Unit, ) + @JvmName("onEachIterable") + public infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit): Unit = onEachIterable(name, this, init) + + @JvmName("onEachIterable") + public infix fun KFunction1>.onEach(init: ValidationBuilder.() -> Unit): Unit = + onEachIterable("$name()", this, init) + + @JvmName("onEachArray") + public infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit): Unit = onEachArray(name, this, init) + + @JvmName("onEachArray") + public infix fun KFunction1>.onEach(init: ValidationBuilder.() -> Unit): Unit = onEachArray("$name()", this, init) + @JvmName("onEachMap") public infix fun KProperty1>.onEach(init: ValidationBuilder>.() -> Unit): Unit = - onEachMap(this, init) + onEachMap(name, this, init) + + @JvmName("onEachMap") + public infix fun KFunction1>.onEach(init: ValidationBuilder>.() -> Unit): Unit = + onEachMap("$name()", this, init) - public abstract infix fun KProperty1.ifPresent(init: ValidationBuilder.() -> Unit) + public operator fun KProperty1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate(name, this, init) - public abstract infix fun KProperty1.required(init: ValidationBuilder.() -> Unit) + public operator fun KFunction1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate("$name()", this, init) + public infix fun KProperty1.ifPresent(init: ValidationBuilder.() -> Unit): Unit = ifPresent(name, this, init) + + public infix fun KFunction1.ifPresent(init: ValidationBuilder.() -> Unit): Unit = ifPresent("$name()", this, init) + + public infix fun KProperty1.required(init: ValidationBuilder.() -> Unit): Unit = required(name, this, init) + + public infix fun KFunction1.required(init: ValidationBuilder.() -> Unit): Unit = required("$name()", this, init) + + /** + * Calculate a value from the input and run a validation on it. + * @param name The name that should be reported in validation errors. Must be a valid kotlin name, optionally followed by (). + * @param f The function for which you want to validate the result of + * @see run + */ + public abstract fun validate( + name: String, + f: (T) -> R, + init: ValidationBuilder.() -> Unit, + ) + + /** + * Calculate a value from the input and run a validation on it, but only if the value is not null. + */ + public abstract fun ifPresent( + name: String, + f: (T) -> R?, + init: ValidationBuilder.() -> Unit, + ) + + /** + * Calculate a value from the input and run a validation on it, and give an error if the result is null. + */ + public abstract fun required( + name: String, + f: (T) -> R?, + init: ValidationBuilder.() -> Unit, + ) + + /** Run an arbitrary other validation. */ public abstract fun run(validation: Validation) public abstract val KProperty1.has: ValidationBuilder + public abstract val KFunction1.has: ValidationBuilder } +/** + * Run a validation if the property is not-null, and allow nulls. + */ public fun ValidationBuilder.ifPresent(init: ValidationBuilder.() -> Unit) { val builder = ValidationBuilderImpl() init(builder) run(OptionalValidation(builder.build())) } +/** + * Run a validation on a nullable property, giving an error on nulls. + */ public fun ValidationBuilder.required(init: ValidationBuilder.() -> Unit) { val builder = ValidationBuilderImpl() init(builder) @@ -84,8 +143,7 @@ public fun > ValidationBuilder.onEach(init: ValidationBuil public fun ValidationBuilder>.onEach(init: ValidationBuilder.() -> Unit) { val builder = ValidationBuilderImpl() init(builder) - @Suppress("UNCHECKED_CAST") - run(ArrayValidation(builder.build()) as Validation>) + run(ArrayValidation(builder.build())) } @JvmName("onEachMap") diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt b/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt index 033ba9c..eafe1c8 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt @@ -1,6 +1,6 @@ package io.konform.validation -import kotlin.reflect.KProperty1 +import io.konform.validation.kotlin.Path public interface ValidationError { public val dataPath: String @@ -43,14 +43,7 @@ public sealed class ValidationResult { public data class Invalid( internal val internalErrors: Map>, ) : ValidationResult() { - override fun get(vararg propertyPath: Any): List? = internalErrors[propertyPath.joinToString("", transform = ::toPathSegment)] - - private fun toPathSegment(it: Any): String = - when (it) { - is KProperty1<*, *> -> ".${it.name}" - is Int -> "[$it]" - else -> ".$it" - } + override fun get(vararg propertyPath: Any): List? = internalErrors[Path.toPath(*propertyPath)] override val errors: List by lazy { internalErrors.flatMap { (path, errors) -> diff --git a/src/commonMain/kotlin/io/konform/validation/internal/Validation.kt b/src/commonMain/kotlin/io/konform/validation/internal/Validation.kt index 61cf05e..2814b7c 100644 --- a/src/commonMain/kotlin/io/konform/validation/internal/Validation.kt +++ b/src/commonMain/kotlin/io/konform/validation/internal/Validation.kt @@ -5,7 +5,6 @@ import io.konform.validation.Invalid import io.konform.validation.Valid import io.konform.validation.Validation import io.konform.validation.ValidationResult -import kotlin.reflect.KProperty1 internal class OptionalValidation( private val validation: Validation, @@ -28,34 +27,37 @@ internal class RequiredValidation( } internal class NonNullPropertyValidation( - private val property: KProperty1, + val property: (T) -> R, + val name: String, private val validation: Validation, ) : Validation { override fun validate(value: T): ValidationResult { val propertyValue = property(value) - return validation(propertyValue).mapError { ".${property.name}$it" }.map { value } + return validation(propertyValue).mapError { ".${name}$it" }.map { value } } } internal class OptionalPropertyValidation( - private val property: KProperty1, + val property: (T) -> R?, + val name: String, private val validation: Validation, ) : Validation { override fun validate(value: T): ValidationResult { val propertyValue = property(value) ?: return Valid(value) - return validation(propertyValue).mapError { ".${property.name}$it" }.map { value } + return validation(propertyValue).mapError { ".${name}$it" }.map { value } } } internal class RequiredPropertyValidation( - private val property: KProperty1, + val property: (T) -> R?, + val name: String, private val validation: Validation, ) : Validation { override fun validate(value: T): ValidationResult { val propertyValue = property(value) - ?: return Invalid(mapOf(".${property.name}" to listOf("is required"))) - return validation(propertyValue).mapError { ".${property.name}$it" }.map { value } + ?: return Invalid(mapOf(".$name" to listOf("is required"))) + return validation(propertyValue).mapError { ".${name}$it" }.map { value } } } diff --git a/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt b/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt index 6d4a4aa..a7a00d4 100644 --- a/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt +++ b/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt @@ -6,7 +6,9 @@ import io.konform.validation.ValidationBuilder import io.konform.validation.internal.ValidationBuilderImpl.Companion.PropModifier.NonNull import io.konform.validation.internal.ValidationBuilderImpl.Companion.PropModifier.Optional import io.konform.validation.internal.ValidationBuilderImpl.Companion.PropModifier.OptionalRequired +import io.konform.validation.kotlin.Grammar import kotlin.collections.Map.Entry +import kotlin.reflect.KFunction1 import kotlin.reflect.KProperty1 internal class ValidationBuilderImpl : ValidationBuilder() { @@ -22,63 +24,65 @@ internal class ValidationBuilderImpl : ValidationBuilder() { } private data class SingleValuePropKey( - val property: KProperty1, + val property: (T) -> R, + val name: String, val modifier: PropModifier, ) : PropKey() { override fun build(builder: ValidationBuilderImpl<*>): Validation { @Suppress("UNCHECKED_CAST") val validations = (builder as ValidationBuilderImpl).build() return when (modifier) { - NonNull -> NonNullPropertyValidation(property, validations) - Optional -> OptionalPropertyValidation(property, validations) - OptionalRequired -> RequiredPropertyValidation(property, validations) + NonNull -> NonNullPropertyValidation(property, name, validations) + Optional -> OptionalPropertyValidation(property, name, validations) + OptionalRequired -> RequiredPropertyValidation(property, name, validations) } } } private data class IterablePropKey( - val property: KProperty1>, + val property: (T) -> Iterable, + val name: String, val modifier: PropModifier, ) : PropKey() { override fun build(builder: ValidationBuilderImpl<*>): Validation { @Suppress("UNCHECKED_CAST") val validations = (builder as ValidationBuilderImpl).build() - @Suppress("UNCHECKED_CAST") return when (modifier) { - NonNull -> NonNullPropertyValidation(property, IterableValidation(validations)) - Optional -> OptionalPropertyValidation(property, IterableValidation(validations)) - OptionalRequired -> RequiredPropertyValidation(property, IterableValidation(validations)) + NonNull -> NonNullPropertyValidation(property, name, IterableValidation(validations)) + Optional -> OptionalPropertyValidation(property, name, IterableValidation(validations)) + OptionalRequired -> RequiredPropertyValidation(property, name, IterableValidation(validations)) } } } private data class ArrayPropKey( - val property: KProperty1>, + val property: (T) -> Array, + val name: String, val modifier: PropModifier, ) : PropKey() { override fun build(builder: ValidationBuilderImpl<*>): Validation { @Suppress("UNCHECKED_CAST") val validations = (builder as ValidationBuilderImpl).build() - @Suppress("UNCHECKED_CAST") return when (modifier) { - NonNull -> NonNullPropertyValidation(property, ArrayValidation(validations)) - Optional -> OptionalPropertyValidation(property, ArrayValidation(validations)) - OptionalRequired -> RequiredPropertyValidation(property, ArrayValidation(validations)) + NonNull -> NonNullPropertyValidation(property, name, ArrayValidation(validations)) + Optional -> OptionalPropertyValidation(property, name, ArrayValidation(validations)) + OptionalRequired -> RequiredPropertyValidation(property, name, ArrayValidation(validations)) } } } private data class MapPropKey( - val property: KProperty1>, + val property: (T) -> Map, + val name: String, val modifier: PropModifier, ) : PropKey() { override fun build(builder: ValidationBuilderImpl<*>): Validation { @Suppress("UNCHECKED_CAST") val validations = (builder as ValidationBuilderImpl>).build() return when (modifier) { - NonNull -> NonNullPropertyValidation(property, MapValidation(validations)) - Optional -> OptionalPropertyValidation(property, MapValidation(validations)) - OptionalRequired -> RequiredPropertyValidation(property, MapValidation(validations)) + NonNull -> NonNullPropertyValidation(property, name, MapValidation(validations)) + Optional -> OptionalPropertyValidation(property, name, MapValidation(validations)) + OptionalRequired -> RequiredPropertyValidation(property, name, MapValidation(validations)) } } } @@ -100,63 +104,89 @@ internal class ValidationBuilderImpl : ValidationBuilder() { test: (T) -> Boolean, ): Constraint = Constraint(errorMessage, templateValues.toList(), test).also { constraints.add(it) } - private fun KProperty1.getOrCreateBuilder(modifier: PropModifier): ValidationBuilder { - val key = SingleValuePropKey(this, modifier) + private fun ((T) -> R?).getOrCreateBuilder( + name: String, + modifier: PropModifier, + ): ValidationBuilder { + requireValidName(name) + val key = SingleValuePropKey(this, name, modifier) @Suppress("UNCHECKED_CAST") - return (subValidations.getOrPut(key, { ValidationBuilderImpl() }) as ValidationBuilder) + return (subValidations.getOrPut(key) { ValidationBuilderImpl() } as ValidationBuilder) } - private fun KProperty1>.getOrCreateIterablePropertyBuilder(modifier: PropModifier): ValidationBuilder { - val key = IterablePropKey(this, modifier) + private fun ((T) -> Iterable).getOrCreateIterablePropertyBuilder( + name: String, + modifier: PropModifier, + ): ValidationBuilder { + val key = IterablePropKey(this, name, modifier) @Suppress("UNCHECKED_CAST") - return (subValidations.getOrPut(key, { ValidationBuilderImpl() }) as ValidationBuilder) + return (subValidations.getOrPut(key) { ValidationBuilderImpl() } as ValidationBuilder) } private fun PropKey.getOrCreateBuilder(): ValidationBuilder { @Suppress("UNCHECKED_CAST") - return (subValidations.getOrPut(this, { ValidationBuilderImpl() }) as ValidationBuilder) - } - - override fun KProperty1.invoke(init: ValidationBuilder.() -> Unit) { - getOrCreateBuilder(NonNull).also(init) + return (subValidations.getOrPut(this) { ValidationBuilderImpl() } as ValidationBuilder) } override fun onEachIterable( - prop: KProperty1>, + name: String, + prop: (T) -> Iterable, init: ValidationBuilder.() -> Unit, ) { - prop.getOrCreateIterablePropertyBuilder(NonNull).also(init) + requireValidName(name) + init(prop.getOrCreateIterablePropertyBuilder(name, NonNull)) } override fun onEachArray( - prop: KProperty1>, + name: String, + prop: (T) -> Array, init: ValidationBuilder.() -> Unit, ) { - ArrayPropKey(prop, NonNull).getOrCreateBuilder().also(init) + requireValidName(name) + init(ArrayPropKey(prop, name, NonNull).getOrCreateBuilder()) } override fun onEachMap( - prop: KProperty1>, + name: String, + prop: (T) -> Map, init: ValidationBuilder>.() -> Unit, ) { - MapPropKey(prop, NonNull).getOrCreateBuilder>().also(init) - } - - override fun KProperty1.ifPresent(init: ValidationBuilder.() -> Unit) { - getOrCreateBuilder(Optional).also(init) - } - - override fun KProperty1.required(init: ValidationBuilder.() -> Unit) { - getOrCreateBuilder(OptionalRequired).also(init) + requireValidName(name) + init(MapPropKey(prop, name, NonNull).getOrCreateBuilder()) } override val KProperty1.has: ValidationBuilder - get() = getOrCreateBuilder(NonNull) + get() = getOrCreateBuilder(name, NonNull) + override val KFunction1.has: ValidationBuilder + get() = getOrCreateBuilder(name, NonNull) override fun run(validation: Validation) { prebuiltValidations.add(validation) } + override fun validate( + name: String, + f: (T) -> R, + init: ValidationBuilder.() -> Unit, + ) = init(f.getOrCreateBuilder(name, NonNull)) + + override fun ifPresent( + name: String, + f: (T) -> R?, + init: ValidationBuilder.() -> Unit, + ) = init(f.getOrCreateBuilder(name, Optional)) + + override fun required( + name: String, + f: (T) -> R?, + init: ValidationBuilder.() -> Unit, + ) = init(f.getOrCreateBuilder(name, OptionalRequired)) + + private fun requireValidName(name: String) = + require(Grammar.Identifier.isValid(name) || Grammar.FunctionDeclaration.isUnary(name)) { + "'$name' is not a valid kotlin identifier or getter name." + } + override fun build(): Validation { val nestedValidations = subValidations.map { (key, builder) -> diff --git a/src/commonMain/kotlin/io/konform/validation/kotlin/Grammar.kt b/src/commonMain/kotlin/io/konform/validation/kotlin/Grammar.kt new file mode 100644 index 0000000..cb4bc0c --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/kotlin/Grammar.kt @@ -0,0 +1,24 @@ +package io.konform.validation.kotlin + +/** + * Representation of parts of [the Kotlin grammar](https://kotlinlang.org/spec/syntax-and-grammar.html#lexical-grammar) + */ +internal object Grammar { + private const val LETTER = "\\p{L}\\p{Nl}" // Unicode letters (Lu, Ll, Lt, Lm, Lo) + private const val UNICODE_DIGIT = "\\p{Nd}" // Unicode digits (Nd) + private const val QUOTED_SYMBOL = "[^`\r\n]" // Anything except backtick, CR, or LF inside backticks + + object Identifier { + internal const val STRING = "([${LETTER}_][${LETTER}_$UNICODE_DIGIT]*)|`$QUOTED_SYMBOL+`" + private val regex = "^$STRING$".toRegex() + + fun isValid(s: String) = s.matches(regex) + } + + object FunctionDeclaration { + private const val UNARY_STRING = """(${Identifier.STRING})\(\)""" + private val unaryRegex = "^$UNARY_STRING$".toRegex() + + fun isUnary(s: String) = s.matches(unaryRegex) + } +} diff --git a/src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt b/src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt new file mode 100644 index 0000000..9491da0 --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt @@ -0,0 +1,25 @@ +package io.konform.validation.kotlin + +import kotlin.reflect.KFunction1 +import kotlin.reflect.KProperty1 + +/** Represents a JSONPath-ish path to a property. */ +internal object Path { + /** Get a path, but treat a single string as the full path */ + fun asPathOrToPath(vararg segments: Any): String = + if (segments.size == 1 && segments[0] is String) { + segments[0] as String + } else { + toPath(*segments) + } + + fun toPath(vararg segments: Any): String = segments.joinToString("") { toPathSegment(it) } + + fun toPathSegment(it: Any): String = + when (it) { + is KProperty1<*, *> -> ".${it.name}" + is KFunction1<*, *> -> ".${it.name}()" + is Int -> "[$it]" + else -> ".$it" + } +} diff --git a/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt b/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt index 8362199..e8c28ff 100644 --- a/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt @@ -7,18 +7,23 @@ import io.konform.validation.jsonschema.minItems import io.konform.validation.jsonschema.minLength import io.konform.validation.jsonschema.minimum import io.konform.validation.jsonschema.pattern +import io.kotest.assertions.konform.shouldBeInvalid +import io.kotest.assertions.konform.shouldBeValid +import io.kotest.assertions.konform.shouldContainError import kotlin.collections.Map.Entry import kotlin.test.Test import kotlin.test.assertEquals class ReadmeExampleTest { + data class UserProfile( + val fullName: String, + val age: Int?, + ) + + private val johnDoe = UserProfile("John Doe", 30) + @Test fun simpleValidation() { - data class UserProfile( - val fullName: String, - val age: Int?, - ) - val validateUser = Validation { UserProfile::fullName { @@ -130,4 +135,87 @@ class ReadmeExampleTest { assertEquals(3, countFieldsWithErrors(validateEvent(invalidEvent))) assertEquals("Attendees must be 18 years or older", validateEvent(invalidEvent)[Event::attendees, 0, Person::age]!![0]) } + + @Test + fun customValidations() { + val validateUser1 = + Validation { + UserProfile::fullName { + addConstraint("Name cannot contain a tab") { !it.contains("\t") } + } + } + + validateUser1 shouldBeValid johnDoe + validateUser1.shouldBeInvalid(UserProfile("John\tDoe", 30)) { + it.shouldContainError(".fullName", "Name cannot contain a tab") + } + + val validateUser2 = + Validation { + validate("trimmedName", { it.fullName.trim() }) { + minLength(5) + } + } + + validateUser2 shouldBeValid johnDoe + validateUser2.shouldBeInvalid(UserProfile("J", 30)) { + it.shouldContainError(".trimmedName", "must have at least 5 characters") + } + } + + @Test + fun splitValidations() { + val ageCheck = + Validation { + required { + minimum(21) + } + } + + val validateUser = + Validation { + UserProfile::age { + run(ageCheck) + } + } + + validateUser shouldBeValid johnDoe + validateUser.shouldBeInvalid(UserProfile("John doe", 10)) { + it.shouldContainError(".age", "must be at least '21'") + } + + val transform = + Validation { + validate("ageMinus10", { it.age?.let { age -> age - 10 } }) { + run(ageCheck) + } + } + + transform shouldBeValid UserProfile("X", 31) + transform.shouldBeInvalid(johnDoe) { + it.shouldContainError(".ageMinus10", "must be at least '21'") + } + + val required = + Validation { + required("age", { it.age }) { + minimum(21) + } + } + val optional = + Validation { + ifPresent("age", { it.age }) { + minimum(21) + } + } + val noAge = UserProfile("John Doe", null) + required.shouldBeInvalid(noAge) { + it.shouldContainError(".age", "is required") + } + optional.shouldBeValid(noAge) + optional.shouldBeValid(johnDoe) + optional.shouldBeInvalid(UserProfile("John Doe", 10)) { + it.shouldContainError(".age", "must be at least '21'") + } + } } diff --git a/src/commonTest/kotlin/io/konform/validation/TestHelpers.kt b/src/commonTest/kotlin/io/konform/validation/TestHelpers.kt index 4bfc613..f2f6a78 100644 --- a/src/commonTest/kotlin/io/konform/validation/TestHelpers.kt +++ b/src/commonTest/kotlin/io/konform/validation/TestHelpers.kt @@ -5,5 +5,4 @@ fun countFieldsWithErrors(validationResult: ValidationResult) = (validati fun countErrors( validationResult: ValidationResult<*>, vararg properties: Any, -) = validationResult.get(*properties)?.size - ?: 0 +) = validationResult.get(*properties)?.size ?: 0 diff --git a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt index 8568035..bcccac2 100644 --- a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt @@ -1,21 +1,29 @@ package io.konform.validation +import io.konform.validation.jsonschema.const +import io.konform.validation.jsonschema.enum import io.konform.validation.jsonschema.minItems +import io.kotest.assertions.konform.shouldBeInvalid +import io.kotest.assertions.konform.shouldBeValid +import io.kotest.assertions.konform.shouldContainError +import io.kotest.assertions.konform.shouldContainExactlyErrors +import io.kotest.assertions.konform.shouldHaveErrorCount +import io.kotest.assertions.konform.shouldNotContainErrorAt import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue class ValidationBuilderTest { // Some example constraints for Testing - fun ValidationBuilder.minLength(minValue: Int) = + private fun ValidationBuilder.minLength(minValue: Int) = addConstraint("must have at least {0} characters", minValue.toString()) { it.length >= minValue } - fun ValidationBuilder.maxLength(minValue: Int) = + private fun ValidationBuilder.maxLength(minValue: Int) = addConstraint("must have at most {0} characters", minValue.toString()) { it.length <= minValue } - fun ValidationBuilder.matches(regex: Regex) = addConstraint("must have correct format") { it.contains(regex) } + private fun ValidationBuilder.matches(regex: Regex) = addConstraint("must have correct format") { it.contains(regex) } - fun ValidationBuilder.containsANumber() = matches("[0-9]".toRegex()) hint "must have at least one number" + private fun ValidationBuilder.containsANumber() = matches("[0-9]".toRegex()) hint "must have at least one number" @Test fun singleValidation() { @@ -179,6 +187,108 @@ class ValidationBuilderTest { Register(email = "tester@").let { assertEquals(2, countFieldsWithErrors(splitDoubleValidation(it))) } } + @Test + fun functionAccessorSyntax() { + val splitDoubleValidation = + Validation { + Register::getPasswordFun { + minLength(1) + } + Register::getPasswordFun { + maxLength(10) + } + Register::getEmailFun { + matches(".+@.+".toRegex()) + } + } + + Register(email = "tester@test.com", password = "a").let { assertEquals(Valid(it), splitDoubleValidation(it)) } + Register( + email = "tester@test.com", + password = "", + ).let { assertEquals(1, countErrors(splitDoubleValidation(it), Register::getPasswordFun)) } + Register(email = "tester@test.com", password = "aaaaaaaaaaa").let { + assertEquals(1, countErrors(splitDoubleValidation(it), Register::getPasswordFun)) + } + Register(email = "tester@").let { assertEquals(2, countFieldsWithErrors(splitDoubleValidation(it))) } + } + + @Test + fun validateLambda() { + val splitDoubleValidation = + Validation { + validate("getPasswordLambda", { r: Register -> r.password }) { + minLength(1) + maxLength(10) + } + validate("getEmailLambda", { r: Register -> r.email }) { + matches(".+@.+".toRegex()) + } + } + + splitDoubleValidation shouldBeValid Register(email = "tester@test.com", password = "a") + splitDoubleValidation.shouldBeInvalid(Register(email = "tester@test.com", password = "")) { + it.shouldContainExactlyErrors(".getPasswordLambda" to "must have at least 1 characters") + } + splitDoubleValidation.shouldBeInvalid(Register(email = "tester@test.com", password = "aaaaaaaaaaa")) { + it.shouldContainExactlyErrors(".getPasswordLambda" to "must have at most 10 characters") + } + splitDoubleValidation.shouldBeInvalid(Register(email = "tester@", password = "")) { + it.shouldContainExactlyErrors( + ".getPasswordLambda" to "must have at least 1 characters", + ".getEmailLambda" to "must have correct format", + ) + } + } + + @Test + fun complexLambdaAccessors() { + data class Token( + val claims: Map, + ) + + fun ValidationBuilder.validateClaim( + key: String, + validations: ValidationBuilder.() -> Unit, + ) { + required("claim_$key", { data: Token -> data.claims[key] }) { + validations() + } + } + + val accessTokenValidation = + Validation { + validateClaim("scope") { + const("access") + } + validateClaim("issuer") { + enum("bob", "eve") + } + } + val refreshTokenVerification = + Validation { + validateClaim("scope") { + const("refresh") + } + validateClaim("issuer") { + enum("bob", "eve") + } + } + + Token(mapOf("scope" to "access", "issuer" to "bob")).let { + assertEquals(Valid(it), accessTokenValidation(it)) + assertEquals(1, countFieldsWithErrors(refreshTokenVerification(it))) + } + Token(mapOf("scope" to "refresh", "issuer" to "eve")).let { + assertEquals(Valid(it), refreshTokenVerification(it)) + assertEquals(1, countFieldsWithErrors(accessTokenValidation(it))) + } + Token(mapOf("issuer" to "alice")).let { + assertEquals(2, countFieldsWithErrors(accessTokenValidation(it))) + assertEquals(2, countFieldsWithErrors(refreshTokenVerification(it))) + } + } + @Test fun validateLists() { data class Data( @@ -312,16 +422,23 @@ class ValidationBuilderTest { } } - Data().let { assertEquals(Valid(it), mapValidation(it)) } - Data( - registrations = - mapOf( - "user1" to Register(email = "valid"), - "user2" to Register(email = "a"), - ), - ).let { - assertEquals(0, countErrors(mapValidation(it), Data::registrations, "user1", Register::email)) - assertEquals(1, countErrors(mapValidation(it), Data::registrations, "user2", Register::email)) + mapValidation shouldBeValid Data() + + mapValidation.shouldBeInvalid( + Data( + registrations = + mapOf( + "user1" to Register(email = "valid"), + "user2" to Register(email = "a"), + ), + ), + ) { + it.shouldContainExactlyErrors( + ".registrations.user2.email" to "must have at least 2 characters", + ) + it.shouldContainError(listOf(Data::registrations, "user2", Register::email), "must have at least 2 characters") + it.shouldNotContainErrorAt(Data::registrations, "user1", Register::email) + it.shouldHaveErrorCount(1) } } @@ -389,7 +506,11 @@ class ValidationBuilderTest { val email: String = "", val referredBy: String? = null, val home: Address? = null, - ) + ) { + fun getPasswordFun() = password + + fun getEmailFun() = email + } private data class Address( val address: String = "", diff --git a/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt b/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt new file mode 100644 index 0000000..a7373e7 --- /dev/null +++ b/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt @@ -0,0 +1,104 @@ +// Shade the kotest konform assertions to avoid the circular dependency and develop independently +@file:Suppress("PackageDirectoryMismatch") + +package io.kotest.assertions.konform + +import io.konform.validation.Invalid +import io.konform.validation.PropertyValidationError +import io.konform.validation.Valid +import io.konform.validation.Validation +import io.konform.validation.ValidationError +import io.konform.validation.kotlin.Path +import io.kotest.matchers.Matcher +import io.kotest.matchers.MatcherResult +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.should + +infix fun Validation.shouldBeValid(value: T) = this should beValid(value) + +fun beValid(a: A) = + object : Matcher> { + override fun test(value: Validation): MatcherResult = + value(a).let { + MatcherResult( + it is Valid, + { "$a should be valid, but was: $it" }, + { "$a should not be valid" }, + ) + } + } + +infix fun Validation.shouldBeInvalid(value: T): Invalid { + this should beInvalid(value) + return this(value) as Invalid +} + +fun beInvalid(a: A) = + object : Matcher> { + override fun test(value: Validation): MatcherResult = + value(a).let { + MatcherResult( + it is Invalid, + { "$a should be invalid" }, + { "$a should not be invalid, but was: $it" }, + ) + } + } + +inline fun Validation.shouldBeInvalid( + value: T, + fn: (Invalid) -> Unit, +): Invalid { + val invalid = this.shouldBeInvalid(value) + fn(invalid) + return invalid +} + +/** + * Asserts that the validation result contains an error for the given field. + * @param field either a string with the full path or a property + */ +fun Invalid.shouldContainError( + field: Any, + error: String, +) { + val path = Path.asPathOrToPath(field) + this.errors shouldContain PropertyValidationError(path, error) +} + +/** + * Asserts that the validation result contains an error for the given field. + * @param propertyPaths a list of paths to the error + */ +fun Invalid.shouldContainError( + propertyPaths: Collection, + error: String, +) { + val array = propertyPaths.toTypedArray() + val path = Path.asPathOrToPath(*array) + // For a clearer error message + this.shouldContainError(path, error) + val errors = this.get(*array) + errors.shouldNotBeNull() + errors shouldContain error +} + +fun Invalid.shouldNotContainErrorAt(vararg propertyPaths: Any) { + val path = Path.asPathOrToPath(*propertyPaths) + this.errors.map { it.dataPath } shouldNotContain path + this[propertyPaths].shouldBeNull() +} + +infix fun Invalid.shouldHaveErrorCount(count: Int) = this.errors shouldHaveSize count + +fun Invalid.shouldContainExactlyErrors(vararg errors: ValidationError) = this.errors.shouldContainExactlyInAnyOrder(*errors) + +fun Invalid.shouldContainExactlyErrors(vararg errors: Pair) = + this.errors shouldContainExactlyInAnyOrder errors.map { PropertyValidationError(it.first, it.second) } + +infix fun Invalid.shouldContainExactlyErrors(errors: List) = this.errors shouldContainExactlyInAnyOrder errors