Kotlin Sealed Interfaces with KotlinX Serialization JSON

I heavily use sealed interfaces to model result objects in Kotlin as they allow me to create a type of classes that can be handled using exhaustive when statements, similar to an enum, but also each type can contain its own properties.

I wanted to serialize these sealed interface Kotlin models to/from JSON over HTTP. There are a bunch of options for serializing JSON in Java like Moshi, Gson and Jackson. While all of those libraries are great, I had a requirement of creating a multi-platform library, and went with KotlinX Serialization.

In this post I’ll walk you through an example of how I configured KotlinX Serialization to work for my use case.

Example: Marketing Campaigns API Result

This endpoint returns a strongly typed campaign, and I wanted to represent this in JSON.

public sealed interface CampaignContent {
    public data class PopupModal(
        public val imageUrl: String,
        public val text: String,
        public val subtext: String,
    ) : CampaignContent

    public data class Link(
        public val linkText: String,
        public val url: String,
        public val linkIcon: String? = null,
    ) : CampaignContent
}
{
  "type": "popup_modal",
  "image_url": "https://...",
  "text": "Text",
  "subtext": "Subtext"
}
{
  "type": "link",
  "link_icon": "https://...",
  "url": "https://..."
}

We need to deserialize a JSON response into a strongly typed object that implements the CampaignContent sealed interface.

fun getCampaignContentFromServer() : CampaignContent

KotlinX Serialization has Polymorphism support allows us to do this. You need to register polymorphic definitions in a SerializersModule that you provide to your Json object that is used to encode and decode objects to/from JSON.

val jsonSerializer = Json {
  serializersModule = SerializersModule {
    polymorphic(
      CampaignContent::class,
      CampaignContent.PopupModal::class,
      CampaignContent.PopupModal.serializer(),
    )
    polymorphic(
      CampaignContent::class,
      CampaignContent.Link::class,
      CampaignContent.Link.serializer(),
    )
  }
}
val campaignContent : CampaignContent = jsonSerializer.decodeFromString(
  CampaignContent.serializer(), 
  jsonString,
)

In order to support polymorphism, a type property is used in the JSON string representation {"type": "..."}. By default this "type" field is a fully qualified classname. This allows KotlinX Serialization know what type to deserialize. You have control over what the name of this classDiscriminator field is, as well as other configuration options when configuring your Json {} serializer.

If you don’t want to use the fully qualified class name as the class type, then you can put a @SerializedName("...") annotation to the class and it will use that name instead of the fully qualified class name. This is helpful for me as the backend did not use fully qualified names, and I had set them explicitly. In the example below I added the @SerializedName("popup_modal") data class.

Final Models after adding @Serializable and @SerializedName

public sealed interface CampaignContent {

  @Serializable
  @SerializedName("popup_model")
  public data class PopupModal(
    @SerializedName("image_url")
    public val imageUrl: String,
    @SerializedName("text")
    public val text: String,
    @SerializedName("subtext")
    public val subtext: String,
  ) : CampaignContent

  @Serializable
  @SerializedName("link")
  public data class Link(
      @SerializedName("link_text")
      public val linkText: String,
      @SerializedName("url")
      public val url: String,
      @SerializedName("link_icon")
      public val linkIcon: String? = null,
  ) : CampaignContent
}

Considerations

At first I made my models match the JSON values as I didn’t have to specify @SerializedName since KotlinX Serialization will just match the field name. After a bit of usage, link.link_text just didn’t feel as correct as link.linkText, so I chose to specify a @SerializedName annotation instead. The resulting Java bytecode is the same as the KotlinX Serialization plugin does code generation that writes out the serializer anyways. This does make your data class look not as pretty, but from the general building and usage perspective of these models, the user will not know.

Conclusion

That was a whirlwind intro, but I had to really dig through deep into the documentation to figure it out and am hoping this helps someone do this faster than I did it originally.

Security Tip: Protecting Session Sensitive Responses with Retrofit 2 and okhttp 3

Problem Statement:

A user could receive and view data that is not theirs, and we must prevent this from happening in a secure application.  Here is a diagram of how this could happen:

Flow Diagram of the Session Mismatch Issue

 

Implementation options with Retrofit 2 and okhttp 3:

Option 1:

Write a custom Retrofit 2 CallAdapter that blocks the response from being processed or passes an exception to the onFailure() method.

  • PROS:
    • Great if you use Retrofit 2 exclusively for session sensitive calls.
    • This can prevent the callback from being invoked, or you could call Retrofit 2’s onFailure() method.
  • CONS:
    • Retrofit 2 CallAdapter is complex and you have to write one for both plain Callbacks as well as for RxJava (if you are using both).
    • We’d have to copy the existing CallAdapter implementation in retrofit2.ExecutorCallAdapterFactory and modify it as it’s a final class.
    • This only works for Retrofit 2 calls, but not all networking calls made through okhttp 3.

Option 2:

When the client session is invalidated, use okhttp 3’s Dispatcher to cancel all running and queued calls.

  • PROS:
    • Very effective. All calls would immediately be cancelled.
  • CONS:
    • If there is a call like “Update Profile” that is still occurring, it would be cancelled, which is not desirable.
    • If there is an unauthenticated call happening, you’d have to ensure that was being executed on non session sensitive Retrofit 2 or okhttp 3 instance to avoid unwanted termination.

Option 3:

Same as the previous option, but instead occurs when a new client session is started, instead of when an old one ends.

  • PROS:
    • Very effective. All calls would immediately be cancelled.
  • CONS:
    • You would have to ensure this code got run before you kicked off any session sensitive calls, otherwise they would be immediately cancelled.
    • If there is an unauthenticated call happening in the background, you’d have to ensure that was being executed on non session sensitive Retrofit 2 or okhttp 3 instance to avoid unwanted termination.

Option 4:

Using an okhttp 3 Network Interceptor to return back an HTTP Response with a custom response code like 999 and removing the response body.

  • PROS:
    • Will work for:
      • All okhttp 3 calls.
      • Retrofit 2 Callback calls.
      • Retrofit 2 RxJava calls.
  • CONS:
    • It will appear like the HTTP response came back from the server so you will have to have custom logic in your response handler to check and see if this response has a HTTP status code of 999.
    • It feels a little “hacky”… but would prevent the security hole.

Option 5: (My Choice)

Using an okhttp 3 Network Interceptor to throw a custom SessionMismatchException when this problem is detected, and handle the Exception appropriately.

  • PROS:
    • Will work for:
      • All okhttp 3 calls
      • Retrofit 2 Callback Calls
      • Retrofit 2 RxJava Calls
    • This is truly an “Exception” scenario, so this makes sense.
    • The request is executed fully, but will not be received by the client. (This could be a “con” depending on your use case)
  • CONS:
    • You must code your onFailure and onError handlers to appropriately handle this SessionMismatchException type.
    • If you just extend IOException, okhttp 3 will by default retry the HTTP call.  However, if you extend java.net.SocketTimeoutException, okhttp 3 will not retry.

My Choice: Option 5, throwing a custom SessionMismatchException.  This seemed to be the most flexible and intuitive since this truly is an Exception/Failure case.  I’ve provided a sample implementation of this below.

Assumption: You have a  “SessionManager” in your Android code which is aware of the current session.

Let me know if this was helpful, or if you’d do something different on Twitter at @HandstandSam