diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlContentPolymorphicSerializer.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlContentPolymorphicSerializer.kt new file mode 100644 index 00000000..e35d7f2e --- /dev/null +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlContentPolymorphicSerializer.kt @@ -0,0 +1,72 @@ +/* + + Copyright 2018-2023 Charles Korn. + + 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 + + https://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 com.charleskorn.kaml + +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialInfo +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PolymorphicKind +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.serializerOrNull +import kotlin.reflect.KClass + +@OptIn(ExperimentalSerializationApi::class) +public abstract class YamlContentPolymorphicSerializer(private val baseClass: KClass) : KSerializer { + @OptIn(InternalSerializationApi::class) + override val descriptor: SerialDescriptor = buildSerialDescriptor( + "${YamlContentPolymorphicSerializer::class.simpleName}<${baseClass.simpleName}>", + PolymorphicKind.SEALED, + ) { + annotations += Marker() + } + + @SerialInfo + internal annotation class Marker + + @OptIn(InternalSerializationApi::class) + override fun serialize(encoder: Encoder, value: T) { + val actualSerializer = encoder.serializersModule.getPolymorphic(baseClass, value) + ?: value::class.serializerOrNull() + ?: throwSubtypeNotRegistered(value::class, baseClass) + @Suppress("UNCHECKED_CAST") + (actualSerializer as KSerializer).serialize(encoder, value) + } + + override fun deserialize(decoder: Decoder): T { + return decoder.decodeSerializableValue(selectDeserializer((decoder as YamlInput).node)) + } + + public abstract fun selectDeserializer(node: YamlNode): DeserializationStrategy + + private fun throwSubtypeNotRegistered(subClass: KClass<*>, baseClass: KClass<*>): Nothing { + val subClassName = subClass.simpleName ?: "$subClass" + throw SerializationException( + """ + Class '$subClassName' is not registered for polymorphic serialization in the scope of '${baseClass.simpleName}'. + Mark the base class as 'sealed' or register the serializer explicitly. + """.trimIndent(), + ) + } +} diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt index a0acf52e..620ec896 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt @@ -49,7 +49,13 @@ public sealed class YamlInput( is YamlScalar -> when { descriptor.kind is PrimitiveKind || descriptor.kind is SerialKind.ENUM || descriptor.isInline -> YamlScalarInput(node, yaml, context, configuration) descriptor.kind is SerialKind.CONTEXTUAL -> createContextual(node, yaml, context, configuration, descriptor) - descriptor.kind is PolymorphicKind -> throw MissingTypeTagException(node.path) + descriptor.kind is PolymorphicKind -> { + if (descriptor.isContentBasedPolymorphic) { + createContextual(node, yaml, context, configuration, descriptor) + } else { + throw MissingTypeTagException(node.path) + } + } else -> throw IncorrectTypeException("Expected ${descriptor.kind.friendlyDescription}, but got a scalar value", node.path) } @@ -63,11 +69,18 @@ public sealed class YamlInput( is StructureKind.CLASS, StructureKind.OBJECT -> YamlObjectInput(node, yaml, context, configuration) is StructureKind.MAP -> YamlMapInput(node, yaml, context, configuration) is SerialKind.CONTEXTUAL -> createContextual(node, yaml, context, configuration, descriptor) - is PolymorphicKind -> when (configuration.polymorphismStyle) { - PolymorphismStyle.None -> - throw IncorrectTypeException("Encountered a polymorphic map descriptor but PolymorphismStyle is 'None'", node.path) - PolymorphismStyle.Tag -> throw MissingTypeTagException(node.path) - PolymorphismStyle.Property -> createPolymorphicMapDeserializer(node, yaml, context, configuration) + is PolymorphicKind -> { + if (descriptor.isContentBasedPolymorphic) { + createContextual(node, yaml, context, configuration, descriptor) + } else { + when (configuration.polymorphismStyle) { + PolymorphismStyle.None -> + throw IncorrectTypeException("Encountered a polymorphic map descriptor but PolymorphismStyle is 'None'", node.path) + + PolymorphismStyle.Tag -> throw MissingTypeTagException(node.path) + PolymorphismStyle.Property -> createPolymorphicMapDeserializer(node, yaml, context, configuration) + } + } } else -> throw IncorrectTypeException("Expected ${descriptor.kind.friendlyDescription}, but got a map", node.path) } @@ -115,6 +128,8 @@ public sealed class YamlInput( private fun YamlMap.withoutKey(key: String): YamlMap { return this.copy(entries = entries.filterKeys { it.content != key }) } + + private val SerialDescriptor.isContentBasedPolymorphic get() = annotations.any { it is YamlContentPolymorphicSerializer.Marker } } override fun decodeSerializableValue(deserializer: DeserializationStrategy): T { diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/YamlContentPolymorphicSerializerTest.kt b/src/commonTest/kotlin/com/charleskorn/kaml/YamlContentPolymorphicSerializerTest.kt new file mode 100644 index 00000000..c91e0983 --- /dev/null +++ b/src/commonTest/kotlin/com/charleskorn/kaml/YamlContentPolymorphicSerializerTest.kt @@ -0,0 +1,195 @@ +/* + + Copyright 2018-2023 Charles Korn. + + 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 + + https://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 com.charleskorn.kaml + +import com.charleskorn.kaml.testobjects.TestSealedStructure +import com.charleskorn.kaml.testobjects.polymorphicModule +import io.kotest.assertions.asClue +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.nullable + +class YamlContentPolymorphicSerializerTest : FlatFunSpec({ + context("a YAML parser") { + context("parsing polymorphic values with PolymorphismStyle.None") { + val polymorphicYaml = Yaml( + serializersModule = polymorphicModule, + configuration = YamlConfiguration(polymorphismStyle = PolymorphismStyle.None), + ) + + context("given some input where the value should be a sealed class") { + val input = """ + value: "asdfg" + """.trimIndent() + + val result = polymorphicYaml.decodeFromString(TestSealedStructureBasedOnContentSerializer, input) + + test("deserializes it to a Kotlin object") { + result shouldBe TestSealedStructure.SimpleSealedString("asdfg") + } + } + + context("given some input where the value should be a sealed class (inline)") { + val input = """ + "abcdef" + """.trimIndent() + + val result = polymorphicYaml.decodeFromString(TestSealedStructureBasedOnContentSerializer, input) + + test("deserializes it to a Kotlin object") { + result shouldBe TestSealedStructure.InlineSealedString("abcdef") + } + } + + context("given some input missing without the serializer") { + val input = """ + value: "asdfg" + """.trimIndent() + + test("throws an exception with the correct location information") { + val exception = shouldThrow { + polymorphicYaml.decodeFromString(TestSealedStructure.serializer(), input) + } + + exception.asClue { + it.message shouldBe "Encountered a polymorphic map descriptor but PolymorphismStyle is 'None'" + it.line shouldBe 1 + it.column shouldBe 1 + it.path shouldBe YamlPath.root + } + } + } + + context("given some input representing a list of polymorphic objects") { + val input = """ + - value: null + - value: -987 + - value: 654 + - "testing" + - value: "tests" + """.trimIndent() + + val result = polymorphicYaml.decodeFromString( + ListSerializer(TestSealedStructureBasedOnContentSerializer), + input, + ) + + test("deserializes it to a Kotlin object") { + result shouldBe listOf( + TestSealedStructure.SimpleSealedString(null), + TestSealedStructure.SimpleSealedInt(-987), + TestSealedStructure.SimpleSealedInt(654), + TestSealedStructure.InlineSealedString("testing"), + TestSealedStructure.SimpleSealedString("tests"), + ) + } + } + + context("given some input with a tag and a type property") { + val input = """ + ! + kind: sealedString + value: "asdfg" + """.trimIndent() + + test("throws an exception with the correct location information") { + val exception = shouldThrow { + polymorphicYaml.decodeFromString(TestSealedStructureBasedOnContentSerializer, input) + } + + exception.asClue { + it.message shouldBe "Encountered a tagged polymorphic descriptor but PolymorphismStyle is 'None'" + it.line shouldBe 1 + it.column shouldBe 1 + it.path shouldBe YamlPath.root + } + } + } + } + } + context("a YAML serializer") { + context("serializing polymorphic values with custom serializer") { + val polymorphicYaml = Yaml( + serializersModule = polymorphicModule, + configuration = YamlConfiguration(polymorphismStyle = PolymorphismStyle.Tag), + ) + + context("serializing a sealed type") { + val input = TestSealedStructure.SimpleSealedInt(5) + val output = polymorphicYaml.encodeToString(TestSealedStructureBasedOnContentSerializer, input) + val expectedYaml = """ + value: 5 + """.trimIndent() + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedYaml + } + } + + context("serializing a list of polymorphic values") { + val input = listOf( + TestSealedStructure.SimpleSealedInt(5), + TestSealedStructure.SimpleSealedString("some test"), + TestSealedStructure.SimpleSealedInt(-20), + TestSealedStructure.InlineSealedString("testing"), + TestSealedStructure.SimpleSealedString(null), + null, + ) + + val output = polymorphicYaml.encodeToString( + ListSerializer(TestSealedStructureBasedOnContentSerializer.nullable), + input, + ) + + val expectedYaml = """ + - value: 5 + - value: "some test" + - value: -20 + - "testing" + - value: null + - null + """.trimIndent() + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedYaml + } + } + } + } +}) + +object TestSealedStructureBasedOnContentSerializer : YamlContentPolymorphicSerializer( + TestSealedStructure::class, +) { + override fun selectDeserializer(node: YamlNode): DeserializationStrategy = when (node) { + is YamlScalar -> TestSealedStructure.InlineSealedString.serializer() + is YamlMap -> when (val value: YamlNode? = node["value"]) { + is YamlScalar -> when { + value.content.toIntOrNull() == null -> TestSealedStructure.SimpleSealedString.serializer() + else -> TestSealedStructure.SimpleSealedInt.serializer() + } + is YamlNull -> TestSealedStructure.SimpleSealedString.serializer() + else -> throw SerializationException("Unsupported property type for TestSealedStructure.value: ${value?.let { it::class.simpleName}}") + } + else -> throw SerializationException("Unsupported node type for TestSealedStructure: ${node::class.simpleName}") + } +}