Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,50 @@ val validateUser = Validation<UserProfile> {
}
```

#### Configurable paths

You can override the path that appears in validation errors by setting the `path` property in a validation builder.
This is particularly useful for inline/value classes where you want the wrapper to be transparent in error paths,
or when the default path doesn't match your serialized structure (e.g., JSON):

```kotlin
@JvmInline
value class WrappedInt(val value: Int)

data class DataWithWrapper(val wrapped: WrappedInt)

val validation = Validation<DataWithWrapper> {
DataWithWrapper::wrapped {
// Remove the "wrapped" segment from the path
path = ValidationPath.EMPTY
validate("value", { it.value }) {
minimum(1)
}
}
}

// Error path will be ".value" instead of ".wrapped.value"
validation(DataWithWrapper(WrappedInt(0)))
// yields Invalid with error at path "value"
```

You can also set a custom path:

```kotlin
val validation = Validation<DataWithWrapper> {
DataWithWrapper::wrapped {
path = ValidationPath.of("customPath")
validate("value", { it.value }) {
minimum(1)
}
}
}

// Error path will be ".customPath.value"
```

This works with all validation methods including `ifPresent`, `required`, and `onEach`.

#### Split validations

You can define validations separately and run them from other validations
Expand Down
2 changes: 2 additions & 0 deletions api/konform.api
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ public class io/konform/validation/ValidationBuilder {
public final fun dynamic (Lkotlin/reflect/KFunction;Lkotlin/jvm/functions/Function2;)V
public final fun dynamic (Lkotlin/reflect/KProperty1;Lkotlin/jvm/functions/Function2;)V
protected final fun getConstraints ()Ljava/util/List;
public final fun getPath ()Lio/konform/validation/path/ValidationPath;
protected final fun getSubValidations ()Ljava/util/List;
public final fun hint (Lio/konform/validation/Constraint;Ljava/lang/String;)Lio/konform/validation/Constraint;
public final fun ifPresent (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
Expand All @@ -115,6 +116,7 @@ public class io/konform/validation/ValidationBuilder {
public final fun requiredOnNotNullProperty (Lkotlin/reflect/KProperty1;Lkotlin/jvm/functions/Function1;)V
public final fun run (Lio/konform/validation/Validation;)V
public final fun runDynamic (Lkotlin/jvm/functions/Function1;)V
public final fun setPath (Lio/konform/validation/path/ValidationPath;)V
public final fun userContext (Lio/konform/validation/Constraint;Ljava/lang/Object;)Lio/konform/validation/Constraint;
public final fun validate (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V
}
Expand Down
4 changes: 4 additions & 0 deletions api/konform.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ open class <#A: kotlin/Any?> io.konform.validation/ValidationBuilder { // io.kon
final val subValidations // io.konform.validation/ValidationBuilder.subValidations|{}subValidations[0]
final fun <get-subValidations>(): kotlin.collections/MutableList<io.konform.validation/Validation<#A>> // io.konform.validation/ValidationBuilder.subValidations.<get-subValidations>|<get-subValidations>(){}[0]

final var path // io.konform.validation/ValidationBuilder.path|{}path[0]
final fun <get-path>(): io.konform.validation.path/ValidationPath? // io.konform.validation/ValidationBuilder.path.<get-path>|<get-path>(){}[0]
final fun <set-path>(io.konform.validation.path/ValidationPath?) // io.konform.validation/ValidationBuilder.path.<set-path>|<set-path>(io.konform.validation.path.ValidationPath?){}[0]

final fun (io.konform.validation/Constraint<#A>).hint(kotlin/String): io.konform.validation/Constraint<#A> // io.konform.validation/ValidationBuilder.hint|hint@io.konform.validation.Constraint<1:0>(kotlin.String){}[0]
final fun (io.konform.validation/Constraint<#A>).path(io.konform.validation.path/ValidationPath): io.konform.validation/Constraint<#A> // io.konform.validation/ValidationBuilder.path|path@io.konform.validation.Constraint<1:0>(io.konform.validation.path.ValidationPath){}[0]
final fun (io.konform.validation/Constraint<#A>).replace(kotlin/String = ..., io.konform.validation.path/ValidationPath = ..., kotlin/Any? = ...): io.konform.validation/Constraint<#A> // io.konform.validation/ValidationBuilder.replace|replace@io.konform.validation.Constraint<1:0>(kotlin.String;io.konform.validation.path.ValidationPath;kotlin.Any?){}[0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@ public open class ValidationBuilder<T> {
protected val constraints: MutableList<Constraint<T>> = mutableListOf()
protected val subValidations: MutableList<Validation<T>> = mutableListOf()

/**
* Override the path that will be used for sub-validations and constraints in this builder.
* When set, this path replaces the default path that would normally be generated.
*
* This is useful for inline/value classes where the wrapper should be transparent in error paths,
* or other scenarios where the default path doesn't match the serialized structure.
*
* Example:
* ```kotlin
* WrapperClass::valueClass {
* path = ValidationPath.EMPTY // Remove this path segment
* ValueClass::integer {
* minimum(1)
* }
* }
* ```
*/
public var path: ValidationPath? = null

public open fun build(): Validation<T> =
subValidations
.let {
Expand Down Expand Up @@ -99,19 +118,34 @@ public open class ValidationBuilder<T> {
pathSegment: PathSegment,
prop: (T) -> Iterable<R>,
init: ValidationBuilder<R>.() -> Unit,
) = run(CallableValidation(pathSegment, prop, IterableValidation(buildWithNew(init))))
) {
val builder = ValidationBuilder<R>()
init(builder)
val actualPath = builder.path ?: ValidationPath.of(pathSegment)
run(CallableValidation(actualPath, prop, IterableValidation(builder.build())))
}

private fun <R> onEachArray(
pathSegment: PathSegment,
prop: (T) -> Array<R>,
init: ValidationBuilder<R>.() -> Unit,
) = run(CallableValidation(pathSegment, prop, ArrayValidation(buildWithNew(init))))
) {
val builder = ValidationBuilder<R>()
init(builder)
val actualPath = builder.path ?: ValidationPath.of(pathSegment)
run(CallableValidation(actualPath, prop, ArrayValidation(builder.build())))
}

private fun <K, V> onEachMap(
pathSegment: PathSegment,
prop: (T) -> Map<K, V>,
init: ValidationBuilder<Map.Entry<K, V>>.() -> Unit,
) = run(CallableValidation(pathSegment, prop, MapValidation(buildWithNew(init))))
) {
val builder = ValidationBuilder<Map.Entry<K, V>>()
init(builder)
val actualPath = builder.path ?: ValidationPath.of(pathSegment)
run(CallableValidation(actualPath, prop, MapValidation(builder.build())))
}

@JvmName("onEachIterable")
public infix fun <R> KProperty1<T, Iterable<R>>.onEach(init: ValidationBuilder<R>.() -> Unit): Unit =
Expand Down Expand Up @@ -190,7 +224,12 @@ public open class ValidationBuilder<T> {
path: Any,
f: (T) -> R,
init: ValidationBuilder<R>.() -> Unit,
): Unit = run(CallableValidation(path, f, buildWithNew(init)))
) {
val builder = ValidationBuilder<R>()
init(builder)
val actualPath = builder.path ?: ValidationPath.of(path)
run(CallableValidation(actualPath, f, builder.build()))
}

/**
* Build a new validation based on a transformed value of the input and run it.
Expand All @@ -216,7 +255,12 @@ public open class ValidationBuilder<T> {
path: Any,
f: (T) -> R?,
init: ValidationBuilder<R>.() -> Unit,
): Unit = run(CallableValidation(path, f, buildWithNew(init).ifPresent()))
) {
val builder = ValidationBuilder<R>()
init(builder)
val actualPath = builder.path ?: ValidationPath.of(path)
run(CallableValidation(actualPath, f, builder.build().ifPresent()))
}

/**
* Calculate a value from the input and run a validation on it, and give an error if the result is null.
Expand All @@ -227,7 +271,12 @@ public open class ValidationBuilder<T> {
path: Any,
f: (T) -> R?,
init: RequiredValidationBuilder<R>.() -> Unit,
): Unit = run(CallableValidation(path, f, RequiredValidationBuilder.buildWithNew(init)))
) {
val builder = RequiredValidationBuilder<R>()
init(builder)
val actualPath = builder.path ?: ValidationPath.of(path)
run(CallableValidation(actualPath, f, builder.build()))
}

// endregion

Expand Down
145 changes: 145 additions & 0 deletions src/commonTest/kotlin/io/konform/validation/ConfigurablePathTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package io.konform.validation

import io.konform.validation.constraints.minimum
import io.konform.validation.path.PathValue
import io.konform.validation.path.PropRef
import io.konform.validation.path.ValidationPath
import io.kotest.assertions.konform.shouldBeInvalid
import io.kotest.assertions.konform.shouldBeValid
import io.kotest.assertions.konform.shouldContainOnlyError
import kotlin.test.Test

class ConfigurablePathTest {
@JvmInline
value class WrappedInt(
val value: Int,
)

data class DataWithWrapper(
val wrapped: WrappedInt,
)

@Test
fun canOverridePathWithEmpty() {
// Test that setting path = ValidationPath.EMPTY removes the path segment
val validation =
Validation<DataWithWrapper> {
DataWithWrapper::wrapped {
path = ValidationPath.EMPTY
validate("value", { it.value }) {
minimum(1)
}
}
}

validation shouldBeValid DataWithWrapper(WrappedInt(5))
(validation shouldBeInvalid DataWithWrapper(WrappedInt(0))) shouldContainOnlyError
ValidationError.of(PathValue("value"), "must be at least '1'")
}

@Test
fun canOverridePathWithCustomPath() {
// Test that setting path to a custom path replaces the default
val validation =
Validation<DataWithWrapper> {
DataWithWrapper::wrapped {
path = ValidationPath.of("customPath")
validate("value", { it.value }) {
minimum(1)
}
}
}

validation shouldBeValid DataWithWrapper(WrappedInt(5))
(validation shouldBeInvalid DataWithWrapper(WrappedInt(0))) shouldContainOnlyError
ValidationError.of(ValidationPath.of("customPath", "value"), "must be at least '1'")
}

@Test
fun pathWorksWithIfPresent() {
data class DataWithNullable(
val wrapped: WrappedInt?,
)

val validation =
Validation<DataWithNullable> {
DataWithNullable::wrapped ifPresent {
path = ValidationPath.EMPTY
validate("value", { it.value }) {
minimum(1)
}
}
}

validation shouldBeValid DataWithNullable(null)
validation shouldBeValid DataWithNullable(WrappedInt(5))
(validation shouldBeInvalid DataWithNullable(WrappedInt(0))) shouldContainOnlyError
ValidationError.of(PathValue("value"), "must be at least '1'")
}

@Test
fun pathWorksWithRequired() {
data class DataWithNullable(
val wrapped: WrappedInt?,
)

val validation =
Validation<DataWithNullable> {
DataWithNullable::wrapped required {
path = ValidationPath.EMPTY
validate("value", { it.value }) {
minimum(1)
}
}
}

validation shouldBeValid DataWithNullable(WrappedInt(5))
// When path is set to EMPTY, the "is required" error also has an empty path
(validation shouldBeInvalid DataWithNullable(null)) shouldContainOnlyError
ValidationError.of(ValidationPath.EMPTY, "is required")
(validation shouldBeInvalid DataWithNullable(WrappedInt(0))) shouldContainOnlyError
ValidationError.of(PathValue("value"), "must be at least '1'")
}

@Test
fun pathWorksWithOnEach() {
data class DataWithList(
val items: List<WrappedInt>,
)

val validation =
Validation<DataWithList> {
DataWithList::items onEach {
path = ValidationPath.EMPTY
validate("value", { it.value }) {
minimum(1)
}
}
}

validation shouldBeValid DataWithList(listOf(WrappedInt(5), WrappedInt(10)))
// When path is set to EMPTY inside onEach, the list property segment is removed but index is kept
(validation shouldBeInvalid DataWithList(listOf(WrappedInt(0)))) shouldContainOnlyError
ValidationError.of(ValidationPath.of(0, "value"), "must be at least '1'")
}

@Test
fun withoutPathOverrideShowsDefaultPath() {
// Verify that without setting path, the default path is used
val validation =
Validation<DataWithWrapper> {
DataWithWrapper::wrapped {
validate("value", { it.value }) {
minimum(1)
}
}
}

validation shouldBeValid DataWithWrapper(WrappedInt(5))
(validation shouldBeInvalid DataWithWrapper(WrappedInt(0))) shouldContainOnlyError
ValidationError.of(
ValidationPath.of(PropRef(DataWithWrapper::wrapped), "value"),
"must be at least '1'",
)
}
}
Loading