diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fbd409659..97c090caf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/retrofit-converters/kotlinx-serialization-json/README.md b/retrofit-converters/kotlinx-serialization-json/README.md new file mode 100644 index 000000000..cabd31de7 --- /dev/null +++ b/retrofit-converters/kotlinx-serialization-json/README.md @@ -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 + + com.squareup.retrofit2 + converter-kotlinx-serialization-json + latest.version + +``` +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/ diff --git a/retrofit-converters/kotlinx-serialization-json/build.gradle b/retrofit-converters/kotlinx-serialization-json/build.gradle new file mode 100644 index 000000000..959199d0d --- /dev/null +++ b/retrofit-converters/kotlinx-serialization-json/build.gradle @@ -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 +} diff --git a/retrofit-converters/kotlinx-serialization-json/gradle.properties b/retrofit-converters/kotlinx-serialization-json/gradle.properties new file mode 100644 index 000000000..df23cbe97 --- /dev/null +++ b/retrofit-converters/kotlinx-serialization-json/gradle.properties @@ -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. diff --git a/retrofit-converters/kotlinx-serialization-json/src/main/java/retrofit2/converter/kotlinx/serialization/json/Factory.kt b/retrofit-converters/kotlinx-serialization-json/src/main/java/retrofit2/converter/kotlinx/serialization/json/Factory.kt new file mode 100644 index 000000000..d75e00d7a --- /dev/null +++ b/retrofit-converters/kotlinx-serialization-json/src/main/java/retrofit2/converter/kotlinx/serialization/json/Factory.kt @@ -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, + retrofit: Retrofit, + ): Converter? { + val loader = serializer(type) + return ResponseBodyConverter(json, loader) + } + + @Suppress("RedundantNullableReturnType") // Retaining interface contract. + override fun requestBodyConverter( + type: Type, + parameterAnnotations: Array, + methodAnnotations: Array, + 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) +} diff --git a/retrofit-converters/kotlinx-serialization-json/src/main/java/retrofit2/converter/kotlinx/serialization/json/RequestBodyConverter.kt b/retrofit-converters/kotlinx-serialization-json/src/main/java/retrofit2/converter/kotlinx/serialization/json/RequestBodyConverter.kt new file mode 100644 index 000000000..f42bc1733 --- /dev/null +++ b/retrofit-converters/kotlinx-serialization-json/src/main/java/retrofit2/converter/kotlinx/serialization/json/RequestBodyConverter.kt @@ -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( + private val json: Json, + private val saver: SerializationStrategy, +) : Converter { + + 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) + } +} diff --git a/retrofit-converters/kotlinx-serialization-json/src/main/java/retrofit2/converter/kotlinx/serialization/json/ResponseBodyConverter.kt b/retrofit-converters/kotlinx-serialization-json/src/main/java/retrofit2/converter/kotlinx/serialization/json/ResponseBodyConverter.kt new file mode 100644 index 000000000..d5f8cd097 --- /dev/null +++ b/retrofit-converters/kotlinx-serialization-json/src/main/java/retrofit2/converter/kotlinx/serialization/json/ResponseBodyConverter.kt @@ -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( + private val json: Json, + private val loader: DeserializationStrategy, +) : Converter { + + override fun convert(value: ResponseBody): T { + val source = value.source() + return json.decodeFromBufferedSource(loader, source) + } +} diff --git a/retrofit-converters/kotlinx-serialization-json/src/test/java/retrofit2/converter/kotlinx/serialization/json/KotlinxSerializationJsonConverterFactoryContextualListTest.kt b/retrofit-converters/kotlinx-serialization-json/src/test/java/retrofit2/converter/kotlinx/serialization/json/KotlinxSerializationJsonConverterFactoryContextualListTest.kt new file mode 100644 index 000000000..4a2641d33 --- /dev/null +++ b/retrofit-converters/kotlinx-serialization-json/src/test/java/retrofit2/converter/kotlinx/serialization/json/KotlinxSerializationJsonConverterFactoryContextualListTest.kt @@ -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> + + @POST("/") + fun serialize(@Body users: List): Call + } + + data class User(val name: String) + + object UserSerializer : KSerializer { + 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"]) + } +} diff --git a/retrofit-converters/kotlinx-serialization-json/src/test/java/retrofit2/converter/kotlinx/serialization/json/KotlinxSerializationJsonConverterFactoryContextualTest.kt b/retrofit-converters/kotlinx-serialization-json/src/test/java/retrofit2/converter/kotlinx/serialization/json/KotlinxSerializationJsonConverterFactoryContextualTest.kt new file mode 100644 index 000000000..be0ce1961 --- /dev/null +++ b/retrofit-converters/kotlinx-serialization-json/src/test/java/retrofit2/converter/kotlinx/serialization/json/KotlinxSerializationJsonConverterFactoryContextualTest.kt @@ -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 + + @POST("/") + fun serialize(@Body user: User): Call + } + + data class User(val name: String) + + object UserSerializer : KSerializer { + 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"]) + } +} diff --git a/retrofit-converters/kotlinx-serialization-json/src/test/java/retrofit2/converter/kotlinx/serialization/json/KotlinxSerializationJsonConverterFactoryTest.kt b/retrofit-converters/kotlinx-serialization-json/src/test/java/retrofit2/converter/kotlinx/serialization/json/KotlinxSerializationJsonConverterFactoryTest.kt new file mode 100644 index 000000000..d76205b8c --- /dev/null +++ b/retrofit-converters/kotlinx-serialization-json/src/test/java/retrofit2/converter/kotlinx/serialization/json/KotlinxSerializationJsonConverterFactoryTest.kt @@ -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 + @POST("/") fun serialize(@Body user: User): Call + } + + @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"]) + } +} diff --git a/settings.gradle b/settings.gradle index db95ce9b0..20fc9aeb1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -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'