Skip to content

Commit 865cfa1

Browse files
committed
Support kotlin coroutines
Resolves: OpenFeign#1565 Inspired by PlaytikaOSS/feign-reactive#486 ## TODO - [ ] Separate Kotlin support module - [ ] Enhance test case - [ ] Refactoring - [ ] Clean up pom.xml
1 parent af37c49 commit 865cfa1

File tree

6 files changed

+270
-2
lines changed

6 files changed

+270
-2
lines changed

core/pom.xml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,35 @@
2929

3030
<properties>
3131
<main.basedir>${project.basedir}/..</main.basedir>
32+
<kotlin.version>1.5.30</kotlin.version>
33+
<kotlinx.coroutines.version>1.5.2</kotlinx.coroutines.version>
3234
</properties>
3335

3436
<dependencies>
37+
<dependency>
38+
<groupId>org.jetbrains.kotlin</groupId>
39+
<artifactId>kotlin-stdlib-jdk8</artifactId>
40+
<version>${kotlin.version}</version>
41+
</dependency>
42+
43+
<dependency>
44+
<groupId>org.jetbrains.kotlin</groupId>
45+
<artifactId>kotlin-reflect</artifactId>
46+
<version>${kotlin.version}</version>
47+
</dependency>
48+
49+
<dependency>
50+
<groupId>org.jetbrains.kotlinx</groupId>
51+
<artifactId>kotlinx-coroutines-jdk8</artifactId>
52+
<version>${kotlinx.coroutines.version}</version>
53+
</dependency>
54+
55+
<dependency>
56+
<groupId>org.jetbrains.kotlinx</groupId>
57+
<artifactId>kotlinx-coroutines-reactor</artifactId>
58+
<version>${kotlinx.coroutines.version}</version>
59+
</dependency>
60+
3561
<dependency>
3662
<groupId>com.squareup.okhttp3</groupId>
3763
<artifactId>mockwebserver</artifactId>
@@ -111,6 +137,37 @@
111137
</execution>
112138
</executions>
113139
</plugin>
140+
<plugin>
141+
<artifactId>kotlin-maven-plugin</artifactId>
142+
<groupId>org.jetbrains.kotlin</groupId>
143+
<version>${kotlin.version}</version>
144+
<executions>
145+
<execution>
146+
<id>compile</id>
147+
<goals>
148+
<goal>compile</goal>
149+
</goals>
150+
<configuration>
151+
<sourceDirs>
152+
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
153+
<sourceDir>${project.basedir}/src/main/java</sourceDir>
154+
</sourceDirs>
155+
</configuration>
156+
</execution>
157+
<execution>
158+
<id>test-compile</id>
159+
<goals>
160+
<goal>test-compile</goal>
161+
</goals>
162+
<configuration>
163+
<sourceDirs>
164+
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
165+
<sourceDir>${project.basedir}/src/test/java</sourceDir>
166+
</sourceDirs>
167+
</configuration>
168+
</execution>
169+
</executions>
170+
</plugin>
114171
</plugins>
115172
</build>
116173

core/src/main/java/feign/AsyncResponseHandler.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import feign.Logger.Level;
1919
import feign.codec.Decoder;
2020
import feign.codec.ErrorDecoder;
21+
import kotlin.Unit;
22+
2123
import java.io.IOException;
2224
import java.lang.reflect.Type;
2325
import java.util.concurrent.CompletableFuture;
@@ -54,7 +56,7 @@ class AsyncResponseHandler {
5456
}
5557

5658
boolean isVoidType(Type returnType) {
57-
return Void.class == returnType || void.class == returnType;
59+
return Void.class == returnType || void.class == returnType || Unit.class == returnType;
5860
}
5961

