diff --git a/README.md b/README.md index 0123d73..6d4c85f 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,50 @@ val validateUser = Validation { } ``` +#### 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::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::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 diff --git a/api/konform.api b/api/konform.api index 2cb340c..37f8437 100644 --- a/api/konform.api +++ b/api/konform.api @@ -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 @@ -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 } diff --git a/api/konform.klib.api b/api/konform.klib.api index de3a549..60d3df6 100644 --- a/api/konform.klib.api +++ b/api/konform.klib.api @@ -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 (): kotlin.collections/MutableList> // io.konform.validation/ValidationBuilder.subValidations.|(){}[0] + final var path // io.konform.validation/ValidationBuilder.path|{}path[0] + final fun (): io.konform.validation.path/ValidationPath? // io.konform.validation/ValidationBuilder.path.|(){}[0] + final fun (io.konform.validation.path/ValidationPath?) // io.konform.validation/ValidationBuilder.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] diff --git a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt index 86ef6d6..58ca76e 100644 --- a/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt +++ b/src/commonMain/kotlin/io/konform/validation/ValidationBuilder.kt @@ -29,6 +29,25 @@ public open class ValidationBuilder { protected val constraints: MutableList> = mutableListOf() protected val subValidations: MutableList> = 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 = subValidations .let { @@ -99,19 +118,34 @@ public open class ValidationBuilder { pathSegment: PathSegment, prop: (T) -> Iterable, init: ValidationBuilder.() -> Unit, - ) = run(CallableValidation(pathSegment, prop, IterableValidation(buildWithNew(init)))) + ) { + val builder = ValidationBuilder() + init(builder) + val actualPath = builder.path ?: ValidationPath.of(pathSegment) + run(CallableValidation(actualPath, prop, IterableValidation(builder.build()))) + } private fun onEachArray( pathSegment: PathSegment, prop: (T) -> Array, init: ValidationBuilder.() -> Unit, - ) = run(CallableValidation(pathSegment, prop, ArrayValidation(buildWithNew(init)))) + ) { + val builder = ValidationBuilder() + init(builder) + val actualPath = builder.path ?: ValidationPath.of(pathSegment) + run(CallableValidation(actualPath, prop, ArrayValidation(builder.build()))) + } private fun onEachMap( pathSegment: PathSegment, prop: (T) -> Map, init: ValidationBuilder>.() -> Unit, - ) = run(CallableValidation(pathSegment, prop, MapValidation(buildWithNew(init)))) + ) { + val builder = ValidationBuilder>() + init(builder) + val actualPath = builder.path ?: ValidationPath.of(pathSegment) + run(CallableValidation(actualPath, prop, MapValidation(builder.build()))) + } @JvmName("onEachIterable") public infix fun KProperty1>.onEach(init: ValidationBuilder.() -> Unit): Unit = @@ -190,7 +224,12 @@ public open class ValidationBuilder { path: Any, f: (T) -> R, init: ValidationBuilder.() -> Unit, - ): Unit = run(CallableValidation(path, f, buildWithNew(init))) + ) { + val builder = ValidationBuilder() + 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. @@ -216,7 +255,12 @@ public open class ValidationBuilder { path: Any, f: (T) -> R?, init: ValidationBuilder.() -> Unit, - ): Unit = run(CallableValidation(path, f, buildWithNew(init).ifPresent())) + ) { + val builder = ValidationBuilder() + 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. @@ -227,7 +271,12 @@ public open class ValidationBuilder { path: Any, f: (T) -> R?, init: RequiredValidationBuilder.() -> Unit, - ): Unit = run(CallableValidation(path, f, RequiredValidationBuilder.buildWithNew(init))) + ) { + val builder = RequiredValidationBuilder() + init(builder) + val actualPath = builder.path ?: ValidationPath.of(path) + run(CallableValidation(actualPath, f, builder.build())) + } // endregion diff --git a/src/commonTest/kotlin/io/konform/validation/ConfigurablePathTest.kt b/src/commonTest/kotlin/io/konform/validation/ConfigurablePathTest.kt new file mode 100644 index 0000000..a2a3bc2 --- /dev/null +++ b/src/commonTest/kotlin/io/konform/validation/ConfigurablePathTest.kt @@ -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::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::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::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::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, + ) + + val validation = + Validation { + 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::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'", + ) + } +}