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 {
|
override fun deserialize(decoder: Decoder): Any {
|
||||||
val jsonDecoder = decoder as? JsonDecoder
|
val jsonDecoder = decoder as? JsonDecoder
|
||||||
?: throw SerializationException("AnySerializer requires a JSON decoder")
|
?: 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) {
|
private fun Any?.toJsonElement(): JsonElement = when (this) {
|
||||||
null -> JsonNull
|
null -> JsonNull
|
||||||
is String -> JsonPrimitive(this)
|
is String -> JsonPrimitive(this)
|
||||||
is Number -> JsonPrimitive(this)
|
is Number -> JsonPrimitive(this)
|
||||||
is Boolean -> JsonPrimitive(this)
|
is Boolean -> JsonPrimitive(this)
|
||||||
is Map<*, *> -> JsonObject(entries.associate { (k, v) -> k.toString() to v.toJsonElement() })
|
is Map<*, *> -> JsonObject(entries.mapNotNull { (k, v) -> v?.let { k.toString() to it.toJsonElement() } }.toMap())
|
||||||
is List<*> -> JsonArray(map { it.toJsonElement() })
|
is List<*> -> JsonArray(mapNotNull { it?.toJsonElement() })
|
||||||
else -> throw SerializationException("Unsupported type: ${this::class.simpleName}")
|
else -> throw SerializationException("Unsupported type: ${this::class.simpleName}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JsonElement.toAny(): Any = when (this) {
|
// Returns null for JSON null (and the literal "null" scalar); callers drop those entries so a
|
||||||
is JsonNull -> throw SerializationException("null values are not supported in Map<String, Any>")
|
// null value never reaches the non-null Map<String, Any>.
|
||||||
|
private fun JsonElement.toAnyOrNull(): Any? = when (this) {
|
||||||
|
is JsonNull -> null
|
||||||
is JsonPrimitive -> {
|
is JsonPrimitive -> {
|
||||||
val content = this.content
|
val content = this.content
|
||||||
when {
|
when {
|
||||||
content == "true" || content == "false" -> content == "true"
|
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.toDoubleOrNull() != null && content.contains('.') -> content.toDouble()
|
||||||
content.toLongOrNull() != null -> content.toLong()
|
content.toLongOrNull() != null -> content.toLong()
|
||||||
else -> content // fallback to string
|
else -> content // fallback to string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is JsonObject -> entries.associate { (k, v) -> k to v.toAny() }
|
is JsonObject -> entries.mapNotNull { (k, v) -> v.toAnyOrNull()?.let { k to it } }.toMap()
|
||||||
is JsonArray -> map { it.toAny() }
|
is JsonArray -> mapNotNull { it.toAnyOrNull() }
|
||||||
}
|
}
|
||||||
|
|
||||||
object AnyMapSerializer : KSerializer<Map<String, Any>> {
|
object AnyMapSerializer : KSerializer<Map<String, Any>> {
|
||||||
private val delegate = MapSerializer(String.serializer(), AnySerializer)
|
private val delegate = MapSerializer(String.serializer(), AnySerializer)
|
||||||
override val descriptor: SerialDescriptor = delegate.descriptor
|
override val descriptor: SerialDescriptor = delegate.descriptor
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
override fun deserialize(decoder: Decoder): Map<String, Any> {
|
||||||
override fun deserialize(decoder: Decoder): Map<String, Any> =
|
// Decode the object directly and drop null-valued keys, rather than delegating per-value
|
||||||
delegate.deserialize(decoder)
|
// (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>) =
|
override fun serialize(encoder: Encoder, value: Map<String, Any>) =
|
||||||
delegate.serialize(encoder, value)
|
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