Skip to content

Commit

Permalink
Support kotlin coroutines
Browse files Browse the repository at this point in the history
Resolves: OpenFeign#1565

Inspired by PlaytikaOSS/feign-reactive#486

## TODO

- [ ] Separate Kotlin support module
- [ ] Enhance test case
- [ ] Refactoring
- [ ] Clean up pom.xml
  • Loading branch information
wplong11 committed Aug 1, 2022
1 parent 83c3d69 commit fc89552
Show file tree
Hide file tree
Showing 6 changed files with 262 additions and 2 deletions.
57 changes: 57 additions & 0 deletions core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,35 @@

<properties>
<main.basedir>${project.basedir}/..</main.basedir>
<kotlin.version>1.6.21</kotlin.version>
<kotlinx.coroutines.version>1.6.4</kotlinx.coroutines.version>
</properties>

<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId>
<version>${kotlin.version}</version>
</dependency>

<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
<version>${kotlin.version}</version>
</dependency>

<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-jdk8</artifactId>
<version>${kotlinx.coroutines.version}</version>
</dependency>

<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-coroutines-reactor</artifactId>
<version>${kotlinx.coroutines.version}</version>
</dependency>

<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
Expand Down Expand Up @@ -111,6 +137,37 @@
</execution>
</executions>
</plugin>
<plugin>
<artifactId>kotlin-maven-plugin</artifactId>
<groupId>org.jetbrains.kotlin</groupId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/test/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

Expand Down
4 changes: 3 additions & 1 deletion core/src/main/java/feign/AsyncResponseHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Object> resultFuture,
Expand Down
10 changes: 9 additions & 1 deletion core/src/main/java/feign/MethodInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,15 @@ class MethodInfo {

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

if (type instanceof ParameterizedType
if (MethodKt.isSuspend(method)) {
this.asyncReturnType = true;
this.underlyingReturnType = MethodKt.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];
Expand Down
14 changes: 14 additions & 0 deletions core/src/main/java/feign/MethodKt.kt
Original file line number Diff line number Diff line change
@@ -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.isSuspend(): Boolean =
kotlinFunction?.isSuspend ?: false

internal val Method.kotlinMethodReturnType: Type?
get() = kotlinFunction?.returnType?.javaType
9 changes: 9 additions & 0 deletions core/src/main/java/feign/ReflectiveAsyncFeign.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -63,6 +66,12 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl

setInvocationContext(new AsyncInvocation<>(context, methodInfo));
try {
if (MethodKt.isSuspend(method)) {
CompletableFuture<?> result = (CompletableFuture<?>) method.invoke(instance, args);
Continuation<Object> continuation = (Continuation<Object>) args[args.length - 1];
return FutureKt.await(result, continuation);
}

return method.invoke(instance, args);
} catch (final InvocationTargetException e) {
Throwable cause = e.getCause();
Expand Down
170 changes: 170 additions & 0 deletions core/src/test/java/feign/CoroutineFeignTest.kt
Original file line number Diff line number Diff line change
@@ -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<Any>(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<Void>()
.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,
)
}

0 comments on commit fc89552

Please sign in to comment.