Issue
The Problem
Due to project architecture, backward compatibility and so on, I need to change class discriminator on one abstract class and all classes that inherit from it. Ideally, I want it to be an enum.
I tried to use @JsonClassDiscriminator
but Kotlinx still uses type
member as discriminator which have name clash with member in class. I changed member name to test what will happen and Kotlinx just used type
as discriminator.
Also, outside of annotations, I want to avoid changing these classes. It's shared code, so any non backward compatible changes will be problematic.
Code
I prepared some code, detached from project, that I use for testing behavior.
fun main() {
val derived = Derived("type", "name") as Base
val json = Json {
prettyPrint = true
encodeDefaults = true
serializersModule = serializers
}.encodeToString(derived)
print(json)
}
@Serializable
@JsonClassDiscriminator("source")
data class Derived(
val type: String?,
val name: String?,
) : Base() {
override val source = FooEnum.A
}
@Serializable
@JsonClassDiscriminator("source")
abstract class Base {
abstract val source: FooEnum
}
enum class FooEnum { A, B }
internal val serializers = SerializersModule {
polymorphic(Base::class) {
subclass(Derived::class)
}
}
If I don't change type
member name, I got this error:
Exception in thread "main" java.lang.IllegalArgumentException: Polymorphic serializer for class my.pack.Derived has property 'type' that conflicts with JSON class discriminator. You can either change class discriminator in JsonConfiguration, rename property with @SerialName annotation or fall back to array polymorphism
If I do change the name, I got this JSON which clearly shows, that json type discriminator wasn't changed.
{
"type": "my.pack.Derived",
"typeChanged": "type",
"name": "name",
"source": "A"
}
Solution
Kotlinx Serialization doesn't allow for significant customisation of the default type discriminator - you can only change the name of the field.
Encoding default fields
Before I jump into the solutions, I want to point out that in these examples using @EncodeDefault
or Json { encodeDefaults = true }
is required, otherwise Kotlinx Serialization won't encode your val source
.
@Serializable
data class Derived(
val type: String?,
val name: String?,
) : Base() {
@EncodeDefault
override val source = FooEnum.A
}
Changing the discriminator field
You can use @JsonClassDiscriminator
to define the name of the discriminator
(Note that you only need @JsonClassDiscriminator
on the parent Base
class, not both)
However, @JsonClassDiscriminator
is more like an 'alternate name', not an override. To override it, you can set classDiscriminator
in the Json { }
builder
val mapper = Json {
prettyPrint = true
encodeDefaults = true
serializersModule = serializers
classDiscriminator = "source"
}
Discriminator value
You can change the value of type
for subclasses though - use @SerialName("...")
on your subclasses.
@Serializable
@SerialName("A")
data class Derived(
val type: String?,
val name: String?,
) : Base()
Including the discriminator in a class
You also can't include the discriminator in your class - https://github.com/Kotlin/kotlinx.serialization/issues/1664
So there are 3 options.
Closed polymorphism
Change your code to use closed polymorphism
Since Base
is a sealed class, instead of an enum, you can use type-checks on any Base
instance
fun main() {
val derived = Derived("type", "name")
val mapper = Json {
prettyPrint = true
encodeDefaults = true
classDiscriminator = "source"
}
val json = mapper.encodeToString(Base.serializer(), derived)
println(json)
val entity = mapper.decodeFromString(Base.serializer(), json)
when (entity) {
is Derived -> println(entity)
}
}
@Serializable
@SerialName("A")
data class Derived(
val type: String?,
val name: String?,
) : Base()
@Serializable
sealed class Base
Since Base
is now sealed, it's basically the same as an enum, so there's no need for your FooEnum
.
val entity = mapper.decodeFromString(Base.serializer(), json)
when (entity) {
is Derived -> println(entity)
// no need for an 'else'
}
However, you still need Json { classDiscriminator= "source" }
...
Content-based deserializer
Use a content-based deserializer.
This would mean you wouldn't need to make Base
a sealed class, and you could manually define a default if the discriminator is unknown.
object BaseSerializer : JsonContentPolymorphicSerializer<Base>(Base::class) {
override fun selectDeserializer(element: JsonElement) = when {
"source" in element.jsonObject -> {
val sourceContent = element.jsonObject["source"]?.jsonPrimitive?.contentOrNull
when (
val sourceEnum = FooEnum.values().firstOrNull { it.name == sourceContent }
) {
FooEnum.A -> Derived.serializer()
FooEnum.B -> error("no serializer for $sourceEnum")
else -> error("'source' is null")
}
}
else -> error("no 'source' in JSON")
}
}
This is a good fit in some situations, especially when you don't have a lot of control over the source code. However, I think this is pretty hacky, and it would be easy to make a mistake in selecting the serializer.
Custom serializer
Alternatively you can write a custom serializer.
The end result isn't that different to the content-based deserializer. It's still complicated, and is still easy to make mistakes with. For these reasons, I won't give a complete example.
This is beneficial because it provides more flexibility if you need to encode/decode with non-JSON formats.
@Serializable(with = BaseSerializer::class)
@JsonClassDiscriminator("source")
sealed class Base {
abstract val source: FooEnum
}
object BaseSerializer : KSerializer<Base> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Base") {
// We have to write our own custom descriptor, because setting a custom serializer
// stops the plugin from generating one
}
override fun deserialize(decoder: Decoder): Base {
require(decoder is JsonDecoder) {"Base can only be deserialized as JSON"}
val sourceValue = decoder.decodeJsonElement().jsonObject["source"]?.jsonPrimitive?.contentOrNull
// same logic as the JsonContentPolymorphicSerializer...
}
override fun serialize(encoder: Encoder, value: Base) {
require(encoder is JsonEncoder) {"Base can only be serialized into JSON"}
when (value) {
is Derived -> encoder.encodeSerializableValue(Derived.serializer(), value)
}
}
}
Answered By - aSemy
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.