kotlinx.serialization icon indicating copy to clipboard operation
kotlinx.serialization copied to clipboard

Multi-format Mixed Primitive-Composite Polymorphism

Open sugarmanz opened this issue 3 years ago • 2 comments

What is your use-case and why do you need this feature?

I've created a small repo to demonstrate my use case: https://github.com/sugarmanz/mixed-primitive-composite-polymorphism

Essentially, I am trying to consume an API with a JSON spec that optionally allows some field to be specified as either a primitive or object:

{
  "license": "ISC"
}
{
  "license": {
    "name": "ISC",
    "url": "https://opensource.org/licenses/ISC"
  }
}

I implemented a sealed class construct with some custom serializers to represent the different ways this data can be described:

https://github.com/sugarmanz/mixed-primitive-composite-polymorphism/blob/a2f014d6c3e8428e22f44140b56be443a7a1512e/src/main/kotlin/License.kt#L10-L55

I don't necessarily love the approach that I took for the base class deserializer, but I wasn't entirely sure how else to do it without introspecting the content. One of the goals with my library is to keep my serializers decoupled from a specific format, which means I can't use JsonContentPolymorphicSerializer to help. For the record, this does work for JSON only inputs:

object Serializer : JsonContentPolymorphicSerializer<License>(License::class) {
    override fun selectDeserializer(element: JsonElement): DeserializationStrategy<out License> = when (element) {
        is JsonPrimitive -> Primitive.serializer()
        is JsonObject -> Boxed.serializer()
        else -> throw SerializationException()
    }
}

Regardless, this approach seems to work when deserializing the different representations as a single member. However, it seems to fail when attempting to deserialize a collection of the complex representation.

Expected class kotlinx.serialization.json.JsonObject as the serialized body of License.Boxed, but had class kotlinx.serialization.json.JsonArray
kotlinx.serialization.json.internal.JsonDecodingException: Expected class kotlinx.serialization.json.JsonObject as the serialized body of License.Boxed, but had class kotlinx.serialization.json.JsonArray
	at kotlinx.serialization.json.internal.JsonExceptionsKt.JsonDecodingException(JsonExceptions.kt:24)
	at kotlinx.serialization.json.internal.AbstractJsonTreeDecoder.beginStructure(TreeJsonDecoder.kt:347)
	at License$Boxed$$serializer.deserialize(License.kt:32)
	at License$Boxed$$serializer.deserialize(License.kt:32)
	at kotlinx.serialization.json.internal.PolymorphicKt.decodeSerializableValuePolymorphic(Polymorphic.kt:59)
	at kotlinx.serialization.json.internal.AbstractJsonTreeDecoder.decodeSerializableValue(TreeJsonDecoder.kt:51)
	at License$Serializer.deserialize(License.kt:46)
	at License$Serializer.deserialize(License.kt:36)
	at kotlinx.serialization.json.internal.PolymorphicKt.decodeSerializableValuePolymorphic(Polymorphic.kt:59)
	at kotlinx.serialization.json.internal.AbstractJsonTreeDecoder.decodeSerializableValue(TreeJsonDecoder.kt:51)
	at kotlinx.serialization.internal.TaggedDecoder.decodeSerializableValue(Tagged.kt:206)
	at kotlinx.serialization.internal.TaggedDecoder$decodeSerializableElement$1.invoke(Tagged.kt:279)
	at kotlinx.serialization.internal.TaggedDecoder.tagBlock(Tagged.kt:296)
	at kotlinx.serialization.internal.TaggedDecoder.decodeSerializableElement(Tagged.kt:279)
	at kotlinx.serialization.encoding.CompositeDecoder$DefaultImpls.decodeSerializableElement$default(Decoding.kt:535)
	at kotlinx.serialization.internal.ListLikeSerializer.readElement(CollectionSerializers.kt:80)
	at kotlinx.serialization.internal.AbstractCollectionSerializer.readElement$default(CollectionSerializers.kt:51)
	at kotlinx.serialization.internal.AbstractCollectionSerializer.merge(CollectionSerializers.kt:36)
	at kotlinx.serialization.internal.AbstractCollectionSerializer.deserialize(CollectionSerializers.kt:43)
	at kotlinx.serialization.json.internal.PolymorphicKt.decodeSerializableValuePolymorphic(Polymorphic.kt:59)
	at kotlinx.serialization.json.internal.AbstractJsonTreeDecoder.decodeSerializableValue(TreeJsonDecoder.kt:51)
	at kotlinx.serialization.json.internal.TreeJsonDecoderKt.readJson(TreeJsonDecoder.kt:24)
	at kotlinx.serialization.json.Json.decodeFromJsonElement(Json.kt:119)
	at LicenseTest.collection of primitive and objects(LicenseTest.kt:122)

Describe the solution you'd like

I'm not entirely sure what I would like to achieve is possible as-is, but would be thrilled to learn if I'm wrong. Otherwise, I think the best case solution, is being able to do content based polymorphism in a format agnostic manner. Maybe usingSerialKinds or something?

object Serializer : ContentPolymorphicSerializer<License>(License::class) {
    override fun selectDeserializer(kind: SerialKind): DeserializationStrategy<out License> = when (kind) {
        is PrimitiveKind -> Primitive.serializer()
        is StructureKind -> Boxed.serializer()
        else -> throw SerializationException()
    }
}

Not sure if this is possible, or desired, but open to conversation.

sugarmanz avatar Feb 24 '22 09:02 sugarmanz

For the multi-format part of your question, it is supported behaviour to detect whether an encoder/decoder passed to serialize/deserialize implements JsonEncoder/JsonDecoder or another format interface respectively. The implementation should have a sane default serialization, but can special case for the specific format.

As to the collection bug, the problem is that an array is not a box. You probably want to detect an array and use a Boxed.serializer.list() list serializer.

pdvrieze avatar Feb 24 '22 12:02 pdvrieze

For the multi-format part of your question, it is supported behaviour to detect whether an encoder/decoder passed to serialize/deserialize implements JsonEncoder/JsonDecoder or another format interface respectively. The implementation should have a sane default serialization, but can special case for the specific format.

I guess "multi-format" isn't really the best term here. What I'd like to say is format agnostic. I'm just trying to create a library that provides serializable models for a specific construct on top of Kotlinx Serialization, of which boasts support for arbitrary formats. Tying my library to a specific format or formats is somewhat antithetical to the goal.

As to the collection bug, the problem is that an array is not a box. You probably want to detect an array and use a Boxed.serializer.list() list serializer.

Why should my serializer for License have to account for containing structures? Shouldn't that automatically be done by the fact that I'm passing in a ListSerializer(Licenser.serializer())?

val licenses: List<License> = Json.decodeFromJsonElement(
    ListSerializer(License.serializer()), // ***
    buildJsonArray {
        // items...
    }
)

By the time it tries to deserialize a single item, the JSON decoder should have already read the [?

sugarmanz avatar Feb 28 '22 16:02 sugarmanz