From 49ea8a54d4865ffe8adbdc5d298dae8ee73fc782 Mon Sep 17 00:00:00 2001 From: "Davis, Kevin" Date: Thu, 13 Sep 2018 08:07:33 -0400 Subject: [PATCH] Reactive Wrapper Support Adding support for Reactive Streams `Publisher` return types. Support is provided through the `ReactiveInvocationHandler` and follows a similar pattern used by `feign-hystrix`. Each method invocation is wrapped in a `Callable`, which is then wrapped into the appropriate Reactive Streams `Publisher`, as defined in the builder and the return type of the method. This approach is not "reactive all the way down". The requests are still executed via a regular `Client` and are synchronous. However, it is possible to still take advantage of the backpressure, scheduling, and functional support provided by the library implementations. Limitations: Streams are not supported and Iterable responses are not treated reactively. Iterables must be explicitly cast into a reactive type. Reworked Builders and removed the need for the enumerator --- core/src/main/java/feign/Types.java | 4 +- pom.xml | 1 + reactive/README.md | 113 ++++++ reactive/pom.xml | 95 +++++ .../reactive/ReactiveDelegatingContract.java | 81 +++++ .../java/feign/reactive/ReactiveFeign.java | 61 ++++ .../reactive/ReactiveInvocationHandler.java | 111 ++++++ .../java/feign/reactive/ReactorFeign.java | 53 +++ .../reactive/ReactorInvocationHandler.java | 44 +++ .../main/java/feign/reactive/RxJavaFeign.java | 53 +++ .../reactive/RxJavaInvocationHandler.java | 36 ++ .../ReactiveDelegatingContractTest.java | 85 +++++ .../ReactiveFeignIntegrationTest.java | 342 ++++++++++++++++++ .../ReactiveInvocationHandlerTest.java | 132 +++++++ 14 files changed, 1209 insertions(+), 2 deletions(-) create mode 100644 reactive/README.md create mode 100644 reactive/pom.xml create mode 100644 reactive/src/main/java/feign/reactive/ReactiveDelegatingContract.java create mode 100644 reactive/src/main/java/feign/reactive/ReactiveFeign.java create mode 100644 reactive/src/main/java/feign/reactive/ReactiveInvocationHandler.java create mode 100644 reactive/src/main/java/feign/reactive/ReactorFeign.java create mode 100644 reactive/src/main/java/feign/reactive/ReactorInvocationHandler.java create mode 100644 reactive/src/main/java/feign/reactive/RxJavaFeign.java create mode 100644 reactive/src/main/java/feign/reactive/RxJavaInvocationHandler.java create mode 100644 reactive/src/test/java/feign/reactive/ReactiveDelegatingContractTest.java create mode 100644 reactive/src/test/java/feign/reactive/ReactiveFeignIntegrationTest.java create mode 100644 reactive/src/test/java/feign/reactive/ReactiveInvocationHandlerTest.java diff --git a/core/src/main/java/feign/Types.java b/core/src/main/java/feign/Types.java index 1e642444f6..f974036f49 100644 --- a/core/src/main/java/feign/Types.java +++ b/core/src/main/java/feign/Types.java @@ -30,7 +30,7 @@ * @author Bob Lee * @author Jesse Wilson */ -final class Types { +public final class Types { private static final Type[] EMPTY_TYPE_ARRAY = new Type[0]; @@ -38,7 +38,7 @@ private Types() { // No instances. } - static Class getRawType(Type type) { + public static Class getRawType(Type type) { if (type instanceof Class) { // Type is a normal class. return (Class) type; diff --git a/pom.xml b/pom.xml index e628b3ccf3..621e0a6b03 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ ribbon sax slf4j + reactive example-github example-wikipedia diff --git a/reactive/README.md b/reactive/README.md new file mode 100644 index 0000000000..ee910506a3 --- /dev/null +++ b/reactive/README.md @@ -0,0 +1,113 @@ +Reactive Streams Wrapper +--- + +This module wraps Feign's http requests in a [Reactive Streams](https://reactive-streams.org) +Publisher, enabling the use of Reactive Stream `Publisher` return types. Supported Reactive Streams implementations are: + +* [Reactor](https://project-reactor.org) (`Mono` and `Flux`) +* [ReactiveX (RxJava)](https://reactivex.io) (`Flowable` only) + +To use these wrappers, add the `feign-reactive-wrappers` module, and your desired `reactive-streams` +implementation to your classpath. Then configure Feign to use the reactive streams wrappers. + +```java +public interface GitHubReactor { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + Flux contributors(@Param("owner") String owner, @Param("repo") String repo); + + class Contributor { + String login; + + public Contributor(String login) { + this.login = login; + } + } +} + +public class ExampleReactor { + public static void main(String args[]) { + GitHubReactor gitHub = ReactorFeign.builder() + .target(GitHubReactor.class, "https://api.github.com"); + + List contributors = gitHub.contributors("OpenFeign", "feign") + .map(Contributor::new) + .collect(Collectors.toList()) + .block(); + } +} + +public interface GitHubReactiveX { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + Flowable contributors(@Param("owner") String owner, @Param("repo") String repo); + + class Contributor { + String login; + + public Contributor(String login) { + this.login = login; + } + } +} + +public class ExampleRxJava2 { + public static void main(String args[]) { + GitHubReactiveX gitHub = RxJavaFeign.builder() + .target(GitHub.class, "https://api.github.com"); + + List contributors = gitHub.contributors("OpenFeign", "feign") + .map(Contributor::new) + .collect(Collectors.toList()) + .block(); + } +} + +``` + +Considerations +--- + +These wrappers are not *reactive all the way down*, given that Feign generated requests are +synchronous. Requests still block, but execution is controlled by the `Publisher` and their +related `Scheduler`. While this may not be ideal in terms of a fully reactive application, providing these +wrappers provide an intermediate upgrade path for Feign. + +### Streaming + +Methods that return `java.util.streams` Types are not supported. Responses are read fully, +the wrapped in the appropriate reactive wrappers. + +### Iterable and Collections responses + +Due to the Synchronous nature of Feign requests, methods that return `Iterable` types must specify the collection +in the `Publisher`. For `Reactor` types, this limits the use of `Flux` as a response type. If you +want to use `Flux`, you will need to manually convert the `Mono` or `Iterable` response types into +`Flux` using the `fromIterable` method. + + +```java +public interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + Mono> contributors(@Param("owner") String owner, @Param("repo") String repo); + + class Contributor { + String login; + + public Contributor(String login) { + this.login = login; + } + } +} + +public class ExampleApplication { + public static void main(String[] args) { + GitHub gitHub = ReactorFeign.builder() + .target(GitHub.class, "https://api.github.com"); + + Mono> contributors = gitHub.contributors("OpenFeign", "feign"); + Flux contributorFlux = Flux.fromIterable(contributors.block()); + } +} +``` diff --git a/reactive/pom.xml b/reactive/pom.xml new file mode 100644 index 0000000000..a80300bee0 --- /dev/null +++ b/reactive/pom.xml @@ -0,0 +1,95 @@ + + + + + io.github.openfeign + parent + 10.0.2-SNAPSHOT + + 4.0.0 + + Feign Reactive Wrappers + feign-reactive-wrappers + Reactive Wrapper for Feign Clients + + + ${project.basedir}/.. + 3.1.8.RELEASE + 1.0.2 + 2.2.2 + 1.9.5 + + + + + io.github.openfeign + feign-core + ${project.version} + + + org.reactivestreams + reactive-streams + ${reactive.streams.version} + + + io.projectreactor + reactor-core + ${reactor.version} + provided + true + + + io.reactivex.rxjava2 + rxjava + ${reactivex.version} + provided + true + + + org.mockito + mockito-all + ${mockito.version} + test + + + io.github.openfeign + feign-jackson + ${project.version} + test + + + io.github.openfeign + feign-okhttp + ${project.version} + test + + + io.github.openfeign + feign-jaxrs + ${project.version} + test + + + com.squareup.okhttp3 + mockwebserver + test + + + + diff --git a/reactive/src/main/java/feign/reactive/ReactiveDelegatingContract.java b/reactive/src/main/java/feign/reactive/ReactiveDelegatingContract.java new file mode 100644 index 0000000000..286c8893b2 --- /dev/null +++ b/reactive/src/main/java/feign/reactive/ReactiveDelegatingContract.java @@ -0,0 +1,81 @@ +/** + * Copyright 2012-2018 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.reactive; + +import feign.Contract; +import feign.MethodMetadata; +import feign.Types; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; +import org.reactivestreams.Publisher; + +public class ReactiveDelegatingContract implements Contract { + + private final Contract delegate; + + ReactiveDelegatingContract(Contract delegate) { + this.delegate = delegate; + } + + @Override + public List parseAndValidatateMetadata(Class targetType) { + List methodsMetadata = this.delegate.parseAndValidatateMetadata(targetType); + + for (final MethodMetadata metadata : methodsMetadata) { + final Type type = metadata.returnType(); + if (!isReactive(type)) { + throw new IllegalArgumentException(String.format( + "Method %s of contract %s doesn't returns a org.reactivestreams.Publisher", + metadata.configKey(), targetType.getSimpleName())); + } + + /* + * we will need to change the return type of the method to match the return type contained + * within the Publisher + */ + Type[] actualTypes = ((ParameterizedType) type).getActualTypeArguments(); + if (actualTypes.length > 1) { + throw new IllegalStateException("Expected only one contained type."); + } else { + Class actual = Types.getRawType(actualTypes[0]); + if (Stream.class.isAssignableFrom(actual)) { + throw new IllegalArgumentException( + "Streams are not supported when using Reactive Wrappers"); + } + metadata.returnType(actualTypes[0]); + } + } + + return methodsMetadata; + } + + /** + * Ensure that the type provided implements a Reactive Streams Publisher. + * + * @param type to inspect. + * @return true if the type implements the Reactive Streams Publisher specification. + */ + private boolean isReactive(Type type) { + if (!ParameterizedType.class.isAssignableFrom(type.getClass())) { + return false; + } + ParameterizedType parameterizedType = (ParameterizedType) type; + Type raw = parameterizedType.getRawType(); + return Arrays.asList(((Class) raw).getInterfaces()) + .contains(Publisher.class); + } +} diff --git a/reactive/src/main/java/feign/reactive/ReactiveFeign.java b/reactive/src/main/java/feign/reactive/ReactiveFeign.java new file mode 100644 index 0000000000..5f78ea1ed0 --- /dev/null +++ b/reactive/src/main/java/feign/reactive/ReactiveFeign.java @@ -0,0 +1,61 @@ +/** + * Copyright 2012-2018 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.reactive; + +import feign.Contract; +import feign.Feign; +import feign.InvocationHandlerFactory; + +abstract class ReactiveFeign { + + + + public static class Builder extends Feign.Builder { + + private Contract contract = new Contract.Default(); + + /** + * Extend the current contract to support Reactive Stream return types. + * + * @param contract to extend. + * @return a Builder for chaining. + */ + @Override + public Builder contract(Contract contract) { + this.contract = contract; + return this; + } + + /** + * Build the Feign instance. + * + * @return a new Feign Instance. + */ + @Override + public Feign build() { + if (!(this.contract instanceof ReactiveDelegatingContract)) { + super.contract(new ReactiveDelegatingContract(this.contract)); + } else { + super.contract(this.contract); + } + return super.build(); + } + + @Override + public Feign.Builder doNotCloseAfterDecode() { + throw new UnsupportedOperationException("Streaming Decoding is not supported."); + } + } +} diff --git a/reactive/src/main/java/feign/reactive/ReactiveInvocationHandler.java b/reactive/src/main/java/feign/reactive/ReactiveInvocationHandler.java new file mode 100644 index 0000000000..ff851275b3 --- /dev/null +++ b/reactive/src/main/java/feign/reactive/ReactiveInvocationHandler.java @@ -0,0 +1,111 @@ +/** + * Copyright 2012-2018 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.reactive; + +import feign.FeignException; +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Target; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.lang.reflect.Type; +import java.util.Map; +import java.util.concurrent.Callable; +import org.reactivestreams.Publisher; + +public abstract class ReactiveInvocationHandler implements InvocationHandler { + + private final Target target; + private final Map dispatch; + + public ReactiveInvocationHandler(Target target, + Map dispatch) { + this.target = target; + this.dispatch = dispatch; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if ("equals".equals(method.getName())) { + try { + Object otherHandler = + args.length > 0 && args[0] != null ? Proxy.getInvocationHandler(args[0]) : null; + return equals(otherHandler); + } catch (IllegalArgumentException e) { + return false; + } + } else if ("hashCode".equals(method.getName())) { + return hashCode(); + } else if ("toString".equals(method.getName())) { + return toString(); + } + return this.invoke(method, this.dispatch.get(method), args); + } + + @Override + public int hashCode() { + return this.target.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (ReactiveInvocationHandler.class.isAssignableFrom(obj.getClass())) { + return this.target.equals(obj); + } + return false; + } + + @Override + public String toString() { + return "Target [" + this.target.toString() + "]"; + } + + /** + * Invoke the Method Handler. + * + * @param method on the Target to invoke. + * @param methodHandler to invoke + * @param arguments for the method + * @return a reactive {@link Publisher} for the invocation. + */ + protected abstract Publisher invoke(Method method, + MethodHandler methodHandler, + Object[] arguments); + + /** + * Invoke the Method Handler as a Callable. + * + * @param methodHandler to invoke + * @param arguments for the method + * @return a Callable wrapper for the invocation. + */ + Callable invokeMethod(MethodHandler methodHandler, Object[] arguments) { + return () -> { + try { + return methodHandler.invoke(arguments); + } catch (Throwable th) { + if (th instanceof FeignException) { + throw (FeignException) th; + } + throw new RuntimeException(th); + } + }; + } +} diff --git a/reactive/src/main/java/feign/reactive/ReactorFeign.java b/reactive/src/main/java/feign/reactive/ReactorFeign.java new file mode 100644 index 0000000000..b3bc1ebc56 --- /dev/null +++ b/reactive/src/main/java/feign/reactive/ReactorFeign.java @@ -0,0 +1,53 @@ +/** + * Copyright 2012-2018 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.reactive; + +import feign.Feign; +import feign.reactive.ReactiveFeign.Builder; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.util.Map; +import feign.InvocationHandlerFactory; +import feign.Target; + +public class ReactorFeign extends ReactiveFeign { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends ReactiveFeign.Builder { + + @Override + public Feign build() { + super.invocationHandlerFactory(new ReactorInvocationHandlerFactory()); + return super.build(); + } + + @Override + public Feign.Builder invocationHandlerFactory( + InvocationHandlerFactory invocationHandlerFactory) { + throw new UnsupportedOperationException( + "Invocation Handler Factory overrides are not supported."); + } + } + + private static class ReactorInvocationHandlerFactory implements InvocationHandlerFactory { + @Override + public InvocationHandler create(Target target, Map dispatch) { + return new ReactorInvocationHandler(target, dispatch); + } + } +} diff --git a/reactive/src/main/java/feign/reactive/ReactorInvocationHandler.java b/reactive/src/main/java/feign/reactive/ReactorInvocationHandler.java new file mode 100644 index 0000000000..1ce1bae39a --- /dev/null +++ b/reactive/src/main/java/feign/reactive/ReactorInvocationHandler.java @@ -0,0 +1,44 @@ +/** + * Copyright 2012-2018 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.reactive; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Target; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.Callable; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +public class ReactorInvocationHandler extends ReactiveInvocationHandler { + + ReactorInvocationHandler(Target target, + Map dispatch) { + super(target, dispatch); + } + + @Override + protected Publisher invoke(Method method, MethodHandler methodHandler, Object[] arguments) { + Callable invocation = this.invokeMethod(methodHandler, arguments); + if (Flux.class.isAssignableFrom(method.getReturnType())) { + return Flux.from(Mono.fromCallable(invocation)).subscribeOn(Schedulers.elastic()); + } else if (Mono.class.isAssignableFrom(method.getReturnType())) { + return Mono.fromCallable(invocation).subscribeOn(Schedulers.elastic()); + } + throw new IllegalArgumentException( + "Return type " + method.getReturnType().getName() + " is not supported"); + } +} diff --git a/reactive/src/main/java/feign/reactive/RxJavaFeign.java b/reactive/src/main/java/feign/reactive/RxJavaFeign.java new file mode 100644 index 0000000000..7405d02bbc --- /dev/null +++ b/reactive/src/main/java/feign/reactive/RxJavaFeign.java @@ -0,0 +1,53 @@ +/** + * Copyright 2012-2018 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.reactive; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.util.Map; +import feign.Feign; +import feign.InvocationHandlerFactory; +import feign.Target; + +public class RxJavaFeign extends ReactiveFeign { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends ReactiveFeign.Builder { + + @Override + public Feign build() { + super.invocationHandlerFactory(new RxJavaInvocationHandlerFactory()); + return super.build(); + } + + @Override + public Feign.Builder invocationHandlerFactory( + InvocationHandlerFactory invocationHandlerFactory) { + throw new UnsupportedOperationException( + "Invocation Handler Factory overrides are not supported."); + } + + } + + private static class RxJavaInvocationHandlerFactory implements InvocationHandlerFactory { + @Override + public InvocationHandler create(Target target, Map dispatch) { + return new RxJavaInvocationHandler(target, dispatch); + } + } + +} diff --git a/reactive/src/main/java/feign/reactive/RxJavaInvocationHandler.java b/reactive/src/main/java/feign/reactive/RxJavaInvocationHandler.java new file mode 100644 index 0000000000..e4893136ac --- /dev/null +++ b/reactive/src/main/java/feign/reactive/RxJavaInvocationHandler.java @@ -0,0 +1,36 @@ +/** + * Copyright 2012-2018 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.reactive; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Target; +import io.reactivex.Flowable; +import io.reactivex.schedulers.Schedulers; +import java.lang.reflect.Method; +import java.util.Map; +import org.reactivestreams.Publisher; + +public class RxJavaInvocationHandler extends ReactiveInvocationHandler { + + RxJavaInvocationHandler(Target target, + Map dispatch) { + super(target, dispatch); + } + + @Override + protected Publisher invoke(Method method, MethodHandler methodHandler, Object[] arguments) { + return Flowable.fromCallable(this.invokeMethod(methodHandler, arguments)) + .observeOn(Schedulers.trampoline()); + } +} diff --git a/reactive/src/test/java/feign/reactive/ReactiveDelegatingContractTest.java b/reactive/src/test/java/feign/reactive/ReactiveDelegatingContractTest.java new file mode 100644 index 0000000000..231accbf5e --- /dev/null +++ b/reactive/src/test/java/feign/reactive/ReactiveDelegatingContractTest.java @@ -0,0 +1,85 @@ +/** + * Copyright 2012-2018 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.reactive; + +import feign.Contract; +import feign.Param; +import feign.RequestLine; +import feign.reactive.ReactiveDelegatingContract; +import io.reactivex.Flowable; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class ReactiveDelegatingContractTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void onlyReactiveReturnTypesSupported() { + this.thrown.expect(IllegalArgumentException.class); + Contract contract = new ReactiveDelegatingContract(new Contract.Default()); + contract.parseAndValidatateMetadata(TestSynchronousService.class); + } + + @Test + public void reactorTypes() { + Contract contract = new ReactiveDelegatingContract(new Contract.Default()); + contract.parseAndValidatateMetadata(TestReactorService.class); + } + + @Test + public void reactivexTypes() { + Contract contract = new ReactiveDelegatingContract(new Contract.Default()); + contract.parseAndValidatateMetadata(TestReactiveXService.class); + } + + @Test + public void streamsAreNotSupported() { + this.thrown.expect(IllegalArgumentException.class); + Contract contract = new ReactiveDelegatingContract(new Contract.Default()); + contract.parseAndValidatateMetadata(StreamsService.class); + } + + public interface TestSynchronousService { + @RequestLine("GET /version") + String version(); + } + + public interface TestReactiveXService { + @RequestLine("GET /version") + Flowable version(); + } + + + public interface TestReactorService { + @RequestLine("GET /version") + Mono version(); + + @RequestLine("GET /users/{username}") + Flux user(@Param("username") String username); + } + + public interface StreamsService { + + @RequestLine("GET /version") + Mono> version(); + } + +} diff --git a/reactive/src/test/java/feign/reactive/ReactiveFeignIntegrationTest.java b/reactive/src/test/java/feign/reactive/ReactiveFeignIntegrationTest.java new file mode 100644 index 0000000000..437dfdc34d --- /dev/null +++ b/reactive/src/test/java/feign/reactive/ReactiveFeignIntegrationTest.java @@ -0,0 +1,342 @@ +/** + * Copyright 2012-2018 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.reactive; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import feign.Client; +import feign.InvocationHandlerFactory; +import feign.Logger; +import feign.Logger.Level; +import feign.Param; +import feign.QueryMap; +import feign.QueryMapEncoder; +import feign.Request; +import feign.Request.Options; +import feign.RequestInterceptor; +import feign.RequestLine; +import feign.RequestTemplate; +import feign.Response; +import feign.ResponseMapper; +import feign.RetryableException; +import feign.Retryer; +import feign.Target; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import feign.jaxrs.JAXRSContract; +import io.reactivex.Flowable; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.AdditionalAnswers; +import org.mockito.stubbing.Answer; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class ReactiveFeignIntegrationTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public final MockWebServer webServer = new MockWebServer(); + + private String getServerUrl() { + return "http://localhost:" + this.webServer.getPort(); + } + + @Test + public void testDefaultMethodsNotProxied() { + TestReactorService service = ReactorFeign.builder() + .target(TestReactorService.class, this.getServerUrl()); + assertThat(service).isEqualTo(service); + assertThat(service.toString()).isNotNull(); + assertThat(service.hashCode()).isNotZero(); + } + + @Test + public void testReactorTargetFull() throws Exception { + this.webServer.enqueue(new MockResponse().setBody("1.0")); + this.webServer.enqueue(new MockResponse().setBody("{ \"username\": \"test\" }")); + + TestReactorService service = ReactorFeign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .logger(new ConsoleLogger()) + .decode404() + .options(new Options()) + .logLevel(Level.FULL) + .target(TestReactorService.class, this.getServerUrl()); + assertThat(service).isNotNull(); + + String version = service.version() + .block(); + assertThat(version).isNotNull(); + assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/version"); + + + /* test encoding and decoding */ + User user = service.user("test") + .blockFirst(); + assertThat(user).isNotNull().hasFieldOrPropertyWithValue("username", "test"); + assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/users/test"); + + } + + @Test + public void testRxJavaTarget() throws Exception { + this.webServer.enqueue(new MockResponse().setBody("1.0")); + this.webServer.enqueue(new MockResponse().setBody("{ \"username\": \"test\" }")); + + TestReactiveXService service = RxJavaFeign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .logger(new ConsoleLogger()) + .logLevel(Level.FULL) + .target(TestReactiveXService.class, this.getServerUrl()); + assertThat(service).isNotNull(); + + String version = service.version() + .firstElement().blockingGet(); + assertThat(version).isNotNull(); + assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/version"); + + /* test encoding and decoding */ + User user = service.user("test") + .firstElement().blockingGet(); + assertThat(user).isNotNull().hasFieldOrPropertyWithValue("username", "test"); + assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/users/test"); + } + + @Test + public void invocationFactoryIsNotSupported() { + this.thrown.expect(UnsupportedOperationException.class); + ReactorFeign.builder() + .invocationHandlerFactory( + (target, dispatch) -> null) + .target(TestReactiveXService.class, "http://localhost"); + } + + @Test + public void doNotCloseUnsupported() { + this.thrown.expect(UnsupportedOperationException.class); + ReactorFeign.builder() + .doNotCloseAfterDecode() + .target(TestReactiveXService.class, "http://localhost"); + } + + @Test + public void testRequestInterceptor() { + this.webServer.enqueue(new MockResponse().setBody("1.0")); + + RequestInterceptor mockInterceptor = mock(RequestInterceptor.class); + TestReactorService service = ReactorFeign.builder() + .requestInterceptor(mockInterceptor) + .target(TestReactorService.class, this.getServerUrl()); + service.version().block(); + verify(mockInterceptor, times(1)).apply(any(RequestTemplate.class)); + } + + @Test + public void testRequestInterceptors() { + this.webServer.enqueue(new MockResponse().setBody("1.0")); + + RequestInterceptor mockInterceptor = mock(RequestInterceptor.class); + TestReactorService service = ReactorFeign.builder() + .requestInterceptors(Arrays.asList(mockInterceptor, mockInterceptor)) + .target(TestReactorService.class, this.getServerUrl()); + service.version().block(); + verify(mockInterceptor, times(2)).apply(any(RequestTemplate.class)); + } + + @Test + public void testResponseMappers() throws Exception { + this.webServer.enqueue(new MockResponse().setBody("1.0")); + + ResponseMapper responseMapper = mock(ResponseMapper.class); + Decoder decoder = mock(Decoder.class); + given(responseMapper.map(any(Response.class), any(Type.class))) + .willAnswer(AdditionalAnswers.returnsFirstArg()); + given(decoder.decode(any(Response.class), any(Type.class))).willReturn("1.0"); + + TestReactorService service = ReactorFeign.builder() + .mapAndDecode(responseMapper, decoder) + .target(TestReactorService.class, this.getServerUrl()); + service.version().block(); + verify(responseMapper, times(1)) + .map(any(Response.class), any(Type.class)); + verify(decoder, times(1)).decode(any(Response.class), any(Type.class)); + } + + @Test + public void testQueryMapEncoders() { + this.webServer.enqueue(new MockResponse().setBody("No Results Found")); + + QueryMapEncoder encoder = mock(QueryMapEncoder.class); + given(encoder.encode(any(Object.class))).willReturn(Collections.emptyMap()); + TestReactiveXService service = RxJavaFeign.builder() + .queryMapEncoder(encoder) + .target(TestReactiveXService.class, this.getServerUrl()); + String results = service.search(new SearchQuery()) + .blockingSingle(); + assertThat(results).isNotEmpty(); + verify(encoder, times(1)).encode(any(Object.class)); + } + + @SuppressWarnings({"ResultOfMethodCallIgnored", "ThrowableNotThrown"}) + @Test + public void testErrorDecoder() { + this.thrown.expect(RuntimeException.class); + this.webServer.enqueue(new MockResponse().setBody("Bad Request").setResponseCode(400)); + + ErrorDecoder errorDecoder = mock(ErrorDecoder.class); + given(errorDecoder.decode(anyString(), any(Response.class))) + .willReturn(new IllegalStateException("bad request")); + + TestReactiveXService service = RxJavaFeign.builder() + .errorDecoder(errorDecoder) + .target(TestReactiveXService.class, this.getServerUrl()); + service.search(new SearchQuery()) + .blockingSingle(); + verify(errorDecoder, times(1)).decode(anyString(), any(Response.class)); + } + + @Test + public void testRetryer() { + this.webServer.enqueue(new MockResponse().setBody("Not Available").setResponseCode(-1)); + this.webServer.enqueue(new MockResponse().setBody("1.0")); + + Retryer retryer = new Retryer.Default(); + Retryer spy = spy(retryer); + when(spy.clone()).thenReturn(spy); + TestReactorService service = ReactorFeign.builder() + .retryer(spy) + .target(TestReactorService.class, this.getServerUrl()); + service.version().log().block(); + verify(spy, times(1)).continueOrPropagate(any(RetryableException.class)); + } + + @Test + public void testClient() throws Exception { + Client client = mock(Client.class); + given(client.execute(any(Request.class), any(Options.class))) + .willAnswer((Answer) invocation -> Response.builder() + .status(200) + .headers(Collections.emptyMap()) + .body("1.0", Charset.defaultCharset()) + .request((Request) invocation.getArguments()[0]) + .build()); + + TestReactorService service = ReactorFeign.builder() + .client(client) + .target(TestReactorService.class, this.getServerUrl()); + service.version().block(); + verify(client, times(1)).execute(any(Request.class), any(Options.class)); + } + + @Test + public void testDifferentContract() throws Exception { + this.webServer.enqueue(new MockResponse().setBody("1.0")); + + TestJaxRSReactorService service = ReactorFeign.builder() + .contract(new JAXRSContract()) + .target(TestJaxRSReactorService.class, this.getServerUrl()); + String version = service.version().block(); + assertThat(version).isNotNull(); + assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/version"); + } + + + interface TestReactorService { + @RequestLine("GET /version") + Mono version(); + + @RequestLine("GET /users/{username}") + Flux user(@Param("username") String username); + } + + + interface TestReactiveXService { + @RequestLine("GET /version") + Flowable version(); + + @RequestLine("GET /users/{username}") + Flowable user(@Param("username") String username); + + @RequestLine("GET /users/search") + Flowable search(@QueryMap SearchQuery query); + } + + interface TestJaxRSReactorService { + + @Path("/version") + @GET + Mono version(); + } + + + @SuppressWarnings("unused") + static class User { + private String username; + + public User() { + super(); + } + + public String getUsername() { + return username; + } + } + + + @SuppressWarnings("unused") + static class SearchQuery { + SearchQuery() { + super(); + } + + public String query() { + return "query"; + } + } + + + public static class ConsoleLogger extends Logger { + @Override + protected void log(String configKey, String format, Object... args) { + System.out.println(String.format(methodTag(configKey) + format, args)); + } + } +} diff --git a/reactive/src/test/java/feign/reactive/ReactiveInvocationHandlerTest.java b/reactive/src/test/java/feign/reactive/ReactiveInvocationHandlerTest.java new file mode 100644 index 0000000000..25b4e01b80 --- /dev/null +++ b/reactive/src/test/java/feign/reactive/ReactiveInvocationHandlerTest.java @@ -0,0 +1,132 @@ +/** + * Copyright 2012-2018 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.reactive; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import feign.FeignException; +import feign.InvocationHandlerFactory.MethodHandler; +import feign.RequestLine; +import feign.Target; +import feign.reactive.ReactorInvocationHandler; +import feign.reactive.RxJavaInvocationHandler; +import io.reactivex.Flowable; +import java.lang.reflect.Method; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import reactor.core.publisher.Mono; + +@RunWith(MockitoJUnitRunner.class) +public class ReactiveInvocationHandlerTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Mock + private Target target; + + @Mock + private MethodHandler methodHandler; + + private Method method; + + @SuppressWarnings("unchecked") + @Test + public void invokeOnSubscribeReactor() throws Throwable { + Method method = TestReactorService.class.getMethod("version"); + ReactorInvocationHandler handler = new ReactorInvocationHandler(this.target, + Collections.singletonMap(method, this.methodHandler)); + + Object result = handler.invoke(method, this.methodHandler, new Object[] {}); + assertThat(result).isInstanceOf(Mono.class); + verifyZeroInteractions(this.methodHandler); + + /* subscribe and execute the method */ + Mono mono = (Mono) result; + mono.log().block(); + verify(this.methodHandler, times(1)).invoke(any()); + } + + @SuppressWarnings("unchecked") + @Test + public void invokeFailureReactor() throws Throwable { + this.thrown.expect(RuntimeException.class); + given(this.methodHandler.invoke(any())).willThrow(new RuntimeException("Could Not Decode")); + given(this.method.getReturnType()).willReturn((Class) Class.forName(Mono.class.getName())); + ReactorInvocationHandler handler = new ReactorInvocationHandler(this.target, + Collections.singletonMap(this.method, this.methodHandler)); + + Object result = handler.invoke(this.method, this.methodHandler, new Object[] {}); + assertThat(result).isInstanceOf(Mono.class); + verifyZeroInteractions(this.methodHandler); + + /* subscribe and execute the method, should result in an error */ + Mono mono = (Mono) result; + mono.log().block(); + verify(this.methodHandler, times(1)).invoke(any()); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + @Test + public void invokeOnSubscribeRxJava() throws Throwable { + given(this.methodHandler.invoke(any())).willReturn("Result"); + RxJavaInvocationHandler handler = + new RxJavaInvocationHandler(this.target, + Collections.singletonMap(this.method, this.methodHandler)); + + Object result = handler.invoke(this.method, this.methodHandler, new Object[] {}); + assertThat(result).isInstanceOf(Flowable.class); + verifyZeroInteractions(this.methodHandler); + + /* subscribe and execute the method */ + Flowable flow = (Flowable) result; + flow.firstElement().blockingGet(); + verify(this.methodHandler, times(1)).invoke(any()); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + @Test + public void invokeFailureRxJava() throws Throwable { + this.thrown.expect(RuntimeException.class); + given(this.methodHandler.invoke(any())).willThrow(new RuntimeException("Could Not Decode")); + RxJavaInvocationHandler handler = + new RxJavaInvocationHandler(this.target, + Collections.singletonMap(this.method, this.methodHandler)); + + Object result = handler.invoke(this.method, this.methodHandler, new Object[] {}); + assertThat(result).isInstanceOf(Flowable.class); + verifyZeroInteractions(this.methodHandler); + + /* subscribe and execute the method */ + Flowable flow = (Flowable) result; + flow.firstElement().blockingGet(); + verify(this.methodHandler, times(1)).invoke(any()); + } + + + public interface TestReactorService { + @RequestLine("GET /version") + Mono version(); + } + +}