diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..7adeb75184 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Compiled source # +################### +*.com +*.class +*.dll +*.exe +*.o +*.so + +# Packages # +############ +# it's better to unpack these files and commit the raw source +# git has its own built in compression methods +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Logs and databases # +###################### +*.log + +# OS generated files # +###################### +.DS_Store* +ehthumbs.db +Icon? +Thumbs.db + +# Editor Files # +################ +*~ +*.swp + +# Gradle Files # +################ +.gradle +local.properties + +# Build output directies +/target +**/test-output +**/target +**/bin +build +*/build +.m2 + +# IntelliJ specific files/directories +out +.idea +*.ipr +*.iws +*.iml +atlassian-ide-plugin.xml + +# Eclipse specific files/directories +.classpath +.project +.settings +.metadata +.factorypath +.generated + +# NetBeans specific files/directories +.nbattrs diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000000..f776d34369 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,54 @@ +### Version 4.4 +* Support overriding default HostnameVerifier. +* Support GZIP content encoding for request bodies. +* Support Iterable args for query parameters. +* Support urls which have query parameters. + +### Version 4.3 +* Add ability to configure zero or more RequestInterceptors. +* Remove `overrides = true` on codec modules. + +### Version 4.2/3.3 +* Document and enforce JAX-RS annotation processing from server POV +* Skip query template parameters when corresponding java arg is null + +### Version 4.1/3.2 +* update to dagger 1.1 +* Add wikipedia search example +* Allow `@Path` on types in feign-jaxrs + +### Version 4.0 +* Support RxJava-style Observers. + * Return type can be `Observable` for an async equiv of `Iterable`. + * `Observer` replaces `IncrementalCallback` and is passed to `Observable.subscribe()`. + * On `Subscription.unsubscribe()`, `Observer.onNext()` will stop being called. + +### Version 3.1 +* Log when an http request is retried or a response fails due to an IOException. + +### Version 3.0 +* Added support for asynchronous callbacks via `IncrementalCallback` and `IncrementalDecoder.TextStream`. +* Wire is now Logger, with configurable Logger.Level. +* Added `feign-gson` codec, used via `new GsonModule()` +* changed codec to be similar to [WebSocket JSR 356](http://docs.oracle.com/javaee/7/api/javax/websocket/package-summary.html) + * Decoder is now `Decoder.TextStream` + * BodyEncoder is now `Encoder.Text` + * FormEncoder is now `Encoder.Text>` +* Encoder and Decoders are specified via `Provides.Type.SET` binding. +* Default Encoder and Form Encoder is `Encoder.Text` +* Default Decoder is `Decoder.TextStream` +* ErrorDecoder now returns Exception, not fallback. +* There can only be one `ErrorDecoder` and `Request.Options` binding now. + +### Version 2.0.0 +* removes guava and jax-rs dependencies +* adds JAX-RS integration + +### Version 1.1.0 +* adds Ribbon integration +* adds cli example +* exponential backoff customizable via Retryer.Default ctor + +### Version 1.0.0 + +* Initial open source release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..7f8ced0d1f --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2012 Netflix, Inc. + + 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. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000000..53830957de --- /dev/null +++ b/NOTICE @@ -0,0 +1,4 @@ +Feign +Copyright 2013 Netflix, Inc. + +Portions of this software developed by Commerce Technologies, Inc. diff --git a/README.md b/README.md index ebf660a86f..57db11da20 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,225 @@ -feign -===== +# Feign makes writing java http clients easier +Feign is a java to http client binder inspired by [Dagger](https://github.com/square/dagger), [Retrofit](https://github.com/square/retrofit), [RxJava](https://github.com/Netflix/RxJava), [JAXRS-2.0](https://jax-rs-spec.java.net/nonav/2.0/apidocs/index.html), and [WebSocket](http://www.oracle.com/technetwork/articles/java/jsr356-1937161.html). Feign's first goal was reducing the complexity of binding [Denominator](https://github.com/Netflix/Denominator) uniformly to http apis regardless of [restfulness](http://www.slideshare.net/adrianfcole/99problems). + +### Why Feign and not X? + +You can use tools like Jersey and CXF to write java clients for ReST or SOAP services. You can write your own code on top of http transport libraries like Apache HC. Feign aims to connect your code to http apis with minimal overhead and code. Via customizable decoders and error handling, you should be able to write to any text-based http api. + +### How does Feign work? + +Feign works by processing annotations into a templatized request. Just before sending it off, arguments are applied to these templates in a straightforward fashion. While this limits Feign to only supporting text-based apis, it dramatically simplified system aspects such as replaying requests. It is also stupid easy to unit test your conversions knowing this. + +### Basics + +Usage typically looks like this, an adaptation of the [canonical Retrofit sample](https://github.com/square/retrofit/blob/master/retrofit-samples/github-client/src/main/java/com/example/retrofit/GitHubClient.java). + +```java +interface GitHub { + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Named("owner") String owner, @Named("repo") String repo); +} + +static class Contributor { + String login; + int contributions; +} + +public static void main(String... args) { + GitHub github = Feign.create(GitHub.class, "https://api.github.com", new GsonModule()); + + // Fetch and print a list of the contributors to this library. + List contributors = github.contributors("netflix", "feign"); + for (Contributor contributor : contributors) { + System.out.println(contributor.login + " (" + contributor.contributions + ")"); + } +} +``` + +Feign includes a fully functional json codec in the `feign-gson` extension. See the `Decoder` section for how to write your own. + +### Request Interceptors +When you need to change all requests, regardless of their target, you'll want to configure a `RequestInterceptor`. +For example, if you are acting as an intermediary, you might want to propagate the `X-Forwarded-For` header. + +``` +@Module(library = true) +static class ForwardedForInterceptor implements RequestInterceptor { + @Provides(type = SET) RequestInterceptor provideThis() { + return this; + } + + @Override public void apply(RequestTemplate template) { + template.header("X-Forwarded-For", "origin.host.com"); + } +} +... +GitHub github = Feign.create(GitHub.class, "https://api.github.com", new GsonModule(), new ForwardedForInterceptor()); +``` + +### Observable Methods +If specified as the last return type of a method `Observable` will invoke a new http request for each call to `subscribe()`. This is the async equivalent to an `Iterable`. +Here's how one looks: +```java +Observable observable = github.contributorsObservable("netflix", "feign"); +subscription = observable.subscribe(newObserver()); +subscription = observable.subscribe(newObserver()); +``` + +`Observer` is fired as a background which adds new elements as they are decoded, or until `subscription.unsubscribe()` is called. Think of `Observer` as an asynchronous equivalent to a lazy sequence. + +Here's how one looks: +```java +Observer printlnObserver = new Observer() { + + public int count; + + @Override public void onNext(Contributor element) { + count++; + } + + @Override public void onSuccess() { + System.out.println("found " + count + " contributors"); + } + + @Override public void onFailure(Throwable cause) { + cause.printStackTrace(); + } +}; +``` + +For more robust integration with `Observable` check out [RxJava](https://github.com/Netflix/RxJava). + +### Multiple Interfaces +Feign can produce multiple api interfaces. These are defined as `Target` (default `HardCodedTarget`), which allow for dynamic discovery and decoration of requests prior to execution. + +For example, the following pattern might decorate each request with the current url and auth token from the identity service. + +```java +CloudDNS cloudDNS = Feign.create().newInstance(new CloudIdentityTarget(user, apiKey)); +``` + +You can find [several examples](https://github.com/Netflix/feign/tree/master/feign-core/src/test/java/feign/examples) in the test tree. Do take time to look at them, as seeing is believing! + +### Integrations +Feign intends to work well within Netflix and other Open Source communities. Modules are welcome to integrate with your favorite projects! +### Gson +[GsonModule](https://github.com/Netflix/feign/tree/master/feign-gson) adds default encoders and decoders so you get get started with a json api. + +Integration requires you pass `new GsonModule()` to `Feign.create()`, or add it to your graph with Dagger: +```java +GitHub github = Feign.create(GitHub.class, "https://api.github.com", new GsonModule()); +``` + +### JAX-RS +[JAXRSModule](https://github.com/Netflix/feign/tree/master/feign-jaxrs) overrides annotation processing to instead use standard ones supplied by the JAX-RS specification. This is currently targeted at the 1.1 spec. + +Here's the example above re-written to use JAX-RS: +```java +interface GitHub { + @GET @Path("/repos/{owner}/{repo}/contributors") + List contributors(@PathParam("owner") String owner, @PathParam("repo") String repo); +} +``` +### Ribbon +[RibbonModule](https://github.com/Netflix/feign/tree/master/feign-ribbon) overrides URL resolution of Feign's client, adding smart routing and resiliency capabilities provided by [Ribbon](https://github.com/Netflix/ribbon). + +Integration requires you to pass your ribbon client name as the host part of the url, for example `myAppProd`. +```java +MyService api = Feign.create(MyService.class, "https://myAppProd", new RibbonModule()); +``` + +### Decoders +The last argument to `Feign.create` allows you to specify additional configuration such as how to decode a responses, modeled in Dagger. + +If any methods in your interface return types besides `void` or `String`, you'll need to configure a `Decoder.TextStream` or a general one for all types (`Decoder.TextStream`). + +The `GsonModule` in the `feign-gson` extension configures a (`Decoder.TextStream`) which parses objects from json using reflection. + +Here's how you could write this yourself, using whatever library you prefer: +```java +@Module(library = true) +static class JsonModule { + @Provides(type = SET) Decoder decoder(final JsonParser parser) { + return new Decoder.TextStream() { + + @Override public Object decode(Reader reader, Type type) throws IOException { + return parser.readJson(reader, type); + } + + }; + } +} +``` +#### Type-specific Decoders +The generic parameter of `Decoder.TextStream` designates which The type parameter is either a concrete type, or `Object`, if your decoder can handle multiple types. To add a type-specific decoder, ensure your type parameter is correct. Here's an example of an xml decoder that will only apply to methods that return `ZoneList`. + +``` +@Provides(type = SET) Decoder zoneListDecoder(Provider handlers) { + return new SAXDecoder(handlers){}; +} +``` +#### Incremental Decoding +The last argument to `Feign.create` allows you to specify additional configuration such as how to decode a responses, modeled in Dagger. + +When using an `IncrementalCallback`, if `T` is not `Void` or `String`, you'll need to configure an `IncrementalDecoder.TextStream` or a general one for all types (`IncrementalDecoder.TextStream`). + +The `GsonModule` in the `feign-gson` extension configures a (`IncrementalDecoder.TextStream`) which parses objects from json using reflection. + +Here's how you could write this yourself, using whatever library you prefer: +```java +@Provides(type = SET) IncrementalDecoder incrementalDecoder(final JsonParser parser) { + return new IncrementalDecoder.TextStream() { + + @Override + public void decode(Reader reader, Type type, IncrementalCallback observer) throws IOException { + jsonReader.beginArray(); + while (jsonReader.hasNext()) { + observer.onNext(parser.readJson(reader, type)); + } + jsonReader.endArray(); + } + }; +} +``` +### Advanced usage and Dagger +#### Dagger +Feign can be directly wired into Dagger which keeps things at compile time and Android friendly. As opposed to exposing builders for config, Feign intends users to embed their config in Dagger. + +Where possible, Feign configuration uses normal Dagger conventions. For example, `Decoder` bindings are of `Provider.Type.SET`, meaning you can make multiple bindings for all the different types you return. Here's an example of multiple decoder bindings. +```java +@Provides(type = SET) Decoder recordListDecoder(Provider handlers) { + return new SAXDecoder>(handlers){}; +} + +@Provides(type = SET) Decoder directionalRecordListDecoder(Provider handlers) { + return new SAXDecoder>(handlers){}; +} +``` +#### Logging +You can log the http messages going to and from the target by setting up a `Logger`. Here's the easiest way to do that: +```java +@Module(overrides = true) +class Overrides { + @Provides @Singleton Logger.Level provideLoggerLevel() { + return Logger.Level.FULL; + } + + @Provides @Singleton Logger provideLogger() { + return new Logger.JavaLogger().appendToFile("logs/http.log"); + } +} +GitHub github = Feign.create(GitHub.class, "https://api.github.com", new GsonGitHubModule(), new Overrides()); +``` +#### Pattern Decoders +If you have to only grab a single field from a server response, you may find regular expressions less maintenance than writing a type adapter. + +Here's how our IAM example grabs only one xml element from a response. +```java +@Module(library = true) +static class IAMModule { + @Provides(type = SET) Decoder arnDecoder() { + return Decoders.firstGroup("([\\S&&[^<]]+)"); + } +} +``` + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000000..7168bffff1 --- /dev/null +++ b/build.gradle @@ -0,0 +1,91 @@ +// Establish version and status +ext.githubProjectName = rootProject.name // Change if github project name is not the same as the root project's name + +buildscript { + repositories { + mavenLocal() + mavenCentral() + } + apply from: file('gradle/buildscript.gradle'), to: buildscript +} + +allprojects { + repositories { + mavenLocal() + mavenCentral() + maven { url 'https://oss.sonatype.org/content/repositories/releases/' } + } +} + +apply from: file('gradle/convention.gradle') +apply from: file('gradle/maven.gradle') +apply from: file('gradle/check.gradle') +apply from: file('gradle/license.gradle') +apply from: file('gradle/release.gradle') +apply plugin: 'idea' + +subprojects { + apply from: rootProject.file('dagger.gradle') + group = "com.netflix.${githubProjectName}" // TEMPLATE: Set to organization of project +} + +project(':feign-core') { + apply plugin: 'java' + + test { + useTestNG() + } + + dependencies { + testCompile 'com.google.guava:guava:14.0.1' + testCompile 'com.google.code.gson:gson:2.2.4' + testCompile 'com.fasterxml.jackson.core:jackson-databind:2.2.2' + testCompile 'org.testng:testng:6.8.5' + testCompile 'com.google.mockwebserver:mockwebserver:20130706' + } +} + +project(':feign-gson') { + apply plugin: 'java' + + test { + useTestNG() + } + + dependencies { + compile project(':feign-core') + compile 'com.google.code.gson:gson:2.2.4' + testCompile 'org.testng:testng:6.8.5' + } +} + +project(':feign-jaxrs') { + apply plugin: 'java' + + test { + useTestNG() + } + + dependencies { + compile project(':feign-core') + compile 'javax.ws.rs:jsr311-api:1.1.1' + testCompile project(':feign-gson') + testCompile 'com.google.guava:guava:14.0.1' + testCompile 'org.testng:testng:6.8.5' + } +} + +project(':feign-ribbon') { + apply plugin: 'java' + + test { + useTestNG() + } + + dependencies { + compile project(':feign-core') + compile 'com.netflix.ribbon:ribbon-core:0.2.0' + testCompile 'org.testng:testng:6.8.5' + testCompile 'com.google.mockwebserver:mockwebserver:20130706' + } +} diff --git a/codequality/HEADER b/codequality/HEADER new file mode 100644 index 0000000000..3102e4b449 --- /dev/null +++ b/codequality/HEADER @@ -0,0 +1,13 @@ +Copyright ${year} Netflix, Inc. + +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. diff --git a/codequality/checkstyle.xml b/codequality/checkstyle.xml new file mode 100644 index 0000000000..47c01a2ea1 --- /dev/null +++ b/codequality/checkstyle.xml @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/main/java/feign/Body.java b/core/src/main/java/feign/Body.java new file mode 100644 index 0000000000..f4d5d2bdc9 --- /dev/null +++ b/core/src/main/java/feign/Body.java @@ -0,0 +1,28 @@ +package feign; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.Map; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * A possibly templated body of a PUT or POST command. variables wrapped in curly braces are expanded before the + * request is submitted. + *
+ * ex. + *
+ *
+ * @Body("<v01:getResourceRecordsOfZone><zoneName>{zoneName}</zoneName><rrType>0</rrType></v01:getResourceRecordsOfZone>")
+ * List<Record> listByZone(@Named("zoneName") String zoneName);
+ * 
+ *
+ * Note that if you'd like curly braces literally in the body, urlencode + * them first. + * + * @see RequestTemplate#expand(String, Map) + */ +@Target(METHOD) @Retention(RUNTIME) public @interface Body { + String value(); +} diff --git a/core/src/main/java/feign/Client.java b/core/src/main/java/feign/Client.java new file mode 100644 index 0000000000..be6a0ba179 --- /dev/null +++ b/core/src/main/java/feign/Client.java @@ -0,0 +1,151 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPOutputStream; + +import javax.inject.Inject; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; + +import dagger.Lazy; +import feign.Request.Options; + +import static feign.Util.CONTENT_ENCODING; +import static feign.Util.CONTENT_LENGTH; +import static feign.Util.ENCODING_GZIP; +import static feign.Util.UTF_8; + +/** + * Submits HTTP {@link Request requests}. Implementations are expected to be + * thread-safe. + */ +public interface Client { + /** + * Executes a request against its {@link Request#url() url} and returns a + * response. + * + * @param request safe to replay. + * @param options options to apply to this request. + * @return connected response, {@link Response.Body} is absent or unread. + * @throws IOException on a network error connecting to {@link Request#url()}. + */ + Response execute(Request request, Options options) throws IOException; + + public static class Default implements Client { + private final Lazy sslContextFactory; + private final Lazy hostnameVerifier; + + @Inject public Default(Lazy sslContextFactory, Lazy hostnameVerifier) { + this.sslContextFactory = sslContextFactory; + this.hostnameVerifier = hostnameVerifier; + } + + @Override public Response execute(Request request, Options options) throws IOException { + HttpURLConnection connection = convertAndSend(request, options); + return convertResponse(connection); + } + + HttpURLConnection convertAndSend(Request request, Options options) throws IOException { + final HttpURLConnection connection = (HttpURLConnection) new URL(request.url()).openConnection(); + if (connection instanceof HttpsURLConnection) { + HttpsURLConnection sslCon = (HttpsURLConnection) connection; + sslCon.setSSLSocketFactory(sslContextFactory.get()); + sslCon.setHostnameVerifier(hostnameVerifier.get()); + } + connection.setConnectTimeout(options.connectTimeoutMillis()); + connection.setReadTimeout(options.readTimeoutMillis()); + connection.setAllowUserInteraction(false); + connection.setInstanceFollowRedirects(true); + connection.setRequestMethod(request.method()); + + Collection contentEncodingValues = request.headers().get(CONTENT_ENCODING); + boolean gzipEncodedRequest = contentEncodingValues != null && contentEncodingValues.contains(ENCODING_GZIP); + + Integer contentLength = null; + for (String field : request.headers().keySet()) { + for (String value : request.headers().get(field)) { + if (field.equals(CONTENT_LENGTH)) { + if (!gzipEncodedRequest) { + contentLength = Integer.valueOf(value); + connection.addRequestProperty(field, value); + } + } else { + connection.addRequestProperty(field, value); + } + } + } + + if (request.body() != null) { + if (contentLength != null) { + connection.setFixedLengthStreamingMode(contentLength); + } else { + connection.setChunkedStreamingMode(8196); + } + connection.setDoOutput(true); + OutputStream out = connection.getOutputStream(); + if (gzipEncodedRequest) { + out = new GZIPOutputStream(out); + } + try { + out.write(request.body().getBytes(UTF_8)); + } finally { + try { + out.close(); + } catch (IOException suppressed) { // NOPMD + } + } + } + return connection; + } + + Response convertResponse(HttpURLConnection connection) throws IOException { + int status = connection.getResponseCode(); + String reason = connection.getResponseMessage(); + + Map> headers = new LinkedHashMap>(); + for (Map.Entry> field : connection.getHeaderFields().entrySet()) { + // response message + if (field.getKey() != null) + headers.put(field.getKey(), field.getValue()); + } + + Integer length = connection.getContentLength(); + if (length == -1) + length = null; + InputStream stream; + if (status >= 400) { + stream = connection.getErrorStream(); + } else { + stream = connection.getInputStream(); + } + Reader body = stream != null ? new InputStreamReader(stream) : null; + return Response.create(status, reason, headers, body, length); + } + } +} diff --git a/core/src/main/java/feign/Contract.java b/core/src/main/java/feign/Contract.java new file mode 100644 index 0000000000..eed9b7bd1a --- /dev/null +++ b/core/src/main/java/feign/Contract.java @@ -0,0 +1,190 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import javax.inject.Named; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import static feign.Util.checkState; +import static feign.Util.emptyToNull; +import static feign.Util.resolveLastTypeParameter; + +/** + * Defines what annotations and values are valid on interfaces. + */ +public interface Contract { + + /** + * Called to parse the methods in the class that are linked to HTTP requests. + */ + List parseAndValidatateMetadata(Class declaring); + + public static abstract class BaseContract implements Contract { + + @Override public List parseAndValidatateMetadata(Class declaring) { + List metadata = new ArrayList(); + for (Method method : declaring.getDeclaredMethods()) { + if (method.getDeclaringClass() == Object.class) + continue; + metadata.add(parseAndValidatateMetadata(method)); + } + return metadata; + } + + /** + * Called indirectly by {@link #parseAndValidatateMetadata(Class)}. + */ + public MethodMetadata parseAndValidatateMetadata(Method method) { + MethodMetadata data = new MethodMetadata(); + data.returnType(method.getGenericReturnType()); + data.configKey(Feign.configKey(method)); + + if (Observable.class.isAssignableFrom(method.getReturnType())) { + Type context = method.getGenericReturnType(); + Type observableType = resolveLastTypeParameter(method.getGenericReturnType(), Observable.class); + checkState(observableType != null, "Expected param %s to be Observable or Observable or a subtype", + context, observableType); + data.incrementalType(observableType); + } + + for (Annotation methodAnnotation : method.getAnnotations()) { + processAnnotationOnMethod(data, methodAnnotation, method); + } + checkState(data.template().method() != null, "Method %s not annotated with HTTP method type (ex. GET, POST)", + method.getName()); + Class[] parameterTypes = method.getParameterTypes(); + + Annotation[][] parameterAnnotations = method.getParameterAnnotations(); + int count = parameterAnnotations.length; + for (int i = 0; i < count; i++) { + boolean isHttpAnnotation = false; + if (parameterAnnotations[i] != null) { + isHttpAnnotation = processAnnotationsOnParameter(data, parameterAnnotations[i], i); + } + if (parameterTypes[i] == URI.class) { + data.urlIndex(i); + } else if (!isHttpAnnotation) { + checkState(!Observer.class.isAssignableFrom(parameterTypes[i]), + "Please return Observer as opposed to passing an Observable arg: %s", method); + checkState(data.formParams().isEmpty(), "Body parameters cannot be used with form parameters."); + checkState(data.bodyIndex() == null, "Method has too many Body parameters: %s", method); + data.bodyIndex(i); + data.bodyType(method.getGenericParameterTypes()[i]); + } + } + return data; + } + + /** + * @param data metadata collected so far relating to the current java method. + * @param annotation annotations present on the current method annotation. + * @param method method currently being processed. + */ + protected abstract void processAnnotationOnMethod(MethodMetadata data, Annotation annotation, Method method); + + /** + * @param data metadata collected so far relating to the current java method. + * @param annotations annotations present on the current parameter annotation. + * @param paramIndex if you find a name in {@code annotations}, call {@link #nameParam(MethodMetadata, String, + * int)} with this as the last parameter. + * @return true if you called {@link #nameParam(MethodMetadata, String, int)} after finding an http-relevant + * annotation. + */ + protected abstract boolean processAnnotationsOnParameter(MethodMetadata data, Annotation[] annotations, int paramIndex); + + + protected Collection addTemplatedParam(Collection possiblyNull, String name) { + if (possiblyNull == null) + possiblyNull = new ArrayList(); + possiblyNull.add(String.format("{%s}", name)); + return possiblyNull; + } + + /** + * links a parameter name to its index in the method signature. + */ + protected void nameParam(MethodMetadata data, String name, int i) { + Collection names = data.indexToName().containsKey(i) ? data.indexToName().get(i) : new ArrayList(); + names.add(name); + data.indexToName().put(i, names); + } + } + + static class Default extends BaseContract { + + @Override + protected void processAnnotationOnMethod(MethodMetadata data, Annotation methodAnnotation, Method method) { + Class annotationType = methodAnnotation.annotationType(); + if (annotationType == RequestLine.class) { + String requestLine = RequestLine.class.cast(methodAnnotation).value(); + checkState(emptyToNull(requestLine) != null, "RequestLine annotation was empty on method %s.", method.getName()); + if (requestLine.indexOf(' ') == -1) { + data.template().method(requestLine); + return; + } + data.template().method(requestLine.substring(0, requestLine.indexOf(' '))); + if (requestLine.indexOf(' ') == requestLine.lastIndexOf(' ')) { + // no HTTP version is ok + data.template().append(requestLine.substring(requestLine.indexOf(' ') + 1)); + } else { + // skip HTTP version + data.template().append(requestLine.substring(requestLine.indexOf(' ') + 1, requestLine.lastIndexOf(' '))); + } + } else if (annotationType == Body.class) { + String body = Body.class.cast(methodAnnotation).value(); + checkState(emptyToNull(body) != null, "Body annotation was empty on method %s.", method.getName()); + if (body.indexOf('{') == -1) { + data.template().body(body); + } else { + data.template().bodyTemplate(body); + } + } else if (annotationType == Headers.class) { + String[] headersToParse = Headers.class.cast(methodAnnotation).value(); + checkState(headersToParse.length > 0, "Headers annotation was empty on method %s.", method.getName()); + for (String header : headersToParse) { + int colon = header.indexOf(':'); + data.template().header(header.substring(0, colon), header.substring(colon + 2)); + } + } + } + + @Override + protected boolean processAnnotationsOnParameter(MethodMetadata data, Annotation[] annotations, int paramIndex) { + boolean isHttpAnnotation = false; + for (Annotation parameterAnnotation : annotations) { + Class annotationType = parameterAnnotation.annotationType(); + if (annotationType == Named.class) { + String name = Named.class.cast(parameterAnnotation).value(); + checkState(emptyToNull(name) != null, "Named annotation was empty on param %s.", paramIndex); + nameParam(data, name, paramIndex); + isHttpAnnotation = true; + if (data.template().url().indexOf('{' + name + '}') == -1 && // + !(data.template().queries().containsKey(name) + || data.template().headers().containsKey(name))) { + data.formParams().add(name); + } + } + } + return isHttpAnnotation; + } + } +} diff --git a/core/src/main/java/feign/Feign.java b/core/src/main/java/feign/Feign.java new file mode 100644 index 0000000000..f92841e9b0 --- /dev/null +++ b/core/src/main/java/feign/Feign.java @@ -0,0 +1,204 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + + +import dagger.Lazy; +import dagger.ObjectGraph; +import dagger.Provides; +import feign.Logger.NoOpLogger; +import feign.Request.Options; +import feign.Target.HardCodedTarget; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; +import feign.codec.IncrementalDecoder; + +import javax.inject.Named; +import javax.inject.Singleton; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; +import java.io.Closeable; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +import static java.lang.Thread.MIN_PRIORITY; + +/** + * Feign's purpose is to ease development against http apis that feign + * restfulness. + *
+ * In implementation, Feign is a {@link Feign#newInstance factory} for + * generating {@link Target targeted} http apis. + */ +public abstract class Feign implements Closeable { + + /** + * Returns a new instance of an HTTP API, defined by annotations in the + * {@link Feign Contract}, for the specified {@code target}. You should + * cache this result. + */ + public abstract T newInstance(Target target); + + public static T create(Class apiType, String url, Object... modules) { + return create(new HardCodedTarget(apiType, url), modules); + } + + /** + * Shortcut to {@link #newInstance(Target) create} a single {@code targeted} + * http api using {@link ReflectiveFeign reflection}. + */ + public static T create(Target target, Object... modules) { + return create(modules).newInstance(target); + } + + /** + * Returns a {@link ReflectiveFeign reflective} factory for generating + * {@link Target targeted} http apis. + */ + public static Feign create(Object... modules) { + return ObjectGraph.create(modulesForGraph(modules).toArray()).get(Feign.class); + } + + + /** + * Returns an {@link ObjectGraph Dagger ObjectGraph} that can inject a + * {@link ReflectiveFeign reflective} Feign. + */ + public static ObjectGraph createObjectGraph(Object... modules) { + return ObjectGraph.create(modulesForGraph(modules).toArray()); + } + + @SuppressWarnings("rawtypes") + @dagger.Module(complete = false, injects = Feign.class, library = true) + public static class Defaults { + + @Provides Logger.Level logLevel() { + return Logger.Level.NONE; + } + + @Provides Contract contract() { + return new Contract.Default(); + } + + @Provides SSLSocketFactory sslSocketFactory() { + return SSLSocketFactory.class.cast(SSLSocketFactory.getDefault()); + } + + @Provides + HostnameVerifier hostnameVerifier() { + return HttpsURLConnection.getDefaultHostnameVerifier(); + } + + @Provides Client httpClient(Client.Default client) { + return client; + } + + @Provides Retryer retryer() { + return new Retryer.Default(); + } + + @Provides Logger noOp() { + return new NoOpLogger(); + } + + @Provides ErrorDecoder errorDecoder() { + return new ErrorDecoder.Default(); + } + + @Provides Options options() { + return new Options(); + } + + /** + * Used for both http invocation and decoding when observers are used. + */ + @Provides @Singleton @Named("http") Executor httpExecutor() { + return Executors.newCachedThreadPool(new ThreadFactory() { + @Override public Thread newThread(final Runnable r) { + return new Thread(new Runnable() { + @Override public void run() { + Thread.currentThread().setPriority(MIN_PRIORITY); + r.run(); + } + }, MethodHandler.IDLE_THREAD_NAME); + } + }); + } + } + + /** + *
+ * Configuration keys are formatted as unresolved see tags. + *
+ * For example. + *
    + *
  • {@code Route53}: would match a class such as + * {@code denominator.route53.Route53} + *
  • {@code Route53#list()}: would match a method such as + * {@code denominator.route53.Route53#list()} + *
  • {@code Route53#listAt(Marker)}: would match a method such as + * {@code denominator.route53.Route53#listAt(denominator.route53.Marker)} + *
  • {@code Route53#listByNameAndType(String, String)}: would match a + * method such as {@code denominator.route53.Route53#listAt(String, String)} + *
+ *
+ * Note that there is no whitespace expected in a key! + */ + public static String configKey(Method method) { + StringBuilder builder = new StringBuilder(); + builder.append(method.getDeclaringClass().getSimpleName()); + builder.append('#').append(method.getName()).append('('); + for (Class param : method.getParameterTypes()) + builder.append(param.getSimpleName()).append(','); + if (method.getParameterTypes().length > 0) + builder.deleteCharAt(builder.length() - 1); + return builder.append(')').toString(); + } + + private static List modulesForGraph(Object... modules) { + List modulesForGraph = new ArrayList(3); + modulesForGraph.add(new Defaults()); + modulesForGraph.add(new ReflectiveFeign.Module()); + if (modules != null) + for (Object module : modules) + modulesForGraph.add(module); + return modulesForGraph; + } + + private final Lazy httpExecutor; + + Feign(Lazy httpExecutor) { + this.httpExecutor = httpExecutor; + } + + @Override public void close() { + Executor e = httpExecutor.get(); + if (e instanceof ExecutorService) { + ExecutorService.class.cast(e).shutdownNow(); + } + } +} diff --git a/core/src/main/java/feign/FeignException.java b/core/src/main/java/feign/FeignException.java new file mode 100644 index 0000000000..ebdf7b0650 --- /dev/null +++ b/core/src/main/java/feign/FeignException.java @@ -0,0 +1,61 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import static java.lang.String.format; + +import java.io.IOException; + +import feign.codec.StringDecoder; + +/** + * Origin exception type for all Http Apis. + */ +public class FeignException extends RuntimeException { + static FeignException errorReading(Request request, Response response, IOException cause) { + return new FeignException(format("%s %s %s", cause.getMessage(), request.method(), request.url(), 0), cause); + } + + private static final StringDecoder toString = new StringDecoder(); + + public static FeignException errorStatus(String methodKey, Response response) { + String message = format("status %s reading %s", response.status(), methodKey); + try { + if (response.body() != null) { + String body = toString.decode(response.body().asReader(), String.class); + response = Response.create(response.status(), response.reason(), response.headers(), body.toString()); + message += "; content:\n" + body; + } + } catch (IOException ignored) { // NOPMD + } + return new FeignException(message); + } + + static FeignException errorExecuting(Request request, IOException cause) { + return new RetryableException(format("error %s executing %s %s", cause.getMessage(), request.method(), + request.url()), cause, null); + } + + protected FeignException(String message, Throwable cause) { + super(message, cause); + } + + protected FeignException(String message) { + super(message); + } + + private static final long serialVersionUID = 0; +} diff --git a/core/src/main/java/feign/Headers.java b/core/src/main/java/feign/Headers.java new file mode 100644 index 0000000000..b1d7061fe1 --- /dev/null +++ b/core/src/main/java/feign/Headers.java @@ -0,0 +1,50 @@ +package feign; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Expands headers supplied in the {@code value}. Variables are permitted as values. + *
+ *
+ * @RequestLine("GET /")
+ * @Headers("Cache-Control: max-age=640000")
+ * ...
+ *
+ * @RequestLine("POST /")
+ * @Headers({
+ *   "X-Foo: Bar",
+ *   "X-Ping: {token}"
+ * }) void post(@Named("token") String token);
+ * ...
+ * 
+ *
+ * Note: Headers do not overwrite each other. All headers with the same name will + * be included in the request. + *

Relationship to JAXRS
+ *
+ * The following two forms are identical. + *
+ * Feign: + *
+ * @RequestLine("POST /")
+ * @Headers({
+ *   "X-Ping: {token}"
+ * }) void post(@Named("token") String token);
+ * ...
+ * 
+ *
+ * JAX-RS: + *
+ * @POST @Path("/")
+ * void post(@HeaderParam("X-Ping") String token);
+ * ...
+ * 
+ */ +@Target(METHOD) @Retention(RUNTIME) +public @interface Headers { + String[] value(); +} diff --git a/core/src/main/java/feign/Logger.java b/core/src/main/java/feign/Logger.java new file mode 100644 index 0000000000..48853b1f29 --- /dev/null +++ b/core/src/main/java/feign/Logger.java @@ -0,0 +1,215 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.Reader; +import java.io.StringWriter; +import java.util.logging.FileHandler; +import java.util.logging.LogRecord; +import java.util.logging.SimpleFormatter; + +import static feign.Util.UTF_8; +import static feign.Util.ensureClosed; +import static feign.Util.valuesOrEmpty; + +/** + * Simple logging abstraction for debug messages. Adapted from {@code retrofit.RestAdapter.Log}. + */ +public abstract class Logger { + + /** + * Controls the level of logging. + */ + public enum Level { + /** + * No logging. + */ + NONE, + /** + * Log only the request method and URL and the response status code and execution time. + */ + BASIC, + /** + * Log the basic information along with request and response headers. + */ + HEADERS, + /** + * Log the headers, body, and metadata for both requests and responses. + */ + FULL + } + + /** + * logs to the category {@link Logger} at {@link java.util.logging.Level#FINE}. + */ + public static class ErrorLogger extends Logger { + final java.util.logging.Logger logger = java.util.logging.Logger.getLogger(Logger.class.getName()); + + @Override protected void log(String configKey, String format, Object... args) { + System.err.printf(methodTag(configKey) + format + "%n", args); + } + } + + /** + * logs to the category {@link Logger} at {@link java.util.logging.Level#FINE}, if loggable. + */ + public static class JavaLogger extends Logger { + final java.util.logging.Logger logger = java.util.logging.Logger.getLogger(Logger.class.getName()); + + @Override void logRequest(String configKey, Level logLevel, Request request) { + if (logger.isLoggable(java.util.logging.Level.FINE)) { + super.logRequest(configKey, logLevel, request); + } + } + + @Override + Response logAndRebufferResponse(String configKey, Level logLevel, Response response, long elapsedTime) throws IOException { + if (logger.isLoggable(java.util.logging.Level.FINE)) { + return super.logAndRebufferResponse(configKey, logLevel, response, elapsedTime); + } + return response; + } + + @Override protected void log(String configKey, String format, Object... args) { + logger.fine(String.format(methodTag(configKey) + format, args)); + } + + /** + * helper that configures jul to sanely log messages at FINE level without additional formatting. + */ + public JavaLogger appendToFile(String logfile) { + logger.setLevel(java.util.logging.Level.FINE); + try { + FileHandler handler = new FileHandler(logfile, true); + handler.setFormatter(new SimpleFormatter() { + @Override + public String format(LogRecord record) { + return String.format("%s%n", record.getMessage()); // NOPMD + } + }); + logger.addHandler(handler); + } catch (IOException e) { + throw new IllegalStateException("Could not add file handler.", e); + } + return this; + } + } + + public static class NoOpLogger extends Logger { + @Override void logRequest(String configKey, Level logLevel, Request request) { + } + + @Override + Response logAndRebufferResponse(String configKey, Level logLevel, Response response, long elapsedTime) throws IOException { + return response; + } + + @Override + protected void log(String configKey, String format, Object... args) { + } + } + + /** + * Override to log requests and responses using your own implementation. + * Messages will be http request and response text. + * + * @param configKey value of {@link Feign#configKey(java.lang.reflect.Method)} + * @param format {@link java.util.Formatter format string} + * @param args arguments applied to {@code format} + */ + protected abstract void log(String configKey, String format, Object... args); + + void logRequest(String configKey, Level logLevel, Request request) { + log(configKey, "---> %s %s HTTP/1.1", request.method(), request.url()); + if (logLevel.ordinal() >= Level.HEADERS.ordinal()) { + + for (String field : request.headers().keySet()) { + for (String value : valuesOrEmpty(request.headers(), field)) { + log(configKey, "%s: %s", field, value); + } + } + + int bytes = 0; + if (request.body() != null) { + bytes = request.body().getBytes(UTF_8).length; + if (logLevel.ordinal() >= Level.FULL.ordinal()) { + log(configKey, ""); // CRLF + log(configKey, "%s", request.body()); + } + } + log(configKey, "---> END HTTP (%s-byte body)", bytes); + } + } + + void logRetry(String configKey, Level logLevel) { + log(configKey, "---> RETRYING"); + } + + Response logAndRebufferResponse(String configKey, Level logLevel, Response response, long elapsedTime) throws IOException { + log(configKey, "<--- HTTP/1.1 %s %s (%sms)", response.status(), response.reason(), elapsedTime); + if (logLevel.ordinal() >= Level.HEADERS.ordinal()) { + + for (String field : response.headers().keySet()) { + for (String value : valuesOrEmpty(response.headers(), field)) { + log(configKey, "%s: %s", field, value); + } + } + + if (response.body() != null) { + if (logLevel.ordinal() >= Level.FULL.ordinal()) { + log(configKey, ""); // CRLF + } + + Reader body = response.body().asReader(); + try { + StringBuilder buffered = new StringBuilder(); + BufferedReader reader = new BufferedReader(body); + String line; + while ((line = reader.readLine()) != null) { + buffered.append(line); + if (logLevel.ordinal() >= Level.FULL.ordinal()) { + log(configKey, "%s", line); + } + } + String bodyAsString = buffered.toString(); + log(configKey, "<--- END HTTP (%s-byte body)", bodyAsString.getBytes(UTF_8).length); + return Response.create(response.status(), response.reason(), response.headers(), bodyAsString); + } finally { + ensureClosed(response.body()); + } + } + } + return response; + } + + 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()) { + StringWriter sw = new StringWriter(); + ioe.printStackTrace(new PrintWriter(sw)); + log(configKey, sw.toString()); + log(configKey, "<--- END ERROR"); + } + return ioe; + } + + static String methodTag(String configKey) { + return new StringBuilder().append('[').append(configKey.substring(0, configKey.indexOf('('))).append("] ").toString(); + } +} diff --git a/core/src/main/java/feign/MethodHandler.java b/core/src/main/java/feign/MethodHandler.java new file mode 100644 index 0000000000..173285da5e --- /dev/null +++ b/core/src/main/java/feign/MethodHandler.java @@ -0,0 +1,315 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import dagger.Lazy; +import feign.Request.Options; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; +import feign.codec.IncrementalDecoder; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Provider; +import java.io.IOException; +import java.io.Reader; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static feign.FeignException.errorExecuting; +import static feign.FeignException.errorReading; +import static feign.Util.checkNotNull; +import static feign.Util.ensureClosed; + +interface MethodHandler { + Object invoke(Object[] argv) throws Throwable; + + static class Factory { + + private final Client client; + private final Lazy httpExecutor; + private final Provider retryer; + private final Set requestInterceptors; + private final Logger logger; + private final Provider logLevel; + + @Inject Factory(Client client, @Named("http") Lazy httpExecutor, Provider retryer, + Set requestInterceptors, Logger logger, Provider logLevel) { + this.client = checkNotNull(client, "client"); + this.httpExecutor = checkNotNull(httpExecutor, "httpExecutor"); + this.retryer = checkNotNull(retryer, "retryer"); + this.requestInterceptors = checkNotNull(requestInterceptors, "requestInterceptors"); + this.logger = checkNotNull(logger, "logger"); + this.logLevel = checkNotNull(logLevel, "logLevel"); + } + + public MethodHandler create(Target target, MethodMetadata md, BuildTemplateFromArgs buildTemplateFromArgs, + Options options, Decoder.TextStream decoder, ErrorDecoder errorDecoder) { + return new SynchronousMethodHandler(target, client, retryer, requestInterceptors, logger, logLevel, md, + buildTemplateFromArgs, options, decoder, errorDecoder); + } + + public MethodHandler create(Target target, MethodMetadata md, BuildTemplateFromArgs buildTemplateFromArgs, + Options options, IncrementalDecoder.TextStream incrementalDecoder, + ErrorDecoder errorDecoder) { + ObserverHandler observerHandler = new ObserverHandler(target, client, retryer, requestInterceptors, logger, + logLevel, md, buildTemplateFromArgs, options, incrementalDecoder, errorDecoder, httpExecutor); + return new ObservableMethodHandler(observerHandler); + } + } + + /** + * Those using guava will implement as {@code Function}. + */ + interface BuildTemplateFromArgs { + public RequestTemplate apply(Object[] argv); + } + + static class ObservableMethodHandler implements MethodHandler { + private final ObserverHandler observerHandler; + + private ObservableMethodHandler(ObserverHandler observerHandler) { + this.observerHandler = observerHandler; + } + + @Override public Object invoke(Object[] argv) { + final Object[] argvCopy = new Object[argv != null ? argv.length : 0]; + if (argv != null) + System.arraycopy(argv, 0, argvCopy, 0, argv.length); + + return new Observable() { + + @Override public Subscription subscribe(Observer observer) { + final Object[] oneMoreArg = new Object[argvCopy.length + 1]; + System.arraycopy(argvCopy, 0, oneMoreArg, 0, argvCopy.length); + oneMoreArg[argvCopy.length] = observer; + return observerHandler.invoke(oneMoreArg); + } + }; + } + } + + static class ObserverHandler extends BaseMethodHandler { + private final Lazy httpExecutor; + private final IncrementalDecoder.TextStream incrementalDecoder; + + private ObserverHandler(Target target, Client client, Provider retryer, + Set requestInterceptors, Logger logger, + Provider logLevel, MethodMetadata metadata, + BuildTemplateFromArgs buildTemplateFromArgs, Options options, + IncrementalDecoder.TextStream incrementalDecoder, ErrorDecoder errorDecoder, + Lazy httpExecutor) { + super(target, client, retryer, requestInterceptors, logger, logLevel, metadata, buildTemplateFromArgs, options, + errorDecoder); + this.httpExecutor = checkNotNull(httpExecutor, "httpExecutor for %s", target); + this.incrementalDecoder = checkNotNull(incrementalDecoder, "incrementalDecoder for %s", target); + } + + @Override public Subscription invoke(Object[] argv) { + final AtomicBoolean subscribed = new AtomicBoolean(true); + final Object[] oneMoreArg = new Object[argv.length + 1]; + System.arraycopy(argv, 0, oneMoreArg, 0, argv.length); + oneMoreArg[argv.length] = subscribed; + httpExecutor.get().execute(new Runnable() { + @Override public void run() { + Error error = null; + Object arg = oneMoreArg[oneMoreArg.length - 2]; + Observer observer = Observer.class.cast(arg); + try { + ObserverHandler.super.invoke(oneMoreArg); + observer.onSuccess(); + } catch (Error cause) { + // assign to a variable in case .onFailure throws a RTE + error = cause; + observer.onFailure(cause); + } catch (Throwable cause) { + observer.onFailure(cause); + } finally { + Thread.currentThread().setName(IDLE_THREAD_NAME); + if (error != null) + throw error; + } + } + }); + return new Subscription() { + @Override public void unsubscribe() { + subscribed.set(false); + } + }; + } + + @Override protected Void decode(Object[] oneMoreArg, Response response) throws IOException { + Object arg = oneMoreArg[oneMoreArg.length - 2]; + Observer observer = Observer.class.cast(arg); + AtomicBoolean subscribed = AtomicBoolean.class.cast(oneMoreArg[oneMoreArg.length - 1]); + if (metadata.incrementalType().equals(Response.class)) { + observer.onNext(response); + } else if (metadata.incrementalType() != Void.class) { + Response.Body body = response.body(); + if (body == null) + return null; + Reader reader = body.asReader(); + try { + incrementalDecoder.decode(reader, metadata.incrementalType(), observer, subscribed); + } finally { + ensureClosed(body); + } + } + return null; + } + + @Override protected Request targetRequest(RequestTemplate template) { + Request request = super.targetRequest(template); + Thread.currentThread().setName(THREAD_PREFIX + metadata.configKey()); + return request; + } + } + + /** + * same approach as retrofit: temporarily rename threads + */ + static String THREAD_PREFIX = "Feign-"; + static String IDLE_THREAD_NAME = THREAD_PREFIX + "Idle"; + + static class SynchronousMethodHandler extends BaseMethodHandler { + private final Decoder.TextStream decoder; + + private SynchronousMethodHandler(Target target, Client client, Provider retryer, + Set requestInterceptors, Logger logger, + Provider logLevel, MethodMetadata metadata, + BuildTemplateFromArgs buildTemplateFromArgs, Options options, + Decoder.TextStream decoder, ErrorDecoder errorDecoder) { + super(target, client, retryer, requestInterceptors, logger, logLevel, metadata, buildTemplateFromArgs, options, + errorDecoder); + this.decoder = checkNotNull(decoder, "decoder for %s", target); + } + + @Override protected Object decode(Object[] argv, Response response) throws Throwable { + if (metadata.returnType().equals(Response.class)) { + return response; + } else if (metadata.returnType() == void.class || response.body() == null) { + return null; + } + try { + return decoder.decode(response.body().asReader(), metadata.returnType()); + } catch (FeignException e) { + throw e; + } catch (RuntimeException e) { + throw new DecodeException(e.getMessage(), e); + } + } + } + + static abstract class BaseMethodHandler implements MethodHandler { + + protected final MethodMetadata metadata; + protected final Target target; + protected final Client client; + protected final Provider retryer; + protected final Set requestInterceptors; + protected final Logger logger; + protected final Provider logLevel; + protected final BuildTemplateFromArgs buildTemplateFromArgs; + protected final Options options; + protected final ErrorDecoder errorDecoder; + + private BaseMethodHandler(Target target, Client client, Provider retryer, + Set requestInterceptors, Logger logger, + Provider logLevel, MethodMetadata metadata, + BuildTemplateFromArgs buildTemplateFromArgs, Options options, ErrorDecoder errorDecoder) { + this.target = checkNotNull(target, "target"); + this.client = checkNotNull(client, "client for %s", target); + this.retryer = checkNotNull(retryer, "retryer for %s", target); + this.requestInterceptors = checkNotNull(requestInterceptors, "requestInterceptors for %s", target); + this.logger = checkNotNull(logger, "logger for %s", target); + this.logLevel = checkNotNull(logLevel, "logLevel for %s", target); + this.metadata = checkNotNull(metadata, "metadata for %s", target); + this.buildTemplateFromArgs = checkNotNull(buildTemplateFromArgs, "metadata for %s", target); + this.options = checkNotNull(options, "options for %s", target); + this.errorDecoder = checkNotNull(errorDecoder, "errorDecoder for %s", target); + } + + @Override public Object invoke(Object[] argv) throws Throwable { + RequestTemplate template = buildTemplateFromArgs.apply(argv); + Retryer retryer = this.retryer.get(); + while (true) { + try { + return executeAndDecode(argv, template); + } catch (RetryableException e) { + retryer.continueOrPropagate(e); + if (logLevel.get() != Logger.Level.NONE) { + logger.logRetry(metadata.configKey(), logLevel.get()); + } + continue; + } + } + } + + public Object executeAndDecode(Object[] argv, RequestTemplate template) throws Throwable { + Request request = targetRequest(template); + + if (logLevel.get() != Logger.Level.NONE) { + logger.logRequest(metadata.configKey(), logLevel.get(), request); + } + + Response response; + long start = System.nanoTime(); + try { + response = client.execute(request, options); + } catch (IOException e) { + if (logLevel.get() != Logger.Level.NONE) { + logger.logIOException(metadata.configKey(), logLevel.get(), e, elapsedTime(start)); + } + throw errorExecuting(request, e); + } + long elapsedTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + + try { + if (logLevel.get() != Logger.Level.NONE) { + response = logger.logAndRebufferResponse(metadata.configKey(), logLevel.get(), response, elapsedTime); + } + if (response.status() >= 200 && response.status() < 300) { + return decode(argv, response); + } else { + throw errorDecoder.decode(metadata.configKey(), response); + } + } catch (IOException e) { + if (logLevel.get() != Logger.Level.NONE) { + logger.logIOException(metadata.configKey(), logLevel.get(), e, elapsedTime); + } + throw errorReading(request, response, e); + } finally { + ensureClosed(response.body()); + } + } + + protected long elapsedTime(long start) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + } + + protected Request targetRequest(RequestTemplate template) { + for (RequestInterceptor interceptor : requestInterceptors) { + interceptor.apply(template); + } + return target.apply(new RequestTemplate(template)); + } + + protected abstract Object decode(Object[] argv, Response response) throws Throwable; + } +} diff --git a/core/src/main/java/feign/MethodMetadata.java b/core/src/main/java/feign/MethodMetadata.java new file mode 100644 index 0000000000..14ca1f1a33 --- /dev/null +++ b/core/src/main/java/feign/MethodMetadata.java @@ -0,0 +1,120 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import java.io.Serializable; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class MethodMetadata implements Serializable { + + MethodMetadata() { + } + + private String configKey; + private transient Type returnType; + private transient Type incrementalType; + private Integer urlIndex; + private Integer observerIndex; + private Integer bodyIndex; + private transient Type bodyType; + private RequestTemplate template = new RequestTemplate(); + private List formParams = new ArrayList(); + private Map> indexToName = new LinkedHashMap>(); + + /** + * @see Feign#configKey(java.lang.reflect.Method) + */ + public String configKey() { + return configKey; + } + + MethodMetadata configKey(String configKey) { + this.configKey = configKey; + return this; + } + + /** + * Method return type. + */ + public Type returnType() { + return returnType; + } + + MethodMetadata returnType(Type returnType) { + this.returnType = returnType; + return this; + } + + /** + * Type that {@link feign.codec.IncrementalDecoder} must process. If null, + * {@link feign.codec.Decoder} will be used against the {@link #returnType()}; + */ + public Type incrementalType() { + return incrementalType; + } + + MethodMetadata incrementalType(Type incrementalType) { + this.incrementalType = incrementalType; + return this; + } + + public Integer urlIndex() { + return urlIndex; + } + + MethodMetadata urlIndex(Integer urlIndex) { + this.urlIndex = urlIndex; + return this; + } + + public Integer bodyIndex() { + return bodyIndex; + } + + MethodMetadata bodyIndex(Integer bodyIndex) { + this.bodyIndex = bodyIndex; + return this; + } + + public Type bodyType() { + return bodyType; + } + + MethodMetadata bodyType(Type bodyType) { + this.bodyType = bodyType; + return this; + } + + public RequestTemplate template() { + return template; + } + + public List formParams() { + return formParams; + } + + public Map> indexToName() { + return indexToName; + } + + private static final long serialVersionUID = 1L; + +} diff --git a/core/src/main/java/feign/Observable.java b/core/src/main/java/feign/Observable.java new file mode 100644 index 0000000000..0ea6112e84 --- /dev/null +++ b/core/src/main/java/feign/Observable.java @@ -0,0 +1,39 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +/** + * An {@code Observer} is asynchronous equivalent to an {@code Iterable}. + *
+ * Each call to {@link #subscribe(Observer)} implies a new + * {@link Request HTTP request}. + * + * @param expected value to decode incrementally from the http response. + */ +public interface Observable { + + /** + * Calling subscribe will initiate a new HTTP request which will be + * {@link feign.codec.IncrementalDecoder incrementally decoded} into the + * {@code observer} until it is finished or + * {@link feign.Subscription#unsubscribe()} is called. + * + * @param observer + * @return a {@link Subscription} with which you can stop the streaming of + * events to the {@code observer}. + */ + public Subscription subscribe(Observer observer); +} diff --git a/core/src/main/java/feign/Observer.java b/core/src/main/java/feign/Observer.java new file mode 100644 index 0000000000..d0aa6c78c4 --- /dev/null +++ b/core/src/main/java/feign/Observer.java @@ -0,0 +1,68 @@ +package feign; + +/** + * An {@code Observer} is asynchronous equivalent to an {@code Iterator}. + *

+ * Observers receive results as they are + * {@link feign.codec.IncrementalDecoder decoded} from an + * {@link Response.Body http response body}. {@link #onNext(Object) onNext} + * will be called for each incremental value of type {@code T} until + * {@link feign.Subscription#unsubscribe()} is called or the response is finished. + *
+ * {@link #onSuccess() onSuccess} or {@link #onFailure(Throwable)} onFailure} + * will be called when the response is finished, but not both. + *
+ * {@code Observer} can be used as an asynchronous alternative to a + * {@code Collection}, or any other use where iterative response parsing is + * worth the additional effort to implement this interface. + *
+ *
+ * Here's an example of implementing {@code Observer}: + *
+ *

+ * Observer counter = new Observer() {
+ *
+ *   public int count;
+ *
+ *   @Override public void onNext(Contributor element) {
+ *     count++;
+ *   }
+ *
+ *   @Override public void onSuccess() {
+ *     System.out.println("found " + count + " contributors");
+ *   }
+ *
+ *   @Override public void onFailure(Throwable cause) {
+ *     System.err.println("sad face after contributor " + count);
+ *   }
+ * };
+ * subscription = github.contributors("netflix", "feign", counter);
+ * 
+ * + * @param expected value to decode incrementally from the http response. + */ +public interface Observer { + /** + * Invoked as soon as new data is available. Could be invoked many times or + * not at all. + * + * @param element next decoded element. + */ + void onNext(T element); + + /** + * Called when response processing completed successfully. + */ + void onSuccess(); + + /** + * Called when response processing failed for any reason. + *
+ * Common failure cases include {@link FeignException}, + * {@link java.io.IOException}, and {@link feign.codec.DecodeException}. + * However, the cause could be a {@code Throwable} of any kind. + * + * @param cause the reason for the failure + */ + void onFailure(Throwable cause); +} diff --git a/core/src/main/java/feign/ReflectiveFeign.java b/core/src/main/java/feign/ReflectiveFeign.java new file mode 100644 index 0000000000..2bdb9a114a --- /dev/null +++ b/core/src/main/java/feign/ReflectiveFeign.java @@ -0,0 +1,318 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import dagger.Lazy; +import dagger.Provides; +import feign.Request.Options; +import feign.codec.Decoder; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; +import feign.codec.IncrementalDecoder; +import feign.codec.StringDecoder; +import feign.codec.StringIncrementalDecoder; + +import javax.inject.Inject; +import javax.inject.Named; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.concurrent.Executor; + +import static feign.Util.checkArgument; +import static feign.Util.checkNotNull; +import static feign.Util.checkState; +import static feign.Util.resolveLastTypeParameter; +import static java.lang.String.format; + +@SuppressWarnings("rawtypes") +public class ReflectiveFeign extends Feign { + + private final ParseHandlersByName targetToHandlersByName; + + @Inject ReflectiveFeign(@Named("http") Lazy httpExecutor, ParseHandlersByName targetToHandlersByName) { + super(httpExecutor); + this.targetToHandlersByName = targetToHandlersByName; + } + + /** + * creates an api binding to the {@code target}. As this invokes reflection, + * care should be taken to cache the result. + */ + @SuppressWarnings("unchecked") @Override public T newInstance(Target target) { + Map nameToHandler = targetToHandlersByName.apply(target); + Map methodToHandler = new LinkedHashMap(); + for (Method method : target.type().getDeclaredMethods()) { + if (method.getDeclaringClass() == Object.class) + continue; + methodToHandler.put(method, nameToHandler.get(Feign.configKey(method))); + } + FeignInvocationHandler handler = new FeignInvocationHandler(target, methodToHandler); + return (T) Proxy.newProxyInstance(target.type().getClassLoader(), new Class[]{target.type()}, handler); + } + + static class FeignInvocationHandler implements InvocationHandler { + + private final Target target; + private final Map methodToHandler; + + FeignInvocationHandler(Target target, Map methodToHandler) { + this.target = checkNotNull(target, "target"); + this.methodToHandler = checkNotNull(methodToHandler, "methodToHandler for %s", target); + } + + @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return methodToHandler.get(method).invoke(args); + } + + @Override public int hashCode() { + return target.hashCode(); + } + + @Override public boolean equals(Object obj) { + if (this == obj) + return true; + if (FeignInvocationHandler.class != obj.getClass()) + return false; + FeignInvocationHandler that = FeignInvocationHandler.class.cast(obj); + return this.target.equals(that.target); + } + + @Override public String toString() { + return "target(" + target + ")"; + } + } + + @dagger.Module(complete = false, injects = {Feign.class, MethodHandler.Factory.class}, library = true) + public static class Module { + @Provides(type = Provides.Type.SET_VALUES) Set noRequestInterceptors() { + return Collections.emptySet(); + } + + @Provides(type = Provides.Type.SET_VALUES) Set noEncoders() { + return Collections.emptySet(); + } + + @Provides(type = Provides.Type.SET_VALUES) Set noDecoders() { + return Collections.emptySet(); + } + + @Provides(type = Provides.Type.SET_VALUES) Set noIncrementalDecoders() { + return Collections.emptySet(); + } + + @Provides Feign provideFeign(ReflectiveFeign in) { + return in; + } + } + + static final class ParseHandlersByName { + private final Contract contract; + private final Options options; + private final Map> encoders = new HashMap>(); + private final Encoder.Text> formEncoder; + private final Map> decoders = new HashMap>(); + private final Map> incrementalDecoders = + new HashMap>(); + private final ErrorDecoder errorDecoder; + private final MethodHandler.Factory factory; + + @SuppressWarnings("unchecked") + @Inject ParseHandlersByName(Contract contract, Options options, Set encoders, Set decoders, + Set incrementalDecoders, ErrorDecoder errorDecoder, + MethodHandler.Factory factory) { + this.contract = contract; + this.options = options; + this.factory = factory; + this.errorDecoder = errorDecoder; + for (Encoder encoder : encoders) { + checkState(encoder instanceof Encoder.Text, + "Currently, only Encoder.Text is supported. Found: ", encoder); + Type type = resolveLastTypeParameter(encoder.getClass(), Encoder.class); + this.encoders.put(type, Encoder.Text.class.cast(encoder)); + } + try { + Type formEncoderType = getClass().getDeclaredField("formEncoder").getGenericType(); + Type formType = resolveLastTypeParameter(formEncoderType, Encoder.class); + Encoder.Text formEncoder = this.encoders.get(formType); + if (formEncoder == null) { + formEncoder = this.encoders.get(Object.class); + } + this.formEncoder = (Encoder.Text) formEncoder; + } catch (NoSuchFieldException e) { + throw new AssertionError(e); + } + StringDecoder stringDecoder = new StringDecoder(); + this.decoders.put(void.class, stringDecoder); + this.decoders.put(Response.class, stringDecoder); + this.decoders.put(String.class, stringDecoder); + for (Decoder decoder : decoders) { + checkState(decoder instanceof Decoder.TextStream, + "Currently, only Decoder.TextStream is supported. Found: ", decoder); + Type type = resolveLastTypeParameter(decoder.getClass(), Decoder.class); + this.decoders.put(type, Decoder.TextStream.class.cast(decoder)); + } + StringIncrementalDecoder stringIncrementalDecoder = new StringIncrementalDecoder(); + this.incrementalDecoders.put(Void.class, stringIncrementalDecoder); + this.incrementalDecoders.put(Response.class, stringIncrementalDecoder); + this.incrementalDecoders.put(String.class, stringIncrementalDecoder); + for (IncrementalDecoder incrementalDecoder : incrementalDecoders) { + checkState(incrementalDecoder instanceof IncrementalDecoder.TextStream, + "Currently, only IncrementalDecoder.TextStream is supported. Found: ", incrementalDecoder); + Type type = resolveLastTypeParameter(incrementalDecoder.getClass(), IncrementalDecoder.class); + this.incrementalDecoders.put(type, IncrementalDecoder.TextStream.class.cast(incrementalDecoder)); + } + } + + public Map apply(Target key) { + List metadata = contract.parseAndValidatateMetadata(key.type()); + Map result = new LinkedHashMap(); + for (MethodMetadata md : metadata) { + BuildTemplateByResolvingArgs buildTemplate; + if (!md.formParams().isEmpty() && md.template().bodyTemplate() == null) { + if (formEncoder == null) { + throw new IllegalStateException(format("%s needs @Provides(type = Set) Encoder encoder()" + + "{ // Encoder.Text> or Encoder.Text}", md.configKey())); + } + buildTemplate = new BuildFormEncodedTemplateFromArgs(md, formEncoder); + } else if (md.bodyIndex() != null) { + Encoder.Text encoder = encoders.get(md.bodyType()); + if (encoder == null) { + encoder = encoders.get(Object.class); + } + if (encoder == null) { + throw new IllegalStateException(format("%s needs @Provides(type = Set) Encoder encoder()" + + "{ // Encoder.Text<%s> or Encoder.Text}", md.configKey(), md.bodyType())); + } + buildTemplate = new BuildEncodedTemplateFromArgs(md, encoder); + } else { + buildTemplate = new BuildTemplateByResolvingArgs(md); + } + if (md.incrementalType() != null) { + IncrementalDecoder.TextStream incrementalDecoder = incrementalDecoders.get(md.incrementalType()); + if (incrementalDecoder == null) { + incrementalDecoder = incrementalDecoders.get(Object.class); + } + if (incrementalDecoder == null) { + throw new IllegalStateException(format("%s needs @Provides(type = Set) IncrementalDecoder incrementalDecoder()" + + "{ // IncrementalDecoder.TextStream<%s> or IncrementalDecoder.TextStream}", md.configKey(), md.incrementalType())); + } + result.put(md.configKey(), factory.create(key, md, buildTemplate, options, incrementalDecoder, errorDecoder)); + } else { + Decoder.TextStream decoder = decoders.get(md.returnType()); + if (decoder == null) { + decoder = decoders.get(Object.class); + } + if (decoder == null) { + throw new IllegalStateException(format("%s needs @Provides(type = Set) Decoder decoder()" + + "{ // Decoder.TextStream<%s> or Decoder.TextStream}", md.configKey(), md.returnType())); + } + result.put(md.configKey(), factory.create(key, md, buildTemplate, options, decoder, errorDecoder)); + } + } + return result; + } + } + + private static class BuildTemplateByResolvingArgs implements MethodHandler.BuildTemplateFromArgs { + protected final MethodMetadata metadata; + + private BuildTemplateByResolvingArgs(MethodMetadata metadata) { + this.metadata = metadata; + } + + public RequestTemplate apply(Object[] argv) { + RequestTemplate mutable = new RequestTemplate(metadata.template()); + if (metadata.urlIndex() != null) { + int urlIndex = metadata.urlIndex(); + checkArgument(argv[urlIndex] != null, "URI parameter %s was null", urlIndex); + mutable.insert(0, String.valueOf(argv[urlIndex])); + } + Map varBuilder = new LinkedHashMap(); + for (Entry> entry : metadata.indexToName().entrySet()) { + Object value = argv[entry.getKey()]; + if (value != null) { // Null values are skipped. + for (String name : entry.getValue()) + varBuilder.put(name, value); + } + } + return resolve(argv, mutable, varBuilder); + } + + protected RequestTemplate resolve(Object[] argv, RequestTemplate mutable, Map variables) { + return mutable.resolve(variables); + } + } + + private static class BuildFormEncodedTemplateFromArgs extends BuildTemplateByResolvingArgs { + private final Encoder.Text> formEncoder; + + private BuildFormEncodedTemplateFromArgs(MethodMetadata metadata, Encoder.Text> formEncoder) { + super(metadata); + this.formEncoder = formEncoder; + } + + @Override + protected RequestTemplate resolve(Object[] argv, RequestTemplate mutable, Map variables) { + Map formVariables = new LinkedHashMap(); + for (Entry entry : variables.entrySet()) { + if (metadata.formParams().contains(entry.getKey())) + formVariables.put(entry.getKey(), entry.getValue()); + } + try { + mutable.body(formEncoder.encode(formVariables)); + } catch (EncodeException e) { + throw e; + } catch (RuntimeException e) { + throw new EncodeException(e.getMessage(), e); + } + return super.resolve(argv, mutable, variables); + } + } + + private static class BuildEncodedTemplateFromArgs extends BuildTemplateByResolvingArgs { + private final Encoder.Text encoder; + + private BuildEncodedTemplateFromArgs(MethodMetadata metadata, Encoder.Text encoder) { + super(metadata); + this.encoder = encoder; + } + + @Override + protected RequestTemplate resolve(Object[] argv, RequestTemplate mutable, Map variables) { + Object body = argv[metadata.bodyIndex()]; + checkArgument(body != null, "Body parameter %s was null", metadata.bodyIndex()); + try { + mutable.body(encoder.encode(body)); + } catch (EncodeException e) { + throw e; + } catch (RuntimeException e) { + throw new EncodeException(e.getMessage(), e); + } + return super.resolve(argv, mutable, variables); + } + } +} diff --git a/core/src/main/java/feign/Request.java b/core/src/main/java/feign/Request.java new file mode 100644 index 0000000000..b40c956a05 --- /dev/null +++ b/core/src/main/java/feign/Request.java @@ -0,0 +1,117 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import static feign.Util.checkNotNull; +import static feign.Util.valuesOrEmpty; + +/** + * An immutable request to an http server. + *
+ *

Note
+ *
+ * Since {@link Feign} is designed for non-binary apis, and expectations are + * that any request can be replayed, we only support a String body. + */ +public final class Request { + + private final String method; + private final String url; + private final Map> headers; + private final String body; + + Request(String method, String url, Map> headers, String body) { + this.method = checkNotNull(method, "method of %s", url); + this.url = checkNotNull(url, "url"); + LinkedHashMap> copyOf = new LinkedHashMap>(); + copyOf.putAll(checkNotNull(headers, "headers of %s %s", method, url)); + this.headers = Collections.unmodifiableMap(copyOf); + this.body = body; // nullable + } + + /* Method to invoke on the server. */ + public String method() { + return method; + } + + /* Fully resolved URL including query. */ + public String url() { + return url; + } + + /* Ordered list of headers that will be sent to the server. */ + public Map> headers() { + return headers; + } + + /* If present, this is the replayable body to send to the server. */ + public String body() { + return body; + } + + /* Controls the per-request settings currently required to be implemented by all {@link Client clients} */ + public static class Options { + + private final int connectTimeoutMillis; + private final int readTimeoutMillis; + + public Options(int connectTimeoutMillis, int readTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + this.readTimeoutMillis = readTimeoutMillis; + } + + public Options() { + this(10 * 1000, 60 * 1000); + } + + /** + * Defaults to 10 seconds. {@code 0} implies no timeout. + * + * @see java.net.HttpURLConnection#getConnectTimeout() + */ + public int connectTimeoutMillis() { + return connectTimeoutMillis; + } + + /** + * Defaults to 60 seconds. {@code 0} implies no timeout. + * + * @see java.net.HttpURLConnection#getReadTimeout() + */ + public int readTimeoutMillis() { + return readTimeoutMillis; + } + } + + @Override public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(method).append(' ').append(url).append(" HTTP/1.1\n"); + for (String field : headers.keySet()) { + for (String value : valuesOrEmpty(headers, field)) { + builder.append(field).append(": ").append(value).append('\n'); + } + } + if (body != null) { + builder.append('\n').append(body); + } + return builder.toString(); + } +} diff --git a/core/src/main/java/feign/RequestInterceptor.java b/core/src/main/java/feign/RequestInterceptor.java new file mode 100644 index 0000000000..39b79c60b0 --- /dev/null +++ b/core/src/main/java/feign/RequestInterceptor.java @@ -0,0 +1,70 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +/** + * Zero or more {@code RequestInterceptors} may be configured for purposes + * such as adding headers to all requests. No guarantees are give with regards + * to the order that interceptors are applied. Once interceptors are applied, + * {@link Target#apply(RequestTemplate)} is called to create the immutable http + * request sent via {@link Client#execute(Request, feign.Request.Options)}. + *
+ *
+ * For example: + *
+ *
+ * public void apply(RequestTemplate input) {
+ *     input.replaceHeader("X-Auth", currentToken);
+ * }
+ * 
+ *
+ *
Configuration
+ *
+ * {@code RequestInterceptors} are configured via Dagger + * {@link dagger.Provides.Type#SET set} or + * {@link dagger.Provides.Type#SET_VALUES set values} + * {@link dagger.Provides provider} methods. + *
+ *
+ * For example: + *
+ *
+ * {@literal @}Provides(Type = SET) RequestInterceptor addTimestamp(TimestampInterceptor in) {
+ * return in;
+ * }
+ * 
+ *
+ *
Implementation notes
+ *
+ * Do not add parameters, such as {@code /path/{foo}/bar } + * in your implementation of {@link #apply(RequestTemplate)}. + *
+ * Interceptors are applied after the template's parameters are + * {@link RequestTemplate#resolve(java.util.Map) resolved}. This is to ensure + * that you can implement signatures are interceptors. + *
+ *

Relationship to Retrofit 1.x
+ *
+ * This class is similar to {@code RequestInterceptor.intercept()}, + * except that the implementation can read, remove, or otherwise mutate any + * part of the request template. + */ +public interface RequestInterceptor { + /** + * Called for every request. Add data using methods on the supplied {@link RequestTemplate}. + */ + void apply(RequestTemplate template); +} diff --git a/core/src/main/java/feign/RequestLine.java b/core/src/main/java/feign/RequestLine.java new file mode 100644 index 0000000000..b344144c53 --- /dev/null +++ b/core/src/main/java/feign/RequestLine.java @@ -0,0 +1,56 @@ +package feign; + +import java.lang.annotation.Retention; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Expands the request-line supplied in the {@code value}, permitting path and query variables, + * or just the http method. + *
+ *
+ * ...
+ * @RequestLine("POST /servers")
+ * ...
+ *
+ * @RequestLine("GET /servers/{serverId}?count={count}")
+ * void get(@Named("serverId") String serverId, @Named("count") int count);
+ * ...
+ *
+ * @RequestLine("GET")
+ * Response getNext(URI nextLink);
+ * ...
+ * 
+ * HTTP version suffix is optional, but permitted. There are no guarantees this version will impact that + * sent by the client. + *
+ *
+ * @RequestLine("POST /servers HTTP/1.1")
+ * ...
+ * 
+ *
+ * Note: Query params do not overwrite each other. All queries with the same name will + * be included in the request. + *

Relationship to JAXRS
+ *
+ * The following two forms are identical. + *
+ * Feign: + *
+ * @RequestLine("GET /servers/{serverId}?count={count}")
+ * void get(@Named("serverId") String serverId, @Named("count") int count);
+ * ...
+ * 
+ *
+ * JAX-RS: + *
+ * @GET @Path("/servers/{serverId}")
+ * void get(@PathParam("serverId") String serverId, @QueryParam("count") int count);
+ * ...
+ * 
+ */ +@java.lang.annotation.Target(METHOD) @Retention(RUNTIME) +public @interface RequestLine { + String value(); +} diff --git a/core/src/main/java/feign/RequestTemplate.java b/core/src/main/java/feign/RequestTemplate.java new file mode 100644 index 0000000000..fc3f7bd138 --- /dev/null +++ b/core/src/main/java/feign/RequestTemplate.java @@ -0,0 +1,553 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import java.io.Serializable; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import static feign.Util.CONTENT_LENGTH; +import static feign.Util.UTF_8; +import static feign.Util.checkNotNull; +import static feign.Util.emptyToNull; +import static feign.Util.toArray; +import static feign.Util.valuesOrEmpty; + +/** + * Builds a request to an http target. Not thread safe. + *
+ *

relationship to JAXRS 2.0
+ *
+ * A combination of {@code javax.ws.rs.client.WebTarget} and + * {@code javax.ws.rs.client.Invocation.Builder}, ensuring you can modify any + * part of the request. However, this object is mutable, so needs to be guarded + * with the copy constructor. + */ +public final class RequestTemplate implements Serializable { + + + private String method; + /* final to encourage mutable use vs replacing the object. */ + private StringBuilder url = new StringBuilder(); + private final Map> queries = new LinkedHashMap>(); + private final Map> headers = new LinkedHashMap>(); + private String body; + private String bodyTemplate; + + public RequestTemplate() { + + } + + /* Copy constructor. Use this when making templates. */ + public RequestTemplate(RequestTemplate toCopy) { + checkNotNull(toCopy, "toCopy"); + this.method = toCopy.method; + this.url.append(toCopy.url); + this.queries.putAll(toCopy.queries); + this.headers.putAll(toCopy.headers); + this.body = toCopy.body; + this.bodyTemplate = toCopy.bodyTemplate; + } + + /** + * Resolves any templated variables in the requests path, query, or headers + * against the supplied unencoded arguments. + *
+ *

relationship to JAXRS 2.0
+ *
+ * This call is similar to + * {@code javax.ws.rs.client.WebTarget.resolveTemplates(templateValues, true)} + * , except that the template values apply to any part of the request, not + * just the URL + */ + public RequestTemplate resolve(Map unencoded) { + replaceQueryValues(unencoded); + Map encoded = new LinkedHashMap(); + for (Entry entry : unencoded.entrySet()) { + encoded.put(entry.getKey(), urlEncode(String.valueOf(entry.getValue()))); + } + String resolvedUrl = expand(url.toString(), encoded).replace("%2F", "/"); + url = new StringBuilder(resolvedUrl); + + Map> resolvedHeaders = new LinkedHashMap>(); + for (String field : headers.keySet()) { + Collection resolvedValues = new ArrayList(); + for (String value : valuesOrEmpty(headers, field)) { + String resolved; + if (value.indexOf('{') == 0) { + resolved = String.valueOf(unencoded.get(field)); + } else { + resolved = value; + } + if (resolved != null) + resolvedValues.add(resolved); + } + resolvedHeaders.put(field, resolvedValues); + } + headers.clear(); + headers.putAll(resolvedHeaders); + if (bodyTemplate != null) + body(urlDecode(expand(bodyTemplate, unencoded))); + return this; + } + + /* roughly analogous to {@code javax.ws.rs.client.Target.request()}. */ + public Request request() { + return new Request(method, new StringBuilder(url).append(queryLine()).toString(), + headers, body); + } + + private static String urlDecode(String arg) { + try { + return URLDecoder.decode(arg, UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + private static String urlEncode(Object arg) { + try { + return URLEncoder.encode(String.valueOf(arg), UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + /** + * Expands a {@code template}, such as {@code username}, using the {@code variables} supplied. Any unresolved + * parameters will remain. + *
+ * Note that if you'd like curly braces literally in the {@code template}, + * urlencode them first. + * + * @param template URI template that can be in level 1 RFC6570 form. + * @param variables to the URI template + * @return expanded template, leaving any unresolved parameters literal + */ + public static String expand(String template, Map variables) { + // skip expansion if there's no valid variables set. ex. {a} is the + // first valid + if (checkNotNull(template, "template").length() < 3) + return template.toString(); + checkNotNull(variables, "variables for %s", template); + + boolean inVar = false; + StringBuilder var = new StringBuilder(); + StringBuilder builder = new StringBuilder(); + for (char c : template.toCharArray()) { + switch (c) { + case '{': + inVar = true; + break; + case '}': + inVar = false; + String key = var.toString(); + Object value = variables.get(var.toString()); + if (value != null) + builder.append(value); + else + builder.append('{').append(key).append('}'); + var = new StringBuilder(); + break; + default: + if (inVar) + var.append(c); + else + builder.append(c); + } + } + return builder.toString(); + } + + /* @see Request#method() */ + public RequestTemplate method(String method) { + this.method = checkNotNull(method, "method"); + return this; + } + + /* @see Request#method() */ + public String method() { + return method; + } + + /* @see #url() */ + public RequestTemplate append(CharSequence value) { + url.append(value); + url = pullAnyQueriesOutOfUrl(url); + return this; + } + + /* @see #url() */ + public RequestTemplate insert(int pos, CharSequence value) { + url.insert(pos, pullAnyQueriesOutOfUrl(new StringBuilder(value))); + return this; + } + + public String url() { + return url.toString(); + } + + /** + * Replaces queries with the specified {@code configKey} with url decoded + * {@code values} supplied. + *
+ * When the {@code value} is {@code null}, all queries with the {@code configKey} + * are removed. + *
+ *

relationship to JAXRS 2.0
+ *
+ * Like {@code WebTarget.query}, except the values can be templatized. + *
+ * ex. + *
+ *
+   * template.query("Signature", "{signature}");
+   * 
+ * + * @param configKey the configKey of the query + * @param values can be a single null to imply removing all values. Else no + * values are expected to be null. + * @see #queries() + */ + public RequestTemplate query(String configKey, String... values) { + queries.remove(checkNotNull(configKey, "configKey")); + if (values != null && values.length > 0 && values[0] != null) { + ArrayList encoded = new ArrayList(); + for (String value : values) { + encoded.add(encodeIfNotVariable(value)); + } + this.queries.put(encodeIfNotVariable(configKey), encoded); + } + return this; + } + + /* @see #query(String, String...) */ + public RequestTemplate query(String configKey, Iterable values) { + if (values != null) + return query(configKey, toArray(values, String.class)); + return query(configKey, (String[]) null); + } + + private String encodeIfNotVariable(String in) { + if (in == null || in.indexOf('{') == 0) + return in; + return urlEncode(in); + } + + /** + * Replaces all existing queries with the newly supplied url decoded + * queries. + *
+ *

relationship to JAXRS 2.0
+ *
+ * Like {@code WebTarget.queries}, except the values can be templatized. + *
+ * ex. + *
+ *
+   * template.queries(ImmutableMultimap.of("Signature", "{signature}"));
+   * 
+ * + * @param queries if null, remove all queries. else value to replace all queries + * with. + * @see #queries() + */ + public RequestTemplate queries(Map> queries) { + if (queries == null || queries.isEmpty()) { + this.queries.clear(); + } else { + for (Entry> entry : queries.entrySet()) + query(entry.getKey(), toArray(entry.getValue(), String.class)); + } + return this; + } + + /** + * Returns an immutable copy of the url decoded queries. + * + * @see Request#url() + */ + public Map> queries() { + Map> decoded = new LinkedHashMap>(); + for (String field : queries.keySet()) { + Collection decodedValues = new ArrayList(); + for (String value : valuesOrEmpty(queries, field)) { + if (value != null) { + decodedValues.add(urlDecode(value)); + } else { + decodedValues.add(null); + } + } + decoded.put(urlDecode(field), decodedValues); + } + return Collections.unmodifiableMap(decoded); + } + + /** + * Replaces headers with the specified {@code configKey} with the + * {@code values} supplied. + *
+ * When the {@code value} is {@code null}, all headers with the {@code configKey} + * are removed. + *
+ *

relationship to JAXRS 2.0
+ *
+ * Like {@code WebTarget.queries} and {@code javax.ws.rs.client.Invocation.Builder.header}, + * except the values can be templatized. + *
+ * ex. + *
+ *
+   * template.query("X-Application-Version", "{version}");
+   * 
+ * + * @param configKey the configKey of the header + * @param values can be a single null to imply removing all values. Else no + * values are expected to be null. + * @see #headers() + */ + public RequestTemplate header(String configKey, String... values) { + checkNotNull(configKey, "header configKey"); + if (values == null || (values.length == 1 && values[0] == null)) { + headers.remove(configKey); + } else { + List headers = new ArrayList(); + headers.addAll(Arrays.asList(values)); + this.headers.put(configKey, headers); + } + return this; + } + + /* @see #header(String, String...) */ + public RequestTemplate header(String configKey, Iterable values) { + if (values != null) + return header(configKey, toArray(values, String.class)); + return header(configKey, (String[]) null); + } + + /** + * Replaces all existing headers with the newly supplied headers. + *
+ *

relationship to JAXRS 2.0
+ *
+ * Like {@code Invocation.Builder.headers(MultivaluedMap)}, except the + * values can be templatized. + *
+ * ex. + *
+ *
+   * template.headers(ImmutableMultimap.of("X-Application-Version", "{version}"));
+   * 
+ * + * @param headers if null, remove all headers. else value to replace all headers + * with. + * @see #headers() + */ + public RequestTemplate headers(Map> headers) { + if (headers == null || headers.isEmpty()) + this.headers.clear(); + else + this.headers.putAll(headers); + return this; + } + + /** + * Returns an immutable copy of the current headers. + * + * @see Request#headers() + */ + public Map> headers() { + return Collections.unmodifiableMap(headers); + } + + /** + * replaces the {@link feign.Util#CONTENT_LENGTH} header. + *
+ * Usually populated by an {@link feign.codec.Encoder}. + * + * @see Request#body() + */ + public RequestTemplate body(String body) { + this.body = body; + if (this.body != null) { + byte[] contentLength = body.getBytes(UTF_8); + header(CONTENT_LENGTH, String.valueOf(contentLength.length)); + } + this.bodyTemplate = null; + return this; + } + + /* @see Request#body() */ + public String body() { + return body; + } + + /** + * populated by {@link Body} + * + * @see Request#body() + */ + public RequestTemplate bodyTemplate(String bodyTemplate) { + this.bodyTemplate = bodyTemplate; + this.body = null; + return this; + } + + /** + * @see Request#body() + * @see #expand(String, Map) + */ + public String bodyTemplate() { + return bodyTemplate; + } + + /** + * if there are any query params in the {@link #body()}, this will extract + * them out. + * + * @return + */ + private StringBuilder pullAnyQueriesOutOfUrl(StringBuilder url) { + // parse out queries + int queryIndex = url.indexOf("?"); + if (queryIndex != -1) { + String queryLine = url.substring(queryIndex + 1); + Map> firstQueries = parseAndDecodeQueries(queryLine); + if (!queries.isEmpty()) { + firstQueries.putAll(queries); + queries.clear(); + } + queries.putAll(firstQueries); + return new StringBuilder(url.substring(0, queryIndex)); + } + return url; + } + + private static Map> parseAndDecodeQueries(String queryLine) { + Map> map = new LinkedHashMap>(); + if (emptyToNull(queryLine) == null) + return map; + if (queryLine.indexOf('&') == -1) { + if (queryLine.indexOf('=') != -1) + putKV(queryLine, map); + else + map.put(queryLine, null); + } else { + char[] chars = queryLine.toCharArray(); + int start = 0; + int i = 0; + for (; i < chars.length; i++) { + if (chars[i] == '&') { + putKV(queryLine.substring(start, i), map); + start = i + 1; + } + } + putKV(queryLine.substring(start, i), map); + } + return map; + } + + private static void putKV(String stringToParse, Map> map) { + String key; + String value; + // note that '=' can be a valid part of the value + int firstEq = stringToParse.indexOf('='); + if (firstEq == -1) { + key = urlDecode(stringToParse); + value = null; + } else { + key = urlDecode(stringToParse.substring(0, firstEq)); + value = urlDecode(stringToParse.substring(firstEq + 1)); + } + Collection values = map.containsKey(key) ? map.get(key) : new ArrayList(); + values.add(value); + map.put(key, values); + } + + @Override public String toString() { + return request().toString(); + } + + /** + * Replaces query values which are templated with corresponding values from the {@code unencoded} map. + * Any unresolved queries are removed. + */ + public void replaceQueryValues(Map unencoded) { + Iterator>> iterator = queries.entrySet().iterator(); + while (iterator.hasNext()) { + Entry> entry = iterator.next(); + if (entry.getValue() == null) { + continue; + } + Collection values = new ArrayList(); + for (String value : entry.getValue()) { + if (value.indexOf('{') == 0 && value.indexOf('}') == value.length() - 1) { + Object variableValue = unencoded.get(value.substring(1, value.length() - 1)); + // only add non-null expressions + if (variableValue == null) { + continue; + } + if (variableValue instanceof Iterable) { + for (Object val : Iterable.class.cast(variableValue)) { + values.add(urlEncode(String.valueOf(val))); + } + } else { + values.add(urlEncode(String.valueOf(variableValue))); + } + } else { + values.add(value); + } + } + if (values.isEmpty()) { + iterator.remove(); + } else { + entry.setValue(values); + } + } + } + + public String queryLine() { + if (queries.isEmpty()) + return ""; + StringBuilder queryBuilder = new StringBuilder(); + for (String field : queries.keySet()) { + for (String value : valuesOrEmpty(queries, field)) { + queryBuilder.append('&'); + queryBuilder.append(field); + if (value != null) { + queryBuilder.append('='); + if (!value.isEmpty()) + queryBuilder.append(value); + } + } + } + queryBuilder.deleteCharAt(0); + return queryBuilder.insert(0, '?').toString(); + } + + private static final long serialVersionUID = 1L; +} diff --git a/core/src/main/java/feign/Response.java b/core/src/main/java/feign/Response.java new file mode 100644 index 0000000000..6baa2b8426 --- /dev/null +++ b/core/src/main/java/feign/Response.java @@ -0,0 +1,190 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import java.io.Closeable; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import static feign.Util.UTF_8; +import static feign.Util.checkNotNull; +import static feign.Util.checkState; +import static feign.Util.valuesOrEmpty; + +/** + * An immutable response to an http invocation which only returns string + * content. + */ +public final class Response { + private final int status; + private final String reason; + private final Map> headers; + private final Body body; + + public static Response create(int status, String reason, Map> headers, + Reader chars, Integer length) { + return new Response(status, reason, headers, ReaderBody.orNull(chars, length)); + } + + public static Response create(int status, String reason, Map> headers, String chars) { + return new Response(status, reason, headers, StringBody.orNull(chars)); + } + + private Response(int status, String reason, Map> headers, Body body) { + checkState(status >= 200, "Invalid status code: %s", status); + this.status = status; + this.reason = checkNotNull(reason, "reason"); + LinkedHashMap> copyOf = new LinkedHashMap>(); + copyOf.putAll(checkNotNull(headers, "headers")); + this.headers = Collections.unmodifiableMap(copyOf); + this.body = body; //nullable + } + + /** + * status code. ex {@code 200} + * + * See rfc2616 + */ + public int status() { + return status; + } + + public String reason() { + return reason; + } + + public Map> headers() { + return headers; + } + + /** + * if present, the response had a body + */ + public Body body() { + return body; + } + + public interface Body extends Closeable { + + /** + * length in bytes, if known. Null if not. + *
+ *

Note
This is an integer as most implementations cannot do + * bodies greater than 2GB. Moreover, the scope of this interface doesn't include + * large bodies. + */ + Integer length(); + + /** + * True if {@link #asReader()} can be called more than once. + */ + boolean isRepeatable(); + + /** + * It is the responsibility of the caller to close the stream. + */ + Reader asReader() throws IOException; + } + + private static final class ReaderBody implements Response.Body { + private static Body orNull(Reader chars, Integer length) { + if (chars == null) + return null; + return new ReaderBody(chars, length); + } + + private final Reader chars; + private final Integer length; + + private ReaderBody(Reader chars, Integer length) { + this.chars = chars; + this.length = length; + } + + @Override public Integer length() { + return length; + } + + @Override public boolean isRepeatable() { + return false; + } + + @Override public Reader asReader() throws IOException { + return chars; + } + + @Override public void close() throws IOException { + chars.close(); + } + } + + private static final class StringBody implements Response.Body { + private static Body orNull(String chars) { + if (chars == null) + return null; + return new StringBody(chars); + } + + private final String chars; + + public StringBody(String chars) { + this.chars = chars; + } + + private volatile Integer length; + + @Override public Integer length() { + if (length == null) { + length = chars.getBytes(UTF_8).length; + } + return length; + } + + @Override public boolean isRepeatable() { + return true; + } + + @Override public Reader asReader() throws IOException { + return new StringReader(chars); + } + + public String toString() { + return chars; + } + + @Override public void close() { + } + } + + @Override public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("HTTP/1.1 ").append(status).append(' ').append(reason).append('\n'); + for (String field : headers.keySet()) { + for (String value : valuesOrEmpty(headers, field)) { + builder.append(field).append(": ").append(value).append('\n'); + } + } + if (body != null) { + builder.append('\n').append(body); + } + return builder.toString(); + } +} diff --git a/core/src/main/java/feign/RetryableException.java b/core/src/main/java/feign/RetryableException.java new file mode 100644 index 0000000000..f5d6eabb9b --- /dev/null +++ b/core/src/main/java/feign/RetryableException.java @@ -0,0 +1,57 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import java.util.Date; + +/** + * This exception is raised when the {@link Response} is deemed to be retryable, + * typically via an {@link feign.codec.ErrorDecoder} when the {@link Response#status() + * status} is 503. + */ +public class RetryableException extends FeignException { + + private static final long serialVersionUID = 1L; + + private final Date retryAfter; + + /** + * @param retryAfter usually corresponds to the {@link feign.Util#RETRY_AFTER} + * header. + */ + public RetryableException(String message, Throwable cause, Date retryAfter) { + super(message, cause); + this.retryAfter = retryAfter; + } + + /** + * @param retryAfter usually corresponds to the {@link feign.Util#RETRY_AFTER} + * header. + */ + public RetryableException(String message, Date retryAfter) { + super(message); + this.retryAfter = retryAfter; + } + + /** + * Sometimes corresponds to the {@link feign.Util#RETRY_AFTER} header + * present in {@code 503} status. Other times parsed from an + * application-specific response. Null if unknown. + */ + public Date retryAfter() { + return retryAfter; + } +} diff --git a/core/src/main/java/feign/Retryer.java b/core/src/main/java/feign/Retryer.java new file mode 100644 index 0000000000..b6cafe5db8 --- /dev/null +++ b/core/src/main/java/feign/Retryer.java @@ -0,0 +1,94 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import static java.util.concurrent.TimeUnit.SECONDS; + +/** + * Created for each invocation to {@link Client#execute(Request, feign.Request.Options)}. + * Implementations may keep state to determine if retry operations should + * continue or not. + */ +public interface Retryer { + + /** + * if retry is permitted, return (possibly after sleeping). Otherwise + * propagate the exception. + */ + void continueOrPropagate(RetryableException e); + + public static class Default implements Retryer { + + private final int maxAttempts; + private final long period; + private final long maxPeriod; + + // visible for testing; + protected long currentTimeMillis() { + return System.currentTimeMillis(); + } + + int attempt; + long sleptForMillis; + + public Default() { + this(100, SECONDS.toMillis(1), 5); + } + + public Default(long period, long maxPeriod, int maxAttempts) { + this.period = period; + this.maxPeriod = maxPeriod; + this.maxAttempts = maxAttempts; + this.attempt = 1; + } + + public void continueOrPropagate(RetryableException e) { + if (attempt++ >= maxAttempts) + throw e; + + long interval; + if (e.retryAfter() != null) { + interval = e.retryAfter().getTime() - currentTimeMillis(); + if (interval > maxPeriod) + interval = maxPeriod; + if (interval < 0) + return; + } else { + interval = nextMaxInterval(); + } + try { + Thread.sleep(interval); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + sleptForMillis += interval; + } + + /** + * Calculates the time interval to a retry attempt. + *
+ * The interval increases exponentially with each attempt, at a rate of + * nextInterval *= 1.5 (where 1.5 is the backoff factor), to the maximum + * interval. + * + * @return time in nanoseconds from now until the next attempt. + */ + long nextMaxInterval() { + long interval = (long) (period * Math.pow(1.5, attempt - 1)); + return interval > maxPeriod ? maxPeriod : interval; + } + } +} diff --git a/core/src/main/java/feign/Subscription.java b/core/src/main/java/feign/Subscription.java new file mode 100644 index 0000000000..1b327f747b --- /dev/null +++ b/core/src/main/java/feign/Subscription.java @@ -0,0 +1,32 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +/** + * Subscription returns from {@link Observable#subscribe(Observer)} to allow + * unsubscribing. + */ +public interface Subscription { + + /** + * Stop receiving notifications on the {@link Observer} that was registered + * when this Subscription was received. + *
+ * This allows unregistering an {@link Observer} before it has finished + * receiving all events (ie. before onCompleted is called). + */ + void unsubscribe(); +} diff --git a/core/src/main/java/feign/Target.java b/core/src/main/java/feign/Target.java new file mode 100644 index 0000000000..ab3588cce4 --- /dev/null +++ b/core/src/main/java/feign/Target.java @@ -0,0 +1,111 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import java.util.Arrays; + +import static feign.Util.checkNotNull; +import static feign.Util.emptyToNull; + +/** + *

relationship to JAXRS 2.0
+ *
+ * Similar to {@code javax.ws.rs.client.WebTarget}, as it produces requests. + * However, {@link RequestTemplate} is a closer match to {@code WebTarget}. + * + * @param type of the interface this target applies to. + */ +public interface Target { + /* The type of the interface this target applies to. ex. {@code Route53}. */ + Class type(); + + /* configuration key associated with this target. For example, {@code route53}. */ + String name(); + + /* base HTTP URL of the target. For example, {@code https://api/v2}. */ + String url(); + + /** + * Targets a template to this target, adding the {@link #url() base url} and + * any target-specific headers or query parameters. + *
+ *
+ * For example: + *
+ *
+   * public Request apply(RequestTemplate input) {
+   *     input.insert(0, url());
+   *     input.replaceHeader("X-Auth", currentToken);
+   *     return input.asRequest();
+   * }
+   * 
+ *
+ *

relationship to JAXRS 2.0
+ *
+ * This call is similar to {@code javax.ws.rs.client.WebTarget.request()}, + * except that we expect transient, but necessary decoration to be applied + * on invocation. + */ + public Request apply(RequestTemplate input); + + public static class HardCodedTarget implements Target { + private final Class type; + private final String name; + private final String url; + + public HardCodedTarget(Class type, String url) { + this(type, url, url); + } + + public HardCodedTarget(Class type, String name, String url) { + this.type = checkNotNull(type, "type"); + this.name = checkNotNull(emptyToNull(name), "name"); + this.url = checkNotNull(emptyToNull(url), "url"); + } + + @Override public Class type() { + return type; + } + + @Override public String name() { + return name; + } + + @Override public String url() { + return url; + } + + /* no authentication or other special activity. just insert the url. */ + @Override public Request apply(RequestTemplate input) { + if (input.url().indexOf("http") != 0) + input.insert(0, url()); + return input.request(); + } + + @Override public int hashCode() { + return Arrays.hashCode(new Object[]{type, name, url}); + } + + @Override public boolean equals(Object obj) { + if (this == obj) + return true; + if (HardCodedTarget.class != obj.getClass()) + return false; + HardCodedTarget that = HardCodedTarget.class.cast(obj); + return this.type.equals(that.type) && this.name.equals(that.name) && this.url.equals(that.url); + } + } +} diff --git a/core/src/main/java/feign/Types.java b/core/src/main/java/feign/Types.java new file mode 100644 index 0000000000..bfdc00fd54 --- /dev/null +++ b/core/src/main/java/feign/Types.java @@ -0,0 +1,414 @@ +/* + * Copyright (C) 2008 Google Inc. + * + * 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; + +import java.lang.reflect.Array; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.GenericDeclaration; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.util.Arrays; +import java.util.NoSuchElementException; + +/** + * Static methods for working with types. + * + * @author Bob Lee + * @author Jesse Wilson + */ +final class Types { + private static final Type[] EMPTY_TYPE_ARRAY = new Type[0]; + + private Types() { + // No instances. + } + + static Class getRawType(Type type) { + if (type instanceof Class) { + // Type is a normal class. + return (Class) type; + + } else if (type instanceof ParameterizedType) { + ParameterizedType parameterizedType = (ParameterizedType) type; + + // I'm not exactly sure why getRawType() returns Type instead of Class. Neal isn't either but + // suspects some pathological case related to nested classes exists. + Type rawType = parameterizedType.getRawType(); + if (!(rawType instanceof Class)) throw new IllegalArgumentException(); + return (Class) rawType; + + } else if (type instanceof GenericArrayType) { + Type componentType = ((GenericArrayType) type).getGenericComponentType(); + return Array.newInstance(getRawType(componentType), 0).getClass(); + + } else if (type instanceof TypeVariable) { + // We could use the variable's bounds, but that won't work if there are multiple. Having a raw + // type that's more general than necessary is okay. + return Object.class; + + } else if (type instanceof WildcardType) { + return getRawType(((WildcardType) type).getUpperBounds()[0]); + + } else { + String className = type == null ? "null" : type.getClass().getName(); + throw new IllegalArgumentException("Expected a Class, ParameterizedType, or " + + "GenericArrayType, but <" + type + "> is of type " + className); + } + } + + /** Returns true if {@code a} and {@code b} are equal. */ + static boolean equals(Type a, Type b) { + if (a == b) { + return true; // Also handles (a == null && b == null). + + } else if (a instanceof Class) { + return a.equals(b); // Class already specifies equals(). + + } else if (a instanceof ParameterizedType) { + if (!(b instanceof ParameterizedType)) return false; + ParameterizedType pa = (ParameterizedType) a; + ParameterizedType pb = (ParameterizedType) b; + return equal(pa.getOwnerType(), pb.getOwnerType()) + && pa.getRawType().equals(pb.getRawType()) + && Arrays.equals(pa.getActualTypeArguments(), pb.getActualTypeArguments()); + + } else if (a instanceof GenericArrayType) { + if (!(b instanceof GenericArrayType)) return false; + GenericArrayType ga = (GenericArrayType) a; + GenericArrayType gb = (GenericArrayType) b; + return equals(ga.getGenericComponentType(), gb.getGenericComponentType()); + + } else if (a instanceof WildcardType) { + if (!(b instanceof WildcardType)) return false; + WildcardType wa = (WildcardType) a; + WildcardType wb = (WildcardType) b; + return Arrays.equals(wa.getUpperBounds(), wb.getUpperBounds()) + && Arrays.equals(wa.getLowerBounds(), wb.getLowerBounds()); + + } else if (a instanceof TypeVariable) { + if (!(b instanceof TypeVariable)) return false; + TypeVariable va = (TypeVariable) a; + TypeVariable vb = (TypeVariable) b; + return va.getGenericDeclaration() == vb.getGenericDeclaration() + && va.getName().equals(vb.getName()); + + } else { + return false; // This isn't a type we support! + } + } + + /** + * Returns the generic supertype for {@code supertype}. For example, given a class {@code + * IntegerSet}, the result for when supertype is {@code Set.class} is {@code Set} and the + * result when the supertype is {@code Collection.class} is {@code Collection}. + */ + static Type getGenericSupertype(Type context, Class rawType, Class toResolve) { + if (toResolve == rawType) return context; + + // We skip searching through interfaces if unknown is an interface. + if (toResolve.isInterface()) { + Class[] interfaces = rawType.getInterfaces(); + for (int i = 0, length = interfaces.length; i < length; i++) { + if (interfaces[i] == toResolve) { + return rawType.getGenericInterfaces()[i]; + } else if (toResolve.isAssignableFrom(interfaces[i])) { + return getGenericSupertype(rawType.getGenericInterfaces()[i], interfaces[i], toResolve); + } + } + } + + // Check our supertypes. + if (!rawType.isInterface()) { + while (rawType != Object.class) { + Class rawSupertype = rawType.getSuperclass(); + if (rawSupertype == toResolve) { + return rawType.getGenericSuperclass(); + } else if (toResolve.isAssignableFrom(rawSupertype)) { + return getGenericSupertype(rawType.getGenericSuperclass(), rawSupertype, toResolve); + } + rawType = rawSupertype; + } + } + + // We can't resolve this further. + return toResolve; + } + + private static int indexOf(Object[] array, Object toFind) { + for (int i = 0; i < array.length; i++) { + if (toFind.equals(array[i])) return i; + } + throw new NoSuchElementException(); + } + + private static boolean equal(Object a, Object b) { + return a == b || (a != null && a.equals(b)); + } + + private static int hashCodeOrZero(Object o) { + return o != null ? o.hashCode() : 0; + } + + static String typeToString(Type type) { + return type instanceof Class ? ((Class) type).getName() : type.toString(); + } + + /** + * Returns the generic form of {@code supertype}. For example, if this is {@code + * ArrayList}, this returns {@code Iterable} given the input {@code + * Iterable.class}. + * + * @param supertype a superclass of, or interface implemented by, this. + */ + static Type getSupertype(Type context, Class contextRawType, Class supertype) { + if (!supertype.isAssignableFrom(contextRawType)) throw new IllegalArgumentException(); + return resolve(context, contextRawType, + getGenericSupertype(context, contextRawType, supertype)); + } + + 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) { + TypeVariable typeVariable = (TypeVariable) toResolve; + toResolve = resolveTypeVariable(context, contextRawType, typeVariable); + if (toResolve == typeVariable) { + return toResolve; + } + + } else if (toResolve instanceof Class && ((Class) toResolve).isArray()) { + Class original = (Class) toResolve; + Type componentType = original.getComponentType(); + Type newComponentType = resolve(context, contextRawType, componentType); + return componentType == newComponentType ? original : new GenericArrayTypeImpl( + newComponentType); + + } else if (toResolve instanceof GenericArrayType) { + GenericArrayType original = (GenericArrayType) toResolve; + Type componentType = original.getGenericComponentType(); + Type newComponentType = resolve(context, contextRawType, componentType); + return componentType == newComponentType ? original : new GenericArrayTypeImpl( + newComponentType); + + } else if (toResolve instanceof ParameterizedType) { + ParameterizedType original = (ParameterizedType) toResolve; + Type ownerType = original.getOwnerType(); + Type newOwnerType = resolve(context, contextRawType, ownerType); + boolean changed = newOwnerType != ownerType; + + Type[] args = original.getActualTypeArguments(); + for (int t = 0, length = args.length; t < length; t++) { + Type resolvedTypeArgument = resolve(context, contextRawType, args[t]); + if (resolvedTypeArgument != args[t]) { + if (!changed) { + args = args.clone(); + changed = true; + } + args[t] = resolvedTypeArgument; + } + } + + return changed + ? new ParameterizedTypeImpl(newOwnerType, original.getRawType(), args) + : original; + + } else if (toResolve instanceof WildcardType) { + WildcardType original = (WildcardType) toResolve; + Type[] originalLowerBound = original.getLowerBounds(); + Type[] originalUpperBound = original.getUpperBounds(); + + if (originalLowerBound.length == 1) { + Type lowerBound = resolve(context, contextRawType, originalLowerBound[0]); + if (lowerBound != originalLowerBound[0]) { + return new WildcardTypeImpl(new Type[] { Object.class }, new Type[] { lowerBound }); + } + } else if (originalUpperBound.length == 1) { + Type upperBound = resolve(context, contextRawType, originalUpperBound[0]); + if (upperBound != originalUpperBound[0]) { + return new WildcardTypeImpl(new Type[] { upperBound }, EMPTY_TYPE_ARRAY); + } + } + return original; + + } else { + return toResolve; + } + } + } + + private static Type resolveTypeVariable( + Type context, Class contextRawType, TypeVariable unknown) { + Class declaredByRaw = declaringClassOf(unknown); + + // We can't reduce this further. + if (declaredByRaw == null) return unknown; + + Type declaredBy = getGenericSupertype(context, contextRawType, declaredByRaw); + if (declaredBy instanceof ParameterizedType) { + int index = indexOf(declaredByRaw.getTypeParameters(), unknown); + return ((ParameterizedType) declaredBy).getActualTypeArguments()[index]; + } + + return unknown; + } + + /** + * Returns the declaring class of {@code typeVariable}, or {@code null} if it was not declared by + * a class. + */ + private static Class declaringClassOf(TypeVariable typeVariable) { + GenericDeclaration genericDeclaration = typeVariable.getGenericDeclaration(); + return genericDeclaration instanceof Class ? (Class) genericDeclaration : null; + } + + private static void checkNotPrimitive(Type type) { + if (type instanceof Class && ((Class) type).isPrimitive()) { + throw new IllegalArgumentException(); + } + } + + private static final class ParameterizedTypeImpl implements ParameterizedType { + private final Type ownerType; + private final Type rawType; + private final Type[] typeArguments; + + ParameterizedTypeImpl(Type ownerType, Type rawType, Type... typeArguments) { + // Require an owner type if the raw type needs it. + if (rawType instanceof Class + && (ownerType == null) != (((Class) rawType).getEnclosingClass() == null)) { + throw new IllegalArgumentException(); + } + + this.ownerType = ownerType; + this.rawType = rawType; + this.typeArguments = typeArguments.clone(); + + for (Type typeArgument : this.typeArguments) { + if (typeArgument == null) throw new NullPointerException(); + checkNotPrimitive(typeArgument); + } + } + + public Type[] getActualTypeArguments() { + return typeArguments.clone(); + } + + public Type getRawType() { + return rawType; + } + + public Type getOwnerType() { + return ownerType; + } + + @Override public boolean equals(Object other) { + return other instanceof ParameterizedType && Types.equals(this, (ParameterizedType) other); + } + + @Override public int hashCode() { + return Arrays.hashCode(typeArguments) ^ rawType.hashCode() ^ hashCodeOrZero(ownerType); + } + + @Override public String toString() { + StringBuilder result = new StringBuilder(30 * (typeArguments.length + 1)); + result.append(typeToString(rawType)); + if (typeArguments.length == 0) return result.toString(); + result.append("<").append(typeToString(typeArguments[0])); + for (int i = 1; i < typeArguments.length; i++) { + result.append(", ").append(typeToString(typeArguments[i])); + } + return result.append(">").toString(); + } + } + + private static final class GenericArrayTypeImpl implements GenericArrayType { + private final Type componentType; + + GenericArrayTypeImpl(Type componentType) { + this.componentType = componentType; + } + + public Type getGenericComponentType() { + return componentType; + } + + @Override public boolean equals(Object o) { + return o instanceof GenericArrayType + && Types.equals(this, (GenericArrayType) o); + } + + @Override public int hashCode() { + return componentType.hashCode(); + } + + @Override public String toString() { + return typeToString(componentType) + "[]"; + } + } + + /** + * The WildcardType interface supports multiple upper bounds and multiple + * lower bounds. We only support what the Java 6 language needs - at most one + * bound. If a lower bound is set, the upper bound must be Object.class. + */ + private static final class WildcardTypeImpl implements WildcardType { + private final Type upperBound; + private final Type lowerBound; + + WildcardTypeImpl(Type[] upperBounds, Type[] lowerBounds) { + if (lowerBounds.length > 1) throw new IllegalArgumentException(); + if (upperBounds.length != 1) throw new IllegalArgumentException(); + + if (lowerBounds.length == 1) { + if (lowerBounds[0] == null) throw new NullPointerException(); + checkNotPrimitive(lowerBounds[0]); + if (upperBounds[0] != Object.class) throw new IllegalArgumentException(); + this.lowerBound = lowerBounds[0]; + this.upperBound = Object.class; + } else { + if (upperBounds[0] == null) throw new NullPointerException(); + checkNotPrimitive(upperBounds[0]); + this.lowerBound = null; + this.upperBound = upperBounds[0]; + } + } + + public Type[] getUpperBounds() { + return new Type[] { upperBound }; + } + + public Type[] getLowerBounds() { + return lowerBound != null ? new Type[] { lowerBound } : EMPTY_TYPE_ARRAY; + } + + @Override public boolean equals(Object other) { + return other instanceof WildcardType && Types.equals(this, (WildcardType) other); + } + + @Override public int hashCode() { + // This equals Arrays.hashCode(getLowerBounds()) ^ Arrays.hashCode(getUpperBounds()). + return (lowerBound != null ? 31 + lowerBound.hashCode() : 1) ^ (31 + upperBound.hashCode()); + } + + @Override public String toString() { + if (lowerBound != null) return "? super " + typeToString(lowerBound); + if (upperBound == Object.class) return "?"; + return "? extends " + typeToString(upperBound); + } + } +} diff --git a/core/src/main/java/feign/Util.java b/core/src/main/java/feign/Util.java new file mode 100644 index 0000000000..c251c31b6a --- /dev/null +++ b/core/src/main/java/feign/Util.java @@ -0,0 +1,165 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import java.io.IOException; +import java.lang.reflect.Array; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +import static java.lang.String.format; + +/** + * Utilities, typically copied in from guava, so as to avoid dependency conflicts. + */ +public class Util { + private Util() { // no instances + } + + /** + * The HTTP Content-Length header field name. + */ + public static final String CONTENT_LENGTH = "Content-Length"; + /** + * The HTTP Content-Encoding header field name. + */ + public static final String CONTENT_ENCODING = "Content-Encoding"; + /** + * The HTTP Retry-After header field name. + */ + public static final String RETRY_AFTER = "Retry-After"; + /** + * Value for the Content-Encoding header that indicates that GZIP encoding is in use. + */ + public static final String ENCODING_GZIP = "gzip"; + + // com.google.common.base.Charsets + /** + * UTF-8: eight-bit UCS Transformation Format. + */ + public static final Charset UTF_8 = Charset.forName("UTF-8"); + + /** + * Copy of {@code com.google.common.base.Preconditions#checkArgument}. + */ + public static void checkArgument(boolean expression, + String errorMessageTemplate, + Object... errorMessageArgs) { + if (!expression) { + throw new IllegalArgumentException( + format(errorMessageTemplate, errorMessageArgs)); + } + } + + /** + * Copy of {@code com.google.common.base.Preconditions#checkNotNull}. + */ + public static T checkNotNull(T reference, + String errorMessageTemplate, + Object... errorMessageArgs) { + if (reference == null) { + // If either of these parameters is null, the right thing happens anyway + throw new NullPointerException( + format(errorMessageTemplate, errorMessageArgs)); + } + return reference; + } + + /** + * Copy of {@code com.google.common.base.Preconditions#checkState}. + */ + public static void checkState(boolean expression, + String errorMessageTemplate, + Object... errorMessageArgs) { + if (!expression) { + throw new IllegalStateException( + format(errorMessageTemplate, errorMessageArgs)); + } + } + + /** + * Adapted from {@code com.google.common.base.Strings#emptyToNull}. + */ + public static String emptyToNull(String string) { + return string == null || string.isEmpty() ? null : string; + } + + /** + * Adapted from {@code com.google.common.base.Strings#emptyToNull}. + */ + @SuppressWarnings("unchecked") + public static T[] toArray(Iterable iterable, Class type) { + Collection collection; + if (iterable instanceof Collection) { + collection = (Collection) iterable; + } else { + collection = new ArrayList(); + for (T element : iterable) { + collection.add(element); + } + } + T[] array = (T[]) Array.newInstance(type, collection.size()); + return collection.toArray(array); + } + + /** + * Returns an unmodifiable collection which may be empty, but is never null. + */ + public static Collection valuesOrEmpty(Map> map, String key) { + return map.containsKey(key) ? map.get(key) : Collections.emptyList(); + } + + public static void ensureClosed(Response.Body body) { + if (body != null) { + try { + body.close(); + } catch (IOException ignored) { // NOPMD + } + } + } + + /** + * Resolves the last type parameter of the parameterized {@code supertype}, based on the {@code genericContext}, + * into its upper bounds. + *

+ * Implementation copied from {@code retrofit.RestMethodInfo}. + * + * @param genericContext Ex. {@link java.lang.reflect.Field#getGenericType()} + * @param supertype Ex. {@code Decoder.class} + * @return in the example above, the type parameter of {@code Decoder}. + * @throws IllegalStateException if {@code supertype} cannot be resolved into a parameterized type using + * {@code context}. + */ + public static Type resolveLastTypeParameter(Type genericContext, Class supertype) throws IllegalStateException { + Type resolvedSuperType = Types.getSupertype(genericContext, Types.getRawType(genericContext), supertype); + checkState(resolvedSuperType instanceof ParameterizedType, "could not resolve %s into a parameterized type %s", + genericContext, supertype); + Type[] types = ParameterizedType.class.cast(resolvedSuperType).getActualTypeArguments(); + for (int i = 0; i < types.length; i++) { + Type type = types[i]; + if (type instanceof WildcardType) { + types[i] = ((WildcardType) type).getUpperBounds()[0]; + } + } + return types[types.length - 1]; + } +} diff --git a/core/src/main/java/feign/codec/DecodeException.java b/core/src/main/java/feign/codec/DecodeException.java new file mode 100644 index 0000000000..5efab25ba6 --- /dev/null +++ b/core/src/main/java/feign/codec/DecodeException.java @@ -0,0 +1,45 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.codec; + +import feign.FeignException; + +import static feign.Util.checkNotNull; + +/** + * Similar to {@code javax.websocket.DecodeException}, raised when a problem + * occurs decoding a message. Note that {@code DecodeException} is not an + * {@code IOException}, nor have one set as its cause. + */ +public class DecodeException extends FeignException { + + /** + * @param message the reason for the failure. + */ + public DecodeException(String message) { + super(checkNotNull(message, "message")); + } + + /** + * @param message the reason for the failure. + * @param cause the cause of the error. + */ + public DecodeException(String message, Throwable cause) { + super(checkNotNull(message, "message"), checkNotNull(cause, "cause")); + } + + private static final long serialVersionUID = 1L; +} diff --git a/core/src/main/java/feign/codec/Decoder.java b/core/src/main/java/feign/codec/Decoder.java new file mode 100644 index 0000000000..8492d143b4 --- /dev/null +++ b/core/src/main/java/feign/codec/Decoder.java @@ -0,0 +1,82 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.codec; + +import feign.FeignException; +import feign.Response; + +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; + +/** + * Decodes an HTTP response into a given type. Invoked when + * {@link Response#status()} is in the 2xx range. Like + * {@code javax.websocket.Decoder}, except that the decode method is passed the + * generic type of the target.
+ * + * @param input that can be derived from {@link feign.Response.Body}. + * @param widest type an instance of this can decode. + */ +public interface Decoder { + /** + * Implement this to decode a resource to an object into a single object. + * If you need to wrap exceptions, please do so via {@link DecodeException}. + * + * @param input if {@code Closeable}, no need to close this, as the caller + * manages resources. + * @param type Target object type. + * @return instance of {@code type} + * @throws IOException will be propagated safely to the caller. + * @throws DecodeException when decoding failed due to a checked exception + * besides IOException. + * @throws FeignException when decoding succeeds, but conveys the operation + * failed. + */ + T decode(I input, Type type) throws IOException, DecodeException, FeignException; + + /** + * Used for text-based apis, follows + * {@link Decoder#decode(Object, java.lang.reflect.Type)} + * semantics, applied to inputs of type {@link java.io.Reader}.
+ * Ex.
+ *

+ *

+   * public class GsonDecoder implements Decoder.TextStream<Object> {
+   *   private final Gson gson;
+   *
+   *   public GsonDecoder(Gson gson) {
+   *     this.gson = gson;
+   *   }
+   *
+   *   @Override
+   *   public Object decode(Reader reader, Type type) throws IOException {
+   *     try {
+   *       return gson.fromJson(reader, type);
+   *     } catch (JsonIOException e) {
+   *       if (e.getCause() != null &&
+   *           e.getCause() instanceof IOException) {
+   *         throw IOException.class.cast(e.getCause());
+   *       }
+   *       throw e;
+   *     }
+   *   }
+   * }
+   * 
+ */ + public interface TextStream extends Decoder { + } +} diff --git a/core/src/main/java/feign/codec/Decoders.java b/core/src/main/java/feign/codec/Decoders.java new file mode 100644 index 0000000000..80b61ce999 --- /dev/null +++ b/core/src/main/java/feign/codec/Decoders.java @@ -0,0 +1,196 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.codec; + +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static feign.Util.checkNotNull; +import static java.lang.String.format; +import static java.util.regex.Pattern.DOTALL; +import static java.util.regex.Pattern.compile; + +/** + * Static utility methods pertaining to {@code Decoder} instances.
+ *
+ *
+ * Pattern Decoders
+ *
+ * Pattern decoders typically require less initialization, dependencies, and + * code than reflective decoders, but not can be awkward to those unfamiliar + * with regex. Typical use of pattern decoders is to grab a single field from an + * xml response, or parse a list of Strings. The pattern decoders here + * facilitate these use cases. + */ +public class Decoders { + /** + * guava users will implement this with {@code ApplyFirstGroup}. + * + * @param intended result type + */ + public interface ApplyFirstGroup { + /** + * create a new instance from the non-null {@code firstGroup} specified. + */ + T apply(String firstGroup); + } + + /** + * shortcut for
new TransformFirstGroup(pattern, applyFirstGroup){}
when + * {@code String} is the type you are decoding into.
+ *
+ * Ex. to pull the first interesting element from an xml response:
+ *

+ *

+   * decodeFirstDirPoolID = firstGroup("<DirPoolID[ˆ>]*>([ˆ<]+)</DirPoolID>");
+   * 
+ */ + public static Decoder.TextStream firstGroup(String pattern) { + return new TransformFirstGroup(pattern, IDENTITY) { + }; + } + + /** + * shortcut for
new TransformEachFirstGroup(pattern, applyFirstGroup){}
when + * {@code List} is the type you are decoding into.
+ * Ex. to pull a list zones names, which are http paths starting with + * {@code /Rest/Zone/}:
+ *

+ *

+   * decodeListOfZonesNames = eachFirstGroup("/REST/Zone/([ˆ/]+)/");
+   * 
+ */ + public static Decoder.TextStream> eachFirstGroup(String pattern) { + return new TransformEachFirstGroup(pattern, IDENTITY) { + }; + } + + private static String toString(Reader reader) throws IOException { + return TO_STRING.decode(reader, null).toString(); + } + + private static final StringDecoder TO_STRING = new StringDecoder(); + + private static final ApplyFirstGroup IDENTITY = new ApplyFirstGroup() { + @Override + public String apply(String firstGroup) { + return firstGroup; + } + }; + + /** + * The first match group is applied to {@code applyGroups} and result + * returned. If no matches are found, the response is null;
+ * Ex. to pull the first interesting element from an xml response:
+ *

+ *

+   * decodeFirstDirPoolID = new TransformFirstGroup<Long>("<DirPoolID[ˆ>]*>([ˆ<]+)</DirPoolID>", ToLong.INSTANCE) {
+   * };
+   * 
+ */ + public static class TransformFirstGroup implements Decoder.TextStream { + private final Pattern patternForMatcher; + private final ApplyFirstGroup applyFirstGroup; + + /** + * You must subclass this, in order to prevent type erasure on {@code T} + * . In addition to making a concrete type, you can also use the + * following form. + *

+ *
+ *

+ *

+     * new TransformFirstGroup<Foo>(pattern, applyFirstGroup) {
+     * }; // note the curly braces ensures no type erasure!
+     * 
+ */ + protected TransformFirstGroup(String pattern, ApplyFirstGroup applyFirstGroup) { + this.patternForMatcher = compile(checkNotNull(pattern, "pattern"), DOTALL); + this.applyFirstGroup = checkNotNull(applyFirstGroup, "applyFirstGroup"); + } + + @Override + public T decode(Reader reader, Type type) throws IOException { + Matcher matcher = patternForMatcher.matcher(Decoders.toString(reader)); + if (matcher.find()) { + return applyFirstGroup.apply(matcher.group(1)); + } + return null; + } + + @Override + public String toString() { + return format("decode groups from %s into %s", patternForMatcher, applyFirstGroup); + } + } + + /** + * On the each find the first match group is applied to + * {@code applyFirstGroup} and added to the list returned. If no matches are + * found, the response is an empty list;
+ * Ex. to pull a list zones constructed from http paths starting with + * {@code /Rest/Zone/}: + *

+ *
+ *

+ *

+   * decodeListOfZones = new TransformEachFirstGroup("/REST/Zone/([ˆ/]+)/", ToZone.INSTANCE) {
+   * };
+   * 
+ */ + public static class TransformEachFirstGroup implements Decoder.TextStream> { + private final Pattern patternForMatcher; + private final ApplyFirstGroup applyFirstGroup; + + /** + * You must subclass this, in order to prevent type erasure on {@code T} + * . In addition to making a concrete type, you can also use the + * following form. + *

+ *
+ *

+ *

+     * new TransformEachFirstGroup<Foo>(pattern, applyFirstGroup) {
+     * }; // note the curly braces ensures no type erasure!
+     * 
+ */ + protected TransformEachFirstGroup(String pattern, ApplyFirstGroup applyFirstGroup) { + this.patternForMatcher = compile(checkNotNull(pattern, "pattern"), DOTALL); + this.applyFirstGroup = checkNotNull(applyFirstGroup, "applyFirstGroup"); + } + + @Override + public List decode(Reader reader, Type type) throws IOException { + Matcher matcher = patternForMatcher.matcher(Decoders.toString(reader)); + List result = new ArrayList(); + while (matcher.find()) { + result.add(applyFirstGroup.apply(matcher.group(1))); + } + return result; + } + + @Override + public String toString() { + return format("decode %s into list elements, where each group(1) is transformed with %s", + patternForMatcher, applyFirstGroup); + } + } +} diff --git a/core/src/main/java/feign/codec/EncodeException.java b/core/src/main/java/feign/codec/EncodeException.java new file mode 100644 index 0000000000..12d06ba340 --- /dev/null +++ b/core/src/main/java/feign/codec/EncodeException.java @@ -0,0 +1,45 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.codec; + +import feign.FeignException; + +import static feign.Util.checkNotNull; + +/** + * Similar to {@code javax.websocket.EncodeException}, raised when a problem + * occurs decoding a message. Note that {@code DecodeException} is not an + * {@code IOException}, nor have one set as its cause. + */ +public class EncodeException extends FeignException { + + /** + * @param message the reason for the failure. + */ + public EncodeException(String message) { + super(checkNotNull(message, "message")); + } + + /** + * @param message the reason for the failure. + * @param cause the cause of the error. + */ + public EncodeException(String message, Throwable cause) { + super(checkNotNull(message, "message"), checkNotNull(cause, "cause")); + } + + private static final long serialVersionUID = 1L; +} diff --git a/core/src/main/java/feign/codec/Encoder.java b/core/src/main/java/feign/codec/Encoder.java new file mode 100644 index 0000000000..80614b53fd --- /dev/null +++ b/core/src/main/java/feign/codec/Encoder.java @@ -0,0 +1,75 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.codec; + +/** + * Encodes an object into an HTTP request body. Like + * {@code javax.websocket.Encoder}.
+ * {@code Encoder} is used when a method parameter has no {@code *Param} + * annotation. For example:
+ *

+ *

+ * @POST
+ * @Path("/")
+ * void create(User user);
+ * 
+ *

+ *

Form encoding

+ *
+ * If any parameters are found in {@link feign.MethodMetadata#formParams()}, they will be + * collected and passed to {@code Encoder.Text>}. + *
+ *
+ * @POST
+ * @Path("/")
+ * Session login(@Named("username") String username, @Named("password") String password);
+ * 
+ * + * @param widest type an instance of this can encode. + */ +public interface Encoder { + + /** + * Converts objects to an appropriate text representation.
+ * Ex.
+ *

+ *

+   * public class GsonEncoder implements Encoder.Text<Object> {
+   *     private final Gson gson;
+   *
+   *     public GsonEncoder(Gson gson) {
+   *         this.gson = gson;
+   *     }
+   *
+   *     @Override
+   *     public String encode(Object object) {
+   *         return gson.toJson(object);
+   *     }
+   * }
+   * 
+ */ + interface Text extends Encoder { + /** + * Implement this to encode an object as a String.. If you need to wrap + * exceptions, please do so via {@link EncodeException} + * + * @param object what to encode as the request body. + * @return the encoded object as a string. * @throws EncodeException + * when encoding failed due to a checked exception. + */ + String encode(T object) throws EncodeException; + } +} diff --git a/core/src/main/java/feign/codec/ErrorDecoder.java b/core/src/main/java/feign/codec/ErrorDecoder.java new file mode 100644 index 0000000000..273202d400 --- /dev/null +++ b/core/src/main/java/feign/codec/ErrorDecoder.java @@ -0,0 +1,151 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.codec; + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Collection; +import java.util.Date; +import java.util.Map; + +import feign.FeignException; +import feign.Response; +import feign.RetryableException; + +import static feign.FeignException.errorStatus; +import static feign.Util.RETRY_AFTER; +import static feign.Util.checkNotNull; +import static java.util.Locale.US; +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +/** + * Allows you to massage an exception into a application-specific one. Converting out to a throttle + * exception are examples of this in use. + *
+ * Ex. + *
+ *
+ * class IllegalArgumentExceptionOn404Decoder extends ErrorDecoder {
+ *
+ *   @Override
+ *   public Exception decode(String methodKey, Response response) {
+ *    if (response.status() == 404)
+ *        throw new IllegalArgumentException("zone not found");
+ *    return ErrorDecoder.DEFAULT.decode(methodKey, request, response);
+ *   }
+ *
+ * }
+ * 
+ *
+ * Error handling
+ *
+ * Responses where {@link Response#status()} is not in the 2xx range are + * classified as errors, addressed by the {@link ErrorDecoder}. That said, + * certain RPC apis return errors defined in the {@link Response#body()} even on + * a 200 status. For example, in the DynECT api, a job still running condition + * is returned with a 200 status, encoded in json. When scenarios like this + * occur, you should raise an application-specific exception (which may be + * {@link feign.RetryableException retryable}). + */ +public interface ErrorDecoder { + + /** + * Implement this method in order to decode an HTTP {@link Response} when + * {@link Response#status()} is not in the 2xx range. Please raise application-specific exceptions where possible. + * If your exception is retryable, wrap or subclass {@link RetryableException} + * + * @param methodKey {@link feign.Feign#configKey} of the java method that invoked the request. ex. {@code IAM#getUser()} + * @param response HTTP response where {@link Response#status() status} is greater than or equal to {@code 300}. + * @return Exception IOException, if there was a network error reading the + * response or an application-specific exception decoded by the + * implementation. If the throwable is retryable, it should be + * wrapped, or a subtype of {@link RetryableException} + */ + public Exception decode(String methodKey, Response response); + + public static class Default implements ErrorDecoder { + + private final RetryAfterDecoder retryAfterDecoder = new RetryAfterDecoder(); + + @Override + public Exception decode(String methodKey, Response response) { + FeignException exception = errorStatus(methodKey, response); + Date retryAfter = retryAfterDecoder.apply(firstOrNull(response.headers(), RETRY_AFTER)); + if (retryAfter != null) + return new RetryableException(exception.getMessage(), exception, retryAfter); + return exception; + } + + private T firstOrNull(Map> map, String key) { + if (map.containsKey(key) && !map.get(key).isEmpty()) { + return map.get(key).iterator().next(); + } + return null; + } + } + + /** + * Decodes a {@link feign.Util#RETRY_AFTER} header into an absolute date, + * if possible. + *
+ * See Retry-After + * format + */ + static class RetryAfterDecoder { + static final DateFormat RFC822_FORMAT = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", US); + private final DateFormat rfc822Format; + + RetryAfterDecoder() { + this(RFC822_FORMAT); + } + + protected long currentTimeNanos() { + return System.currentTimeMillis(); + } + + RetryAfterDecoder(DateFormat rfc822Format) { + this.rfc822Format = checkNotNull(rfc822Format, "rfc822Format"); + } + + /** + * returns a date that corresponds to the first time a request can be + * retried. + * + * @param retryAfter String in Retry-After format + */ + public Date apply(String retryAfter) { + if (retryAfter == null) + return null; + if (retryAfter.matches("^[0-9]+$")) { + long currentTimeMillis = NANOSECONDS.toMillis(currentTimeNanos()); + long deltaMillis = SECONDS.toMillis(Long.parseLong(retryAfter)); + return new Date(currentTimeMillis + deltaMillis); + } + synchronized (rfc822Format) { + try { + return rfc822Format.parse(retryAfter); + } catch (ParseException ignored) { + return null; + } + } + } + } +} diff --git a/core/src/main/java/feign/codec/IncrementalDecoder.java b/core/src/main/java/feign/codec/IncrementalDecoder.java new file mode 100644 index 0000000000..00e11b4b2d --- /dev/null +++ b/core/src/main/java/feign/codec/IncrementalDecoder.java @@ -0,0 +1,117 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.codec; + +import feign.FeignException; +import feign.Observer; + +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Decodes an HTTP response incrementally into an {@link feign.Observer} + * via a series of {@link feign.Observer#onNext(Object) onNext} calls. + *

+ * Invoked when {@link feign.Response#status()} is in the 2xx range. + * + * @param input that can be derived from {@link feign.Response.Body}. + * @param widest type an instance of this can decode. + */ +public interface IncrementalDecoder { + /** + * Implement this to decode a resource to an object into a single object. + * If you need to wrap exceptions, please do so via {@link feign.codec.DecodeException}. + *
+ * Do not call {@link feign.Observer#onSuccess() onSuccess} or + * {@link feign.Observer#onFailure onFailure}. + * + * @param input if {@code Closeable}, no need to close this, as the caller + * manages resources. + * @param type type parameter of {@link feign.Observer#onNext}. + * @param observer call {@link feign.Observer#onNext onNext} + * each time an object of {@code type} is decoded + * from the response. + * @param subscribed false indicates the observer should no longer receive + * {@link Observer#onNext(Object)} calls. + * @throws java.io.IOException will be propagated safely to the caller. + * @throws feign.codec.DecodeException when decoding failed due to a checked exception + * besides IOException. + * @throws feign.FeignException when decoding succeeds, but conveys the operation + * failed. + */ + void decode(I input, Type type, Observer observer, AtomicBoolean subscribed) + throws IOException, DecodeException, FeignException; + + /** + * Used for text-based apis, follows + * {@link feign.codec.IncrementalDecoder#decode(Object, java.lang.reflect.Type, feign.Observer, AtomicBoolean)} + * semantics, applied to inputs of type {@link java.io.Reader}.
+ * Ex.
+ *

+ *

+   * public class GsonDecoder implements Decoder.TextStream<Object> {
+   *   private final Gson gson;
+   *
+   *   public GsonDecoder(Gson gson) {
+   *     this.gson = gson;
+   *   }
+   *
+   *   @Override
+   *   public Object decode(Reader reader, Type type) throws IOException {
+   *     try {
+   *       return gson.fromJson(reader, type);
+   *     } catch (JsonIOException e) {
+   *       if (e.getCause() != null &&
+   *           e.getCause() instanceof IOException) {
+   *         throw IOException.class.cast(e.getCause());
+   *       }
+   *       throw e;
+   *     }
+   *   }
+   * }
+   * 
+ *
+   * public class GsonIncrementalDecoder implements IncrementalDecoder {
+   *   private final Gson gson;
+   *
+   *   public GsonIncrementalDecoder(Gson gson) {
+   *     this.gson = gson;
+   *   }
+   *
+   *   @Override public void decode(Reader reader, Type type, Observer observer) throws Exception {
+   *     JsonReader jsonReader = new JsonReader(reader);
+   *     jsonReader.beginArray();
+   *     while (jsonReader.hasNext()) {
+   *       try {
+   *          observer.onNext(gson.fromJson(jsonReader, type));
+   *       } catch (JsonIOException e) {
+   *         if (e.getCause() != null &&
+   *             e.getCause() instanceof IOException) {
+   *           throw IOException.class.cast(e.getCause());
+   *         }
+   *         throw e;
+   *       }
+   *     }
+   *     jsonReader.endArray();
+   *   }
+   * }
+   * 
+   */
+  public interface TextStream extends IncrementalDecoder {
+  }
+}
diff --git a/core/src/main/java/feign/codec/SAXDecoder.java b/core/src/main/java/feign/codec/SAXDecoder.java
new file mode 100644
index 0000000000..972fee9cc2
--- /dev/null
+++ b/core/src/main/java/feign/codec/SAXDecoder.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2013 Netflix, Inc.
+ *
+ * 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.codec;
+
+import org.xml.sax.ContentHandler;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.XMLReader;
+import org.xml.sax.helpers.XMLReaderFactory;
+
+import javax.inject.Provider;
+import java.io.IOException;
+import java.io.Reader;
+import java.lang.reflect.Type;
+
+import static feign.Util.checkNotNull;
+import static feign.Util.checkState;
+
+public class SAXDecoder implements Decoder.TextStream {
+  /* Implementations are not intended to be shared across requests. */
+  public interface ContentHandlerWithResult extends ContentHandler {
+    /*
+     * expected to be set following a call to {@link
+     * XMLReader#parse(InputSource)}
+     */
+    T result();
+  }
+
+  private final Provider> handlers;
+
+  /**
+   * You must subclass this, in order to prevent type erasure on {@code T}. In
+   * addition to making a concrete type, you can also use the following form.
+   * 

+ *
+ *

+ *

+   * new SaxDecoder<Foo>(fooHandlers) {
+   * }; // note the curly braces ensures no type erasure!
+   * 
+ */ + protected SAXDecoder(Provider> handlers) { + this.handlers = checkNotNull(handlers, "handlers"); + } + + @Override + public T decode(Reader reader, Type type) throws IOException, DecodeException { + ContentHandlerWithResult handler = handlers.get(); + checkState(handler != null, "%s returned null for type %s", this, type); + try { + XMLReader xmlReader = XMLReaderFactory.createXMLReader(); + xmlReader.setFeature("http://xml.org/sax/features/namespaces", false); + xmlReader.setFeature("http://xml.org/sax/features/validation", false); + xmlReader.setContentHandler(handler); + xmlReader.parse(new InputSource(reader)); + return handler.result(); + } catch (SAXException e) { + throw new DecodeException(e.getMessage(), e); + } + } +} diff --git a/core/src/main/java/feign/codec/StringDecoder.java b/core/src/main/java/feign/codec/StringDecoder.java new file mode 100644 index 0000000000..8711b2d424 --- /dev/null +++ b/core/src/main/java/feign/codec/StringDecoder.java @@ -0,0 +1,40 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.codec; + +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.nio.CharBuffer; + +/** + * Adapted from {@code com.google.common.io.CharStreams.toString()}. + */ +public class StringDecoder implements Decoder.TextStream { + private static final int BUF_SIZE = 0x800; // 2K chars (4K bytes) + + @Override + public String decode(Reader from, Type type) throws IOException { + StringBuilder to = new StringBuilder(); + CharBuffer buf = CharBuffer.allocate(BUF_SIZE); + while (from.read(buf) != -1) { + buf.flip(); + to.append(buf); + buf.clear(); + } + return to.toString(); + } +} diff --git a/core/src/main/java/feign/codec/StringIncrementalDecoder.java b/core/src/main/java/feign/codec/StringIncrementalDecoder.java new file mode 100644 index 0000000000..a3fa77bae8 --- /dev/null +++ b/core/src/main/java/feign/codec/StringIncrementalDecoder.java @@ -0,0 +1,33 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.codec; + +import feign.Observer; + +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.concurrent.atomic.AtomicBoolean; + +public class StringIncrementalDecoder implements IncrementalDecoder.TextStream { + private static final StringDecoder STRING_DECODER = new StringDecoder(); + + @Override + public void decode(Reader reader, Type type, Observer observer, AtomicBoolean subscribed) + throws IOException { + observer.onNext(STRING_DECODER.decode(reader, type)); + } +} diff --git a/core/src/test/java/feign/AcceptAllHostnameVerifier.java b/core/src/test/java/feign/AcceptAllHostnameVerifier.java new file mode 100644 index 0000000000..fa0055dba3 --- /dev/null +++ b/core/src/test/java/feign/AcceptAllHostnameVerifier.java @@ -0,0 +1,26 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; + +final class AcceptAllHostnameVerifier implements HostnameVerifier { + @Override + public boolean verify(String s, SSLSession sslSession) { + return true; + } +} diff --git a/core/src/test/java/feign/DefaultContractTest.java b/core/src/test/java/feign/DefaultContractTest.java new file mode 100644 index 0000000000..aaaaf7ebc4 --- /dev/null +++ b/core/src/test/java/feign/DefaultContractTest.java @@ -0,0 +1,277 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.gson.reflect.TypeToken; +import org.testng.annotations.Test; + +import javax.inject.Named; +import java.lang.reflect.Type; +import java.net.URI; +import java.util.List; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Tests interfaces defined per {@link Contract.Default} are interpreted into expected {@link feign + * .RequestTemplate template} + * instances. + */ +@Test +public class DefaultContractTest { + Contract.Default contract = new Contract.Default(); + + interface Methods { + @RequestLine("POST /") void post(); + + @RequestLine("PUT /") void put(); + + @RequestLine("GET /") void get(); + + @RequestLine("DELETE /") void delete(); + } + + @Test public void httpMethods() throws Exception { + assertEquals(contract.parseAndValidatateMetadata(Methods.class.getDeclaredMethod("post")).template().method(), + "POST"); + assertEquals(contract.parseAndValidatateMetadata(Methods.class.getDeclaredMethod("put")).template().method(), + "PUT"); + assertEquals(contract.parseAndValidatateMetadata(Methods.class.getDeclaredMethod("get")).template().method(), + "GET"); + assertEquals(contract.parseAndValidatateMetadata(Methods.class.getDeclaredMethod("delete")).template().method(), + "DELETE"); + } + + interface BodyParams { + @RequestLine("POST") Response post(List body); + + @RequestLine("POST") Response tooMany(List body, List body2); + } + + @Test public void bodyParamIsGeneric() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(BodyParams.class.getDeclaredMethod("post", + List.class)); + assertNull(md.template().body()); + assertNull(md.template().bodyTemplate()); + assertNull(md.urlIndex()); + assertEquals(md.bodyIndex(), Integer.valueOf(0)); + assertEquals(md.bodyType(), new TypeToken>() { + }.getType()); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "Method has too many Body.*") + public void tooManyBodies() throws Exception { + contract.parseAndValidatateMetadata(BodyParams.class.getDeclaredMethod("tooMany", List.class, List.class)); + } + + interface CustomMethodAndURIParam { + @RequestLine("PATCH") Response patch(URI nextLink); + } + + @Test public void requestLineOnlyRequiresMethod() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(CustomMethodAndURIParam.class.getDeclaredMethod("patch", + URI.class)); + assertEquals(md.template().method(), "PATCH"); + assertEquals(md.template().url(), ""); + assertTrue(md.template().queries().isEmpty()); + assertTrue(md.template().headers().isEmpty()); + assertNull(md.template().body()); + assertNull(md.template().bodyTemplate()); + assertEquals(md.urlIndex(), Integer.valueOf(0)); + } + + interface WithQueryParamsInPath { + @RequestLine("GET /") Response none(); + + @RequestLine("GET /?Action=GetUser") Response one(); + + @RequestLine("GET /?Action=GetUser&Version=2010-05-08") Response two(); + + @RequestLine("GET /?Action=GetUser&Version=2010-05-08&limit=1") Response three(); + + @RequestLine("GET /?flag&Action=GetUser&Version=2010-05-08") Response empty(); + } + + @Test public void queryParamsInPathExtract() throws Exception { + { + MethodMetadata md = contract.parseAndValidatateMetadata(WithQueryParamsInPath.class.getDeclaredMethod("none")); + assertEquals(md.template().url(), "/"); + assertTrue(md.template().queries().isEmpty()); + assertEquals(md.template().toString(), "GET / HTTP/1.1\n"); + } + { + MethodMetadata md = contract.parseAndValidatateMetadata(WithQueryParamsInPath.class.getDeclaredMethod("one")); + assertEquals(md.template().url(), "/"); + assertEquals(md.template().queries().get("Action"), ImmutableSet.of("GetUser")); + assertEquals(md.template().toString(), "GET /?Action=GetUser HTTP/1.1\n"); + } + { + MethodMetadata md = contract.parseAndValidatateMetadata(WithQueryParamsInPath.class.getDeclaredMethod("two")); + assertEquals(md.template().url(), "/"); + assertEquals(md.template().queries().get("Action"), ImmutableSet.of("GetUser")); + assertEquals(md.template().queries().get("Version"), ImmutableSet.of("2010-05-08")); + assertEquals(md.template().toString(), "GET /?Action=GetUser&Version=2010-05-08 HTTP/1.1\n"); + } + { + MethodMetadata md = contract.parseAndValidatateMetadata(WithQueryParamsInPath.class.getDeclaredMethod("three")); + assertEquals(md.template().url(), "/"); + assertEquals(md.template().queries().get("Action"), ImmutableSet.of("GetUser")); + assertEquals(md.template().queries().get("Version"), ImmutableSet.of("2010-05-08")); + assertEquals(md.template().queries().get("limit"), ImmutableSet.of("1")); + assertEquals(md.template().toString(), "GET /?Action=GetUser&Version=2010-05-08&limit=1 HTTP/1.1\n"); + } + { + MethodMetadata md = contract.parseAndValidatateMetadata(WithQueryParamsInPath.class.getDeclaredMethod("empty")); + assertEquals(md.template().url(), "/"); + assertTrue(md.template().queries().containsKey("flag")); + assertEquals(md.template().queries().get("Action"), ImmutableSet.of("GetUser")); + assertEquals(md.template().queries().get("Version"), ImmutableSet.of("2010-05-08")); + assertEquals(md.template().toString(), "GET /?flag&Action=GetUser&Version=2010-05-08 HTTP/1.1\n"); + } + } + + interface BodyWithoutParameters { + @RequestLine("POST /") + @Headers("Content-Type: application/xml") + @Body("") Response post(); + } + + @Test public void bodyWithoutParameters() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(BodyWithoutParameters.class.getDeclaredMethod("post")); + assertEquals(md.template().body(), ""); + assertFalse(md.template().bodyTemplate() != null); + assertTrue(md.formParams().isEmpty()); + assertTrue(md.indexToName().isEmpty()); + } + + @Test public void producesAddsContentTypeHeader() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(BodyWithoutParameters.class.getDeclaredMethod("post")); + assertEquals(md.template().headers().get("Content-Type"), ImmutableSet.of("application/xml")); + } + + interface WithURIParam { + @RequestLine("GET /{1}/{2}") Response uriParam(@Named("1") String one, URI endpoint, @Named("2") String two); + } + + @Test public void methodCanHaveUriParam() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(WithURIParam.class.getDeclaredMethod("uriParam", String.class, + URI.class, String.class)); + assertEquals(md.urlIndex(), Integer.valueOf(1)); + } + + @Test public void pathParamsParseIntoIndexToName() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(WithURIParam.class.getDeclaredMethod("uriParam", String.class, + URI.class, String.class)); + assertEquals(md.template().url(), "/{1}/{2}"); + assertEquals(md.indexToName().get(0), ImmutableSet.of("1")); + assertEquals(md.indexToName().get(2), ImmutableSet.of("2")); + } + + interface WithPathAndQueryParams { + @RequestLine("GET /domains/{domainId}/records?name={name}&type={type}") + Response recordsByNameAndType(@Named("domainId") int id, @Named("name") String nameFilter, + @Named("type") String typeFilter); + } + + @Test public void mixedRequestLineParams() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(WithPathAndQueryParams.class.getDeclaredMethod + ("recordsByNameAndType", int.class, String.class, String.class)); + assertNull(md.template().body()); + assertNull(md.template().bodyTemplate()); + assertTrue(md.template().headers().isEmpty()); + assertEquals(md.template().url(), "/domains/{domainId}/records"); + assertEquals(md.template().queries().get("name"), ImmutableSet.of("{name}")); + assertEquals(md.template().queries().get("type"), ImmutableSet.of("{type}")); + assertEquals(md.indexToName().get(0), ImmutableSet.of("domainId")); + assertEquals(md.indexToName().get(1), ImmutableSet.of("name")); + assertEquals(md.indexToName().get(2), ImmutableSet.of("type")); + assertEquals(md.template().toString(), "GET /domains/{domainId}/records?name={name}&type={type} HTTP/1.1\n"); + } + + interface FormParams { + @RequestLine("POST /") + @Body("%7B\"customer_name\": \"{customer_name}\", \"user_name\": \"{user_name}\", \"password\": \"{password}\"%7D") + void login( + @Named("customer_name") String customer, + @Named("user_name") String user, @Named("password") String password); + } + + @Test public void formParamsParseIntoIndexToName() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(FormParams.class.getDeclaredMethod("login", String.class, + String.class, String.class)); + + assertFalse(md.template().body() != null); + assertEquals(md.template().bodyTemplate(), + "%7B\"customer_name\": \"{customer_name}\", \"user_name\": \"{user_name}\", \"password\": \"{password}\"%7D"); + assertEquals(md.formParams(), ImmutableList.of("customer_name", "user_name", "password")); + assertEquals(md.indexToName().get(0), ImmutableSet.of("customer_name")); + assertEquals(md.indexToName().get(1), ImmutableSet.of("user_name")); + assertEquals(md.indexToName().get(2), ImmutableSet.of("password")); + } + + interface HeaderParams { + @RequestLine("POST /") + @Headers("Auth-Token: {Auth-Token}") void logout(@Named("Auth-Token") String token); + } + + @Test public void headerParamsParseIntoIndexToName() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(HeaderParams.class.getDeclaredMethod("logout", String.class)); + + assertEquals(md.template().headers().get("Auth-Token"), ImmutableSet.of("{Auth-Token}")); + assertEquals(md.indexToName().get(0), ImmutableSet.of("Auth-Token")); + } + + interface WithObservable { + @RequestLine("GET /") Observable> valid(); + + @RequestLine("GET /") Observable> wildcardExtends(); + + @RequestLine("GET /") ParameterizedObservable> subtype(); + + @RequestLine("GET /") Response returnType(Observable> one); + + @RequestLine("GET /") Observable> alsoObserver(Observer> observer); + } + + interface ParameterizedObservable> extends Observable { + } + + static final List listString = null; + + @Test public void methodCanHaveObservableReturn() throws Exception { + contract.parseAndValidatateMetadata(WithObservable.class.getDeclaredMethod("valid")); + } + + @Test public void methodMetadataReturnTypeOnObservableMethodIsItsTypeParameter() throws Exception { + Type listStringType = getClass().getDeclaredField("listString").getGenericType(); + MethodMetadata md = contract.parseAndValidatateMetadata(WithObservable.class.getDeclaredMethod("valid")); + assertEquals(md.incrementalType(), listStringType); + md = contract.parseAndValidatateMetadata(WithObservable.class.getDeclaredMethod("wildcardExtends")); + assertEquals(md.incrementalType(), listStringType); + md = contract.parseAndValidatateMetadata(WithObservable.class.getDeclaredMethod("subtype")); + assertEquals(md.incrementalType(), listStringType); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "Please return Observer as opposed to passing an Observable arg.*") + public void noObserverArgs() throws Exception { + contract.parseAndValidatateMetadata(WithObservable.class.getDeclaredMethod("alsoObserver", Observer.class)); + } +} diff --git a/core/src/test/java/feign/DefaultRetryerTest.java b/core/src/test/java/feign/DefaultRetryerTest.java new file mode 100644 index 0000000000..6ccc9c6857 --- /dev/null +++ b/core/src/test/java/feign/DefaultRetryerTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import org.testng.annotations.Test; + +import java.util.Date; + +import feign.Retryer.Default; + +import static org.testng.Assert.assertEquals; + +@Test +public class DefaultRetryerTest { + + @Test(expectedExceptions = RetryableException.class) + public void only5TriesAllowedAndExponentialBackoff() throws Exception { + RetryableException e = new RetryableException(null, null, null); + Default retryer = new Retryer.Default(); + assertEquals(retryer.attempt, 1); + assertEquals(retryer.sleptForMillis, 0); + + retryer.continueOrPropagate(e); + assertEquals(retryer.attempt, 2); + assertEquals(retryer.sleptForMillis, 150); + + retryer.continueOrPropagate(e); + assertEquals(retryer.attempt, 3); + assertEquals(retryer.sleptForMillis, 375); + + retryer.continueOrPropagate(e); + assertEquals(retryer.attempt, 4); + assertEquals(retryer.sleptForMillis, 712); + + retryer.continueOrPropagate(e); + assertEquals(retryer.attempt, 5); + assertEquals(retryer.sleptForMillis, 1218); + + retryer.continueOrPropagate(e); + // fail + } + + @Test public void considersRetryAfterButNotMoreThanMaxPeriod() throws Exception { + Default retryer = new Retryer.Default() { + protected long currentTimeMillis() { + return 0; + } + }; + + retryer.continueOrPropagate(new RetryableException(null, null, new Date(5000))); + assertEquals(retryer.attempt, 2); + assertEquals(retryer.sleptForMillis, 1000); + } +} diff --git a/core/src/test/java/feign/FeignTest.java b/core/src/test/java/feign/FeignTest.java new file mode 100644 index 0000000000..c0dc93e378 --- /dev/null +++ b/core/src/test/java/feign/FeignTest.java @@ -0,0 +1,632 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import com.google.common.base.Joiner; +import com.google.common.io.ByteStreams; +import com.google.common.io.CharStreams; +import com.google.mockwebserver.MockResponse; +import com.google.mockwebserver.MockWebServer; +import com.google.mockwebserver.RecordedRequest; +import com.google.mockwebserver.SocketPolicy; +import dagger.Lazy; +import dagger.Module; +import dagger.Provides; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; +import feign.codec.StringDecoder; +import org.testng.annotations.Test; + +import javax.inject.Named; +import javax.inject.Singleton; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +import static dagger.Provides.Type.SET; +import static feign.Util.UTF_8; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +@Test +// unbound wildcards are not currently injectable in dagger. +@SuppressWarnings("rawtypes") +public class FeignTest { + + @Test public void closeShutsdownExecutorService() throws IOException, InterruptedException { + final ExecutorService service = Executors.newCachedThreadPool(); + new Feign(new Lazy() { + @Override public Executor get() { + return service; + } + }) { + @Override public T newInstance(Target target) { + return null; + } + }.close(); + assertTrue(service.isShutdown()); + } + + interface TestInterface { + @RequestLine("POST /") String post(); + + @RequestLine("POST /") + @Body("%7B\"customer_name\": \"{customer_name}\", \"user_name\": \"{user_name}\", \"password\": \"{password}\"%7D") + void login( + @Named("customer_name") String customer, @Named("user_name") String user, @Named("password") String password); + + @RequestLine("POST /") void body(List contents); + + @RequestLine("POST /") @Headers("Content-Encoding: gzip") void gzipBody(List contents); + + @RequestLine("POST /") void form( + @Named("customer_name") String customer, @Named("user_name") String user, @Named("password") String password); + + @RequestLine("GET /{1}/{2}") Response uriParam(@Named("1") String one, URI endpoint, @Named("2") String two); + + @RequestLine("GET /?1={1}&2={2}") Response queryParams(@Named("1") String one, @Named("2") Iterable twos); + + @RequestLine("POST /") Observable observableVoid(); + + @RequestLine("POST /") Observable observableString(); + + @RequestLine("POST /") Observable observableResponse(); + + @dagger.Module(library = true) + static class Module { + @Provides(type = SET) Encoder defaultEncoder() { + return new Encoder.Text() { + @Override public String encode(Object object) { + return object.toString(); + } + }; + } + + @Provides(type = SET) Encoder formEncoder() { + return new Encoder.Text>() { + @Override public String encode(Map object) { + return Joiner.on(',').withKeyValueSeparator("=").join(object); + } + }; + } + } + } + + @Test + public void iterableQueryParams() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo")); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), new TestInterface.Module()); + + api.queryParams("user", Arrays.asList("apple", "pear")); + assertEquals(server.takeRequest().getRequestLine(), "GET /?1=user&2=apple&2=pear HTTP/1.1"); + } finally { + server.shutdown(); + } + } + + @Test + public void observableVoid() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo")); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), + new TestInterface.Module(), new RunSynchronous()); + + final AtomicBoolean success = new AtomicBoolean(); + + Observer observer = new Observer() { + + @Override public void onNext(Void element) { + fail("on next isn't valid for void"); + } + + @Override public void onSuccess() { + success.set(true); + } + + @Override public void onFailure(Throwable cause) { + fail(cause.getMessage()); + } + }; + api.observableVoid().subscribe(observer); + + assertTrue(success.get()); + assertEquals(server.getRequestCount(), 1); + } finally { + server.shutdown(); + } + } + + @Test + public void observableResponse() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo")); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), + new TestInterface.Module(), new RunSynchronous()); + + final AtomicBoolean success = new AtomicBoolean(); + + Observer observer = new Observer() { + + @Override public void onNext(Response element) { + assertEquals(element.status(), 200); + } + + @Override public void onSuccess() { + success.set(true); + } + + @Override public void onFailure(Throwable cause) { + fail(cause.getMessage()); + } + }; + api.observableResponse().subscribe(observer); + + assertTrue(success.get()); + assertEquals(server.getRequestCount(), 1); + } finally { + server.shutdown(); + } + } + + @Module(library = true, overrides = true) + static class RunSynchronous { + @Provides @Singleton @Named("http") Executor httpExecutor() { + return new Executor() { + @Override public void execute(Runnable command) { + command.run(); + } + }; + } + } + + @Test + public void incrementString() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo")); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), + new TestInterface.Module(), new RunSynchronous()); + + final AtomicBoolean success = new AtomicBoolean(); + + Observer observer = new Observer() { + + @Override public void onNext(String element) { + assertEquals(element, "foo"); + } + + @Override public void onSuccess() { + success.set(true); + } + + @Override public void onFailure(Throwable cause) { + fail(cause.getMessage()); + } + }; + api.observableString().subscribe(observer); + + assertTrue(success.get()); + assertEquals(server.getRequestCount(), 1); + } finally { + server.shutdown(); + } + } + + @Test + public void multipleObservers() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo")); + server.enqueue(new MockResponse().setBody("foo")); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), new TestInterface.Module()); + + final CountDownLatch latch = new CountDownLatch(2); + + Observer observer = new Observer() { + + @Override public void onNext(String element) { + assertEquals(element, "foo"); + } + + @Override public void onSuccess() { + latch.countDown(); + } + + @Override public void onFailure(Throwable cause) { + fail(cause.getMessage()); + } + }; + + Observable observable = api.observableString(); + observable.subscribe(observer); + observable.subscribe(observer); + latch.await(); + + assertEquals(server.getRequestCount(), 2); + } finally { + server.shutdown(); + } + } + + @Test + public void postTemplateParamsResolve() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo")); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), new TestInterface.Module()); + + api.login("netflix", "denominator", "password"); + assertEquals(new String(server.takeRequest().getBody()), + "{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"}"); + } finally { + server.shutdown(); + } + } + + @Test + public void postFormParams() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo")); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), new TestInterface.Module()); + + api.form("netflix", "denominator", "password"); + assertEquals(new String(server.takeRequest().getBody()), + "customer_name=netflix,user_name=denominator,password=password"); + } finally { + server.shutdown(); + } + } + + @Test + public void postBodyParam() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo")); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), new TestInterface.Module()); + + api.body(Arrays.asList("netflix", "denominator", "password")); + RecordedRequest request = server.takeRequest(); + assertEquals(request.getHeader("Content-Length"), "32"); + assertEquals(new String(request.getBody()), "[netflix, denominator, password]"); + } finally { + server.shutdown(); + } + } + + @Test + public void postGZIPEncodedBodyParam() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo")); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), new TestInterface.Module()); + + api.gzipBody(Arrays.asList("netflix", "denominator", "password")); + RecordedRequest request = server.takeRequest(); + assertNull(request.getHeader("Content-Length")); + byte[] compressedBody = request.getBody(); + String uncompressedBody = CharStreams.toString(CharStreams.newReaderSupplier( + GZIPStreams.newInputStreamSupplier(ByteStreams.newInputStreamSupplier(compressedBody)), UTF_8)); + assertEquals(uncompressedBody, "[netflix, denominator, password]"); + } finally { + server.shutdown(); + } + } + + @Module(library = true) + static class ForwardedForInterceptor implements RequestInterceptor { + @Provides(type = SET) RequestInterceptor provideThis() { + return this; + } + + @Override public void apply(RequestTemplate template) { + template.header("X-Forwarded-For", "origin.host.com"); + } + } + + @Test + public void singleInterceptor() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo")); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), + new TestInterface.Module(), new ForwardedForInterceptor()); + + api.post(); + assertEquals(server.takeRequest().getHeader("X-Forwarded-For"), "origin.host.com"); + } finally { + server.shutdown(); + } + } + + @Module(library = true) + static class UserAgentInterceptor implements RequestInterceptor { + @Provides(type = SET) RequestInterceptor provideThis() { + return this; + } + + @Override public void apply(RequestTemplate template) { + template.header("User-Agent", "Feign"); + } + } + + @Test + public void multipleInterceptor() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo")); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), + new TestInterface.Module(), new ForwardedForInterceptor(), new UserAgentInterceptor()); + + api.post(); + RecordedRequest request = server.takeRequest(); + assertEquals(request.getHeader("X-Forwarded-For"), "origin.host.com"); + assertEquals(request.getHeader("User-Agent"), "Feign"); + } finally { + server.shutdown(); + } + } + + @Test public void toKeyMethodFormatsAsExpected() throws Exception { + assertEquals(Feign.configKey(TestInterface.class.getDeclaredMethod("post")), "TestInterface#post()"); + assertEquals(Feign.configKey(TestInterface.class.getDeclaredMethod("uriParam", String.class, URI.class, + String.class)), "TestInterface#uriParam(String,URI,String)"); + } + + @dagger.Module(overrides = true, library = true, includes = TestInterface.Module.class) + static class IllegalArgumentExceptionOn404 { + @Provides @Singleton ErrorDecoder errorDecoder() { + return new ErrorDecoder.Default() { + + @Override + public Exception decode(String methodKey, Response response) { + if (response.status() == 404) + return new IllegalArgumentException("zone not found"); + return super.decode(methodKey, response); + } + + }; + } + } + + @Test(expectedExceptions = IllegalArgumentException.class, expectedExceptionsMessageRegExp = "zone not found") + public void canOverrideErrorDecoder() throws IOException, InterruptedException { + + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setResponseCode(404).setBody("foo")); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), + new IllegalArgumentExceptionOn404()); + + api.post(); + } finally { + server.shutdown(); + } + } + + @Test public void retriesLostConnectionBeforeRead() throws IOException, InterruptedException { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + server.enqueue(new MockResponse().setBody("success!".getBytes())); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), + new TestInterface.Module()); + + api.post(); + assertEquals(server.getRequestCount(), 2); + + } finally { + server.shutdown(); + } + } + + @dagger.Module(overrides = true, library = true, includes = TestInterface.Module.class) + static class DecodeFail { + @Provides(type = SET) Decoder decoder() { + return new Decoder.TextStream() { + @Override + public String decode(Reader reader, Type type) throws IOException { + return "fail"; + } + }; + } + } + + public void overrideTypeSpecificDecoder() throws IOException, InterruptedException { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("success!".getBytes())); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), + new DecodeFail()); + + assertEquals(api.post(), "fail"); + } finally { + server.shutdown(); + assertEquals(server.getRequestCount(), 1); + } + } + + @dagger.Module(overrides = true, library = true, includes = TestInterface.Module.class) + static class RetryableExceptionOnRetry { + @Provides(type = SET) Decoder decoder() { + return new StringDecoder() { + @Override + public String decode(Reader reader, Type type) throws RetryableException, IOException { + String string = super.decode(reader, type); + if ("retry!".equals(string)) + throw new RetryableException(string, null); + return string; + } + }; + } + } + + /** + * when you must parse a 2xx status to determine if the operation succeeded or not. + */ + public void retryableExceptionInDecoder() throws IOException, InterruptedException { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("retry!".getBytes())); + server.enqueue(new MockResponse().setBody("success!".getBytes())); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), + new RetryableExceptionOnRetry()); + + assertEquals(api.post(), "success!"); + } finally { + server.shutdown(); + assertEquals(server.getRequestCount(), 2); + } + } + + @dagger.Module(overrides = true, library = true, includes = TestInterface.Module.class) + static class IOEOnDecode { + @Provides(type = SET) Decoder decoder() { + return new Decoder.TextStream() { + @Override + public String decode(Reader reader, Type type) throws IOException { + throw new IOException("error reading response"); + } + }; + } + } + + @Test(expectedExceptions = FeignException.class, expectedExceptionsMessageRegExp = "error reading response POST http://.*") + public void doesntRetryAfterResponseIsSent() throws IOException, InterruptedException { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("success!".getBytes())); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "http://localhost:" + server.getPort(), + new IOEOnDecode()); + + api.post(); + } finally { + server.shutdown(); + assertEquals(server.getRequestCount(), 1); + } + } + + @Module(injects = Client.Default.class, overrides = true, addsTo = Feign.Defaults.class) + static class TrustSSLSockets { + @Provides SSLSocketFactory trustingSSLSocketFactory() { + return TrustingSSLSocketFactory.get(); + } + } + + @Test public void canOverrideSSLSocketFactory() throws IOException, InterruptedException { + MockWebServer server = new MockWebServer(); + server.useHttps(TrustingSSLSocketFactory.get("localhost"), false); + server.enqueue(new MockResponse().setBody("success!".getBytes())); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "https://localhost:" + server.getPort(), + new TestInterface.Module(), new TrustSSLSockets()); + api.post(); + } finally { + server.shutdown(); + } + } + + @Module(injects = Client.Default.class, overrides = true, addsTo = Feign.Defaults.class) + static class DisableHostnameVerification { + @Provides HostnameVerifier acceptAllHostnameVerifier() { + return new AcceptAllHostnameVerifier(); + } + } + + @Test public void canOverrideHostnameVerifier() throws IOException, InterruptedException { + MockWebServer server = new MockWebServer(); + server.useHttps(TrustingSSLSocketFactory.get("bad.example.com"), false); + server.enqueue(new MockResponse().setBody("success!".getBytes())); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "https://localhost:" + server.getPort(), + new TestInterface.Module(), new TrustSSLSockets(), new DisableHostnameVerification()); + api.post(); + } finally { + server.shutdown(); + } + } + + @Test public void retriesFailedHandshake() throws IOException, InterruptedException { + MockWebServer server = new MockWebServer(); + server.useHttps(TrustingSSLSocketFactory.get("localhost"), false); + server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.FAIL_HANDSHAKE)); + server.enqueue(new MockResponse().setBody("success!".getBytes())); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "https://localhost:" + server.getPort(), + new TestInterface.Module(), new TrustSSLSockets()); + api.post(); + assertEquals(server.getRequestCount(), 2); + } finally { + server.shutdown(); + } + } +} diff --git a/core/src/test/java/feign/GZIPStreams.java b/core/src/test/java/feign/GZIPStreams.java new file mode 100644 index 0000000000..42b2886825 --- /dev/null +++ b/core/src/test/java/feign/GZIPStreams.java @@ -0,0 +1,41 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import com.google.common.io.InputSupplier; + +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.GZIPInputStream; + +class GZIPStreams { + static InputSupplier newInputStreamSupplier(InputSupplier supplier) { + return new GZIPInputStreamSupplier(supplier); + } + + private static class GZIPInputStreamSupplier implements InputSupplier { + private final InputSupplier supplier; + + GZIPInputStreamSupplier(InputSupplier supplier) { + this.supplier = supplier; + } + + @Override + public GZIPInputStream getInput() throws IOException { + return new GZIPInputStream(supplier.getInput()); + } + } +} diff --git a/core/src/test/java/feign/LoggerTest.java b/core/src/test/java/feign/LoggerTest.java new file mode 100644 index 0000000000..a7a715ed2b --- /dev/null +++ b/core/src/test/java/feign/LoggerTest.java @@ -0,0 +1,326 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import com.google.common.base.Joiner; +import com.google.mockwebserver.MockResponse; +import com.google.mockwebserver.MockWebServer; +import dagger.Provides; +import feign.codec.Encoder; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import javax.inject.Named; +import javax.inject.Singleton; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; + +import static dagger.Provides.Type.SET; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +@Test +public class LoggerTest { + + Logger logger = new Logger() { + @Override protected void log(String configKey, String format, Object... args) { + messages.add(methodTag(configKey) + String.format(format, args)); + } + }; + + List messages = new ArrayList(); + + @BeforeMethod void clear() { + messages.clear(); + } + + interface SendsStuff { + + @RequestLine("POST /") + @Headers("Content-Type: application/json") + @Body("%7B\"customer_name\": \"{customer_name}\", \"user_name\": \"{user_name}\", \"password\": \"{password}\"%7D") + String login( + @Named("customer_name") String customer, + @Named("user_name") String user, @Named("password") String password); + } + + @DataProvider(name = "levelToOutput") + public Object[][] levelToOutput() { + Object[][] data = new Object[4][2]; + data[0][0] = Logger.Level.NONE; + data[0][1] = Arrays.asList(); + data[1][0] = Logger.Level.BASIC; + data[1][1] = Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1", + "\\[SendsStuff#login\\] <--- HTTP/1.1 200 OK \\([0-9]+ms\\)" + ); + data[2][0] = Logger.Level.HEADERS; + data[2][1] = Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[SendsStuff#login\\] Content-Length: 80", + "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)", + "\\[SendsStuff#login\\] <--- HTTP/1.1 200 OK \\([0-9]+ms\\)", + "\\[SendsStuff#login\\] Content-Length: 3", + "\\[SendsStuff#login\\] <--- END HTTP \\(3-byte body\\)" + ); + data[3][0] = Logger.Level.FULL; + data[3][1] = Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[SendsStuff#login\\] Content-Length: 80", + "\\[SendsStuff#login\\] ", + "\\[SendsStuff#login\\] \\{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"\\}", + "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)", + "\\[SendsStuff#login\\] <--- HTTP/1.1 200 OK \\([0-9]+ms\\)", + "\\[SendsStuff#login\\] Content-Length: 3", + "\\[SendsStuff#login\\] ", + "\\[SendsStuff#login\\] foo", + "\\[SendsStuff#login\\] <--- END HTTP \\(3-byte body\\)" + ); + return data; + } + + @Test(dataProvider = "levelToOutput") + public void levelEmits(final Logger.Level logLevel, List expectedMessages) throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo")); + server.play(); + + try { + SendsStuff api = Feign.create(SendsStuff.class, "http://localhost:" + server.getUrl("").getPort(), + new DefaultModule(logger, logLevel)); + + api.login("netflix", "denominator", "password"); + + assertEquals(messages.size(), expectedMessages.size()); + for (int i = 0; i < messages.size(); i++) { + assertTrue(messages.get(i).matches(expectedMessages.get(i)), messages.get(i)); + } + + assertEquals(new String(server.takeRequest().getBody()), + "{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"}"); + } finally { + server.shutdown(); + } + } + + static @dagger.Module(overrides = true, library = true) class DefaultModule { + final Logger logger; + final Logger.Level logLevel; + + DefaultModule(Logger logger, Logger.Level logLevel) { + this.logger = logger; + this.logLevel = logLevel; + } + + @Provides(type = SET) Encoder defaultEncoder() { + return new Encoder.Text() { + @Override public String encode(Object object) { + return object.toString(); + } + }; + } + + @Provides @Singleton Logger logger() { + return logger; + } + + @Provides @Singleton Logger.Level level() { + return logLevel; + } + } + + @DataProvider(name = "levelToReadTimeoutOutput") + public Object[][] levelToReadTimeoutOutput() { + Object[][] data = new Object[4][2]; + data[0][0] = Logger.Level.NONE; + data[0][1] = Arrays.asList(); + data[1][0] = Logger.Level.BASIC; + data[1][1] = Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1", + "\\[SendsStuff#login\\] <--- HTTP/1.1 200 OK \\([0-9]+ms\\)", + "\\[SendsStuff#login\\] <--- ERROR SocketTimeoutException: Read timed out \\([0-9]+ms\\)" + ); + data[2][0] = Logger.Level.HEADERS; + data[2][1] = Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[SendsStuff#login\\] Content-Length: 80", + "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)", + "\\[SendsStuff#login\\] <--- HTTP/1.1 200 OK \\([0-9]+ms\\)", + "\\[SendsStuff#login\\] Content-Length: 3", + "\\[SendsStuff#login\\] <--- ERROR SocketTimeoutException: Read timed out \\([0-9]+ms\\)" + ); + data[3][0] = Logger.Level.FULL; + data[3][1] = Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[SendsStuff#login\\] Content-Length: 80", + "\\[SendsStuff#login\\] ", + "\\[SendsStuff#login\\] \\{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"\\}", + "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)", + "\\[SendsStuff#login\\] <--- HTTP/1.1 200 OK \\([0-9]+ms\\)", + "\\[SendsStuff#login\\] Content-Length: 3", + "\\[SendsStuff#login\\] ", + "\\[SendsStuff#login\\] <--- ERROR SocketTimeoutException: Read timed out \\([0-9]+ms\\)", + "\\[SendsStuff#login\\] java.net.SocketTimeoutException: Read timed out.*", + "\\[SendsStuff#login\\] <--- END ERROR" + ); + return data; + } + + @dagger.Module(overrides = true, library = true) + static class LessReadTimeoutModule { + @Provides Request.Options lessReadTimeout() { + return new Request.Options(10 * 1000, 50); + } + } + + @Test(dataProvider = "levelToReadTimeoutOutput") + public void readTimeoutEmits(final Logger.Level logLevel, List expectedMessages) throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBytesPerSecond(1).setBody("foo")); + server.play(); + + + try { + SendsStuff api = Feign.create(SendsStuff.class, "http://localhost:" + server.getUrl("").getPort(), + new LessReadTimeoutModule(), new DefaultModule(logger, logLevel)); + + api.login("netflix", "denominator", "password"); + + fail(); + } catch (FeignException e) { + + assertMessagesMatch(expectedMessages); + + assertEquals(new String(server.takeRequest().getBody()), + "{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"}"); + } finally { + server.shutdown(); + } + } + + @DataProvider(name = "levelToUnknownHostOutput") + public Object[][] levelToUnknownHostOutput() { + Object[][] data = new Object[4][2]; + data[0][0] = Logger.Level.NONE; + data[0][1] = Arrays.asList(); + data[1][0] = Logger.Level.BASIC; + data[1][1] = Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://robofu.abc/ HTTP/1.1", + "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: robofu.abc \\([0-9]+ms\\)" + ); + data[2][0] = Logger.Level.HEADERS; + data[2][1] = Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://robofu.abc/ HTTP/1.1", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[SendsStuff#login\\] Content-Length: 80", + "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)", + "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: robofu.abc \\([0-9]+ms\\)" + ); + data[3][0] = Logger.Level.FULL; + data[3][1] = Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://robofu.abc/ HTTP/1.1", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[SendsStuff#login\\] Content-Length: 80", + "\\[SendsStuff#login\\] ", + "\\[SendsStuff#login\\] \\{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"\\}", + "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)", + "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: robofu.abc \\([0-9]+ms\\)", + "\\[SendsStuff#login\\] java.net.UnknownHostException: robofu.abc.*", + "\\[SendsStuff#login\\] <--- END ERROR" + ); + return data; + } + + @dagger.Module(overrides = true, library = true) + static class DontRetryModule { + @Provides Retryer retryer() { + return new Retryer() { + @Override public void continueOrPropagate(RetryableException e) { + throw e; + } + }; + } + } + + @Test(dataProvider = "levelToUnknownHostOutput") + public void unknownHostEmits(final Logger.Level logLevel, List expectedMessages) throws IOException, InterruptedException { + + try { + SendsStuff api = Feign.create(SendsStuff.class, "http://robofu.abc", + new DontRetryModule(), new DefaultModule(logger, logLevel)); + + api.login("netflix", "denominator", "password"); + + fail(); + } catch (FeignException e) { + assertMessagesMatch(expectedMessages); + } + } + + @dagger.Module(overrides = true, library = true) + static class RetryOnceModule { + @Provides Retryer retryer() { + return new Retryer() { + boolean retried; + + @Override public void continueOrPropagate(RetryableException e) { + if (!retried) { + retried = true; + return; + } + throw e; + } + }; + } + } + + public void retryEmits() throws IOException, InterruptedException { + + try { + SendsStuff api = Feign.create(SendsStuff.class, "http://robofu.abc", + new RetryOnceModule(), new DefaultModule(logger, Logger.Level.BASIC)); + + api.login("netflix", "denominator", "password"); + + fail(); + } catch (FeignException e) { + assertMessagesMatch(Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://robofu.abc/ HTTP/1.1", + "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: robofu.abc \\([0-9]+ms\\)", + "\\[SendsStuff#login\\] ---> RETRYING", + "\\[SendsStuff#login\\] ---> POST http://robofu.abc/ HTTP/1.1", + "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: robofu.abc \\([0-9]+ms\\)" + )); + } + } + + private void assertMessagesMatch(List expectedMessages) { + assertEquals(messages.size(), expectedMessages.size()); + for (int i = 0; i < messages.size(); i++) { + assertTrue(Pattern.compile(expectedMessages.get(i), Pattern.DOTALL).matcher(messages.get(i)).matches(), + "Didn't match at message " + (i + 1) + ":\n" + Joiner.on('\n').join(messages)); + } + } +} diff --git a/core/src/test/java/feign/RequestTemplateTest.java b/core/src/test/java/feign/RequestTemplateTest.java new file mode 100644 index 0000000000..bc1f31a8d2 --- /dev/null +++ b/core/src/test/java/feign/RequestTemplateTest.java @@ -0,0 +1,204 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ImmutableMap; +import org.testng.annotations.Test; + +import java.util.Arrays; + +import static feign.RequestTemplate.expand; +import static org.testng.Assert.assertEquals; + +public class RequestTemplateTest { + @Test public void expandNotUrlEncoded() { + for (String val : ImmutableList.of("apples", "sp ace", "unic???de", "qu?stion")) + assertEquals(expand("/users/{user}", ImmutableMap.of("user", val)), "/users/" + val); + } + + @Test public void expandMultipleParams() { + assertEquals(expand("/users/{user}/{repo}", ImmutableMap.of("user", "unic???de", "repo", "foo")), + "/users/unic???de/foo"); + } + + @Test public void expandParamKeyHyphen() { + assertEquals(expand("/{user-dir}", ImmutableMap.of("user-dir", "foo")), "/foo"); + } + + @Test public void expandMissingParamProceeds() { + assertEquals(expand("/{user-dir}", ImmutableMap.of("user_dir", "foo")), "/{user-dir}"); + } + + @Test public void resolveTemplateWithParameterizedPathSkipsEncodingSlash() { + + RequestTemplate template = new RequestTemplate().method("GET") + .append("{zoneId}"); + + assertEquals(template.toString(), ""// + + "GET {zoneId} HTTP/1.1\n"); + + template.resolve(ImmutableMap.of("zoneId", "/hostedzone/Z1PA6795UKMFR9")); + + assertEquals(template.toString(), ""// + + "GET /hostedzone/Z1PA6795UKMFR9 HTTP/1.1\n"); + + template.insert(0, "https://route53.amazonaws.com/2012-12-12"); + + assertEquals(template.request().toString(), ""// + + "GET https://route53.amazonaws.com/2012-12-12/hostedzone/Z1PA6795UKMFR9 HTTP/1.1\n"); + } + + @Test public void resolveTemplateWithBaseAndParameterizedQuery() { + RequestTemplate template = new RequestTemplate().method("GET") + .append("/?Action=DescribeRegions").query("RegionName.1", "{region}"); + + assertEquals(template.queries(), + ImmutableListMultimap.of("Action", "DescribeRegions", "RegionName.1", "{region}").asMap()); + assertEquals(template.toString(), ""// + + "GET /?Action=DescribeRegions&RegionName.1={region} HTTP/1.1\n"); + + template.resolve(ImmutableMap.of("region", "eu-west-1")); + assertEquals(template.queries(), + ImmutableListMultimap.of("Action", "DescribeRegions", "RegionName.1", "eu-west-1").asMap()); + + assertEquals(template.toString(), ""// + + "GET /?Action=DescribeRegions&RegionName.1=eu-west-1 HTTP/1.1\n"); + + template.insert(0, "https://iam.amazonaws.com"); + + assertEquals(template.request().toString(), ""// + + "GET https://iam.amazonaws.com/?Action=DescribeRegions&RegionName.1=eu-west-1 HTTP/1.1\n"); + } + + @Test public void resolveTemplateWithBaseAndParameterizedIterableQuery() { + RequestTemplate template = new RequestTemplate().method("GET") + .append("/?Query=one").query("Queries", "{queries}"); + + template.resolve(ImmutableMap.of("queries", Arrays.asList("us-east-1", "eu-west-1"))); + assertEquals(template.queries(), + ImmutableListMultimap. builder() + .put("Query", "one") + .putAll("Queries", "us-east-1", "eu-west-1") + .build().asMap()); + + assertEquals(template.toString(), "GET /?Query=one&Queries=us-east-1&Queries=eu-west-1 HTTP/1.1\n"); + } + + @Test public void resolveTemplateWithMixedRequestLineParams() throws Exception { + RequestTemplate template = new RequestTemplate().method("GET")// + .append("/domains/{domainId}/records")// + .query("name", "{name}")// + .query("type", "{type}"); + + template = template.resolve(ImmutableMap.builder()// + .put("domainId", 1001)// + .put("name", "denominator.io")// + .put("type", "CNAME")// + .build() + ); + + assertEquals(template.toString(), ""// + + "GET /domains/1001/records?name=denominator.io&type=CNAME HTTP/1.1\n"); + + template.insert(0, "https://dns.api.rackspacecloud.com/v1.0/1234"); + + assertEquals(template.request().toString(), ""// + + "GET https://dns.api.rackspacecloud.com/v1.0/1234"// + + "/domains/1001/records?name=denominator.io&type=CNAME HTTP/1.1\n"); + } + + @Test public void insertHasQueryParams() throws Exception { + RequestTemplate template = new RequestTemplate().method("GET")// + .append("/domains/{domainId}/records")// + .query("name", "{name}")// + .query("type", "{type}"); + + template = template.resolve(ImmutableMap.builder()// + .put("domainId", 1001)// + .put("name", "denominator.io")// + .put("type", "CNAME")// + .build() + ); + + assertEquals(template.toString(), ""// + + "GET /domains/1001/records?name=denominator.io&type=CNAME HTTP/1.1\n"); + + template.insert(0, "https://host/v1.0/1234?provider=foo"); + + assertEquals(template.request().toString(), ""// + + "GET https://host/v1.0/1234/domains/1001/records?provider=foo&name=denominator.io&type=CNAME HTTP/1.1\n"); + } + + @Test public void resolveTemplateWithBodyTemplateSetsBodyAndContentLength() { + RequestTemplate template = new RequestTemplate().method("POST") + .bodyTemplate("%7B\"customer_name\": \"{customer_name}\", \"user_name\": \"{user_name}\", " + + "\"password\": \"{password}\"%7D"); + + template = template.resolve(ImmutableMap.builder()// + .put("customer_name", "netflix")// + .put("user_name", "denominator")// + .put("password", "password")// + .build() + ); + + assertEquals(template.toString(), ""// + + "POST HTTP/1.1\n"// + + "Content-Length: 80\n"// + + "\n"// + + "{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"}"); + + template.insert(0, "https://api2.dynect.net/REST"); + + assertEquals(template.request().toString(), ""// + + "POST https://api2.dynect.net/REST HTTP/1.1\n" // + + "Content-Length: 80\n" // + + "\n" // + + "{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"}"); + } + + @Test public void skipUnresolvedQueries() throws Exception { + RequestTemplate template = new RequestTemplate().method("GET")// + .append("/domains/{domainId}/records")// + .query("optional", "{optional}")// + .query("name", "{nameVariable}"); + + template = template.resolve(ImmutableMap.builder()// + .put("domainId", 1001)// + .put("nameVariable", "denominator.io")// + .build() + ); + + assertEquals(template.toString(), ""// + + "GET /domains/1001/records?name=denominator.io HTTP/1.1\n"); + } + + @Test public void allQueriesUnresolvable() throws Exception { + RequestTemplate template = new RequestTemplate().method("GET")// + .append("/domains/{domainId}/records")// + .query("optional", "{optional}")// + .query("optional2", "{optional2}"); + + template = template.resolve(ImmutableMap.builder()// + .put("domainId", 1001)// + .build() + ); + + assertEquals(template.toString(), ""// + + "GET /domains/1001/records HTTP/1.1\n"); + } +} diff --git a/core/src/test/java/feign/TrustingSSLSocketFactory.java b/core/src/test/java/feign/TrustingSSLSocketFactory.java new file mode 100644 index 0000000000..15d3eae6e2 --- /dev/null +++ b/core/src/test/java/feign/TrustingSSLSocketFactory.java @@ -0,0 +1,193 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.io.Closer; +import com.google.common.io.InputSupplier; +import com.google.common.io.Resources; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.Socket; +import java.security.KeyStore; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Arrays; + +import javax.inject.Provider; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509KeyManager; +import javax.net.ssl.X509TrustManager; + +import static com.google.common.base.Throwables.propagate; + +/** + * Used for ssl tests to simplify setup. + */ +final class TrustingSSLSocketFactory extends SSLSocketFactory implements X509TrustManager, X509KeyManager { + + private static LoadingCache sslSocketFactories = + CacheBuilder.newBuilder().build(new CacheLoader() { + @Override + public SSLSocketFactory load(String serverAlias) throws Exception { + return new TrustingSSLSocketFactory(serverAlias); + } + }); + + public static SSLSocketFactory get() { + return get(""); + } + + public static SSLSocketFactory get(String serverAlias) { + return sslSocketFactories.getUnchecked(serverAlias); + } + + private static final char[] KEYSTORE_PASSWORD = "password".toCharArray(); + + private final SSLSocketFactory delegate; + private final String serverAlias; + private final PrivateKey privateKey; + private final X509Certificate[] certificateChain; + + private TrustingSSLSocketFactory(String serverAlias) { + try { + SSLContext sc = SSLContext.getInstance("SSL"); + sc.init(new KeyManager[]{this}, new TrustManager[]{this}, new SecureRandom()); + this.delegate = sc.getSocketFactory(); + } catch (Exception e) { + throw propagate(e); + } + this.serverAlias = serverAlias; + if (serverAlias.isEmpty()) { + this.privateKey = null; + this.certificateChain = null; + } else { + try { + KeyStore keyStore = loadKeyStore(Resources.newInputStreamSupplier(Resources.getResource("keystore.jks"))); + this.privateKey = (PrivateKey) keyStore.getKey(serverAlias, KEYSTORE_PASSWORD); + Certificate[] rawChain = keyStore.getCertificateChain(serverAlias); + this.certificateChain = Arrays.copyOf(rawChain, rawChain.length, X509Certificate[].class); + } catch (Exception e) { + throw propagate(e); + } + } + } + + @Override public String[] getDefaultCipherSuites() { + return ENABLED_CIPHER_SUITES; + } + + @Override public String[] getSupportedCipherSuites() { + return ENABLED_CIPHER_SUITES; + } + + @Override + public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException { + return setEnabledCipherSuites(delegate.createSocket(s, host, port, autoClose)); + } + + static Socket setEnabledCipherSuites(Socket socket) { + SSLSocket.class.cast(socket).setEnabledCipherSuites(ENABLED_CIPHER_SUITES); + return socket; + } + + @Override + public Socket createSocket(String host, int port) throws IOException { + return setEnabledCipherSuites(delegate.createSocket(host, port)); + } + + @Override public Socket createSocket(InetAddress host, int port) throws IOException { + return setEnabledCipherSuites(delegate.createSocket(host, port)); + } + + @Override + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException { + return setEnabledCipherSuites(delegate.createSocket(host, port, localHost, localPort)); + } + + @Override + public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) + throws IOException { + return setEnabledCipherSuites(delegate.createSocket(address, port, localAddress, localPort)); + } + + public X509Certificate[] getAcceptedIssuers() { + return null; + } + + public void checkClientTrusted(X509Certificate[] certs, String authType) { + } + + public void checkServerTrusted(X509Certificate[] certs, String authType) { + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return null; + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return null; + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return null; + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return serverAlias; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return certificateChain; + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return privateKey; + } + + private static KeyStore loadKeyStore(InputSupplier inputStreamSupplier) throws IOException { + Closer closer = Closer.create(); + try { + InputStream inputStream = closer.register(inputStreamSupplier.getInput()); + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(inputStream, KEYSTORE_PASSWORD); + return keyStore; + } catch (Throwable e) { + throw closer.rethrow(e); + } finally { + closer.close(); + } + } + + private final static String[] ENABLED_CIPHER_SUITES = {"SSL_RSA_WITH_RC4_128_MD5"}; +} diff --git a/core/src/test/java/feign/UtilTest.java b/core/src/test/java/feign/UtilTest.java new file mode 100644 index 0000000000..63a2e9ae22 --- /dev/null +++ b/core/src/test/java/feign/UtilTest.java @@ -0,0 +1,92 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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; + +import feign.codec.Decoder; +import feign.codec.Decoders; +import feign.codec.StringDecoder; +import org.testng.annotations.Test; + +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.List; + +import static feign.Util.resolveLastTypeParameter; +import static org.testng.Assert.assertEquals; + +@Test +public class UtilTest { + + interface LastTypeParameter { + final List LIST_STRING = null; + final Decoder.TextStream> DECODER_LIST_STRING = null; + final Decoder.TextStream> DECODER_WILDCARD_LIST_STRING = null; + final ParameterizedDecoder> PARAMETERIZED_DECODER_LIST_STRING = null; + final ParameterizedDecoder PARAMETERIZED_DECODER_UNBOUND = null; + } + + interface ParameterizedDecoder> extends Decoder.TextStream { + } + + @Test public void resolveLastTypeParameterWhenNotSubtype() throws Exception { + Type context = LastTypeParameter.class.getDeclaredField("DECODER_LIST_STRING").getGenericType(); + Type listStringType = LastTypeParameter.class.getDeclaredField("LIST_STRING").getGenericType(); + Type last = resolveLastTypeParameter(context, Decoder.class); + assertEquals(last, listStringType); + } + + @Test public void lastTypeFromInstance() throws Exception { + Decoder.TextStream decoder = new StringDecoder(); + Type last = resolveLastTypeParameter(decoder.getClass(), Decoder.class); + assertEquals(last, String.class); + } + + @Test public void lastTypeFromStaticMethod() throws Exception { + Decoder.TextStream decoder = Decoders.firstGroup("foo"); + Type last = resolveLastTypeParameter(decoder.getClass(), Decoder.class); + assertEquals(last, String.class); + } + + @Test public void lastTypeFromAnonymous() throws Exception { + Decoder.TextStream decoder = new Decoder.TextStream() { + @Override public Reader decode(Reader reader, Type type) { + return null; + } + }; + Type last = resolveLastTypeParameter(decoder.getClass(), Decoder.class); + assertEquals(last, Reader.class); + } + + @Test public void resolveLastTypeParameterWhenWildcard() throws Exception { + Type context = LastTypeParameter.class.getDeclaredField("DECODER_WILDCARD_LIST_STRING").getGenericType(); + Type listStringType = LastTypeParameter.class.getDeclaredField("LIST_STRING").getGenericType(); + Type last = resolveLastTypeParameter(context, Decoder.class); + assertEquals(last, listStringType); + } + + @Test public void resolveLastTypeParameterWhenParameterizedSubtype() throws Exception { + Type context = LastTypeParameter.class.getDeclaredField("PARAMETERIZED_DECODER_LIST_STRING").getGenericType(); + Type listStringType = LastTypeParameter.class.getDeclaredField("LIST_STRING").getGenericType(); + Type last = resolveLastTypeParameter(context, ParameterizedDecoder.class); + assertEquals(last, listStringType); + } + + @Test public void unboundWildcardIsObject() throws Exception { + Type context = LastTypeParameter.class.getDeclaredField("PARAMETERIZED_DECODER_UNBOUND").getGenericType(); + Type last = resolveLastTypeParameter(context, ParameterizedDecoder.class); + assertEquals(last, Object.class); + } +} diff --git a/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java b/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java new file mode 100644 index 0000000000..efab2c9a76 --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.codec; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableMultimap; + +import org.testng.annotations.Test; + +import java.util.Collection; + +import feign.FeignException; +import feign.Response; +import feign.RetryableException; + +import static feign.Util.RETRY_AFTER; + +public class DefaultErrorDecoderTest { + ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + + @Test(expectedExceptions = FeignException.class, expectedExceptionsMessageRegExp = "status 500 reading Service#foo\\(\\)") + public void throwsFeignException() throws Throwable { + Response response = Response.create(500, "Internal server error", ImmutableMap.>of(), + null); + + throw errorDecoder.decode("Service#foo()", response); + } + + @Test(expectedExceptions = FeignException.class, expectedExceptionsMessageRegExp = "status 500 reading Service#foo\\(\\); content:\nhello world") + public void throwsFeignExceptionIncludingBody() throws Throwable { + Response response = Response.create(500, "Internal server error", ImmutableMap.>of(), + "hello world"); + + throw errorDecoder.decode("Service#foo()", response); + } + + @Test(expectedExceptions = RetryableException.class, expectedExceptionsMessageRegExp = "status 503 reading Service#foo\\(\\)") + public void retryAfterHeaderThrowsRetryableException() throws Throwable { + Response response = Response.create(503, "Service Unavailable", + ImmutableMultimap.of(RETRY_AFTER, "Sat, 1 Jan 2000 00:00:00 GMT").asMap(), null); + + throw errorDecoder.decode("Service#foo()", response); + } +} diff --git a/core/src/test/java/feign/codec/RetryAfterDecoderTest.java b/core/src/test/java/feign/codec/RetryAfterDecoderTest.java new file mode 100644 index 0000000000..7f4e4fbaca --- /dev/null +++ b/core/src/test/java/feign/codec/RetryAfterDecoderTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.codec; + +import org.testng.annotations.Test; + +import java.text.ParseException; + +import feign.codec.ErrorDecoder.RetryAfterDecoder; + +import static feign.codec.ErrorDecoder.RetryAfterDecoder.RFC822_FORMAT; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; + +public class RetryAfterDecoderTest { + + @Test public void malformDateFailsGracefully() { + assertFalse(decoder.apply("Fri, 31 Dec 1999 23:59:59 ZBW") != null); + } + + @Test public void rfc822Parses() throws ParseException { + assertEquals(decoder.apply("Fri, 31 Dec 1999 23:59:59 GMT"), + RFC822_FORMAT.parse("Fri, 31 Dec 1999 23:59:59 GMT")); + } + + @Test public void relativeSecondsParses() throws ParseException { + assertEquals(decoder.apply("86400"), RFC822_FORMAT.parse("Sun, 2 Jan 2000 00:00:00 GMT")); + } + + private RetryAfterDecoder decoder = new RetryAfterDecoder(RFC822_FORMAT) { + protected long currentTimeNanos() { + try { + return MILLISECONDS.toNanos(RFC822_FORMAT.parse("Sat, 1 Jan 2000 00:00:00 GMT").getTime()); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } + }; +} diff --git a/core/src/test/java/feign/examples/AWSSignatureVersion4.java b/core/src/test/java/feign/examples/AWSSignatureVersion4.java new file mode 100644 index 0000000000..40c83eda84 --- /dev/null +++ b/core/src/test/java/feign/examples/AWSSignatureVersion4.java @@ -0,0 +1,160 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.examples; + +import com.google.common.base.Function; +import com.google.common.base.Joiner; +import com.google.common.collect.Multimap; +import com.google.common.collect.TreeMultimap; + +import java.net.URI; +import java.text.SimpleDateFormat; +import java.util.Collection; +import java.util.Date; +import java.util.Map.Entry; +import java.util.TimeZone; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import feign.Request; +import feign.RequestTemplate; + +import static com.google.common.base.Throwables.propagate; +import static com.google.common.collect.Iterables.transform; +import static com.google.common.hash.Hashing.sha256; +import static com.google.common.io.BaseEncoding.base16; +import static feign.Util.UTF_8; + +// http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html +public class AWSSignatureVersion4 implements Function { + + String region = "us-east-1"; + String service = "iam"; + String accessKey; + String secretKey; + + public AWSSignatureVersion4(String accessKey, String secretKey) { + this.accessKey = accessKey; + this.secretKey = secretKey; + } + + @Override public Request apply(RequestTemplate input) { + input.header("Host", URI.create(input.url()).getHost()); + TreeMultimap sortedLowercaseHeaders = TreeMultimap.create(); + for (String key : input.headers().keySet()) { + sortedLowercaseHeaders.putAll(trimToLowercase.apply(key), + transform(input.headers().get(key), trimToLowercase)); + } + + String timestamp = iso8601.format(new Date()); + String credentialScope = Joiner.on('/').join(timestamp.substring(0, 8), region, service, "aws4_request"); + + input.query("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); + input.query("X-Amz-Credential", accessKey + "/" + credentialScope); + input.query("X-Amz-Date", timestamp); + input.query("X-Amz-SignedHeaders", Joiner.on(';').join(sortedLowercaseHeaders.keySet())); + + String canonicalString = canonicalString(input, sortedLowercaseHeaders); + String toSign = toSign(timestamp, credentialScope, canonicalString); + + byte[] signatureKey = signatureKey(secretKey, timestamp); + String signature = base16().lowerCase().encode(hmacSHA256(toSign, signatureKey)); + + input.query("X-Amz-Signature", signature); + + return input.request(); + } + + byte[] signatureKey(String secretKey, String timestamp) { + byte[] kSecret = ("AWS4" + secretKey).getBytes(UTF_8); + byte[] kDate = hmacSHA256(timestamp.substring(0, 8), kSecret); + byte[] kRegion = hmacSHA256(region, kDate); + byte[] kService = hmacSHA256(service, kRegion); + byte[] kSigning = hmacSHA256("aws4_request", kService); + return kSigning; + } + + static byte[] hmacSHA256(String data, byte[] key) { + try { + String algorithm = "HmacSHA256"; + Mac mac = Mac.getInstance(algorithm); + mac.init(new SecretKeySpec(key, algorithm)); + return mac.doFinal(data.getBytes(UTF_8)); + } catch (Exception e) { + throw propagate(e); + } + } + + private static final String EMPTY_STRING_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + private String canonicalString(RequestTemplate input, Multimap sortedLowercaseHeaders) { + StringBuilder canonicalRequest = new StringBuilder(); + // HTTPRequestMethod + '\n' + + canonicalRequest.append(input.method()).append('\n'); + + // CanonicalURI + '\n' + + canonicalRequest.append(URI.create(input.url()).getPath()).append('\n'); + + // CanonicalQueryString + '\n' + + canonicalRequest.append(input.queryLine().substring(1)); + canonicalRequest.append('\n'); + + // CanonicalHeaders + '\n' + + for (Entry> entry : sortedLowercaseHeaders.asMap().entrySet()) { + canonicalRequest.append(entry.getKey()).append(':').append(Joiner.on(',').join(entry.getValue())) + .append('\n'); + } + canonicalRequest.append('\n'); + + // SignedHeaders + '\n' + + canonicalRequest.append(Joiner.on(',').join(sortedLowercaseHeaders.keySet())).append('\n'); + + // HexEncode(Hash(Payload)) + if (input.body() != null) { + canonicalRequest.append(base16().lowerCase().encode( + sha256().hashString(input.body() != null ? input.body() : "", UTF_8).asBytes())); + } else { + canonicalRequest.append(EMPTY_STRING_HASH); + } + return canonicalRequest.toString(); + } + + private static final Function trimToLowercase = new Function() { + public String apply(String in) { + return in.toLowerCase().trim(); + } + }; + + private String toSign(String timestamp, String credentialScope, String canonicalRequest) { + StringBuilder toSign = new StringBuilder(); + // Algorithm + '\n' + + toSign.append("AWS4-HMAC-SHA256").append('\n'); + // RequestDate + '\n' + + toSign.append(timestamp).append('\n'); + // CredentialScope + '\n' + + toSign.append(credentialScope).append('\n'); + // HexEncode(Hash(CanonicalRequest)) + toSign.append(base16().lowerCase().encode(sha256().hashString(canonicalRequest, UTF_8).asBytes())); + return toSign.toString(); + } + + private static final SimpleDateFormat iso8601 = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'"); + + static { + iso8601.setTimeZone(TimeZone.getTimeZone("GMT")); + } +} diff --git a/core/src/test/java/feign/examples/GitHubExample.java b/core/src/test/java/feign/examples/GitHubExample.java new file mode 100644 index 0000000000..428f968570 --- /dev/null +++ b/core/src/test/java/feign/examples/GitHubExample.java @@ -0,0 +1,172 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.examples; + +import com.google.gson.Gson; +import com.google.gson.JsonIOException; +import com.google.gson.stream.JsonReader; +import dagger.Module; +import dagger.Provides; +import feign.Feign; +import feign.Logger; +import feign.Observable; +import feign.Observer; +import feign.RequestLine; +import feign.codec.Decoder; +import feign.codec.IncrementalDecoder; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; + +import static dagger.Provides.Type.SET; + +/** + * adapted from {@code com.example.retrofit.GitHubClient} + */ +public class GitHubExample { + + interface GitHub { + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Named("owner") String owner, @Named("repo") String repo); + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + Observable observable(@Named("owner") String owner, @Named("repo") String repo); + } + + static class Contributor { + String login; + int contributions; + } + + public static void main(String... args) throws InterruptedException { + GitHub github = Feign.create(GitHub.class, "https://api.github.com", new GitHubModule()); + + System.out.println("Let's fetch and print a list of the contributors to this library."); + List contributors = github.contributors("netflix", "feign"); + for (Contributor contributor : contributors) { + System.out.println(contributor.login + " (" + contributor.contributions + ")"); + } + + System.out.println("Let's treat our contributors as an observable."); + Observable observable = github.observable("netflix", "feign"); + + CountDownLatch latch = new CountDownLatch(2); + + System.out.println("Let's add 2 subscribers."); + observable.subscribe(new ContributorObserver(latch)); + observable.subscribe(new ContributorObserver(latch)); + + // wait for the task to complete. + latch.await(); + + System.exit(0); + } + + @Module(overrides = true, library = true, includes = GsonModule.class) + static class GitHubModule { + + @Provides Logger.Level loggingLevel() { + return Logger.Level.BASIC; + } + + @Provides Logger logger() { + return new Logger.ErrorLogger(); + } + } + + /** + * Here's how it looks to wire json codecs. Note, that you can always instead use {@code feign-gson}! + */ + @Module(library = true) + static class GsonModule { + + @Provides @Singleton Gson gson() { + return new Gson(); + } + + @Provides(type = SET) Decoder decoder(GsonDecoder gsonDecoder) { + return gsonDecoder; + } + + @Provides(type = SET) IncrementalDecoder incrementalDecoder(GsonDecoder gsonDecoder) { + return gsonDecoder; + } + } + + static class GsonDecoder implements Decoder.TextStream, IncrementalDecoder.TextStream { + private final Gson gson; + + @Inject GsonDecoder(Gson gson) { + this.gson = gson; + } + + @Override public Object decode(Reader reader, Type type) throws IOException { + return fromJson(new JsonReader(reader), type); + } + + @Override + public void decode(Reader reader, Type type, Observer observer, AtomicBoolean subscribed) throws IOException { + JsonReader jsonReader = new JsonReader(reader); + jsonReader.beginArray(); + while (jsonReader.hasNext() && subscribed.get()) { + observer.onNext(fromJson(jsonReader, type)); + } + } + + private Object fromJson(JsonReader jsonReader, Type type) throws IOException { + try { + return gson.fromJson(jsonReader, type); + } catch (JsonIOException e) { + if (e.getCause() != null && e.getCause() instanceof IOException) { + throw IOException.class.cast(e.getCause()); + } + throw e; + } + } + } + + static class ContributorObserver implements Observer { + + private final CountDownLatch latch; + public int count; + + public ContributorObserver(CountDownLatch latch) { + this.latch = latch; + } + + // parsed directly from the text stream without an intermediate collection. + @Override public void onNext(Contributor contributor) { + count++; + } + + @Override public void onSuccess() { + System.out.println("found " + count + " contributors"); + latch.countDown(); + } + + @Override public void onFailure(Throwable cause) { + cause.printStackTrace(); + latch.countDown(); + } + } +} diff --git a/core/src/test/java/feign/examples/IAMExample.java b/core/src/test/java/feign/examples/IAMExample.java new file mode 100644 index 0000000000..f16bb3c274 --- /dev/null +++ b/core/src/test/java/feign/examples/IAMExample.java @@ -0,0 +1,72 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.examples; + +import dagger.Module; +import dagger.Provides; +import feign.Feign; +import feign.Request; +import feign.RequestLine; +import feign.RequestTemplate; +import feign.Target; +import feign.codec.Decoder; +import feign.codec.Decoders; + +import static dagger.Provides.Type.SET; + +public class IAMExample { + + interface IAM { + @RequestLine("GET /?Action=GetUser&Version=2010-05-08") String arn(); + } + + public static void main(String... args) { + + IAM iam = Feign.create(new IAMTarget(args[0], args[1]), new IAMModule()); + System.out.println(iam.arn()); + } + + static class IAMTarget extends AWSSignatureVersion4 implements Target { + + @Override public Class type() { + return IAM.class; + } + + @Override public String name() { + return "iam"; + } + + @Override public String url() { + return "https://iam.amazonaws.com"; + } + + private IAMTarget(String accessKey, String secretKey) { + super(accessKey, secretKey); + } + + @Override public Request apply(RequestTemplate in) { + in.insert(0, url()); + return super.apply(in); + } + } + + @Module(library = true) + static class IAMModule { + @Provides(type = SET) Decoder decoder() { + return Decoders.firstGroup("([\\S&&[^<]]+)"); + } + } +} diff --git a/core/src/test/resources/keystore.jks b/core/src/test/resources/keystore.jks new file mode 100644 index 0000000000..19108e3579 Binary files /dev/null and b/core/src/test/resources/keystore.jks differ diff --git a/dagger.gradle b/dagger.gradle new file mode 100644 index 0000000000..599960266f --- /dev/null +++ b/dagger.gradle @@ -0,0 +1,178 @@ +// Manages classpath and IDE annotation processing config for dagger. +// +// setup: +// Add the following to your root build.gradle +// +// apply plugin: 'idea' +// subprojects { +// apply from: rootProject.file('dagger.gradle') +// } +// +// do not use gradle integration of the ide. instead generate and import like so: +// +// ./gradlew clean cleanEclipse cleanIdea eclipse idea +// +// known limitations: +// as output folders include generated classes, you may need to run clean a few times. +// incompatible with android plugin as it applies the java plugin +// unnecessarily applies both eclipse and idea plugins even if you don't use them +// suffers from the normal non-IDE eclipse integration where nested projects don't import properly. +// change your structure to flattened to avoid this. +// +// deprecated by: https://github.com/Netflix/gradle-template/issues/8 +// +// original design: cfieber +apply plugin: 'java' +apply plugin: 'eclipse' +apply plugin: 'idea' + +if (!project.hasProperty('daggerVersion')) { + ext { + daggerVersion = "1.1.0" + } +} + +configurations { + daggerCompiler { + visible false + } +} + +configurations.all { + resolutionStrategy { + eachDependency { DependencyResolveDetails details -> + if (details.requested.group == 'com.squareup.dagger') { + details.useVersion daggerVersion + } + } + } +} + +def annotationGeneratedSources = file('.generated/src') +def annotationGeneratedTestSources = file('.generated/test') + +task prepareAnnotationGeneratedSourceDirs(overwrite: true) << { + annotationGeneratedSources.mkdirs() + annotationGeneratedTestSources.mkdirs() + sourceSets*.java.srcDirs*.each { it.mkdirs() } + sourceSets*.resources.srcDirs*.each { it.mkdirs() } +} + +sourceSets { + main { + java { + compileClasspath += configurations.daggerCompiler + } + } + test { + java { + compileClasspath += configurations.daggerCompiler + } + } +} + +dependencies { + compile "com.squareup.dagger:dagger:${project.daggerVersion}" + daggerCompiler "com.squareup.dagger:dagger-compiler:${project.daggerVersion}" +} + +rootProject.idea.project.ipr.withXml { projectXml -> + projectXml.asNode().component.find { it.@name == 'CompilerConfiguration' }.annotationProcessing[0].replaceNode { + annotationProcessing { + profile(default: true, name: 'Default', enabled: true) { + sourceOutputDir name: relativePath(annotationGeneratedSources) + sourceTestOutputDir name: relativePath(annotationGeneratedTestSources) + outputRelativeToContentRoot value: true + processorPath useClasspath: true + } + } + } +} + +tasks.ideaModule.dependsOn(prepareAnnotationGeneratedSourceDirs) + +idea.module { + scopes.PROVIDED.plus += project.configurations.daggerCompiler + iml.withXml { xml-> + def moduleSource = xml.asNode().component.find { it.@name = 'NewModuleRootManager' }.content[0] + moduleSource.appendNode('sourceFolder', [url: "file://\$MODULE_DIR\$/${relativePath(annotationGeneratedSources)}", isTestSource: false]) + moduleSource.appendNode('sourceFolder', [url: "file://\$MODULE_DIR\$/${relativePath(annotationGeneratedTestSources)}", isTestSource: true]) + } +} + +tasks.eclipseClasspath.dependsOn(prepareAnnotationGeneratedSourceDirs) + +eclipse.classpath { + plusConfigurations += project.configurations.daggerCompiler +} + +tasks.eclipseClasspath { + doLast { + eclipse.classpath.file.withXml { + it.asNode().children()[0] + { + classpathentry(kind: 'src', path: relativePath(annotationGeneratedSources)) { + attributes { + attribute name: 'optional', value: true + } + } + } + } + } +} + +// http://forums.gradle.org/gradle/topics/eclipse_generated_files_should_be_put_in_the_same_place_as_the_gradle_generated_files +Map pathMappings = [:]; +SourceSetContainer sourceSets = project.sourceSets; +sourceSets.each { SourceSet sourceSet -> + String relativeJavaOutputDirectory = project.relativePath(sourceSet.output.classesDir); + String relativeResourceOutputDirectory = project.relativePath(sourceSet.output.resourcesDir); + sourceSet.java.getSrcDirTrees().each { DirectoryTree sourceDirectory -> + String relativeSrcPath = project.relativePath(sourceDirectory.dir.absolutePath); + + pathMappings[relativeSrcPath] = relativeJavaOutputDirectory; + } + sourceSet.resources.getSrcDirTrees().each { DirectoryTree resourceDirectory -> + String relativeResourcePath = project.relativePath(resourceDirectory.dir.absolutePath); + + pathMappings[relativeResourcePath] = relativeResourceOutputDirectory; + } +} + +project.eclipse.classpath.file { + whenMerged { classpath -> + classpath.entries.findAll { entry -> + return entry.kind == 'src'; + }.each { entry -> + if(pathMappings.containsKey(entry.path)) { + entry.output = pathMappings[entry.path]; + } + } + } +} + +eclipse.jdt.file.withProperties { props -> + props.setProperty('org.eclipse.jdt.core.compiler.processAnnotations', 'enabled') +} + +tasks.eclipseJdt { + doFirst { + def aptPrefs = file('.settings/org.eclipse.jdt.apt.core.prefs') + aptPrefs.parentFile.mkdirs() + + aptPrefs.text = """\ + eclipse.preferences.version=1 + org.eclipse.jdt.apt.aptEnabled=true + org.eclipse.jdt.apt.genSrcDir=${relativePath(annotationGeneratedSources)} + org.eclipse.jdt.apt.reconcileEnabled=true + """.stripIndent() + + file('.factorypath').withWriter { + new groovy.xml.MarkupBuilder(it).'factorypath' { + project.configurations.daggerCompiler.files.each { dep -> + 'factorypathentry' kind: 'EXTJAR', id: dep.absolutePath, enabled: true, runInBatchMode: false + } + } + } + } +} + diff --git a/example-github/build.gradle b/example-github/build.gradle new file mode 100644 index 0000000000..126b8632df --- /dev/null +++ b/example-github/build.gradle @@ -0,0 +1,49 @@ +apply plugin: 'java' + +dependencies { + compile 'com.netflix.feign:feign-core:4.3.0' + compile 'com.netflix.feign:feign-gson:4.3.0' + provided 'com.squareup.dagger:dagger-compiler:1.1.0' +} + +// create a self-contained jar that is executable +// the output is both a 'fat' project artifact and +// a convenience file named "build/github" +task fatJar(dependsOn: classes, type: Jar) { + classifier 'fat' + + doFirst { + // Delay evaluation until the compile configuration is ready + from { + configurations.compile.collect { zipTree(it) } + } + } + + from (sourceSets*.output.classesDir) { + } + + // really executable jar + // http://skife.org/java/unix/2011/06/20/really_executable_jars.html + + manifest { + attributes 'Main-Class': 'feign.example.github.GitHubExample' + } + + // for convenience, we make a file in the build dir named github with no extension + doLast { + def srcFile = new File("${buildDir}/libs/${archiveName}") + def shortcutFile = new File("${buildDir}/github") + shortcutFile.delete() + shortcutFile << "#!/usr/bin/env sh\n" + shortcutFile << 'exec java -jar $0 "$@"' + "\n" + shortcutFile << srcFile.bytes + shortcutFile.setExecutable(true, true) + srcFile.delete() + srcFile << shortcutFile.bytes + srcFile.setExecutable(true, true) + } +} + +artifacts { + archives fatJar +} diff --git a/example-github/src/main/java/feign/example/github/GitHubExample.java b/example-github/src/main/java/feign/example/github/GitHubExample.java new file mode 100644 index 0000000000..6f8977913b --- /dev/null +++ b/example-github/src/main/java/feign/example/github/GitHubExample.java @@ -0,0 +1,109 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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 dagger.Module; +import dagger.Provides; +import feign.Feign; +import feign.Logger; +import feign.Observable; +import feign.Observer; +import feign.RequestLine; +import feign.gson.GsonModule; + +import javax.inject.Named; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +/** + * adapted from {@code com.example.retrofit.GitHubClient} + */ +public class GitHubExample { + + interface GitHub { + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Named("owner") String owner, @Named("repo") String repo); + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + Observable observable(@Named("owner") String owner, @Named("repo") String repo); + } + + static class Contributor { + String login; + int contributions; + } + + public static void main(String... args) throws InterruptedException { + GitHub github = Feign.create(GitHub.class, "https://api.github.com", new GsonModule(), new LogToStderr()); + + System.out.println("Let's fetch and print a list of the contributors to this library."); + List contributors = github.contributors("netflix", "feign"); + for (Contributor contributor : contributors) { + System.out.println(contributor.login + " (" + contributor.contributions + ")"); + } + + System.out.println("Let's treat our contributors as an observable."); + Observable observable = github.observable("netflix", "feign"); + + CountDownLatch latch = new CountDownLatch(2); + + System.out.println("Let's add 2 subscribers."); + observable.subscribe(new ContributorObserver(latch)); + observable.subscribe(new ContributorObserver(latch)); + + // wait for the task to complete. + latch.await(); + + System.exit(0); + } + + static class ContributorObserver implements Observer { + + private final CountDownLatch latch; + public int count; + + public ContributorObserver(CountDownLatch latch) { + this.latch = latch; + } + + // parsed directly from the text stream without an intermediate collection. + @Override public void onNext(Contributor contributor) { + count++; + } + + @Override public void onSuccess() { + System.out.println("found " + count + " contributors"); + latch.countDown(); + } + + @Override public void onFailure(Throwable cause) { + cause.printStackTrace(); + latch.countDown(); + } + } + + @Module(overrides = true, library = true) + static class LogToStderr { + + @Provides Logger.Level loggingLevel() { + return Logger.Level.BASIC; + } + + @Provides Logger logger() { + return new Logger.ErrorLogger(); + } + } +} diff --git a/example-wikipedia/build.gradle b/example-wikipedia/build.gradle new file mode 100644 index 0000000000..816eda6481 --- /dev/null +++ b/example-wikipedia/build.gradle @@ -0,0 +1,49 @@ +apply plugin: 'java' + +dependencies { + compile 'com.netflix.feign:feign-core:4.3.0' + compile 'com.netflix.feign:feign-gson:4.3.0' + provided 'com.squareup.dagger:dagger-compiler:1.1.0' +} + +// create a self-contained jar that is executable +// the output is both a 'fat' project artifact and +// a convenience file named "build/github" +task fatJar(dependsOn: classes, type: Jar) { + classifier 'fat' + + doFirst { + // Delay evaluation until the compile configuration is ready + from { + configurations.compile.collect { zipTree(it) } + } + } + + from (sourceSets*.output.classesDir) { + } + + // really executable jar + // http://skife.org/java/unix/2011/06/20/really_executable_jars.html + + manifest { + attributes 'Main-Class': 'feign.example.wikipedia.WikipediaExample' + } + + // for convenience, we make a file in the build dir named github with no extension + doLast { + def srcFile = new File("${buildDir}/libs/${archiveName}") + def shortcutFile = new File("${buildDir}/wikipedia") + shortcutFile.delete() + shortcutFile << "#!/usr/bin/env sh\n" + shortcutFile << 'exec java -jar $0 "$@"' + "\n" + shortcutFile << srcFile.bytes + shortcutFile.setExecutable(true, true) + srcFile.delete() + srcFile << shortcutFile.bytes + srcFile.setExecutable(true, true) + } +} + +artifacts { + archives fatJar +} diff --git a/example-wikipedia/src/main/java/feign/example/wikipedia/ResponseDecoder.java b/example-wikipedia/src/main/java/feign/example/wikipedia/ResponseDecoder.java new file mode 100644 index 0000000000..9cb54bba9a --- /dev/null +++ b/example-wikipedia/src/main/java/feign/example/wikipedia/ResponseDecoder.java @@ -0,0 +1,87 @@ +package feign.example.wikipedia; + +import com.google.gson.stream.JsonReader; +import feign.codec.Decoder; + +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; + +abstract class ResponseDecoder implements Decoder.TextStream> { + + /** + * name of the key inside the {@code query} dict which holds the elements desired. ex. {@code pages}. + */ + protected abstract String query(); + + /** + * Parses the contents of a result object. + *

+ *
+ * ex. If {@link #query()} is {@code pages}, then this would parse the value of each key in the dict {@code pages}. + * In the example below, this would first start at line {@code 3}. + *

+ *

+   * "pages": {
+   *   "2576129": {
+   *     "pageid": 2576129,
+   *     "title": "Burchell's zebra",
+   * --snip--
+   * 
+ */ + protected abstract X build(JsonReader reader) throws IOException; + + /** + * the wikipedia api doesn't use json arrays, rather a series of nested objects. + */ + @Override + public WikipediaExample.Response decode(Reader ireader, Type type) throws IOException { + WikipediaExample.Response pages = new WikipediaExample.Response(); + JsonReader reader = new JsonReader(ireader); + reader.beginObject(); + while (reader.hasNext()) { + String nextName = reader.nextName(); + if ("query".equals(nextName)) { + reader.beginObject(); + while (reader.hasNext()) { + if (query().equals(reader.nextName())) { + reader.beginObject(); + while (reader.hasNext()) { + // each element is in form: "id" : { object } + // this advances the pointer to the value and skips the key + reader.nextName(); + reader.beginObject(); + pages.add(build(reader)); + reader.endObject(); + } + reader.endObject(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + } else if ("query-continue".equals(nextName)) { + reader.beginObject(); + while (reader.hasNext()) { + if ("search".equals(reader.nextName())) { + reader.beginObject(); + while (reader.hasNext()) { + if ("gsroffset".equals(reader.nextName())) { + pages.nextOffset = reader.nextLong(); + } + } + reader.endObject(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + reader.close(); + return pages; + } +} diff --git a/example-wikipedia/src/main/java/feign/example/wikipedia/WikipediaExample.java b/example-wikipedia/src/main/java/feign/example/wikipedia/WikipediaExample.java new file mode 100644 index 0000000000..90ee691636 --- /dev/null +++ b/example-wikipedia/src/main/java/feign/example/wikipedia/WikipediaExample.java @@ -0,0 +1,148 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.wikipedia; + +import com.google.gson.stream.JsonReader; +import dagger.Module; +import dagger.Provides; +import feign.Feign; +import feign.Logger; +import feign.RequestLine; +import feign.codec.Decoder; +import feign.gson.GsonModule; + +import javax.inject.Named; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; + +import static dagger.Provides.Type.SET; + +public class WikipediaExample { + + public static interface Wikipedia { + @RequestLine("GET /w/api.php?action=query&generator=search&prop=info&format=json&gsrsearch={search}") + Response search(@Named("search") String search); + + @RequestLine("GET /w/api.php?action=query&generator=search&prop=info&format=json&gsrsearch={search}&gsroffset={offset}") + Response resumeSearch(@Named("search") String search, @Named("offset") long offset); + } + + static class Page { + long id; + String title; + } + + public static class Response extends ArrayList { + /** + * when present, the position to resume the list. + */ + Long nextOffset; + } + + public static void main(String... args) throws InterruptedException { + Wikipedia wikipedia = Feign.create(Wikipedia.class, "http://en.wikipedia.org", + new WikipediaDecoder(), new LogToStderr()); + + System.out.println("Let's search for PTAL!"); + Iterator pages = lazySearch(wikipedia, "PTAL"); + while (pages.hasNext()) { + System.out.println(pages.next().title); + } + } + + /** + * this will lazily continue searches, making new http calls as necessary. + * + * @param wikipedia used to search + * @param query see {@link Wikipedia#search(String)}. + */ + static Iterator lazySearch(final Wikipedia wikipedia, final String query) { + final Response first = wikipedia.search(query); + if (first.nextOffset == null) + return first.iterator(); + return new Iterator() { + Iterator current = first.iterator(); + Long nextOffset = first.nextOffset; + + @Override + public boolean hasNext() { + while (!current.hasNext() && nextOffset != null) { + System.out.println("Wow.. even more results than " + nextOffset); + Response nextPage = wikipedia.resumeSearch(query, nextOffset); + current = nextPage.iterator(); + nextOffset = nextPage.nextOffset; + } + return current.hasNext(); + } + + @Override + public Page next() { + return current.next(); + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + }; + } + + @Module(library = true, includes = GsonModule.class) + static class WikipediaDecoder { + + /** + * add to the set of Decoders one that handles {@code Response}. + */ + @Provides(type = SET) Decoder pagesDecoder() { + return new ResponseDecoder() { + + @Override + protected String query() { + return "pages"; + } + + @Override + protected Page build(JsonReader reader) throws IOException { + Page page = new Page(); + while (reader.hasNext()) { + String key = reader.nextName(); + if (key.equals("pageid")) { + page.id = reader.nextLong(); + } else if (key.equals("title")) { + page.title = reader.nextString(); + } else { + reader.skipValue(); + } + } + return page; + } + }; + } + } + + @Module(overrides = true, library = true) + static class LogToStderr { + + @Provides Logger.Level loggingLevel() { + return Logger.Level.BASIC; + } + + @Provides Logger logger() { + return new Logger.ErrorLogger(); + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000000..11d8403777 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +version=5.0.0-SNAPSHOT diff --git a/gradle/buildscript.gradle b/gradle/buildscript.gradle new file mode 100644 index 0000000000..0b6da7ce84 --- /dev/null +++ b/gradle/buildscript.gradle @@ -0,0 +1,11 @@ +// Executed in context of buildscript +repositories { + // Repo in addition to maven central + repositories { maven { url 'http://dl.bintray.com/content/netflixoss/external-gradle-plugins/' } } // For gradle-release +} +dependencies { + classpath 'nl.javadude.gradle.plugins:license-gradle-plugin:0.6.1' + classpath 'com.mapvine:gradle-cobertura-plugin:0.1' + classpath 'gradle-release:gradle-release:1.1.5' + classpath 'org.ajoberstar:gradle-git:0.5.0' +} diff --git a/gradle/check.gradle b/gradle/check.gradle new file mode 100644 index 0000000000..a3e4b4e7f5 --- /dev/null +++ b/gradle/check.gradle @@ -0,0 +1,26 @@ +subprojects { +// Checkstyle +apply plugin: 'checkstyle' +checkstyle { + ignoreFailures = true + configFile = rootProject.file('codequality/checkstyle.xml') +} + +// FindBugs +apply plugin: 'findbugs' +findbugs { + ignoreFailures = true +} + +// PMD +apply plugin: 'pmd' +//tasks.withType(Pmd) { reports.html.enabled true } + +apply plugin: 'cobertura' +cobertura { + sourceDirs = sourceSets.main.java.srcDirs + format = 'html' + includes = ['**/*.java', '**/*.groovy'] + excludes = [] +} +} diff --git a/gradle/convention.gradle b/gradle/convention.gradle new file mode 100644 index 0000000000..c4658fc33e --- /dev/null +++ b/gradle/convention.gradle @@ -0,0 +1,101 @@ +// GRADLE-2087 workaround, perform after java plugin +status = project.hasProperty('preferredStatus')?project.preferredStatus:(version.contains('SNAPSHOT')?'snapshot':'release') + +subprojects { project -> + apply plugin: 'java' // Plugin as major conventions + + sourceCompatibility = 1.6 + + // Restore status after Java plugin + status = rootProject.status + + task sourcesJar(type: Jar, dependsOn:classes) { + from sourceSets.main.allSource + classifier 'sources' + extension 'jar' + } + + task javadocJar(type: Jar, dependsOn:javadoc) { + from javadoc.destinationDir + classifier 'javadoc' + extension 'jar' + } + + configurations.add('sources') + configurations.add('javadoc') + configurations.archives { + extendsFrom configurations.sources + extendsFrom configurations.javadoc + } + + // When outputing to an Ivy repo, we want to use the proper type field + gradle.taskGraph.whenReady { + def isNotMaven = !it.hasTask(project.uploadMavenCentral) + if (isNotMaven) { + def artifacts = project.configurations.sources.artifacts + def sourceArtifact = artifacts.iterator().next() + sourceArtifact.type = 'sources' + } + } + + artifacts { + sources(sourcesJar) { + // Weird Gradle quirk where type will be used for the extension, but only for sources + type 'jar' + } + javadoc(javadocJar) { + type 'javadoc' + } + } + + configurations { + provided { + description = 'much like compile, but indicates you expect the JDK or a container to provide it. It is only available on the compilation classpath, and is not transitive.' + transitive = true + visible = true + } + } + + project.sourceSets { + main.compileClasspath += project.configurations.provided + main.runtimeClasspath -= project.configurations.provided + test.compileClasspath += project.configurations.provided + test.runtimeClasspath += project.configurations.provided + } +} + +apply plugin: 'github-pages' // Used to create publishGhPages task + +def docTasks = [:] +[Javadoc,ScalaDoc,Groovydoc].each{ Class docClass -> + def allSources = allprojects.tasks*.withType(docClass).flatten()*.source + if (allSources) { + def shortName = docClass.simpleName.toLowerCase() + def docTask = task "aggregate${shortName.capitalize()}"(type: docClass, description: "Aggregate subproject ${shortName}s") { + source = allSources + destinationDir = file("${project.buildDir}/docs/${shortName}") + doFirst { + def classpaths = allprojects.findAll { it.plugins.hasPlugin(JavaPlugin) }.collect { it.sourceSets.main.compileClasspath } + classpath = files(classpaths) + } + } + docTasks[shortName] = docTask + processGhPages.dependsOn(docTask) + } +} + +githubPages { + repoUri = "git@github.com:Netflix/${rootProject.githubProjectName}.git" + pages { + docTasks.each { shortName, docTask -> + from(docTask.outputs.files) { + into "docs/${shortName}" + } + } + } +} + +// Generate wrapper, which is distributed as part of source to alleviate the need of installing gradle +task createWrapper(type: Wrapper) { + gradleVersion = '1.5' +} diff --git a/gradle/license.gradle b/gradle/license.gradle new file mode 100644 index 0000000000..abd2e2c0e1 --- /dev/null +++ b/gradle/license.gradle @@ -0,0 +1,10 @@ +// Dependency for plugin was set in buildscript.gradle + +subprojects { +apply plugin: 'license' //nl.javadude.gradle.plugins.license.LicensePlugin +license { + header rootProject.file('codequality/HEADER') + ext.year = Calendar.getInstance().get(Calendar.YEAR) + skipExistingHeaders true +} +} diff --git a/gradle/maven.gradle b/gradle/maven.gradle new file mode 100644 index 0000000000..817846d77f --- /dev/null +++ b/gradle/maven.gradle @@ -0,0 +1,70 @@ +// Maven side of things +subprojects { + apply plugin: 'maven' // Java plugin has to have been already applied for the conf2scope mappings to work + apply plugin: 'signing' + + signing { + required { gradle.taskGraph.hasTask(uploadMavenCentral) } + sign configurations.archives + } + +/** + * Publishing to Maven Central example provided from http://jedicoder.blogspot.com/2011/11/automated-gradle-project-deployment-to.html + * artifactory will execute uploadArchives to force generation of ivy.xml, and we don't want that to trigger an upload to maven + * central, so using custom upload task. + */ +task uploadMavenCentral(type:Upload, dependsOn: signArchives) { + configuration = configurations.archives + onlyIf { ['release', 'snapshot'].contains(project.status) } + repositories.mavenDeployer { + beforeDeployment { signing.signPom(it) } + + // To test deployment locally, use the following instead of oss.sonatype.org + //repository(url: "file://localhost/${rootProject.rootDir}/repo") + + def sonatypeUsername = rootProject.hasProperty('sonatypeUsername')?rootProject.sonatypeUsername:'' + def sonatypePassword = rootProject.hasProperty('sonatypePassword')?rootProject.sonatypePassword:'' + + repository(url: 'https://oss.sonatype.org/service/local/staging/deploy/maven2') { + authentication(userName: sonatypeUsername, password: sonatypePassword) + } + + snapshotRepository(url: 'https://oss.sonatype.org/content/repositories/snapshots/') { + authentication(userName: sonatypeUsername, password: sonatypePassword) + } + + // Prevent datastamp from being appending to artifacts during deployment + uniqueVersion = false + + // Closure to configure all the POM with extra info, common to all projects + pom.project { + name "${project.name}" + description "${project.name} developed by Netflix" + developers { + developer { + id 'netflixgithub' + name 'Netflix Open Source Development' + email 'talent@netflix.com' + } + } + licenses { + license { + name 'The Apache Software License, Version 2.0' + url 'http://www.apache.org/licenses/LICENSE-2.0.txt' + distribution 'repo' + } + } + url "https://github.com/Netflix/${rootProject.githubProjectName}" + scm { + connection "scm:git:git@github.com:Netflix/${rootProject.githubProjectName}.git" + url "scm:git:git@github.com:Netflix/${rootProject.githubProjectName}.git" + developerConnection "scm:git:git@github.com:Netflix/${rootProject.githubProjectName}.git" + } + issueManagement { + system 'github' + url "https://github.com/Netflix/${rootProject.githubProjectName}/issues" + } + } + } + } +} diff --git a/gradle/netflix-oss.gradle b/gradle/netflix-oss.gradle new file mode 100644 index 0000000000..a87bc54efe --- /dev/null +++ b/gradle/netflix-oss.gradle @@ -0,0 +1 @@ +apply from: 'http://artifacts.netflix.com/gradle-netflix-local/artifactory.gradle' diff --git a/gradle/release.gradle b/gradle/release.gradle new file mode 100644 index 0000000000..7979dc3a18 --- /dev/null +++ b/gradle/release.gradle @@ -0,0 +1,61 @@ +apply plugin: 'release' + +[ uploadIvyLocal: 'uploadLocal', uploadArtifactory: 'artifactoryPublish', buildWithArtifactory: 'build' ].each { key, value -> + // Call out to compile against internal repository + task "${key}"(type: GradleBuild) { + startParameter = project.gradle.startParameter.newInstance() + doFirst { + startParameter.projectProperties = [status: project.status, preferredStatus: project.status] + } + startParameter.addInitScript( file('gradle/netflix-oss.gradle') ) + startParameter.getExcludedTaskNames().add('check') + tasks = [ 'build', value ] + } +} + +// Marker task for following code to key in on +task releaseCandidate(dependsOn: release) +task forceCandidate { + onlyIf { gradle.taskGraph.hasTask(releaseCandidate) } + doFirst { project.status = 'candidate' } +} +task forceRelease { + onlyIf { !gradle.taskGraph.hasTask(releaseCandidate) } + doFirst { project.status = 'release' } +} +release.dependsOn([forceCandidate, forceRelease]) + +task uploadMavenCentral(dependsOn: subprojects.tasks.uploadMavenCentral) +task releaseSnapshot(dependsOn: [uploadArtifactory, uploadMavenCentral]) + +// Ensure our versions look like the project status before publishing +task verifyStatus << { + def hasSnapshot = version.contains('-SNAPSHOT') + if (project.status == 'snapshot' && !hasSnapshot) { + throw new GradleException("Version (${version}) needs -SNAPSHOT if publishing snapshot") + } +} +uploadArtifactory.dependsOn(verifyStatus) +uploadMavenCentral.dependsOn(verifyStatus) + +// Ensure upload happens before taggging, hence upload failures will leave repo in a revertable state +preTagCommit.dependsOn([uploadArtifactory, uploadMavenCentral]) + + +gradle.taskGraph.whenReady { taskGraph -> + def hasRelease = taskGraph.hasTask('commitNewVersion') + def indexOf = { return taskGraph.allTasks.indexOf(it) } + + if (hasRelease) { + assert indexOf(build) < indexOf(unSnapshotVersion), 'build target has to be after unSnapshotVersion' + assert indexOf(uploadMavenCentral) < indexOf(preTagCommit), 'preTagCommit has to be after uploadMavenCentral' + assert indexOf(uploadArtifactory) < indexOf(preTagCommit), 'preTagCommit has to be after uploadArtifactory' + } +} + +// Prevent plugin from asking for a version number interactively +ext.'gradle.release.useAutomaticVersion' = "true" + +release { + git.requireBranch = null +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..faa569a9a0 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..061b536b4b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Apr 02 11:45:56 PDT 2013 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=http\://services.gradle.org/distributions/gradle-1.5-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000000..91a7e269e1 --- /dev/null +++ b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000000..aec99730b4 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/gson/README.md b/gson/README.md new file mode 100644 index 0000000000..206990e74b --- /dev/null +++ b/gson/README.md @@ -0,0 +1,10 @@ +Gson Codec +=================== + +This module adds support for encoding and decoding json via the Gson library. + +Add this to your object graph like so: + +```java +GitHub github = Feign.create(GitHub.class, "https://api.github.com", new GsonModule()); +``` diff --git a/gson/src/main/java/feign/gson/GsonModule.java b/gson/src/main/java/feign/gson/GsonModule.java new file mode 100644 index 0000000000..aab32687d3 --- /dev/null +++ b/gson/src/main/java/feign/gson/GsonModule.java @@ -0,0 +1,127 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.gson; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.InstanceCreator; +import com.google.gson.JsonIOException; +import com.google.gson.TypeAdapter; +import com.google.gson.internal.ConstructorConstructor; +import com.google.gson.internal.bind.MapTypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import dagger.Provides; +import feign.Observer; +import feign.codec.Decoder; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.codec.IncrementalDecoder; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import static dagger.Provides.Type.SET; + +@dagger.Module(library = true) +public final class GsonModule { + + @Provides(type = SET) Encoder encoder(GsonCodec codec) { + return codec; + } + + @Provides(type = SET) Decoder decoder(GsonCodec codec) { + return codec; + } + + @Provides(type = SET) IncrementalDecoder incrementalDecoder(GsonCodec codec) { + return codec; + } + + static class GsonCodec implements Encoder.Text, Decoder.TextStream, IncrementalDecoder.TextStream { + private final Gson gson; + + @Inject GsonCodec(Gson gson) { + this.gson = gson; + } + + @Override public String encode(Object object) throws EncodeException { + return gson.toJson(object); + } + + @Override public Object decode(Reader reader, Type type) throws IOException { + return fromJson(new JsonReader(reader), type); + } + + @Override + public void decode(Reader reader, Type type, Observer observer, AtomicBoolean subscribed) throws IOException { + JsonReader jsonReader = new JsonReader(reader); + jsonReader.beginArray(); + while (subscribed.get() && jsonReader.hasNext()) { + observer.onNext(fromJson(jsonReader, type)); + } + } + + private Object fromJson(JsonReader jsonReader, Type type) throws IOException { + try { + return gson.fromJson(jsonReader, type); + } catch (JsonIOException e) { + if (e.getCause() != null && e.getCause() instanceof IOException) { + throw IOException.class.cast(e.getCause()); + } + throw e; + } + } + } + + // deals with scenario where gson Object type treats all numbers as doubles. + @Provides TypeAdapter> doubleToInt() { + return new TypeAdapter>() { + TypeAdapter> delegate = new MapTypeAdapterFactory(new ConstructorConstructor( + Collections.>emptyMap()), false).create(new Gson(), token); + + @Override + public void write(JsonWriter out, Map value) throws IOException { + delegate.write(out, value); + } + + @Override + public Map read(JsonReader in) throws IOException { + Map map = delegate.read(in); + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() instanceof Double) { + entry.setValue(Double.class.cast(entry.getValue()).intValue()); + } + } + return map; + } + }.nullSafe(); + } + + @Provides @Singleton Gson gson(TypeAdapter> doubleToInt) { + return new GsonBuilder().registerTypeAdapter(token.getType(), doubleToInt).setPrettyPrinting().create(); + } + + protected final static TypeToken> token = new TypeToken>() { + }; +} diff --git a/gson/src/test/java/feign/gson/GsonModuleTest.java b/gson/src/test/java/feign/gson/GsonModuleTest.java new file mode 100644 index 0000000000..983c58ffcf --- /dev/null +++ b/gson/src/test/java/feign/gson/GsonModuleTest.java @@ -0,0 +1,183 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.gson; + +import com.google.gson.reflect.TypeToken; +import dagger.Module; +import dagger.ObjectGraph; +import feign.Observer; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.IncrementalDecoder; +import org.testng.annotations.Test; + +import javax.inject.Inject; +import java.io.StringReader; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.fail; + +@Test +public class GsonModuleTest { + @Module(includes = GsonModule.class, library = true, injects = EncodersAndDecoders.class) + static class EncodersAndDecoders { + @Inject Set encoders; + @Inject Set decoders; + @Inject Set incrementalDecoders; + } + + @Test public void providesEncoderDecoderAndIncrementalDecoder() throws Exception { + EncodersAndDecoders bindings = new EncodersAndDecoders(); + ObjectGraph.create(bindings).inject(bindings); + + assertEquals(bindings.encoders.size(), 1); + assertEquals(bindings.encoders.iterator().next().getClass(), GsonModule.GsonCodec.class); + assertEquals(bindings.decoders.size(), 1); + assertEquals(bindings.decoders.iterator().next().getClass(), GsonModule.GsonCodec.class); + assertEquals(bindings.incrementalDecoders.size(), 1); + assertEquals(bindings.incrementalDecoders.iterator().next().getClass(), GsonModule.GsonCodec.class); + } + + @Module(includes = GsonModule.class, library = true, injects = Encoders.class) + static class Encoders { + @Inject Set encoders; + } + + @Test public void encodesMapObjectNumericalValuesAsInteger() throws Exception { + Encoders bindings = new Encoders(); + ObjectGraph.create(bindings).inject(bindings); + + Map map = new LinkedHashMap(); + map.put("foo", 1); + + assertEquals(Encoder.Text.class.cast(bindings.encoders.iterator().next()).encode(map), ""// + + "{\n" // + + " \"foo\": 1\n" // + + "}"); + } + + @Test public void encodesFormParams() throws Exception { + + Encoders bindings = new Encoders(); + ObjectGraph.create(bindings).inject(bindings); + + Map form = new LinkedHashMap(); + form.put("foo", 1); + form.put("bar", Arrays.asList(2, 3)); + + assertEquals(Encoder.Text.class.cast(bindings.encoders.iterator().next()).encode(form), ""// + + "{\n" // + + " \"foo\": 1,\n" // + + " \"bar\": [\n" // + + " 2,\n" // + + " 3\n" // + + " ]\n" // + + "}"); + } + + static class Zone extends LinkedHashMap { + Zone() { + // for reflective instantiation. + } + + Zone(String name) { + this(name, null); + } + + Zone(String name, String id) { + put("name", name); + if (id != null) + put("id", id); + } + + private static final long serialVersionUID = 1L; + } + + @Module(includes = GsonModule.class, library = true, injects = Decoders.class) + static class Decoders { + @Inject Set decoders; + } + + @Test public void decodes() throws Exception { + Decoders bindings = new Decoders(); + ObjectGraph.create(bindings).inject(bindings); + + List zones = new LinkedList(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "ABCD")); + + assertEquals(Decoder.TextStream.class.cast(bindings.decoders.iterator().next()) + .decode(new StringReader(zonesJson), new TypeToken>() { + }.getType()), zones); + } + + @Module(includes = GsonModule.class, library = true, injects = IncrementalDecoders.class) + static class IncrementalDecoders { + @Inject Set decoders; + } + + @Test public void decodesIncrementally() throws Exception { + IncrementalDecoders bindings = new IncrementalDecoders(); + ObjectGraph.create(bindings).inject(bindings); + + final List zones = new LinkedList(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "ABCD")); + + final AtomicInteger index = new AtomicInteger(0); + + Observer zoneCallback = new Observer() { + + @Override public void onNext(Zone element) { + assertEquals(element, zones.get(index.getAndIncrement())); + } + + @Override public void onSuccess() { + // decoder shouldn't call onSuccess + fail(); + } + + @Override public void onFailure(Throwable cause) { + // decoder shouldn't call onFailure + fail(); + } + }; + + IncrementalDecoder.TextStream.class.cast(bindings.decoders.iterator().next()) + .decode(new StringReader(zonesJson), Zone.class, zoneCallback, new AtomicBoolean(true)); + + assertEquals(index.get(), 2); + } + + private String zonesJson = ""// + + "[\n"// + + " {\n"// + + " \"name\": \"denominator.io.\"\n"// + + " },\n"// + + " {\n"// + + " \"name\": \"denominator.io.\",\n"// + + " \"id\": \"ABCD\"\n"// + + " }\n"// + + "]\n"; +} diff --git a/jaxrs/README.md b/jaxrs/README.md new file mode 100644 index 0000000000..5026c7ac00 --- /dev/null +++ b/jaxrs/README.md @@ -0,0 +1,37 @@ +# Feign JAXRS +This module overrides annotation processing to instead use standard ones supplied by the JAX-RS specification. This is currently targeted at the 1.1 spec. + +## Limitations +While it may appear possible to reuse the same interface across client and server, bear in mind that JAX-RS resource + annotations were not designed to be processed by clients. Moreover, JAX-RS 2.0 has a different package hierarchy for +client invocation. Finally, JAX-RS is a large spec and attempts to implement it completely would be a project larger +than feign itself. In other words, this implementation is *best efforts* and concedes far from 100% compatibility with +server interface behavior. + +## Currently Supported Annotation Processing +Feign only supports processing java interfaces (not abstract or concrete classes). + +ISE is raised when any annotation's value is empty or null. Ex. `Path("")` raises an ISE. + +Here are a list of behaviors currently supported. +### Type Annotations +#### `@Path` +Appends the value to `Target.url()`. Can have tokens corresponding to `@PathParam` annotations. +### Method Annotations +#### `@HttpMethod` meta-annotation (present on `@GET`, `@POST`, etc.) +Sets the request method. +#### `@Path` +Appends the value to `Target.url()`. Can have tokens corresponding to `@PathParam` annotations. +#### `@Produces` +Adds the first value as the `Accept` header. +#### `@Consumes` +Adds the first value as the `Content-Type` header. +### Parameter Annotations +#### `@PathParam` +Links the value of the corresponding parameter to a template variable declared in the path. +#### `@QueryParam` +Links the value of the corresponding parameter to a query parameter. When invoked, null will skip the query param. +#### `@HeaderParam` +Links the value of the corresponding parameter to a header. +#### `@FormParam` +Links the value of the corresponding parameter to a key passed to `Encoder.Text>.encode()`. diff --git a/jaxrs/src/main/java/feign/jaxrs/JAXRSModule.java b/jaxrs/src/main/java/feign/jaxrs/JAXRSModule.java new file mode 100644 index 0000000000..dc84c8e2d0 --- /dev/null +++ b/jaxrs/src/main/java/feign/jaxrs/JAXRSModule.java @@ -0,0 +1,126 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.jaxrs; + +import dagger.Provides; +import feign.Body; +import feign.Contract; +import feign.MethodMetadata; + +import javax.ws.rs.Consumes; +import javax.ws.rs.FormParam; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.HttpMethod; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Collection; + +import static feign.Util.checkState; +import static feign.Util.emptyToNull; + +/** + * Please refer to the + * Feign JAX-RS README. + */ +@dagger.Module(library = true, overrides = true) +public final class JAXRSModule { + static final String ACCEPT = "Accept"; + static final String CONTENT_TYPE = "Content-Type"; + + @Provides Contract provideContract() { + return new JAXRSContract(); + } + + public static final class JAXRSContract extends Contract.BaseContract { + + @Override + public MethodMetadata parseAndValidatateMetadata(Method method) { + MethodMetadata md = super.parseAndValidatateMetadata(method); + Path path = method.getDeclaringClass().getAnnotation(Path.class); + if (path != null) { + String pathValue = emptyToNull(path.value()); + checkState(pathValue != null, "Path.value() was empty on type %s", method.getDeclaringClass().getName()); + md.template().insert(0, pathValue); + } + return md; + } + + @Override + protected void processAnnotationOnMethod(MethodMetadata data, Annotation methodAnnotation, Method method) { + Class annotationType = methodAnnotation.annotationType(); + HttpMethod http = annotationType.getAnnotation(HttpMethod.class); + if (http != null) { + checkState(data.template().method() == null, + "Method %s contains multiple HTTP methods. Found: %s and %s", method.getName(), data.template() + .method(), http.value()); + data.template().method(http.value()); + } else if (annotationType == Path.class) { + String pathValue = emptyToNull(Path.class.cast(methodAnnotation).value()); + checkState(pathValue != null, "Path.value() was empty on method %s", method.getName()); + data.template().append(Path.class.cast(methodAnnotation).value()); + } else if (annotationType == Produces.class) { + String[] serverProduces = ((Produces) methodAnnotation).value(); + String clientAccepts = serverProduces.length == 0 ? null: emptyToNull(serverProduces[0]); + checkState(clientAccepts != null, "Produces.value() was empty on method %s", method.getName()); + data.template().header(ACCEPT, clientAccepts); + } else if (annotationType == Consumes.class) { + String[] serverConsumes = ((Consumes) methodAnnotation).value(); + String clientProduces = serverConsumes.length == 0 ? null: emptyToNull(serverConsumes[0]); + checkState(clientProduces != null, "Consumes.value() was empty on method %s", method.getName()); + data.template().header(CONTENT_TYPE, clientProduces); + } + } + + @Override + protected boolean processAnnotationsOnParameter(MethodMetadata data, Annotation[] annotations, int paramIndex) { + boolean isHttpParam = false; + for (Annotation parameterAnnotation : annotations) { + Class annotationType = parameterAnnotation.annotationType(); + if (annotationType == PathParam.class) { + String name = PathParam.class.cast(parameterAnnotation).value(); + checkState(emptyToNull(name) != null, "PathParam.value() was empty on parameter %s", paramIndex); + nameParam(data, name, paramIndex); + isHttpParam = true; + } else if (annotationType == QueryParam.class) { + String name = QueryParam.class.cast(parameterAnnotation).value(); + checkState(emptyToNull(name) != null, "QueryParam.value() was empty on parameter %s", paramIndex); + Collection query = addTemplatedParam(data.template().queries().get(name), name); + data.template().query(name, query); + nameParam(data, name, paramIndex); + isHttpParam = true; + } else if (annotationType == HeaderParam.class) { + String name = HeaderParam.class.cast(parameterAnnotation).value(); + checkState(emptyToNull(name) != null, "HeaderParam.value() was empty on parameter %s", paramIndex); + Collection header = addTemplatedParam(data.template().headers().get(name), name); + data.template().header(name, header); + nameParam(data, name, paramIndex); + isHttpParam = true; + } else if (annotationType == FormParam.class) { + String name = FormParam.class.cast(parameterAnnotation).value(); + checkState(emptyToNull(name) != null, "FormParam.value() was empty on parameter %s", paramIndex); + data.formParams().add(name); + nameParam(data, name, paramIndex); + isHttpParam = true; + } + } + return isHttpParam; + } + } +} diff --git a/jaxrs/src/test/java/feign/jaxrs/JAXRSContractTest.java b/jaxrs/src/test/java/feign/jaxrs/JAXRSContractTest.java new file mode 100644 index 0000000000..1669e3698c --- /dev/null +++ b/jaxrs/src/test/java/feign/jaxrs/JAXRSContractTest.java @@ -0,0 +1,382 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.jaxrs; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.gson.reflect.TypeToken; +import feign.MethodMetadata; +import feign.Observable; +import feign.Observer; +import feign.Response; +import org.testng.annotations.Test; + +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.FormParam; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.HttpMethod; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Type; +import java.net.URI; +import java.util.List; + +import static feign.jaxrs.JAXRSModule.ACCEPT; +import static feign.jaxrs.JAXRSModule.CONTENT_TYPE; +import static javax.ws.rs.HttpMethod.DELETE; +import static javax.ws.rs.HttpMethod.GET; +import static javax.ws.rs.HttpMethod.POST; +import static javax.ws.rs.HttpMethod.PUT; +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; +import static javax.ws.rs.core.MediaType.APPLICATION_XML; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Tests interfaces defined per {@link feign.jaxrs.JAXRSModule.JAXRSContract} are interpreted into expected {@link feign + * .RequestTemplate template} + * instances. + */ +@Test +public class JAXRSContractTest { + JAXRSModule.JAXRSContract contract = new JAXRSModule.JAXRSContract(); + + interface Methods { + @POST void post(); + + @PUT void put(); + + @GET void get(); + + @DELETE void delete(); + } + + @Test public void httpMethods() throws Exception { + assertEquals(contract.parseAndValidatateMetadata(Methods.class.getDeclaredMethod("post")).template().method(), + POST); + assertEquals(contract.parseAndValidatateMetadata(Methods.class.getDeclaredMethod("put")).template().method(), PUT); + assertEquals(contract.parseAndValidatateMetadata(Methods.class.getDeclaredMethod("get")).template().method(), GET); + assertEquals(contract.parseAndValidatateMetadata(Methods.class.getDeclaredMethod("delete")).template().method(), DELETE); + } + + interface CustomMethodAndURIParam { + @Target({ElementType.METHOD}) + @Retention(RetentionPolicy.RUNTIME) + @HttpMethod("PATCH") + public @interface PATCH { + } + + @PATCH Response patch(URI nextLink); + } + + @Test public void requestLineOnlyRequiresMethod() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(CustomMethodAndURIParam.class.getDeclaredMethod("patch", + URI.class)); + assertEquals(md.template().method(), "PATCH"); + assertEquals(md.template().url(), ""); + assertTrue(md.template().queries().isEmpty()); + assertTrue(md.template().headers().isEmpty()); + assertNull(md.template().body()); + assertNull(md.template().bodyTemplate()); + assertEquals(md.urlIndex(), Integer.valueOf(0)); + } + + interface WithQueryParamsInPath { + @GET @Path("/") Response none(); + + @GET @Path("/?Action=GetUser") Response one(); + + @GET @Path("/?Action=GetUser&Version=2010-05-08") Response two(); + + @GET @Path("/?Action=GetUser&Version=2010-05-08&limit=1") Response three(); + + @GET @Path("/?flag&Action=GetUser&Version=2010-05-08") Response empty(); + } + + @Test public void queryParamsInPathExtract() throws Exception { + { + MethodMetadata md = contract.parseAndValidatateMetadata(WithQueryParamsInPath.class.getDeclaredMethod("none")); + assertEquals(md.template().url(), "/"); + assertTrue(md.template().queries().isEmpty()); + assertEquals(md.template().toString(), "GET / HTTP/1.1\n"); + } + { + MethodMetadata md = contract.parseAndValidatateMetadata(WithQueryParamsInPath.class.getDeclaredMethod("one")); + assertEquals(md.template().url(), "/"); + assertEquals(md.template().queries().get("Action"), ImmutableSet.of("GetUser")); + assertEquals(md.template().toString(), "GET /?Action=GetUser HTTP/1.1\n"); + } + { + MethodMetadata md = contract.parseAndValidatateMetadata(WithQueryParamsInPath.class.getDeclaredMethod("two")); + assertEquals(md.template().url(), "/"); + assertEquals(md.template().queries().get("Action"), ImmutableSet.of("GetUser")); + assertEquals(md.template().queries().get("Version"), ImmutableSet.of("2010-05-08")); + assertEquals(md.template().toString(), "GET /?Action=GetUser&Version=2010-05-08 HTTP/1.1\n"); + } + { + MethodMetadata md = contract.parseAndValidatateMetadata(WithQueryParamsInPath.class.getDeclaredMethod("three")); + assertEquals(md.template().url(), "/"); + assertEquals(md.template().queries().get("Action"), ImmutableSet.of("GetUser")); + assertEquals(md.template().queries().get("Version"), ImmutableSet.of("2010-05-08")); + assertEquals(md.template().queries().get("limit"), ImmutableSet.of("1")); + assertEquals(md.template().toString(), "GET /?Action=GetUser&Version=2010-05-08&limit=1 HTTP/1.1\n"); + } + { + MethodMetadata md = contract.parseAndValidatateMetadata(WithQueryParamsInPath.class.getDeclaredMethod("empty")); + assertEquals(md.template().url(), "/"); + assertTrue(md.template().queries().containsKey("flag")); + assertEquals(md.template().queries().get("Action"), ImmutableSet.of("GetUser")); + assertEquals(md.template().queries().get("Version"), ImmutableSet.of("2010-05-08")); + assertEquals(md.template().toString(), "GET /?flag&Action=GetUser&Version=2010-05-08 HTTP/1.1\n"); + } + } + + interface ProducesAndConsumes { + @GET @Produces(APPLICATION_XML) Response produces(); + + @GET @Produces({}) Response producesNada(); + + @GET @Produces({""}) Response producesEmpty(); + + @POST @Consumes(APPLICATION_JSON) Response consumes(); + + @POST @Consumes({}) Response consumesNada(); + + @POST @Consumes({""}) Response consumesEmpty(); + } + + @Test public void producesAddsAcceptHeader() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(ProducesAndConsumes.class.getDeclaredMethod("produces")); + assertEquals(md.template().headers().get(ACCEPT), ImmutableSet.of(APPLICATION_XML)); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "Produces.value\\(\\) was empty on method producesNada") + public void producesNada() throws Exception { + contract.parseAndValidatateMetadata(ProducesAndConsumes.class.getDeclaredMethod("producesNada")); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "Produces.value\\(\\) was empty on method producesEmpty") + public void producesEmpty() throws Exception { + contract.parseAndValidatateMetadata(ProducesAndConsumes.class.getDeclaredMethod("producesEmpty")); + } + + @Test public void consumesAddsContentTypeHeader() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(ProducesAndConsumes.class.getDeclaredMethod("consumes")); + assertEquals(md.template().headers().get(CONTENT_TYPE), ImmutableSet.of(APPLICATION_JSON)); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "Consumes.value\\(\\) was empty on method consumesNada") + public void consumesNada() throws Exception { + contract.parseAndValidatateMetadata(ProducesAndConsumes.class.getDeclaredMethod("consumesNada")); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "Consumes.value\\(\\) was empty on method consumesEmpty") + public void consumesEmpty() throws Exception { + contract.parseAndValidatateMetadata(ProducesAndConsumes.class.getDeclaredMethod("consumesEmpty")); + } + + interface BodyParams { + @POST Response post(List body); + + @POST Response tooMany(List body, List body2); + } + + @Test public void bodyParamIsGeneric() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(BodyParams.class.getDeclaredMethod("post", + List.class)); + assertNull(md.template().body()); + assertNull(md.template().bodyTemplate()); + assertNull(md.urlIndex()); + assertEquals(md.bodyIndex(), Integer.valueOf(0)); + assertEquals(md.bodyType(), new TypeToken>() { + }.getType()); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "Method has too many Body.*") + public void tooManyBodies() throws Exception { + contract.parseAndValidatateMetadata(BodyParams.class.getDeclaredMethod("tooMany", List.class, List.class)); + } + + @Path("") interface EmptyPathOnType { + @GET Response base(); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "Path.value\\(\\) was empty on type .*") + public void emptyPathOnType() throws Exception { + contract.parseAndValidatateMetadata(EmptyPathOnType.class.getDeclaredMethod("base")); + } + + @Path("/base") interface PathOnType { + @GET Response base(); + + @GET @Path("/specific") Response get(); + + @GET @Path("") Response emptyPath(); + + @GET @Path("/{param}") Response emptyPathParam(@PathParam("") String empty); + } + + @Test public void pathOnType() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(PathOnType.class.getDeclaredMethod("base")); + assertEquals(md.template().url(), "/base"); + md = contract.parseAndValidatateMetadata(PathOnType.class.getDeclaredMethod("get")); + assertEquals(md.template().url(), "/base/specific"); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "Path.value\\(\\) was empty on method emptyPath") + public void emptyPathOnMethod() throws Exception { + contract.parseAndValidatateMetadata(PathOnType.class.getDeclaredMethod("emptyPath")); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "PathParam.value\\(\\) was empty on parameter 0") + public void emptyPathParam() throws Exception { + contract.parseAndValidatateMetadata(PathOnType.class.getDeclaredMethod("emptyPathParam", String.class)); + } + + interface WithURIParam { + @GET @Path("/{1}/{2}") Response uriParam(@PathParam("1") String one, URI endpoint, @PathParam("2") String two); + } + + @Test public void methodCanHaveUriParam() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(WithURIParam.class.getDeclaredMethod("uriParam", String.class, + URI.class, String.class)); + assertEquals(md.urlIndex(), Integer.valueOf(1)); + } + + @Test public void pathParamsParseIntoIndexToName() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(WithURIParam.class.getDeclaredMethod("uriParam", String.class, + URI.class, String.class)); + assertEquals(md.template().url(), "/{1}/{2}"); + assertEquals(md.indexToName().get(0), ImmutableSet.of("1")); + assertEquals(md.indexToName().get(2), ImmutableSet.of("2")); + } + + interface WithPathAndQueryParams { + @GET @Path("/domains/{domainId}/records") + Response recordsByNameAndType(@PathParam("domainId") int id, @QueryParam("name") String nameFilter, + @QueryParam("type") String typeFilter); + + @GET Response emptyQueryParam(@QueryParam("") String empty); + } + + @Test public void mixedRequestLineParams() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(WithPathAndQueryParams.class.getDeclaredMethod + ("recordsByNameAndType", int.class, String.class, String.class)); + assertNull(md.template().body()); + assertNull(md.template().bodyTemplate()); + assertTrue(md.template().headers().isEmpty()); + assertEquals(md.template().url(), "/domains/{domainId}/records"); + assertEquals(md.template().queries().get("name"), ImmutableSet.of("{name}")); + assertEquals(md.template().queries().get("type"), ImmutableSet.of("{type}")); + assertEquals(md.indexToName().get(0), ImmutableSet.of("domainId")); + assertEquals(md.indexToName().get(1), ImmutableSet.of("name")); + assertEquals(md.indexToName().get(2), ImmutableSet.of("type")); + assertEquals(md.template().toString(), "GET /domains/{domainId}/records?name={name}&type={type} HTTP/1.1\n"); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "QueryParam.value\\(\\) was empty on parameter 0") + public void emptyQueryParam() throws Exception { + contract.parseAndValidatateMetadata(WithPathAndQueryParams.class.getDeclaredMethod("emptyQueryParam", String.class)); + } + + interface FormParams { + @POST void login( + @FormParam("customer_name") String customer, + @FormParam("user_name") String user, @FormParam("password") String password); + + @GET Response emptyFormParam(@FormParam("") String empty); + } + + @Test public void formParamsParseIntoIndexToName() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(FormParams.class.getDeclaredMethod("login", String.class, + String.class, String.class)); + + assertNull(md.template().body()); + assertNull(md.template().bodyTemplate()); + assertEquals(md.formParams(), ImmutableList.of("customer_name", "user_name", "password")); + assertEquals(md.indexToName().get(0), ImmutableSet.of("customer_name")); + assertEquals(md.indexToName().get(1), ImmutableSet.of("user_name")); + assertEquals(md.indexToName().get(2), ImmutableSet.of("password")); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "FormParam.value\\(\\) was empty on parameter 0") + public void emptyFormParam() throws Exception { + contract.parseAndValidatateMetadata(FormParams.class.getDeclaredMethod("emptyFormParam", String.class)); + } + + interface HeaderParams { + @POST void logout(@HeaderParam("Auth-Token") String token); + + @GET Response emptyHeaderParam(@HeaderParam("") String empty); + } + + @Test public void headerParamsParseIntoIndexToName() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(HeaderParams.class.getDeclaredMethod("logout", String.class)); + + assertEquals(md.template().headers().get("Auth-Token"), ImmutableSet.of("{Auth-Token}")); + assertEquals(md.indexToName().get(0), ImmutableSet.of("Auth-Token")); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "HeaderParam.value\\(\\) was empty on parameter 0") + public void emptyHeaderParam() throws Exception { + contract.parseAndValidatateMetadata(HeaderParams.class.getDeclaredMethod("emptyHeaderParam", String.class)); + } + + interface WithObservable { + @GET @Path("/") Observable> valid(); + + @GET @Path("/") Observable> wildcardExtends(); + + @GET @Path("/") ParameterizedObservable> subtype(); + + @GET @Path("/") Observable> alsoObserver(Observer> observer); + } + + interface ParameterizedObservable> extends Observable { + } + + static final List listString = null; + + @Test public void methodCanHaveObservableReturn() throws Exception { + contract.parseAndValidatateMetadata(WithObservable.class.getDeclaredMethod("valid")); + } + + @Test public void methodMetadataReturnTypeOnObservableMethodIsItsTypeParameter() throws Exception { + Type listStringType = getClass().getDeclaredField("listString").getGenericType(); + MethodMetadata md = contract.parseAndValidatateMetadata(WithObservable.class.getDeclaredMethod("valid")); + assertEquals(md.incrementalType(), listStringType); + md = contract.parseAndValidatateMetadata(WithObservable.class.getDeclaredMethod("wildcardExtends")); + assertEquals(md.incrementalType(), listStringType); + md = contract.parseAndValidatateMetadata(WithObservable.class.getDeclaredMethod("subtype")); + assertEquals(md.incrementalType(), listStringType); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = "Please return Observer as opposed to passing an Observable arg.*") + public void noObserverArgs() throws Exception { + contract.parseAndValidatateMetadata(WithObservable.class.getDeclaredMethod("alsoObserver", Observer.class)); + } +} diff --git a/jaxrs/src/test/java/feign/jaxrs/examples/GitHubExample.java b/jaxrs/src/test/java/feign/jaxrs/examples/GitHubExample.java new file mode 100644 index 0000000000..80289f112a --- /dev/null +++ b/jaxrs/src/test/java/feign/jaxrs/examples/GitHubExample.java @@ -0,0 +1,115 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.jaxrs.examples; + +import dagger.Module; +import dagger.Provides; +import feign.Feign; +import feign.Logger; +import feign.Observable; +import feign.Observer; +import feign.gson.GsonModule; +import feign.jaxrs.JAXRSModule; + +import javax.inject.Named; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +/** + * adapted from {@code com.example.retrofit.GitHubClient} + */ +public class GitHubExample { + + interface GitHub { + @GET @Path("/repos/{owner}/{repo}/contributors") + List contributors(@PathParam("owner") String owner, @PathParam("repo") String repo); + + @GET @Path("/repos/{owner}/{repo}/contributors") + Observable observable(@PathParam("owner") String owner, @PathParam("repo") String repo); + } + + static class Contributor { + String login; + int contributions; + } + + public static void main(String... args) throws InterruptedException { + GitHub github = Feign.create(GitHub.class, "https://api.github.com", new GitHubModule()); + + System.out.println("Let's fetch and print a list of the contributors to this library."); + List contributors = github.contributors("netflix", "feign"); + for (Contributor contributor : contributors) { + System.out.println(contributor.login + " (" + contributor.contributions + ")"); + } + + System.out.println("Let's treat our contributors as an observable."); + Observable observable = github.observable("netflix", "feign"); + + CountDownLatch latch = new CountDownLatch(2); + + System.out.println("Let's add 2 subscribers."); + observable.subscribe(new ContributorObserver(latch)); + observable.subscribe(new ContributorObserver(latch)); + + // wait for the task to complete. + latch.await(); + + System.exit(0); + } + + /** + * JAXRSModule tells us to process @GET etc annotations + */ + @Module(overrides = true, library = true, includes = {JAXRSModule.class, GsonModule.class}) + static class GitHubModule { + + @Provides Logger.Level loggingLevel() { + return Logger.Level.BASIC; + } + + @Provides Logger logger() { + return new Logger.ErrorLogger(); + } + } + + static class ContributorObserver implements Observer { + + private final CountDownLatch latch; + public int count; + + public ContributorObserver(CountDownLatch latch) { + this.latch = latch; + } + + // parsed directly from the text stream without an intermediate collection. + @Override public void onNext(Contributor contributor) { + count++; + } + + @Override public void onSuccess() { + System.out.println("found " + count + " contributors"); + latch.countDown(); + } + + @Override public void onFailure(Throwable cause) { + cause.printStackTrace(); + latch.countDown(); + } + } +} diff --git a/ribbon/README.md b/ribbon/README.md new file mode 100644 index 0000000000..02f72ef99e --- /dev/null +++ b/ribbon/README.md @@ -0,0 +1,30 @@ +# Ribbon +This module includes a feign `Target` and `Client` adapter to take advantage of [Ribbon](https://github.com/Netflix/ribbon). + +## Conventions +This integration relies on the Feign `Target.url()` being encoded like `https://myAppProd` where `myAppProd` is the ribbon client or loadbalancer name and `myAppProd.ribbon.listOfServers` configuration is set. + +### RibbonModule +Adding `RibbonModule` overrides URL resolution of Feign's client, adding smart routing and resiliency capabilities provided by Ribbon. + +#### Usage +instead of  +```java +MyService api = Feign.create(MyService.class, "https://myAppProd-1234567890.us-east-1.elb.amazonaws.com"); +``` +do +```java +MyService api = Feign.create(MyService.class, "https://myAppProd", new RibbonModule()); +``` +### LoadBalancingTarget +Using or extending `LoadBalancingTarget` will enable dynamic url discovery via ribbon including incrementing server request counts. + +#### Usage +instead of +```java +MyService api = Feign.create(MyService.class, "https://myAppProd-1234567890.us-east-1.elb.amazonaws.com"); +``` +do +```java +MyService api = Feign.create(LoadBalancingTarget.create(MyService.class, "https://myAppProd")); +``` diff --git a/ribbon/src/main/java/feign/ribbon/LBClient.java b/ribbon/src/main/java/feign/ribbon/LBClient.java new file mode 100644 index 0000000000..134c289bf4 --- /dev/null +++ b/ribbon/src/main/java/feign/ribbon/LBClient.java @@ -0,0 +1,144 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.ribbon; + +import com.netflix.client.AbstractLoadBalancerAwareClient; +import com.netflix.client.ClientException; +import com.netflix.client.ClientRequest; +import com.netflix.client.IResponse; +import com.netflix.client.config.CommonClientConfigKey; +import com.netflix.client.config.IClientConfig; +import com.netflix.loadbalancer.ILoadBalancer; +import com.netflix.util.Pair; + +import java.io.IOException; +import java.net.URI; +import java.util.Collection; +import java.util.Map; + +import feign.Client; +import feign.Request; +import feign.RequestTemplate; +import feign.Response; +import feign.RetryableException; + +import static com.netflix.client.config.CommonClientConfigKey.ConnectTimeout; +import static com.netflix.client.config.CommonClientConfigKey.ReadTimeout; + +class LBClient extends AbstractLoadBalancerAwareClient { + + private final Client delegate; + private final int connectTimeout; + private final int readTimeout; + + LBClient(Client delegate, ILoadBalancer lb, IClientConfig clientConfig) { + this.delegate = delegate; + this.connectTimeout = Integer.valueOf(clientConfig.getProperty(ConnectTimeout).toString()); + this.readTimeout = Integer.valueOf(clientConfig.getProperty(ReadTimeout).toString()); + setLoadBalancer(lb); + initWithNiwsConfig(clientConfig); + } + + @Override + public RibbonResponse execute(RibbonRequest request) throws IOException { + int connectTimeout = config(request, ConnectTimeout, this.connectTimeout); + int readTimeout = config(request, ReadTimeout, this.readTimeout); + + Request.Options options = new Request.Options(connectTimeout, readTimeout); + Response response = delegate.execute(request.toRequest(), options); + return new RibbonResponse(request.getUri(), response); + } + + @Override protected boolean isCircuitBreakerException(Exception e) { + return e instanceof IOException; + } + + @Override protected boolean isRetriableException(Exception e) { + return e instanceof RetryableException; + } + + @Override + protected Pair deriveSchemeAndPortFromPartialUri(RibbonRequest task) { + return new Pair(URI.create(task.request.url()).getScheme(), task.getUri().getPort()); + } + + @Override protected int getDefaultPort() { + return 443; + } + + static class RibbonRequest extends ClientRequest implements Cloneable { + + private final Request request; + + RibbonRequest(Request request, URI uri) { + this.request = request; + setUri(uri); + } + + Request toRequest() { + return new RequestTemplate() + .method(request.method()) + .append(getUri().toASCIIString()) + .headers(request.headers()) + .body(request.body()).request(); + } + + public Object clone() { + return new RibbonRequest(request, getUri()); + } + } + + static class RibbonResponse implements IResponse { + + private final URI uri; + private final Response response; + + RibbonResponse(URI uri, Response response) { + this.uri = uri; + this.response = response; + } + + @Override public Object getPayload() throws ClientException { + return response.body(); + } + + @Override public boolean hasPayload() { + return response.body() != null; + } + + @Override public boolean isSuccess() { + return response.status() == 200; + } + + @Override public URI getRequestedURI() { + return uri; + } + + @Override public Map> getHeaders() { + return response.headers(); + } + + Response toResponse() { + return response; + } + } + + static int config(RibbonRequest request, CommonClientConfigKey key, int defaultValue) { + if (request.getOverrideConfig() != null && request.getOverrideConfig().containsProperty(key)) + return Integer.valueOf(request.getOverrideConfig().getProperty(key).toString()); + return defaultValue; + } +} diff --git a/ribbon/src/main/java/feign/ribbon/LoadBalancingTarget.java b/ribbon/src/main/java/feign/ribbon/LoadBalancingTarget.java new file mode 100644 index 0000000000..c10aa5e6b3 --- /dev/null +++ b/ribbon/src/main/java/feign/ribbon/LoadBalancingTarget.java @@ -0,0 +1,114 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.ribbon; + +import com.google.common.base.Objects; +import com.netflix.loadbalancer.AbstractLoadBalancer; +import com.netflix.loadbalancer.Server; + +import java.net.URI; + +import feign.Request; +import feign.RequestTemplate; +import feign.Target; + +import static com.google.common.base.Objects.equal; +import static com.netflix.client.ClientFactory.getNamedLoadBalancer; +import static feign.Util.checkNotNull; +import static java.lang.String.format; + +/** + * Basic integration for {@link com.netflix.loadbalancer.ILoadBalancer loadbalancer-aware} targets. + * Using this will enable dynamic url discovery via ribbon including incrementing server request counts. + *
+ * Ex. + *
+ * MyService api = Feign.create(LoadBalancingTarget.create(MyService.class, "http://myAppProd"))
+ * 
+ * Where {@code myAppProd} is the ribbon loadbalancer name and {@code myAppProd.ribbon.listOfServers} configuration + * is set. + * + * @param corresponds to {@link feign.Target#type()} + */ +public class LoadBalancingTarget implements Target { + + /** + * creates a target which dynamically derives urls from a {@link com.netflix.loadbalancer.ILoadBalancer loadbalancer}. + * + * @param type corresponds to {@link feign.Target#type()} + * @param schemeName naming convention is {@code https://name} or {@code http://name} where + * name corresponds to {@link com.netflix.client.ClientFactory#getNamedLoadBalancer(String)} + */ + public static LoadBalancingTarget create(Class type, String schemeName) { + URI asUri = URI.create(schemeName); + return new LoadBalancingTarget(type, asUri.getScheme(), asUri.getHost()); + } + + private final String name; + private final String scheme; + private final Class type; + private final AbstractLoadBalancer lb; + + protected LoadBalancingTarget(Class type, String scheme, String name) { + this.type = checkNotNull(type, "type"); + this.scheme = checkNotNull(scheme, "scheme"); + this.name = checkNotNull(name, "name"); + this.lb = AbstractLoadBalancer.class.cast(getNamedLoadBalancer(name())); + } + + @Override public Class type() { + return type; + } + + @Override public String name() { + return name; + } + + @Override public String url() { + return name; + } + + /** + * current load balancer for the target. + */ + public AbstractLoadBalancer lb() { + return lb; + } + + @Override public Request apply(RequestTemplate input) { + Server currentServer = lb.chooseServer(null); + String url = format("%s://%s", scheme, currentServer.getHostPort()); + input.insert(0, url); + try { + return input.request(); + } finally { + lb.getLoadBalancerStats().incrementNumRequests(currentServer); + } + } + + @Override public int hashCode() { + return Objects.hashCode(type, name); + } + + @Override public boolean equals(Object obj) { + if (this == obj) + return true; + if (LoadBalancingTarget.class != obj.getClass()) + return false; + LoadBalancingTarget that = LoadBalancingTarget.class.cast(obj); + return equal(this.type, that.type) && equal(this.name, that.name); + } +} diff --git a/ribbon/src/main/java/feign/ribbon/RibbonModule.java b/ribbon/src/main/java/feign/ribbon/RibbonModule.java new file mode 100644 index 0000000000..9054d10139 --- /dev/null +++ b/ribbon/src/main/java/feign/ribbon/RibbonModule.java @@ -0,0 +1,89 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.ribbon; + +import com.google.common.base.Throwables; +import com.netflix.client.ClientException; +import com.netflix.client.ClientFactory; +import com.netflix.client.config.IClientConfig; +import com.netflix.loadbalancer.ILoadBalancer; + +import java.io.IOException; +import java.net.URI; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import dagger.Provides; +import feign.Client; +import feign.Request; +import feign.Response; + +/** + * Adding this module will override URL resolution of {@link feign.Client Feign's client}, + * adding smart routing and resiliency capabilities provided by Ribbon. + *
+ * When using this, ensure the {@link feign.Target#url()} is set to as {@code http://clientName} + * or {@code https://clientName}. {@link com.netflix.client.config.IClientConfig#getClientName() clientName} + * will lookup the real url and port of your service dynamically. + *
+ * Ex. + *
+ * MyService api = Feign.create(MyService.class, "http://myAppProd", new RibbonModule());
+ * 
+ * Where {@code myAppProd} is the ribbon client name and {@code myAppProd.ribbon.listOfServers} configuration + * is set. + */ +@dagger.Module(overrides = true, library = true, complete = false) +public class RibbonModule { + + @Provides @Named("delegate") Client delegate(Client.Default delegate) { + return delegate; + } + + @Provides @Singleton Client httpClient(RibbonClient ribbon) { + return ribbon; + } + + @Singleton + static class RibbonClient implements Client { + private final Client delegate; + + @Inject + public RibbonClient(@Named("delegate") Client delegate) { + this.delegate = delegate; + } + + @Override public Response execute(Request request, Request.Options options) throws IOException { + try { + URI asUri = URI.create(request.url()); + String clientName = asUri.getHost(); + URI uriWithoutSchemeAndPort = URI.create(request.url().replace(asUri.getScheme() + "://" + asUri.getHost(), "")); + LBClient.RibbonRequest ribbonRequest = new LBClient.RibbonRequest(request, uriWithoutSchemeAndPort); + return lbClient(clientName).executeWithLoadBalancer(ribbonRequest).toResponse(); + } catch (ClientException e) { + throw Throwables.propagate(e); + } + } + + private LBClient lbClient(String clientName) { + IClientConfig config = ClientFactory.getNamedConfig(clientName); + ILoadBalancer lb = ClientFactory.getNamedLoadBalancer(clientName); + return new LBClient(delegate, lb, config); + } + } +} diff --git a/ribbon/src/test/java/feign/ribbon/LoadBalancingTargetTest.java b/ribbon/src/test/java/feign/ribbon/LoadBalancingTargetTest.java new file mode 100644 index 0000000000..29e834e0d5 --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/LoadBalancingTargetTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.ribbon; + +import com.google.mockwebserver.MockResponse; +import com.google.mockwebserver.MockWebServer; + +import org.testng.annotations.Test; + +import java.io.IOException; +import java.net.URL; + +import feign.Feign; +import feign.RequestLine; + +import static com.netflix.config.ConfigurationManager.getConfigInstance; +import static org.testng.Assert.assertEquals; + +@Test +public class LoadBalancingTargetTest { + interface TestInterface { + @RequestLine("POST /") void post(); + } + + @Test + public void loadBalancingDefaultPolicyRoundRobin() throws IOException, InterruptedException { + String name = "LoadBalancingTargetTest-loadBalancingDefaultPolicyRoundRobin"; + String serverListKey = name + ".ribbon.listOfServers"; + + MockWebServer server1 = new MockWebServer(); + server1.enqueue(new MockResponse().setResponseCode(200).setBody("success!".getBytes())); + server1.play(); + MockWebServer server2 = new MockWebServer(); + server2.enqueue(new MockResponse().setResponseCode(200).setBody("success!".getBytes())); + server2.play(); + + getConfigInstance().setProperty(serverListKey, hostAndPort(server1.getUrl("")) + "," + hostAndPort(server2.getUrl(""))); + + try { + LoadBalancingTarget target = LoadBalancingTarget.create(TestInterface.class, "http://" + name); + TestInterface api = Feign.create(target); + + api.post(); + api.post(); + + assertEquals(server1.getRequestCount(), 1); + assertEquals(server2.getRequestCount(), 1); + // TODO: verify ribbon stats match + // assertEquals(target.lb().getLoadBalancerStats().getSingleServerStat()) + } finally { + server1.shutdown(); + server2.shutdown(); + getConfigInstance().clearProperty(serverListKey); + } + } + + static String hostAndPort(URL url) { + // our build slaves have underscores in their hostnames which aren't permitted by ribbon + return "localhost:" + url.getPort(); + } +} diff --git a/ribbon/src/test/java/feign/ribbon/RibbonClientTest.java b/ribbon/src/test/java/feign/ribbon/RibbonClientTest.java new file mode 100644 index 0000000000..2f49d56959 --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/RibbonClientTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * 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.ribbon; + +import com.google.mockwebserver.MockResponse; +import com.google.mockwebserver.MockWebServer; + +import org.testng.annotations.Test; + +import java.io.IOException; +import java.net.URL; + +import feign.Feign; +import feign.RequestLine; + +import static com.netflix.config.ConfigurationManager.getConfigInstance; +import static org.testng.Assert.assertEquals; + +@Test +public class RibbonClientTest { + interface TestInterface { + @RequestLine("POST /") void post(); + } + + @Test + public void loadBalancingDefaultPolicyRoundRobin() throws IOException, InterruptedException { + String client = "RibbonClientTest-loadBalancingDefaultPolicyRoundRobin"; + String serverListKey = client + ".ribbon.listOfServers"; + + MockWebServer server1 = new MockWebServer(); + server1.enqueue(new MockResponse().setResponseCode(200).setBody("success!".getBytes())); + server1.play(); + MockWebServer server2 = new MockWebServer(); + server2.enqueue(new MockResponse().setResponseCode(200).setBody("success!".getBytes())); + server2.play(); + + getConfigInstance().setProperty(serverListKey, hostAndPort(server1.getUrl("")) + "," + hostAndPort(server2.getUrl(""))); + + try { + + TestInterface api = Feign.create(TestInterface.class, "http://" + client, new RibbonModule()); + + api.post(); + api.post(); + + assertEquals(server1.getRequestCount(), 1); + assertEquals(server2.getRequestCount(), 1); + // TODO: verify ribbon stats match + // assertEquals(target.lb().getLoadBalancerStats().getSingleServerStat()) + } finally { + server1.shutdown(); + server2.shutdown(); + getConfigInstance().clearProperty(serverListKey); + } + } + + static String hostAndPort(URL url) { + // our build slaves have underscores in their hostnames which aren't permitted by ribbon + return "localhost:" + url.getPort(); + } +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000000..a7bf699763 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,6 @@ +rootProject.name='feign' +include 'core', 'gson', 'jaxrs', 'ribbon', 'example-github', 'example-wikipedia' + +rootProject.children.each { childProject -> + childProject.name = 'feign-' + childProject.name +}