Browse Source

Support kotlin coroutines (#1706)

* 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

* Apply optional dependency to kotlin support related dependency

* Seperate Kotlin support module

* Remove unused code from ClassUtils.java

* Remove unused code from ClassUtils.java

* Refactor KotlinDetector

* Move ClassUtils location into KotlinDetector

* Move KotlinDetector location

* Format code

* First attempt to move kotlin work to it's own isolated module

* Coroutine Feign using AyncFeign

* Coroutine Feign using AyncFeign

* Refactor suspending function  detect logic

- Remove KotlinDetector.java
- Add Method.isSuspend extension function

* Cleanup CoroutineFeignTest test code format

* Fix suspend function contract parsing error when using http body

* Rename test names to be meaningful

* Add Github Example With Coroutine

- Copy of GithubExample

* Remove unnecessary dependency

https://github.com/OpenFeign/feign/pull/1706/files#r965389041

Co-authored-by: Marvin Froeder <velo.br@gmail.com>
Co-authored-by: Marvin Froeder <velo@users.noreply.github.com>
pull/1750/head
Donghyeon Kim 2 years ago committed by GitHub
parent
commit
39ed8ef2a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      core/pom.xml
  2. 18
      core/src/main/java/feign/AsyncInvocation.java
  3. 20
      core/src/main/java/feign/AsyncResponseHandler.java
  4. 4
      core/src/main/java/feign/Contract.java
  5. 2
      core/src/main/java/feign/Feign.java
  6. 16
      core/src/main/java/feign/Logger.java
  7. 4
      core/src/main/java/feign/MethodInfo.java
  8. 8
      core/src/main/java/feign/Types.java
  9. 10
      example-github-with-coroutine/README.md
  10. 170
      example-github-with-coroutine/pom.xml
  11. 133
      example-github-with-coroutine/src/main/java/example/github/GitHubExample.kt
  12. 45
      example-github-with-coroutine/src/test/java/feign/example/github/GitHubExampleIT.java
  13. 145
      kotlin/pom.xml
  14. 358
      kotlin/src/main/java/feign/kotlin/CoroutineFeign.java
  15. 57
      kotlin/src/main/java/feign/kotlin/KotlinMethodInfo.java
  16. 27
      kotlin/src/main/kotlin/feign/kotlin/MethodKt.kt
  17. 213
      kotlin/src/test/kotlin/feign/kotlin/CoroutineFeignTest.kt
  18. 8
      pom.xml

1
core/pom.xml

@ -54,7 +54,6 @@ @@ -54,7 +54,6 @@
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
<scope>test</scope>
</dependency>

18
core/src/main/java/feign/AsyncInvocation.java

@ -20,45 +20,45 @@ import java.util.concurrent.CompletableFuture; @@ -20,45 +20,45 @@ import java.util.concurrent.CompletableFuture;
* A specific invocation of an APU
*/
@Experimental
class AsyncInvocation<C> {
public class AsyncInvocation<C> {
private final C context;
private final MethodInfo methodInfo;
private final long startNanos;
private CompletableFuture<Response> responseFuture;
AsyncInvocation(C context, MethodInfo methodInfo) {
public AsyncInvocation(C context, MethodInfo methodInfo) {
super();
this.context = context;
this.methodInfo = methodInfo;
this.startNanos = System.nanoTime();
}
C context() {
public C context() {
return context;
}
String configKey() {
public String configKey() {
return methodInfo.configKey();
}
long startNanos() {
public long startNanos() {
return startNanos;
}
Type underlyingType() {
public Type underlyingType() {
return methodInfo.underlyingReturnType();
}
boolean isAsyncReturnType() {
public boolean isAsyncReturnType() {
return methodInfo.isAsyncReturnType();
}
void setResponseFuture(CompletableFuture<Response> responseFuture) {
public void setResponseFuture(CompletableFuture<Response> responseFuture) {
this.responseFuture = responseFuture;
}
CompletableFuture<Response> responseFuture() {
public CompletableFuture<Response> responseFuture() {
return responseFuture;
}
}

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

@ -27,7 +27,7 @@ import java.util.concurrent.CompletableFuture; @@ -27,7 +27,7 @@ import java.util.concurrent.CompletableFuture;
* handling
*/
@Experimental
class AsyncResponseHandler {
public class AsyncResponseHandler {
private static final long MAX_RESPONSE_BUFFER_SIZE = 8192L;
@ -41,8 +41,9 @@ class AsyncResponseHandler { @@ -41,8 +41,9 @@ class AsyncResponseHandler {
private final ResponseInterceptor responseInterceptor;
AsyncResponseHandler(Level logLevel, Logger logger, Decoder decoder, ErrorDecoder errorDecoder,
boolean dismiss404, boolean closeAfterDecode, ResponseInterceptor responseInterceptor) {
public AsyncResponseHandler(Level logLevel, Logger logger, Decoder decoder,
ErrorDecoder errorDecoder, boolean dismiss404, boolean closeAfterDecode,
ResponseInterceptor responseInterceptor) {
super();
this.logLevel = logLevel;
this.logger = logger;
@ -54,14 +55,15 @@ class AsyncResponseHandler { @@ -54,14 +55,15 @@ class AsyncResponseHandler {
}
boolean isVoidType(Type returnType) {
return Void.class == returnType || void.class == returnType;
return Void.class == returnType || void.class == returnType
|| returnType.getTypeName().equals("kotlin.Unit");
}
void handleResponse(CompletableFuture<Object> resultFuture,
String configKey,
Response response,
Type returnType,
long elapsedTime) {
public void handleResponse(CompletableFuture<Object> resultFuture,
String configKey,
Response response,
Type returnType,
long elapsedTime) {
// copied fairly liberally from SynchronousMethodHandler
boolean shouldClose = true;

4
core/src/main/java/feign/Contract.java

@ -130,6 +130,10 @@ public interface Contract { @@ -130,6 +130,10 @@ public interface Contract {
data.ignoreParamater(i);
}
if ("kotlin.coroutines.Continuation".equals(parameterTypes[i].getName())) {
data.ignoreParamater(i);
}
if (parameterTypes[i] == URI.class) {
data.urlIndex(i);
} else if (!isHttpAnnotation

2
core/src/main/java/feign/Feign.java

@ -192,7 +192,7 @@ public abstract class Feign { @@ -192,7 +192,7 @@ public abstract class Feign {
/**
* Internal - used to indicate that the decoder should be immediately called
*/
Builder forceDecoding() {
public /* FIXME should not be public */ Builder forceDecoding() {
this.forceDecoding = true;
return this;
}

16
core/src/main/java/feign/Logger.java

@ -13,14 +13,16 @@ @@ -13,14 +13,16 @@
*/
package feign;
import static feign.Util.UTF_8;
import static feign.Util.decodeOrDefault;
import static feign.Util.valuesOrEmpty;
import static java.util.Objects.nonNull;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.logging.FileHandler;
import java.util.logging.LogRecord;
import java.util.logging.SimpleFormatter;
import static feign.Util.*;
import static java.util.Objects.nonNull;
/**
* Simple logging abstraction for debug messages. Adapted from {@code retrofit.RestAdapter.Log}.
@ -137,10 +139,10 @@ public abstract class Logger { @@ -137,10 +139,10 @@ public abstract class Logger {
return response;
}
protected IOException logIOException(String configKey,
Level logLevel,
IOException ioe,
long elapsedTime) {
public IOException logIOException(String configKey,
Level logLevel,
IOException ioe,
long elapsedTime) {
log(configKey, "<--- ERROR %s: %s (%sms)", ioe.getClass().getSimpleName(), ioe.getMessage(),
elapsedTime);
if (logLevel.ordinal() >= Level.FULL.ordinal()) {
@ -217,7 +219,7 @@ public abstract class Logger { @@ -217,7 +219,7 @@ public abstract class Logger {
/**
* Constructor for JavaLogger class
*
*
* @param loggerName a name for the logger. This should be a dot-separated name and should
* normally be based on the package name or class name of the subsystem, such as java.net
* or javax.swing

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

@ -19,12 +19,12 @@ import java.lang.reflect.Type; @@ -19,12 +19,12 @@ import java.lang.reflect.Type;
import java.util.concurrent.CompletableFuture;
@Experimental
class MethodInfo {
public class MethodInfo {
private final String configKey;
private final Type underlyingReturnType;
private final boolean asyncReturnType;
MethodInfo(String configKey, Type underlyingReturnType, boolean asyncReturnType) {
protected MethodInfo(String configKey, Type underlyingReturnType, boolean asyncReturnType) {
this.configKey = configKey;
this.underlyingReturnType = underlyingReturnType;
this.asyncReturnType = asyncReturnType;

8
core/src/main/java/feign/Types.java

@ -199,7 +199,7 @@ public final class Types { @@ -199,7 +199,7 @@ public final class Types {
getGenericSupertype(context, contextRawType, supertype));
}
static Type resolve(Type context, Class<?> contextRawType, Type toResolve) {
public static Type resolve(Type context, Class<?> contextRawType, Type toResolve) {
// This implementation is made a little more complicated in an attempt to avoid object-creation.
while (true) {
if (toResolve instanceof TypeVariable) {
@ -350,14 +350,17 @@ public final class Types { @@ -350,14 +350,17 @@ public final class Types {
}
}
@Override
public Type[] getActualTypeArguments() {
return typeArguments.clone();
}
@Override
public Type getRawType() {
return rawType;
}
@Override
public Type getOwnerType() {
return ownerType;
}
@ -395,6 +398,7 @@ public final class Types { @@ -395,6 +398,7 @@ public final class Types {
this.componentType = componentType;
}
@Override
public Type getGenericComponentType() {
return componentType;
}
@ -454,10 +458,12 @@ public final class Types { @@ -454,10 +458,12 @@ public final class Types {
}
}
@Override
public Type[] getUpperBounds() {
return new Type[] {upperBound};
}
@Override
public Type[] getLowerBounds() {
return lowerBound != null ? new Type[] {lowerBound} : EMPTY_TYPE_ARRAY;
}

10
example-github-with-coroutine/README.md

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
GitHub Example With Coroutine
===================
This is an example of a simple json client.
=== Building example with Gradle
Install and run `gradle` to produce `build/github`
=== Building example with Maven
Install and run `mvn` to produce `target/github`

170
example-github-with-coroutine/pom.xml

@ -0,0 +1,170 @@ @@ -0,0 +1,170 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2012-2022 The Feign Authors
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License
is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
or implied. See the License for the specific language governing permissions and limitations under
the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.openfeign</groupId>
<artifactId>parent</artifactId>
<version>11.10-SNAPSHOT</version>
</parent>
<artifactId>feign-example-github-with-coroutine</artifactId>
<packaging>jar</packaging>
<name>GitHub Example With Coroutine</name>
<properties>
<main.basedir>${project.basedir}/..</main.basedir>
</properties>
<dependencies>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-core</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-kotlin</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-gson</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-exec</artifactId>
<version>1.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<defaultGoal>package</defaultGoal>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.3.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>example.github.GitHubExample</mainClass>
</transformer>
</transformers>
<createDependencyReducedPom>false</createDependencyReducedPom>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.skife.maven</groupId>
<artifactId>really-executable-jar-maven-plugin</artifactId>
<version>1.5.0</version>
<configuration>
<programFile>github</programFile>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>really-executable-jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
<configuration>
<!--
Travis does not inject GITHUB_TOKEN when building PRs, which will make module fail
https://docs.travis-ci.com/user/environment-variables/#defining-encrypted-variables-in-travisyml
ignoring test errors for any build that is not master
-->
<testFailureIgnore>true</testFailureIgnore>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<activation>
<property>
<name>env.TRAVIS_PULL_REQUEST</name>
<value>false</value>
</property>
</activation>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<testFailureIgnore>false</testFailureIgnore>
</configuration>
</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>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

133
example-github-with-coroutine/src/main/java/example/github/GitHubExample.kt

@ -0,0 +1,133 @@ @@ -0,0 +1,133 @@
/*
* Copyright 2012-2022 The Feign Authors
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package example.github
import feign.Logger
import feign.Logger.ErrorLogger
import feign.Param
import feign.Request
import feign.RequestLine
import feign.Response
import feign.codec.Decoder
import feign.codec.Encoder
import feign.codec.ErrorDecoder
import feign.gson.GsonEncoder
import feign.kotlin.CoroutineFeign
import java.io.IOException
import java.util.concurrent.TimeUnit
suspend fun main() {
val github = GitHub.connect()
println("Let's fetch and print a list of the contributors to this org.")
val contributors = github.contributors("openfeign")
for (contributor in contributors) {
println(contributor)
}
println("Now, let's cause an error.")
try {
github.contributors("openfeign", "some-unknown-project")
} catch (e: GitHubClientError) {
println(e.message)
}
println("Now, try to create an issue - which will also cause an error.")
try {
val issue = GitHub.Issue(
title = "The title",
body = "Some Text",
)
github.createIssue(issue, "OpenFeign", "SomeRepo")
} catch (e: GitHubClientError) {
println(e.message)
}
}
/**
* Inspired by `com.example.retrofit.GitHubClient`
*/
interface GitHub {
data class Repository(
val name: String
)
data class Contributor(
val login: String
)
data class Issue(
val title: String,
val body: String,
val assignees: List<String> = emptyList(),
val milestone: Int = 0,
val labels: List<String> = emptyList(),
)
@RequestLine("GET /users/{username}/repos?sort=full_name")
suspend fun repos(@Param("username") owner: String): List<Repository>
@RequestLine("GET /repos/{owner}/{repo}/contributors")
suspend fun contributors(@Param("owner") owner: String, @Param("repo") repo: String): List<Contributor>
@RequestLine("POST /repos/{owner}/{repo}/issues")
suspend fun createIssue(issue: Issue, @Param("owner") owner: String, @Param("repo") repo: String)
companion object {
fun connect(): GitHub {
val decoder: Decoder = feign.gson.GsonDecoder()
val encoder: Encoder = GsonEncoder()
return CoroutineFeign.coBuilder<Unit>()
.encoder(encoder)
.decoder(decoder)
.errorDecoder(GitHubErrorDecoder(decoder))
.logger(ErrorLogger())
.logLevel(Logger.Level.BASIC)
.requestInterceptor { template ->
template.header(
// not available when building PRs...
// https://docs.travis-ci.com/user/environment-variables/#defining-encrypted-variables-in-travisyml
"Authorization",
"token 383f1c1b474d8f05a21e7964976ab0d403fee071");
}
.options(Request.Options(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true))
.target(GitHub::class.java, "https://api.github.com")
}
}
}
/** Lists all contributors for all repos owned by a user. */
suspend fun GitHub.contributors(owner: String): List<String> {
return repos(owner)
.flatMap { contributors(owner, it.name) }
.map { it.login }
.distinct()
}
internal class GitHubClientError() : RuntimeException() {
override val message: String? = null
}
internal class GitHubErrorDecoder(
private val decoder: Decoder
) : ErrorDecoder {
private val defaultDecoder: ErrorDecoder = ErrorDecoder.Default()
override fun decode(methodKey: String, response: Response): Exception {
return try {
// must replace status by 200 other GSONDecoder returns null
val response = response.toBuilder().status(200).build()
decoder.decode(response, GitHubClientError::class.java) as Exception
} catch (fallbackToDefault: IOException) {
defaultDecoder.decode(methodKey, response)
}
}
}

45
example-github-with-coroutine/src/test/java/feign/example/github/GitHubExampleIT.java

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
/*
* Copyright 2012-2022 The Feign Authors
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package feign.example.github;
import static org.hamcrest.MatcherAssert.assertThat;
import org.apache.commons.exec.CommandLine;
import org.apache.commons.exec.DefaultExecutor;
import org.hamcrest.CoreMatchers;
import org.junit.Test;
import java.io.File;
import java.util.Arrays;
/**
* Run main for {@link GitHubExampleIT}
*/
public class GitHubExampleIT {
@Test
public void runMain() throws Exception {
final String jar = Arrays.stream(new File("target").listFiles())
.filter(file -> file.getName().startsWith("feign-example-github-with-coroutine")
&& file.getName().endsWith(".jar"))
.findFirst()
.map(File::getAbsolutePath)
.get();
final String line = "java -jar " + jar;
final CommandLine cmdLine = CommandLine.parse(line);
final int exitValue = new DefaultExecutor().execute(cmdLine);
assertThat(exitValue, CoreMatchers.equalTo(0));
}
}

145
kotlin/pom.xml

@ -0,0 +1,145 @@ @@ -0,0 +1,145 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2012-2022 The Feign Authors
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License
is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
or implied. See the License for the specific language governing permissions and limitations under
the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>io.github.openfeign</groupId>
<artifactId>parent</artifactId>
<version>11.10-SNAPSHOT</version>
</parent>
<artifactId>feign-kotlin</artifactId>
<name>Feign Kotlin</name>
<description>Feign Kotlin</description>
<properties>
<main.basedir>${project.basedir}/..</main.basedir>
<kotlin.version>1.6.20</kotlin.version>
<kotlinx.coroutines.version>1.6.4</kotlinx.coroutines.version>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>feign-core</artifactId>
</dependency>
<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>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<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>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<!-- Replacing default-compile as it is treated specially by maven -->
<execution>
<id>default-compile</id>
<phase>none</phase>
</execution>
<!-- Replacing default-testCompile as it is treated specially by maven -->
<execution>
<id>default-testCompile</id>
<phase>none</phase>
</execution>
<execution>
<id>java-compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
<execution>
<id>java-test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

358
kotlin/src/main/java/feign/kotlin/CoroutineFeign.java

@ -0,0 +1,358 @@ @@ -0,0 +1,358 @@
/*
* Copyright 2012-2022 The Feign Authors
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package feign.kotlin;
import feign.AsyncClient;
import feign.AsyncContextSupplier;
import feign.AsyncFeign;
import feign.AsyncInvocation;
import feign.AsyncJoinException;
import feign.AsyncResponseHandler;
import feign.BaseBuilder;
import feign.Capability;
import feign.Client;
import feign.Experimental;
import feign.Feign;
import feign.Logger;
import feign.Logger.Level;
import feign.MethodInfo;
import feign.Response;
import feign.Target;
import feign.Target.HardCodedTarget;
import feign.codec.Decoder;
import java.io.IOException;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Proxy;
import java.lang.reflect.Type;
import java.lang.reflect.WildcardType;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import kotlin.coroutines.Continuation;
import kotlinx.coroutines.future.FutureKt;
@Experimental
public class CoroutineFeign<C> extends AsyncFeign<C> {
public static <C> CoroutineBuilder<C> coBuilder() {
return new CoroutineBuilder<>();
}
private static class LazyInitializedExecutorService {
private static final ExecutorService instance =
Executors.newCachedThreadPool(
r -> {
final Thread result = new Thread(r);
result.setDaemon(true);
return result;
});
}
private class CoroutineFeignInvocationHandler<T> implements InvocationHandler {
private final Map<Method, MethodInfo> methodInfoLookup = new ConcurrentHashMap<>();
private final Class<T> type;
private final T instance;
private final C context;
CoroutineFeignInvocationHandler(Class<T> type, T instance, C context) {
this.type = type;
this.instance = instance;
this.context = context;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("equals".equals(method.getName()) && method.getParameterCount() == 1) {
try {
final Object otherHandler =
args.length > 0 && args[0] != null ? Proxy.getInvocationHandler(args[0]) : null;
return equals(otherHandler);
} catch (final IllegalArgumentException e) {
return false;
}
} else if ("hashCode".equals(method.getName()) && method.getParameterCount() == 0) {
return hashCode();
} else if ("toString".equals(method.getName()) && method.getParameterCount() == 0) {
return toString();
}
final MethodInfo methodInfo =
methodInfoLookup.computeIfAbsent(method, m -> KotlinMethodInfo.createInstance(type, m));
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();
if (cause instanceof AsyncJoinException) {
cause = cause.getCause();
}
throw cause;
} finally {
clearInvocationContext();
}
}
@SuppressWarnings("unchecked")
@Override
public boolean equals(Object obj) {
if (obj instanceof CoroutineFeignInvocationHandler) {
final CoroutineFeignInvocationHandler<?> other = (CoroutineFeignInvocationHandler<?>) obj;
return instance.equals(other.instance);
}
return false;
}
@Override
public int hashCode() {
return instance.hashCode();
}
@Override
public String toString() {
return instance.toString();
}
}
public static class CoroutineBuilder<C> extends BaseBuilder<CoroutineBuilder<C>> {
private AsyncContextSupplier<C> defaultContextSupplier = () -> null;
private AsyncClient<C> client =
new AsyncClient.Default<>(
new Client.Default(null, null), LazyInitializedExecutorService.instance);
@Deprecated
public CoroutineBuilder<C> defaultContextSupplier(Supplier<C> supplier) {
this.defaultContextSupplier = supplier::get;
return this;
}
public CoroutineBuilder<C> client(AsyncClient<C> client) {
this.client = client;
return this;
}
public CoroutineBuilder<C> defaultContextSupplier(AsyncContextSupplier<C> supplier) {
this.defaultContextSupplier = supplier;
return this;
}
public <T> T target(Class<T> apiType, String url) {
return target(new HardCodedTarget<>(apiType, url));
}
public <T> T target(Class<T> apiType, String url, C context) {
return target(new HardCodedTarget<>(apiType, url), context);
}
public <T> T target(Target<T> target) {
return build().newInstance(target);
}
public <T> T target(Target<T> target, C context) {
return build().newInstance(target, context);
}
public CoroutineFeign<C> build() {
super.enrich();
ThreadLocal<AsyncInvocation<C>> activeContextHolder = new ThreadLocal<>();
AsyncResponseHandler responseHandler =
(AsyncResponseHandler) Capability.enrich(
new AsyncResponseHandler(
logLevel,
logger,
decoder,
errorDecoder,
dismiss404,
closeAfterDecode,
responseInterceptor),
AsyncResponseHandler.class,
capabilities);
return new CoroutineFeign<>(
Feign.builder()
.logLevel(logLevel)
.client(stageExecution(activeContextHolder, client))
.decoder(stageDecode(activeContextHolder, logger, logLevel, responseHandler))
.forceDecoding() // force all handling through stageDecode
.contract(contract)
.logger(logger)
.encoder(encoder)
.queryMapEncoder(queryMapEncoder)
.options(options)
.requestInterceptors(requestInterceptors)
.responseInterceptor(responseInterceptor)
.invocationHandlerFactory(invocationHandlerFactory)
.build(),
defaultContextSupplier,
activeContextHolder);
}
private Client stageExecution(
ThreadLocal<AsyncInvocation<C>> activeContext,
AsyncClient<C> client) {
return (request, options) -> {
final Response result = Response.builder().status(200).request(request).build();
final AsyncInvocation<C> invocationContext = activeContext.get();
invocationContext.setResponseFuture(
client.execute(request, options, Optional.ofNullable(invocationContext.context())));
return result;
};
}
// from SynchronousMethodHandler
long elapsedTime(long start) {
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
}
private Decoder stageDecode(
ThreadLocal<AsyncInvocation<C>> activeContext,
Logger logger,
Level logLevel,
AsyncResponseHandler responseHandler) {
return (response, type) -> {
final AsyncInvocation<C> invocationContext = activeContext.get();
final CompletableFuture<Object> result = new CompletableFuture<>();
invocationContext
.responseFuture()
.whenComplete(
(r, t) -> {
final long elapsedTime = elapsedTime(invocationContext.startNanos());
if (t != null) {
if (logLevel != Logger.Level.NONE && t instanceof IOException) {
final IOException e = (IOException) t;
logger.logIOException(
invocationContext.configKey(), logLevel, e, elapsedTime);
}
result.completeExceptionally(t);
} else {
responseHandler.handleResponse(
result,
invocationContext.configKey(),
r,
invocationContext.underlyingType(),
elapsedTime);
}
});
result.whenComplete(
(r, t) -> {
if (result.isCancelled()) {
invocationContext.responseFuture().cancel(true);
}
});
if (invocationContext.isAsyncReturnType()) {
return result;
}
try {
return result.join();
} catch (final CompletionException e) {
final Response r = invocationContext.responseFuture().join();
Throwable cause = e.getCause();
if (cause == null) {
cause = e;
}
throw new AsyncJoinException(r.status(), cause.getMessage(), r.request(), cause);
}
};
}
}
protected ThreadLocal<AsyncInvocation<C>> activeContextHolder;
protected CoroutineFeign(
Feign feign,
AsyncContextSupplier<C> defaultContextSupplier,
ThreadLocal<AsyncInvocation<C>> contextHolder) {
super(feign, defaultContextSupplier);
this.activeContextHolder = contextHolder;
}
protected void setInvocationContext(AsyncInvocation<C> invocationContext) {
activeContextHolder.set(invocationContext);
}
protected void clearInvocationContext() {
activeContextHolder.remove();
}
private String getFullMethodName(Class<?> type, Type retType, Method m) {
return retType.getTypeName() + " " + type.toGenericString() + "." + m.getName();
}
@Override
protected <T> T wrap(Class<T> type, T instance, C context) {
if (!type.isInterface()) {
throw new IllegalArgumentException("Type must be an interface: " + type);
}
for (final Method m : type.getMethods()) {
final Class<?> retType = m.getReturnType();
if (!CompletableFuture.class.isAssignableFrom(retType)) {
continue; // synchronous case
}
if (retType != CompletableFuture.class) {
throw new IllegalArgumentException(
"Method return type is not CompleteableFuture: " + getFullMethodName(type, retType, m));
}
final Type genRetType = m.getGenericReturnType();
if (!ParameterizedType.class.isInstance(genRetType)) {
throw new IllegalArgumentException(
"Method return type is not parameterized: " + getFullMethodName(type, genRetType, m));
}
if (WildcardType.class.isInstance(
ParameterizedType.class.cast(genRetType).getActualTypeArguments()[0])) {
throw new IllegalArgumentException(
"Wildcards are not supported for return-type parameters: "
+ getFullMethodName(type, genRetType, m));
}
}
return type.cast(
Proxy.newProxyInstance(
type.getClassLoader(),
new Class<?>[] {type},
new CoroutineFeignInvocationHandler<>(type, instance, context)));
}
}

57
kotlin/src/main/java/feign/kotlin/KotlinMethodInfo.java

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
/*
* Copyright 2012-2022 The Feign Authors
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package feign.kotlin;
import feign.Feign;
import feign.MethodInfo;
import feign.Types;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.concurrent.CompletableFuture;
class KotlinMethodInfo extends MethodInfo {
KotlinMethodInfo(String configKey, Type underlyingReturnType, boolean asyncReturnType) {
super(configKey, underlyingReturnType, asyncReturnType);
}
static KotlinMethodInfo createInstance(Class<?> targetType, Method method) {
String configKey = Feign.configKey(targetType, method);
final Type type = Types.resolve(targetType, targetType, method.getGenericReturnType());
Type underlyingReturnType;
boolean asyncReturnType;
if (MethodKt.isSuspend(method)) {
asyncReturnType = true;
underlyingReturnType = MethodKt.getKotlinMethodReturnType(method);
if (underlyingReturnType == null) {
throw new IllegalArgumentException(
String.format(
"Method %s can't have continuation argument, only kotlin method is allowed",
configKey));
}
} else if (type instanceof ParameterizedType
&& Types.getRawType(type).isAssignableFrom(CompletableFuture.class)) {
asyncReturnType = true;
underlyingReturnType = ((ParameterizedType) type).getActualTypeArguments()[0];
} else {
asyncReturnType = false;
underlyingReturnType = type;
}
return new KotlinMethodInfo(configKey, underlyingReturnType, asyncReturnType);
}
}

27
kotlin/src/main/kotlin/feign/kotlin/MethodKt.kt

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
/*
* Copyright 2012-2022 The Feign Authors
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
@file:JvmName("MethodKt")
package feign.kotlin
import java.lang.reflect.Method
import java.lang.reflect.Type
import kotlin.reflect.jvm.javaType
import kotlin.reflect.jvm.kotlinFunction
val Method.isSuspend: Boolean
get() = kotlinFunction?.isSuspend == true
val Method.kotlinMethodReturnType: Type?
get() = kotlinFunction?.returnType?.javaType

213
kotlin/src/test/kotlin/feign/kotlin/CoroutineFeignTest.kt

@ -0,0 +1,213 @@ @@ -0,0 +1,213 @@
/*
* Copyright 2012-2022 The Feign Authors
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
package feign.kotlin
import com.google.gson.Gson
import com.google.gson.JsonIOException
import feign.Param
import feign.QueryMapEncoder
import feign.RequestInterceptor
import feign.RequestLine
import feign.Response
import feign.Util
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 CoroutineFeignTest {
@Test
fun `sut should run correctly when response is basic type`(): 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: String = client.findOrderThatReturningBasicType(orderId = 1)
// Assert
assertThat(firstOrder).isEqualTo(expected)
}
@Test
fun `sut should run correctly when response is complex type`(): 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: IceCreamOrder = client.findOrderThatReturningComplexType(orderId = 1)
// Assert
assertThat(firstOrder).isEqualTo(expected)
}
@Test
fun `sut should run correctly when empty response is represented by java_lang_Void`(): Unit = runBlocking {
// Arrange
val server = MockWebServer()
server.enqueue(MockResponse().setBody("HELLO WORLD"))
val client = TestInterfaceAsyncBuilder()
.target("http://localhost:" + server.port)
// Act
val firstOrder: Void = client.findOrderThatReturningVoid(orderId = 1)
// Assert
assertThat(firstOrder).isNull()
}
@Test
fun `sut should run correctly when empty response is represented by kotlin_Unit`(): Unit = runBlocking {
// Arrange
val server = MockWebServer()
server.enqueue(MockResponse().setBody("HELLO WORLD"))
val client = TestInterfaceAsyncBuilder()
.target("http://localhost:" + server.port)
// Act
val firstOrder: Unit = client.findOrderThatReturningUnit(orderId = 1)
// Assert
assertThat(firstOrder).isEqualTo(Unit)
}
@Test
fun `sut should run correctly when using http body`(): Unit = runBlocking {
// Arrange
val server = MockWebServer()
server.enqueue(MockResponse().setBody("HELLO WORLD"))
val client = TestInterfaceAsyncBuilder()
.target("http://localhost:" + server.port)
// Act
val firstOrder = client.findOrderWithHttpBody(
order = IceCreamOrder(
id = "1",
no = 2,
)
)
// 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 = CoroutineFeign.coBuilder<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 findOrderThatReturningBasicType(@Param("orderId") orderId: Int): String
@RequestLine("GET /icecream/orders/{orderId}")
suspend fun findOrderThatReturningComplexType(@Param("orderId") orderId: Int): IceCreamOrder
@RequestLine("GET /icecream/orders/{orderId}")
suspend fun findOrderThatReturningVoid(@Param("orderId") orderId: Int): Void
@RequestLine("GET /icecream/orders/{orderId}")
suspend fun findOrderThatReturningUnit(@Param("orderId") orderId: Int): Unit
@RequestLine("POST /icecream/orders")
suspend fun findOrderWithHttpBody(order: IceCreamOrder): Unit
}
data class IceCreamOrder(
val id: String,
val no: Long,
)
}

8
pom.xml

@ -48,8 +48,10 @@ @@ -48,8 +48,10 @@
<module>reactive</module>
<module>dropwizard-metrics4</module>
<module>dropwizard-metrics5</module>
<module>kotlin</module>
<module>micrometer</module>
<module>example-github</module>
<module>example-github-with-coroutine</module>
<module>example-wikipedia</module>
<module>mock</module>
<module>apt-test-generator</module>
@ -274,6 +276,12 @@ @@ -274,6 +276,12 @@
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>feign-kotlin</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>feign-micrometer</artifactId>

Loading…
Cancel
Save