diff --git a/README.md b/README.md index e9a6bfc..42db762 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,11 @@ val validateUser = Validation { ifPresent("yourName", /* ... */) { // your validations, only running if the result is not null } + // You can use a more extensive path, for example + // the path will be ".fullName.trimmed" here: + validate(ValidationPath.of(UserProfile::fullName, "trimmed"), { /* ... */ }) { + /* ... */ + } } ``` diff --git a/api/konform.api b/api/konform.api index 3b5ff85..29e9ae1 100644 --- a/api/konform.api +++ b/api/konform.api @@ -338,6 +338,15 @@ public final class io/konform/validation/path/ValidationPath$Companion { public final fun of ([Ljava/lang/Object;)Lio/konform/validation/path/ValidationPath; } +public final class io/konform/validation/types/AlwaysInvalidValidation : io/konform/validation/Validation { + public static final field INSTANCE Lio/konform/validation/types/AlwaysInvalidValidation; + public fun invoke (Ljava/lang/Object;)Lio/konform/validation/ValidationResult; + public fun prependPath (Lio/konform/validation/path/PathSegment;)Lio/konform/validation/Validation; + public fun prependPath (Lio/konform/validation/path/ValidationPath;)Lio/konform/validation/Validation; + public fun validate (Ljava/lang/Object;)Lio/konform/validation/Invalid; + public synthetic fun validate (Ljava/lang/Object;)Lio/konform/validation/ValidationResult; +} + public final class io/konform/validation/types/CallableValidation : io/konform/validation/Validation { public fun (Lio/konform/validation/path/ValidationPath;Lkotlin/jvm/functions/Function1;Lio/konform/validation/Validation;)V public synthetic fun (Lio/konform/validation/path/ValidationPath;Lkotlin/jvm/functions/Function1;Lio/konform/validation/Validation;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt index 750985e..59f8fec 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt @@ -4,7 +4,6 @@ import io.konform.validation.ValidationBuilder.Companion.buildWithNew import io.konform.validation.helpers.prepend import io.konform.validation.path.FuncRef import io.konform.validation.path.PathSegment -import io.konform.validation.path.PathSegment.Companion.toPathSegment import io.konform.validation.path.PropRef import io.konform.validation.path.ValidationPath import io.konform.validation.types.ArrayValidation @@ -126,33 +125,37 @@ public class ValidationBuilder { /** * Calculate a value from the input and run a validation on it. - * @param pathSegment The [PathSegment] of the validation. - * is [Any] for backwards compatibility and easy of use, see [toPathSegment] + * @param path The [PathSegment] or [ValidationPath] of the validation. + * is [Any] for backwards compatibility and ease of use, see [ValidationPath.of]. * @param f The function for which you want to validate the result of */ public fun validate( - pathSegment: Any, + path: Any, f: (T) -> R, init: ValidationBuilder.() -> Unit, - ): Unit = run(CallableValidation(pathSegment, f, buildWithNew(init))) + ): Unit = run(CallableValidation(path, f, buildWithNew(init))) /** * Calculate a value from the input and run a validation on it, but only if the value is not null. + * @param path The [PathSegment] or [ValidationPath] of the validation. + * is [Any] for backwards compatibility and ease of use, see [ValidationPath.of]. */ public fun ifPresent( - pathSegment: Any, + path: Any, f: (T) -> R?, init: ValidationBuilder.() -> Unit, - ): Unit = run(CallableValidation(pathSegment, f, buildWithNew(init).ifPresent())) + ): Unit = run(CallableValidation(path, f, buildWithNew(init).ifPresent())) /** * Calculate a value from the input and run a validation on it, and give an error if the result is null. + * @param path The [PathSegment] or [ValidationPath] of the validation. + * is [Any] for backwards compatibility and ease of use, see [ValidationPath.of]. */ public fun required( - pathSegment: Any, + path: Any, f: (T) -> R?, init: ValidationBuilder.() -> Unit, - ): Unit = run(CallableValidation(pathSegment, f, buildWithNew(init).required())) + ): Unit = run(CallableValidation(path, f, buildWithNew(init).required())) public fun run(validation: Validation) { subValidations.add(validation) diff --git a/src/commonMain/kotlin/io/konform/validation/path/PathSegment.kt b/src/commonMain/kotlin/io/konform/validation/path/PathSegment.kt index 384863a..cf13646 100644 --- a/src/commonMain/kotlin/io/konform/validation/path/PathSegment.kt +++ b/src/commonMain/kotlin/io/konform/validation/path/PathSegment.kt @@ -39,6 +39,10 @@ public sealed interface PathSegment { is Int -> PathIndex(pathSegment) is Map.Entry<*, *> -> pathSegment.toPathSegment() is KClass<*> -> PathClass(pathSegment) + is ValidationPath -> throw IllegalArgumentException( + "Converting $pathSegment to a PathSegment is likely a mistake, as it would lead to a nested ValidationPath", + ) + else -> PathValue(pathSegment) } } diff --git a/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt b/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt index 073b94e..0bd8b5f 100644 --- a/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt +++ b/src/commonMain/kotlin/io/konform/validation/path/ValidationPath.kt @@ -28,6 +28,20 @@ public data class ValidationPath( public companion object { internal val EMPTY = ValidationPath(emptyList()) - public fun of(vararg validationPath: Any): ValidationPath = ValidationPath(validationPath.map { PathSegment.toPathSegment(it) }) + /** + * Convert the specified arguments into a [ValidationPath] + * any [ValidationPath] given will be merged, anything else will be converted to [PathSegment] + * using [PathSegment.toPathSegment]. + */ + public fun of(vararg validationPath: Any): ValidationPath = + ValidationPath( + validationPath.flatMap { + if (it is ValidationPath) { + it.segments + } else { + listOf(PathSegment.toPathSegment(it)) + } + }, + ) } } diff --git a/src/commonMain/kotlin/io/konform/validation/types/EmptyValidation.kt b/src/commonMain/kotlin/io/konform/validation/types/AlwaysValidation.kt similarity index 50% rename from src/commonMain/kotlin/io/konform/validation/types/EmptyValidation.kt rename to src/commonMain/kotlin/io/konform/validation/types/AlwaysValidation.kt index e25bb5d..1e57cf5 100644 --- a/src/commonMain/kotlin/io/konform/validation/types/EmptyValidation.kt +++ b/src/commonMain/kotlin/io/konform/validation/types/AlwaysValidation.kt @@ -1,10 +1,13 @@ package io.konform.validation.types +import io.konform.validation.Invalid import io.konform.validation.Valid import io.konform.validation.Validation +import io.konform.validation.ValidationError import io.konform.validation.ValidationResult +import io.konform.validation.path.ValidationPath -/** Validation that always returns [Valid]. `unit` in monadic terms. */ +/** [Validation] that always returns [Valid]. `unit` in monadic terms. */ public object EmptyValidation : Validation { override fun validate(value: Any?): ValidationResult = Valid(value) @@ -14,3 +17,8 @@ public object EmptyValidation : Validation { override fun hashCode(): Int = 912378 } + +/** [Validation] that always returns [Invalid]. */ +public object AlwaysInvalidValidation : Validation { + override fun validate(value: Any?): Invalid = Invalid(listOf(ValidationError(ValidationPath.EMPTY, "always invalid"))) +} diff --git a/src/commonMain/kotlin/io/konform/validation/types/CallableValidation.kt b/src/commonMain/kotlin/io/konform/validation/types/CallableValidation.kt index 49fa58d..bce782c 100644 --- a/src/commonMain/kotlin/io/konform/validation/types/CallableValidation.kt +++ b/src/commonMain/kotlin/io/konform/validation/types/CallableValidation.kt @@ -4,7 +4,6 @@ import io.konform.validation.Invalid import io.konform.validation.Valid import io.konform.validation.Validation import io.konform.validation.ValidationResult -import io.konform.validation.path.PathSegment import io.konform.validation.path.ValidationPath /** Validate the result of a property/function. */ @@ -13,8 +12,8 @@ public class CallableValidation( private val callable: (T) -> R, private val validation: Validation, ) : Validation { - internal constructor(pathSegment: Any, callable: (T) -> R, validation: Validation) : - this(ValidationPath.of(PathSegment.toPathSegment(pathSegment)), callable, validation) + internal constructor(path: Any, callable: (T) -> R, validation: Validation) : + this(ValidationPath.of(path), callable, validation) override fun validate(value: T): ValidationResult { val toValidate = callable(value) diff --git a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt index 86fb1f4..023848c 100644 --- a/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt +++ b/src/commonTest/kotlin/io/konform/validation/ValidationBuilderTest.kt @@ -1,8 +1,6 @@ package io.konform.validation -import io.konform.validation.constraints.const import io.konform.validation.constraints.containsPattern -import io.konform.validation.constraints.enum import io.konform.validation.constraints.maxLength import io.konform.validation.constraints.minItems import io.konform.validation.constraints.minLength @@ -206,82 +204,6 @@ class ValidationBuilderTest { ) } - @Test - fun validateLambda() { - val splitDoubleValidation = - Validation { - validate("getPasswordLambda", { r: Register -> r.password }) { - minLength(1) - maxLength(10) - } - validate("getEmailLambda", { r: Register -> r.email }) { - pattern(".+@.+".toRegex()).hint("must have correct format") - } - } - - splitDoubleValidation shouldBeValid Register(email = "tester@test.com", password = "a") - splitDoubleValidation.shouldBeInvalid(Register(email = "tester@test.com", password = "")) { - it.shouldContainOnlyError(ValidationError.of("getPasswordLambda", "must have at least 1 characters")) - } - splitDoubleValidation.shouldBeInvalid(Register(email = "tester@test.com", password = "aaaaaaaaaaa")) { - it.shouldContainOnlyError(ValidationError.of("getPasswordLambda", "must have at most 10 characters")) - } - splitDoubleValidation.shouldBeInvalid(Register(email = "tester@", password = "")) { - it.shouldContainExactlyErrors( - ValidationError.of("getPasswordLambda", "must have at least 1 characters"), - ValidationError.of("getEmailLambda", "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( diff --git a/src/commonTest/kotlin/io/konform/validation/validationbuilder/ValidateTest.kt b/src/commonTest/kotlin/io/konform/validation/validationbuilder/ValidateTest.kt new file mode 100644 index 0000000..23447c2 --- /dev/null +++ b/src/commonTest/kotlin/io/konform/validation/validationbuilder/ValidateTest.kt @@ -0,0 +1,151 @@ +package io.konform.validation.validationbuilder + +import io.konform.validation.Valid +import io.konform.validation.Validation +import io.konform.validation.ValidationBuilder +import io.konform.validation.ValidationError +import io.konform.validation.constraints.const +import io.konform.validation.constraints.enum +import io.konform.validation.constraints.maxLength +import io.konform.validation.constraints.minLength +import io.konform.validation.constraints.pattern +import io.konform.validation.countFieldsWithErrors +import io.konform.validation.path.PathIndex +import io.konform.validation.path.PathSegment +import io.konform.validation.path.PathValue +import io.konform.validation.path.PropRef +import io.konform.validation.path.ValidationPath +import io.konform.validation.types.AlwaysInvalidValidation +import io.kotest.assertions.konform.shouldBeInvalid +import io.kotest.assertions.konform.shouldBeValid +import io.kotest.assertions.konform.shouldContainExactlyErrors +import io.kotest.assertions.konform.shouldContainOnlyError +import kotlin.test.Test +import kotlin.test.assertEquals + +class ValidateTest { + @Test + fun validateLambda() { + val splitDoubleValidation = + Validation { + validate("getPasswordLambda", { r: Register -> r.password }) { + minLength(1) + maxLength(10) + } + validate("getEmailLambda", { r: Register -> r.email }) { + pattern(".+@.+".toRegex()).hint("must have correct format") + } + } + + splitDoubleValidation shouldBeValid Register(email = "tester@test.com", password = "a") + splitDoubleValidation.shouldBeInvalid(Register(email = "tester@test.com", password = "")) { + it.shouldContainOnlyError(ValidationError.of("getPasswordLambda", "must have at least 1 characters")) + } + splitDoubleValidation.shouldBeInvalid(Register(email = "tester@test.com", password = "aaaaaaaaaaa")) { + it.shouldContainOnlyError(ValidationError.of("getPasswordLambda", "must have at most 10 characters")) + } + splitDoubleValidation.shouldBeInvalid(Register(email = "tester@", password = "")) { + it.shouldContainExactlyErrors( + ValidationError.of("getPasswordLambda", "must have at least 1 characters"), + ValidationError.of("getEmailLambda", "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 ifPresent() { + val validation = + Validation { + ifPresent("notBlank", { it?.ifBlank { null } }) { + run(AlwaysInvalidValidation) + } + } + + validation shouldBeValid null + validation shouldBeValid "" + validation shouldBeValid "\t" + validation shouldBeInvalid "abc" + } + + @Test + fun validatePath() { + val validation = + Validation { + validate("sub", { it }) { + run(AlwaysInvalidValidation) + } + validate(PathSegment.toPathSegment(Register::password), { it }) { + run(AlwaysInvalidValidation) + } + validate(ValidationPath.of("sub", 1), { it }) { + run(AlwaysInvalidValidation) + } + } + + (validation shouldBeInvalid "").shouldContainExactlyErrors( + ValidationError(ValidationPath(listOf(PathValue("sub"))), "always invalid"), + ValidationError(ValidationPath(listOf(PropRef(Register::password))), "always invalid"), + ValidationError(ValidationPath(listOf(PathValue("sub"), PathIndex(1))), "always invalid"), + ) + } + + private data class Register( + val password: String = "", + val email: String = "", + val referredBy: String? = null, + val home: Address? = null, + ) + + private data class Address( + val address: String = "", + val country: String = "DE", + ) +}