Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add streaming deserialization support for kotlinx.serialization.json #4166

Open
wants to merge 7 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ autoService-compiler = { module = "com.google.auto.service:auto-service", versio
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.8.1" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
kotlinx-serialization-jsonOkio = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-okio", version.ref = "kotlinx-serialization" }
kotlinx-serialization-proto = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinx-serialization" }
okhttp-client = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-loggingInterceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
Expand Down
38 changes: 38 additions & 0 deletions retrofit-converters/kotlinx-serialization-json/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# kotlinx.serialization Converter

A `Converter` which uses [kotlinx.serialization.json][1] for serialization.

Given a `Json`, call `asConverterFactory()` in order to
create a `Converter.Factory`.

```kotlin
val retrofit = Retrofit.Builder()
.baseUrl("https://example.com/")
.addConverterFactory(Json.asConverterFactory())
.build()
```


## Download

Download [the latest JAR][2] or grab via [Maven][3]:
```xml
<dependency>
<groupId>com.squareup.retrofit2</groupId>
<artifactId>converter-kotlinx-serialization-json</artifactId>
<version>latest.version</version>
</dependency>
```
or [Gradle][3]:
```groovy
implementation 'com.squareup.retrofit2:converter-kotlinx-serialization-json:latest.version'
```

Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap].



[1]: https://github.com/Kotlin/kotlinx.serialization
[2]: https://search.maven.org/remote_content?g=com.squareup.retrofit2&a=converter-kotlinx-serialization-json&v=LATEST
[3]: http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.squareup.retrofit2%22%20a%3A%22converter-kotlinx-serialization-json%22
[snap]: https://s01.oss.sonatype.org/content/repositories/snapshots/
12 changes: 12 additions & 0 deletions retrofit-converters/kotlinx-serialization-json/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
apply plugin: 'org.jetbrains.kotlin.jvm'
apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
apply plugin: 'com.vanniktech.maven.publish'
apply plugin: 'org.jetbrains.dokka'

dependencies {
api projects.retrofit
api libs.kotlinx.serialization.jsonOkio

testImplementation libs.junit
testImplementation libs.okhttp.mockwebserver
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
POM_ARTIFACT_ID=converter-kotlinx-serialization-json
POM_NAME=Converter: kotlinx.serialization.json
POM_DESCRIPTION=A Retrofit Converter which uses kotlinx.serialization.json for serialization.
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package retrofit2.converter.kotlinx.serialization.json

import java.lang.reflect.Type
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit

@ExperimentalSerializationApi
internal class Factory(
private val json: Json,
) : Converter.Factory() {

@Suppress("RedundantNullableReturnType") // Retaining interface contract.
override fun responseBodyConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit,
): Converter<ResponseBody, *>? {
val loader = serializer(type)
return ResponseBodyConverter(json, loader)
}

@Suppress("RedundantNullableReturnType") // Retaining interface contract.
override fun requestBodyConverter(
type: Type,
parameterAnnotations: Array<out Annotation>,
methodAnnotations: Array<out Annotation>,
retrofit: Retrofit,
): Converter<*, RequestBody>? {
val saver = serializer(type)
return RequestBodyConverter(json, saver)
}

private fun serializer(type: Type) = json.serializersModule.serializer(type)
}

