Kalidation = A Kotlin validation DSL
Objective
Creation of a validation DSL which allows this kind of fluent code:
val spec = validationSpec {
constraints<Foo> {
property(Foo::bar) {
notBlank()
inValues("GREEN", "WHITE", "RED")
}
property(Foo::bax) {
min(3)
email()
}
property(Foo::baz) {
validByScript(lang = "groovy", script = "baz.validate()", alias = "baz")
}
returnOf(Foo::validate) {
assertTrue()
}
returnOf(Foo::total) {
min(10)
}
}
}
This DSL does Type Checking on the properties of the bean to validate, ie constraints on Foo
should only contain
properties of Foo
.
It also does Type Checking on the rule: eg: an email()
constraint is not applicable to an numeric property, so you
shouldn’t be allowed to put a constraint to such a property.
Furthermore, this DSL decouples your domain classes from any validation framework and annotations and, as such, respect the Clean Architecture.
Usage
For Kalidation to work, you need to add the arrow-core
, jakarta-validation
and jakarta-el
dependencies.
Example for gradle:
implementation("io.github.rcapraro:kalidation:1.9.1")
implementation("io.arrow-kt:arrow-core:1.2.0-RC")
implementation("jakarta.validation:jakarta.validation-api:3.0.1")
implementation("org.glassfish:jakarta.el:4.0.2")
You can then build a validationSpec
object with the DSL:
val spec = validationSpec(messageBundle = "MyMessages", locale = Locale.FRENCH) {
constraints<MyClass> {
property(MyClass::color) {
notBlank()
inValues("GREEN", "WHITE", "RED")
size(3, 5)
}
property(MyClass::token) {
regexp("[A-Za-z0-9]+")
}
property(MyClass::date) {
future()
}
returnOf(Foo::validate) {
assertTrue()
}
property(MyClass::innerClass) {
valid()
}
}
constraints<InnerClass> {
property(InnerClass::amount) {
negativeOrZero()
}
property(InnerClass::emailList) {
notEmpty()
eachElement {
notNull()
email()
}
}
}
}
val myClass = MyClass("BLUE", "foobar", LocalDateTime.parse("2017-12-03T10:15:30"), ...)
val validated = spec.validate(myClass)
In this example, validated
is an Arrow Either
object, which we can transform through Arrow
built-in functions: when
, fold
, getOrElse
, map
, etc.
There is also an alternative validation-function called validateNel
, which returns the Arrow
type NonEmptyList<ValidationResult>
.
See Arrow Validation for more documentation.
Example with fold
:
val validated = spec.validate(myClass)
validated.fold(
{ throw ValidationException(it) },
{ return it }
)
Example with when
:
val validated = spec.validate(myClass)
when (validated) {
is Valid -> return validated.a
is Invalid -> throw ValidationException(validated.e)
}
Monad comprehension:
You can also use the right side of the validated result to operate a monad comprehension on the validated class:
val validated = spec.validate(myClass)
.map { it.doSomething() } //it is myClass
//etc
Structure of the validation result:
The validation result structure is a Set
of ValidationResult
instances.
data class ValidationResult(
val fieldName: String,
val invalidValue: Any?,
val messageTemplate: String,
val message: String
)
The ValidationResult
object contains the name and the value of the field in error, the message template and the i18n
corresponding message.
Complete example
The following examples illustrates the use of Kalidation in a typical Spring Boot application, structured in a Clean Architecture way:
https://github.com/rcapraro/cleanarch
Implemented validation functions on properties
All classes
- notNull()
- isNull()
- valid(), used for cascading validation (on an inner class)
- validByScript(lang: String, script: String, alias: String = "_this", reportOn: String = "") - supports javascript, jexl and groovy scripts which returns a Boolean
Array
- size(val min: Int, val max: Int)
- notEmpty()
Collections (List, Set, etc.)
- size(val min: Int, val max: Int)
- notEmpty()
- subSetOf(val completeValues: List)
Maps
- size(val min: Int, val max: Int)
- notEmpty()
- hasKeys(val keys: List)
Boolean
- assertTrue()
- assertFalse()
CharSequence (String, StringBuilder, StringBuffer, etc.)
- notBlank()
- notEmpty()
- size(val min: Int, val max: Int)
- size(size: Int)
- regexp(val regexp: String)
- email()
- phoneNumber(val regionCode: String)
- inValues(val values: List)
- negativeOrZero()
- positiveOrZero()
- negative()
- positive()
- range(val min: Long, val max: Long)
- min(val value: Long)
- max(val value: Long)
- decimalMin(val value: String, val inclusive: Boolean)
- decimalMax(val value: String, val inclusive: Boolean)
- digits (val integer: Int, val fraction: Int)
- iso8601Date()
- inIso8601DateRange(startDate: String, stopDate: String)
Number (Integer, Float, Long, BigDecimal, BigInteger, etc.)
- range(val min: Long, val max: Long)
- negativeOrZero()
- positiveOrZero()
- negative()
- positive()
- min(val value: Long)
- max(val value: Long)
- decimalMin(val value: String, val inclusive: Boolean)
- decimalMax(val value: String, val inclusive: Boolean)
- digits (val integer: Int, val fraction: Int)
Temporal (LocalDate, LocalDateTime, ZonedDateTime, etc.)
- future()
- past()
- futureOrPresent()
- pastOrPresent()
For all methods, an optional message: String? parameter can be used to override the resource bundle message.
Validation on method return type
It is also possible to specify a validation on a return type of a method:
returnOf(Foo::validate) {
notNull()
assertTrue()
//etc...
}
The method returnOf
accepts an optional alias
parameter to report the violation on a specific property rather than
the method.
In this example, if the method validate returns false
, the ValidationResult
object will look like:
Invalid(e=[ValidationResult(
fieldName=validate.<return value>,
invalidValue=false,
messageTemplate={jakarta.validation.constraints.AssertTrue.message},
message=doit être vrai)]
)
Validation of containers (List, Maps, Sets, etc)
It is possible to validate each property inside a container:
eachElement(Foo::emails) {
notNull()
email()
//etc...
}
In case of more complex containers (ex: Map of List), a NonEmptyList
of indexes enables a navigation inside the
container types to validate.
For example, to validate the List<String?>
of a Map<String, List<String?>
, we must write the following validation:
eachElement(String::class, NonEmptyList(1, 0)) {
notNull()
email()
//etc...
}