Object validation in Ktor/Kotlin
Introduction
As part of my series An opinionated Kotlin backend service, I was checking out several libraries to validate client requests.
My requirement was to use a Kotlin library to have a concise and fluent API without the typical Java verbosity (thus none of these made it onto my list: http://java-source.net/open-source/validation).
What I didn’t want (https://sebthom.github.io/oval):
public class BusinessObject { @NotNull
@NotEmpty
@Length(max=32)
private String name; @NotNull
private String deliveryAddress; @NotNull
private String invoiceAddress; @Assert(expr = "_value ==_this.deliveryAddress || _value == _
this.invoiceAddress", lang = "groovy")
public String mailingAddress;}
More to my liking (https://joi.dev), although it’s not a DSL but “just” a fluent API:
username: Joi.string()
.alphanum()
.min(3)
.max(30)
.required(),
email: Joi.string()
.email({ minDomainSegments: 2, tlds: { allow: ['com', 'net']
I want to use a single language to build my application. Annotations tend to blur the line between configuration and code as the mailingAddress example shows. The same is true for configuration files (yaml, json, xml etc.). True configuration files (e.g. deployment configuration) are ok but config files for object validation? Absolutely not.
Anyway I checked out the following 5 libraries:
The Bad
The first three libraries didn’t make it onto my shortlist.
EasyValidation
- 329 Github Stars, 7 Watchers, 63 Forks
- Last commit: 10/23/2020
The API is fluent but it doesn’t have a DSL:
Overall the API looked out-dated and it’s also dependent on some Android specific libraries.
Kamedon
- 19 Github Stars, 2 Watchers, 6 Forks
- Last commit: 10/08/2018
Not active, no community, dead project.
Kvalidation
- 43 Github Stars, 7 Watchers, 5 Forks
- Last commit: 9/11/2020
The validation DSL seems to be quite flexible, maybe too flexible:
There seems to be more boilerplate code compared to Valiktor and Konform. Together with the relative low interest for the library, my verdict was that I would give the other two a shot first and only get back to Kvalidation in case I found some showstopper in the other two libs.
The Good
One of my requirements was to be able to surface the validation errors to the client in a descriptive way so it would be clear which parameter failed validation.
The logic to create the error message had to be encapsulated in the validation code to make sure the specifics of the validation library doesn’t leak into other components of the app (e.g. the routing or error handling module). To achieve that I wanted the validation library to throw an IllegalArgumentException with an error message. I would then capture those exceptions and translate them to HTTP 400 responses (using a Ktor feature):
exception<IllegalArgumentException> { e ->
logger.error("Exception occurred: ${e.message}")
val response = TextContent(e.message ?: "Bad Request",
ContentType.Text.Plain.withCharset(Charsets.UTF_8),
HttpStatusCode.BadRequest
)
call.respond(response)
}
Valiktor
- 279 Github Stars, 10 Watchers, 27 Forks
- Last commit: 10/11/2020
The library raised two (minor) red flags right away.
The first was the fact that despite the claim of being a DSL, the examples were a mixture between DSL and a Builder type / fluent api:
data class Employee(val id: Int, val name: String, val email: String) {
init {
validate(this) {
validate(Employee::id).isPositive()
validate(Employee::name).hasSize(min = 3, max = 80)
validate(Employee::email).isNotBlank().isEmail()
}
}
}
The second was that a validation doesn’t return a result but throws a ConstraintViolationException which isn’t helpful to have a flat call structure.
In any case I still went ahead and put together a simple example to test the validation as part of a real application. Using some extension functions and runCatching, I got rid of most of the boilerplate code:
The actual validation logic is quite compact with an acceptable amount of boilerplate code:
init {
runCatching {
validate(this) {
validate(Account::accountUUID).matches(uuidRegex)
validate(Account::createdAt).isNotNull()
validate(Account::modifiedAt).isNotNull()
validate(Account::status).isNotNull()
}
}.throwOnFailure()
}
The errors surfaced to the client are self explaining although a bit verbose for my taste:
accountUUID: DefaultConstraintViolationMessage(property=accountUUID, value=20836570-d4ef-420e-b500–7b7f6, constraint=Matches(pattern=^[0–9a-fA-F]{8}-[0–9a-fA-F]{4}-[0–9a-fA-F]{4}-[0–9a-fA-F]{4}-[0–9a-fA-F]{12}$), message=Must match ^[0–9a-fA-F]{8}-[0–9a-fA-F]{4}-[0–9a-fA-F]{4}-[0–9a-fA-F]{4}-[0–9a-fA-F]{12}$)
Konform
- 300 Github Stars, 10 Watchers, 16 Forks
- Last commit: 3/21/2021
Here we have a “real” DSL:
val validateUser = Validation<UserProfile> {
UserProfile::fullName {
minLength(2)
maxLength(100)
}
UserProfile::age ifPresent {
minimum(0)
maximum(150)
}
}
I like the very short Account::accountUUID compared to the longer validate(Account::accountUUID) with Valiktor.
Using an extension function for the invocation and error handling, we get the following code for our Account object:
The actual validation logic is quite compact with even less boilerplate code than Valiktor:
init {
Validation<Account> {
Account::accountUUID {
pattern(uuidRegex)
}
Account::createdAt required { }
Account::modifiedAt required { }
Account::status required { }
}.validateAndThrowOnFailure(this)
}
The errors surfaced to the client are self explaining and very short:
[ValidationError(dataPath=.accountUUID, message=must match the expected pattern)]
Verdict
- Konform (winner)
- Valiktor (runner-up)
- Kvalidation (promissing)
I barely scratched the surface of these libraries and will certainly have more to talk about once I added more complex validations but for the time being I will use both Konform and Valiktor in parallel.
Feel free to comment and provide feedback. Happy coding!
Addendum 1
Adding this library https://github.com/michaelbull/kotlin-result the error handling part for Valiktor can be changed to:
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.onFailurefun <V> Result<V, Throwable>.throwOnFailure() {
onFailure {
val error = (component2() as ConstraintViolationException)
throw IllegalArgumentException(error.getMessage())
}
}
The runCatching part stays the same with one extra import:
import com.github.michaelbull.result.*runCatching {
validate(this) {
validate(Account::accountUUID).matches(uuidRegex)
validate(Account::status).isNotNull()
}
}.throwOnFailure()
Addendum 2
While experimenting with different JSON Serializer/Deserializer libraries (see https://medium.com/p/feae3d06eadb) I realized that triggering the validation in init { } won’t work because e.g. GSON constructs the objects without using the primary constructor (see e.g. why-kotlin-data-classes-can-have-nulls-in-non-nullable-fields-with-gson or data-class-init-function-is-not-called-when-object-generated-from-gson-in-kotlin). I therefore had to change how the validation is triggered by converting the init {} into a regular validate() function. So something like this:
data class Account(
var accountUUID: String,
var createdAt: Instant,
var modified: Instant,
var status: AccountStatus
) {
fun validate(): Account {
// do validation
In the router I need to call the validate() function now explicitly, so instead of doing:
val account = call.receive<Account>()
I do:
val account = call.receive<Account>().validate()