diff --git a/build.gradle b/build.gradle index ceed00ccab..4ea9274cf6 100644 --- a/build.gradle +++ b/build.gradle @@ -85,7 +85,7 @@ subprojects { } kotlin { ktlint(libs.ktlint.get().version) - target 'src/**/*.kt' + target '**/src/**/*.kt' } } } diff --git a/retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendTest.kt b/retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendTest.kt index b82b8ea5ad..abe6eb8de6 100644 --- a/retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendTest.kt +++ b/retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendTest.kt @@ -59,6 +59,12 @@ class KotlinSuspendTest { @HEAD("/") suspend fun headUnit() + @GET("user") + suspend fun getString(): Result + + @HEAD("user") + suspend fun headUser(): Result + @GET("/{a}/{b}/{c}") suspend fun params( @Path("a") a: String, @@ -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 diff --git a/retrofit/src/main/java/retrofit2/ResultCallAdapterFactory.kt b/retrofit/src/main/java/retrofit2/ResultCallAdapterFactory.kt new file mode 100644 index 0000000000..9b6cc33f60 --- /dev/null +++ b/retrofit/src/main/java/retrofit2/ResultCallAdapterFactory.kt @@ -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, + 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>, Call>, " + + "Result or Result" + } + + // 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 or Result" } + + val successBodyType = getParameterUpperBound(0, responseType) + + return ResultCallAdapter(successBodyType) + } + + companion object { + @JvmStatic + fun create(): CallAdapter.Factory = ResultCallAdapterFactory() + } +} + +class ResultCallAdapter( + private val responseType: Type, +) : CallAdapter>> { + + override fun responseType(): Type = responseType + + override fun adapt(call: Call): Call> = ResultCall(call) +} + +class ResultCall(private val delegate: Call) : Call> { + + override fun enqueue(callback: Callback>) { + delegate.enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + 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: Throwable) { + callback.onResponse(this@ResultCall, Response.success(Result.failure(t))) + } + }) + } + + override fun execute(): Response> { + 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 = 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() +} diff --git a/retrofit/src/main/resources/META-INF/proguard/retrofit2.pro b/retrofit/src/main/resources/META-INF/proguard/retrofit2.pro index feea13f2a9..1579f1b3bd 100644 --- a/retrofit/src/main/resources/META-INF/proguard/retrofit2.pro +++ b/retrofit/src/main/resources/META-INF/proguard/retrofit2.pro @@ -35,10 +35,11 @@ -if interface * { @retrofit2.http.* ; } -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 *** *(...); } diff --git a/samples/build.gradle b/samples/build.gradle index c832e73395..b3690558ef 100644 --- a/samples/build.gradle +++ b/samples/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'java-library' +apply plugin: 'org.jetbrains.kotlin.jvm' dependencies { implementation projects.retrofit @@ -10,5 +11,6 @@ dependencies { implementation libs.mockwebserver implementation libs.guava implementation libs.jsoup + implementation libs.kotlinCoroutines compileOnly libs.findBugsAnnotations } diff --git a/samples/src/main/java/com/example/retrofit/KotlinCoroutines.kt b/samples/src/main/java/com/example/retrofit/KotlinCoroutines.kt new file mode 100644 index 0000000000..ef22d19936 --- /dev/null +++ b/samples/src/main/java/com/example/retrofit/KotlinCoroutines.kt @@ -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 + + @GET("/repos/{owner}/{repo}/contributors") + suspend fun getContributorsWithResult( + @Path("owner") owner: String, + @Path("repo") repo: String, + ): Result> +} + +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") + } +}