Skip to content

Commit

Permalink
Add support for Kotlin's Result
Browse files Browse the repository at this point in the history
Co-authored-by: tomridder <[email protected]>
  • Loading branch information
Goooler and tomridder committed Dec 21, 2023
1 parent c0d2b54 commit ef13d4a
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 4 deletions.
1 change: 1 addition & 0 deletions retrofit/kotlin-test/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ apply plugin: 'org.jetbrains.kotlin.jvm'
dependencies {
testImplementation projects.retrofit
testImplementation projects.retrofit.testHelpers
testImplementation projects.retrofitConverters.gson
testImplementation libs.junit
testImplementation libs.assertj
testImplementation libs.mockwebserver
Expand Down
55 changes: 55 additions & 0 deletions retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import org.junit.Assert.fail
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.helpers.ToStringConverterFactory
import retrofit2.http.GET
import retrofit2.http.HEAD
Expand All @@ -49,6 +50,8 @@ class KotlinSuspendTest {
@GET("/") suspend fun response(): Response<String>
@GET("/") suspend fun unit()
@HEAD("/") suspend fun headUnit()
@GET("user") suspend fun getUser(): Result<User>
@HEAD("user") suspend fun headUser(): Result<Unit>

@GET("/{a}/{b}/{c}")
suspend fun params(
Expand All @@ -60,6 +63,8 @@ class KotlinSuspendTest {
@GET("/") suspend fun bodyWithCallType(): Call<String>
}

data class User(val id: Int, val name: String, val email: String)

@Test fun body() {
val retrofit = Retrofit.Builder()
.baseUrl(server.url("/"))
Expand Down Expand Up @@ -374,6 +379,56 @@ class KotlinSuspendTest {
}
}

@Test fun returnResultType() = runBlocking {
val responseBody = """
{
"id": 1,
"name": "John Doe",
"email": "[email protected]"
}
""".trimIndent()
val retrofit = Retrofit.Builder()
.baseUrl(server.url("/"))
.addCallAdapterFactory(ResultCallAdapterFactory.create())
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(Service::class.java)

// Successful response with body.
server.enqueue(MockResponse().setBody(responseBody))
service.getUser().let { result ->
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrThrow().id).isEqualTo(1)
assertThat(result.getOrThrow().name).isEqualTo("John Doe")
assertThat(result.getOrThrow().email).isEqualTo("[email protected]")
}

// 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.getUser().let { result ->
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull())
.isInstanceOf(HttpException::class.java)
.hasMessage("HTTP 404 Client Error")
}

// Network error.
server.shutdown()
service.getUser().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
15 changes: 11 additions & 4 deletions retrofit/src/main/java/retrofit2/HttpServiceMethod.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import javax.annotation.Nullable;
import kotlin.Result;
import kotlin.Unit;
import kotlin.coroutines.Continuation;
import okhttp3.ResponseBody;
Expand All @@ -42,7 +43,7 @@ static <ResponseT, ReturnT> HttpServiceMethod<ResponseT, ReturnT> parseAnnotatio
boolean continuationIsUnit = false;

Annotation[] annotations = method.getAnnotations();
Type adapterType;
final Type adapterType;
if (isKotlinSuspendFunction) {
Type[] parameterTypes = method.getGenericParameterTypes();
Type responseType =
Expand All @@ -52,22 +53,28 @@ static <ResponseT, ReturnT> HttpServiceMethod<ResponseT, ReturnT> parseAnnotatio
// Unwrap the actual body type from Response<T>.
responseType = Utils.getParameterUpperBound(0, (ParameterizedType) responseType);
continuationWantsResponse = true;
adapterType = new Utils.ParameterizedTypeImpl(null, Call.class, responseType);
} else {
if (getRawType(responseType) == Call.class) {
Class<?> rawType = getRawType(responseType);
if (rawType == Call.class) {
throw methodError(
method,
"Suspend functions should not return Call, as they already execute asynchronously.\n" +
"Change its return type to %s", Utils.getParameterUpperBound(0, (ParameterizedType) responseType));
}

if (rawType == Result.class) {
adapterType = responseType;
} else {
adapterType = new Utils.ParameterizedTypeImpl(null, Call.class, responseType);
}

continuationIsUnit = Utils.isUnit(responseType);
// TODO figure out if type is nullable or not
// Metadata metadata = method.getDeclaringClass().getAnnotation(Metadata.class)
// Find the entry for method
// Determine if return type is nullable or not
}

adapterType = new Utils.ParameterizedTypeImpl(null, Call.class, responseType);
annotations = SkipCallbackExecutorImpl.ensurePresent(annotations);
} else {
adapterType = method.getGenericReturnType();
Expand Down
83 changes: 83 additions & 0 deletions retrofit/src/main/java/retrofit2/ResultCallAdapterFactory.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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<*, *>? {
if (getRawType(returnType) != Result::class.java) return null

check(returnType is ParameterizedType) {
"Result must have a generic type (e.g., Result<T>)"
}

return ResultCallAdapter<Any>(getParameterUpperBound(0, returnType))
}

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()
}

0 comments on commit ef13d4a

Please sign in to comment.