diff --git a/core/pom.xml b/core/pom.xml index d355351e7..228c8aa0b 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -54,7 +54,6 @@ com.fasterxml.jackson.core jackson-databind - ${jackson.version} test diff --git a/core/src/main/java/feign/AsyncInvocation.java b/core/src/main/java/feign/AsyncInvocation.java index c8c63da17..f1f3dc2c0 100644 --- a/core/src/main/java/feign/AsyncInvocation.java +++ b/core/src/main/java/feign/AsyncInvocation.java @@ -20,45 +20,45 @@ * A specific invocation of an APU */ @Experimental -class AsyncInvocation { +public class AsyncInvocation { private final C context; private final MethodInfo methodInfo; private final long startNanos; private CompletableFuture 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 responseFuture) { + public void setResponseFuture(CompletableFuture responseFuture) { this.responseFuture = responseFuture; } - CompletableFuture responseFuture() { + public CompletableFuture responseFuture() { return responseFuture; } } diff --git a/core/src/main/java/feign/AsyncResponseHandler.java b/core/src/main/java/feign/AsyncResponseHandler.java index b73439d36..45174c152 100644 --- a/core/src/main/java/feign/AsyncResponseHandler.java +++ b/core/src/main/java/feign/AsyncResponseHandler.java @@ -27,7 +27,7 @@ * handling */ @Experimental -class AsyncResponseHandler { +public class AsyncResponseHandler { private static final long MAX_RESPONSE_BUFFER_SIZE = 8192L; @@ -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 { } 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 resultFuture, - String configKey, - Response response, - Type returnType, - long elapsedTime) { + public void handleResponse(CompletableFuture resultFuture, + String configKey, + Response response, + Type returnType, + long elapsedTime) { // copied fairly liberally from SynchronousMethodHandler boolean shouldClose = true; diff --git a/core/src/main/java/feign/Contract.java b/core/src/main/java/feign/Contract.java index 9f6a48800..aadde000b 100644 --- a/core/src/main/java/feign/Contract.java +++ b/core/src/main/java/feign/Contract.java @@ -130,6 +130,10 @@ protected MethodMetadata parseAndValidateMetadata(Class targetType, Method me 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 diff --git a/core/src/main/java/feign/Feign.java b/core/src/main/java/feign/Feign.java index 6134439ae..1ecc262cd 100644 --- a/core/src/main/java/feign/Feign.java +++ b/core/src/main/java/feign/Feign.java @@ -192,7 +192,7 @@ public Builder addCapability(Capability capability) { /** * 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; } diff --git a/core/src/main/java/feign/Logger.java b/core/src/main/java/feign/Logger.java index 99740e53c..f389139da 100644 --- a/core/src/main/java/feign/Logger.java +++ b/core/src/main/java/feign/Logger.java @@ -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 @@ protected Response logAndRebufferResponse(String configKey, 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 JavaLogger() { /** * 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 diff --git a/core/src/main/java/feign/MethodInfo.java b/core/src/main/java/feign/MethodInfo.java index 608e03c2a..453f5b322 100644 --- a/core/src/main/java/feign/MethodInfo.java +++ b/core/src/main/java/feign/MethodInfo.java @@ -19,12 +19,12 @@ 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; diff --git a/core/src/main/java/feign/Types.java b/core/src/main/java/feign/Types.java index 178abf4e0..2d2ac529c 100644 --- a/core/src/main/java/feign/Types.java +++ b/core/src/main/java/feign/Types.java @@ -199,7 +199,7 @@ static Type getSupertype(Type context, Class contextRawType, Class superty 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 @@ static final class ParameterizedTypeImpl implements ParameterizedType { } } + @Override public Type[] getActualTypeArguments() { return typeArguments.clone(); } + @Override public Type getRawType() { return rawType; } + @Override public Type getOwnerType() { return ownerType; } @@ -395,6 +398,7 @@ private static final class GenericArrayTypeImpl implements GenericArrayType { this.componentType = componentType; } + @Override public Type getGenericComponentType() { return componentType; } @@ -454,10 +458,12 @@ static final class WildcardTypeImpl implements WildcardType { } } + @Override public Type[] getUpperBounds() { return new Type[] {upperBound}; } + @Override public Type[] getLowerBounds() { return lowerBound != null ? new Type[] {lowerBound} : EMPTY_TYPE_ARRAY; } diff --git a/example-github-with-coroutine/README.md b/example-github-with-coroutine/README.md new file mode 100644 index 000000000..384402286 --- /dev/null +++ b/example-github-with-coroutine/README.md @@ -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` diff --git a/example-github-with-coroutine/pom.xml b/example-github-with-coroutine/pom.xml new file mode 100644 index 000000000..9136b768f --- /dev/null +++ b/example-github-with-coroutine/pom.xml @@ -0,0 +1,170 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 11.10-SNAPSHOT + + + feign-example-github-with-coroutine + jar + GitHub Example With Coroutine + + + ${project.basedir}/.. + + + + + io.github.openfeign + feign-core + + + io.github.openfeign + feign-kotlin + + + io.github.openfeign + feign-gson + + + org.apache.commons + commons-exec + 1.3 + test + + + + + package + + + org.apache.maven.plugins + maven-shade-plugin + 3.3.0 + + + package + + shade + + + + + example.github.GitHubExample + + + false + + + + + + org.skife.maven + really-executable-jar-maven-plugin + 1.5.0 + + github + + + + package + + really-executable-jar + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-surefire-plugin.version} + + + true + + + + + integration-test + verify + + + + + + + + + + + + env.TRAVIS_PULL_REQUEST + false + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + false + + + + kotlin-maven-plugin + org.jetbrains.kotlin + ${kotlin.version} + + + compile + + compile + + + + ${project.basedir}/src/main/kotlin + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + + + + + + + + + + diff --git a/example-github-with-coroutine/src/main/java/example/github/GitHubExample.kt b/example-github-with-coroutine/src/main/java/example/github/GitHubExample.kt new file mode 100644 index 000000000..21538fca7 --- /dev/null +++ b/example-github-with-coroutine/src/main/java/example/github/GitHubExample.kt @@ -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 = emptyList(), + val milestone: Int = 0, + val labels: List = emptyList(), + ) + + @RequestLine("GET /users/{username}/repos?sort=full_name") + suspend fun repos(@Param("username") owner: String): List + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + suspend fun contributors(@Param("owner") owner: String, @Param("repo") repo: String): List + + @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() + .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 { + 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) + } + } +} diff --git a/example-github-with-coroutine/src/test/java/feign/example/github/GitHubExampleIT.java b/example-github-with-coroutine/src/test/java/feign/example/github/GitHubExampleIT.java new file mode 100644 index 000000000..4cca29135 --- /dev/null +++ b/example-github-with-coroutine/src/test/java/feign/example/github/GitHubExampleIT.java @@ -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)); + } + +} diff --git a/kotlin/pom.xml b/kotlin/pom.xml new file mode 100644 index 000000000..c37e39245 --- /dev/null +++ b/kotlin/pom.xml @@ -0,0 +1,145 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 11.10-SNAPSHOT + + + feign-kotlin + Feign Kotlin + Feign Kotlin + + + ${project.basedir}/.. + 1.6.20 + 1.6.4 + + + + + ${project.groupId} + feign-core + + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + + org.jetbrains.kotlinx + kotlinx-coroutines-jdk8 + ${kotlinx.coroutines.version} + + + + org.jetbrains.kotlinx + kotlinx-coroutines-reactor + ${kotlinx.coroutines.version} + + + + com.squareup.okhttp3 + mockwebserver + test + + + + com.google.code.gson + gson + test + + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + + + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + default-compile + none + + + + default-testCompile + none + + + java-compile + compile + + compile + + + + java-test-compile + test-compile + + testCompile + + + + + + + diff --git a/kotlin/src/main/java/feign/kotlin/CoroutineFeign.java b/kotlin/src/main/java/feign/kotlin/CoroutineFeign.java new file mode 100644 index 000000000..8bba27a6d --- /dev/null +++ b/kotlin/src/main/java/feign/kotlin/CoroutineFeign.java @@ -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 extends AsyncFeign { + public static CoroutineBuilder 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 implements InvocationHandler { + + private final Map methodInfoLookup = new ConcurrentHashMap<>(); + + private final Class type; + private final T instance; + private final C context; + + CoroutineFeignInvocationHandler(Class 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 continuation = (Continuation) 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 extends BaseBuilder> { + + private AsyncContextSupplier defaultContextSupplier = () -> null; + private AsyncClient client = + new AsyncClient.Default<>( + new Client.Default(null, null), LazyInitializedExecutorService.instance); + + @Deprecated + public CoroutineBuilder defaultContextSupplier(Supplier supplier) { + this.defaultContextSupplier = supplier::get; + return this; + } + + public CoroutineBuilder client(AsyncClient client) { + this.client = client; + return this; + } + + public CoroutineBuilder defaultContextSupplier(AsyncContextSupplier supplier) { + this.defaultContextSupplier = supplier; + return this; + } + + public T target(Class apiType, String url) { + return target(new HardCodedTarget<>(apiType, url)); + } + + public T target(Class apiType, String url, C context) { + return target(new HardCodedTarget<>(apiType, url), context); + } + + public T target(Target target) { + return build().newInstance(target); + } + + public T target(Target target, C context) { + return build().newInstance(target, context); + } + + public CoroutineFeign build() { + super.enrich(); + ThreadLocal> 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> activeContext, + AsyncClient client) { + return (request, options) -> { + final Response result = Response.builder().status(200).request(request).build(); + + final AsyncInvocation 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> activeContext, + Logger logger, + Level logLevel, + AsyncResponseHandler responseHandler) { + return (response, type) -> { + final AsyncInvocation invocationContext = activeContext.get(); + + final CompletableFuture 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> activeContextHolder; + + protected CoroutineFeign( + Feign feign, + AsyncContextSupplier defaultContextSupplier, + ThreadLocal> contextHolder) { + super(feign, defaultContextSupplier); + this.activeContextHolder = contextHolder; + } + + protected void setInvocationContext(AsyncInvocation 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 wrap(Class 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))); + } +} diff --git a/kotlin/src/main/java/feign/kotlin/KotlinMethodInfo.java b/kotlin/src/main/java/feign/kotlin/KotlinMethodInfo.java new file mode 100644 index 000000000..3e953a7c8 --- /dev/null +++ b/kotlin/src/main/java/feign/kotlin/KotlinMethodInfo.java @@ -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); + } +} diff --git a/kotlin/src/main/kotlin/feign/kotlin/MethodKt.kt b/kotlin/src/main/kotlin/feign/kotlin/MethodKt.kt new file mode 100644 index 000000000..bcf652828 --- /dev/null +++ b/kotlin/src/main/kotlin/feign/kotlin/MethodKt.kt @@ -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 diff --git a/kotlin/src/test/kotlin/feign/kotlin/CoroutineFeignTest.kt b/kotlin/src/test/kotlin/feign/kotlin/CoroutineFeignTest.kt new file mode 100644 index 000000000..41943bc48 --- /dev/null +++ b/kotlin/src/test/kotlin/feign/kotlin/CoroutineFeignTest.kt @@ -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(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() + .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, + ) +} diff --git a/pom.xml b/pom.xml index bbedb2c03..0d563869e 100644 --- a/pom.xml +++ b/pom.xml @@ -48,8 +48,10 @@ reactive dropwizard-metrics4 dropwizard-metrics5 + kotlin micrometer example-github + example-github-with-coroutine example-wikipedia mock apt-test-generator @@ -274,6 +276,12 @@ ${project.version} + + ${project.groupId} + feign-kotlin + ${project.version} + + ${project.groupId} feign-micrometer