Reputation: 1034
I had the bright idea to implement a generic object and attempt to serialize / deserialize it, and received this error:
Serializer for class 'DetailsRequest' is not found.
Mark the class as @Serializable or provide the serializer explicitly.
I thought the @Serializer annotation would have achieved this... ???
The implementation of the data class, and its custom serializer (which is probably overkill), are as follows:
@Serializable(with=DetailsRequestSerializer::class)
data class DetailsRequest<out T>(val details: T)
object DetailsRequestSerializer : KSerializer<DetailsRequest<*>> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor(
"DetailsRequest") {
when (this::class) {
String::class -> element<String>("details")
Long::class -> element<Long>("details")
Int::class -> element<Int>("details")
Double::class -> element<Double>("details")
Float::class -> element<Float>("details")
else -> element<String>("details")
}
}
override fun serialize(
encoder: Encoder,
value: DetailsRequest<*>
) {
value.details.let {
encoder.encodeStructure(descriptor) {
when (value::class) {
String::class -> encodeStringElement(descriptor, 0, value.details as String)
Long::class -> encodeLongElement(descriptor, 0, value.details as Long)
Int::class -> encodeIntElement(descriptor, 0, value.details as Int)
Double::class -> encodeDoubleElement(descriptor, 0, value.details as Double)
Float::class -> encodeFloatElement(descriptor, 0, value.details as Float)
else -> encodeStringElement(descriptor, 0, value.details as String)
}
}
}
}
override fun deserialize(decoder: Decoder): DetailsRequest<*> {
return when (this::class) {
String::class -> DetailsRequest(decoder.decodeString())
Long::class -> DetailsRequest(decoder.decodeLong())
Int::class -> DetailsRequest(decoder.decodeInt())
Double::class -> DetailsRequest(decoder.decodeDouble())
Float::class -> DetailsRequest(decoder.decodeFloat())
else -> DetailsRequest(decoder.decodeString())
}
}
}
I have written this unit test:
class PlaygroundTest {
private val json = Json {
encodeDefaults = true
isLenient = true
allowSpecialFloatingPointValues = true
allowStructuredMapKeys = true
prettyPrint = true
useArrayPolymorphism = false
ignoreUnknownKeys = true
}
@Test
fun `Details Integration Tests`() {
val input = DetailsRequest(details = "New Record")
val value = json.encodeToString(input)
println("value=[$value]")
val output = value.convertToDataClass<DetailsRequest<String>>()
println("output=[$output]")
}
@OptIn(InternalSerializationApi::class)
internal inline fun <reified R : Any> String.convertToDataClass() =
json.decodeFromString(R::class.serializer(), this)
}
Bonus points: This is a Kotlin Multiplatform project, which I do not believe will impact this situation.
Is this type of situation even possible?
Upvotes: 12
Views: 5520
Reputation: 437
Good news: this situation is totally possible, and your code is very nearly correct. Kotlin's builtin serialization is a bit wonky about getting serializers, though, so it is a bit surprising at first that your code snippet doesn't work.
The answer lies in a bit of almost fine print in the documentation[1]:
Constraints
This paragraph explains known (but not all!) constraints of the serializer() implementation. Please note that they are not bugs, but implementation restrictions that we cannot workaround.
[...]
- Serializers for classes with generic parameters are ignored by this method
Since DetailsRequest<out T>
has that generic out T
, this means that accessing its serializer through DetailsRequest::class.serializer()
won't work.
Fortunately, kotlinx.serialization
provides us a different method[2] to get a serializer for an arbitrary type:
inline fun <T> serializer(): KSerializer<T>
In your case, you could use it like this:
// OptIn no longer needed
internal inline fun <reified R : Any> String.convertToDataClass() =
json.decodeFromString(serializer<R>(), this)
Note that there's an overload of json.decodeFromString
that uses serializer() automatically: all you really need is
internal inline fun <reified R : Any> String.convertToDataClass() =
json.decodeFromString<R>(this)
(In fact, at this point, you may even consider removing the convertToDataClass method entirely.)
If you run the code with that change, you'll get a different error:
Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 0: Expected beginning of the string, but got {
JSON input: {
"details": "New Record"
}
This is simply because your implementation of the deserializer is a bit off—it's trying to deserialize DetailsRequest as a single string (such as "New Record"
) rather than a full JSON object the way you serialized it (such as { "details": "New Record" }
).
A full explanation of the issue here would take a bit more space than is reasonable for this answer, but I refer you to the very complete—if a bit long—serialization guide here. You may need to scroll up and read the rest of the Custom Serializers section to ensure you understand it all as well.
If you mark DetailsRequest
with simply @Serializable
(without the with
argument) and remove the entire DetailsRequestSerializer
object, the code will run fine and serialize and deserialize as one would expect. The @Serializable
annotation (without a with
argument) tells the compiler to autogenerate a serializer, which it does successfully and reasonably in this case.
Unless you have a specific serialization need that this default serializer does not fulfill, I would strongly recommend simply using the default one. It's much cleaner and a lot less work. :-)
[1] You'll have to scroll down a bit; Dokka doesn't allow me to link to a specific declaration of a function. The relevant bit is all the way at the bottom.
[2] Yes, it's on the same page in the documentation. Just don't scroll down this time. :-)
Upvotes: 13