6062
void handleResponse(CompletableFuture<Object> resultFuture,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
@file:JvmName("KotlinExtensions")
2+
3+
package feign
4+
5+
import kotlinx.coroutines.future.await
6+
import java.lang.reflect.Method
7+
import java.lang.reflect.Type
8+
import java.util.concurrent.CompletableFuture
9+
import kotlin.reflect.jvm.javaType
10+
import kotlin.reflect.jvm.kotlinFunction
11+
12+
internal suspend fun CompletableFuture<*>.awaitRequest(): Any? =
13+
this.await()
14+
15+
internal fun Method.isSuspendMethod(): Boolean =
16+
kotlinFunction?.isSuspend ?: false
17+
18+
internal fun Method.getKotlinMethodReturnType(): Type? =
19+
kotlinFunction?.returnType?.javaType

core/src/main/java/feign/MethodInfo.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
import java.lang.reflect.Type;
1919
import java.util.concurrent.CompletableFuture;
2020

21+
import static feign.KotlinExtensions.*;
22+
2123
@Experimental
2224
class MethodInfo {
2325
private final String configKey;
@@ -35,7 +37,15 @@ class MethodInfo {
3537

3638
final Type type = Types.resolve(targetType, targetType, method.getGenericReturnType());
3739

38-
if (type instanceof ParameterizedType
40+
if (isSuspendMethod(method)) {
41+
this.asyncReturnType = true;
42+
this.underlyingReturnType = getKotlinMethodReturnType(method);
43+
if (this.underlyingReturnType == null) {
44+
throw new IllegalArgumentException(String.format(
45+
"Method %s can't have continuation argument, only kotlin method is allowed",
46+
this.configKey));
47+
}
48+
} else if (type instanceof ParameterizedType
3949
&& Types.getRawType(type).isAssignableFrom(CompletableFuture.class)) {
4050
this.asyncReturnType = true;
4151
this.underlyingReturnType = ((ParameterizedType) type).getActualTypeArguments()[0];

core/src/main/java/feign/ReflectiveAsyncFeign.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
*/
1414
package feign;
1515

16+
import kotlin.coroutines.Continuation;
17+
1618
import java.lang.reflect.InvocationHandler;
1719
import java.lang.reflect.InvocationTargetException;
1820
import java.lang.reflect.Method;
@@ -24,6 +26,8 @@
2426
import java.util.concurrent.CompletableFuture;
2527
import java.util.concurrent.ConcurrentHashMap;
2628

29+
import static feign.KotlinExtensions.isSuspendMethod;
30+
2731
@Experimental
2832
public class ReflectiveAsyncFeign<C> extends AsyncFeign<C> {
2933

@@ -63,6 +67,12 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl
6367

6468
setInvocationContext(new AsyncInvocation<>(context, methodInfo));
6569
try {
70+
if (isSuspendMethod(method)) {
71+
CompletableFuture<?> result = (CompletableFuture<?>) method.invoke(instance, args);
72+
Continuation<Object> continuation = (Continuation<Object>) args[args.length - 1];
73+
return KotlinExtensions.awaitRequest(result, continuation);
74+
}
75+
6676
return method.invoke(instance, args);
6777
} catch (final InvocationTargetException e) {
6878
Throwable cause = e.getCause();
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package feign
2+
3+
import com.google.gson.Gson
4+
import com.google.gson.JsonIOException
5+
import feign.codec.Decoder
6+
import feign.codec.Encoder
7+
import feign.codec.ErrorDecoder
8+
import kotlinx.coroutines.runBlocking
9+
import okhttp3.mockwebserver.MockResponse
10+
import okhttp3.mockwebserver.MockWebServer
11+
import org.assertj.core.api.Assertions.assertThat
12+
import org.junit.Test
13+
import java.io.IOException
14+
import java.lang.reflect.Type
15+
16+
class SuspendTest {
17+
@Test
18+
fun shouldRun1(): Unit = runBlocking {
19+
// Arrange
20+
val server = MockWebServer()
21+
val expected = "Hello Worlda"
22+
server.enqueue(MockResponse().setBody(expected))
23+
val client = TestInterfaceAsyncBuilder()
24+
.target("http://localhost:" + server.port)
25+
26+
// Act
27+
val firstOrder = client.findOrder1(orderId = 1)
28+
29+
// Assert
30+
assertThat(firstOrder).isEqualTo(expected)
31+
}
32+
33+
@Test
34+
fun shouldRun2(): Unit = runBlocking {
35+
// Arrange
36+
val server = MockWebServer()
37+
val expected = IceCreamOrder(
38+
id = "HELLO WORLD",
39+
no = 999,
40+
)
41+
server.enqueue(MockResponse().setBody("{ id: '${expected.id}', no: '${expected.no}'}"))
42+
43+
val client = TestInterfaceAsyncBuilder()
44+
.decoder(GsonDecoder())
45+
.target("http://localhost:" + server.port)
46+
47+
// Act
48+
val firstOrder = client.findOrder2(orderId = 1)
49+
50+
// Assert
51+
assertThat(firstOrder).isEqualTo(expected)
52+
}
53+
54+
@Test
55+
fun shouldRun3(): Unit = runBlocking {
56+
// Arrange
57+
val server = MockWebServer()
58+
server.enqueue(MockResponse().setBody("HELLO WORLD"))
59+
60+
val client = TestInterfaceAsyncBuilder()
61+
.target("http://localhost:" + server.port)
62+
63+
// Act
64+
val firstOrder = client.findOrder3(orderId = 1)
65+
66+
// Assert
67+
assertThat(firstOrder).isNull()
68+
}
69+
70+
@Test
71+
fun shouldRun4(): Unit = runBlocking {
72+
// Arrange
73+
val server = MockWebServer()
74+
server.enqueue(MockResponse().setBody("HELLO WORLD"))
75+
76+
val client = TestInterfaceAsyncBuilder()
77+
.target("http://localhost:" + server.port)
78+
79+
// Act
80+
val firstOrder = client.findOrder4(orderId = 1)
81+
82+
// Assert
83+
assertThat(firstOrder).isEqualTo(Unit)
84+
}
85+
86+
internal class GsonDecoder : Decoder {
87+
private val gson = Gson()
88+
89+
override fun decode(response: Response, type: Type): Any? {
90+
if (Void.TYPE == type || response.body() == null) {
91+
return null
92+
}
93+
val reader = response.body().asReader(Util.UTF_8)
94+
return try {
95+
gson.fromJson<Any>(reader, type)
96+
} catch (e: JsonIOException) {
97+
if (e.cause != null && e.cause is IOException) {
98+
throw IOException::class.java.cast(e.cause)
99+
}
100+
throw e
101+
} finally {
102+
Util.ensureClosed(reader)
103+
}
104+
}
105+
}
106+
107+
internal class TestInterfaceAsyncBuilder {
108+
private val delegate = AsyncFeign.asyncBuilder<Void>()
109+
.decoder(Decoder.Default()).encoder { `object`, bodyType, template ->
110+
if (`object` is Map<*, *>) {
111+
template.body(Gson().toJson(`object`))
112+
} else {
113+
template.body(`object`.toString())
114+
}
115+
}
116+
117+
fun requestInterceptor(requestInterceptor: RequestInterceptor?): TestInterfaceAsyncBuilder {
118+
delegate.requestInterceptor(requestInterceptor)
119+
return this
120+
}
121+
122+
fun encoder(encoder: Encoder?): TestInterfaceAsyncBuilder {
123+
delegate.encoder(encoder)
124+
return this
125+
}
126+
127+
fun decoder(decoder: Decoder?): TestInterfaceAsyncBuilder {
128+
delegate.decoder(decoder)
129+
return this
130+
}
131+
132+
fun errorDecoder(errorDecoder: ErrorDecoder?): TestInterfaceAsyncBuilder {
133+
delegate.errorDecoder(errorDecoder)
134+
return this
135+
}
136+
137+
fun dismiss404(): TestInterfaceAsyncBuilder {
138+
delegate.dismiss404()
139+
return this
140+
}
141+
142+
fun queryMapEndcoder(queryMapEncoder: QueryMapEncoder?): TestInterfaceAsyncBuilder {
143+
delegate.queryMapEncoder(queryMapEncoder)
144+
return this
145+
}
146+
147+
fun target(url: String?): TestInterfaceAsync {
148+
return delegate.target(TestInterfaceAsync::class.java, url)
149+
}
150+
}
151+
152+
internal interface TestInterfaceAsync {
153+
@RequestLine("GET /icecream/orders/{orderId}")
154+
suspend fun findOrder1(@Param("orderId") orderId: Int): String
155+
156+
@RequestLine("GET /icecream/orders/{orderId}")
157+
suspend fun findOrder2(@Param("orderId") orderId: Int): IceCreamOrder
158+
159+
@RequestLine("GET /icecream/orders/{orderId}")
160+
suspend fun findOrder3(@Param("orderId") orderId: Int): Void
161+
162+
@RequestLine("GET /icecream/orders/{orderId}")
163+
suspend fun findOrder4(@Param("orderId") orderId: Int): Unit
164+
}
165+
166+
data class IceCreamOrder(
167+
val id: String,
168+
val no: Long,
169+
)
170+
}

0 commit comments

Comments
 (0)