More Kotlin (Ktor Client Json)

A basic example that pulls together a setup for using ktor http client with json decoding.

For a more complete example with Serialization and Flow pagination see git repo ktor-client-json

For a Ktor application, the Kotlin serialization compiler plugin is added to the build -

that generates visitor code for serializable classes, runtime library with core serialization API and support libraries with various serialization formats

See kotlinx.serialization

A typical complete build.gradle.kts file depends on ktor core, a client engine and json serialization library


plugins {
    kotlin("jvm") version "1.7.0"
    kotlin("plugin.serialization") version "1.7.0" // compiler plugin
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    implementation("io.ktor:ktor-client-core-jvm:2.0.2")
    implementation("io.ktor:ktor-client-java:2.0.2")
    implementation("io.ktor:ktor-client-content-negotiation:2.0.2")
    implementation("io.ktor:ktor-serialization-kotlinx-json:2.0.2")
    // kotlinx-serialization-json will be pulled in by ktor-serialization-kotlinx-json
    // uncomment below to specify exact version if latest is required
    // implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3")
    testImplementation(kotlin("test"))
}

tasks.test {
    useJUnitPlatform()
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(11))
    }
}


An example Ktor client making requests to a suitable json producing api

Star Wars Api

For the swapi.dev/api/planets response, be aware that most values in a Planet can be of “unknown” instead of null for data that is not quantified and We want to represent it as a nullable type in our data class

This serves as an example where the response requires modification when decoding and exercises the flexibilty of the serialization library

With Kotlin Serialization, there doesn’t seem to be an easy way of specifying decoding “unknown” as null using the compiler generated Serializer. All the available examples can be seen here

Planets response


{
	"count": 60,
	"next": "https://swapi.dev/api/planets/?page=2",
	"previous": null,
	"results": [
		{
			"name": "Tatooine",
			"rotation_period": "23",
			"orbital_period": "304",
			"diameter": "10465",
			"climate": "arid",
			"gravity": "1 standard",
			"terrain": "desert",
			"surface_water": "1",
			"population": "200000",
			"residents": [
				"https://swapi.dev/api/people/1/",
			],
			"films": [
				"https://swapi.dev/api/films/1/",
			],
			"created": "2014-12-09T13:50:49.641000Z",
			"edited": "2014-12-20T20:58:18.411000Z",
			"url": "https://swapi.dev/api/planets/1/"
		},
	

First approach with a KSerializer for each nullable type, can also be configured as @file:UseSerializers(UnknownToNullableSerializer,...)


package griffio.client

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.java.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json

@Serializable
data class Planets(
    val results: List<Planet>
)

@Serializable
data class Planet(
    val climate: String,
    val diameter: Int,
    val gravity: String,
    val name: String,
    // FYI https://github.com/Kotlin/kotlinx.serialization/issues/33
    @SerialName("orbital_period")
    val orbitalPeriod: Int,
    @Serializable(with = UnknownToNullableSerializer::class)
    val population: Long?
)
// This KSerializer would have to be duplicated for every "unknown" type (String?, Int?)
// See repo for example
class UnknownToNullableSerializer : KSerializer<Long?> {
    override val descriptor: SerialDescriptor =
        PrimitiveSerialDescriptor("population.Long?", PrimitiveKind.STRING)
    override fun serialize(encoder: Encoder, value: Long?): Unit =
        encoder.encodeString(value.toString())
    override fun deserialize(decoder: Decoder): Long? {
        val decoded = decoder.decodeString()
        return if (decoded.startsWith("unknown")) null else decoded.toLong()
    }
}

suspend fun main() {
    // Setup HttpClient - e.g use Java engine
    // io.ktor:ktor-client-java
    // io.ktor:ktor-client-content-negotiation
    // io.ktor:ktor-serialization-kotlinx-json
    val client = HttpClient(Java) {
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
                ignoreUnknownKeys = true
            })
        }
    }

    val resource = "https://swapi.dev/api/planets"

    val response: HttpResponse = client.request(resource)

    val planets: Planets = response.body()

    println(planets)
    // see more complete version https://github.com/griffio/ktor-client-json
}


Second approach with a single JsonTransformingSerializer for the Planet type itself

Repo branch example github.com/griffio/ktor-client-json/tree/JsonTransformingSerializer

All json values containing “unknown” will be set to null and Planet properties are set to nullable types

@file:UseSerializers(UnknownToNullPlanetSerializer::class) is used at the top of the file because the plugin generated Planet.serializer from @Serializable is still needed to decode the transformed element to a Planet


@file:UseSerializers(UnknownToNullPlanetSerializer::class)

package griffio.client

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.java.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json

@Serializable
data class Planets(
    val results: List<Planet>
)

@Serializable // Keep the default Serializer available for the transformer
data class Planet(
    val climate: String?,
    val diameter: Int?,
    val gravity: String?,
    val name: String?,
    @SerialName("orbital_period")
    val orbitalPeriod: Int?,
    val population: Long?
)

// Planet.serializer() will decode the transformed JsonObject into a Planet
class UnknownToNullPlanetSerializer : JsonTransformingSerializer<Planet>(Planet.serializer()) {
    override fun transformDeserialize(element: JsonElement): JsonElement {
        val unknown = JsonPrimitive("unknown")
        val newMap = element.jsonObject.mapValues { entry ->
            if (entry.value == unknown) {
                JsonNull
            } else entry.value
        }
        return JsonObject(newMap)
    }
}

suspend fun main() {
    // Setup HttpClient - e.g use Java engine
    // io.ktor:ktor-client-java
    // io.ktor:ktor-client-content-negotiation
    // io.ktor:ktor-serialization-kotlinx-json
    val client = HttpClient(Java) {
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
                ignoreUnknownKeys = true
            })
        }
    }

    val resource = "https://swapi.dev/api/planets"

    val response: HttpResponse = client.request(resource)

    val planets: Planets = response.body()

    println(planets)
    // see more complete version https://github.com/griffio/ktor-client-json/blob/JsonTransformingSerializer
}