DEV Community

Cover image for Do We Really Need Reflection or Code Generation for JSON in Kotlin? Building a Lightweight JSON Library
Alexey Kozyakov
Alexey Kozyakov

Posted on

Do We Really Need Reflection or Code Generation for JSON in Kotlin? Building a Lightweight JSON Library

Building a lightweight Kotlin/JVM JSON library with a direct JSON representation and optional reflection-based mapping between JSON and Kotlin classes.

Why another parser library?

I wanted to investigate how convenient a JSON parser can be when it doesn't require any reflection or code generation.
The main goal was to create a tool with a concise, self-explanatory Kotlin API for building and accessing a JSON representation, which I could conveniently use in Android applications. Another requirement was to keep JSON-to-Kotlin-class mapping in a separate artifact, so applications that don't need reflection don't have to include it. This allows us to choose the approach that fits our use case by including only the required artifact.

Library architecture

Such a library can be divided into two modules:

  • low-level JSON-tree manipulation API
  • mapping between JSON and Kotlin classes

The first module requires only the Kotlin standard library. The second module depends on the first because it uses the low-level JSON API under the hood and also includes Kotlin reflection library. I decided to expose the first module as an api() dependency of the second one to allow the user to use direct JSON manipulation even if only the second module is included.
I used reflection for JSON-to-Kotlin-class mapping because of its simplicity even though it has some problems with R8 optimization that can be solved by supplying consumer-proguard-rules.pro keeping rules. Implementation of JSON-to-Kotlin-class mapping using code generation is outside the scope of this article.

I named these two modules:

  • json
  • json-reflect
Module Purpose Reflection
json JSON parsing, representation, accessors, DSL and writing No
json-reflect JSON ↔ Kotlin class mapping Yes

json-reflect depends on json, while json has no dependency on reflection.

Usage example

json

The json module can be imported as follows:

dependencies {
    implementation("io.github.alexeykozyakov.json:json:1.0.11")
}
Enter fullscreen mode Exit fullscreen mode

Usage of JSON accessors is pretty straightforward:

val json = parseJson("""{"name":"Alex","age":28}""")

println(json.string("name"))
println(json.int("age"))
Enter fullscreen mode Exit fullscreen mode

A JSON string can be built using the library DSL:

writeJson(
    jsonObj {
        string("name", "Alex")
        int("age", 28)
    }
)
Enter fullscreen mode Exit fullscreen mode

json-reflect

To include the json-reflect module, add the following dependency:

dependencies {
    implementation("io.github.alexeykozyakov.json:json-reflect:1.0.11")
}
Enter fullscreen mode Exit fullscreen mode

A simple json-reflect example looks like:

data class User(val name: String, val age: Int) : JsonModel

val user = fromJson<User>("""{ "name": "Alex", "age": 28 }""")
val json = user.toJson()
Enter fullscreen mode Exit fullscreen mode

More complex use cases can be found in github.com/AlexeyKozyakov/Json/tree/main/examples.

Now, let’s take a closer look at the library's API and some interesting implementation details.

json module

Representing JSON using sealed interface

According to the specification, JSON can be one of the following:

  • object -> { "key1": json1, ... "keyN": jsonN }
  • array -> [json1, json2, ... jsonN]
  • string -> "some text"
  • number -> 123
  • boolean -> true|false
  • null -> null

This hierarchy maps naturally to a Kotlin sealed interface.
A JSON object can be represented as a mapping from strings to JSON values, and a JSON array as a list of JSON values.
Furthermore, Kotlin's Number type provides a convenient common representation for JSON numeric values while allowing the parser to preserve their concrete numeric types internally.

sealed interface Json

data class JsonObject(val value: Map<String, Json>) : Json

data class JsonArray(val value: List<Json>) : Json

data class JsonString(val value: String) : Json

data class JsonNumber(val value: Number) : Json

data class JsonBoolean(val value: Boolean) : Json

data object JsonNull : Json
Enter fullscreen mode Exit fullscreen mode

JSON field accessors

