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.
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.
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.