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 support for Kotlin's Result #4018

Draft
wants to merge 9 commits into
base: trunk
Choose a base branch
from
Draft
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
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ subprojects {
}
kotlin {
ktlint(libs.ktlint.get().version)
target 'src/**/*.kt'
target '**/src/**/*.kt'
}
}
}
Expand Down
48 changes: 48 additions & 0 deletions retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ class KotlinSuspendTest {
@HEAD("/")
suspend fun headUnit()

@GET("user")
suspend fun getString(): Result<String>

@HEAD("user")
suspend fun headUser(): Result<Unit>

@GET("/{a}/{b}/{c}")
suspend fun params(
@Path("a") a: String,
Expand Down Expand Up @@ -389,6 +395,48 @@ class KotlinSuspendTest {
}
}

@Test fun returnResultType() = runBlocking {
val retrofit = Retrofit.Builder()
.baseUrl(server.url("/"))
.addCallAdapterFactory(ResultCallAdapterFactory.create())
.addConverterFactory(ToStringConverterFactory())
.build()
val service = retrofit.create(Service::class.java)

// Successful response with body.
server.enqueue(MockResponse().setBody("Hello World"))
service.getString().let { result ->
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrThrow()).isEqualTo("Hello World")
}

// Successful response without body.
server.enqueue(MockResponse())
service.headUser().let { result ->
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrThrow()).isEqualTo(Unit)
}

// Error response without body.
server.enqueue(MockResponse().setResponseCode(404))
service.getString().let { result ->
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).let {
it.hasMessageThat().isEqualTo("HTTP 404 Client Error")
it.isInstanceOf(HttpException::class.java)
}
}

// Network error.
server.shutdown()
service.getString().let { result ->
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java)
}

Unit // Return type of runBlocking is Unit.
}

@Suppress("EXPERIMENTAL_OVERRIDE")
private object DirectUnconfinedDispatcher : CoroutineDispatcher() {
override fun isDispatchNeeded(context: CoroutineContext): Boolean = false
Expand Down
102 changes: 102 additions & 0 deletions retrofit/src/main/java/retrofit2/ResultCallAdapterFactory.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package retrofit2

import java.io.IOException
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
import okhttp3.Request
import okio.Timeout

class ResultCallAdapterFactory private constructor() : CallAdapter.Factory() {
override fun get(
returnType: Type,
annotations: Array<Annotation>,
retrofit: Retrofit,
): CallAdapter<*, *>? {
val rawReturnType = getRawType(returnType)
// suspend functions wrap the response type in `Call`
if (Call::class.java != rawReturnType && Result::class.java != rawReturnType) {
return null
}

// check first that the return type is `ParameterizedType`
check(returnType is ParameterizedType) {
"return type must be parameterized as Call<Result<Foo>>, Call<Result<out Foo>>, " +
"Result<Foo> or Result<out Foo>"
}

// get the response type inside the `Call` or `NetworkResult` type
val responseType = getParameterUpperBound(0, returnType)

// if the response type is not NetworkResult then we can't handle this type, so we return null
if (getRawType(responseType) != Result::class.java) {
return null
}

// the response type is Result and should be parameterized
check(responseType is ParameterizedType) { "Response must be parameterized as Result<Foo> or Result<out Foo>" }

val successBodyType = getParameterUpperBound(0, responseType)

return ResultCallAdapter<Any>(successBodyType)
}

companion object {
@JvmStatic
fun create(): CallAdapter.Factory = ResultCallAdapterFactory()
}
}

class ResultCallAdapter<T>(
private val responseType: Type,
) : CallAdapter<T, Call<Result<T>>> {

override fun responseType(): Type = responseType

override fun adapt(call: Call<T>): Call<Result<T>> = ResultCall(call)
}

class ResultCall<T>(private val delegate: Call<T>) : Call<Result<T>> {

override fun enqueue(callback: Callback<Result<T>>) {
delegate.enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
val result = runCatching {
if (response.isSuccessful) {
response.body() ?: error("Response $response body is null.")
} else {
throw HttpException(response)
}
}
callback.onResponse(this@ResultCall, Response.success(result))
}

override fun onFailure(call: Call<T>, t: Throwable) {
callback.onResponse(this@ResultCall, Response.success(Result.failure(t)))
}
})
}

override fun execute(): Response<Result<T>> {
val result = runCatching {
val response = delegate.execute()
if (response.isSuccessful) {
response.body() ?: error("Response $response body is null.")
} else {
throw IOException("Unexpected error: ${response.errorBody()?.string()}")
}
}
return Response.success(result)
}

override fun isExecuted(): Boolean = delegate.isExecuted

override fun clone(): ResultCall<T> = ResultCall(delegate.clone())

override fun isCanceled(): Boolean = delegate.isCanceled

override fun cancel(): Unit = delegate.cancel()

override fun request(): Request = delegate.request()

override fun timeout(): Timeout = delegate.timeout()
}
7 changes: 4 additions & 3 deletions retrofit/src/main/resources/META-INF/proguard/retrofit2.pro
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@
-if interface * { @retrofit2.http.* <methods>; }
-keep,allowobfuscation interface * extends <1>

# With R8 full mode generic signatures are stripped for classes that are not
# kept. Suspend functions are wrapped in continuations where the type argument
# is used.
# With R8 full mode generic signatures are stripped for classes that are not kept.
# Suspend functions are wrapped in continuations where the type argument is used.
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
# We support Reulst as the return type, need to keep it in R8 full mode.
-keep,allowobfuscation,allowshrinking class kotlin.Result

# R8 full mode strips generic signatures from return types if not kept.
-if interface * { @retrofit2.http.* public *** *(...); }
Expand Down
2 changes: 2 additions & 0 deletions samples/build.gradle
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
apply plugin: 'java-library'
apply plugin: 'org.jetbrains.kotlin.jvm'

dependencies {
implementation projects.retrofit
Expand All @@ -10,5 +11,6 @@ dependencies {
implementation libs.mockwebserver
implementation libs.guava
implementation libs.jsoup
implementation libs.kotlinCoroutines
compileOnly libs.findBugsAnnotations
}
60 changes: 60 additions & 0 deletions samples/src/main/java/com/example/retrofit/KotlinCoroutines.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.example.retrofit

import retrofit2.ResultCallAdapterFactory
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
import retrofit2.http.GET
import retrofit2.http.Path

class Contributor(
val login: String,
val contributions: Int,
) {
override fun toString(): String {
return "Contributor(login='$login', contributions=$contributions)"
}
}

interface GitHub {
@GET("/repos/{owner}/{repo}/contributors")
suspend fun getContributors(
@Path("owner") owner: String,
@Path("repo") repo: String,
): List<Contributor>

@GET("/repos/{owner}/{repo}/contributors")
suspend fun getContributorsWithResult(
@Path("owner") owner: String,
@Path("repo") repo: String,
): Result<List<Contributor>>
}

suspend fun main() {
val retrofit = Retrofit.Builder()
.baseUrl("https://api.github.com")
.addCallAdapterFactory(ResultCallAdapterFactory.create())
.addConverterFactory(GsonConverterFactory.create())
.build()
val github: GitHub = retrofit.create()

println("Request without Result using")
try {
github.getContributors("square", "retrofit").forEach { contributor ->
println(contributor)
}
} catch (e: Exception) {
println("An error occurred when not using Result: $e")
}

println("Request with Result using")
github.getContributorsWithResult("square", "retrofit")
.onSuccess {
it.forEach { contributor ->
println(contributor)
}
}
.onFailure {
println("An error occurred when using Result: $it")
}
}
Loading