fix(events): AnyMapSerializer drops nulls instead of failing to read them
toJsonElement wrote a null map value as JSON null, but toAny threw on reading it back — asymmetric, so a single null-valued tool-arg made the whole session's events undecodable: 500 on /events AND a runtime WorkflowFailed when the projection rebuilt mid-run. A Map<String, Any> can't hold a null anyway, so drop null-valued keys on both serialize and deserialize, keeping the log round-trippable.
This commit is contained in:
+24
-11
@@ -28,43 +28,56 @@ private object AnySerializer : KSerializer<Any> {
|
||||
override fun deserialize(decoder: Decoder): Any {
|
||||
val jsonDecoder = decoder as? JsonDecoder
|
||||
?: throw SerializationException("AnySerializer requires a JSON decoder")
|
||||
return jsonDecoder.decodeJsonElement().toAny()
|
||||
return jsonDecoder.decodeJsonElement().toAnyOrNull()
|
||||
?: throw SerializationException("null values are not supported in Map<String, Any>")
|
||||
}
|
||||
}
|
||||
|
||||
// Null values are dropped, not written: a Map<String, Any> can't hold a Kotlin null, so a null
|
||||
// (e.g. a tool arg the model emitted as JSON null) carries no information. Serialize and deserialize
|
||||
// must stay symmetric — the event log has to round-trip anything it writes, or a single null-valued
|
||||
// map key makes the whole session's events undecodable (500 on /events, unreplayable).
|
||||
private fun Any?.toJsonElement(): JsonElement = when (this) {
|
||||
null -> JsonNull
|
||||
is String -> JsonPrimitive(this)
|
||||
is Number -> JsonPrimitive(this)
|
||||
is Boolean -> JsonPrimitive(this)
|
||||
is Map<*, *> -> JsonObject(entries.associate { (k, v) -> k.toString() to v.toJsonElement() })
|
||||
is List<*> -> JsonArray(map { it.toJsonElement() })
|
||||
is Map<*, *> -> JsonObject(entries.mapNotNull { (k, v) -> v?.let { k.toString() to it.toJsonElement() } }.toMap())
|
||||
is List<*> -> JsonArray(mapNotNull { it?.toJsonElement() })
|
||||
else -> throw SerializationException("Unsupported type: ${this::class.simpleName}")
|
||||
}
|
||||
|
||||
private fun JsonElement.toAny(): Any = when (this) {
|
||||
is JsonNull -> throw SerializationException("null values are not supported in Map<String, Any>")
|
||||
// Returns null for JSON null (and the literal "null" scalar); callers drop those entries so a
|
||||
// null value never reaches the non-null Map<String, Any>.
|
||||
private fun JsonElement.toAnyOrNull(): Any? = when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> {
|
||||
val content = this.content
|
||||
when {
|
||||
content == "true" || content == "false" -> content == "true"
|
||||
content == "null" -> throw SerializationException("null values are not supported in Map<String, Any>")
|
||||
content == "null" -> null
|
||||
content.toDoubleOrNull() != null && content.contains('.') -> content.toDouble()
|
||||
content.toLongOrNull() != null -> content.toLong()
|
||||
else -> content // fallback to string
|
||||
}
|
||||
}
|
||||
is JsonObject -> entries.associate { (k, v) -> k to v.toAny() }
|
||||
is JsonArray -> map { it.toAny() }
|
||||
is JsonObject -> entries.mapNotNull { (k, v) -> v.toAnyOrNull()?.let { k to it } }.toMap()
|
||||
is JsonArray -> mapNotNull { it.toAnyOrNull() }
|
||||
}
|
||||
|
||||
object AnyMapSerializer : KSerializer<Map<String, Any>> {
|
||||
private val delegate = MapSerializer(String.serializer(), AnySerializer)
|
||||
override val descriptor: SerialDescriptor = delegate.descriptor
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun deserialize(decoder: Decoder): Map<String, Any> =
|
||||
delegate.deserialize(decoder)
|
||||
override fun deserialize(decoder: Decoder): Map<String, Any> {
|
||||
// Decode the object directly and drop null-valued keys, rather than delegating per-value
|
||||
// (which throws on a null before the map can skip it). Keeps a null-containing map readable.
|
||||
val jsonDecoder = decoder as? JsonDecoder
|
||||
?: return delegate.deserialize(decoder)
|
||||
val obj = jsonDecoder.decodeJsonElement() as? JsonObject
|
||||
?: throw SerializationException("expected a JSON object for Map<String, Any>")
|
||||
return obj.entries.mapNotNull { (k, v) -> v.toAnyOrNull()?.let { k to it } }.toMap()
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Map<String, Any>) =
|
||||
delegate.serialize(encoder, value)
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AnyMapSerializerTest {
|
||||
|
||||
@Test
|
||||
fun `deserializes a map whose value is JSON null by dropping that key`() {
|
||||
// The exact shape that used to 500 the whole session's /events: a persisted tool-arg map
|
||||
// with a null value. Reading it back must succeed, not throw.
|
||||
val decoded = Json.decodeFromString(AnyMapSerializer, """{"path":"a.txt","mode":null}""")
|
||||
assertEquals("a.txt", decoded["path"])
|
||||
assertFalse(decoded.containsKey("mode"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trips scalars, nested objects, and arrays`() {
|
||||
val decoded = Json.decodeFromString(
|
||||
AnyMapSerializer,
|
||||
"""{"s":"x","n":3,"b":true,"arr":[1,2],"obj":{"k":"v"}}""",
|
||||
)
|
||||
assertEquals("x", decoded["s"])
|
||||
assertEquals(3L, decoded["n"])
|
||||
assertEquals(true, decoded["b"])
|
||||
assertEquals(listOf(1L, 2L), decoded["arr"])
|
||||
assertEquals(mapOf("k" to "v"), decoded["obj"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not write null values`() {
|
||||
val json = Json.encodeToString(AnyMapSerializer, mapOf("a" to "keep"))
|
||||
assertFalse(json.contains("null"))
|
||||
assertTrue(json.contains("\"a\":\"keep\""))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user