Skip to content

Commit

Permalink
Add function and lambda accessor syntax (#65)
Browse files Browse the repository at this point in the history
Co-authored-by: David Hoepelman <dhoepelman@flexport.com>
Co-authored-by: Jilles van Gurp <jillesvangurp@users.noreply.github.com>
Co-authored-by: David Hoepelman <992153+dhoepelman@users.noreply.github.com>
  • Loading branch information
4 people authored Sep 10, 2024
1 parent 4e1da83 commit 2d9ba43
Show file tree
Hide file tree
Showing 12 changed files with 622 additions and 125 deletions.
67 changes: 44 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ dependencies {

Suppose you have a data class like this:

```Kotlin
```kotlin
data class UserProfile(
val fullName: String,
val age: Int?
Expand All @@ -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> {
UserProfile::fullName {
minLength(2)
Expand All @@ -60,14 +60,14 @@ val validateUser = Validation<UserProfile> {

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")

Expand All @@ -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),
Expand All @@ -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> {
UserProfile::age ifPresent {
minimum(0) hint "Registering before birth is not supported"
Expand All @@ -109,7 +109,7 @@ val validateUser = Validation<UserProfile> {

You can use `{value}` to include the `.toString()`-ed data in the hint

```Kotlin
```kotlin
val validateUser = Validation<UserProfile> {
UserProfile::fullName {
minLength(2) hint "'{value}' is too short a name, must be at least 2 characters long."
Expand All @@ -119,40 +119,61 @@ val validateUser = Validation<UserProfile> {

#### 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> {
UserProfile::fullName {
addConstraint("Name cannot contain a tab") { !it.contains("\t") }
}
}
```

#### 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<UserProfile> {
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
}
}
```

#### Split validations

```Kotlin
val ageCheck = Validation<UserProfile> {
UserProfile::age required {
minimum(18)
You can define validations separately and run them from other validations

```kotlin
val ageCheck = Validation<Int?> {
required {
minimum(21)
}
}

val validateUser = Validation<UserProfile> {
UserProfile::fullName {
minLength(2)
maxLength(100)
UserProfile::age {
run(ageCheck)
}

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(
Expand Down Expand Up @@ -206,7 +227,7 @@ val validateEvent = Validation<Event> {
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]

Expand Down
38 changes: 35 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -95,6 +97,14 @@ kotlin {
//endregion

sourceSets {
val kotestSupported =
listOf(
appleTest,
jsTest,
jvmTest,
nativeTest,
wasmJsTest,
)
// Shared dependencies
commonMain.dependencies {
api(kotlin("stdlib"))
Expand All @@ -104,9 +114,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)
Expand All @@ -132,6 +146,24 @@ tasks.named<Test>("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<KotlinTestReport>("${it}Test") {
enabled = false
}
}

//endregion

//region Publishing configuration
Expand Down
90 changes: 74 additions & 16 deletions src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,48 +25,106 @@ public abstract class ValidationBuilder<T> {

public abstract infix fun Constraint<T>.hint(hint: String): Constraint<T>

public abstract operator fun <R> KProperty1<T, R>.invoke(init: ValidationBuilder<R>.() -> Unit)

internal abstract fun <R> onEachIterable(
prop: KProperty1<T, Iterable<R>>,
name: String,
prop: (T) -> Iterable<R>,
init: ValidationBuilder<R>.() -> Unit,
)

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

internal abstract fun <R> onEachArray(
prop: KProperty1<T, Array<R>>,
name: String,
prop: (T) -> Array<R>,
init: ValidationBuilder<R>.() -> Unit,
)

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

internal abstract fun <K, V> onEachMap(
prop: KProperty1<T, Map<K, V>>,
name: String,
prop: (T) -> Map<K, V>,
init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit,
)

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

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

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

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

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

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

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

public infix fun <R> KProperty1<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("$name()", this, init)

public infix fun <R> KProperty1<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("$name()", 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 f The function for which you want to validate the result of
* @see run
*/
public abstract fun <R> validate(
name: String,
f: (T) -> R,
init: ValidationBuilder<R>.() -> Unit,
)

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

/** Run an arbitrary other validation. */
public abstract fun run(validation: Validation<T>)

public abstract val <R> KProperty1<T, R>.has: ValidationBuilder<R>
public abstract val <R> KFunction1<T, R>.has: ValidationBuilder<R>
}

/**
* Run a validation if the property is not-null, and allow nulls.
*/
public fun <T : Any> ValidationBuilder<T?>.ifPresent(init: ValidationBuilder<T>.() -> Unit) {
val builder = ValidationBuilderImpl<T>()
init(builder)
run(OptionalValidation(builder.build()))
}

/**
* Run a validation on a nullable property, giving an error on nulls.
*/
public fun <T : Any> ValidationBuilder<T?>.required(init: ValidationBuilder<T>.() -> Unit) {
val builder = ValidationBuilderImpl<T>()
init(builder)
Expand All @@ -84,8 +143,7 @@ public fun <S, T : Iterable<S>> ValidationBuilder<T>.onEach(init: ValidationBuil
public fun <T> ValidationBuilder<Array<T>>.onEach(init: ValidationBuilder<T>.() -> Unit) {
val builder = ValidationBuilderImpl<T>()
init(builder)
@Suppress("UNCHECKED_CAST")
run(ArrayValidation(builder.build()) as Validation<Array<T>>)
run(ArrayValidation(builder.build()))
}

@JvmName("onEachMap")
Expand Down
11 changes: 2 additions & 9 deletions src/commonMain/kotlin/io/konform/validation/ValidationResult.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package io.konform.validation

import kotlin.reflect.KProperty1
import io.konform.validation.kotlin.Path

public interface ValidationError {
public val dataPath: String
Expand Down Expand Up @@ -43,14 +43,7 @@ public sealed class ValidationResult<out T> {
public data class Invalid(
internal val internalErrors: Map<String, List<String>>,
) : ValidationResult<Nothing>() {
override fun get(vararg propertyPath: Any): List<String>? = internalErrors[propertyPath.joinToString("", transform = ::toPathSegment)]

private fun toPathSegment(it: Any): String =
when (it) {
is KProperty1<*, *> -> ".${it.name}"
is Int -> "[$it]"
else -> ".$it"
}
override fun get(vararg propertyPath: Any): List<String>? = internalErrors[Path.toPath(*propertyPath)]

override val errors: List<ValidationError> by lazy {
internalErrors.flatMap { (path, errors) ->
Expand Down
Loading

0 comments on commit 2d9ba43

Please sign in to comment.