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")
}
Usage of JSON accessors is pretty straightforward:
val json = parseJson("""{"name":"Alex","age":28}""")
println(json.string("name"))
println(json.int("age"))
A JSON string can be built using the library DSL:
writeJson(
jsonObj {
string("name", "Alex")
int("age", 28)
}
)
json-reflect
To include the json-reflect module, add the following dependency:
dependencies {
implementation("io.github.alexeykozyakov.json:json-reflect:1.0.11")
}
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()
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
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()
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")
)
}
)
}
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)
}
//...
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()
}
//...
}
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")
}
}
}
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()
}
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("...")
}
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
//...
Some important points:
- parsing should support both integer and floating-point numbers. This can be achieved by parsing numbers as
LongorDoubleand storing them asNumber, 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 codeXXXX. - 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 }""")
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)
//...
}
}
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)
}
)
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
}
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 -
Jsonsubclass <- castjsontoT -
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
jsonisJsonNulland typeTis nullable, just returnnull
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)
}
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)
}
Conversion to JSON from type T is implemented as follows:
-
String->JsonString(value) -
Number->JsonNumber(value) -
Boolean->JsonBoolean(value) -
null->JsonNull -
Enum<*>-> constructJsonStringfromvalue.name -
Json-> just returnvalueas is -
Iterable<*>->value.map { /*recursive conversion*/ } -
Sequence<*>-> convert toIterablethen do conversion above -
Map<*, *>-> check that key is ofStringtype and then map values recursively - A class with properties -> construct
JsonObjectfrom map of property names mapped to its values converted toJson
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)
}
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
}
}
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)
}
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
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)
}
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
}
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
}
]
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
}
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
}
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())
}
}
}
}
}
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()
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
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*();
}
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*/ }
}
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
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;
}
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"
}
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
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")
}
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 { /**/ }
}
}
I included a convenient plugin in json and json-reflect modules:
plugins {
id("jsonparser.kotlin-library")
}
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."
}
}
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")
}
Multi-module documentation aggregation is configured in the dependencies block of the root project:
dependencies {
dokka(project(":json"))
dokka(project(":json-reflect"))
}
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
grepandsed - 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:
- github.com/AlexeyKozyakov/Json
- central.sonatype.com/artifact/io.github.alexeykozyakov.json
- central.sonatype.com/artifact/io.github.alexeykozyakov.json/json-reflect
I would appreciate any feedback on it.
Top comments (0)