4

Intro

I'm sending JSON messages between two backend servers that use different languages. The producing server creates a variety of JSON messages, wrapped inside a message with metadata.

The wrapping class is Message, The consuming server has to determine which type of message its receiving based solely on the message contents.

When I try to use a star-projection to deserialize the message, I get an error.

Example

import kotlinx.serialization.json.Json

@Language("JSON")
val carJson = """
    {
      "message_type": "some message",
      "data": {
        "info_type": "Car",
        "name": "Toyota"
      }
    }
  """.trimIndent()

// normally I wouldn't know what the Json message would be - so the type is Message<*>
val actualCarMessage = Json.decodeFromString<Message<*>>(carJson)

Error message

Exception in thread "main" java.lang.IllegalArgumentException: Star projections in type arguments are not allowed, but Message<*>
    at kotlinx.serialization.SerializersKt__SerializersKt.serializerByKTypeImpl$SerializersKt__SerializersKt(Serializers.kt:81)
    at kotlinx.serialization.SerializersKt__SerializersKt.serializer(Serializers.kt:59)
    at kotlinx.serialization.SerializersKt.serializer(Unknown Source)
    at ExampleKt.main(example.kt:96)
    at ExampleKt.main(example.kt)

Class structure

I want to deserialize JSON into a data class, Message, that has a field with a generic type.

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class Message<out DataType : SpecificInformation>(
  @SerialName("message_type")
  val type: String,
  @SerialName("data")
  val data: DataType,
)

The field is constrained by a sealed interface, SpecificInformation, with some implementations.

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonClassDiscriminator

@JsonClassDiscriminator("info_type")
sealed interface SpecificInformation {
  @SerialName("info_type")
  val infoType: String
}

@Serializable
@SerialName("User")
data class UserInformation(
  @SerialName("info_type")
  override val infoType: String,
  val name: String,
) : SpecificInformation

// there are more implementations...

Workaround?

This is a known issue (kotlinx.serialization/issues/944) ,
so I'm looking for workarounds.

I have control over the JSON structure and libraries - though I have a preference for kotlinx.serialization.

I can't change that there are two JSON objects, one is inside the other, and the discriminator is inside the inner-class.

A custom serializer would be great. But I'd prefer to have this configured on the class or file (with @Serializable(with = ...) or @file:UseSerializers(...)) as using a custom SerializersModule is not as seamless.

Attempt: JsonContentPolymorphicSerializer

I've written a custom serializer, which only if it's used specifically (which is something I'd like to avoid). It's also quite clunky, breaks if the data classes change or a new one is added, and doesn't benefit from the sealed interface.

Can this be improved so that

  1. It can be used generically? Json.decodeFromString<Message<*>>(carJson)
  2. It doesn't have any hard-coded strings?
class MessageCustomSerializer : JsonContentPolymorphicSerializer<Message<*>>(Message::class) {
  override fun selectDeserializer(element: JsonElement): DeserializationStrategy<out Message<*>> {

    val discriminator = element
      .jsonObject["data"]
      ?.jsonObject?.get("info_type")
      ?.jsonPrimitive?.contentOrNull
    println("found discriminator $discriminator")

    val subclassSerializer = when (discriminator?.lowercase()) {
      "user" -> UserInformation.serializer()
      "car"  -> CarInformation.serializer()
      else   -> throw IllegalStateException("could not find serializer for $discriminator")
    }
    println("found subclassSerializer $subclassSerializer")

    return Message.serializer(subclassSerializer)
  }
}

fun main() {

  @Language("JSON")
  val carJson = """
      {
        "message_type": "another message",
        "data": {
          "info_type": "Car",
          "brand": "Toyota"
        }
      }
    """.trimIndent()

  val actualCarMessage =
    Json.decodeFromString(MessageCustomSerializer(), carJson)
  val expectedCarMessage = Message("another message", CarInformation("Car", "Toyota"))

  require(actualCarMessage == expectedCarMessage) {
    println("car json parsing ❌")
  }
  println("car json parsing ✅")
}

@Serializable(with = ... - infinite loop

I tried applying MessageCustomSerializer directly to Message...

@Serializable(with = MessageCustomSerializer::class)
data class Message<out T : SpecificInformation>(
//...

But then I couldn't access the plugin-generated serializer, and this causes an infinite loop.

return Message.serializer(subclassSerializer) // calls 'MessageCustomSerializer', causes infinite loop

@Serializer(forClass = ...) - not generic

In addition to annotating Message with @Serializable(with = MessageCustomSerializer::class), I tried deriving a plugin-generated serializer:

@Serializer(forClass = Message::class)
object MessagePluginGeneratedSerializer : KSerializer<Message<*>>

But this serializer is not generic, and causes an error

java.lang.AssertionError: No such value argument slot in IrConstructorCallImpl: 0 (total=0).
Symbol: MessageCustomSerializer.<init>|-5645683436151566731[0]
    at org.jetbrains.kotlin.ir.expressions.IrMemberAccessExpressionKt.throwNoSuchArgumentSlotException(IrMemberAccessExpression.kt:66)
    at org.jetbrains.kotlin.ir.expressions.IrFunctionAccessExpression.putValueArgument(IrFunctionAccessExpression.kt:31)
    at org.jetbrains.kotlinx.serialization.compiler.backend.ir.IrBuilderExtension$DefaultImpls.irInvoke(GeneratorHelpers.kt:210)
    at org.jetbrains.kotlinx.serialization.compiler.backend.ir.SerializableCompanionIrGenerator.irInvoke(SerializableCompanionIrGenerator.kt:35)
aSemy
  • 5,485
  • 2
  • 25
  • 51

1 Answers1

1

You are asking many things here, so I will simply try to give some pointers in regards to the errors you are making which you seem to be stuck on. With those in mind, and reading the documentation I link to, I believe you should be able to resolve the rest yourself.

Polymorphic serialization

Acquaint yourself with kotlinx.serialization polymorphic serialization. When you are trying to serialize Message<*> and DataType you are trying to use polymorphic serialization.

In case you are serializing Message<*> as the root object, specifying PolymorphicSerializer explicitly (as I also posted in the bug report you link to) should work. E.g., Json.decodeFromString( PolymorphicSerializer( Message::class ), carJson ).

P.s. I'm not 100% certain what you are trying to do here is the same as in the bug report. Either way, specifying the serializer explicitely should work, whether or not it is a bug that you shouldn't be required to do so.

The message_type and info_type fields you have in Message and DataType respectively are class discriminators. You need to configure this in your Json settings, and set the correct SerialName on your concrete classes for them to work. Using a different class discriminator per hierarchy is only possible starting from kotlinx.serialization 1.3.0 using @JsonClassDiscriminator.

Overriding plugin-generated serializer

But then I couldn't access the plugin-generated serializer, and this causes an infinite loop.

@Serializable(with = ...) overrides the plugin-generated serializer. If you want to retain the plugin-generated serializer, do not apply with.

When you are serializing the object directly (as the root object), you can still pass a different serializer to use as the first parameter to encode/decode. When you want to override the serializer to use for a specific property nested somewhere in the root object, use @Serializable on the property.

Polymorphism and generic classes

The "No such value argument slot in IrConstructorCallImpl: 0" error is to be expected.

You need to do more work in case you want to specify a serializer for polymorphic generic classes.

Steven Jeuris
  • 18,274
  • 9
  • 70
  • 161