Search code examples
kotlinserializationretrofit

Can't deserialize JSON with kotlinx.serialization


I'm trying to use Retrofit to retrieve and deserialise a json response in the format below;

{
    "com.ctrlart.jerusalemcardovr": {
        "id": "5306520532779265",
        "app_name": "JerusalemCardoVR",
        "is_available": true,
        "is_free": false,
        "is_demo": false
    },
    "com.johnraygames.mailroom": {
        "id": "5631025370338266",
        "app_name": "You've Got Mail - Stealth Office Simulator",
        "is_available": true,
        "is_free": false,
        "is_demo": false
    }
}

I have put together the two dataclasses below;

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class PackageInfo(
    val id: String,
    @SerialName("app_name")
    val appName: String,
    @SerialName("is_available")
    val available: Boolean,
    @SerialName("is_free")
    val free: Boolean,
    @SerialName("is_demo")
    val demo: Boolean
)

@Serializable
data class PackageData(
    val root: Map<String, PackageInfo>
)

My retrofit client below;

import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit

@InstallIn(SingletonComponent::class)
@Module
object NetworkModule {
    private const val BASE = "https://raw.githubusercontent.com"

    @Provides
    @Singleton
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
        val contentType = "application/json".toMediaType()
        val json = Json {
            ignoreUnknownKeys = true
            prettyPrint = true
            isLenient = false
        }

        return Retrofit.Builder()
            .client(okHttpClient)
            .baseUrl(BASE)
            .addConverterFactory(json.asConverterFactory(contentType))
            .callFactory { okHttpClient.newCall(it) }
            .build()
    }
}

And finally my service and remote:

interface PackageService {
    @GET("$PATH/data/_apps.json")
    suspend fun getPackageData(): PackageData
}

class PackageRemoteImpl @Inject constructor(
    private val packageService: PackageService,
) : PackageRemote {
    override fun getPackageData(): Flow<PackageData> {
        return flow {
            emit(packageService.getPackageData())
        }
    }
}

I can see when adding a HttpLoggingInterceptor that the data is successfully being retrieved from the remote, though it never actually generates a flow of PackageData nor do I receive any kind of serialization error.

EDIT 1

RemoteModule

@InstallIn(SingletonComponent::class)
@Module
object RemoteModule {
    @Provides
    @Singleton
    fun providePackageService(retrofit: Retrofit): PackageService {
        return retrofit.create(PackageService::class.java)
    }
}

Edit 2

I managed to resolve this, turns out the data source was returning a null value on a few entries on the is_available bool.

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class PackageInfo(
    val id: String,
    @SerialName("app_name")
    val appName: String,
    @SerialName("is_available")
    val available: Boolean?,
    @SerialName("is_free")
    val free: Boolean,
    @SerialName("is_demo")
    val demo: Boolean
)

In addition to above, I also implemented the change required highlighted by Simon Jacobs.

interface PackageService {
    @GET("$PATH/data/_apps.json")
    suspend fun getPackageData(): Map<String, PackageInfo>
}

Now to figure out why the null Boolean result didn't raise a serialisation error.


Solution

  • Part 1: Serialization

    When you use this:

    @Serializable
    data class PackageData(
        val root: Map<String, PackageInfo>
    )
    

    the library expects JSON of the form:

    {
        "root": {
            "someKey": { /* some PackageInfo representation */ },
            "someOtherKey": { /* some PackageInfo representation */ }
        }
    }
    

    but you are actually passing JSON of the form:

    {
        "someKey": { /* some PackageInfo representation */ },
        "someOtherKey": { /* some PackageInfo representation */ }
    }
    

    This is the JSON that is represented by Map<String, PackageInfo> by default, not PackageData.

    You have two alternatives to fix this.

    1. Expect a different type

    You could tell your HTTP client that you are expecting an object that deserializes to type Map<String, PackageInfo>, which presumably involves writing a different interface as follows (I am not familiar with your framework (edit: this does need further work; see part 2):

    interface PackageService {
        @GET("$PATH/data/_apps.json")
        suspend fun getPackageData(): Map<String, PackageInfo>
    }
    

    2. Change the PackageData serializer

    Alternatively you can change the PackageData serializer. You can use the built-in MapSerializer and wrap it as follows:

    class PackageDataSerializer : KSerializer<PackageData> {
        private val serializer get() = MapSerializer(String.serializer(), PackageInfo.serializer())
        override val descriptor: SerialDescriptor get() = serializer.descriptor
    
        override fun deserialize(decoder: Decoder): PackageData = PackageData(serializer.deserialize(decoder))
    
        override fun serialize(encoder: Encoder, value: PackageData) {
            serializer.serialize(encoder, value.root)
        }
    }
    

    Then you can assign the new serializer to PackageData:

    @Serializable(with = PackageDataSerializer::class)
    data class PackageData(
        val root: Map<String, PackageInfo>
    )
    

    Now Kotlin serialization will expect the JSON you are transmitting.

    Part 2: Retrofit

    There look to also be some errors in your Retrofit setup.

    1. Your PackageService should return an object of type Call<PackageData> rather than PackageData
    2. Have you created your service on your built Retrofit object somewhere (ie the object returned from provideRetrofit()) as this is not shown? This would involve something like:
    val packageService = retrofit.create(GitHubService.class.java)