Browse Source
* 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
18 changed files with 1209 additions and 30 deletions
@ -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` |
@ -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> |
@ -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) |
||||
} |
||||
} |
||||
} |
@ -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)); |
||||
} |
||||
|
||||
} |
@ -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> |
@ -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))); |
||||
} |
||||
} |
@ -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); |
||||
} |
||||
} |
@ -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 |
@ -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, |
||||
) |
||||
} |
Loading…
Reference in new issue