Skip to content

[#380] fix: add dedicated JsonMetaDataSerializer #382

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

Merged
merged 1 commit into from
May 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ val AxonSerializersModule = SerializersModule {
subclass(MultipleInstancesResponseTypeSerializer)
subclass(ArrayResponseTypeSerializer)
}
contextual(MetaData::class) { MetaDataSerializer }
contextual(MetaData::class) { ComposedMetaDataSerializer }
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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<MetaData> {
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:
Expand All @@ -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<MetaData> {

object StringMetaDataSerializer : KSerializer<MetaData> {
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }

override val descriptor: SerialDescriptor = String.serializer().descriptor
Expand All @@ -46,14 +79,93 @@ object MetaDataSerializer : KSerializer<MetaData> {
val map: Map<String, JsonElement> = 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<MetaData> {
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)
}

Expand All @@ -67,7 +179,9 @@ object MetaDataSerializer : KSerializer<MetaData> {
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}")
Expand All @@ -79,12 +193,8 @@ object MetaDataSerializer : KSerializer<MetaData> {
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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<SerializationException> {
Expand Down
Loading