Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor and cleanup Any? json deserialization #19

Merged
merged 9 commits into from
Nov 25, 2024
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

* Update dependencies. See [#17](https://github.com/collectiveidea/twirp-kmp/pull/17)
* Runtime - Refactor and cleanup `Any?` JSON deserialization. See [#19](https://github.com/collectiveidea/twirp-kmp/pull/19).
* Update dependencies. See [#17](https://github.com/collectiveidea/twirp-kmp/pull/17).
* Bump Kotlin from 2.0.20 to 2.0.21
* Runtime - Bump Ktor to 3.0.1
* Runtime - Bump kotlinx-serialization to 1.7.3
* Bump pbandk to 0.16.0
* **BREAKING** Rename project from twirp-kmm to twirp-kmp. See [#16](https://github.com/collectiveidea/twirp-kmm/pull/16)
* **BREAKING** Rename project from twirp-kmm to twirp-kmp. See [#16](https://github.com/collectiveidea/twirp-kmm/pull/16).
* Generator - Ensure generator .jar artifact is built for Java 8. See [#15](https://github.com/collectiveidea/twirp-kmm/pull/15).

## [0.4.0] - 2024-09-03
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.collectiveidea.serialization.json

import kotlinx.serialization.KSerializer
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive

// `Any?` does not appear to be supported out of the box in kotlinx-serialization.
//
// With a little help from https://github.com/Kotlin/kotlinx.serialization/issues/746 and
// https://github.com/Kotlin/kotlinx.serialization/issues/296 we can write a custom
// `KSerializer` to do the work for us.

internal object AnySerializer : KSerializer<Any?> {
private val delegateSerializer = JsonElement.serializer()
override val descriptor = delegateSerializer.descriptor

override fun serialize(
encoder: Encoder,
value: Any?,
) {
encoder.encodeSerializableValue(delegateSerializer, value.toJsonElement())
}

override fun deserialize(decoder: Decoder): Any? = decoder.decodeSerializableValue(delegateSerializer).toAnyOrNull()
}

//
// Serialize
//

internal fun Any?.toJsonElement(): JsonElement = when (this) {
is Number -> JsonPrimitive(this)
is Boolean -> JsonPrimitive(this)
is String -> JsonPrimitive(this)
is Map<*, *> -> toJsonObject()
is Collection<*> -> toJsonArray()
is JsonElement -> this
else -> JsonNull
}

private fun Collection<*>.toJsonArray() = JsonArray(map { it.toJsonElement() })

private fun Map<*, *>.toJsonObject() = JsonObject(mapKeys { it.key.toString() }.mapValues { it.value.toJsonElement() })

//
// Deserialize
//

internal fun JsonElement.toAnyOrNull(): Any? = when (this) {
is JsonNull -> null
is JsonPrimitive -> toAnyValue()
is JsonObject -> map { it.key to it.value.toAnyOrNull() }.toMap()
is JsonArray -> map { it.toAnyOrNull() }
}

private fun JsonPrimitive.toAnyValue(): Any? {
val content = this.content
if (isString) {
return content
}
if (content.equals("null", ignoreCase = true)) {
return null
}
if (content.equals("true", ignoreCase = true)) {
return true
}
if (content.equals("false", ignoreCase = true)) {
return false
}
return content.toIntOrNull()
?: content.toLongOrNull()
?: content.toDoubleOrNull()
?: throw Exception("Cannot convert JSON $content to value")
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,16 @@
package com.collectiveidea.twirp

import com.collectiveidea.serialization.json.AnySerializer
import io.ktor.http.HttpStatusCode
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive

@Serializable
public data class ErrorResponse(
val code: ErrorCode = ErrorCode.Unknown,
val msg: String,
val meta: Map<
String,
@Serializable(with = AnySerializer::class)
Any?,
>? = null,
@Suppress("ktlint:standard:annotation")
val meta: Map<String, @Serializable(with = AnySerializer::class) Any?>? = null,
)

// See: https://github.com/twitchtv/twirp/blob/main/docs/spec_v7.md#error-codes
Expand Down Expand Up @@ -82,83 +72,3 @@ public enum class ErrorCode(
@SerialName("dataloss")
Dataloss(HttpStatusCode.InternalServerError.value),
}

// `Any?` does not appear to be supported out of the box in kotlinx-serialization. With
// a little help from https://github.com/Kotlin/kotlinx.serialization/issues/746 and
// https://github.com/Kotlin/kotlinx.serialization/issues/296 we can write a custom
// `KSerializer` to do the work for us.

//
// Serialize
//

internal fun Any?.toJsonElement(): JsonElement = when (this) {
is Number -> JsonPrimitive(this)
is Boolean -> JsonPrimitive(this)
is String -> JsonPrimitive(this)
is Map<*, *> -> this.toJsonObject()
is Collection<*> -> this.toJsonArray()
is JsonElement -> this
else -> JsonNull
}

internal fun Collection<*>.toJsonArray() = JsonArray(map { it.toJsonElement() })

internal fun Map<*, *>.toJsonObject() = JsonObject(mapKeys { it.key.toString() }.mapValues { it.value.toJsonElement() })

//
// Deserialize
//

private fun JsonPrimitive.toAnyValue(): Any? {
val content = this.content
if (this.isString) {
return content
}
if (content.equals("null", ignoreCase = true)) {
return null
}
if (content.equals("true", ignoreCase = true)) {
return true
}
if (content.equals("false", ignoreCase = true)) {
return false
}
val intValue = content.toIntOrNull()
if (intValue != null) {
return intValue
}
val longValue = content.toLongOrNull()
if (longValue != null) {
return longValue
}
val doubleValue = content.toDoubleOrNull()
if (doubleValue != null) {
return doubleValue
}
throw Exception("Cannot convert JSON $content to value")
}

internal fun JsonElement.toAnyOrNull(): Any? = when (this) {
is JsonNull -> null
is JsonPrimitive -> toAnyValue()
is JsonObject -> this.map { it.key to it.value.toAnyOrNull() }.toMap()
is JsonArray -> this.map { it.toAnyOrNull() }
}

internal object AnySerializer : KSerializer<Any?> {
private val delegateSerializer = JsonElement.serializer()
override val descriptor = delegateSerializer.descriptor

override fun serialize(
encoder: Encoder,
value: Any?,
) {
encoder.encodeSerializableValue(delegateSerializer, value.toJsonElement())
}

override fun deserialize(decoder: Decoder): Any? {
val jsonElement = decoder.decodeSerializableValue(delegateSerializer)
return jsonElement.toAnyOrNull()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,36 +46,38 @@ class ErrorResponseTest {
"nestedKey3": 17,
"nestedKey4": [9, null]
},
"key6": null
"key6": null,
"key7": 12.53,
"key8": 4000000000
}
}
""".trimIndent(),
)

val meta = error.meta
assertNotNull(meta)
assertEquals("a string", meta["key1"])
assertEquals(false, meta["key2"])
assertEquals(51, meta["key3"])

val key4Array = meta["key4"] as ArrayList<*>
assertEquals(4, key4Array.size)
assertEquals(true, key4Array[0])
assertEquals(1, key4Array[1])
assertEquals("test", key4Array[2])
val key4ArrayNestedMap = key4Array[3] as Map<*, *>
assertEquals(1, key4ArrayNestedMap.keys.size)
assertEquals("yup", key4ArrayNestedMap["nestedKey0"])

val key5Map = meta["key5"] as Map<*, *>
assertEquals("another string", key5Map["nestedKey1"])
assertEquals(true, key5Map["nestedKey2"])
assertEquals(17, key5Map["nestedKey3"])
val key5NestedArray = key5Map["nestedKey4"] as ArrayList<*>
assertEquals(2, key5NestedArray.size)
assertEquals(9, key5NestedArray[0])
assertEquals(null, key5NestedArray[1])

assertEquals(null, meta["key6"])
assertEquals(
mapOf(
"key1" to "a string",
"key2" to false,
"key3" to 51,
"key4" to listOf(
true,
1,
"test",
mapOf("nestedKey0" to "yup"),
),
"key5" to mapOf(
"nestedKey1" to "another string",
"nestedKey2" to true,
"nestedKey3" to 17,
"nestedKey4" to listOf(9, null),
),
"key6" to null,
"key7" to 12.53,
"key8" to 4000000000,
),
meta,
)
}
}
Loading