Skip to content

Instantly share code, notes, and snippets.

@floatdrop
Last active August 6, 2022 17:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save floatdrop/11de8d9de827f8ac11c1a1ae8260ccbd to your computer and use it in GitHub Desktop.
Save floatdrop/11de8d9de827f8ac11c1a1ae8260ccbd to your computer and use it in GitHub Desktop.
Konform proposal
package io.konform.validation
import com.quickbirdstudios.nonEmptyCollection.list.NonEmptyList
import com.quickbirdstudios.nonEmptyCollection.unsafe.toNonEmptyList
import kotlin.reflect.KProperty1
class Validation<T, E>(private val block: ValidationBuilder<T, E>.(T) -> Unit) : Validator<T, E> {
override operator fun invoke(
value: T,
builder: ValidationBuilder<T, E>
): Validated<T, NonEmptyList<Pair<PropertyPath, E>>> {
try {
builder.block(value)
} catch (_: BreakValidationBlock) {
}
return when (builder.isValid()) {
false -> Invalid(builder.errors.toNonEmptyList())
true -> Valid(value)
}
}
/**
* Maps Validation errors from type E to E0 and returns new Validation object.
*
* @see ValidationBuilder.by
*/
fun <EOut> with(errorMapper: (E) -> EOut): Validator<T, EOut> {
return ValidationErrorMapper(this, errorMapper)
}
}
internal class ValidationErrorMapper<T, EIn, EOut>(
private val validation: Validation<T, EIn>,
private val errorMapper: (EIn) -> EOut
) : Validator<T, EOut> {
override fun invoke(
value: T,
builder: ValidationBuilder<T, EOut>
): Validated<T, NonEmptyList<Pair<PropertyPath, EOut>>> =
when (val res = validation.invoke(value, ValidationBuilder(value, parent = builder))) {
is Validated.Invalid -> Invalid(res.errors.map { it.first to errorMapper(it.second) }.toNonEmptyList())
is Validated.Valid -> Valid(value)
}
}
class PropertyPath(private val l: List<String>) : List<String> by l {
override fun toString(): String {
return l.joinToString(".")
}
}
typealias Valid<A> = Validated.Valid<A>
typealias Invalid<E> = Validated.Invalid<NonEmptyList<Pair<PropertyPath, E>>>
sealed class Validated<out A, out E> {
data class Valid<out A>(val value: A) : Validated<A, Nothing>() {
override fun toString(): String = "Validated.Valid($value)"
}
data class Invalid<out E>(val errors: E) : Validated<Nothing, E>() {
override fun toString(): String = "Validated.Invalid($errors)"
}
}
interface Validator<T, E> {
operator fun invoke(
value: T,
builder: ValidationBuilder<T, E> = ValidationBuilder(value)
): Validated<T, NonEmptyList<Pair<PropertyPath, E>>>
}
@DslMarker
private annotation class ValidationScope
open class NoStackTraceRuntimeException(message: String) : Throwable(message, null, false, false)
object BreakValidationBlock : NoStackTraceRuntimeException("")
@ValidationScope
class ValidationBuilder<T, E>(
val value: T,
private val node: String? = null,
val failMode: FailMode = FailMode.FAILFAST,
private val parent: ValidationBuilder<*, *>? = null
) {
private val path: PropertyPath
get() = PropertyPath((parent?.path ?: emptyList()) + listOfNotNull(node))
enum class FailMode {
FAILFAST,
ACCUMULATE
}
private val subValidations = mutableListOf<ValidationBuilder<*, E>>()
internal val errors: List<Pair<PropertyPath, E>>
get() = blockErrors + subValidations.flatMap { it.errors }
private val blockErrors: MutableList<Pair<PropertyPath, E>> = mutableListOf()
private fun isInvalid(): Boolean = blockErrors.isNotEmpty() || subValidations.any { it.isInvalid() }
/**
* Returns true if current ValidationBuilder contains errors. Function can return different values in different validation parts.
*
* val userValidator = Validation<User, UserValidationError> {
* accumulate {
* isValid() shouldBe true
* User::age ifPresent { minimum(18) { UserAgeViolation(it) } }
* isValid() shouldBe false
* }
* }
*/
fun isValid(): Boolean = !isInvalid()
/**
* Gathers all errors inside validation block (opposite of fail-fast approach).
*
* val userValidator = Validation<User, UserValidationError> {
* accumulate {
* User::age ifPresent { minimum(18) { UserAgeViolation(it) } }
* User::name { minLength(2) { UserNameViolation(it) } }
* }
* }
*/
fun accumulate(block: ValidationBuilder<T, E>.(T) -> Unit) {
createSubBuilder(value, failMode = FailMode.ACCUMULATE, block = block)
}
/**
* Runs different validator in current context. Use [Validation.with] method to convert returned error type from validator to current error type of context.
*
* val userValidator = Validation<User, UserValidationError> {
* User::name { minLength(2) { UserNameViolation(it) } }
* }
*
* val externalValidator = Validation<User, UserValidationError> {
* run(userValidator)
* }
*/
fun run(validator: Validator<T, E>) {
// TODO: This can be reduced to validator(value, this)) with ProxyValidationBuilder
when (val result = validator(value, this)) {
is Validated.Invalid -> blockErrors.addAll(result.errors)
is Validated.Valid -> {}
}
}
/**
* Runs validation block on each element of Iterable.
*
* val userValidator = Validation<User, UserValidationError> {
* User::contacts onEach {
* minLength(2) { UserContactViolation(it) }
* }
* }
*/
@JvmName("onEachIterable")
infix fun <R> KProperty1<T, Iterable<R>>.onEach(block: ValidationBuilder<R, E>.(R) -> Unit) {
val property = this(value)
for ((index, value) in property.withIndex()) {
createSubBuilder(value, "${this.name}[$index]", block = block)
}
}
/**
* Runs validation block on each element of Array.
*
* val userValidator = Validation<User, UserValidationError> {
* User::measurements onEach {
* minimum(0) { UserMeasurementViolation(it) }
* }
* }
*/
@JvmName("onEachArray")
infix fun <R> KProperty1<T, Array<R>>.onEach(block: ValidationBuilder<R, E>.(R) -> Unit) {
val property = this(value)
for ((index, value) in property.withIndex()) {
createSubBuilder(value, "${this.name}[${index}]", block = block)
}
}
/**
* Runs validation block on each map entry of Map.
*
* val userValidator = Validation<User, UserValidationError> {
* User::wallet onEach {
* Map.Entry<String, Int>::value {
* minimum(0) { UserWalletViolation(it) }
* }
* }
* }
*/
@JvmName("onEachMap")
infix fun <K, V> KProperty1<T, Map<K, V>>.onEach(block: ValidationBuilder<Map.Entry<K, V>, E>.(Map.Entry<K, V>) -> Unit) {
val property = this(value)
for (entry in property) {
createSubBuilder(entry, "${this.name}[${entry.key}]", block = block)
}
}
/**
* Runs validation block only if property is not null.
*
* val userValidator = Validation<User, UserValidationError> {
* User::age ifPresent {
* minimum(18) { UserAgeViolation(it) }
* }
* }
*/
infix fun <R> KProperty1<T, R?>.ifPresent(block: ValidationBuilder<R, E>.(R) -> Unit) {
val property = this(value)
if (property != null) {
createSubBuilder(property, this.name, block = block)
}
}
/**
* Checks that property is not null. If not - [constructError] will be called and error added to context.
*
* val userValidator = Validation<User, UserValidationError> {
* User::age.required({UserAgeRequired}) {
* minimum(18) { UserAgeViolation(it) }
* }
* }
*/
fun <R> KProperty1<T, R?>.required(
constructError: () -> E,
block: ValidationBuilder<R, E>.(R) -> Unit = {}
) {
val property = this(value)
if (property == null) {
blockErrors.add(path to constructError())
} else {
createSubBuilder(property, this.name, block = block)
}
}
/**
* Creates context around validated property and executes validation on its value.
*
* val userValidator = Validation<User, UserValidationError> {
* User::name {
* it shouldBe "hello"
* }
* }
*/
operator fun <R> KProperty1<T, R>.invoke(block: ValidationBuilder<R, E>.(R) -> Unit) {
createSubBuilder(this(value), this.name, block = block)
}
/**
* Delegates validation of property to external validator. Use [Validation.with] method to convert returned error type from validator to current error type of context.
*
* val companyValidator = Validation<Company, CompanyValidationError> {
* Company::balance {
* minimum(0) { CompanyBalanceViolation(it) }
* }
* }
* val userValidator = Validation<User, UserValidationError> {
* User::company by companyValidator.with { UserCompanyViolation(it) }
* }
*/
infix fun <R> KProperty1<T, R>.by(validation: Validator<R, E>) {
createSubBuilder(this(value), this.name) { run(validation) }
}
/**
* Static field for one-liners:
*
* val userValidator = Validation<User, UserValidationError> {
* User::name.has.const("Ivan") { UserNameViolation(it) }
* }
*/
val <R> KProperty1<T, R>.has: ValidationBuilder<R, E>
get() = createSubBuilder(this(value), this.name) {}
/**
* General check function to verify boolean condition.
*
* val userValidator = Validation<User, UserValidationError> {
* check(it.name in clubInvitesList) { UserNameViolation(it) }
* }
*/
fun check(test: Boolean, constructError: () -> E) {
if (!test) {
fail(constructError()) // Propagates non-local return
}
}
/**
* Deferred check function for helpers. See helpers in `JsonSchema.kt`
*/
fun check(test: (T) -> Boolean, constructError: () -> E) {
if (!test(value)) {
fail(constructError()) // Propagates non-local return
}
}
/**
* Add error (with corrected property path) to validation context.
*/
fun fail(error: E) {
recordError(error)
if (parent.shouldBreakOnError()) {
throw BreakValidationBlock
}
}
private fun recordError(error: E) {
blockErrors.add(path to error)
}
private fun <R> createSubBuilder(
property: R,
node: String? = null,
failMode: FailMode = FailMode.FAILFAST,
block: ValidationBuilder<R, E>.(R) -> Unit
): ValidationBuilder<R, E> {
val builder = ValidationBuilder<R, E>(property, node, failMode, this)
try {
builder.block(property)
} catch (e: BreakValidationBlock) {
if (parent.shouldBreakOnError()) {
throw e
}
} finally {
subValidations.add(builder)
}
return builder
}
}
private fun <T, E> ValidationBuilder<T, E>?.shouldBreakOnError(): Boolean {
return this?.failMode != ValidationBuilder.FailMode.ACCUMULATE
}
fun <T : Number, E> ValidationBuilder<T, E>.minimum(
minimumInclusive: Number,
constructError: () -> E
) = check({ it.toDouble() >= minimumInclusive.toDouble() }, constructError)
fun <T : Number> ValidationBuilder<T, String>.minimum(
minimumInclusive: Number
) = check({ it.toDouble() >= minimumInclusive.toDouble() }, { "must be at least '$minimumInclusive'" })
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment