Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Kotlin serialization of a polymorphic hierarchy using REST dependencies #44868

Open
mprechtl opened this issue Dec 2, 2024 · 5 comments
Open
Labels
area/kotlin kind/bug Something isn't working

Comments

@mprechtl
Copy link

mprechtl commented Dec 2, 2024

Describe the bug

TL;DR: When using kotlin serialization with a polymorphic hierarchy, the REST server expects a discriminator value. This discriminator value is not created when using the Quarkus REST-Client (see MessageBodyWriter of the quarkus-rest-kotlin-serialization dependency here).

The following exception will be thrown:

2024-12-02 10:58:08,062 ERROR [io.qua.ver.htt.run.QuarkusErrorHandler] (executor-thread-1) HTTP Request to /hello failed, error id: 978f9b51-4ea3-4c94-9869-3e2a5419a93f-1: kotlinx.serialization.json.internal.JsonDecodingException: Class discriminator was missing and no default serializers were registered in the polymorphic scope of 'Person'.
JSON input: {"name":"Max"}
	at kotlinx.serialization.json.internal.JsonExceptionsKt.JsonDecodingException(JsonExceptions.kt:24)
	at kotlinx.serialization.json.internal.JsonExceptionsKt.JsonDecodingException(JsonExceptions.kt:32)
	at kotlinx.serialization.json.internal.StreamingJsonDecoder.decodeSerializableValue(StreamingJsonDecoder.kt:412)
	at kotlinx.serialization.json.internal.JsonStreamsKt.decodeByReader(JsonStreams.kt:111)
	at kotlinx.serialization.json.JvmStreamsKt.decodeFromStream(JvmStreams.kt:61)
	at io.quarkus.resteasy.reactive.kotlin.serialization.runtime.KotlinSerializationMessageBodyReader.doReadFrom(KotlinSerializationMessageBodyReader.kt:57)
	at io.quarkus.resteasy.reactive.kotlin.serialization.runtime.KotlinSerializationMessageBodyReader.readFrom(KotlinSerializationMessageBodyReader.kt:50)
	at org.jboss.resteasy.reactive.server.handlers.RequestDeserializeHandler.readFrom(RequestDeserializeHandler.java:126)
	at org.jboss.resteasy.reactive.server.handlers.RequestDeserializeHandler.handle(RequestDeserializeHandler.java:84)
	at io.quarkus.resteasy.reactive.server.runtime.QuarkusResteasyReactiveRequestContext.invokeHandler(QuarkusResteasyReactiveRequestContext.java:135)
	at org.jboss.resteasy.reactive.common.core.AbstractResteasyReactiveContext.run(AbstractResteasyReactiveContext.java:147)
	at io.quarkus.vertx.core.runtime.VertxCoreRecorder$15.runWith(VertxCoreRecorder.java:637)
	at org.jboss.threads.EnhancedQueueExecutor$Task.doRunWith(EnhancedQueueExecutor.java:2675)
	at org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2654)
	at org.jboss.threads.EnhancedQueueExecutor.runThreadBody(EnhancedQueueExecutor.java:1627)
	at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1594)
	at org.jboss.threads.DelegatingRunnable.run(DelegatingRunnable.java:11)
	at org.jboss.threads.ThreadLocalResettingRunnable.run(ThreadLocalResettingRunnable.java:11)
	at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
	at java.base/java.lang.Thread.run(Thread.java:1583)

Expected behavior

When the serialization of a polymorphic hierarchy takes place on the client-side, the discriminator value is added.

In my example below, a workaround can be done by adding the following provider (and probably overwriting the default provider) which considers the super class:

@OptIn(ExperimentalSerializationApi::class)
@Provider
class KotlinSerializationHandler : MessageBodyWriter<Any> {

    @Inject
    lateinit var json: Json

    override fun isWriteable(type: Class<*>, genericType: Type, annotations: Array<out Annotation>?, mediaType: MediaType?): Boolean {
        return serializerOrNull(genericType) != null
    }

    override fun writeTo(
        t: Any,
        type: Class<*>,
        genericType: Type,
        annotations: Array<out Annotation>?,
        mediaType: MediaType?,
        httpHeaders: MultivaluedMap<String, Any>?,
        entityStream: OutputStream,
    ) {
        when (t) {
            is String -> entityStream.write(t.toByteArray())
            else -> {
                val serializer = getSerializer(type, serializer(genericType))
                json.encodeToStream(serializer, t, entityStream)
            }
        }
    }

    private fun getSerializer(type: Class<*>, defaultSerializer: KSerializer<Any>): KSerializer<Any> {
        val serializerOfSuperClass = serializerOrNull(type.superclass)
        val serializerOfInterface = type.interfaces.asList().firstNotNullOfOrNull { serializerOrNull(it) }

        return if (serializerOfSuperClass != null) {
            getSerializer(type.superclass, serializerOfSuperClass)
        } else serializerOfInterface ?: defaultSerializer
    }
}

Actual behavior

The discriminator value is missing. The problem is that the MessageBodyWriter of the quarkus-rest-kotlin-serialization dependency (see here) receives in the writeTo function the sub class instead of the super class. And when serializing the sub class, the discriminator value will be ignored.

How to Reproduce?

The code can be found here.

  1. A simple REST interface (and an implementation) which looks like the following:
@Path("/hello")
interface GreetingInterface {

    @POST
    @Produces(MediaType.TEXT_PLAIN)
    fun hello(person: Person): String;
}

class GreetingResource : GreetingInterface {

    override fun hello(person: Person): String {
        return if (person is IdentifiedPerson) {
            "Hello ${person.name} from Quarkus REST"
        } else {
            "Hello from Quarkus REST"
        }
    }
}
  1. A model with a polymorphic hierarchy:
@Serializable
sealed class Person

@Serializable
@SerialName("anonymous")
data object AnonymousPerson : Person()

@Serializable
@SerialName("identified")
data class IdentifiedPerson(
    val name: String,
) : Person()
  1. Finally, a REST-Client is required:
@QuarkusTest
class GreetingResourceTest {

    @RestClient
    lateinit var greetingClient: GreetingClient

    @Test
    fun testHelloEndpoint() {
        greetingClient.hello(IdentifiedPerson("Max"))
    }
}

@RegisterRestClient(baseUri = "http://localhost:8081")
interface GreetingClient: GreetingInterface

Output of uname -a or ver

Linux prech 6.1.0-28-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.119-1 (2024-11-22) x86_64 GNU/Linux

Output of java -version

java 21.0.2 2024-01-16 LTS Java(TM) SE Runtime Environment (build 21.0.2+13-LTS-58) Java HotSpot(TM) 64-Bit Server VM (build 21.0.2+13-LTS-58, mixed mode, sharing)

Quarkus version or git rev

3.17.2

Build tool (ie. output of mvnw --version or gradlew --version)

Gradle 8.9

Additional information

No response

@mprechtl mprechtl added the kind/bug Something isn't working label Dec 2, 2024
Copy link

quarkus-bot bot commented Dec 2, 2024

/cc @geoand (kotlin)

@geoand
Copy link
Contributor

geoand commented Dec 2, 2024

Thanks a lot for the detailed description!

I am right in assuming you have a fix in mind?
If so, feel free to open a Pull Request!

@sake
Copy link

sake commented Dec 2, 2024

I was involved too in the finding. While the MessageBodyWriter shown in the Expected Behaviour section solves the problem, this doesn't quite cut it, if the superclass is an interface. We talked about it today and the approach would not work anymore more than one interface used.
The actual question, where we don't have an answer to, is why the type or genericType parameter is IdentifiedPerson instead of Person. I would assume that the type provided when calling the writeTo method is the type found in the method of the client interface (GreetingInterface).
If someone could shed some light on how this should work, then I'm sure we can come up with a proper proposal to fix it.

@geoand
Copy link
Contributor

geoand commented Dec 2, 2024

The handling of type and genericType is mandated by the JAX-RS / Jakarta REST specification and in my experience, if any change were maded to these, a ton of stuff would break

@geoand
Copy link
Contributor

geoand commented Dec 2, 2024

FWIW, Jackson has a similar feature and it doesn't require any explicit support in the MessageBodyWriter

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area/kotlin kind/bug Something isn't working
Projects
None yet
Development

No branches or pull requests

3 participants