More Kotlin (Ktor Client Json)
23 Jun 2022A 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
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
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/"
},
- Each response is a list of Planets
- ContentNegotiation is a Ktor client plugin used when a server sends a response with
application/json
, the response payload is marshalled into a data class
- ContentNegotiation is a Ktor client plugin used when a server sends a response with
- Planets is a wrapper for the results and pagination
- Kotlin Serialization only supports explicit attribute name to data class property via @SerialName
- For/against arguments of using automatic naming strategy kotlinx.serialization/issues/33
- Planet demonstrates a custom KSerializer to handle typically variant data fields where “unknown” is returned in the field value
- The documentation doesn’t seem to handle this particular usage where
null
is substituted for a specific value during deserialization - In this case - a population value of “unknown” is considered nullable Long
- Serializers can be installed at the top level instead of property annotations e.g
@file:UseSerializers(UnknownToNullableSerializer::class)
- The documentation doesn’t seem to handle this particular usage where
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
}