From 4880267d735da2646c93e1c86bbf9f6e8a735f4d Mon Sep 17 00:00:00 2001 From: Mateusz Nowak Date: Wed, 7 May 2025 13:53:30 +0200 Subject: [PATCH] [#380] refactor: add dedicated `JsonMetaDataSerializer` --- .../kotlin/serialization/AxonSerializers.kt | 2 +- .../serialization/MetaDataSerializer.kt | 146 +++++++++++++++--- .../serializer/MetaDataSerializerTest.kt | 24 ++- 3 files changed, 152 insertions(+), 20 deletions(-) diff --git a/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/AxonSerializers.kt b/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/AxonSerializers.kt index 084020b..ea30085 100644 --- a/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/AxonSerializers.kt +++ b/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/AxonSerializers.kt @@ -110,7 +110,7 @@ val AxonSerializersModule = SerializersModule { subclass(MultipleInstancesResponseTypeSerializer) subclass(ArrayResponseTypeSerializer) } - contextual(MetaData::class) { MetaDataSerializer } + contextual(MetaData::class) { ComposedMetaDataSerializer } } /** diff --git a/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/MetaDataSerializer.kt b/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/MetaDataSerializer.kt index e31312c..7521bef 100644 --- a/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/MetaDataSerializer.kt +++ b/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/MetaDataSerializer.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encodeToString import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* @@ -13,11 +14,44 @@ import java.time.Instant import java.util.UUID /** - * A Kotlinx [KSerializer] for Axon Framework's [MetaData] type, supporting serialization across any format. + * A composite Kotlinx [KSerializer] for Axon Framework's [MetaData] type that selects the + * appropriate serializer based on the encoder/decoder type. + * + * This serializer delegates to: + * - [JsonMetaDataSerializer] when used with [JsonEncoder]/[JsonDecoder] + * - [StringMetaDataSerializer] for all other encoder/decoder types + * + * This allows efficient JSON serialization without unnecessary string encoding, while + * maintaining compatibility with all other serialization formats through string-based + * serialization. + * + * @author Mateusz Nowak + * @since 4.11.2 + */ +object ComposedMetaDataSerializer : KSerializer { + override val descriptor: SerialDescriptor = StringMetaDataSerializer.descriptor + + override fun serialize(encoder: Encoder, value: MetaData) { + when (encoder) { + is JsonEncoder -> JsonMetaDataSerializer.serialize(encoder, value) + else -> StringMetaDataSerializer.serialize(encoder, value) + } + } + + override fun deserialize(decoder: Decoder): MetaData { + return when (decoder) { + is JsonDecoder -> JsonMetaDataSerializer.deserialize(decoder) + else -> StringMetaDataSerializer.deserialize(decoder) + } + } +} + +/** + * A Kotlinx [KSerializer] for Axon Framework's [MetaData] type, suitable for serialization across any format. * * This serializer converts a [MetaData] instance to a JSON-encoded [String] using a recursive conversion - * of all entries into [JsonElement]s. This JSON string is then serialized using [String.serializer], - * ensuring compatibility with any [kotlinx.serialization.encoding.Encoder]—including formats such as JSON, CBOR, ProtoBuf, or Avro. + * of all entries into [JsonElement]s. This JSON string is then serialized using [String.serializer()], + * ensuring compatibility with any serialization format. * * ### Supported value types * Each entry in the MetaData map must conform to one of the following: @@ -31,13 +65,12 @@ import java.util.UUID * - Custom types that do not fall into the above categories will throw a [SerializationException] * - Deserialized non-primitive types (like [UUID], [Instant]) are restored as [String], not their original types * - * This serializer guarantees structural integrity of nested metadata (e.g. map within list within map), while remaining format-agnostic. + * This serializer guarantees structural integrity of nested metadata while remaining format-agnostic. * * @author Mateusz Nowak * @since 4.11.1 */ -object MetaDataSerializer : KSerializer { - +object StringMetaDataSerializer : KSerializer { private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } override val descriptor: SerialDescriptor = String.serializer().descriptor @@ -46,14 +79,93 @@ object MetaDataSerializer : KSerializer { val map: Map = value.entries.associate { (key, rawValue) -> key to toJsonElement(rawValue) } - val jsonString = json.encodeToString(MapSerializer(String.serializer(), JsonElement.serializer()), map) - encoder.encodeSerializableValue(String.serializer(), jsonString) + val jsonString = json.encodeToString(JsonObject(map)) + encoder.encodeString(jsonString) + } + + override fun deserialize(decoder: Decoder): MetaData { + val jsonString = decoder.decodeString() + val jsonObject = json.parseToJsonElement(jsonString).jsonObject + val reconstructed = jsonObject.mapValues { (_, jsonElement) -> + fromJsonElement(jsonElement) + } + return MetaData(reconstructed) + } + + private fun toJsonElement(value: Any?): JsonElement = when (value) { + null -> JsonNull + is String -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Int -> JsonPrimitive(value) + is Long -> JsonPrimitive(value) + is Float -> JsonPrimitive(value) + is Double -> JsonPrimitive(value) + is UUID -> JsonPrimitive(value.toString()) + is Instant -> JsonPrimitive(value.toString()) + is Map<*, *> -> JsonObject(value.entries.associate { (k, v) -> + k.toString() to toJsonElement(v) + }) + is Collection<*> -> JsonArray(value.map { toJsonElement(it) }) + is Array<*> -> JsonArray(value.map { toJsonElement(it) }) + else -> throw SerializationException("Unsupported type: ${value::class}") + } + + private fun fromJsonElement(element: JsonElement): Any? = when (element) { + is JsonNull -> null + is JsonPrimitive -> { + if (element.isString) { + element.content + } else { + element.booleanOrNull ?: element.intOrNull ?: element.longOrNull ?: + element.floatOrNull ?: element.doubleOrNull ?: element.content + } + } + is JsonObject -> element.mapValues { fromJsonElement(it.value) } + is JsonArray -> element.map { fromJsonElement(it) } + } +} + +/** + * A Kotlinx [KSerializer] for Axon Framework's [MetaData] type, optimized for JSON serialization. + * + * This serializer converts a [MetaData] instance directly to a JSON object structure, + * avoiding the string-encoding that [StringMetaDataSerializer] uses. This ensures JSON values + * are properly encoded without quote escaping. + * + * ### Supported value types + * Each entry in the MetaData map must conform to one of the following: + * - Primitives: [String], [Int], [Long], [Float], [Double], [Boolean] + * - Complex types: [UUID], [Instant] + * - Collections: [Collection], [List], [Set] + * - Arrays: [Array] + * - Nested Maps: [Map] with keys convertible to [String] + * + * ### Limitations + * - Custom types that do not fall into the above categories will throw a [SerializationException] + * - Deserialized non-primitive types (like [UUID], [Instant]) are restored as [String], not their original types + * + * This serializer is specifically optimized for JSON serialization formats. + * + * @author Mateusz Nowak + * @since 4.11.2 + */ +object JsonMetaDataSerializer : KSerializer { + private val mapSerializer = MapSerializer(String.serializer(), JsonElement.serializer()) + + override val descriptor: SerialDescriptor = mapSerializer.descriptor + + override fun serialize(encoder: Encoder, value: MetaData) { + val jsonMap = value.entries.associate { (key, rawValue) -> + key to toJsonElement(rawValue) + } + encoder.encodeSerializableValue(mapSerializer, jsonMap) } override fun deserialize(decoder: Decoder): MetaData { - val jsonString = decoder.decodeSerializableValue(String.serializer()) - val map = json.decodeFromString(MapSerializer(String.serializer(), JsonElement.serializer()), jsonString) - val reconstructed = map.mapValues { (_, jsonElement) -> fromJsonElement(jsonElement) } + val jsonMap = decoder.decodeSerializableValue(mapSerializer) + val reconstructed = jsonMap.mapValues { (_, jsonElement) -> + fromJsonElement(jsonElement) + } return MetaData(reconstructed) } @@ -67,7 +179,9 @@ object MetaDataSerializer : KSerializer { is Double -> JsonPrimitive(value) is UUID -> JsonPrimitive(value.toString()) is Instant -> JsonPrimitive(value.toString()) - is Map<*, *> -> JsonObject(value.entries.associate { (k, v) -> k.toString() to toJsonElement(v) }) + is Map<*, *> -> JsonObject(value.entries.associate { (k, v) -> + k.toString() to toJsonElement(v) + }) is Collection<*> -> JsonArray(value.map { toJsonElement(it) }) is Array<*> -> JsonArray(value.map { toJsonElement(it) }) else -> throw SerializationException("Unsupported type: ${value::class}") @@ -79,12 +193,8 @@ object MetaDataSerializer : KSerializer { if (element.isString) { element.content } else { - element.booleanOrNull - ?: element.intOrNull - ?: element.longOrNull - ?: element.floatOrNull - ?: element.doubleOrNull - ?: element.content + element.booleanOrNull ?: element.intOrNull ?: element.longOrNull ?: + element.floatOrNull ?: element.doubleOrNull ?: element.content } } is JsonObject -> element.mapValues { fromJsonElement(it.value) } diff --git a/kotlin/src/test/kotlin/org/axonframework/extensions/kotlin/serializer/MetaDataSerializerTest.kt b/kotlin/src/test/kotlin/org/axonframework/extensions/kotlin/serializer/MetaDataSerializerTest.kt index 68d133b..b1709be 100644 --- a/kotlin/src/test/kotlin/org/axonframework/extensions/kotlin/serializer/MetaDataSerializerTest.kt +++ b/kotlin/src/test/kotlin/org/axonframework/extensions/kotlin/serializer/MetaDataSerializerTest.kt @@ -218,6 +218,28 @@ class MetaDataSerializerTest { assertEquals(deserializedValue, complexStructure) } + @Test + fun `should not escape quotes in complex nested structures in MetaData`() { + val complexStructure = mapOf( + "string" to "value", + "number" to 42, + "boolean" to true, + "null" to null, + "list" to listOf(1, 2, 3), + "nestedMap" to mapOf( + "a" to "valueA", + "b" to listOf("x", "y", "z"), + "c" to mapOf("nested" to "deepValue") + ) + ) + + val metaData = MetaData.with("complexValue", complexStructure) + + val serialized = jsonSerializer.serialize(metaData, String::class.java) + val json = """{"complexValue":{"string":"value","number":42,"boolean":true,"null":null,"list":[1,2,3],"nestedMap":{"a":"valueA","b":["x","y","z"],"c":{"nested":"deepValue"}}}}""" + assertEquals(json, serialized.data); + } + @Test fun `do not handle custom objects`() { data class Person(val name: String, val age: Int) @@ -235,7 +257,7 @@ class MetaDataSerializerTest { fun `should throw exception when deserializing malformed JSON`() { val serializedType = SimpleSerializedType(MetaData::class.java.name, null) - val syntaxErrorJson = """{"key": value}""" // missing quotes around value + val syntaxErrorJson = """{"key": value""" // missing closing bracket around value val syntaxErrorObject = SimpleSerializedObject(syntaxErrorJson, String::class.java, serializedType) assertThrows {