Skip to content

Easy JSON in Kotlin with a Type-Safe Builder DSL

Posted on:January 8, 2022

After learning of the type-safe builders in Kotlin in 2019, I wanted to create a domain-specific language (DSL) to avoid having to use the rather cumbersome creation pattern in Jackson. I felt like Bob the Builder after writing mapper.createObjectNode()... too many times.

Type-safe builder

Domain Specific Languages (DSLs)

If you haven’t seen a type-safe builder before, it’s a way of building complex hierarchical data structures in a semi-declarative way. A good example of this is Kotlin’s HTML. This gives a domain specific language (DSL) of something like:

fun result() = html {
  head {
    title {+"XML encoding with Kotlin"}
  }
  body {
    h1 {+"Kotlin DSL"}
    p  {
      +"Here is "
      a("https://kotlinlang.org") { +"official Kotlin site" }
    }
  }
}

If you have used HTML before, this may look a little odd but also familiar, where we have functions that are expressed as keywords in Kotlin that are almost a transaction from <html>...</html> to html { ... }.

Maven/Gradle Import

Import the library for either Jackson or Gson:

// Gradle
implementation("com.abroadbent:jackson-dsl:0.2.0")
implementation("com.abroadbent:gson-dsl:0.2.0")
<!-- Maven -->
<dependency>
  <groupId>com.abroadbent</groupId>
  <artifactId>jackson-dsl</artifcatId>
  <version>0.2.0
</dependency>
<dependency>
  <groupId>com.abroadbent</groupId>
  <artifactId>gson-dsl</artifcatId>
  <version>0.2.0
</dependency>

JSON DSL Syntax

Using the Kotlin DSL, we have operators for array and object and primitive creation (through string, int, long, double and boolean functions), and we can build objects using put and arrays using add.

Once in the context of either an array or object, the primitive functions will expect a single value inside an array and a key/value pair in an object. This enforces the type-safety aspect of the DSL, as you won’t be able to create something which doesn’t convert into the Jackson JSON library.

This leads to a simple syntax such as:

val cat = object {
  put("name", "Princess")
  put("breed", "British Shorthair")
  put("age", 3)
  put("healthy", true)
  array("hobbies") {
    add("sleeping")
    add("eating")
    add("purring")
  }
}

Example

Let’s say we want to create the (rather nonsensical) JSON object:

{
  "one": 171,
  "two": [
    false,
    {
      "three": "bar"
    }
  ],
  "four": {
    "five": 1.83
  }
}

Using the Jackson builder we create this by:

val json = mapper.createObjectNode()
  .put("one", 171)
  .set<ObjectNode>(
    "two", mapper.createArrayNode()
      .add(false)
      .add(
        mapper.createObjectNode()
          .put("three", "bar")
      )
    )
  .set<ObjectNode>(
    "four", mapper.createObjectNode()
      .put("five", 1.83)
  )

This feels rather confusing and difficult to read.

Using the DSL, the same JSON object is created by:

val json = `object` {
    put("one", 171)
    array("two") {
        add(false)
        obj {
            put("three", "bar")
        }
    }
    `object`("four") {
        put("five", 1.83)
    }
}

This reads much closer to the original JSON object in structure and avoids the issues of having to use the createObjectNode and createArrayNode, we also don’t need to use the type-safety measures of Jackson’s set<ObjectNode>.


Implementation

The builder is created using @DslMarker to create a new annotation which will define the DSL. In our case, we created an annotation called @JsonMarker.

We create an abstract class JacksonElement which is tagged with the @JsonMarker annotation to show that it is the DSL language object. We have two implementations of JacksonElement which are JacksonObject and JacksonArray which are a representation of a Jackson object and array respectively. Anything that we define within JacksonObject will be allowed in the object { … } scope and anything defined within JacksonArray will be allowed in the array { … } scope.

There is a common interface between JacksonObject and JacksonArray for the hashCode, equals and toString functions. But the functions for mutating the elements changes between them functions in JacksonObject provide a key value to store the value against in the object whereas the JacksonArray will only store values. Therefore, the JacksonObject defines the primitive string function as fun put(key: String, value: String) whereas JacksonArray defines this as fun add(value: String).

For Gson, it’s exactly the same but with the classes named GsonElement, GsonObject and GsonArray.

The design of the API is intentionally minimalistic, you can build up nested objects and arrays with minimal effort using the DSL language. As this project was created to sample the type-safe builders in Kotlin, it is only solving one problem of repeated use of the createObjectNode and createArrayNode functions of Jackson’s ObjectMapper class.

Caveats

Due to "object" being a reserved keyword in Kotlin, backticks (`) are required around the word `object` { … } in order to create an object, or the alias obj { … } is available.


CI Pipeline

The project uses Github Actions, which was also relatively new at the time of when I created this project. This configuration file in the .github/workflows/build.yml directory in order to set up the build steps.

name: Build
on: [push]
jobs:
  build:
    runs-on: macos-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Setup JDK
        uses: actions/setup-java@v3
        with:
          distribution: temurin
          java-version: 11
      - name: Validate Gradle wrapper
        uses: gradle/wrapper-validation-action@v1
      - name: Gradle build
        uses: gradle/gradle-build-action@v2
        with:
          arguments: build
      - name: Publish reports to Codecov
        uses: codecov/codecov-action@v3

The build will run on any push or pull request in Github, on the latest version of MacOS. The first step actions/checkout@v3 pulls down the source code to the machine, the second step of actions/setup-java@v3 will setup the Eclipse Temurin flavour of Java version 11.

The gradle/wrapper-validation-action and gradle/gradle-build-action steps take care of running the build and test phases, then run our Jacoco coverage report. The final step of codecov/codecov-action@v3 will push the coverage report up to code which means you can see the full report on CodeCov.

Github build report
Successful run of the GitHub build workflow

Conclusion

Hopefully this article demonstrates that writing a DSL in Kotlin cuts down on the amount of repetition and complexity when using a third-party library.

Feel free to fork the repository on Github to play around with the DSL or use it in a project of your own.


Go to Source code

Go to Code Coverage