Skip to content

Commit

Permalink
Merge pull request #19 from collectiveidea/refactor-any-json-deserial…
Browse files Browse the repository at this point in the history
…ization

Refactor and cleanup `Any?` json deserialization
  • Loading branch information
darronschall authored Nov 25, 2024
2 parents bd0b4f1 + ca2d82f commit d87b6f4
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 119 deletions.
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,
)
}
}

0 comments on commit d87b6f4

Please sign in to comment.