/**
* Return a [Converter.Factory] which uses Kotlin serialization for Json-based payloads.
*
* Because Kotlin serialization is so flexible in the types it supports, this converter assumes
* that it can handle all types. If you are mixing this with something else, you must add this
* instance last to allow the other converters a chance to see their types.
*/
@ExperimentalSerializationApi
@JvmName("create")
fun Json.asConverterFactory(): Converter.Factory {
return Factory(this)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package retrofit2.converter.kotlinx.serialization.json

import kotlinx.serialization.SerializationStrategy
import kotlinx.serialization.json.Json
import okhttp3.MediaType
import okhttp3.RequestBody
import retrofit2.Converter

internal class RequestBodyConverter<T>(
private val json: Json,
private val saver: SerializationStrategy<T>,
) : Converter<T, RequestBody> {

private val contentType = MediaType.get("application/json; charset=UTF-8")

override fun convert(value: T): RequestBody {
val string = json.encodeToString(saver, value)
return RequestBody.create(contentType, string)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package retrofit2.converter.kotlinx.serialization.json

import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.okio.decodeFromBufferedSource
import okhttp3.ResponseBody
import retrofit2.Converter

@ExperimentalSerializationApi
class ResponseBodyConverter<T>(
private val json: Json,
private val loader: DeserializationStrategy<T>,
) : Converter<ResponseBody, T> {

override fun convert(value: ResponseBody): T {
val source = value.source()
return json.decodeFromBufferedSource(loader, source)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package retrofit2.converter.kotlinx.serialization.json

import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST

@OptIn(ExperimentalSerializationApi::class)
class KotlinxSerializationJsonConverterFactoryContextualListTest {
@get:Rule
val server = MockWebServer()

private lateinit var service: Service

interface Service {
@GET("/")
fun deserialize(): Call<List<User>>

@POST("/")
fun serialize(@Body users: List<User>): Call<Void?>
}

data class User(val name: String)

object UserSerializer : KSerializer<User> {
override val descriptor = PrimitiveSerialDescriptor("User", PrimitiveKind.STRING)

override fun deserialize(decoder: Decoder): User =
decoder.decodeSerializableValue(UserResponse.serializer()).run {
User(name)
}

override fun serialize(encoder: Encoder, value: User): Unit =
encoder.encodeSerializableValue(UserResponse.serializer(), UserResponse(value.name))

@Serializable
private data class UserResponse(val name: String)
}

private val json = Json {
serializersModule = SerializersModule {
contextual(UserSerializer)
}
}

@Before
fun setUp() {
val retrofit = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(json.asConverterFactory())
.build()
service = retrofit.create(Service::class.java)
}

@Test
fun deserialize() {
server.enqueue(MockResponse().setBody("""[{"name":"Bob"}]"""))
val user = service.deserialize().execute().body()!!
Assert.assertEquals(listOf(User("Bob")), user)
}

@Test
fun serialize() {
server.enqueue(MockResponse())
service.serialize(listOf(User("Bob"))).execute()
val request = server.takeRequest()
Assert.assertEquals("""[{"name":"Bob"}]""", request.body.readUtf8())
Assert.assertEquals("application/json; charset=UTF-8", request.headers["Content-Type"])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package retrofit2.converter.kotlinx.serialization.json

import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST

@OptIn(ExperimentalSerializationApi::class)
class KotlinxSerializationJsonConverterFactoryContextualTest {
@get:Rule
val server = MockWebServer()

private lateinit var service: Service

interface Service {
@GET("/")
fun deserialize(): Call<User>

@POST("/")
fun serialize(@Body user: User): Call<Void?>
}

data class User(val name: String)

object UserSerializer : KSerializer<User> {
override val descriptor = PrimitiveSerialDescriptor("User", PrimitiveKind.STRING)

override fun deserialize(decoder: Decoder): User =
decoder.decodeSerializableValue(UserResponse.serializer()).run {
User(name)
}

override fun serialize(encoder: Encoder, value: User): Unit =
encoder.encodeSerializableValue(UserResponse.serializer(), UserResponse(value.name))

@Serializable
private data class UserResponse(val name: String)
}

private val json = Json {
serializersModule = SerializersModule {
contextual(UserSerializer)
}
}

@Before
fun setUp() {
val retrofit = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(json.asConverterFactory())
.build()
service = retrofit.create(Service::class.java)
}

@Test
fun deserialize() {
server.enqueue(MockResponse().setBody("""{"name":"Bob"}"""))
val user = service.deserialize().execute().body()!!
assertEquals(User("Bob"), user)
}

@Test
fun serialize() {
server.enqueue(MockResponse())
service.serialize(User("Bob")).execute()
val request = server.takeRequest()
assertEquals("""{"name":"Bob"}""", request.body.readUtf8())
assertEquals("application/json; charset=UTF-8", request.headers["Content-Type"])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package retrofit2.converter.kotlinx.serialization.json

import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST

@OptIn(ExperimentalSerializationApi::class)
class KotlinxSerializationJsonConverterFactoryTest {
@get:Rule val server = MockWebServer()

private lateinit var service: Service

interface Service {
@GET("/") fun deserialize(): Call<User>
@POST("/") fun serialize(@Body user: User): Call<Void?>
}

@Serializable
data class User(val name: String)

@Before fun setUp() {
val retrofit = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(Json.asConverterFactory())
.build()
service = retrofit.create(Service::class.java)
}

@Test fun deserialize() {
server.enqueue(MockResponse().setBody("""{"name":"Bob"}"""))
val user = service.deserialize().execute().body()!!
assertEquals(User("Bob"), user)
}

@Test fun serialize() {
server.enqueue(MockResponse())
service.serialize(User("Bob")).execute()
val request = server.takeRequest()
assertEquals("""{"name":"Bob"}""", request.body.readUtf8())
assertEquals("application/json; charset=UTF-8", request.headers["Content-Type"])
}
}
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ include ':retrofit-converters:java8'
include ':retrofit-converters:jaxb'
include ':retrofit-converters:jaxb3'
include ':retrofit-converters:kotlinx-serialization'
include ':retrofit-converters:kotlinx-serialization-json'
include ':retrofit-converters:moshi'
include ':retrofit-converters:protobuf'
include ':retrofit-converters:scalars'
Expand Down