From f5da6a175493de718316b32a55a622b13916ba2f Mon Sep 17 00:00:00 2001 From: Nikky Date: Tue, 31 Jan 2023 14:57:56 +0100 Subject: [PATCH 1/7] add function and lambda accessor syntax --- .../konform/validation/ValidationBuilder.kt | 32 +++++-- .../io/konform/validation/internal/Builder.kt | 73 +++++++------- .../konform/validation/internal/Validation.kt | 17 ++-- .../validation/ValidationBuilderTest.kt | 96 ++++++++++++++++++- 4 files changed, 168 insertions(+), 50 deletions(-) diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt index fdf0636..dfc2768 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 @@ -17,20 +18,33 @@ abstract class ValidationBuilder { abstract fun build(): Validation abstract fun addConstraint(errorMessage: String, vararg templateValues: String, test: (T) -> Boolean): Constraint abstract infix fun Constraint.hint(hint: String): Constraint - abstract operator fun KProperty1.invoke(init: ValidationBuilder.() -> Unit) - internal abstract fun onEachIterable(prop: KProperty1>, init: ValidationBuilder.() -> Unit) + abstract operator fun ((T) -> R).invoke(name: String, init: ValidationBuilder.() -> Unit) + internal abstract fun onEachIterable(name: String, prop: (T) -> Iterable, init: ValidationBuilder.() -> Unit) + internal abstract fun onEachArray(name: String, prop: (T) -> Array, init: ValidationBuilder.() -> Unit) + internal abstract fun onEachMap(name: String, prop: (T) -> Map, init: ValidationBuilder>.() -> Unit) + abstract fun ((T) -> R?).ifPresent(name: String, init: ValidationBuilder.() -> Unit) + abstract fun ((T) -> R?).required(name: String, init: ValidationBuilder.() -> Unit) + operator fun KProperty1.invoke(init: ValidationBuilder.() -> Unit) = invoke(name, init) + operator fun KFunction1.invoke(init: ValidationBuilder.() -> Unit) = invoke("$name()", init) + @JvmName("onEachArray") + infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit) = onEachArray(name, this, init) @JvmName("onEachIterable") - infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit) = onEachIterable(this, init) - internal abstract fun onEachArray(prop: KProperty1>, init: ValidationBuilder.() -> Unit) + infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit) = onEachIterable(name, this, init) + @JvmName("onEachMap") + infix fun KProperty1>.onEach(init: ValidationBuilder>.() -> Unit) = onEachMap(name, this, init) @JvmName("onEachArray") - infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit) = onEachArray(this, init) - internal abstract fun onEachMap(prop: KProperty1>, init: ValidationBuilder>.() -> Unit) + infix fun KFunction1>.onEach(init: ValidationBuilder.() -> Unit) = onEachArray("$name()", this, init) + @JvmName("onEachIterable") + infix fun KFunction1>.onEach(init: ValidationBuilder.() -> Unit) = onEachIterable("$name()", this, init) @JvmName("onEachMap") - infix fun KProperty1>.onEach(init: ValidationBuilder>.() -> Unit) = onEachMap(this, init) - abstract infix fun KProperty1.ifPresent(init: ValidationBuilder.() -> Unit) - abstract infix fun KProperty1.required(init: ValidationBuilder.() -> Unit) + infix fun KFunction1>.onEach(init: ValidationBuilder>.() -> Unit) = onEachMap("$name()", this, init) + infix fun KProperty1.ifPresent(init: ValidationBuilder.() -> Unit) = ifPresent(name, init) + infix fun KProperty1.required(init: ValidationBuilder.() -> Unit) = required(name, init) + infix fun KFunction1.ifPresent(init: ValidationBuilder.() -> Unit) = ifPresent("$name()", init) + infix fun KFunction1.required(init: ValidationBuilder.() -> Unit) = required("$name()", init) abstract fun run(validation: Validation) abstract val KProperty1.has: ValidationBuilder + abstract val KFunction1.has: ValidationBuilder } fun ValidationBuilder.ifPresent(init: ValidationBuilder.() -> Unit) { diff --git a/src/commonMain/kotlin/io/konform/validation/internal/Builder.kt b/src/commonMain/kotlin/io/konform/validation/internal/Builder.kt index 4a00179..5bbb86e 100644 --- a/src/commonMain/kotlin/io/konform/validation/internal/Builder.kt +++ b/src/commonMain/kotlin/io/konform/validation/internal/Builder.kt @@ -7,6 +7,7 @@ import io.konform.validation.internal.ValidationBuilderImpl.Companion.PropModifi import io.konform.validation.internal.ValidationBuilderImpl.Companion.PropModifier.Optional import io.konform.validation.internal.ValidationBuilderImpl.Companion.PropModifier.OptionalRequired import kotlin.collections.Map.Entry +import kotlin.reflect.KFunction1 import kotlin.reflect.KProperty1 internal class ValidationBuilderImpl : ValidationBuilder() { @@ -20,22 +21,24 @@ 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 { @@ -43,15 +46,16 @@ internal class ValidationBuilderImpl : ValidationBuilder() { 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 { @@ -59,24 +63,25 @@ internal class ValidationBuilderImpl : ValidationBuilder() { 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)) } } } @@ -95,14 +100,14 @@ internal class ValidationBuilderImpl : ValidationBuilder() { return 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 { + val key = SingleValuePropKey(this, name, modifier) @Suppress("UNCHECKED_CAST") 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) } @@ -112,32 +117,34 @@ internal class ValidationBuilderImpl : ValidationBuilder() { return (subValidations.getOrPut(this, { ValidationBuilderImpl() }) as ValidationBuilder) } - override fun KProperty1.invoke(init: ValidationBuilder.() -> Unit) { - getOrCreateBuilder(NonNull).also(init) + override fun ((T) -> R).invoke(name: String, init: ValidationBuilder.() -> Unit) { + getOrCreateBuilder(name, NonNull).also(init) } - override fun onEachIterable(prop: KProperty1>, init: ValidationBuilder.() -> Unit) { - prop.getOrCreateIterablePropertyBuilder(NonNull).also(init) + override fun onEachIterable(name: String, prop: (T) -> Iterable, init: ValidationBuilder.() -> Unit) { + prop.getOrCreateIterablePropertyBuilder(name, NonNull).also(init) } - override fun onEachArray(prop: KProperty1>, init: ValidationBuilder.() -> Unit) { - ArrayPropKey(prop, NonNull).getOrCreateBuilder().also(init) + override fun onEachArray(name: String, prop: (T) -> Array, init: ValidationBuilder.() -> Unit) { + ArrayPropKey(prop, name, NonNull).getOrCreateBuilder().also(init) } - override fun onEachMap(prop: KProperty1>, init: ValidationBuilder>.() -> Unit) { - MapPropKey(prop, NonNull).getOrCreateBuilder>().also(init) + override fun onEachMap(name: String, prop: (T) -> Map, init: ValidationBuilder>.() -> Unit) { + MapPropKey(prop, name, NonNull).getOrCreateBuilder>().also(init) } - override fun KProperty1.ifPresent(init: ValidationBuilder.() -> Unit) { - getOrCreateBuilder(Optional).also(init) + override fun ((T) -> R?).ifPresent(name: String, init: ValidationBuilder.() -> Unit) { + getOrCreateBuilder(name, Optional).also(init) } - override fun KProperty1.required(init: ValidationBuilder.() -> Unit) { - getOrCreateBuilder(OptionalRequired).also(init) + override fun ((T) -> R?).required(name: String, init: ValidationBuilder.() -> Unit) { + getOrCreateBuilder(name, OptionalRequired).also(init) } 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) diff --git a/src/commonMain/kotlin/io/konform/validation/internal/Validation.kt b/src/commonMain/kotlin/io/konform/validation/internal/Validation.kt index 13d2dde..43473bb 100644 --- a/src/commonMain/kotlin/io/konform/validation/internal/Validation.kt +++ b/src/commonMain/kotlin/io/konform/validation/internal/Validation.kt @@ -27,33 +27,36 @@ 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/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt index 9bf3916..a26d4a9 100644 --- a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt @@ -1,5 +1,7 @@ package io.konform.validation +import io.konform.validation.jsonschema.const +import io.konform.validation.jsonschema.enum import io.konform.validation.jsonschema.minItems import kotlin.test.Test import kotlin.test.assertEquals @@ -168,6 +170,95 @@ 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::password)) } + Register(email = "tester@test.com", password = "aaaaaaaaaaa").let { assertEquals(1, countErrors(splitDoubleValidation(it), Register::password)) } + Register(email = "tester@").let { assertEquals(2, countFieldsWithErrors(splitDoubleValidation(it))) } + } + + @Test + fun lambdaAccessorSyntax() { + val splitDoubleValidation = Validation { + val getPassword = { r: Register -> r.password } + val getEmail = { r: Register -> r.email } + getPassword("getPasswordLambda") { + minLength(1) + } + getPassword("getPasswordLambda") { + maxLength(10) + } + getEmail("getEmailLambda") { + 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::password)) } + Register(email = "tester@test.com", password = "aaaaaaaaaaa").let { assertEquals(1, countErrors(splitDoubleValidation(it), Register::password)) } + Register(email = "tester@").let { assertEquals(2, countFieldsWithErrors(splitDoubleValidation(it))) } + } + + @Test + fun complexLambdaAccessors() { + data class Token( + val claims: Map + ) + + fun ValidationBuilder.validateClaim( + key: String, + validations: ValidationBuilder.() -> Unit + ) { + val getClaimValue = { data: Token -> data.claims[key]} + getClaimValue.required(".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() { @@ -353,6 +444,9 @@ class ValidationBuilderTest { assertTrue(validation(Register(password = ""))[Register::password]!![0].contains("8")) } - private data class Register(val password: String = "", val email: String = "", val referredBy: String? = null, val home: Address? = null) + private data class Register(val password: String = "", val email: String = "", val referredBy: String? = null, val home: Address? = null) { + fun getPasswordFun() = password + fun getEmailFun() = email + } private data class Address(val address: String = "", val country: String = "DE") } From e67b637693c0b4a35fd521003c6840c6f3670d2b Mon Sep 17 00:00:00 2001 From: David Hoepelman <992153+dhoepelman@users.noreply.github.com> Date: Mon, 9 Sep 2024 12:21:00 +0200 Subject: [PATCH 2/7] Add validate API --- README.md | 57 ++++++++++++------- .../konform/validation/ValidationBuilder.kt | 21 ++++--- .../internal/ValidationBuilderImpl.kt | 17 +++--- .../io/konform/validation/kotlin/Grammar.kt | 16 ++++++ .../konform/validation/ReadmeExampleTest.kt | 53 +++++++++++++++-- .../validation/ValidationBuilderTest.kt | 8 +-- 6 files changed, 129 insertions(+), 43 deletions(-) create mode 100644 src/commonMain/kotlin/io/konform/validation/kotlin/Grammar.kt diff --git a/README.md b/README.md index 94d5625..42f4bad 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,13 +129,23 @@ 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) + } +} +``` + +#### Split validations -```Kotlin -val ageCheck = Validation { - UserProfile::age required { +You can define validations separately and run them from other validations + +```kotlin +val ageCheck = Validation { + required { minimum(18) } } @@ -146,13 +156,22 @@ val validateUser = Validation { maxLength(100) } - run(ageCheck) + UserProfile::age { + 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 +225,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/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt index bd0b522..1f1da9a 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt @@ -25,11 +25,6 @@ public abstract class ValidationBuilder { public abstract infix fun Constraint.hint(hint: String): Constraint - public abstract operator fun ((T) -> R).invoke( - name: String, - init: ValidationBuilder.() -> Unit, - ) - internal abstract fun onEachIterable( name: String, prop: (T) -> Iterable, @@ -58,9 +53,9 @@ public abstract class ValidationBuilder { init: ValidationBuilder.() -> Unit, ) - public operator fun KProperty1.invoke(init: ValidationBuilder.() -> Unit): Unit = invoke(name, init) + public operator fun KProperty1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate(name,this, init) - public operator fun KFunction1.invoke(init: ValidationBuilder.() -> Unit): Unit = invoke("$name()", init) + public operator fun KFunction1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate("$name()", this, init) @JvmName("onEachIterable") public infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit): Unit = onEachIterable(name, this, init) @@ -91,8 +86,20 @@ public abstract class ValidationBuilder { public infix fun KFunction1.required(init: ValidationBuilder.() -> Unit): Unit = required("$name()", init) + + /** + * Calculate a value from the input and run a validation on it. + * @param name The name that should be reported in validation errors + * @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) + + /** Run an arbitrary other validation. */ public abstract fun run(validation: Validation) + public abstract fun runOn(validation: Validation, f: (T) -> R) + public abstract val KProperty1.has: ValidationBuilder public abstract val KFunction1.has: ValidationBuilder } diff --git a/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt b/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt index 2e04716..48fa898 100644 --- a/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt +++ b/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt @@ -109,7 +109,7 @@ internal class ValidationBuilderImpl : ValidationBuilder() { ): ValidationBuilder { 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 ((T) -> Iterable).getOrCreateIterablePropertyBuilder( @@ -126,13 +126,6 @@ internal class ValidationBuilderImpl : ValidationBuilder() { return (subValidations.getOrPut(this, { ValidationBuilderImpl() }) as ValidationBuilder) } - override fun ((T) -> R).invoke( - name: String, - init: ValidationBuilder.() -> Unit, - ) { - getOrCreateBuilder(name, NonNull).also(init) - } - override fun onEachIterable( name: String, prop: (T) -> Iterable, @@ -180,6 +173,14 @@ internal class ValidationBuilderImpl : ValidationBuilder() { prebuiltValidations.add(validation) } + override fun validate( + name: String, + f: (T) -> R, + init: ValidationBuilder.() -> Unit, + ) { + f.getOrCreateBuilder(name, NonNull).also(init) + } + 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..391697f --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/kotlin/Grammar.kt @@ -0,0 +1,16 @@ +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 unicodeDigit = "\\p{Nd}" // Unicode digits (Nd) + private const val quotedSymbol = "[^`\r\n]" // Anything except backtick, CR, or LF inside backticks + + object Identifier { + private const val STRING = "([${letter}_][${letter}_$unicodeDigit]*)|`$quotedSymbol+`" + private val regex = "^$STRING$".toRegex() + fun isValid(s: String) = s.matches(regex) + } +} diff --git a/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt b/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt index 8362199..bae1e40 100644 --- a/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt @@ -12,13 +12,13 @@ import kotlin.test.Test import kotlin.test.assertEquals class ReadmeExampleTest { + data class UserProfile( + val fullName: String, + val age: Int?, + ) + @Test fun simpleValidation() { - data class UserProfile( - val fullName: String, - val age: Int?, - ) - val validateUser = Validation { UserProfile::fullName { @@ -130,4 +130,47 @@ 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") } + } + } + + val validateUser2 = Validation { + validate("trimmed name", { it.fullName.trim() }) { + minLength(5) + } + } + + + } + + @Test + fun splitValidations(){ + val ageCheck = Validation { + required { + minimum(18) + } + } + + val validateUser = Validation { + UserProfile::fullName { + minLength(2) + maxLength(100) + } + + UserProfile::age { + run(ageCheck) + } + } + + val transform = Validation { + validate("ageMinus10", { it.age?.let { age -> age - 10 } }) { + run(ageCheck) + } + } + } } diff --git a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt index 2571eb8..65bba77 100644 --- a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt @@ -9,15 +9,15 @@ 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() { From a900bec5bf0416bcce9e1c19a4fa50ae92bd348e Mon Sep 17 00:00:00 2001 From: David Hoepelman <992153+dhoepelman@users.noreply.github.com> Date: Tue, 10 Sep 2024 11:52:05 +0200 Subject: [PATCH 3/7] Fix API and tests --- README.md | 7 +- build.gradle.kts | 2 +- .../konform/validation/ValidationBuilder.kt | 13 ++- .../io/konform/validation/ValidationResult.kt | 13 +-- .../internal/ValidationBuilderImpl.kt | 7 ++ .../io/konform/validation/kotlin/Grammar.kt | 16 ++- .../io/konform/validation/kotlin/Path.kt | 22 ++++ .../konform/validation/ReadmeExampleTest.kt | 69 ++++++++---- .../validation/ValidationBuilderTest.kt | 62 ++++++----- .../shaded/kotest/konform/Matchers.kt | 105 ++++++++++++++++++ 10 files changed, 238 insertions(+), 78 deletions(-) create mode 100644 src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt create mode 100644 src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt diff --git a/README.md b/README.md index 42f4bad..1964973 100644 --- a/README.md +++ b/README.md @@ -146,16 +146,11 @@ You can define validations separately and run them from other validations ```kotlin val ageCheck = Validation { required { - minimum(18) + minimum(21) } } val validateUser = Validation { - UserProfile::fullName { - minLength(2) - maxLength(100) - } - UserProfile::age { run(ageCheck) } diff --git a/build.gradle.kts b/build.gradle.kts index 1f7fa1d..f335f8c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -104,7 +104,7 @@ kotlin { implementation(kotlin("test")) // implementation(kotlin("test-annotations-common")) // implementation(kotlin("test-common")) - // implementation(libs.kotest.assertions.core) + implementation(libs.kotest.assertions.core) // implementation(libs.kotest.framework.datatest) // implementation(libs.kotest.framework.engine) } diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt index 1f1da9a..1370b99 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt @@ -53,7 +53,7 @@ public abstract class ValidationBuilder { init: ValidationBuilder.() -> Unit, ) - public operator fun KProperty1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate(name,this, init) + public operator fun KProperty1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate(name, this, init) public operator fun KFunction1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate("$name()", this, init) @@ -86,20 +86,21 @@ public abstract class ValidationBuilder { public infix fun KFunction1.required(init: ValidationBuilder.() -> Unit): Unit = required("$name()", init) - /** * Calculate a value from the input and run a validation on it. - * @param name The name that should be reported in validation errors + * @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) + public abstract fun validate( + name: String, + f: (T) -> R, + init: ValidationBuilder.() -> Unit, + ) /** Run an arbitrary other validation. */ public abstract fun run(validation: Validation) - public abstract fun runOn(validation: Validation, f: (T) -> R) - public abstract val KProperty1.has: ValidationBuilder public abstract val KFunction1.has: ValidationBuilder } diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt b/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt index c866a75..eafe1c8 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationResult.kt @@ -1,7 +1,6 @@ package io.konform.validation -import kotlin.reflect.KFunction1 -import kotlin.reflect.KProperty1 +import io.konform.validation.kotlin.Path public interface ValidationError { public val dataPath: String @@ -44,15 +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 KFunction1<*, *> -> ".${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/ValidationBuilderImpl.kt b/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt index 48fa898..2fd8038 100644 --- a/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt +++ b/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt @@ -6,6 +6,7 @@ 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 @@ -178,9 +179,15 @@ internal class ValidationBuilderImpl : ValidationBuilder() { f: (T) -> R, init: ValidationBuilder.() -> Unit, ) { + requireValidName(name) f.getOrCreateBuilder(name, NonNull).also(init) } + 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 index 391697f..cb4bc0c 100644 --- a/src/commonMain/kotlin/io/konform/validation/kotlin/Grammar.kt +++ b/src/commonMain/kotlin/io/konform/validation/kotlin/Grammar.kt @@ -4,13 +4,21 @@ 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 unicodeDigit = "\\p{Nd}" // Unicode digits (Nd) - private const val quotedSymbol = "[^`\r\n]" // Anything except backtick, CR, or LF inside backticks + 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 { - private const val STRING = "([${letter}_][${letter}_$unicodeDigit]*)|`$quotedSymbol+`" + 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..a010fef --- /dev/null +++ b/src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt @@ -0,0 +1,22 @@ +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 bae1e40..606003c 100644 --- a/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt @@ -7,6 +7,9 @@ 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 @@ -17,6 +20,8 @@ class ReadmeExampleTest { val age: Int?, ) + private val johnDoe = UserProfile("John Doe", 30) + @Test fun simpleValidation() { val validateUser = @@ -133,44 +138,62 @@ class ReadmeExampleTest { @Test fun customValidations() { - val validateUser1 = Validation { - UserProfile::fullName { - addConstraint("Name cannot contain a tab") { !it.contains("\t") } + val validateUser1 = + Validation { + UserProfile::fullName { + addConstraint("Name cannot contain a tab") { !it.contains("\t") } + } } - } - val validateUser2 = Validation { - validate("trimmed name", { it.fullName.trim() }) { - minLength(5) - } + 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(18) + fun splitValidations() { + val ageCheck = + Validation { + required { + minimum(21) + } } - } - val validateUser = Validation { - UserProfile::fullName { - minLength(2) - maxLength(100) + val validateUser = + Validation { + UserProfile::age { + run(ageCheck) + } } - 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) + 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'") } } } diff --git a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt index 65bba77..47934a9 100644 --- a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt @@ -3,6 +3,12 @@ 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 @@ -12,7 +18,7 @@ class ValidationBuilderTest { private fun ValidationBuilder.minLength(minValue: Int) = addConstraint("must have at least {0} characters", minValue.toString()) { it.length >= minValue } - private fun ValidationBuilder.maxLength(minValue: Int) = + private fun ValidationBuilder.maxLength(minValue: Int) = addConstraint("must have at most {0} characters", minValue.toString()) { it.length <= minValue } private fun ValidationBuilder.matches(regex: Regex) = addConstraint("must have correct format") { it.contains(regex) } @@ -208,34 +214,30 @@ class ValidationBuilderTest { } @Test - fun lambdaAccessorSyntax() { + fun validateLambda() { val splitDoubleValidation = Validation { - val getPassword = { r: Register -> r.password } - val getEmail = { r: Register -> r.email } - getPassword("getPasswordLambda") { + validate("getPasswordLambda", { r: Register -> r.password }) { minLength(1) - } - getPassword("getPasswordLambda") { maxLength(10) } - getEmail("getEmailLambda") { + validate("getEmailLambda", { r: Register -> r.email }) { 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), "getPasswordLambda")) + 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") } - Register(email = "tester@test.com", password = "aaaaaaaaaaa").let { - assertEquals(1, countErrors(splitDoubleValidation(it), "getPasswordLambda")) + splitDoubleValidation.shouldBeInvalid(Register(email = "tester@test.com", password = "aaaaaaaaaaa")) { + it.shouldContainExactlyErrors(".getPasswordLambda" to "must have at most 10 characters") } - Register(email = "tester@").let { - assertEquals(2, countFieldsWithErrors(splitDoubleValidation(it))) + splitDoubleValidation.shouldBeInvalid(Register(email = "tester@", password = "")) { + it.shouldContainExactlyErrors( + ".getPasswordLambda" to "must have at least 1 characters", + ".getEmailLambda" to "must have correct format", + ) } } @@ -421,17 +423,23 @@ class ValidationBuilderTest { } } - Data().let { assertEquals(Valid(it), mapValidation(it)) } - Data( + mapValidation shouldBeValid Data() + + mapValidation.shouldBeInvalid(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)) + 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) } + } @Test 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..7c4e30c --- /dev/null +++ b/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt @@ -0,0 +1,105 @@ +// 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 +import io.kotest.matchers.shouldNot + +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 From 1925cf9f54c603f238dd8f4522bf1590d1619fc1 Mon Sep 17 00:00:00 2001 From: David Hoepelman <992153+dhoepelman@users.noreply.github.com> Date: Tue, 10 Sep 2024 12:13:34 +0200 Subject: [PATCH 4/7] Fix dependencies --- build.gradle.kts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index f335f8c..3f23c99 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -95,6 +95,13 @@ kotlin { //endregion sourceSets { + val kotestSupported = listOf( + appleTest, + jsTest, + jvmTest, + nativeTest, + wasmJsTest, + ) // Shared dependencies commonMain.dependencies { api(kotlin("stdlib")) @@ -104,9 +111,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) From b92b03c419896fcb1f5b37674c0b49f69c27a62c Mon Sep 17 00:00:00 2001 From: David Hoepelman <992153+dhoepelman@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:30:49 +0200 Subject: [PATCH 5/7] Make ifPresent and required for new API --- README.md | 7 +++ .../konform/validation/ValidationBuilder.kt | 50 ++++++++++++------- .../internal/ValidationBuilderImpl.kt | 30 ++++------- .../konform/validation/ReadmeExampleTest.kt | 20 ++++++++ .../validation/ValidationBuilderTest.kt | 3 +- 5 files changed, 70 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 1964973..834eec8 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,13 @@ 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 + } } ``` diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt index 1370b99..79e3956 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt @@ -43,20 +43,6 @@ public abstract class ValidationBuilder { init: ValidationBuilder>.() -> Unit, ) - public abstract fun ((T) -> R?).ifPresent( - name: String, - init: ValidationBuilder.() -> Unit, - ) - - public abstract fun ((T) -> R?).required( - name: String, - init: ValidationBuilder.() -> Unit, - ) - - public operator fun KProperty1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate(name, this, init) - - public operator fun KFunction1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate("$name()", this, init) - @JvmName("onEachIterable") public infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit): Unit = onEachIterable(name, this, init) @@ -78,13 +64,17 @@ public abstract class ValidationBuilder { public infix fun KFunction1>.onEach(init: ValidationBuilder>.() -> Unit): Unit = onEachMap("$name()", this, init) - public infix fun KProperty1.ifPresent(init: ValidationBuilder.() -> Unit): Unit = ifPresent(name, init) + public operator fun KProperty1.invoke(init: ValidationBuilder.() -> Unit): Unit = validate(name, this, init) + + 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()", 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, 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()", 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. @@ -98,6 +88,24 @@ public abstract class ValidationBuilder { 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) @@ -105,12 +113,18 @@ public abstract class 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) diff --git a/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt b/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt index 2fd8038..4a44281 100644 --- a/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt +++ b/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt @@ -108,6 +108,7 @@ internal class ValidationBuilderImpl : ValidationBuilder() { name: String, modifier: PropModifier, ): ValidationBuilder { + requireValidName(name) val key = SingleValuePropKey(this, name, modifier) @Suppress("UNCHECKED_CAST") return (subValidations.getOrPut(key) { ValidationBuilderImpl() } as ValidationBuilder) @@ -119,12 +120,12 @@ internal class ValidationBuilderImpl : ValidationBuilder() { ): 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) + return (subValidations.getOrPut(this) { ValidationBuilderImpl() } as ValidationBuilder) } override fun onEachIterable( @@ -151,20 +152,6 @@ internal class ValidationBuilderImpl : ValidationBuilder() { MapPropKey(prop, name, NonNull).getOrCreateBuilder>().also(init) } - override fun ((T) -> R?).ifPresent( - name: String, - init: ValidationBuilder.() -> Unit, - ) { - getOrCreateBuilder(name, Optional).also(init) - } - - override fun ((T) -> R?).required( - name: String, - init: ValidationBuilder.() -> Unit, - ) { - getOrCreateBuilder(name, OptionalRequired).also(init) - } - override val KProperty1.has: ValidationBuilder get() = getOrCreateBuilder(name, NonNull) override val KFunction1.has: ValidationBuilder @@ -178,10 +165,13 @@ internal class ValidationBuilderImpl : ValidationBuilder() { name: String, f: (T) -> R, init: ValidationBuilder.() -> Unit, - ) { - requireValidName(name) - f.getOrCreateBuilder(name, NonNull).also(init) - } + ) = 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)) { diff --git a/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt b/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt index 606003c..282dbfd 100644 --- a/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt @@ -195,5 +195,25 @@ class ReadmeExampleTest { 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/ValidationBuilderTest.kt b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt index 47934a9..e29df0a 100644 --- a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt @@ -251,8 +251,7 @@ class ValidationBuilderTest { key: String, validations: ValidationBuilder.() -> Unit, ) { - val getClaimValue = { data: Token -> data.claims[key] } - getClaimValue.required(".claims[$key]") { + required("claim_$key", { data: Token -> data.claims[key] }) { validations() } } From 5efa3004346d2a5accc761c013da895107739d10 Mon Sep 17 00:00:00 2001 From: David Hoepelman <992153+dhoepelman@users.noreply.github.com> Date: Tue, 10 Sep 2024 13:38:30 +0200 Subject: [PATCH 6/7] Formatting and fixes --- build.gradle.kts | 15 ++++++------ .../konform/validation/ValidationBuilder.kt | 2 +- .../internal/ValidationBuilderImpl.kt | 23 +++++++++++++------ .../io/konform/validation/kotlin/Path.kt | 7 ++++-- .../konform/validation/ReadmeExampleTest.kt | 18 ++++++++------- .../validation/ValidationBuilderTest.kt | 17 +++++++------- .../shaded/kotest/konform/Matchers.kt | 1 - 7 files changed, 49 insertions(+), 34 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 3f23c99..2a90e0c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -95,13 +95,14 @@ kotlin { //endregion sourceSets { - val kotestSupported = listOf( - appleTest, - jsTest, - jvmTest, - nativeTest, - wasmJsTest, - ) + val kotestSupported = + listOf( + appleTest, + jsTest, + jvmTest, + nativeTest, + wasmJsTest, + ) // Shared dependencies commonMain.dependencies { api(kotlin("stdlib")) diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt index 79e3956..c88e97d 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt @@ -72,7 +72,7 @@ public abstract class ValidationBuilder { 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 KProperty1.required(init: ValidationBuilder.() -> Unit): Unit = required(name, this, init) public infix fun KFunction1.required(init: ValidationBuilder.() -> Unit): Unit = required("$name()", this, init) diff --git a/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt b/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt index 4a44281..a7a00d4 100644 --- a/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt +++ b/src/commonMain/kotlin/io/konform/validation/internal/ValidationBuilderImpl.kt @@ -133,7 +133,8 @@ internal class ValidationBuilderImpl : ValidationBuilder() { prop: (T) -> Iterable, init: ValidationBuilder.() -> Unit, ) { - prop.getOrCreateIterablePropertyBuilder(name, NonNull).also(init) + requireValidName(name) + init(prop.getOrCreateIterablePropertyBuilder(name, NonNull)) } override fun onEachArray( @@ -141,7 +142,8 @@ internal class ValidationBuilderImpl : ValidationBuilder() { prop: (T) -> Array, init: ValidationBuilder.() -> Unit, ) { - ArrayPropKey(prop, name, NonNull).getOrCreateBuilder().also(init) + requireValidName(name) + init(ArrayPropKey(prop, name, NonNull).getOrCreateBuilder()) } override fun onEachMap( @@ -149,7 +151,8 @@ internal class ValidationBuilderImpl : ValidationBuilder() { prop: (T) -> Map, init: ValidationBuilder>.() -> Unit, ) { - MapPropKey(prop, name, NonNull).getOrCreateBuilder>().also(init) + requireValidName(name) + init(MapPropKey(prop, name, NonNull).getOrCreateBuilder()) } override val KProperty1.has: ValidationBuilder @@ -167,11 +170,17 @@ internal class ValidationBuilderImpl : ValidationBuilder() { 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 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)) + 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)) { diff --git a/src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt b/src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt index a010fef..9491da0 100644 --- a/src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt +++ b/src/commonMain/kotlin/io/konform/validation/kotlin/Path.kt @@ -7,8 +7,11 @@ import kotlin.reflect.KProperty1 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) + 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) } diff --git a/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt b/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt index 282dbfd..e8c28ff 100644 --- a/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ReadmeExampleTest.kt @@ -196,16 +196,18 @@ class ReadmeExampleTest { it.shouldContainError(".ageMinus10", "must be at least '21'") } - val required = Validation { - required("age", { it.age }) { - minimum(21) + val required = + Validation { + required("age", { it.age }) { + minimum(21) + } } - } - val optional = Validation { - ifPresent("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") diff --git a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt index e29df0a..bcccac2 100644 --- a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt @@ -251,7 +251,7 @@ class ValidationBuilderTest { key: String, validations: ValidationBuilder.() -> Unit, ) { - required("claim_$key", { data: Token -> data.claims[key] }) { + required("claim_$key", { data: Token -> data.claims[key] }) { validations() } } @@ -424,13 +424,15 @@ class ValidationBuilderTest { mapValidation shouldBeValid Data() - mapValidation.shouldBeInvalid(Data( - registrations = - mapOf( - "user1" to Register(email = "valid"), - "user2" to Register(email = "a"), + 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", ) @@ -438,7 +440,6 @@ class ValidationBuilderTest { it.shouldNotContainErrorAt(Data::registrations, "user1", Register::email) it.shouldHaveErrorCount(1) } - } @Test 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 index 7c4e30c..a7373e7 100644 --- a/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt +++ b/src/commonTest/kotlin/io/konform/validation/shaded/kotest/konform/Matchers.kt @@ -18,7 +18,6 @@ import io.kotest.matchers.collections.shouldNotContain import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.should -import io.kotest.matchers.shouldNot infix fun Validation.shouldBeValid(value: T) = this should beValid(value) From 3eecccdce13d707acb8a50dfc36c26c2d79260d7 Mon Sep 17 00:00:00 2001 From: David Hoepelman <992153+dhoepelman@users.noreply.github.com> Date: Tue, 10 Sep 2024 14:12:26 +0200 Subject: [PATCH 7/7] Disable wasm wasi tests --- build.gradle.kts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 2a90e0c..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" @@ -144,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