From 8c1e6bede01168d8eff459edaf6feed9224536c3 Mon Sep 17 00:00:00 2001 From: Donghyeon Kim Date: Mon, 1 Aug 2022 01:44:58 +0900 Subject: [PATCH] Support kotlin coroutines Resolves: #1565 Inspired by https://github.com/PlaytikaOSS/feign-reactive/pull/486 ## TODO - [ ] Separate Kotlin support module - [ ] Enhance test case - [ ] Refactoring - [ ] Clean up pom.xml --- core/pom.xml | 57 ++++++ .../main/java/feign/AsyncResponseHandler.java | 4 +- core/src/main/java/feign/MethodInfo.java | 13 +- core/src/main/java/feign/MethodKt.kt | 14 ++ .../main/java/feign/ReflectiveAsyncFeign.java | 11 ++ .../src/test/java/feign/CoroutineFeignTest.kt | 170 ++++++++++++++++++ 6 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/feign/MethodKt.kt create mode 100644 core/src/test/java/feign/CoroutineFeignTest.kt diff --git a/core/pom.xml b/core/pom.xml index cf243ea7e..ae4cf062c 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -29,9 +29,35 @@ ${project.basedir}/.. + 1.6.21 + 1.6.4 + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + + org.jetbrains.kotlinx + kotlinx-coroutines-jdk8 + ${kotlinx.coroutines.version} + + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + ${kotlinx.coroutines.version} + + com.squareup.okhttp3 mockwebserver @@ -111,6 +137,37 @@ + + kotlin-maven-plugin + org.jetbrains.kotlin + ${kotlin.version} + + + compile + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + + + diff --git a/core/src/main/java/feign/AsyncResponseHandler.java b/core/src/main/java/feign/AsyncResponseHandler.java index b73439d36..f927edc02 100644 --- a/core/src/main/java/feign/AsyncResponseHandler.java +++ b/core/src/main/java/feign/AsyncResponseHandler.java @@ -18,6 +18,8 @@ import feign.Logger.Level; import feign.codec.Decoder; import feign.codec.ErrorDecoder; +import kotlin.Unit; + import java.io.IOException; import java.lang.reflect.Type; import java.util.concurrent.CompletableFuture; @@ -54,7 +56,7 @@ class AsyncResponseHandler { } boolean isVoidType(Type returnType) { - return Void.class == returnType || void.class == returnType; + return Void.class == returnType || void.class == returnType || Unit.class == returnType; } void handleResponse(CompletableFuture resultFuture, diff --git a/core/src/main/java/feign/MethodInfo.java b/core/src/main/java/feign/MethodInfo.java index 608e03c2a..162f156d2 100644 --- a/core/src/main/java/feign/MethodInfo.java +++ b/core/src/main/java/feign/MethodInfo.java @@ -18,6 +18,9 @@ import java.lang.reflect.Type; import java.util.concurrent.CompletableFuture; +import static feign.MethodKt.getKotlinMethodReturnType; +import static feign.MethodKt.isSuspendMethod; + @Experimental class MethodInfo { private final String configKey; @@ -35,7 +38,15 @@ class MethodInfo { final Type type = Types.resolve(targetType, targetType, method.getGenericReturnType()); - if (type instanceof ParameterizedType + if (isSuspendMethod(method)) { + this.asyncReturnType = true; + this.underlyingReturnType = getKotlinMethodReturnType(method); + if (this.underlyingReturnType == null) { + throw new IllegalArgumentException(String.format( + "Method %s can't have continuation argument, only kotlin method is allowed", + this.configKey)); + } + } else if (type instanceof ParameterizedType && Types.getRawType(type).isAssignableFrom(CompletableFuture.class)) { this.asyncReturnType = true; this.underlyingReturnType = ((ParameterizedType) type).getActualTypeArguments()[0]; diff --git a/core/src/main/java/feign/MethodKt.kt b/core/src/main/java/feign/MethodKt.kt new file mode 100644 index 000000000..7493f8386 --- /dev/null +++ b/core/src/main/java/feign/MethodKt.kt @@ -0,0 +1,14 @@ +@file:JvmName("MethodKt") + +package feign + +import java.lang.reflect.Method +import java.lang.reflect.Type +import kotlin.reflect.jvm.javaType +import kotlin.reflect.jvm.kotlinFunction + +internal fun Method.isSuspendMethod(): Boolean = + kotlinFunction?.isSuspend ?: false + +internal val Method.kotlinMethodReturnType: Type? + get() = kotlinFunction?.returnType?.javaType diff --git a/core/src/main/java/feign/ReflectiveAsyncFeign.java b/core/src/main/java/feign/ReflectiveAsyncFeign.java index 1fc4e1306..ab9a21208 100644 --- a/core/src/main/java/feign/ReflectiveAsyncFeign.java +++ b/core/src/main/java/feign/ReflectiveAsyncFeign.java @@ -13,6 +13,9 @@ */ package feign; +import kotlin.coroutines.Continuation; +import kotlinx.coroutines.future.FutureKt; + import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; @@ -24,6 +27,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import static feign.MethodKt.isSuspendMethod; + @Experimental public class ReflectiveAsyncFeign extends AsyncFeign { @@ -63,6 +68,12 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl setInvocationContext(new AsyncInvocation<>(context, methodInfo)); try { + if (isSuspendMethod(method)) { + CompletableFuture result = (CompletableFuture) method.invoke(instance, args); + Continuation continuation = (Continuation) args[args.length - 1]; + return FutureKt.await(result, continuation); + } + return method.invoke(instance, args); } catch (final InvocationTargetException e) { Throwable cause = e.getCause(); diff --git a/core/src/test/java/feign/CoroutineFeignTest.kt b/core/src/test/java/feign/CoroutineFeignTest.kt new file mode 100644 index 000000000..f84441f2e --- /dev/null +++ b/core/src/test/java/feign/CoroutineFeignTest.kt @@ -0,0 +1,170 @@ +package feign + +import com.google.gson.Gson +import com.google.gson.JsonIOException +import feign.codec.Decoder +import feign.codec.Encoder +import feign.codec.ErrorDecoder +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.io.IOException +import java.lang.reflect.Type + +class SuspendTest { + @Test + fun shouldRun1(): Unit = runBlocking { + // Arrange + val server = MockWebServer() + val expected = "Hello Worlda" + server.enqueue(MockResponse().setBody(expected)) + val client = TestInterfaceAsyncBuilder() + .target("http://localhost:" + server.port) + + // Act + val firstOrder = client.findOrder1(orderId = 1) + + // Assert + assertThat(firstOrder).isEqualTo(expected) + } + + @Test + fun shouldRun2(): Unit = runBlocking { + // Arrange + val server = MockWebServer() + val expected = IceCreamOrder( + id = "HELLO WORLD", + no = 999, + ) + server.enqueue(MockResponse().setBody("{ id: '${expected.id}', no: '${expected.no}'}")) + + val client = TestInterfaceAsyncBuilder() + .decoder(GsonDecoder()) + .target("http://localhost:" + server.port) + + // Act + val firstOrder = client.findOrder2(orderId = 1) + + // Assert + assertThat(firstOrder).isEqualTo(expected) + } + + @Test + fun shouldRun3(): Unit = runBlocking { + // Arrange + val server = MockWebServer() + server.enqueue(MockResponse().setBody("HELLO WORLD")) + + val client = TestInterfaceAsyncBuilder() + .target("http://localhost:" + server.port) + + // Act + val firstOrder = client.findOrder3(orderId = 1) + + // Assert + assertThat(firstOrder).isNull() + } + + @Test + fun shouldRun4(): Unit = runBlocking { + // Arrange + val server = MockWebServer() + server.enqueue(MockResponse().setBody("HELLO WORLD")) + + val client = TestInterfaceAsyncBuilder() + .target("http://localhost:" + server.port) + + // Act + val firstOrder = client.findOrder4(orderId = 1) + + // Assert + assertThat(firstOrder).isEqualTo(Unit) + } + + internal class GsonDecoder : Decoder { + private val gson = Gson() + + override fun decode(response: Response, type: Type): Any? { + if (Void.TYPE == type || response.body() == null) { + return null + } + val reader = response.body().asReader(Util.UTF_8) + return try { + gson.fromJson(reader, type) + } catch (e: JsonIOException) { + if (e.cause != null && e.cause is IOException) { + throw IOException::class.java.cast(e.cause) + } + throw e + } finally { + Util.ensureClosed(reader) + } + } + } + + internal class TestInterfaceAsyncBuilder { + private val delegate = AsyncFeign.asyncBuilder() + .decoder(Decoder.Default()).encoder { `object`, bodyType, template -> + if (`object` is Map<*, *>) { + template.body(Gson().toJson(`object`)) + } else { + template.body(`object`.toString()) + } + } + + fun requestInterceptor(requestInterceptor: RequestInterceptor?): TestInterfaceAsyncBuilder { + delegate.requestInterceptor(requestInterceptor) + return this + } + + fun encoder(encoder: Encoder?): TestInterfaceAsyncBuilder { + delegate.encoder(encoder) + return this + } + + fun decoder(decoder: Decoder?): TestInterfaceAsyncBuilder { + delegate.decoder(decoder) + return this + } + + fun errorDecoder(errorDecoder: ErrorDecoder?): TestInterfaceAsyncBuilder { + delegate.errorDecoder(errorDecoder) + return this + } + + fun dismiss404(): TestInterfaceAsyncBuilder { + delegate.dismiss404() + return this + } + + fun queryMapEndcoder(queryMapEncoder: QueryMapEncoder?): TestInterfaceAsyncBuilder { + delegate.queryMapEncoder(queryMapEncoder) + return this + } + + fun target(url: String?): TestInterfaceAsync { + return delegate.target(TestInterfaceAsync::class.java, url) + } + } + + internal interface TestInterfaceAsync { + @RequestLine("GET /icecream/orders/{orderId}") + suspend fun findOrder1(@Param("orderId") orderId: Int): String + + @RequestLine("GET /icecream/orders/{orderId}") + suspend fun findOrder2(@Param("orderId") orderId: Int): IceCreamOrder + + @RequestLine("GET /icecream/orders/{orderId}") + suspend fun findOrder3(@Param("orderId") orderId: Int): Void + + @RequestLine("GET /icecream/orders/{orderId}") + suspend fun findOrder4(@Param("orderId") orderId: Int): Unit + } + + data class IceCreamOrder( + val id: String, + val no: Long, + ) +}