Skip to content

Commit 70f0d97

Browse files
Dan JasekAdrian Cole
authored andcommitted
Do not error on interfaces with default methods.
Pass calls to default methods on proxy through to implementation on interface.
1 parent 32e05a2 commit 70f0d97

9 files changed

Lines changed: 179 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
### Version 8.16
22
* Adds `@HeaderMap` annotation to support dynamic header fields and values
3+
* Add support for default and static methods on interfaces
34

45
### Version 8.15
56
* Adds `@QueryMap` annotation to support dynamic query parameters

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,3 +428,39 @@ A Map parameter can be annotated with `QueryMap` to construct a query that uses
428428
@RequestLine("GET /find")
429429
V find(@QueryMap Map<String, Object> queryMap);
430430
```
431+
432+
#### Static and Default Methods
433+
Interfaces targeted by Feign may have static or default methods (if using Java 8+).
434+
These allows Feign clients to contain logic that is not expressly defined by the underlying API.
435+
For example, static methods make it easy to specify common client build configurations; default methods can be used to compose queries or define default parameters.
436+
437+
```java
438+
interface GitHub {
439+
@RequestLine("GET /repos/{owner}/{repo}/contributors")
440+
List<Contributor> contributors(@Param("owner") String owner, @Param("repo") String repo);
441+
442+
@RequestLine("GET /users/{username}/repos?sort={sort}")
443+
List<Repo> repos(@Param("username") String owner, @Param("sort") String sort);
444+
445+
default List<Repo> repos(String owner) {
446+
return repos(owner, "full_name");
447+
}
448+
449+
/**
450+
* Lists all contributors for all repos owned by a user.
451+
*/
452+
default List<Contributor> contributors(String user) {
453+
MergingContributorList contributors = new MergingContributorList();
454+
for(Repo repo : this.repos(owner)) {
455+
contributors.addAll(this.contributors(user, repo.getName()));
456+
}
457+
return contributors.mergeResult();
458+
}
459+
460+
static GitHub connect() {
461+
return Feign.builder()
462+
.decoder(new GsonDecoder())
463+
.target(GitHub.class, "https://api.github.com");
464+
}
465+
}
466+
```

core/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ apply plugin: 'osgi'
44
sourceCompatibility = 1.6
55

66
dependencies {
7+
compile 'org.jvnet:animal-sniffer-annotation:1.0'
78
testCompile 'junit:junit:4.12'
89
testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7
910
testCompile 'com.squareup.okhttp:mockwebserver:2.7.5'

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ public List<MethodMetadata> parseAndValidatateMetadata(Class<?> targetType) {
5757
Map<String, MethodMetadata> result = new LinkedHashMap<String, MethodMetadata>();
5858
for (Method method : targetType.getMethods()) {
5959
if (method.getDeclaringClass() == Object.class ||
60-
(method.getModifiers() & Modifier.STATIC) != 0) {
60+
(method.getModifiers() & Modifier.STATIC) != 0 ||
61+
Util.isDefault(method)) {
6162
continue;
6263
}
6364
MethodMetadata metadata = parseAndValidateMetadata(targetType, method);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package feign;
2+
3+
import feign.InvocationHandlerFactory.MethodHandler;
4+
import org.jvnet.animal_sniffer.IgnoreJRERequirement;
5+
6+
import java.lang.invoke.MethodHandle;
7+
import java.lang.invoke.MethodHandles.Lookup;
8+
import java.lang.reflect.Field;
9+
import java.lang.reflect.Method;
10+
11+
/**
12+
* Handles default methods by directly invoking the default method code on the interface.
13+
* The bindTo method must be called on the result before invoke is called.
14+
*/
15+
@IgnoreJRERequirement
16+
final class DefaultMethodHandler implements MethodHandler {
17+
// Uses Java 7 MethodHandle based reflection. As default methods will only exist when
18+
// run on a Java 8 JVM this will not affect use on legacy JVMs.
19+
// When Feign upgrades to Java 7, remove the @IgnoreJRERequirement annotation.
20+
private final MethodHandle unboundHandle;
21+
22+
// handle is effectively final after bindTo has been called.
23+
private MethodHandle handle;
24+
25+
public DefaultMethodHandler(Method defaultMethod) {
26+
try {
27+
Class<?> declaringClass = defaultMethod.getDeclaringClass();
28+
Field field = Lookup.class.getDeclaredField("IMPL_LOOKUP");
29+
field.setAccessible(true);
30+
Lookup lookup = (Lookup) field.get(null);
31+
32+
this.unboundHandle = lookup.unreflectSpecial(defaultMethod, declaringClass);
33+
} catch (NoSuchFieldException ex) {
34+
throw new IllegalStateException(ex);
35+
} catch (IllegalAccessException ex) {
36+
throw new IllegalStateException(ex);
37+
}
38+
}
39+
40+
/**
41+
* Bind this handler to a proxy object. After bound, DefaultMethodHandler#invoke will act as if it was called
42+
* on the proxy object. Must be called once and only once for a given instance of DefaultMethodHandler
43+
*/
44+
public void bindTo(Object proxy) {
45+
if(handle != null) {
46+
throw new IllegalStateException("Attempted to rebind a default method handler that was already bound");
47+
}
48+
handle = unboundHandle.bindTo(proxy);
49+
}
50+
51+
/**
52+
* Invoke this method. DefaultMethodHandler#bindTo must be called before the first
53+
* time invoke is called.
54+
*/
55+
@Override
56+
public Object invoke(Object[] argv) throws Throwable {
57+
if(handle == null) {
58+
throw new IllegalStateException("Default method handler invoked before proxy has been bound.");
59+
}
60+
return handle.invokeWithArguments(argv);
61+
}
62+
}

core/src/main/java/feign/ReflectiveFeign.java

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,7 @@
1818
import java.lang.reflect.InvocationHandler;
1919
import java.lang.reflect.Method;
2020
import java.lang.reflect.Proxy;
21-
import java.util.ArrayList;
22-
import java.util.Collection;
23-
import java.util.Iterator;
24-
import java.util.LinkedHashMap;
25-
import java.util.List;
26-
import java.util.Map;
21+
import java.util.*;
2722
import java.util.Map.Entry;
2823

2924
import feign.InvocationHandlerFactory.MethodHandler;
@@ -57,15 +52,26 @@ public class ReflectiveFeign extends Feign {
5752
public <T> T newInstance(Target<T> target) {
5853
Map<String, MethodHandler> nameToHandler = targetToHandlersByName.apply(target);
5954
Map<Method, MethodHandler> methodToHandler = new LinkedHashMap<Method, MethodHandler>();
55+
List<DefaultMethodHandler> defaultMethodHandlers = new LinkedList<DefaultMethodHandler>();
56+
6057
for (Method method : target.type().getMethods()) {
6158
if (method.getDeclaringClass() == Object.class) {
6259
continue;
60+
} else if(Util.isDefault(method)) {
61+
DefaultMethodHandler handler = new DefaultMethodHandler(method);
62+
defaultMethodHandlers.add(handler);
63+
methodToHandler.put(method, handler);
64+
} else {
65+
methodToHandler.put(method, nameToHandler.get(Feign.configKey(target.type(), method)));
6366
}
64-
methodToHandler.put(method, nameToHandler.get(Feign.configKey(target.type(), method)));
6567
}
6668
InvocationHandler handler = factory.create(target, methodToHandler);
67-
return (T) Proxy
68-
.newProxyInstance(target.type().getClassLoader(), new Class<?>[]{target.type()}, handler);
69+
T proxy = (T) Proxy.newProxyInstance(target.type().getClassLoader(), new Class<?>[]{target.type()}, handler);
70+
71+
for(DefaultMethodHandler defaultMethodHandler : defaultMethodHandlers) {
72+
defaultMethodHandler.bindTo(proxy);
73+
}
74+
return proxy;
6975
}
7076

7177
static class FeignInvocationHandler implements InvocationHandler {

core/src/main/java/feign/Util.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import java.io.OutputStream;
2323
import java.io.Reader;
2424
import java.lang.reflect.Array;
25+
import java.lang.reflect.Method;
26+
import java.lang.reflect.Modifier;
2527
import java.lang.reflect.ParameterizedType;
2628
import java.lang.reflect.Type;
2729
import java.lang.reflect.WildcardType;
@@ -127,6 +129,19 @@ public static void checkState(boolean expression,
127129
}
128130
}
129131

132+
/**
133+
* Identifies a method as a default instance method.
134+
*/
135+
public static boolean isDefault(Method method) {
136+
// Default methods are public non-abstract, non-synthetic, and non-static instance methods
137+
// declared in an interface.
138+
// method.isDefault() is not sufficient for our usage as it does not check
139+
// for synthetic methods. As a result, it picks up overridden methods as well as actual default methods.
140+
final int SYNTHETIC = 0x00001000;
141+
return ((method.getModifiers() & (Modifier.ABSTRACT | Modifier.PUBLIC | Modifier.STATIC | SYNTHETIC)) ==
142+
Modifier.PUBLIC) && method.getDeclaringClass().isInterface();
143+
}
144+
130145
/**
131146
* Adapted from {@code com.google.common.base.Strings#emptyToNull}.
132147
*/

core/src/test/java/feign/DefaultContractTest.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,4 +710,21 @@ public void staticMethodsOnInterfaceIgnored() throws Exception {
710710
MethodMetadata md = mds.get(0);
711711
assertThat(md.configKey()).isEqualTo("StaticMethodOnInterface#get(String)");
712712
}
713+
714+
interface DefaultMethodOnInterface {
715+
@RequestLine("GET /api/{key}")
716+
String get(@Param("key") String key);
717+
718+
default String defaultGet(String key) {
719+
return get(key);
720+
}
721+
}
722+
723+
@Test
724+
public void defaultMethodsOnInterfaceIgnored() throws Exception {
725+
List<MethodMetadata> mds = contract.parseAndValidatateMetadata(DefaultMethodOnInterface.class);
726+
assertThat(mds).hasSize(1);
727+
MethodMetadata md = mds.get(0);
728+
assertThat(md.configKey()).isEqualTo("DefaultMethodOnInterface#get(String)");
729+
}
713730
}

core/src/test/java/feign/FeignBuilderTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,28 @@ public void testSlashIsEncodedInPathParams() throws Exception {
220220
.hasPath("/api/queues/%2F");
221221
}
222222

223+
@Test
224+
public void testBasicDefaultMethod() throws Exception {
225+
String url = "http://localhost:" + server.getPort();
226+
227+
TestInterface api = Feign.builder().target(TestInterface.class, url);
228+
String result = api.independentDefaultMethod();
229+
230+
assertThat(result.equals("default result"));
231+
}
232+
233+
@Test
234+
public void testDefaultCallingProxiedMethod() throws Exception {
235+
server.enqueue(new MockResponse().setBody("response data"));
236+
237+
String url = "http://localhost:" + server.getPort();
238+
TestInterface api = Feign.builder().target(TestInterface.class, url);
239+
240+
Response response = api.defaultMethodPassthrough();
241+
assertEquals("response data", Util.toString(response.body().asReader()));
242+
assertThat(server.takeRequest()).hasPath("/");
243+
}
244+
223245
interface TestInterface {
224246
@RequestLine("GET")
225247
Response getNoPath();
@@ -238,5 +260,13 @@ interface TestInterface {
238260

239261
@RequestLine(value = "GET /api/queues/{vhost}", decodeSlash = false)
240262
byte[] getQueues(@Param("vhost") String vhost);
263+
264+
default String independentDefaultMethod() {
265+
return "default result";
266+
}
267+
268+
default Response defaultMethodPassthrough() {
269+
return getNoPath();
270+
}
241271
}
242272
}

0 commit comments

Comments
 (0)