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.
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.
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>
}
PackageData
serializerAlternatively 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.
There look to also be some errors in your Retrofit setup.
PackageService
should return an object of type Call<PackageData>
rather than PackageData
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)