en ru de
Victor Kropp

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?

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.

kotlinprogramming
Send

Subscribe to all blog posts via RSS, follow me @kropp and join my Telegram channel.