A validation library with distinct separation of pre- and post-validation models, focused on validator composability.
This library is based on an idea of a clear separation between a non-validated and validated models, which makes only a validated and mapped data appear in the resulting model:
@ValidatedAs(Person::class)
data class PersonDraft(
val firstName: String?,
val lastName: String?,
val age: String?,
val index: Int,
val nickname: String?
)
data class Person(
val firstName: String,
val lastName: String,
val age: Int
)
Given above data types, library will generate a validator builder which will let you specify validation and mapping rules:
val validator = PersonDraftValidatorBuilder<String>()
.firstName(isNotNull(error = "expected not null first name"))
.lastName(isNotNull(error = "expected not null last name"))
.age(isNonNullInteger(error = "expected not null int as age value"))
.build()
val personDraft = PersonDraft(
firstName = "Alex",
lastName = "Smirnoff",
age = "23",
index = 1,
nickname = "al183"
)
println(validator.validate(personDraft))
// "Ok(Person(firstName = "Alex", lastName = "Smirnoff", age = 23))"
println(validator.validate(personDraft.copy(firstName = null, lastName = null)))
// "Err(error = ["expected not null first name", "expected not null last name"])"
Note that validator successful output is a nice and clean Person
class with not nullable firstName
and lastName
fields and age
field converted from String?
to Int
.
Fields are matched by names, ones which are not present in the output model are ignored (index
, nickname
).
Properties of the source model can be annotated with @ValidatedName
annotation which specifies a custom property name
to match in the target model.
Generated validator builder is parametrized by an error type, and it is up to you to choose an error representation.
Here the enum
is used to represent individual field errors.
enum class PersonFormError { MissingFirstName, MissingLastName, AgeIsNotInt }
val validator = PersonDraftValidatorBuilder<PersonFormError>()
.firstName(isNotNull(error = PersonFormError.MissingFirstName))
.lastName(isNotNull(error = PersonFormError.MissingLastName))
.age(isNonNullInteger(error = PersonFormError.AgeIsNotInt))
.build()
Validation result is an instance of the Result
type which would be either an Ok(validatedModel)
or an Err<List<E>>
,
where E
is an error type:
val result = validator.validate(
PersonDraft(firstName = null, lastName = "Smirnoff", age = null, index = 1, nickname = null)
)
println(result)
// Err(error = [MissingFirstName, AgeIsNotInt])
Note how errors from the individual field validators are accumulated in the final result.
Validator is simply a function of type (I) -> Result<O, List<E>>
, where
I
is some input typeO
is a successful output typeE
is an error type
For example, here's an implementation of the (built-in) isNotNull()
validator:
fun <I : Any, E> isNotNull(error: E): Validator<I?, I, E> {
return Validator { input -> if (input != null) Ok(input) else Err(listOf(error)) }
}
Another example: an implementation of a custom validator which either converts String
input to a LocalDate
output or produces an error if conversion fails:
fun <E> hasDateFormat(pattern: String, error: E): Validator<String, LocalDate, E> {
return Validator { input ->
return try {
Ok(LocalDate.parse(input, DateTimeFormatter.ofPattern(pattern)))
} catch (e: DateTimeParseException) {
Err(listOf(error))
}
}
}
Existing validators can be composed together to form a new validator using buildValidator
:
fun <E> hasNotNullAgeAtLeast(age: Int, error: E): Validator<String?, Int, E> {
return buildValidator {
startWith(isNotNull(error = error))
.andThen(hasDateFormat(pattern = "yyyy.MM.dd", error = error))
.andThen(Validator { input: LocalDate -> Period.between(input, LocalDate.now()).years })
.andThen(isGreaterThanOrEqual(age, error))
}
}
Each successive validator in the composition chain will receive an output produced by the previous validator:
isNotNull
receivesString?
and returnsString
hasDateFormat
receivesString
and returnsLocalDate
- "inline"
Validator
receivesLocalDate
and returnsInt
- finally
isGreaterThanOrEqual
receivesInt
and checks its bounds
The resulting custom validator can then be used just like any other one:
@ValidatedAs(Person::class)
data class PersonDraft(
@ValidatedName("age")
val birthday: String?
)
data class Person(
val age: Int
)
val validator = PersonDraftValidatorBuilder<String>()
.birthday(hasNotNullAgeAtLeast(age = 18, "expected not null age of at least 18 years old"))
.build()
If you have several validator builders, they can be reused just like any other custom or built-in validator:
@ValidatedAs(Address::class)
data class AddressDraft(val city: String?, val street: String?, val house: Int?)
data class Address(val city: String, val street: String, val house: Int)
@ValidatedAs(Person::class)
data class PersonDraft(
val homeAddress: AddressDraft,
val extraAddresses: List<AddressDraft>?
)
data class Person(
val homeAddress: Address,
val extraAddresses: List<Address>
)
val addressValidator = AddressDraftValidatorBuilder<String>()
.city(isNotNull(error = "expected not null city"))
.street(isNotNull(error = "expected not null street"))
.house(isNotNull(error = "expected not null house"))
.build()
val personValidator = PersonDraftValidatorBuilder<String>()
.homeAddress(addressValidator)
.extraAddresses(buildValidator {
startWith(isNotNull(error = "expected not null extra addresses"))
.andThen(eachElement(addressValidator))
})
.build()
Note how personValidator
is able to turn each of the draft addresses in the list into a validated Address
model.
It will also accumulate errors (as en Err
case) and produce a list of failed to validate addresses if there
will be any.
implementation "ru.dimsuz:vanilla:0.14.1"
ksp "ru.dimsuz:vanilla-processor:0.14.1"
MIT License
Copyright (c) 2021 Dmitry Suzdalev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.