To allow users to retrieve data from JSON, we can provide accessors implemented as extension functions on the Json type. These functions should throw an exception if the JSON structure does not match the expected type. I chose explicit function names based on JSON data types, making the JSON structure easier to understand directly from the code. There are two types of accessors in the library: those with non-null return values and those with nullable return values. Nullable accessors return null if a value for a given key is missing or if the provided value is JsonNull. Non-null accessors throw an exception in these cases.
Here is an example of some accessors. Note that you can provide a JSON key as an argument to interpret a Json value as a JsonObject and immediately retrieve the value for the given key.

// Usage
json.string("first_name")
json.string("last_name")
json.stringOrNull("description")
json.int("age")
json.array("posts")

// Implementation
fun Json.string(key: String) = obj().require(key).string()
fun Json.stringOrNull(key: String) = obj()[key]?.stringOrNull()
fun Json.obj() = requireNotNull(this as? JsonObject) { /*error*/ }
fun Json.number() = requireNotNull(this as? JsonNumber) { /*error*/ } 
fun Json.int() = number().toInt()
Enter fullscreen mode Exit fullscreen mode

Kotlin's Number type provides a common representation for JSON numbers, which can then be converted to the required numeric type in accessors such as long(), int(), float(), and double().

Here is an example of using accessors to map JSON to list of student profiles:

json.array("students").map { studentJson ->
            Student(
                age = studentJson.int("age"),
                gender = studentJson.boolean("gender"),
                name = studentJson.string("name"),
                courses = studentJson.array("courses").map { courseJson ->
                    Course(
                        id = courseJson.int("id"),
                        name = courseJson.string("name"),
                        description = courseJson.string("description")
                    )
                }
            )
        }
Enter fullscreen mode Exit fullscreen mode

JSON building DSL

Kotlin provides language features for implementing declarative DSLs for building arbitrary data structures. We can provide two types of DSL functions:

  • top-level functions, entry points to JSON-building
  • methods of array or object builders

In the json module, top-level builder functions start with the json prefix:

fun jsonObj(builder: JsonObjectBuilder.() -> Unit): JsonObject {
    return JsonObjectBuilder().apply(builder).build()
}

fun jsonArray(builder: JsonArrayBuilder.() -> Unit): JsonArray {
    return JsonArrayBuilder().apply(builder).build()
}

fun jsonString(value: String): JsonString {
    return JsonString(value)
}

//...
Enter fullscreen mode Exit fullscreen mode

Builder methods use the same names as the corresponding JSON accessors, keeping the API consistent.
Internally, object and array builders collect Json values and create immutable JsonObject and JsonArray instances when build() is called.

class JsonObjectBuilder {
    private val values = mutableMapOf<String, Json>()

    fun obj(key: String, builder: JsonObjectBuilder.() -> Unit) {
        values[key] = JsonObjectBuilder().apply(builder).build()
    }

    //...

    fun build(): JsonObject {
        return JsonObject(values.toMap())
    }
}

class JsonArrayBuilder {
    private val values = mutableListOf<Json>()

    fun obj(builder: JsonObjectBuilder.() -> Unit) {
        values += JsonObjectBuilder().apply(builder).build()
    }

    //...
}

Enter fullscreen mode Exit fullscreen mode

Building a user profile JSON will look like the following:

