JSON construction DSL in Kotlin
In the previous blog post, I’ve suggested an approach to better object builders in Kotlin. That approach involved writing some support code, which is less than ideal solution. Now, I would like to make a step further and automate code generation for such builders.
As an example, I take a small library for creating serializable JSON object. It is a common task in many modern server applications: process some data and output response as JSON. Usually, DTOs are used to handle the output. I’ll show how to construct answers easily and simplify serialization using code generation.
The Goal
Suppose we have the following data model for our code review application:
interface CodeReview {
val id: String
val timestamp: Date
val finished: Boolean
val commits: Array<out Commit>
}
interface Commit {
val id: String
val author: Person
}
interface Person {
val name: String
}
Note that all properties are immutable. Wouldn’t it be great if we can have mutable interfaces generated during the compilation, as well as implementations for both variants? So that we would be able to write code like this with no additional effort?
val review = review {
id = "42"
timestamp = Date()
finished = true
commits(
{ id = "abc123", author { name = "John Doe" } },
{ id = "321cba", author { name = "Jane Doe" } }
)
}
It is easier than you think!
Annotation Processing
Let’s annotate those interfaces with @Json
and write a simple annotation processor. Since Kotlin 1.1.2 it is possible to generate Kotlin code in annotation processors, which allows us to apply all fancy DSL magic there. Of course, the processor itself can be written in Kotlin too.
Its entry point is very simple:
@SupportedAnnotationTypes(JSON_ANNTOTAION)
@SupportedSourceVersion(SourceVersion.RELEASE_8)
class JsonAnnotationProcessor : AbstractProcessor() {
private val kotlinGenerated by lazy { processingEnv.options["kapt.kotlin.generated"] }
override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
val elements = annotations.filter { it.qualifiedName.contentEquals(JSON_ANNTOTAION) }
.map { roundEnv.getElementsAnnotatedWith(it) }
.flatMap { it }
if (elements.any()) {
File(kotlinGenerated).mkdirs()
elements.forEach {
generateObjectBuilder(it)
}
}
return true
}
// code generation skipped
}
That’s it. I’ve skipped all code generation code for brevity. You can study it here.
Generated Code
The annotation processor generates following code for each of the interfaces:
interface MutablePerson : Person {
override var name: String
}
class PersonImpl(map: Map<String,Any> = mapOf()) : Person, JsonObject<PersonImpl>(map) {
override val name by string()
}
class MutablePersonImpl(map: MutableMap<String,Any> = mutableMapOf()) : MutablePerson, JsonObjectBuilder<MutablePersonImpl, MutablePerson>(map) {
override var name: String by string()
}
fun person(builder: MutablePerson.() -> Unit): Person = PersonImpl(MutablePersonImpl().apply(builder)._map)
What do we have here?
- A mutable interface, which is absolutely the same as the original, but with
var
properties instead ofval
- Two implementations for both mutable and immutable interfaces
- An utility function to create immutable object by building it from mutable counterpart.
The source code of JsonObject
base class, which hides all the magic, can be found here.
This significantly reduces the boilerplate code you need to write by hand.
Usage
Annotation processors are very easy to enable in Gradle and Maven builds, please refer to the Kotlin documentation.
Here is a sample Gradle configuration.
apply plugin: 'kotlin-kapt'
dependencies {
kapt project("com.github.kropp.jsonex:jsonex:0.1") // not available yet
}
kapt {
arguments {
arg("generate.kotlin.code", "true")
}
}
However, I haven’t released it yet, due to a small issue in Kotlin plugin for IntelliJ: Kotlin code generated by annotation processor compiles but is not resolved in IntelliJ
This issue kinda defeats all the advantages we can get from this approach. Hopefully, it will be fixed soon.
Subscribe to all blog posts via RSS