https://kotlinlang.org logo
#serialization
Title
# serialization
d

dephinera

11/14/2023, 3:33 PM
👋 I need to parse a response the format of which can vary. It can either be
Copy code
{
  "error": {
    "code": "ERROR_CODE",
    "message": "Error message"
  },
  "errorDetails": {
    "missingDetails": [
      "ADDRESS",
      "NAME"
    ]
  }
}
or like this
Copy code
{
  "error": {
    "code": "ERROR_CODE",
    "message": "Error message",
    "invalidAttributes": {
      "body": {
        "missingDetails": [
          "ADDRESS",
          "NAME"
        ]
      }
    }
  }
}
Depending on the “code” I want to deserialize to one of two subclasses of a sealed interface
Copy code
sealed interface ApiError {
    data class Common(...) : ApiError
    data class Specific(...) : ApiError)
}
I started implementing a
JsonTransformingSerializer
to normalize the json so I can handle both response formats. The custom
JsonTransformingSerializer
is for
ApiError
-
ApiErrorSerializer : JsonTransformingSerializer<ApiError>(ApiError.serializer())
. Now I have the following issues: 1. I can deserialize only by passing the serializer to the json - aka like this
json.decodeFromString(ApiErrorSerializer, jsonString)
2. Annotating ApiError with
@Serializable(with = ApiErrorSerializer::class)
doesn’t work, as
ApiErrorSerializer
relies on the generated serializer. There’s no way set
ApiErrorSerializer
to be used globally 3. Things work if the json is wrapped in another property and therefore if my model is wrapped as well. Something like this: a.
@Serializable data class Result(@Serializable(ApiErrorSerializer::class) val result: ApiError)
I guess the key part here is that I need to operate on the whole object, so in the serializer I have access to both
"error"
and
"errorDetails" (if present)
and I want to unwrap the object so neither
ApiError.Common
nor
ApiError.Specific
have an
error
property but rather its inner properties. What’s the best way to achieve this? I don’t have control over the API unfortunately. Is there a way to achieve this by both declaring the serializer globally and without wrapping the whole thing? Is there a way I can both declare a custom serializer for
ApiError
and delegate to it’s default one?
c

chr

11/14/2023, 7:58 PM
Would a value potentially class work here?
Copy code
@JvmInline
@Serializable
value class ApiErrorResponse(
    @Serializable(with = ApiErrorSerializer::class)
    val apiError: ApiError
)

val apiError = json.decodeFromString<ApiErrorResponse>(jsonString).apiError
d

dephinera

11/14/2023, 8:02 PM
I'll give it a try. It still requires wrapping from the code side but at least it doesn't require wrapping inside the json
c

chr

11/14/2023, 8:02 PM
Yeah if you don’t have any control over the API (which I imagine is often the case), this might be a decent bet
d

dephinera

11/14/2023, 8:08 PM
This one worked. Thank you! I’ll try to figure a solution that doesn’t require this wrapping as well, but it’s a nice option
c

chr

11/14/2023, 8:10 PM
No problem! For what it’s worth, if you wanted a “global” way to set the serializer, you can mimic that by using a typealias:
Copy code
typealias ApiErrorResponse = @Serializable(with = ApiErrorSerializer::class) ApiError
Then you wouldn’t need a wrapper at all, I think, as long as you declare the type as `ApiErrorResponse`:
Copy code
val apiError = json.decodeFromString<ApiErrorResponse>(jsonString)
d

dephinera

11/14/2023, 8:14 PM
I tried this, but this is equivalent of
Copy code
@Serializable(with = ApiErrorSerializer::class)
sealed interface ApiError
which doesn’t work as ApiErrorSerializer delegates to
ApiError.serializer()
🫠 1
I find it a bit weird that one can pass the deserializer to
json.decodeFromString
and it can operate at the higher level of the json, but the same deserializer can’t be set as default one for the type, without overriding the generated serializer. Perhaps it’s the explicitness that was desired but still…
j

Joshua Hansen

11/14/2023, 10:41 PM
The issue seems to be that the
JsonTransformingSerializer
still relies on the plugin-generated serializer to operate. If you don't mark the class simply with
@Serializable
then there won't be a plugin-generated serializer to delegate to. blob shrug The value class option is probably the best one if you don't want to pass the serializer manually every time. That, or just write a wrapper function which does it.
Copy code
companion object {
    val JSON = Json { /* config */ }
    fun encodeMyClassToString(toEncode: MyClass): String {
        return JSON.encodeToString(MySerializer, toEncode)
    }
}
You could extend it further if you have a bunch of classes and create a map of class to serializer which the function uses or something like that. But that's quite a bit of noise just to not pass in a serializer each time. And wrapping APIs around APIs is probably not a good idea for most cases since it obfuscates your logic/intentions with your code.
d

dephinera

11/15/2023, 8:13 AM
The
always pass the serializer
approach is not viable because I can’t pass it when using the ktor json content negotiation plugin, as the call to encode/decode happens internally. I guess that leaves the value class as the only option
So I actually managed to overcome the issue. Using
JsonContentPolymorphicSerializer
on the sealed interface I can scan the incoming
JsonElement
and manually call the default serializer for the according sealed interface child. That however doesn’t allow me to modify the json element itself, so I can pass the normalized tree to the delegate serializers. I ended up using a
KSerializer<ApiCallError<T>>
that receives a
Decoder
which I then cast to
JsonDecoder
. I then decode the
JsonElement
from it, create a new Json object with the desired structure and use the
decoder.json
instance to decode from the transformed json. This way I don’t need a wrapper and it’s not a problem to override the default serializer of the sealed interface. I just delegate to the ones of the sealed interface children