jsonObj {
    string("first_name", "Alexey")
    string("last_name", "Kozyakov")
    int("age", 28)
    array("subjects") {
        obj {
            int("id", 1)
            string("name", "Math")
            string("description", "Very important subject")
        }
        obj {
            int("id", 2)
            string("name", "English")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

JSON string parsing

Parsing JSON includes two main parts:

  • tokenizing
  • parsing itself

The tokenizer provides a sequence of tokens from the input string, which is then consumed by the parser.
In the library I provided one top-level function for parsing a JSON string. It uses the internal parser and tokenizer classes.

fun parseJson(input: String): Json {
    val tokenizer = JsonTokenizer(input)
    val parser = Parser(tokenizer)
    return parser.parse()
}
Enter fullscreen mode Exit fullscreen mode

Parsing is recursive, and the main parser function looks like this:

internal class Parser(
    private val tokenizer: JsonTokenizer
) {
    fun parse(): Json {
        return parseObject()
            ?: parseArray()
            ?: parseString()
            ?: parseNumber()
            ?: parseBoolean()
            ?: parseNull()
            ?: tokenizer.parsingError("...")
    }
Enter fullscreen mode Exit fullscreen mode

JsonTokenizer has methods get(): Token and pop(): Token to access the current token with or without moving to the next one.

Tokens are also represented as a sealed interface:

internal sealed interface Token
internal class TokenString(val value: String): Token
internal class TokenNumber(val value: Number): Token
//...
Enter fullscreen mode Exit fullscreen mode

Some important points:

  • parsing should support both integer and floating-point numbers. This can be achieved by parsing numbers as Long or Double and storing them as Number, which can then be converted to the required numeric type.
  • string literals can contain escaped characters or sequences, so the parser must support them, for example \n - newline, \t - tab, \uXXXX - unicode character with code XXXX.
  • proper error reporting with line and position numbers is important

I took these requirements into account when implementing the library.
Parser usage looks like this:

val json = parseJson("""{ "name": "Alex", "age": 28 }""")
Enter fullscreen mode Exit fullscreen mode

JSON string writing

It is useful for a JSON writer to support pretty-printing, which adds whitespace and newlines to the output JSON. The writing function can be implemented using recursion like this:

fun writeJson(json: Json, pretty: Boolean = true): String {
    return buildString {
        writeJson(json, pretty, level = 0)
    }
}

private fun StringBuilder.writeJson(json: Json, pretty: Boolean, level: Int) {
    when (json) {
        is JsonObject -> writeObject(json, pretty, level)
        is JsonArray -> writeArray(json, pretty, level)
        // ...
    }
}

private fun StringBuilder.writeObject(json: JsonObject, pretty: Boolean, level: Int) {
    val indent by lazy { String(CharArray(level * 4) { ' ' }) }
    append('{')
    if (pretty) append("\n")
    json.value.entries.forEachIndexed { index, field ->
        if (pretty) append("    $indent")
        append("\"${field.key}\":")
        if (pretty) append(" ")
        writeJson(field.value, pretty, level + 1)
        //...
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that it is important to support escaping of control characters, backslashes and double quotes.

The JSON writing function can be used as follows:

writeJson(
    jsonObj {
        string("name", "Alex")
        int("age", 28)
    }
)
Enter fullscreen mode Exit fullscreen mode

Everything listed above is sufficient for low-level work with JSON. Now we can move on to the json-reflect module.

json-reflect module

Reflection-based JSON-to-Kotlin-class mapping

A simple and straightforward way to convert a Json representation to user-defined classes is to use reflection.
This approach has an obvious trade-off. It requires Kotlin reflection at runtime and needs additional configuration for R8/ProGuard on Android. The advantage is that no generated code is required and the API can work with ordinary Kotlin classes.
We can traverse the class constructor parameters, recursively convert the corresponding JSON values, and then use the resulting arguments to instantiate the class.
This can be done with an inline function with a reified type parameter, which is needed to access Kotlin type metadata such as its classifier, nullability and type arguments:

inline fun <reified T> fromJson(input: String): T {
    val json = parseJson(input)
    return fromJson(json, typeOf<T>()) as T
}
Enter fullscreen mode Exit fullscreen mode

Mapping JSON to type T can be implemented using our accessor API as follows:

  • String <- json.string()
  • Int <- json.int()
  • Other primitives <- json.<primitive_name>()
  • Enum<*> <- json.string() then accessing enum constant by its name
  • Json subclass <- cast json to T
  • List<*>, Iterable<*>, Collection<*> <- json.array().map { /*recursive mapping*/ }
  • Sequence<*> <- json.array().map { /**/ }.asSequence()
  • Map<String, *> <- json.obj().value.mapValues { /*recursive mapping*/ }
  • Any <- return the corresponding raw Kotlin value, which can be a primitive, map, or list
  • A class with a primary constructor <- json.obj() plus mapping of values to constructor arguments and then instantiation
  • if json is JsonNull and type T is nullable, just return null

Simplified code for class mapping with support for nullability and default parameters using the primary constructor looks like the following:

private fun objectFromJson(json: Json, kClass: KClass<*>): Any {
    val constructor = checkNotNull(kClass.primaryConstructor)
    val args = mutableMapOf<KParameter, Any?>()
    for (parameter in constructor.parameters) {
        val innerKey = parameter.name
        val innerJson = json.obj().value[innerKey]
        if (innerJson != null) {
            args[parameter] = fromJson(innerJson, parameter.type)
        } else {
            when {
                parameter.isOptional -> Unit
                parameter.type.isMarkedNullable -> args[parameter] = null
                else -> error("...")
            }
        }
    }
    return constructor.callBy(args)
}
Enter fullscreen mode Exit fullscreen mode

Mapping classes to JSON

To implement the process that is inverse to parsing you don't even need an inline function with a reified type parameter. A function that accepts the Any type is enough, because a KClass<*> instance can be retrieved from an object instance at runtime.
However, my implementation has a type parameter because of custom JsonMapper support, which is explained later.
The implementation uses the low-level writeJson function from the json module.
Parameter omitNulls allows to skip fields with null value during mapping.

inline fun <reified T> T.toJson(omitNulls: Boolean = true): String {
    val json = toJson(this, typeOf<T>(), omitNulls)
    return writeJson(json)
}
Enter fullscreen mode Exit fullscreen mode

Conversion to JSON from type T is implemented as follows:

  • String -> JsonString(value)
  • Number -> JsonNumber(value)
  • Boolean -> JsonBoolean(value)
  • null -> JsonNull
  • Enum<*> -> construct JsonString from value.name
  • Json -> just return value as is
  • Iterable<*> -> value.map { /*recursive conversion*/ }
  • Sequence<*> -> convert to Iterable then do conversion above
  • Map<*, *> -> check that key is of String type and then map values recursively
  • A class with properties -> construct JsonObject from map of property names mapped to its values converted to Json

A simplified function for JsonObject construction from class properties looks like:

private fun objectToJson(value: Any, omitNulls: Boolean): Json {
        val properties = value::class.getPropertiesInDeclarationOrderIfPossible()
        val values = mutableMapOf<String, Json>()
        for (property in properties) {
            val propertyValue = property.allowAccessAndCall(value)
            if (propertyValue == null && omitNulls) continue
            values[property.name] = toJson(propertyValue, property.returnType, omitNulls)
        }
        return JsonObject(value = values)
}
Enter fullscreen mode Exit fullscreen mode

Reflection does not necessarily return properties in their declaration order. So I implemented a function that orders them according to the primary constructor parameters, which works well for data classes:

internal fun KClass<*>.getPropertiesInDeclarationOrderIfPossible(): Collection<KProperty1<*, *>> {
    return if (isData) {
        val constructor = primaryConstructor!!
        val propertiesMap = declaredMemberProperties.associateBy { property ->
            property.name
        }
        constructor.parameters.map { parameter ->
            propertiesMap[parameter.name]!!
        }
    } else {
        declaredMemberProperties
    }
}
Enter fullscreen mode Exit fullscreen mode

Accessing private class members

User DTO classes can also be private. In this case we need to bypass the restriction on accessing private members, allowing the user to use private classes for JSON mapping.
To do that, the isAccessible property of KCallable or a Java Field can be used.

internal fun <T> KCallable<T>.allowAccessAndCall(vararg args: Any?): T {
    isAccessible = true
    return call(*args)
}
Enter fullscreen mode Exit fullscreen mode

Using annotations to adjust JSON mapping

Annotations can be used to control JSON mapping properties such as field names and which properties should be skipped during serialization.
I provided two of them:

@Target(AnnotationTarget.PROPERTY, AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class JsonName(val name: String)

@Target(AnnotationTarget.PROPERTY)
annotation class JsonSkip
Enter fullscreen mode Exit fullscreen mode

The first allows you to set the serializable name of a property or enum constant. The second allows you to skip properties during serialization. With annotation support, code of JSON object writing looks like:

private fun objectToJson(value: Any, omitNulls: Boolean): Json {
        val properties = value::class.getPropertiesInDeclarationOrderIfPossible()
        val values = mutableMapOf<String, Json>()
        for (property in properties) {
            val skipAnnotation = property.findAnnotation<JsonSkip>()
            if (skipAnnotation != null) continue
            val propertyValue = property.allowAccessAndCall(value)
            if (propertyValue == null && omitNulls) continue
            val nameAnnotation = property.findAnnotation<JsonName>()
            val innerKey = nameAnnotation?.name ?: property.name
            values[innerKey] = toJson(propertyValue, property.returnType, omitNulls, innerKey)
        }
        return JsonObject(value = values)
}
Enter fullscreen mode Exit fullscreen mode

Custom JSON mappers

In some cases mapping based on primary constructors and class properties is not enough. One example is polymorphic type serialization.
Let's say we have sealed interface Shape and its different implementations for supported shape types:

sealed interface Shape {
    data class Rectangle(val width: Int, val height: Int) : Shape
    data class Circle(val radius: Int) : Shape
}
Enter fullscreen mode Exit fullscreen mode

Shapes can be represented in JSON as objects with a type property that determines which Shape type is encoded.
It would be convenient if we could parse the following array of shapes by calling val shapes = fromJson<List<Shape>>(json).

[
    {
         "type": "rectangle",
          "width": 10,
          "height": 20
    },
    {
          "type": "circle",
          "radius": 5
    }
]
Enter fullscreen mode Exit fullscreen mode

To support this feature I introduced the JsonMapper interface which allows the user to implement custom mapping logic for specific types:

interface JsonMapper<T : Any> {
    fun toJson(value: T): Json
    fun fromJson(json: Json): T
}
Enter fullscreen mode Exit fullscreen mode

This interface should be implemented by the companion object of a class or interface. The library retrieves the class companion object using reflection and, if it implements the JsonMapper interface, uses this mapper to create a class instance from Json or vice versa instead of using the primary constructor or declared properties.

The following is an implementation detail rather than part of the public API.
Retrieving a companion object is a little tricky, because Kotlin property companionObjectInstance of KClass<*> doesn't allow us to retrieve the companion object of a private class, so we need to use java reflection. On the JVM, the companion instance can be exposed through different generated fields depending on whether the companion belongs to a class or an interface. A class's companion object is accessible through a Companion field of the enclosing class. An interface's companion object can be exposed through its own INSTANCE or $$INSTANCE field, depending on the generated bytecode.
With this knowledge, the code for retrieving the class mapper and calling its fromJson method looks like this:

internal fun KClass<*>.getCompanionObject(): Any? {
    val companionClass = companionObject ?: return null
    val companionJavaClass = companionClass.java
    val instanceField = companionJavaClass
        .declaredFields
        .firstOrNull { field ->
            field.name == "INSTANCE" || field.name == $$$"$$INSTANCE"
        } ?: companionJavaClass.enclosingClass
        ?.declaredFields?.firstOrNull { field ->
            field.name == companionClass.simpleName
        } ?: return null
    instanceField.isAccessible = true
    return instanceField.get(null)
}


private fun objectFromJson(json: Json, kClass: KClass<*>): Any {
    val mapper = kClass.getCompanionObject() as? JsonMapper<*>
    if (mapper != null) {
       return mapper::fromJson.allowAccessAndCall(json)
    }
    //... default mapping logic
}
Enter fullscreen mode Exit fullscreen mode

To implement the mapping of shapes described previously we can implement such a mapper:

sealed interface Shape {
    data class Rectangle(val width: Int, val height: Int) : Shape
    data class Circle(val radius: Int) : Shape

    companion object : JsonMapper<Shape> {
        override fun fromJson(json: Json): Shape {
            val type = json.string("type")
            return when (type) {
                "rectangle" -> fromJsonRepresentation<Rectangle>(json)
                "circle" -> fromJsonRepresentation<Circle>(json)
                else -> error("Unknown shape")
            }
        }

        override fun toJson(value: Shape): Json {
            return when (value) {
                is Rectangle -> jsonObj {
                    string("type", "rectangle")
                    fields(value.toJsonRepresentation().obj())
                }
                is Circle -> jsonObj {
                    string("type", "circle")
                    fields(value.toJsonRepresentation().obj())
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Where fromJsonRepresentation and toJsonRepresentation are additional functions that contain the same logic as fromJson and toJson, but work with Json objects instead of raw strings. And fields() copies the fields from the generated JSON object into the current builder.
After implementing this mapper we can do conversion like this:

val shapes = fromJson<List<Shape>>(json)
val json = shapes.toJson()
Enter fullscreen mode Exit fullscreen mode

Android and ProGuard rules

There are some problems with reflection in applications optimized by R8 or ProGuard, especially on Android where optimization is a widespread practice. Reflective access to classes and their members by names or types can be broken because of shrinking and obfuscation. Classes or members accessed only by reflection can be considered unused and removed. The names of classes and members can be changed to arbitrary character sequences. All of this prevents using reflection. To fix these problems, the library can supply its own ProGuard rules in META-INF/proguard/consumer-proguard-rules.pro file.

Using marker interface in keep rules

In the json-reflect module, I provided marker interface JsonModel which can be used to mark a class as a DTO for JSON parsing.

interface JsonModel
data class User(val name: String, val age: Int): JsonModel
Enter fullscreen mode Exit fullscreen mode

The library provides the following keep rules for classes marked with this interface:

-keepnames class ** implements io.github.alexeykozyakov.json.reflect.JsonModel

-keepclassmembers class ** implements io.github.alexeykozyakov.json.reflect.JsonModel {
    <init>(...);
    <fields>;
    *** get*();
    *** is*();
}
Enter fullscreen mode Exit fullscreen mode

First rule keeps the name of the class itself. In my experiments, keeping the class name was necessary for Kotlin reflection to resolve the primary constructor correctly. The second rule keeps the primary constructor and class members, including backing fields and the Companion instance field.
To help ensure that users of the library don't forget to implement this interface, I added a check which verifies that the interface is implemented by the class when mapping function is called. This check helps catch the problem in the debug build rather than in production. I enabled this check only for Android by checking whether the android.os.Build class exists, which indicates that the library is most likely being used on Android.

internal val isAndroid by lazy {
    try {
        Class.forName("android.os.Build")
        true
    } catch (_: ClassNotFoundException) {
        false
    }
}

private fun ensureMarkerInterfaceImplemented(kClass: KClass<*>) {
    if (!isAndroid) return
    check(kClass.isSubclassOf(JsonModel::class)) { /*error*/ }
}
Enter fullscreen mode Exit fullscreen mode

Keep annotations and companion object members

I also added rules to keep JsonName and JsonSkip annotations:

-keep @interface io.github.alexeykozyakov.json.reflect.JsonName
-keep @interface io.github.alexeykozyakov.json.reflect.JsonSkip

-keepattributes RuntimeVisibleAnnotations,RuntimeInvisibleAnnotations
-keepattributes RuntimeVisibleParameterAnnotations,RuntimeInvisibleParameterAnnotations
Enter fullscreen mode Exit fullscreen mode

And the last rule keeps the JsonMapper implementation itself, its methods and INSTANCE fields:


-keep, allowoptimization, includedescriptorclasses class ** implements io.github.alexeykozyakov.json.reflect.JsonMapper {
    *** toJson(...);
    *** fromJson(...);
    *** INSTANCE;
    *** $$INSTANCE;
}
Enter fullscreen mode Exit fullscreen mode

Here I used includedescriptorclasses to keep the classes referenced by the method descriptors. This is important because the JsonMapper methods reference Json, and inconsistent obfuscation of these types caused runtime failures in my Android release build.

This set of rules seems to be enough for reflection to work correctly in release builds. I tried to make the rules as narrow as possible to minimize their impact on shrinking and optimization.

Publication of the library

The next step after implementing the library is publishing it. A simple way to make the library available to users is to publish it to Maven Central.

Publication to Maven Central

Required preparations

First of all, I registered an account on the central.sonatype.com using my GitHub account and got a verified namespace in the form io.github.<login>. Then I created a user token and saved a login and password from it. Another thing that is required before publication is a signing key. It can be generated with gpg --full-generate-key and exported with the gpg --armor --export-secret-keys <KEY_ID> command. To get KEY_ID, you should run gpg --list-secret-keys in the terminal. It is important to upload the key to a PGP keyserver supported by Maven Central. It can be done like this: gpg --keyserver keyserver.ubuntu.com --send-keys YOUR_KEY_ID.

Publish plugin setup

Let's assume we have a multi-module project. We want to publish modules as different artifacts with the same group and version. We can declare version and group for all artifacts in root project's build.gradle.kts:

allprojects {
    group = "io.github.alexeykozyakov.json"
    version = "1.0.11"
}
Enter fullscreen mode Exit fullscreen mode

I moved the common publishing configuration into a convention plugin so that both modules could share the same setup. The resulting project structure looks like this:

JsonParser/
├── build.gradle.kts
├── settings.gradle.kts
├── build-logic/
│   ├── src/
│   │   └── main/
│   │       └── kotlin/
│   │           └── jsonparser.kotlin-library.gradle.kts
│   ├── build.gradle.kts
│   └── settings.gradle.kts
├── json/
│   └── build.gradle.kts
└── json-reflect/
    └── build.gradle.kts
Enter fullscreen mode Exit fullscreen mode

The build-logic project contains the jsonparser.kotlin-library convention plugin, which is applied to both json and json-reflect.
The main module's settings.gradle.kts includes build-logic build like this:

pluginManagement {
    includeBuild("build-logic")
}
Enter fullscreen mode Exit fullscreen mode

The build-logic project depends on the com.vanniktech.maven.publish plugin. It configures its parameters using extensions.configure in the jsonparser.kotlin-library.gradle.kts file:

plugins {
    id("com.vanniktech.maven.publish")
}

extensions.configure<MavenPublishBaseExtension> {
    publishToMavenCentral()
    signAllPublications()

    pom {
        url = "https://github.com/AlexeyKozyakov/Json"

        licenses { /**/ } 

        developers { /**/ }

        scm { /**/ }
    }
}
Enter fullscreen mode Exit fullscreen mode

I included a convenient plugin in json and json-reflect modules:

plugins {
    id("jsonparser.kotlin-library")
}
Enter fullscreen mode Exit fullscreen mode

Then I set project-specific publication settings using the mavenPublishing { } block:

mavenPublishing {
    coordinates(
        artifactId = "json"
    )

    pom {
        name = "Json"
        description = "Lightweight Kotlin/JVM JSON parser with a direct JSON representation."
    }
}
Enter fullscreen mode Exit fullscreen mode

After this setup, publishing can be run by command:
./gradlew publishAndReleaseToMavenCentral.

Maven Central credentials and signing key should be set through environment variables:
ORG_GRADLE_PROJECT_mavenCentralUsername
ORG_GRADLE_PROJECT_mavenCentralPassword
ORG_GRADLE_PROJECT_signingInMemoryKey
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword

Generating documentation

Another important thing for the library is its documentation.
I used the Dokka plugin to generate the documentation. Dokka can be used for a multi-module project and produce the aggregated documentation from all of its subprojects.
To enable Dokka, I added a dependency on it in the build-logic module and applied the plugin in the plugins section of jsonparser.kotlin-library.gradle.kts by:

plugins {
    id("org.jetbrains.dokka")
}
Enter fullscreen mode Exit fullscreen mode

Multi-module documentation aggregation is configured in the dependencies block of the root project:

dependencies {
    dokka(project(":json"))
    dokka(project(":json-reflect"))
}
Enter fullscreen mode Exit fullscreen mode

The following command builds the library documentation:
./gradlew dokkaGenerateHtml

Release automation with GitHub Actions

To automate the release process I created a GitHub Actions workflow that:

  • can be triggered manually
  • allows to select version increment type (major, minor, patch)
  • checks out main
  • sets up Java and Gradle
  • calculates the new version from the current one using grep and sed
  • updates version using sed
  • runs tests
  • publishes to Maven Central
  • commits version increment
  • creates a release tag
  • pushes tag
  • creates GitHub release with notes

To automate the documentation deployment to GitHub pages I also created a workflow that:

  • can be triggered manually
  • checks out main
  • sets up JDK and Gradle
  • builds documentation using Dokka
  • sets up pages
  • uploads pages artifact
  • deploys pages

Conclusion

The main result of this experiment was not just another JSON parser. It was a different separation of concerns. The json module provides a lightweight JSON representation and API without reflection or code generation. The json-reflect module adds automatic mapping when that convenience is needed.
This allows developers to choose between explicit JSON manipulation and automatic Kotlin class mapping without pulling both approaches into the same artifact.
To answer the question posed at the beginning of the article: yes, we can do without reflection or code generation when parsing JSON, but reflection or code generation can be convenient when mapping JSON data to Kotlin classes.

Try the library

The library is accessible through these GitHub and Maven Central links:

I would appreciate any feedback on it.

Top comments (0)