Ambidex
Ambidex

Reputation: 857

How to use @Serializer on deeper JSON datastructures

I'm new in Kotlin as a PHP dev. I have a data model, something like this:

@Serializable
data class Site (
    @SerialName("id")
    val id: Int,
    @SerialName("name")
    val name: String,
    @SerialName("accountId")
    val accountId: Int,
}

I have JSON output something like the following, which comes from a external API and which I am unable to control:

{
  "sites": {
    "count": 1,
    "site": [
      {
        "id": 12345,
        "name": "Foobar",
        "accountId": 123456 
      }
    ]
  }
}

When trying to get this from the API with ktor HTTPClient, I'd like to instruct the serializer to use sites.site as the root for my Site datamodel. Currently, I get the error: Uncaught Kotlin exception: io.ktor.serialization.JsonConvertException: Illegal input and Caused by: kotlinx.serialization.json.internal.JsonDecodingException: Expected start of the array '[', but had 'EOF' instead at path: $

I'm using the following to fetch the endpoint:

package com.example.myapplication.myapp

import com.example.myapplication.myapp.models.Site
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json

class Api {

    private val client = HttpClient {
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
                ignoreUnknownKeys = true
            })
        }
    }

    private val apiKey = "REDACTED"
    private val installationId = "REDACTED"
    private val apiHost = "REDACTED"

    suspend fun getSitesList(): List<Site> {
        return get("sites/list").body()
    }

    suspend fun get(endpoint: String): HttpResponse {
        val response = client.get(buildEndpointUrl(endpoint))
        return response
    }

    private fun buildEndpointUrl(endpoint: String): HttpRequestBuilder {

        val builder = HttpRequestBuilder()
        val parametersBuilder = ParametersBuilder()

        parametersBuilder.append("api_key", apiKey)
        builder.url {
            protocol = URLProtocol.HTTPS
            host = apiHost
            encodedPath = endpoint
            encodedParameters = parametersBuilder
        }
        builder.header("Accept", "application/json")

        return builder
    }
}

Upvotes: 1

Views: 199

Answers (3)

Bradford2000
Bradford2000

Reputation: 723

In case anyone else has the same or similar question, KotlinX serialization does support extracting fields as originally asked using a custom serializer. https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/serializers.md#custom-serializers

For example, with the JSON given in the original question, you can use a custom serializer for List like this:

@Serializable
data class SitesSurrogate(
    val sites: Sites?,
)

@Serializable
data class Sites(
    val count: Int?,
    val site: List<Site>?,
)

@Serializable
data class Site(
    val id: Int?,
    val name: String?,
    val accountId: Int?,
)

object SiteSerializer : KSerializer<List<Site>?> {
  override val descriptor: SerialDescriptor = SitesSurrogate.serializer().descriptor

  @OptIn(ExperimentalSerializationApi::class)
  override fun deserialize(decoder: Decoder): List<Site>? {
    return decoder.decodeNullableSerializableValue(SitesSurrogate.serializer())?.sites?.site
  }

  override fun serialize(encoder: Encoder, value: List<Site>?) {
    encoder.encodeSerializableValue(SitesSurrogate.serializer(), SitesSurrogate(Sites(value?.size, value)))
  }
}

And then deserialize the json like this:

val sites: List<Site>? = Json.decodeFromString(SiteSerializer, json)

This is perhaps not the best use case for this feature, but works great if you need to extract embedded fields to a more flat format. For example, for the following JSON:

{
  "id": "12345",
  "chatId": "67890",
  "createdDateTime": "2018-01-22T15:54:12.532Z"
  "from": {
    "user": {
      "displayName": "Bill S. Preston, Esq."
    }
  }
}

You can extract displayName out to be just the value of the from field with Json.decodeFromString<ImportedMessage>(json) like this:

@Serializable
data class ImportedMessage(
    val id: String?,
    val chatId: String?,
    val createdDateTime: String?,
    @Serializable(with = FromSerializer::class) val from: String?,
)

@Serializable
data class FromSurrogate(
    val user: User?,
)

@Serializable
data class User(
    val displayName: String?,
)

object FromSerializer : KSerializer<String?> {
  override val descriptor: SerialDescriptor = FromSurrogate.serializer().descriptor

  @OptIn(ExperimentalSerializationApi::class)
  override fun deserialize(decoder: Decoder): String? {
    return decoder.decodeNullableSerializableValue(FromSurrogate.serializer())?.user?.displayName
  }

  override fun serialize(encoder: Encoder, value: String?) {
    encoder.encodeSerializableValue(FromSurrogate.serializer(), FromSurrogate(User(value)))
  }
}

Upvotes: 0

Endzeit
Endzeit

Reputation: 5474

You have to model the whole response object and cannot just provide a model for some of its parts.

@Serializable
data class SitesResponse(
    val sites: SitesContainer,
)

@Serializable
data class SitesContainer(
    val count: Int,
    val site: List<Site>,
)

@Serializable
data class Site(    
    val accountId: Int,
    val id: Int,
    val name: String,
)

Upvotes: 1

Acera
Acera

Reputation: 11

you can try make your data model like this,

data class Site(
@SerializedName("sites")
var sites: Sites) {
data class Sites(
    @SerializedName("count")
    var count: Int,
    @SerializedName("site")
    var site: List<Site>
) {
    data class Site(
        @SerializedName("accountId")
        var accountId: Int,
        @SerializedName("id")
        var id: Int,
        @SerializedName("name")
        var name: String
    )
}}

Upvotes: 0

Related Questions