Last active
August 6, 2022 17:08
-
-
Save floatdrop/11de8d9de827f8ac11c1a1ae8260ccbd to your computer and use it in GitHub Desktop.
Konform proposal
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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