Skip to content

Commit

Permalink
Add model for validation path (#155)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhoepelman authored Nov 13, 2024
1 parent a2dff2b commit 8ef4325
Show file tree
Hide file tree
Showing 44 changed files with 1,010 additions and 583 deletions.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ 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
validationResult[UserProfile::fullName]
validationResult.errors.messagesAtPath(UserProfile::fullName)
// yields listOf("must have at least 2 characters")

validationResult[UserProfile::age]
validationResult.errors.messagesAtPath(UserProfile::age)
// yields listOf("must be at least '0'")
```

Expand All @@ -80,8 +80,8 @@ or you can get all validation errors with details as a list:
```kotlin
validationResult.errors
// yields listOf(
// ValidationError(dataPath=.fullName, message=must have at least 2 characters),
// ValidationError(dataPath=.age, message=must be at least '0'
// ValidationError(path=ValidationPath(Prop(fullName)), message=must have at least 2 characters),
// ValidationError(path=ValidationPath(Prop(age)), message=must be at least '0')
// )
```

Expand Down
192 changes: 173 additions & 19 deletions api/konform.api

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions src/commonMain/kotlin/io/konform/validation/Validation.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package io.konform.validation

import io.konform.validation.path.PathSegment
import io.konform.validation.path.ValidationPath
import io.konform.validation.types.EmptyValidation
import io.konform.validation.types.NullableValidation
import io.konform.validation.types.PrependPathValidation
import io.konform.validation.types.ValidateAll

public interface Validation<in T> {
Expand All @@ -12,6 +15,10 @@ public interface Validation<in T> {
public fun validate(value: T): ValidationResult<@UnsafeVariance T>

public operator fun invoke(value: T): ValidationResult<@UnsafeVariance T> = validate(value)

public fun prependPath(path: ValidationPath): Validation<T> = PrependPathValidation(path, this)

public fun prependPath(pathSegment: PathSegment): Validation<T> = prependPath(ValidationPath.of(pathSegment))
}

/** Combine a [List] of [Validation]s into a single one that returns all validation errors. */
Expand Down
146 changes: 56 additions & 90 deletions src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
package io.konform.validation

import io.konform.validation.ValidationBuilder.Companion.buildWithNew
import io.konform.validation.builder.ArrayPropKey
import io.konform.validation.builder.IterablePropKey
import io.konform.validation.builder.MapPropKey
import io.konform.validation.builder.PropKey
import io.konform.validation.builder.PropModifier
import io.konform.validation.builder.PropModifier.NonNull
import io.konform.validation.builder.PropModifier.Optional
import io.konform.validation.builder.PropModifier.OptionalRequired
import io.konform.validation.builder.SingleValuePropKey
import io.konform.validation.internal.ArrayValidation
import io.konform.validation.internal.IterableValidation
import io.konform.validation.internal.MapValidation
import io.konform.validation.internal.ValidationNode
import io.konform.validation.kotlin.Grammar
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
import io.konform.validation.types.CallableValidation
import io.konform.validation.types.ConstraintsValidation
import io.konform.validation.types.IsClassValidation
import io.konform.validation.types.NullableValidation
import io.konform.validation.types.IterableValidation
import io.konform.validation.types.MapValidation
import kotlin.jvm.JvmName
import kotlin.reflect.KFunction1
import kotlin.reflect.KProperty1
Expand All @@ -27,16 +23,17 @@ private annotation class ValidationScope
@ValidationScope
public class ValidationBuilder<T> {
private val constraints = mutableListOf<Constraint<T>>()
private val subValidations = mutableMapOf<PropKey<T>, ValidationBuilder<*>>()
private val prebuiltValidations = mutableListOf<Validation<T>>()

public fun build(): Validation<T> {
val nestedValidations =
subValidations.map { (key, builder) ->
key.build(builder.build())
}
return ValidationNode(constraints, nestedValidations + prebuiltValidations)
}
private val subValidations = mutableListOf<Validation<T>>()

public fun build(): Validation<T> =
subValidations
.let {
if (constraints.isNotEmpty()) {
it.prepend(ConstraintsValidation(ValidationPath.EMPTY, constraints))
} else {
it
}
}.flatten()

public fun addConstraint(
errorMessage: String,
Expand All @@ -52,119 +49,93 @@ public class ValidationBuilder<T> {
}

private fun <R> onEachIterable(
name: String,
pathSegment: PathSegment,
prop: (T) -> Iterable<R>,
init: ValidationBuilder<R>.() -> Unit,
) {
requireValidName(name)
val key = IterablePropKey(prop, name, NonNull)
init(key.getOrCreateBuilder())
}
) = run(CallableValidation(pathSegment, prop, IterableValidation(buildWithNew(init))))

private fun <R> onEachArray(
name: String,
pathSegment: PathSegment,
prop: (T) -> Array<R>,
init: ValidationBuilder<R>.() -> Unit,
) {
requireValidName(name)
val key = ArrayPropKey(prop, name, NonNull)
init(key.getOrCreateBuilder())
}
) = run(CallableValidation(pathSegment, prop, ArrayValidation(buildWithNew(init))))

private fun <K, V> onEachMap(
name: String,
pathSegment: PathSegment,
prop: (T) -> Map<K, V>,
init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit,
) {
requireValidName(name)
init(MapPropKey(prop, name, NonNull).getOrCreateBuilder())
}
) = run(CallableValidation(pathSegment, prop, MapValidation(buildWithNew(init))))

@JvmName("onEachIterable")
public infix fun <R> KProperty1<T, Iterable<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit = onEachIterable(name, this, init)
public infix fun <R> KProperty1<T, Iterable<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit =
onEachIterable(PropRef(this), this, init)

@JvmName("onEachIterable")
public infix fun <R> KFunction1<T, Iterable<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit =
onEachIterable("$name()", this, init)
onEachIterable(FuncRef(this), this, init)

@JvmName("onEachArray")
public infix fun <R> KProperty1<T, Array<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit = onEachArray(name, this, init)
public infix fun <R> KProperty1<T, Array<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit =
onEachArray(PropRef(this), this, init)

@JvmName("onEachArray")
public infix fun <R> KFunction1<T, Array<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit = onEachArray("$name()", this, init)
public infix fun <R> KFunction1<T, Array<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit =
onEachArray(FuncRef(this), this, init)

@JvmName("onEachMap")
public infix fun <K, V> KProperty1<T, Map<K, V>>.onEach(init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit): Unit =
onEachMap(name, this, init)
onEachMap(PropRef(this), this, init)

@JvmName("onEachMap")
public infix fun <K, V> KFunction1<T, Map<K, V>>.onEach(init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit): Unit =
onEachMap("$name()", this, init)
onEachMap(FuncRef(this), this, init)

public operator fun <R> KProperty1<T, R>.invoke(init: ValidationBuilder<R>.() -> Unit): Unit = validate(name, this, init)
public operator fun <R> KProperty1<T, R>.invoke(init: ValidationBuilder<R>.() -> Unit): Unit = validate(PropRef(this), this, init)

public operator fun <R> KFunction1<T, R>.invoke(init: ValidationBuilder<R>.() -> Unit): Unit = validate("$name()", this, init)
public operator fun <R> KFunction1<T, R>.invoke(init: ValidationBuilder<R>.() -> Unit): Unit = validate(this, this, init)

public infix fun <R> KProperty1<T, R?>.ifPresent(init: ValidationBuilder<R>.() -> Unit): Unit = ifPresent(name, this, init)
public infix fun <R> KProperty1<T, R?>.ifPresent(init: ValidationBuilder<R>.() -> Unit): Unit = ifPresent(this, this, init)

public infix fun <R> KFunction1<T, R?>.ifPresent(init: ValidationBuilder<R>.() -> Unit): Unit = ifPresent("$name()", this, init)
public infix fun <R> KFunction1<T, R?>.ifPresent(init: ValidationBuilder<R>.() -> Unit): Unit = ifPresent(this, this, init)

public infix fun <R> KProperty1<T, R?>.required(init: ValidationBuilder<R>.() -> Unit): Unit = required(name, this, init)
public infix fun <R> KProperty1<T, R?>.required(init: ValidationBuilder<R>.() -> Unit): Unit = required(this, this, init)

public infix fun <R> KFunction1<T, R?>.required(init: ValidationBuilder<R>.() -> Unit): Unit = required("$name()", this, init)
public infix fun <R> KFunction1<T, R?>.required(init: ValidationBuilder<R>.() -> Unit): Unit = required(this, 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 pathSegment The [PathSegment] of the validation.
* is [Any] for backwards compatibility and easy of use, see [toPathSegment]
* @param f The function for which you want to validate the result of
* @see run
*/
public fun <R> validate(
name: String,
pathSegment: Any,
f: (T) -> R,
init: ValidationBuilder<R>.() -> Unit,
): Unit = init(f.toPropKey(name, NonNull).getOrCreateBuilder())
): Unit = run(CallableValidation(pathSegment, f, buildWithNew(init)))

/**
* Calculate a value from the input and run a validation on it, but only if the value is not null.
*/
public fun <R> ifPresent(
name: String,
pathSegment: Any,
f: (T) -> R?,
init: ValidationBuilder<R>.() -> Unit,
): Unit = init(f.toPropKey(name, Optional).getOrCreateBuilder())
): Unit = run(CallableValidation(pathSegment, 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.
*/
public fun <R> required(
name: String,
pathSegment: Any,
f: (T) -> R?,
init: ValidationBuilder<R>.() -> Unit,
): Unit = init(f.toPropKey(name, OptionalRequired).getOrCreateBuilder())
): Unit = run(CallableValidation(pathSegment, f, buildWithNew(init).required()))

public fun run(validation: Validation<T>) {
prebuiltValidations.add(validation)
}

private fun <R> ((T) -> R?).toPropKey(
name: String,
modifier: PropModifier,
): PropKey<T> {
requireValidName(name)
return SingleValuePropKey(this, name, modifier)
subValidations.add(validation)
}

private fun <R> PropKey<T>.getOrCreateBuilder(): ValidationBuilder<R> {
@Suppress("UNCHECKED_CAST")
return subValidations.getOrPut(this) { ValidationBuilder<R>() } as ValidationBuilder<R>
}

private fun requireValidName(name: String) =
require(Grammar.Identifier.isValid(name) || Grammar.FunctionDeclaration.isUnary(name)) {
"'$name' is not a valid kotlin identifier or getter name."
}

public inline fun <reified SubT : T & Any> ifInstanceOf(init: ValidationBuilder<SubT>.() -> Unit): Unit =
run(IsClassValidation<SubT, T>(SubT::class, required = false, buildWithNew(init)))

Expand All @@ -183,21 +154,16 @@ public class ValidationBuilder<T> {
/**
* Run a validation if the property is not-null, and allow nulls.
*/
public fun <T : Any> ValidationBuilder<T?>.ifPresent(init: ValidationBuilder<T>.() -> Unit): Unit =
run(NullableValidation(required = false, validation = buildWithNew(init)))
public fun <T : Any> ValidationBuilder<T?>.ifPresent(init: ValidationBuilder<T>.() -> Unit): Unit = run(buildWithNew(init).ifPresent())

/**
* Run a validation on a nullable property, giving an error on nulls.
*/
public fun <T : Any> ValidationBuilder<T?>.required(init: ValidationBuilder<T>.() -> Unit): Unit =
run(NullableValidation(required = true, validation = buildWithNew(init)))
public fun <T : Any> ValidationBuilder<T?>.required(init: ValidationBuilder<T>.() -> Unit): Unit = run(buildWithNew(init).required())

@JvmName("onEachIterable")
public fun <S, T : Iterable<S>> ValidationBuilder<T>.onEach(init: ValidationBuilder<S>.() -> Unit) {
val builder = ValidationBuilder<S>()
init(builder)
run(IterableValidation(builder.build()))
}
public fun <S, T : Iterable<S>> ValidationBuilder<T>.onEach(init: ValidationBuilder<S>.() -> Unit): Unit =
run(IterableValidation(buildWithNew(init)))

@JvmName("onEachArray")
public fun <T> ValidationBuilder<Array<T>>.onEach(init: ValidationBuilder<T>.() -> Unit) {
Expand Down
47 changes: 40 additions & 7 deletions src/commonMain/kotlin/io/konform/validation/ValidationError.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,46 @@
package io.konform.validation

public interface ValidationError {
public val dataPath: String
public val message: String
import io.konform.validation.helpers.prepend
import io.konform.validation.path.PathSegment
import io.konform.validation.path.ValidationPath

public companion object {
internal operator fun invoke(
dataPath: String,
/** Represents the path and error of a validation failure. */
public data class ValidationError(
public val path: ValidationPath,
public val message: String,
) {
public val dataPath: String get() = path.dataPath

public inline fun mapPath(f: (List<PathSegment>) -> List<PathSegment>): ValidationError = copy(path = ValidationPath(f(path.segments)))

internal fun prependPath(path: ValidationPath) = copy(path = this.path.prepend(path))

internal fun prependPath(pathSegment: PathSegment) = mapPath { it.prepend(pathSegment) }

internal companion object {
internal fun of(
pathSegment: PathSegment,
message: String,
): ValidationError = ValidationError(ValidationPath.of(pathSegment), message)

internal fun ofAny(
pathSegment: Any,
message: String,
): ValidationError = PropertyValidationError(dataPath, message)
): ValidationError = of(PathSegment.toPathSegment(pathSegment), message)
}
}

public fun List<ValidationError>.filterPath(vararg validationPath: Any): List<ValidationError> {
val path = ValidationPath.fromAny(*validationPath)
return filter { it.path == path }
}

public fun List<ValidationError>.filterDataPath(vararg validationPath: Any): List<ValidationError> {
val dataPath = ValidationPath.fromAny(*validationPath).dataPath
return filter { it.dataPath == dataPath }
}

public fun List<ValidationError>.messagesAtPath(vararg validationPath: Any): List<String> = filterPath(*validationPath).map { it.message }

public fun List<ValidationError>.messagesAtDataPath(vararg validationPath: Any): List<String> =
filterDataPath(*validationPath).map { it.message }
Loading

0 comments on commit 8ef4325

Please sign in to comment.