diff --git a/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/KotlinSerializer.kt b/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/KotlinSerializer.kt new file mode 100644 index 00000000..2c3eb4fc --- /dev/null +++ b/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/KotlinSerializer.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2010-2021. Axon Framework + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.axonframework.extensions.kotlin.serialization + +import kotlinx.serialization.ContextualSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import org.axonframework.common.ObjectUtils +import org.axonframework.serialization.AnnotationRevisionResolver +import org.axonframework.serialization.ChainingConverter +import org.axonframework.serialization.Converter +import org.axonframework.serialization.RevisionResolver +import org.axonframework.serialization.SerializedObject +import org.axonframework.serialization.SerializedType +import org.axonframework.serialization.Serializer +import org.axonframework.serialization.SimpleSerializedObject +import org.axonframework.serialization.SimpleSerializedType +import org.axonframework.serialization.UnknownSerializedType +import kotlin.reflect.full.companionObject +import kotlin.reflect.full.companionObjectInstance +import org.axonframework.serialization.SerializationException as AxonSerializationException + +/** + * Implementation of Axon Serializer that uses a kotlinx.serialization implementation. + * The serialized format is JSON. + * + * The DSL function kotlinSerializer can be used to easily configure the parameters + * for this serializer. + * + * @see kotlinx.serialization.Serializer + * @see org.axonframework.serialization.Serializer + * @see kotlinSerializer + * + * @since 0.2.0 + * @author Hidde Wieringa + */ +class KotlinSerializer( + private val revisionResolver: RevisionResolver = AnnotationRevisionResolver(), + private val converter: Converter = ChainingConverter(), + private val json: Json = Json, +) : Serializer { + + private val serializerCache: MutableMap, KSerializer<*>> = mutableMapOf() + + override fun serialize(value: Any?, expectedRepresentation: Class): SerializedObject { + try { + val type = ObjectUtils.nullSafeTypeOf(value) + + if (expectedRepresentation.isAssignableFrom(JsonElement::class.java)) { + return SimpleSerializedObject( + (if (value == null) JsonNull else json.encodeToJsonElement(type.serializer(), value)) as T, + expectedRepresentation, + typeForClass(type) + ) + } + + // By default, encode to String. This can be converted to other types by the converter + val stringSerialized: SerializedObject = SimpleSerializedObject( + (if (value == null) "null" else json.encodeToString(type.serializer(), value)), + String::class.java, + typeForClass(type) + ) + + return converter.convert(stringSerialized, expectedRepresentation) + } catch (ex: SerializationException) { + throw AxonSerializationException("Cannot serialize type ${value?.javaClass?.name} to representation $expectedRepresentation.", ex) + } + } + + override fun canSerializeTo(expectedRepresentation: Class): Boolean = + expectedRepresentation.isAssignableFrom(JsonElement::class.java) || + converter.canConvert(String::class.java, expectedRepresentation) + + override fun deserialize(serializedObject: SerializedObject?): T? { + try { + if (serializedObject == null) { + return null + } + + if (serializedObject.type == SerializedType.emptyType()) { + return null + } + + val foundType = classForType(serializedObject.type) + if (UnknownSerializedType::class.java.isAssignableFrom(foundType)) { + return UnknownSerializedType(this, serializedObject) as T + } + + val serializer: KSerializer = foundType.serializer() as KSerializer + + if (serializedObject.contentType.isAssignableFrom(JsonElement::class.java)) { + return json.decodeFromJsonElement(serializer, serializedObject.data as JsonElement) + } + + val stringSerialized: SerializedObject = converter.convert(serializedObject, String::class.java) + return json.decodeFromString(serializer, stringSerialized.data) + } catch (ex: SerializationException) { + throw AxonSerializationException( + "Could not deserialize from content type ${serializedObject?.contentType} to type ${serializedObject?.type}", + ex + ) + } + } + + private fun SerializedObject.serializer(): KSerializer = + classForType(type).serializer() as KSerializer + + /** + * When a type is compiled by the Kotlin compiler extension, a companion object + * is created which contains a method `serializer()`. This method should be called + * to get the serializer of the class. + * + * In a 'normal' serialization environment, you would call the MyClass.serializer() + * method directly. Here we are in a generic setting, and need reflection to call + * the method. + * + * If there is no `serializer` method, a ContextualSerializer will be created. This + * serializer requires manual configuration of the SerializersModule containing a + * KSerializer which will be used when this class is serialized. + * + * This method caches the reflection mapping from class to serializer for efficiency. + */ + private fun Class.serializer(): KSerializer = + serializerCache.computeIfAbsent(this) { + // Class: T must be non-null + val kClass = (this as Class).kotlin + + val companion = kClass.companionObject + ?: return@computeIfAbsent ContextualSerializer(kClass) + + val serializerMethod = companion.java.getMethod("serializer") + ?: return@computeIfAbsent ContextualSerializer(kClass) + + serializerMethod.invoke(kClass.companionObjectInstance) as KSerializer<*> + } as KSerializer + + override fun classForType(type: SerializedType): Class<*> = + if (SerializedType.emptyType() == type) { + Void.TYPE + } else { + try { + Class.forName(type.name) + } catch (e: ClassNotFoundException) { + UnknownSerializedType::class.java + } + } + + override fun typeForClass(type: Class<*>?): SerializedType = + if (type == null || Void.TYPE == type || Void::class.java == type) { + SimpleSerializedType.emptyType() + } else { + SimpleSerializedType(type.name, revisionResolver.revisionOf(type)) + } + + override fun getConverter(): Converter = + converter + +} \ No newline at end of file diff --git a/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/serializer/ConfigTokenSerializer.kt b/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/serializer/ConfigTokenSerializer.kt new file mode 100644 index 00000000..f923d420 --- /dev/null +++ b/kotlin/src/main/kotlin/org/axonframework/extensions/kotlin/serialization/serializer/ConfigTokenSerializer.kt @@ -0,0 +1,38 @@ +package org.axonframework.extensions.kotlin.serialization.serializer + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.Serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import org.axonframework.eventhandling.tokenstore.ConfigToken + +@Serializable +data class ConfigTokenSurrogate( + private val config: Map +) { + fun toToken() = ConfigToken(config) +} + +fun ConfigToken.toSurrogate() = ConfigTokenSurrogate(config) + +@OptIn(ExperimentalSerializationApi::class) +@Serializer(forClass = ConfigToken::class) +class ConfigTokenSerializer : KSerializer { + override val descriptor: SerialDescriptor = ConfigTokenSurrogate.serializer().descriptor + + override fun serialize(encoder: Encoder, value: ConfigToken) = + encoder.encodeSerializableValue(ConfigTokenSurrogate.serializer(), value.toSurrogate()) + + override fun deserialize(decoder: Decoder): ConfigToken = + decoder.decodeSerializableValue(ConfigTokenSurrogate.serializer()).toToken() +} + +// The following does not work, emits compiler errors. +// Also with the `-Xuse-ir` Kotlin compiler argument (version 1.6.20) + +//@OptIn(ExperimentalSerializationApi::class) +//@Serializer(forClass = ConfigToken::class) +//object ConfigTokenSerializer \ No newline at end of file diff --git a/kotlin/src/test/kotlin/org/axonframework/extensions/kotlin/serialization/KotlinSerializerTest.kt b/kotlin/src/test/kotlin/org/axonframework/extensions/kotlin/serialization/KotlinSerializerTest.kt new file mode 100644 index 00000000..15bc7448 --- /dev/null +++ b/kotlin/src/test/kotlin/org/axonframework/extensions/kotlin/serialization/KotlinSerializerTest.kt @@ -0,0 +1,159 @@ +package org.axonframework.extensions.kotlin.serialization + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual +import org.axonframework.eventhandling.tokenstore.ConfigToken +import org.axonframework.extensions.kotlin.serialization.serializer.ConfigTokenSerializer +import org.axonframework.serialization.AnnotationRevisionResolver +import org.axonframework.serialization.ChainingConverter +import org.axonframework.serialization.SerializedType +import org.axonframework.serialization.SimpleSerializedObject +import org.axonframework.serialization.UnknownSerializedType +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.InputStream +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class KotlinSerializerTest { + + /** + * This class will automatically become serializable through the Kotlin serialization compiler plugin. + */ + @Serializable + data class TestData( + val name: String, + val value: Float? + ) + + @Test + fun canSerializeTo() { + val serializer = KotlinSerializer() + + assertTrue(serializer.canSerializeTo(String::class.java)) + assertTrue(serializer.canSerializeTo(ByteArray::class.java)) + assertTrue(serializer.canSerializeTo(JsonElement::class.java)) + assertTrue(serializer.canSerializeTo(InputStream::class.java)) + } + + @Test + fun `configuration options`() { + val serializer = KotlinSerializer( + revisionResolver = AnnotationRevisionResolver(), + converter = ChainingConverter(), + json = Json, + ) + + assertNotNull(serializer) + } + + @Test + fun serialize() { + val serializer = KotlinSerializer() + + val emptySerialized = serializer.serialize(TestData("", null), String::class.java) + assertEquals("SimpleSerializedType[org.axonframework.extensions.kotlin.serialization.KotlinSerializerTest\$TestData] (revision null)", emptySerialized.type.toString()) + assertEquals("""{"name":"","value":null}""", emptySerialized.data) + assertEquals(String::class.java, emptySerialized.contentType) + + val filledSerialized = serializer.serialize(TestData("name", 1.23f), String::class.java) + assertEquals("SimpleSerializedType[org.axonframework.extensions.kotlin.serialization.KotlinSerializerTest\$TestData] (revision null)", filledSerialized.type.toString()) + assertEquals("""{"name":"name","value":1.23}""", filledSerialized.data) + assertEquals(String::class.java, filledSerialized.contentType) + + val nullSerialized = serializer.serialize(null, String::class.java) + assertEquals("null", nullSerialized.data) + assertEquals(String::class.java, nullSerialized.contentType) + } + + @Test + fun deserialize() { + val serializer = KotlinSerializer() + + val nullDeserialized: Any? = serializer.deserialize( + SimpleSerializedObject( + "", + String::class.java, + SerializedType.emptyType() + ) + ) + assertNull(nullDeserialized) + + val emptyDeserialized: Any? = serializer.deserialize( + SimpleSerializedObject( + """{"name":"","value":null}""", + String::class.java, + TestData::class.java.name, + null + ) + ) + assertNotNull(emptyDeserialized as TestData) + assertEquals(emptyDeserialized.name, "") + assertEquals(emptyDeserialized.value, null) + + val filledDeserialized: Any? = serializer.deserialize( + SimpleSerializedObject( + """{"name":"name","value":1.23}""", + String::class.java, + TestData::class.java.name, + null + ) + ) + assertNotNull(filledDeserialized as TestData) + assertEquals(filledDeserialized.name, "name") + assertEquals(filledDeserialized.value, 1.23f) + + val unknownDeserializedType: Any? = serializer.deserialize( + SimpleSerializedObject( + """anything""", + String::class.java, + UnknownSerializedType::class.java.name, + null + ) + ) + assertNotNull(unknownDeserializedType as UnknownSerializedType) + } + + @Test + fun `byte arrays`() { + val serializer = KotlinSerializer() + + assertNotNull(serializer.deserialize(serializer.serialize(TestData("name", null), ByteArray::class.java))) + } + + @Test + fun `JSON elements`() { + val serializer = KotlinSerializer() + + assertNotNull(serializer.deserialize(serializer.serialize(TestData("name", null), JsonElement::class.java))) + } + + @Test + fun `input stream`() { + val serializer = KotlinSerializer() + + assertNotNull(serializer.deserialize(serializer.serialize(TestData("name", null), InputStream::class.java))) + } + + @Test + fun `example of custom serializer for ConfigToken`() { + val serializer = KotlinSerializer( + json = Json { + serializersModule = SerializersModule { + contextual(ConfigTokenSerializer()) + } + } + ) + + val tokenBefore = ConfigToken(mapOf("test" to "value")) + val serialized = serializer.serialize(tokenBefore, String::class.java) + assertEquals("""{"config":{"test":"value"}}""", serialized.data) + val token: ConfigToken? = serializer.deserialize(serialized) + assertNotNull(token as ConfigToken) + assertEquals("value", token.get("test")) + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 92daf94e..ea7e6ece 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ 1.6.20 2.13.2 2.1.21 + 1.3.2 1.12.3 5.8.2 1.7.36 @@ -129,6 +130,14 @@ test + + + org.jetbrains.kotlinx + kotlinx-serialization-json + ${kotlin-serialization.version} + true + + org.junit.jupiter @@ -433,6 +442,7 @@ no-arg all-open + kotlinx-serialization @@ -476,6 +486,12 @@ kotlin-maven-noarg ${kotlin.version} + + org.jetbrains.kotlin + kotlin-maven-serialization + ${kotlin.version} + true +