Validate parameterized collections using annotations with Spring Boot and Kotlin
Kotlin is a wonderful language that's enjoyed widespread adoption, but there are
still a few gotchas when using it with massive frameworks like Spring. In this
post, we're going to explore one such gotcha—validation of parameterized
collections like List<String>
—and a simple solution.
TLDR: Jump to the solution.
So, what's the problem?
Let's say you have a simple REST POST endpoint that accepts a request body
representing a Person
and simulates creating that Person
by returning a
dummy response. Here's the code:
data class Person(
val name: String,
val namesOfChildren: List<String>
)
@RestController
class RestEndpoints {
@PostMapping
fun createPerson(@RequestBody person: Person) = ResponseEntity.ok("Person created successfully!")
}
Okay, big deal. The Person
data class takes a name and a list of names of
children. Right now, if you call this endpoint with an empty body, you'll get a
400 Bad Request
as expected:
$ curl -X POST http://localhost:8080/ -H 'Content-Type: application/json' -d '{}'
{"timestamp":"2024-03-13T17:32:26.393+00:00","status":400,"error":"Bad Request","path":"/"}
If you look at your application logs, you'll see a message like this:
JSON parse error: Instantiation of [simple type, class com.deathbyquadratic.blogs.validateparameterizedcollectionsannotationsspringbootkotlin.Person] value failed for JSON property name due to missing (therefore NULL) value for creator parameter name which is a non-nullable type]
This is because the JSON parser requires us to pass parameters for all
non-nullable types. In our case, those are name
and namesOfChildren
. This
level of validation is something we kind of get for free with a strong type
system.
However, nothing prevents us from passing empty strings and lists like so:
$ curl -X POST http://localhost:8080/ -H 'Content-Type: application/json' -d@- <<EOF
{
"name": "",
"namesOfChildren": []
}
EOF
Person created successfully!
Let's prevent that by using the @NotBlank
and @NotEmpty
annotations. We'll
also need to add @Valid
to the request body so that Spring knows to run the
validators on it. Here's the revised code:
data class Person(
@field:NotBlank
val name: String,
@field:NotEmpty
val namesOfChildren: List<@NotBlank String>
)
@RestController
class RestEndpoints {
@PostMapping
fun createPerson(@Valid @RequestBody person: Person) =
ResponseEntity.ok("Person created successfully!")
}
The field:
prefix is necessary in Kotlin due to its terse syntax where name
and namesOfChildren
are both constructor parameters as well as backing fields.
We want to validate the backing fields. It's not necessary inside the
List<String>
, though, since annotation usage there is unambiguous: it can only
refer to type annotation.
Alrighty, let's give it a whirl:
$ curl -X POST http://localhost:8080/ -H 'Content-Type: application/json' -d@- <<EOF
{
"name": "",
"namesOfChildren": []
}
EOF
{"timestamp":"2024-03-13T17:46:06.469+00:00","status":400,"error":"Bad Request","path":"/"}
Yay! We're getting a 400 Bad Request
now like we should. Okay, let's pass in
both a name and some children's names as well:
$ curl -X POST http://localhost:8080/ -H 'Content-Type: application/json' -d@- <<EOF
{
"name": "John Doe",
"namesOfChildren": ["Bobby Doe", "Sally Doe"]
}
EOF
Person created successfully!
It works as expected. Oh yeah, we also added List<@NotBlank String>
, so the
list itself should fail to validate if we pass an empty string as one of its
items. Let's try it:
$ curl -X POST http://localhost:8080/ -H 'Content-Type: application/json' -d@- <<EOF
{
"name": "John Doe",
"namesOfChildren": ["Bobby Doe", "Sally Doe", ""]
}
EOF
Person created successfully!
Ah, crap... it validated, and it shouldn't have.
Where it all goes wrong, and how to fix it
The problem is that the Kotlin compiler, by default, doesn't retain type
annotations when compiling to JVM bytecode. Type annotations are simply
annotations that are applied to a type like how @NotBlank String
above applies
the @NotBlank
annotation to the String
type.
Since Kotlin drops type annotations during compilation, the Hibernate Validator which inspects annotations at runtime can't find them. From the validator's point of view, they never existed to begin with.
Thankfully, the solution is simple. As of Kotlin
1.4.0,
the Kotlin compiler takes a flag named -Xemit-jvm-type-annotations
, which will
retain the type annotations during compilation. Since Kotlin 1.4.0 is ancient at
this point, and you should be using a higher version anyway, you should have no
problem adding this compiler flag.
If you're using Maven, you can add compiler flags to the kotlin-maven-plugin
like so:
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<configuration>
<args>
<arg>-Xemit-jvm-type-annotations</arg>
</args>
</configuration>
</plugin>
If you use IntelliJ IDEA to build your project, it may not respect your
kotlin-maven-plugin
settings when building. You can either configure the
Kotlin compiler inside of IntelliJ IDEA to also compile with the
-Xemit-jvm-type-annotations
flag, or have IntelliJ IDEA run your Maven build
as a pre-build step.
Appendix
The source code referenced in this blog post is available on GitHub. In particular, this commit contains all of the meaningful changes.
Special thanks to Alex MacArthur for proofreading this post.
Published March 13, 2024 by Jacob Chappell