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..8d8a96a17d --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,104 @@ +### Version 7.0 +* Expose reflective dispatch hook: InvocationHandlerFactory +* Add JAXB integration + +### Version 6.1.1 +* Fix for #85 + +### Version 6.1.0 +* Add [SLF4J](http://www.slf4j.org/) integration + +### Version 6.0.1 +* Fix for BasicAuthRequestInterceptor when username and/or password are long. + +### Version 6.0 +* Support binary request and response bodies. +* Don't throw http status code exceptions when return type is `Response`. + +### Version 5.4.0 +* Add `BasicAuthRequestInterceptor` +* Add Jackson integration + +### Version 5.3.0 +* Split `GsonCodec` into `GsonEncoder` and `GsonDecoder`, which are easy to use with `Feign.Builder` +* Deprecate `GsonCodec` +* Update to Ribbon 0.2.3 + +### Version 5.2.0 +* Support usage of `GsonCodec` via `Feign.Builder` + +### Version 5.1.0 +* Correctly handle IOExceptions wrapped by Ribbon. +* Miscellaneous findbugs fixes. + +### Version 5.0.1 +* `Decoder.decode()` is no longer called for `Response` or `void` types. + +### Version 5.0 +* Remove support for Observable methods. +* Use single non-generic Decoder/Encoder instead of sets of type-specific Decoders/Encoders. +* Decoders/Encoders are now more flexible, having access to the Response/RequestTemplate respectively. +* Moved SaxDecoder into `feign-sax` dependency. + * SaxDecoder now decodes multiple types. + * Remove pattern decoders in favor of SaxDecoder. +* Added Feign.Builder to simplify client customizations without using Dagger. +* Gson type adapters can be registered as Dagger set bindings. +* `Feign.create(...)` now requires specifying an encoder and decoder. + +### Version 4.4.1 +* Fix NullPointerException on calling equals and hashCode. + +### 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..64ec28bbe9 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), [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.builder() + .decoder(new GsonDecoder()) + .target(GitHub.class, "https://api.github.com"); + + // 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 + ")"); + } +} +``` + +### Customization + +Feign has several aspects that can be customized. For simple cases, you can use `Feign.builder()` to construct an API interface with your custom components. For example: + +```java +interface Bank { + @RequestLine("POST /account/{id}") + Account getAccountInfo(@Named("id") String id); +} +... +Bank bank = Feign.builder().decoder(new AccountDecoder()).target(Bank.class, "https://api.examplebank.com"); +``` + +For further flexibility, you can use Dagger modules directly. See the `Dagger` section for more details. + +### 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. + +```java +static class ForwardedForInterceptor implements RequestInterceptor { + @Override public void apply(RequestTemplate template) { + template.header("X-Forwarded-For", "origin.host.com"); + } +} +... +Bank bank = Feign.builder().decoder(accountDecoder).requestInterceptor(new ForwardedForInterceptor()).target(Bank.class, "https://api.examplebank.com"); +``` + +Another common example of an interceptor would be authentication, such as using the built-in `BasicAuthRequestInterceptor`. + +```java +Bank bank = Feign.builder().decoder(accountDecoder).requestInterceptor(new BasicAuthRequestInterceptor(username, password)).target(Bank.class, "https://api.examplebank.com"); +``` + +### 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.builder().target(new CloudIdentityTarget(user, apiKey)); +``` + +You can find [several examples](https://github.com/Netflix/feign/tree/master/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/gson) adds default encoders and decoders so you get get started with a JSON api. + +Add `GsonEncoder` and/or `GsonDecoder` to your `Feign.Builder` like so: + +```java +GsonCodec codec = new GsonCodec(); +GitHub github = Feign.builder() + .encoder(new GsonEncoder()) + .decoder(new GsonDecoder()) + .target(GitHub.class, "https://api.github.com"); +``` + +### Jackson +[JacksonModule](https://github.com/Netflix/feign/tree/master/jackson) adds an encoder and decoder you can use with a JSON API. + +Add `JacksonEncoder` and/or `JacksonDecoder` to your `Feign.Builder` like so: + +```java +GitHub github = Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .target(GitHub.class, "https://api.github.com"); +``` + +### Sax +[SaxDecoder](https://github.com/Netflix/feign/tree/master/sax) allows you to decode XML in a way that is compatible with normal JVM and also Android environments. + +Here's an example of how to configure Sax response parsing: +```java +api = Feign.builder() + .decoder(SAXDecoder.builder() + .registerContentHandler(UserIdHandler.class) + .build()) + .target(Api.class, "https://apihost"); +``` + +### JAXB +[JAXBModule](https://github.com/Netflix/feign/tree/master/jaxb) allows you to encode and decode XML using JAXB. + +Add `JAXBEncoder` and/or `JAXBDecoder` to your `Feign.Builder` like so: + +```java +api = Feign.builder() + .encoder(new JAXBEncoder()) + .decoder(new JAXBDecoder()) + .target(Api.class, "https://apihost"); +``` + +### JAX-RS +[JAXRSModule](https://github.com/Netflix/feign/tree/master/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/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()); +``` + +### SLF4J +[SLF4JModule](https://github.com/Netflix/feign/tree/master/slf4j) allows directing Feign's logging to [SLF4J](http://www.slf4j.org/), allowing you to easily use a logging backend of your choice (Logback, Log4J, etc.) + +To use SLF4J with Feign, add both the SLF4J module and an SLF4J binding of your choice to your classpath. Then, configure Feign to use the Slf4jLogger: + +```java +GitHub github = Feign.builder() + .logger(new Slf4jLogger()) + .target(GitHub.class, "https://api.github.com"); +``` + +### Decoders +`Feign.builder()` allows you to specify additional configuration such as how to decode a response. + +If any methods in your interface return types besides `Response`, `String`, `byte[]` or `void`, you'll need to configure a non-default `Decoder`. + +Here's how to configure JSON decoding (using the `feign-gson` extension): + +```java +GitHub github = Feign.builder() + .decoder(new GsonDecoder()) + .target(GitHub.class, "https://api.github.com"); +``` + +### Encoders +`Feign.builder()` allows you to specify additional configuration such as how to encode a request. + +If any methods in your interface use parameters types besides `String` or `byte[]`, you'll need to configure a non-default `Encoder`. + +Here's how to configure JSON encoding (using the `feign-gson` extension): + +```json +GitHub github = Feign.builder() + .encoder(new GsonEncoder()) + .target(GitHub.class, "https://api.github.com"); +``` + +### 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, `RequestInterceptor` bindings are of `Provider.Type.SET`, meaning you can have multiple interceptors. Here's an example of multiple interceptor bindings. +```java +@Provides(type = SET) RequestInterceptor forwardedForInterceptor() { + return new RequestInterceptor() { + @Override public void apply(RequestTemplate template) { + template.header("X-Forwarded-For", "origin.host.com"); + } + }; +} + +@Provides(type = SET) RequestInterceptor userAgentInterceptor() { + return new RequestInterceptor() { + @Override public void apply(RequestTemplate template) { + template.header("User-Agent", "My Cool Client"); + } + }; +} +``` +#### 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 +GitHub github = Feign.builder() + .decoder(new GsonDecoder()) + .logger(new Logger.JavaLogger().appendToFile("logs/http.log")) + .logLevel(Logger.Level.FULL) + .target(GitHub.class, "https://api.github.com"); +``` + +The SLF4JModule (see above) may also be of interest. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000000..39272bb145 --- /dev/null +++ b/build.gradle @@ -0,0 +1,156 @@ +// 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 { + if (JavaVersion.current().isJava8Compatible()) { + tasks.withType(Javadoc) { + options.addStringOption('Xdoclint:none', '-quiet') // Doclint is onerous in Java 8. + } + } + repositories { + mavenLocal() + mavenCentral() + maven { url 'https://oss.sonatype.org/content/repositories/releases/' } + } +} + +apply from: file('gradle/convention.gradle') +apply from: file('gradle/maven.gradle') +if (!JavaVersion.current().isJava8Compatible()) { + apply from: file('gradle/check.gradle') // FindBugs is incompatible with Java 8. +} +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-sax') { + apply plugin: 'java' + + test { + useTestNG() + } + + dependencies { + compile project(':feign-core') + testCompile 'com.google.guava:guava:14.0.1' + testCompile 'org.testng:testng:6.8.5' + } +} + +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-jackson') { + apply plugin: 'java' + + test { + useTestNG() + } + + dependencies { + compile project(':feign-core') + compile 'com.fasterxml.jackson.core:jackson-databind:2.2.2' + testCompile 'org.testng:testng:6.8.5' + testCompile 'com.google.guava:guava:14.0.1' + } +} + +project(':feign-jaxb') { + apply plugin: 'java' + + test { + useTestNG() + } + + dependencies { + compile project(':feign-core') + testCompile 'org.testng:testng:6.8.5' + testCompile 'com.google.guava:guava:14.0.1' + } +} + +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.4' + testCompile 'org.testng:testng:6.8.5' + testCompile 'com.google.mockwebserver:mockwebserver:20130706' + } +} + +project(':feign-slf4j') { + apply plugin: 'java' + + test { + useTestNG() + } + + dependencies { + compile project(':feign-core') + compile 'org.slf4j:slf4j-api:1.7.5' + testCompile 'org.testng:testng:6.8.5' + testCompile 'org.slf4j:slf4j-simple:1.7.5' + } +} 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..aab143daa1 --- /dev/null +++ b/core/src/main/java/feign/Client.java @@ -0,0 +1,147 @@ +/* + * 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.OutputStream; +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; + +/** + * 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()); + } 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(); + } + return Response.create(status, reason, headers, stream, 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..d9ac3bd110 --- /dev/null +++ b/core/src/main/java/feign/Contract.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 javax.inject.Named; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static feign.Util.checkState; +import static feign.Util.emptyToNull; + +/** + * 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)); + + 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(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; + String varName = '{' + name + '}'; + if (data.template().url().indexOf(varName) == -1 && + !searchMapValues(data.template().queries(), varName) && + !searchMapValues(data.template().headers(), varName)) { + data.formParams().add(name); + } + } + } + return isHttpAnnotation; + } + + private boolean searchMapValues(Map> map, V search) { + Collection> values = map.values(); + if (values == null) + return false; + + for (Collection entry : values) { + if (entry.contains(search)) + return true; + } + + return false; + } + } +} diff --git a/core/src/main/java/feign/Feign.java b/core/src/main/java/feign/Feign.java new file mode 100644 index 0000000000..cc5bd597e2 --- /dev/null +++ b/core/src/main/java/feign/Feign.java @@ -0,0 +1,312 @@ +/* + * 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.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 javax.inject.Inject; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 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 { + + /** + * 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 Builder builder() { + return new Builder(); + } + + 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") + // incomplete as missing Encoder/Decoder + @dagger.Module(injects = {Feign.class, Builder.class}, complete = false, includes = ReflectiveFeign.Module.class) + public static class Defaults { + @Provides Contract contract() { + return new Contract.Default(); + } + + @Provides Logger.Level logLevel() { + return Logger.Level.NONE; + } + + @Provides Logger noOp() { + return new NoOpLogger(); + } + + @Provides Retryer retryer() { + return new Retryer.Default(); + } + + @Provides ErrorDecoder errorDecoder() { + return new ErrorDecoder.Default(); + } + + @Provides Options options() { + return new Options(); + } + + @Provides SSLSocketFactory sslSocketFactory() { + return SSLSocketFactory.class.cast(SSLSocketFactory.getDefault()); + } + + @Provides HostnameVerifier hostnameVerifier() { + return HttpsURLConnection.getDefaultHostnameVerifier(); + } + + @Provides Client httpClient(Client.Default client) { + return client; + } + + @Provides InvocationHandlerFactory invocationHandlerFactory() { + return new InvocationHandlerFactory.Default(); + } + } + + /** + *
+ * 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(2); + modulesForGraph.add(new Defaults()); + if (modules != null) + for (Object module : modules) + modulesForGraph.add(module); + return modulesForGraph; + } + + @dagger.Module(injects = Feign.class, includes = ReflectiveFeign.Module.class) + public static class Builder { + private final Set requestInterceptors = new LinkedHashSet(); + @Inject Logger.Level logLevel; + @Inject Contract contract; + @Inject Client client; + @Inject Retryer retryer; + @Inject Logger logger; + Encoder encoder = new Encoder.Default(); + Decoder decoder = new Decoder.Default(); + @Inject ErrorDecoder errorDecoder; + @Inject Options options; + @Inject InvocationHandlerFactory invocationHandlerFactory; + + Builder() { + ObjectGraph.create(new Defaults()).inject(this); + } + + public Builder logLevel(Logger.Level logLevel) { + this.logLevel = logLevel; + return this; + } + + public Builder contract(Contract contract) { + this.contract = contract; + return this; + } + + public Builder client(Client client) { + this.client = client; + return this; + } + + public Builder retryer(Retryer retryer) { + this.retryer = retryer; + return this; + } + + public Builder logger(Logger logger) { + this.logger = logger; + return this; + } + + public Builder encoder(Encoder encoder) { + this.encoder = encoder; + return this; + } + + public Builder decoder(Decoder decoder) { + this.decoder = decoder; + return this; + } + + public Builder errorDecoder(ErrorDecoder errorDecoder) { + this.errorDecoder = errorDecoder; + return this; + } + + public Builder options(Options options) { + this.options = options; + return this; + } + + /** + * Adds a single request interceptor to the builder. + */ + public Builder requestInterceptor(RequestInterceptor requestInterceptor) { + this.requestInterceptors.add(requestInterceptor); + return this; + } + + /** + * Sets the full set of request interceptors for the builder, overwriting any previous interceptors. + */ + public Builder requestInterceptors(Iterable requestInterceptors) { + this.requestInterceptors.clear(); + for (RequestInterceptor requestInterceptor : requestInterceptors) { + this.requestInterceptors.add(requestInterceptor); + } + return this; + } + + /** Allows you to override how reflective dispatch works inside of Feign. */ + public Builder invocationHandlerFactory(InvocationHandlerFactory invocationHandlerFactory) { + this.invocationHandlerFactory = invocationHandlerFactory; + return this; + } + + public T target(Class apiType, String url) { + return target(new HardCodedTarget(apiType, url)); + } + + public T target(Target target) { + return ObjectGraph.create(this).get(Feign.class).newInstance(target); + } + + @Provides Logger.Level logLevel() { + return logLevel; + } + + @Provides Contract contract() { + return contract; + } + + @Provides Client client() { + return client; + } + + @Provides Retryer retryer() { + return retryer; + } + + @Provides Logger logger() { + return logger; + } + + @Provides Encoder encoder() { + return encoder; + } + + @Provides Decoder decoder() { + return decoder; + } + + @Provides ErrorDecoder errorDecoder() { + return errorDecoder; + } + + @Provides Options options() { + return options; + } + + @Provides(type = Provides.Type.SET_VALUES) Set requestInterceptors() { + return requestInterceptors; + } + + @Provides InvocationHandlerFactory invocationHandlerFactory() { + return invocationHandlerFactory; + } + } +} diff --git a/core/src/main/java/feign/FeignException.java b/core/src/main/java/feign/FeignException.java new file mode 100644 index 0000000000..b014d71130 --- /dev/null +++ b/core/src/main/java/feign/FeignException.java @@ -0,0 +1,56 @@ +/* + * 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; + +/** + * Origin exception type for all Http Apis. + */ +public class FeignException extends RuntimeException { + static FeignException errorReading(Request request, Response ignored, IOException cause) { + return new FeignException(format("%s %s %s", cause.getMessage(), request.method(), request.url()), cause); + } + + 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 = Util.toString(response.body().asReader()); + 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/InvocationHandlerFactory.java b/core/src/main/java/feign/InvocationHandlerFactory.java new file mode 100644 index 0000000000..cf8080492e --- /dev/null +++ b/core/src/main/java/feign/InvocationHandlerFactory.java @@ -0,0 +1,37 @@ +/* + * Copyright 2014 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.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.util.Map; + +/** Controls reflective method dispatch. */ +public interface InvocationHandlerFactory { + /** Like {@link InvocationHandler#invoke(Object, java.lang.reflect.Method, Object[])}, except for a single method. */ + interface MethodHandler { + Object invoke(Object[] argv) throws Throwable; + } + + InvocationHandler create(Target target, Map dispatch); + + static final class Default implements InvocationHandlerFactory { + @Override + public InvocationHandler create(Target target, Map dispatch) { + return new ReflectiveFeign.FeignInvocationHandler(target, dispatch); + } + } +} diff --git a/core/src/main/java/feign/Logger.java b/core/src/main/java/feign/Logger.java new file mode 100644 index 0000000000..c693f68eb1 --- /dev/null +++ b/core/src/main/java/feign/Logger.java @@ -0,0 +1,203 @@ +/* + * 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.PrintWriter; +import java.io.StringWriter; +import java.util.logging.FileHandler; +import java.util.logging.LogRecord; +import java.util.logging.SimpleFormatter; + +import static feign.Util.decodeOrDefault; +import static feign.Util.UTF_8; +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 protected void logRequest(String configKey, Level logLevel, Request request) { + if (logger.isLoggable(java.util.logging.Level.FINE)) { + super.logRequest(configKey, logLevel, request); + } + } + + @Override protected 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 protected void logRequest(String configKey, Level logLevel, Request request) { + } + + @Override protected 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); + + protected 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 bodyLength = 0; + if (request.body() != null) { + bodyLength = request.body().length; + if (logLevel.ordinal() >= Level.FULL.ordinal()) { + String bodyText = request.charset() != null ? new String(request.body(), request.charset()) : null; + log(configKey, ""); // CRLF + log(configKey, "%s", bodyText != null ? bodyText : "Binary data"); + } + } + log(configKey, "---> END HTTP (%s-byte body)", bodyLength); + } + } + + void logRetry(String configKey, Level logLevel) { + log(configKey, "---> RETRYING"); + } + + protected 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); + } + } + + int bodyLength = 0; + if (response.body() != null) { + if (logLevel.ordinal() >= Level.FULL.ordinal()) { + log(configKey, ""); // CRLF + } + byte[] bodyData = Util.toByteArray(response.body().asInputStream()); + bodyLength = bodyData.length; + if (logLevel.ordinal() >= Level.FULL.ordinal() && bodyLength > 0) { + log(configKey, "%s", decodeOrDefault(bodyData, UTF_8, "Binary data")); + } + log(configKey, "<--- END HTTP (%s-byte body)", bodyLength); + return Response.create(response.status(), response.reason(), response.headers(), bodyData); + } else { + log(configKey, "<--- END HTTP (%s-byte body)", bodyLength); + } + } + 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; + } + + protected 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/MethodMetadata.java b/core/src/main/java/feign/MethodMetadata.java new file mode 100644 index 0000000000..d2c8f3a5d2 --- /dev/null +++ b/core/src/main/java/feign/MethodMetadata.java @@ -0,0 +1,105 @@ +/* + * 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 Integer urlIndex; + 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; + } + + 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/ReflectiveFeign.java b/core/src/main/java/feign/ReflectiveFeign.java new file mode 100644 index 0000000000..5d8fe06841 --- /dev/null +++ b/core/src/main/java/feign/ReflectiveFeign.java @@ -0,0 +1,236 @@ +/* + * 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.Provides; +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Request.Options; +import feign.codec.Decoder; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; + +import javax.inject.Inject; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import static feign.Util.checkArgument; +import static feign.Util.checkNotNull; + +@SuppressWarnings("rawtypes") +public class ReflectiveFeign extends Feign { + + private final ParseHandlersByName targetToHandlersByName; + private final InvocationHandlerFactory factory; + + @Inject ReflectiveFeign(ParseHandlersByName targetToHandlersByName, InvocationHandlerFactory factory) { + this.targetToHandlersByName = targetToHandlersByName; + this.factory = factory; + } + + /** + * 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))); + } + InvocationHandler handler = factory.create(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 dispatch; + + FeignInvocationHandler(Target target, Map dispatch) { + this.target = checkNotNull(target, "target"); + this.dispatch = checkNotNull(dispatch, "dispatch for %s", target); + } + + @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if ("equals".equals(method.getName())) { + try { + Object otherHandler = args.length > 0 && args[0] != null ? Proxy.getInvocationHandler(args[0]) : null; + return equals(otherHandler); + } catch (IllegalArgumentException e) { + return false; + } + } + if ("hashCode".equals(method.getName())) { + return hashCode(); + } + return dispatch.get(method).invoke(args); + } + + @Override public int hashCode() { + return target.hashCode(); + } + + @Override public boolean equals(Object other) { + if (other instanceof FeignInvocationHandler) { + FeignInvocationHandler that = (FeignInvocationHandler) other; + return this.target.equals(that.target); + } + return false; + } + + @Override public String toString() { + return "target(" + target + ")"; + } + } + + @dagger.Module(complete = false, injects = {Feign.class, SynchronousMethodHandler.Factory.class}, library = true) + public static class Module { + @Provides(type = Provides.Type.SET_VALUES) Set noRequestInterceptors() { + return Collections.emptySet(); + } + + @Provides Feign provideFeign(ReflectiveFeign in) { + return in; + } + } + + static final class ParseHandlersByName { + private final Contract contract; + private final Options options; + private final Encoder encoder; + private final Decoder decoder; + private final ErrorDecoder errorDecoder; + private final SynchronousMethodHandler.Factory factory; + + @SuppressWarnings("unchecked") + @Inject ParseHandlersByName(Contract contract, Options options, Encoder encoder, Decoder decoder, + ErrorDecoder errorDecoder, SynchronousMethodHandler.Factory factory) { + this.contract = contract; + this.options = options; + this.factory = factory; + this.errorDecoder = errorDecoder; + this.encoder = checkNotNull(encoder, "encoder"); + this.decoder = checkNotNull(decoder, "decoder"); + } + + 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) { + buildTemplate = new BuildFormEncodedTemplateFromArgs(md, encoder); + } else if (md.bodyIndex() != null) { + buildTemplate = new BuildEncodedTemplateFromArgs(md, encoder); + } else { + buildTemplate = new BuildTemplateByResolvingArgs(md); + } + result.put(md.configKey(), factory.create(key, md, buildTemplate, options, decoder, errorDecoder)); + } + return result; + } + } + + private static class BuildTemplateByResolvingArgs implements RequestTemplate.Factory { + protected final MethodMetadata metadata; + + private BuildTemplateByResolvingArgs(MethodMetadata metadata) { + this.metadata = metadata; + } + + @Override public RequestTemplate create(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 encoder; + + private BuildFormEncodedTemplateFromArgs(MethodMetadata metadata, Encoder encoder) { + super(metadata); + this.encoder = encoder; + } + + @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 { + encoder.encode(formVariables, mutable); + } 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 encoder; + + private BuildEncodedTemplateFromArgs(MethodMetadata metadata, Encoder 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 { + encoder.encode(body, mutable); + } 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..76d0f54f59 --- /dev/null +++ b/core/src/main/java/feign/Request.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; + +import java.nio.charset.Charset; +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. + */ +public final class Request { + + private final String method; + private final String url; + private final Map> headers; + private final byte[] body; + private final Charset charset; + + Request(String method, String url, Map> headers, byte[] body, Charset charset) { + 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 + this.charset = charset; // 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; + } + + /** + * The character set with which the body is encoded, or null if unknown or not applicable. When this is + * present, you can use {@code new String(req.body(), req.charset())} to access the body as a String. + */ + public Charset charset() { + return charset; + } + + /** + * If present, this is the replayable body to send to the server. In some cases, this may be interpretable as text. + * + * @see #charset() + */ + public byte[] 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(charset != null ? new String(body, charset) : "Binary data"); + } + 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..42c6b9046e --- /dev/null +++ b/core/src/main/java/feign/RequestTemplate.java @@ -0,0 +1,604 @@ +/* + * 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.nio.charset.Charset; +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 { + + interface Factory { + /** create a request template using args passed to a method invocation. */ + RequestTemplate create(Object[] argv); + } + + 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 transient Charset charset; + private byte[] 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.charset = toCopy.charset; + 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, charset); + } + + 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(byte[] bodyData, Charset charset) { + this.bodyTemplate = null; + this.charset = charset; + this.body = bodyData; + int bodyLength = bodyData != null ? bodyData.length : 0; + header(CONTENT_LENGTH, String.valueOf(bodyLength)); + return this; + } + + /** + * replaces the {@link feign.Util#CONTENT_LENGTH} header. + *
+ * Usually populated by an {@link feign.codec.Encoder}. + * + * @see Request#body() + */ + public RequestTemplate body(String bodyText) { + byte[] bodyData = bodyText != null ? bodyText.getBytes(UTF_8) : null; + return body(bodyData, UTF_8); + } + + /** + * The character set with which the body is encoded, or null if unknown or not applicable. When this is + * present, you can use {@code new String(req.body(), req.charset())} to access the body as a String. + */ + public Charset charset() { + return charset; + } + + /** + * @see Request#body() + */ + public byte[] body() { + return body; + } + + /** + * populated by {@link Body} + * + * @see Request#body() + */ + public RequestTemplate bodyTemplate(String bodyTemplate) { + this.bodyTemplate = bodyTemplate; + this.charset = null; + 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 URL, this will extract them out. + */ + 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(); + } + //Since we decode all queries, we want to use the + //query()-method to re-add them to ensure that all + //logic (such as url-encoding) are executed, giving + //a valid queryLine() + for(String key : firstQueries.keySet()) { + Collection values = firstQueries.get(key); + if(allValuesAreNull(values)) { + //Queryies where all values are null will + //be ignored by the query(key, value)-method + //So we manually avoid this case here, to ensure that + //we still fulfill the contract (ex. parameters without values) + queries.put(urlEncode(key), values); + } + else { + query(key, values); + } + + } + return new StringBuilder(url.substring(0, queryIndex)); + } + return url; + } + + private boolean allValuesAreNull(Collection values) { + if(values.isEmpty()) return true; + for(String val : values) { + if(val != null) return false; + } + return true; + } + + 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..2324254d74 --- /dev/null +++ b/core/src/main/java/feign/Response.java @@ -0,0 +1,218 @@ +/* + * 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.ByteArrayInputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.Charset; +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.decodeOrDefault; +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, + InputStream inputStream, Integer length) { + return new Response(status, reason, headers, InputStreamBody.orNull(inputStream, length)); + } + + public static Response create(int status, String reason, Map> headers, + byte[] data) { + return new Response(status, reason, headers, ByteArrayBody.orNull(data)); + } + + public static Response create(int status, String reason, Map> headers, + String text, Charset charset) { + return new Response(status, reason, headers, ByteArrayBody.orNull(text, charset)); + } + + 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 #asInputStream()} and {@link #asReader()} can be called more than once. + */ + boolean isRepeatable(); + + /** + * It is the responsibility of the caller to close the stream. + */ + InputStream asInputStream() throws IOException; + + /** + * It is the responsibility of the caller to close the stream. + */ + Reader asReader() throws IOException; + } + + private static final class InputStreamBody implements Response.Body { + private static Body orNull(InputStream inputStream, Integer length) { + if (inputStream == null) { + return null; + } + return new InputStreamBody(inputStream, length); + } + + private final InputStream inputStream; + private final Integer length; + + private InputStreamBody(InputStream inputStream, Integer length) { + this.inputStream = inputStream; + this.length = length; + } + + @Override public Integer length() { + return length; + } + + @Override public boolean isRepeatable() { + return false; + } + + @Override public InputStream asInputStream() throws IOException { + return inputStream; + } + + @Override public Reader asReader() throws IOException { + return new InputStreamReader(inputStream, UTF_8); + } + + @Override public void close() throws IOException { + inputStream.close(); + } + } + + private static final class ByteArrayBody implements Response.Body { + private static Body orNull(byte[] data) { + if (data == null) { + return null; + } + return new ByteArrayBody(data); + } + + private static Body orNull(String text, Charset charset) { + if (text == null) { + return null; + } + checkNotNull(charset, "charset"); + return new ByteArrayBody(text.getBytes(charset)); + } + + private final byte[] data; + + public ByteArrayBody(byte[] data) { + this.data = data; + } + + @Override public Integer length() { + return data.length; + } + + @Override public boolean isRepeatable() { + return true; + } + + @Override public InputStream asInputStream() throws IOException { + return new ByteArrayInputStream(data); + } + + @Override public Reader asReader() throws IOException { + return new InputStreamReader(asInputStream(), UTF_8); + } + + @Override public void close() throws IOException { + } + + @Override public String toString() { + return decodeOrDefault(data, UTF_8, "Binary data"); + } + } + + @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..d812cbc1e3 --- /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 Long 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 != null ? retryAfter.getTime() : null; + } + + /** + * @param retryAfter usually corresponds to the {@link feign.Util#RETRY_AFTER} + * header. + */ + public RetryableException(String message, Date retryAfter) { + super(message); + this.retryAfter = retryAfter != null ? retryAfter.getTime() : null; + } + + /** + * 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 != null ? new Date(retryAfter) : null; + } +} 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/SynchronousMethodHandler.java b/core/src/main/java/feign/SynchronousMethodHandler.java new file mode 100644 index 0000000000..83c102da45 --- /dev/null +++ b/core/src/main/java/feign/SynchronousMethodHandler.java @@ -0,0 +1,176 @@ +/* + * Copyright 2014 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.InvocationHandlerFactory.MethodHandler; +import feign.Request.Options; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; + +import javax.inject.Inject; +import javax.inject.Provider; +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static feign.FeignException.errorExecuting; +import static feign.FeignException.errorReading; +import static feign.Util.checkNotNull; +import static feign.Util.ensureClosed; + +final class SynchronousMethodHandler implements MethodHandler { + + static class Factory { + + private final Client client; + private final Provider retryer; + private final Set requestInterceptors; + private final Logger logger; + private final Provider logLevel; + + @Inject Factory(Client client, Provider retryer, Set requestInterceptors, + Logger logger, Provider logLevel) { + this.client = checkNotNull(client, "client"); + 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, RequestTemplate.Factory buildTemplateFromArgs, + Options options, Decoder decoder, ErrorDecoder errorDecoder) { + return new SynchronousMethodHandler(target, client, retryer, requestInterceptors, logger, logLevel, md, + buildTemplateFromArgs, options, decoder, errorDecoder); + } + } + + private final MethodMetadata metadata; + private final Target target; + private final Client client; + private final Provider retryer; + private final Set requestInterceptors; + private final Logger logger; + private final Provider logLevel; + private final RequestTemplate.Factory buildTemplateFromArgs; + private final Options options; + private final Decoder decoder; + private final ErrorDecoder errorDecoder; + + private SynchronousMethodHandler(Target target, Client client, Provider retryer, + Set requestInterceptors, Logger logger, + Provider logLevel, MethodMetadata metadata, + RequestTemplate.Factory buildTemplateFromArgs, Options options, + Decoder decoder, 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); + this.decoder = checkNotNull(decoder, "decoder for %s", target); + } + + @Override public Object invoke(Object[] argv) throws Throwable { + RequestTemplate template = buildTemplateFromArgs.create(argv); + Retryer retryer = this.retryer.get(); + while (true) { + try { + return executeAndDecode(template); + } catch (RetryableException e) { + retryer.continueOrPropagate(e); + if (logLevel.get() != Logger.Level.NONE) { + logger.logRetry(metadata.configKey(), logLevel.get()); + } + continue; + } + } + } + + Object executeAndDecode(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) { + if (Response.class == metadata.returnType()) { + if (response.body() == null) { + return response; + } + // Ensure the response body is disconnected + byte[] bodyData = Util.toByteArray(response.body().asInputStream()); + return Response.create(response.status(), response.reason(), response.headers(), bodyData); + } else if (void.class == metadata.returnType()) { + return null; + } else { + return decode(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()); + } + } + + long elapsedTime(long start) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + } + + Request targetRequest(RequestTemplate template) { + for (RequestInterceptor interceptor : requestInterceptors) { + interceptor.apply(template); + } + return target.apply(new RequestTemplate(template)); + } + + Object decode(Response response) throws Throwable { + try { + return decoder.decode(response, metadata.returnType()); + } catch (FeignException e) { + throw e; + } catch (RuntimeException e) { + throw new DecodeException(e.getMessage(), e); + } + } +} diff --git a/core/src/main/java/feign/Target.java b/core/src/main/java/feign/Target.java new file mode 100644 index 0000000000..894855d472 --- /dev/null +++ b/core/src/main/java/feign/Target.java @@ -0,0 +1,113 @@ +/* + * 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 (obj == null) + return false; + 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..2b847fa6c6 --- /dev/null +++ b/core/src/main/java/feign/Util.java @@ -0,0 +1,246 @@ +/* + * 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.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.lang.reflect.Array; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +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"); + /** + * ISO-8859-1: ISO Latin Alphabet Number 1 (ISO-LATIN-1). + */ + public static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1"); + + /** + * 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(Closeable closeable) { + if (closeable != null) { + try { + closeable.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]; + } + + private static final int BUF_SIZE = 0x800; // 2K chars (4K bytes) + + /** + * Adapted from {@code com.google.common.io.CharStreams.toString()}. + */ + public static String toString(Reader reader) throws IOException { + if (reader == null) { + return null; + } + try { + StringBuilder to = new StringBuilder(); + CharBuffer buf = CharBuffer.allocate(BUF_SIZE); + while (reader.read(buf) != -1) { + buf.flip(); + to.append(buf); + buf.clear(); + } + return to.toString(); + } finally { + ensureClosed(reader); + } + } + + /** + * Adapted from {@code com.google.common.io.ByteStreams.toByteArray()}. + */ + public static byte[] toByteArray(InputStream in) throws IOException { + checkNotNull(in, "in"); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + copy(in, out); + return out.toByteArray(); + } finally { + ensureClosed(in); + } + } + + /** + * Adapted from {@code com.google.common.io.ByteStreams.copy()}. + */ + private static long copy(InputStream from, OutputStream to) + throws IOException { + checkNotNull(from, "from"); + checkNotNull(to, "to"); + byte[] buf = new byte[BUF_SIZE]; + long total = 0; + while (true) { + int r = from.read(buf); + if (r == -1) { + break; + } + to.write(buf, 0, r); + total += r; + } + return total; + } + + static String decodeOrDefault(byte[] data, Charset charset, String defaultValue) { + if (data == null) { + return defaultValue; + } + checkNotNull(charset, "charset"); + try { + return charset.newDecoder().decode(ByteBuffer.wrap(data)).toString(); + } catch (CharacterCodingException ex) { + return defaultValue; + } + } +} diff --git a/core/src/main/java/feign/auth/Base64.java b/core/src/main/java/feign/auth/Base64.java new file mode 100644 index 0000000000..f75c092faf --- /dev/null +++ b/core/src/main/java/feign/auth/Base64.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.auth; + +import java.io.UnsupportedEncodingException; + +/** + * copied from okhttp + * @author Alexander Y. Kleymenov + */ +final class Base64 { + + public static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + + private Base64() { + } + + public static byte[] decode(byte[] in) { + return decode(in, in.length); + } + + public static byte[] decode(byte[] in, int len) { + // approximate output length + int length = len / 4 * 3; + // return an empty array on empty or short input without padding + if (length == 0) { + return EMPTY_BYTE_ARRAY; + } + // temporary array + byte[] out = new byte[length]; + // number of padding characters ('=') + int pad = 0; + byte chr; + // compute the number of the padding characters + // and adjust the length of the input + for (; ; len--) { + chr = in[len - 1]; + // skip the neutral characters + if ((chr == '\n') || (chr == '\r') || (chr == ' ') || (chr == '\t')) { + continue; + } + if (chr == '=') { + pad++; + } else { + break; + } + } + // index in the output array + int outIndex = 0; + // index in the input array + int inIndex = 0; + // holds the value of the input character + int bits = 0; + // holds the value of the input quantum + int quantum = 0; + for (int i = 0; i < len; i++) { + chr = in[i]; + // skip the neutral characters + if ((chr == '\n') || (chr == '\r') || (chr == ' ') || (chr == '\t')) { + continue; + } + if ((chr >= 'A') && (chr <= 'Z')) { + // char ASCII value + // A 65 0 + // Z 90 25 (ASCII - 65) + bits = chr - 65; + } else if ((chr >= 'a') && (chr <= 'z')) { + // char ASCII value + // a 97 26 + // z 122 51 (ASCII - 71) + bits = chr - 71; + } else if ((chr >= '0') && (chr <= '9')) { + // char ASCII value + // 0 48 52 + // 9 57 61 (ASCII + 4) + bits = chr + 4; + } else if (chr == '+') { + bits = 62; + } else if (chr == '/') { + bits = 63; + } else { + return null; + } + // append the value to the quantum + quantum = (quantum << 6) | (byte) bits; + if (inIndex % 4 == 3) { + // 4 characters were read, so make the output: + out[outIndex++] = (byte) (quantum >> 16); + out[outIndex++] = (byte) (quantum >> 8); + out[outIndex++] = (byte) quantum; + } + inIndex++; + } + if (pad > 0) { + // adjust the quantum value according to the padding + quantum = quantum << (6 * pad); + // make output + out[outIndex++] = (byte) (quantum >> 16); + if (pad == 1) { + out[outIndex++] = (byte) (quantum >> 8); + } + } + // create the resulting array + byte[] result = new byte[outIndex]; + System.arraycopy(out, 0, result, 0, outIndex); + return result; + } + + private static final byte[] MAP = new byte[] { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', + 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', + 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', + '5', '6', '7', '8', '9', '+', '/' + }; + + public static String encode(byte[] in) { + int length = (in.length + 2) * 4 / 3; + byte[] out = new byte[length]; + int index = 0, end = in.length - in.length % 3; + for (int i = 0; i < end; i += 3) { + out[index++] = MAP[(in[i] & 0xff) >> 2]; + out[index++] = MAP[((in[i] & 0x03) << 4) | ((in[i + 1] & 0xff) >> 4)]; + out[index++] = MAP[((in[i + 1] & 0x0f) << 2) | ((in[i + 2] & 0xff) >> 6)]; + out[index++] = MAP[(in[i + 2] & 0x3f)]; + } + switch (in.length % 3) { + case 1: + out[index++] = MAP[(in[end] & 0xff) >> 2]; + out[index++] = MAP[(in[end] & 0x03) << 4]; + out[index++] = '='; + out[index++] = '='; + break; + case 2: + out[index++] = MAP[(in[end] & 0xff) >> 2]; + out[index++] = MAP[((in[end] & 0x03) << 4) | ((in[end + 1] & 0xff) >> 4)]; + out[index++] = MAP[((in[end + 1] & 0x0f) << 2)]; + out[index++] = '='; + break; + } + try { + return new String(out, 0, index, "US-ASCII"); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } +} + diff --git a/core/src/main/java/feign/auth/BasicAuthRequestInterceptor.java b/core/src/main/java/feign/auth/BasicAuthRequestInterceptor.java new file mode 100644 index 0000000000..318f36f117 --- /dev/null +++ b/core/src/main/java/feign/auth/BasicAuthRequestInterceptor.java @@ -0,0 +1,69 @@ +/* + * 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.auth; + +import feign.RequestInterceptor; +import feign.RequestTemplate; + +import java.nio.charset.Charset; + +import static feign.Util.checkNotNull; +import static feign.Util.ISO_8859_1; + +/** + * An interceptor that adds the request header needed to use HTTP basic authentication. + */ +public class BasicAuthRequestInterceptor implements RequestInterceptor { + private final String headerValue; + + /** + * Creates an interceptor that authenticates all requests with the specified username and password encoded using + * ISO-8859-1. + * + * @param username the username to use for authentication + * @param password the password to use for authentication + */ + public BasicAuthRequestInterceptor(String username, String password) { + this(username, password, ISO_8859_1); + } + + /** + * Creates an interceptor that authenticates all requests with the specified username and password encoded using + * the specified charset. + * + * @param username the username to use for authentication + * @param password the password to use for authentication + * @param charset the charset to use when encoding the credentials + */ + public BasicAuthRequestInterceptor(String username, String password, Charset charset) { + checkNotNull(username, "username"); + checkNotNull(password, "password"); + this.headerValue = "Basic " + base64Encode((username + ":" + password).getBytes(charset)); + } + + @Override public void apply(RequestTemplate template) { + template.header("Authorization", headerValue); + } + + /* + * This uses a Sun internal method; if we ever encounter a case where this method is not available, the appropriate + * response would be to pull the necessary portions of Guava's BaseEncoding class into Util. + */ + private static String base64Encode(byte[] bytes) { + return Base64.encode(bytes); + } +} + 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..1671bbdb60 --- /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 does it 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..346b149bfb --- /dev/null +++ b/core/src/main/java/feign/codec/Decoder.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.codec; + +import feign.FeignException; +import feign.Response; +import feign.Util; + +import java.io.IOException; +import java.lang.reflect.Type; + +/** + * Decodes an HTTP response into a single object of the given {@code type}. Invoked when + * {@link Response#status()} is in the 2xx range and the return type is neither {@code void} nor {@code Response}. + *

+ *

+ * Example Implementation:
+ *

+ *

+ * public class GsonDecoder implements Decoder {
+ *   private final Gson gson = new Gson();
+ *
+ *   @Override
+ *   public Object decode(Response response, Type type) throws IOException {
+ *     try {
+ *       return gson.fromJson(response.body().asReader(), type);
+ *     } catch (JsonIOException e) {
+ *       if (e.getCause() != null &&
+ *           e.getCause() instanceof IOException) {
+ *         throw IOException.class.cast(e.getCause());
+ *       }
+ *       throw e;
+ *     }
+ *   }
+ * }
+ * 
+ *
+ *

Implementation Note

+ * The {@code type} parameter will correspond to the + * {@link java.lang.reflect.Method#getGenericReturnType() generic return type} + * of an {@link feign.Target#type() interface} processed by + * {@link feign.Feign#newInstance(feign.Target)}. When writing your + * implementation of Decoder, ensure you also test parameterized types such as + * {@code List}. + * + */ +public interface Decoder { + /** + * Decodes an http response into an object corresponding to its + * {@link java.lang.reflect.Method#getGenericReturnType() generic return type}. + * If you need to wrap exceptions, please do so via {@link DecodeException}. + * + * @param response the response to decode + * @param type {@link java.lang.reflect.Method#getGenericReturnType() generic return type} + * of the method corresponding to this {@code response}. + * @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. + */ + Object decode(Response response, Type type) throws IOException, DecodeException, FeignException; + + /** + * Default implementation of {@code Decoder}. + */ + public class Default extends StringDecoder { + @Override + public Object decode(Response response, Type type) throws IOException { + Response.Body body = response.body(); + if (body == null) { + return null; + } + if (byte[].class.equals(type)) { + return Util.toByteArray(body.asInputStream()); + } + return super.decode(response, type); + } + } +} 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..bc9c660ca0 --- /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 encoding a message. Note that {@code EncodeException} is not an + * {@code IOException}, nor does it 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..c3b07d591a --- /dev/null +++ b/core/src/main/java/feign/codec/Encoder.java @@ -0,0 +1,86 @@ +/* + * 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.RequestTemplate; + +import static java.lang.String.format; + +/** + * 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);
+ * 
+ * Example implementation:
+ *

+ *

+ * public class GsonEncoder implements Encoder {
+ *   private final Gson gson;
+ *
+ *   public GsonEncoder(Gson gson) {
+ *     this.gson = gson;
+ *   }
+ *
+ *   @Override
+ *   public void encode(Object object, RequestTemplate template) {
+ *     template.body(gson.toJson(object));
+ *   }
+ * }
+ * 
+ * + *

+ *

Form encoding

+ *
+ * If any parameters are found in {@link feign.MethodMetadata#formParams()}, they will be + * collected and passed to the Encoder as a {@code Map}. + *
+ *
+ * @POST
+ * @Path("/")
+ * Session login(@Named("username") String username, @Named("password") String password);
+ * 
+ */ +public interface Encoder { + /** + * Converts objects to an appropriate representation in the template. + * + * @param object what to encode as the request body. + * @param template the request template to populate. + * @throws EncodeException when encoding failed due to a checked exception. + */ + void encode(Object object, RequestTemplate template) throws EncodeException; + + /** + * Default implementation of {@code Encoder}. + */ + public class Default implements Encoder { + @Override + public void encode(Object object, RequestTemplate template) throws EncodeException { + if (object instanceof String) { + template.body(object.toString()); + } else if (object instanceof byte[]) { + template.body((byte[]) object, null); + } else if (object != null) { + throw new EncodeException(format("%s is not a type supported by this encoder.", object.getClass())); + } + } + } +} 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/StringDecoder.java b/core/src/main/java/feign/codec/StringDecoder.java new file mode 100644 index 0000000000..ae35eca978 --- /dev/null +++ b/core/src/main/java/feign/codec/StringDecoder.java @@ -0,0 +1,38 @@ +/* + * 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.Response; +import feign.Util; + +import java.io.IOException; +import java.lang.reflect.Type; + +import static java.lang.String.format; + +public class StringDecoder implements Decoder { + @Override + public Object decode(Response response, Type type) throws IOException { + Response.Body body = response.body(); + if (body == null) { + return null; + } + if (String.class.equals(type)) { + return Util.toString(body.asReader()); + } + throw new DecodeException(format("%s is not a type supported by this decoder.", 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..e268fb7f77 --- /dev/null +++ b/core/src/test/java/feign/DefaultContractTest.java @@ -0,0 +1,243 @@ +/* + * 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.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; + +import static feign.Util.UTF_8; + +/** + * 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 { + String expectedBody = ""; + MethodMetadata md = contract.parseAndValidatateMetadata(BodyWithoutParameters.class.getDeclaredMethod("post")); + assertEquals(md.template().body(), expectedBody.getBytes(UTF_8)); + 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")); + } +} 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/FeignBuilderTest.java b/core/src/test/java/feign/FeignBuilderTest.java new file mode 100644 index 0000000000..c9a3ef8f20 --- /dev/null +++ b/core/src/test/java/feign/FeignBuilderTest.java @@ -0,0 +1,159 @@ +/* + * 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.mockwebserver.MockResponse; +import com.google.mockwebserver.MockWebServer; +import com.google.mockwebserver.RecordedRequest; +import feign.codec.Decoder; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import org.testng.annotations.Test; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.testng.Assert.assertEquals; + +public class FeignBuilderTest { + interface TestInterface { + @RequestLine("POST /") Response codecPost(String data); + + @RequestLine("POST /") void encodedPost(List data); + + @RequestLine("POST /") String decodedPost(); + } + + @Test public void testDefaults() throws Exception { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("response data")); + server.play(); + + String url = "http://localhost:" + server.getPort(); + try { + TestInterface api = Feign.builder().target(TestInterface.class, url); + Response response = api.codecPost("request data"); + assertEquals(Util.toString(response.body().asReader()), "response data"); + } finally { + server.shutdown(); + assertEquals(server.getRequestCount(), 1); + assertEquals(server.takeRequest().getUtf8Body(), "request data"); + } + } + + @Test public void testOverrideEncoder() throws Exception { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("response data")); + server.play(); + + String url = "http://localhost:" + server.getPort(); + Encoder encoder = new Encoder() { + @Override + public void encode(Object object, RequestTemplate template) throws EncodeException { + template.body(object.toString()); + } + }; + try { + TestInterface api = Feign.builder().encoder(encoder).target(TestInterface.class, url); + api.encodedPost(Arrays.asList("This", "is", "my", "request")); + } finally { + server.shutdown(); + assertEquals(server.getRequestCount(), 1); + assertEquals(server.takeRequest().getUtf8Body(), "[This, is, my, request]"); + } + } + + @Test public void testOverrideDecoder() throws Exception { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("success!")); + server.play(); + + String url = "http://localhost:" + server.getPort(); + Decoder decoder = new Decoder() { + @Override + public Object decode(Response response, Type type) { + return "fail"; + } + }; + + try { + TestInterface api = Feign.builder().decoder(decoder).target(TestInterface.class, url); + assertEquals(api.decodedPost(), "fail"); + } finally { + server.shutdown(); + assertEquals(server.getRequestCount(), 1); + } + } + + @Test public void testProvideRequestInterceptors() throws Exception { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("response data")); + server.play(); + + String url = "http://localhost:" + server.getPort(); + RequestInterceptor requestInterceptor = new RequestInterceptor() { + @Override + public void apply(RequestTemplate template) { + template.header("Content-Type", "text/plain"); + } + }; + try { + TestInterface api = Feign.builder().requestInterceptor(requestInterceptor).target(TestInterface.class, url); + Response response = api.codecPost("request data"); + assertEquals(Util.toString(response.body().asReader()), "response data"); + } finally { + server.shutdown(); + assertEquals(server.getRequestCount(), 1); + RecordedRequest request = server.takeRequest(); + assertEquals(request.getUtf8Body(), "request data"); + assertEquals(request.getHeader("Content-Type"), "text/plain"); + } + } + + @Test public void testProvideInvocationHandlerFactory() throws Exception { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("response data")); + server.play(); + + String url = "http://localhost:" + server.getPort(); + + final AtomicInteger callCount = new AtomicInteger(); + InvocationHandlerFactory factory = new InvocationHandlerFactory() { + private final InvocationHandlerFactory delegate = new Default(); + @Override public InvocationHandler create(Target target, Map dispatch) { + callCount.incrementAndGet(); + return delegate.create(target, dispatch); + } + }; + + try { + TestInterface api = Feign.builder().invocationHandlerFactory(factory).target(TestInterface.class, url); + Response response = api.codecPost("request data"); + assertEquals(Util.toString(response.body().asReader()), "response data"); + assertEquals(callCount.get(), 1); + } finally { + server.shutdown(); + assertEquals(server.getRequestCount(), 1); + RecordedRequest request = server.takeRequest(); + assertEquals(request.getUtf8Body(), "request data"); + } + } +} diff --git a/core/src/test/java/feign/FeignTest.java b/core/src/test/java/feign/FeignTest.java new file mode 100644 index 0000000000..801422e255 --- /dev/null +++ b/core/src/test/java/feign/FeignTest.java @@ -0,0 +1,539 @@ +/* + * 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.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.lang.reflect.Type; +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; + +import static dagger.Provides.Type.SET; +import static feign.Util.UTF_8; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +@Test +// unbound wildcards are not currently injectable in dagger. +@SuppressWarnings("rawtypes") +public class FeignTest { + + interface TestInterface { + @RequestLine("POST /") Response response(); + + @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); + + @dagger.Module(injects = Feign.class, addsTo = Feign.Defaults.class) + static class Module { + @Provides Decoder defaultDecoder() { + return new Decoder.Default(); + } + + @Provides Encoder defaultEncoder() { + return new Encoder() { + @Override public void encode(Object object, RequestTemplate template) { + if (object instanceof Map) { + template.body(Joiner.on(',').withKeyValueSeparator("=").join((Map) object)); + } else { + template.body(object.toString()); + } + } + }; + } + } + } + + @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(); + } + } + + interface OtherTestInterface { + @RequestLine("POST /") String post(); + + @RequestLine("POST /") byte[] binaryResponseBody(); + + @RequestLine("POST /") void binaryRequestBody(byte[] contents); + } + + @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 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(), UTF_8), + "{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"}"); + } finally { + server.shutdown(); + } + } + + @Test + public void responseCoercesToStringBody() 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()); + + Response response = api.response(); + assertTrue(response.body().isRepeatable()); + assertEquals(response.body().toString(), "foo"); + } 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(), UTF_8), + "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(), UTF_8), "[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(UTF_8))); + 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 Decoder decoder() { + return new Decoder() { + @Override + public Object decode(Response response, Type type) { + return "fail"; + } + }; + } + } + + public void overrideTypeSpecificDecoder() throws IOException, InterruptedException { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("success!".getBytes(UTF_8))); + 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 Decoder decoder() { + return new StringDecoder() { + @Override + public Object decode(Response response, Type type) throws IOException, FeignException { + String string = super.decode(response, type).toString(); + 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(UTF_8))); + server.enqueue(new MockResponse().setBody("success!".getBytes(UTF_8))); + 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 Decoder decoder() { + return new Decoder() { + @Override + public Object decode(Response response, 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(UTF_8))); + 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(overrides = true, includes = TestInterface.Module.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(UTF_8))); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "https://localhost:" + server.getPort(), + new TrustSSLSockets()); + api.post(); + } finally { + server.shutdown(); + } + } + + @Module(overrides = true, includes = TrustSSLSockets.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(UTF_8))); + server.play(); + + try { + TestInterface api = Feign.create(TestInterface.class, "https://localhost:" + server.getPort(), + 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(UTF_8))); + 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(); + } + } + + @Test public void equalsAndHashCodeWork() { + TestInterface i1 = Feign.create(TestInterface.class, "http://localhost:8080", new TestInterface.Module()); + TestInterface i2 = Feign.create(TestInterface.class, "http://localhost:8080", new TestInterface.Module()); + TestInterface i3 = Feign.create(TestInterface.class, "http://localhost:8888", new TestInterface.Module()); + OtherTestInterface i4 = Feign.create(OtherTestInterface.class, "http://localhost:8080", new TestInterface.Module()); + + assertTrue(i1.equals(i1)); + assertTrue(i1.equals(i2)); + assertFalse(i1.equals(i3)); + assertFalse(i1.equals(i4)); + + assertEquals(i1.hashCode(), i1.hashCode()); + assertEquals(i1.hashCode(), i2.hashCode()); + } + + @Test public void decodeLogicSupportsByteArray() throws Exception { + byte[] expectedResponse = {12, 34, 56}; + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody(expectedResponse)); + server.play(); + + try { + OtherTestInterface api = Feign.builder().target(OtherTestInterface.class, "http://localhost:" + server.getPort()); + + byte[] actualResponse = api.binaryResponseBody(); + assertEquals(actualResponse, expectedResponse); + } finally { + server.shutdown(); + } + } + + @Test public void encodeLogicSupportsByteArray() throws Exception { + byte[] expectedRequest = {12, 34, 56}; + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse()); + server.play(); + + try { + OtherTestInterface api = Feign.builder().target(OtherTestInterface.class, "http://localhost:" + server.getPort()); + + api.binaryRequestBody(expectedRequest); + byte[] actualRequest = server.takeRequest().getBody(); + assertEquals(actualRequest, expectedRequest); + } 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..0d32a36d11 --- /dev/null +++ b/core/src/test/java/feign/LoggerTest.java @@ -0,0 +1,327 @@ +/* + * 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.Decoder; +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 feign.Util.UTF_8; +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(), UTF_8), + "{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"}"); + } finally { + server.shutdown(); + } + } + + static @dagger.Module(injects = Feign.class, overrides = true, addsTo = Feign.Defaults.class) class DefaultModule { + final Logger logger; + final Logger.Level logLevel; + + DefaultModule(Logger logger, Logger.Level logLevel) { + this.logger = logger; + this.logLevel = logLevel; + } + + @Provides Decoder defaultDecoder() { + return new Decoder.Default(); + } + + @Provides Encoder defaultEncoder() { + return new Encoder.Default(); + } + + @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(), UTF_8), + "{\"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..72fd77b201 --- /dev/null +++ b/core/src/test/java/feign/UtilTest.java @@ -0,0 +1,86 @@ +/* + * 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 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 Parameterized> PARAMETERIZED_LIST_STRING = null; + final Parameterized> PARAMETERIZED_WILDCARD_LIST_STRING = null; + final ParameterizedDecoder> PARAMETERIZED_DECODER_LIST_STRING = null; + final ParameterizedDecoder PARAMETERIZED_DECODER_UNBOUND = null; + } + + interface ParameterizedDecoder> extends Decoder { + } + + interface Parameterized { + } + + static class ParameterizedSubtype implements Parameterized { + } + + @Test public void resolveLastTypeParameterWhenNotSubtype() throws Exception { + Type context = LastTypeParameter.class.getDeclaredField("PARAMETERIZED_LIST_STRING").getGenericType(); + Type listStringType = LastTypeParameter.class.getDeclaredField("LIST_STRING").getGenericType(); + Type last = resolveLastTypeParameter(context, Parameterized.class); + assertEquals(last, listStringType); + } + + @Test public void lastTypeFromInstance() throws Exception { + Parameterized instance = new ParameterizedSubtype(); + Type last = resolveLastTypeParameter(instance.getClass(), Parameterized.class); + assertEquals(last, String.class); + } + + @Test public void lastTypeFromAnonymous() throws Exception { + Parameterized instance = new Parameterized() {}; + Type last = resolveLastTypeParameter(instance.getClass(), Parameterized.class); + assertEquals(last, Reader.class); + } + + @Test public void resolveLastTypeParameterWhenWildcard() throws Exception { + Type context = LastTypeParameter.class.getDeclaredField("PARAMETERIZED_WILDCARD_LIST_STRING").getGenericType(); + Type listStringType = LastTypeParameter.class.getDeclaredField("LIST_STRING").getGenericType(); + Type last = resolveLastTypeParameter(context, Parameterized.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/auth/BasicAuthRequestInterceptorTest.java b/core/src/test/java/feign/auth/BasicAuthRequestInterceptorTest.java new file mode 100644 index 0000000000..9b16527620 --- /dev/null +++ b/core/src/test/java/feign/auth/BasicAuthRequestInterceptorTest.java @@ -0,0 +1,55 @@ +/* + * 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.auth; + +import feign.RequestTemplate; +import org.testng.annotations.Test; + +import java.util.Collection; +import java.util.Collections; + +import static org.testng.Assert.assertEquals; + +/** + * Tests for {@link BasicAuthRequestInterceptor}. + */ +public class BasicAuthRequestInterceptorTest { + /** + * Tests that request headers are added as expected. + */ + @Test public void testAuthentication() { + RequestTemplate template = new RequestTemplate(); + BasicAuthRequestInterceptor interceptor = new BasicAuthRequestInterceptor("Aladdin", "open sesame"); + interceptor.apply(template); + Collection actualValue = template.headers().get("Authorization"); + Collection expectedValue = Collections.singletonList("Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="); + assertEquals(actualValue, expectedValue); + } + + /** + * Tests that requests headers are added as expected when user and pass are too long + */ + @Test public void testAuthenticationWhenUserPassAreTooLong() { + RequestTemplate template = new RequestTemplate(); + BasicAuthRequestInterceptor interceptor = new BasicAuthRequestInterceptor("IOIOIOIOIOIOIOIOIOIOIOIOIOIOIOIOIOIOIO", + "101010101010101010101010101010101010101010"); + interceptor.apply(template); + Collection actualValue = template.headers().get("Authorization"); + Collection expectedValue = Collections. + singletonList("Basic SU9JT0lPSU9JT0lPSU9JT0lPSU9JT0lPSU9JT0lPSU9JT0lPSU86MTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEw"); + assertEquals(actualValue, expectedValue); + } +} diff --git a/core/src/test/java/feign/codec/DefaultDecoderTest.java b/core/src/test/java/feign/codec/DefaultDecoderTest.java new file mode 100644 index 0000000000..e270df5b53 --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultDecoderTest.java @@ -0,0 +1,71 @@ +/* + * 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.Response; +import org.testng.annotations.Test; +import org.w3c.dom.Document; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; + +import static feign.Util.UTF_8; + +public class DefaultDecoderTest { + private final Decoder decoder = new Decoder.Default(); + + @Test public void testDecodesToString() throws Exception { + Response response = knownResponse(); + Object decodedObject = decoder.decode(response, String.class); + assertEquals(decodedObject.getClass(), String.class); + assertEquals(decodedObject.toString(), "response body"); + } + + @Test public void testDecodesToByteArray() throws Exception { + Response response = knownResponse(); + Object decodedObject = decoder.decode(response, byte[].class); + assertEquals(decodedObject.getClass(), byte[].class); + assertEquals((byte[]) decodedObject, "response body".getBytes(UTF_8)); + } + + @Test public void testDecodesNullBodyToNull() throws Exception { + assertNull(decoder.decode(nullBodyResponse(), Document.class)); + } + + @Test(expectedExceptions = DecodeException.class, expectedExceptionsMessageRegExp = ".* is not a type supported by this decoder.") + public void testRefusesToDecodeOtherTypes() throws Exception { + decoder.decode(knownResponse(), Document.class); + } + + private Response knownResponse() { + String content = "response body"; + InputStream inputStream = new ByteArrayInputStream(content.getBytes(UTF_8)); + Map> headers = new HashMap>(); + headers.put("Content-Type", Collections.singleton("text/plain")); + return Response.create(200, "OK", headers, inputStream, content.length()); + } + + private Response nullBodyResponse() { + return Response.create(200, "OK", Collections.>emptyMap(), null); + } +} diff --git a/core/src/test/java/feign/codec/DefaultEncoderTest.java b/core/src/test/java/feign/codec/DefaultEncoderTest.java new file mode 100644 index 0000000000..1dc4fe5985 --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultEncoderTest.java @@ -0,0 +1,48 @@ +/* + * 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.RequestTemplate; +import org.testng.annotations.Test; + +import java.util.Date; + +import static org.testng.Assert.assertEquals; + +import static feign.Util.UTF_8; + +public class DefaultEncoderTest { + private final Encoder encoder = new Encoder.Default(); + + @Test public void testEncodesStrings() throws Exception { + String content = "This is my content"; + RequestTemplate template = new RequestTemplate(); + encoder.encode(content, template); + assertEquals(template.body(), content.getBytes(UTF_8)); + } + + @Test public void testEncodesByteArray() throws Exception { + byte[] content = {12, 34, 56}; + RequestTemplate template = new RequestTemplate(); + encoder.encode(content, template); + assertEquals(template.body(), content); + } + + @Test(expectedExceptions = EncodeException.class, expectedExceptionsMessageRegExp = ".* is not a type supported by this encoder.") + public void testRefusesToEncodeOtherTypes() throws Exception { + encoder.encode(new Date(), new RequestTemplate()); + } +} 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..e6173bca6c --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java @@ -0,0 +1,58 @@ +/* + * 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; +import static feign.Util.UTF_8; + +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", UTF_8); + + 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/GitHubExample.java b/core/src/test/java/feign/examples/GitHubExample.java new file mode 100644 index 0000000000..c52308d52f --- /dev/null +++ b/core/src/test/java/feign/examples/GitHubExample.java @@ -0,0 +1,86 @@ +/* + * 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 feign.Feign; +import feign.Logger; +import feign.RequestLine; +import feign.Response; +import feign.codec.Decoder; + +import javax.inject.Named; +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.List; + +import static feign.Util.ensureClosed; + +/** + * 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); + } + + static class Contributor { + String login; + int contributions; + } + + public static void main(String... args) { + GitHub github = Feign.builder() + .logger(new Logger.ErrorLogger()) + .logLevel(Logger.Level.BASIC) + .decoder(new GsonDecoder()) + .target(GitHub.class, "https://api.github.com"); + + 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 + ")"); + } + } + + /** + * Here's how it looks to write a decoder. Note: you can instead use {@code feign-gson}! + */ + static class GsonDecoder implements Decoder { + private final Gson gson = new Gson(); + + @Override public Object decode(Response response, Type type) throws IOException { + if (void.class == type || response.body() == null) { + return null; + } + Reader reader = response.body().asReader(); + 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; + } finally { + ensureClosed(reader); + } + } + } +} 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..8b92037caf --- /dev/null +++ b/example-github/build.gradle @@ -0,0 +1,49 @@ +apply plugin: 'java' + +dependencies { + compile 'com.netflix.feign:feign-core:5.3.0' + compile 'com.netflix.feign:feign-gson:5.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..900bfc18b8 --- /dev/null +++ b/example-github/src/main/java/feign/example/github/GitHubExample.java @@ -0,0 +1,64 @@ +/* + * 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.RequestLine; +import feign.gson.GsonModule; + +import javax.inject.Named; +import java.util.List; + +/** + * 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); + } + + 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 + ")"); + } + } + + @Module(overrides = true, library = true, includes = GsonModule.class) + 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..9a85fd6165 --- /dev/null +++ b/example-wikipedia/build.gradle @@ -0,0 +1,49 @@ +apply plugin: 'java' + +dependencies { + compile 'com.netflix.feign:feign-core:5.3.0' + compile 'com.netflix.feign:feign-gson:5.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/ResponseAdapter.java b/example-wikipedia/src/main/java/feign/example/wikipedia/ResponseAdapter.java new file mode 100644 index 0000000000..e202cc109b --- /dev/null +++ b/example-wikipedia/src/main/java/feign/example/wikipedia/ResponseAdapter.java @@ -0,0 +1,90 @@ +package feign.example.wikipedia; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +abstract class ResponseAdapter extends TypeAdapter> { + + /** + * 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 read(JsonReader reader) throws IOException { + WikipediaExample.Response pages = new WikipediaExample.Response(); + 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; + } + + @Override + public void write(JsonWriter out, WikipediaExample.Response response) throws IOException { + throw new UnsupportedOperationException(); + } +} 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..feb5712174 --- /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.TypeAdapter; +import com.google.gson.stream.JsonReader; +import dagger.Module; +import dagger.Provides; +import feign.Feign; +import feign.Logger; +import feign.RequestLine; +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(includes = GsonModule.class) + static class WikipediaDecoder { + + /** + * registers a gson {@link TypeAdapter} for {@code Response}. + */ + @Provides(type = SET) TypeAdapter pagesAdapter() { + return new ResponseAdapter() { + + @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..868bb9b9a2 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +version=7.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..bc6a476887 --- /dev/null +++ b/gson/README.md @@ -0,0 +1,19 @@ +Gson Codec +=================== + +This module adds support for encoding and decoding JSON via the Gson library. + +Add `GsonEncoder` and/or `GsonDecoder` to your `Feign.Builder` like so: + +```java +GitHub github = Feign.builder() + .encoder(new GsonEncoder()) + .decoder(new GsonDecoder()) + .target(GitHub.class, "https://api.github.com"); +``` + +Or add them to your Dagger 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/DoubleToIntMapTypeAdapter.java b/gson/src/main/java/feign/gson/DoubleToIntMapTypeAdapter.java new file mode 100644 index 0000000000..3a92f4f8a5 --- /dev/null +++ b/gson/src/main/java/feign/gson/DoubleToIntMapTypeAdapter.java @@ -0,0 +1,54 @@ +/* + * 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.InstanceCreator; +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 java.io.IOException; +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.Map; + +/** + * Deals with scenario where Gson Object type treats all numbers as doubles. + */ +public class DoubleToIntMapTypeAdapter extends TypeAdapter> { + final static TypeToken> token = new TypeToken>() {}; + + private final 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; + } +} diff --git a/gson/src/main/java/feign/gson/GsonCodec.java b/gson/src/main/java/feign/gson/GsonCodec.java new file mode 100644 index 0000000000..b6ef12be1e --- /dev/null +++ b/gson/src/main/java/feign/gson/GsonCodec.java @@ -0,0 +1,37 @@ +package feign.gson; + +import com.google.gson.Gson; +import feign.RequestTemplate; +import feign.Response; +import feign.codec.Decoder; +import feign.codec.Encoder; + +import javax.inject.Inject; +import java.io.IOException; +import java.lang.reflect.Type; + +/** + * @deprecated use {@link GsonEncoder} and {@link GsonDecoder} instead + */ +@Deprecated +public class GsonCodec implements Encoder, Decoder { + private final GsonEncoder encoder; + private final GsonDecoder decoder; + + public GsonCodec() { + this(new Gson()); + } + + @Inject public GsonCodec(Gson gson) { + this.encoder = new GsonEncoder(gson); + this.decoder = new GsonDecoder(gson); + } + + @Override public void encode(Object object, RequestTemplate template) { + encoder.encode(object, template); + } + + @Override public Object decode(Response response, Type type) throws IOException { + return decoder.decode(response, type); + } +} diff --git a/gson/src/main/java/feign/gson/GsonDecoder.java b/gson/src/main/java/feign/gson/GsonDecoder.java new file mode 100644 index 0000000000..66df54ea85 --- /dev/null +++ b/gson/src/main/java/feign/gson/GsonDecoder.java @@ -0,0 +1,56 @@ +/* + * 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.JsonIOException; +import feign.Response; +import feign.codec.Decoder; + +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; + +import static feign.Util.ensureClosed; + +public class GsonDecoder implements Decoder { + private final Gson gson; + + public GsonDecoder() { + this(new Gson()); + } + + public GsonDecoder(Gson gson) { + this.gson = gson; + } + + @Override public Object decode(Response response, Type type) throws IOException { + if (response.body() == null) { + return null; + } + Reader reader = response.body().asReader(); + 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; + } finally { + ensureClosed(reader); + } + } +} diff --git a/gson/src/main/java/feign/gson/GsonEncoder.java b/gson/src/main/java/feign/gson/GsonEncoder.java new file mode 100644 index 0000000000..4bee8df58b --- /dev/null +++ b/gson/src/main/java/feign/gson/GsonEncoder.java @@ -0,0 +1,36 @@ +/* + * 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 feign.RequestTemplate; +import feign.codec.Encoder; + +public class GsonEncoder implements Encoder { + private final Gson gson; + + public GsonEncoder() { + this(new Gson()); + } + + public GsonEncoder(Gson gson) { + this.gson = gson; + } + + @Override public void encode(Object object, RequestTemplate template) { + template.body(gson.toJson(object)); + } +} 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..79093101f7 --- /dev/null +++ b/gson/src/main/java/feign/gson/GsonModule.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.gson; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.TypeAdapter; +import dagger.Provides; +import feign.Feign; +import feign.codec.Decoder; +import feign.codec.Encoder; + +import javax.inject.Singleton; +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.Set; + +import static feign.Util.resolveLastTypeParameter; + +/** + *

Custom type adapters

+ *
+ * In order to specify custom json parsing, + * {@code Gson} supports {@link TypeAdapter type adapters}. This module adds one + * to read numbers in a {@code Map} as Integers. You can + * customize further by adding additional set bindings to the raw type + * {@code TypeAdapter}. + *

+ *
+ * Here's an example of adding a custom json type adapter. + *

+ *

+ * @Provides(type = Provides.Type.SET)
+ * TypeAdapter upperZone() {
+ *     return new TypeAdapter<Zone>() {
+ *
+ *         @Override
+ *         public void write(JsonWriter out, Zone value) throws IOException {
+ *             throw new IllegalArgumentException();
+ *         }
+ *
+ *         @Override
+ *         public Zone read(JsonReader in) throws IOException {
+ *             in.beginObject();
+ *             Zone zone = new Zone();
+ *             while (in.hasNext()) {
+ *                 zone.put(in.nextName(), in.nextString().toUpperCase());
+ *             }
+ *             in.endObject();
+ *             return zone;
+ *         }
+ *     };
+ * }
+ * 
+ */ +@dagger.Module(injects = Feign.class, addsTo = Feign.Defaults.class) +public final class GsonModule { + + @Provides Encoder encoder(Gson gson) { + return new GsonEncoder(gson); + } + + @Provides Decoder decoder(Gson gson) { + return new GsonDecoder(gson); + } + + @Provides @Singleton Gson gson(Set adapters) { + GsonBuilder builder = new GsonBuilder().setPrettyPrinting(); + for (TypeAdapter adapter : adapters) { + Type type = resolveLastTypeParameter(adapter.getClass(), TypeAdapter.class); + builder.registerTypeAdapter(type, adapter); + } + return builder.create(); + } + + @Provides(type = Provides.Type.SET_VALUES) Set noDefaultTypeAdapters() { + return Collections.emptySet(); + } +} 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..d0bce2abfc --- /dev/null +++ b/gson/src/test/java/feign/gson/GsonModuleTest.java @@ -0,0 +1,198 @@ +/* + * 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.TypeAdapter; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import dagger.Module; +import dagger.ObjectGraph; +import dagger.Provides; +import feign.RequestTemplate; +import feign.Response; +import feign.codec.Decoder; +import feign.codec.Encoder; +import org.testng.annotations.Test; + +import javax.inject.Inject; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import static org.testng.Assert.assertEquals; + +import static feign.Util.UTF_8; + +@Test +public class GsonModuleTest { + @Module(includes = GsonModule.class, injects = EncoderAndDecoderBindings.class) + static class EncoderAndDecoderBindings { + @Inject Encoder encoder; + @Inject Decoder decoder; + } + + @Test public void providesEncoderDecoder() throws Exception { + EncoderAndDecoderBindings bindings = new EncoderAndDecoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + assertEquals(bindings.encoder.getClass(), GsonEncoder.class); + assertEquals(bindings.decoder.getClass(), GsonDecoder.class); + } + + @Module(includes = GsonModule.class, injects = EncoderBindings.class) + static class EncoderBindings { + @Inject Encoder encoder; + } + + @Test public void encodesMapObjectNumericalValuesAsInteger() throws Exception { + String expectedBody = "" + + "{\n" + + " \"foo\": 1\n" + + "}"; + + EncoderBindings bindings = new EncoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + Map map = new LinkedHashMap(); + map.put("foo", 1); + + RequestTemplate template = new RequestTemplate(); + bindings.encoder.encode(map, template); + assertEquals(template.body(), expectedBody.getBytes(UTF_8)); + } + + @Test public void encodesFormParams() throws Exception { + String expectedBody = ""// + + "{\n" // + + " \"foo\": 1,\n" // + + " \"bar\": [\n" // + + " 2,\n" // + + " 3\n" // + + " ]\n" // + + "}"; + + EncoderBindings bindings = new EncoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + Map form = new LinkedHashMap(); + form.put("foo", 1); + form.put("bar", Arrays.asList(2, 3)); + + RequestTemplate template = new RequestTemplate(); + bindings.encoder.encode(form, template); + assertEquals(template.body(), expectedBody.getBytes(UTF_8)); + } + + 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, injects = DecoderBindings.class) + static class DecoderBindings { + @Inject Decoder decoder; + } + + @Test public void decodes() throws Exception { + DecoderBindings bindings = new DecoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + List zones = new LinkedList(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "ABCD")); + + Response response = + Response.create(200, "OK", Collections.>emptyMap(), zonesJson, UTF_8); + assertEquals(bindings.decoder.decode(response, new TypeToken>() { + }.getType()), zones); + } + + @Test public void nullBodyDecodesToNull() throws Exception { + DecoderBindings bindings = new DecoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + Response response = Response.create(204, "OK", Collections.>emptyMap(), null); + assertEquals(bindings.decoder.decode(response, String.class), null); + } + + private String zonesJson = ""// + + "[\n"// + + " {\n"// + + " \"name\": \"denominator.io.\"\n"// + + " },\n"// + + " {\n"// + + " \"name\": \"denominator.io.\",\n"// + + " \"id\": \"ABCD\"\n"// + + " }\n"// + + "]\n"; + + @Module(includes = GsonModule.class, injects = CustomTypeAdapter.class) + static class CustomTypeAdapter { + @Provides(type = Provides.Type.SET) TypeAdapter upperZone() { + return new TypeAdapter() { + + @Override public void write(JsonWriter out, Zone value) throws IOException { + throw new IllegalArgumentException(); + } + + @Override public Zone read(JsonReader in) throws IOException { + in.beginObject(); + Zone zone = new Zone(); + while (in.hasNext()) { + zone.put(in.nextName(), in.nextString().toUpperCase()); + } + in.endObject(); + return zone; + } + }; + } + + @Inject Decoder decoder; + } + + @Test public void customDecoder() throws Exception { + CustomTypeAdapter bindings = new CustomTypeAdapter(); + ObjectGraph.create(bindings).inject(bindings); + + List zones = new LinkedList(); + zones.add(new Zone("DENOMINATOR.IO.")); + zones.add(new Zone("DENOMINATOR.IO.", "ABCD")); + + Response response = + Response.create(200, "OK", Collections.>emptyMap(), zonesJson, UTF_8); + assertEquals(bindings.decoder.decode(response, new TypeToken>() { + }.getType()), zones); + } +} diff --git a/gson/src/test/java/feign/gson/examples/GitHubExample.java b/gson/src/test/java/feign/gson/examples/GitHubExample.java new file mode 100644 index 0000000000..6053ce51a5 --- /dev/null +++ b/gson/src/test/java/feign/gson/examples/GitHubExample.java @@ -0,0 +1,49 @@ +/* + * 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.examples; + +import feign.Feign; +import feign.RequestLine; +import feign.gson.GsonDecoder; + +import javax.inject.Named; +import java.util.List; + +/** + * 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); + } + + static class Contributor { + String login; + int contributions; + } + + public static void main(String... args) throws InterruptedException { + GitHub github = Feign.builder().decoder(new GsonDecoder()).target(GitHub.class, "https://api.github.com"); + + 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 + ")"); + } + } +} diff --git a/jackson/README.md b/jackson/README.md new file mode 100644 index 0000000000..a6b8f0fcdc --- /dev/null +++ b/jackson/README.md @@ -0,0 +1,33 @@ +Jackson Codec +=================== + +This module adds support for encoding and decoding JSON via Jackson. + +Add `JacksonEncoder` and/or `JacksonDecoder` to your `Feign.Builder` like so: + +```java +GitHub github = Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .target(GitHub.class, "https://api.github.com"); +``` + +If you want to customize the `ObjectMapper` that is used, provide it to the `JacksonEncoder` and `JacksonDecoder`: + +```java +ObjectMapper mapper = new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(SerializationFeature.INDENT_OUTPUT, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + +GitHub github = Feign.builder() + .encoder(new JacksonEncoder(mapper)) + .decoder(new JacksonDecoder(mapper)) + .target(GitHub.class, "https://api.github.com"); +``` + +Alternatively, you can add the encoder and decoder to your Dagger object graph using the provided `JacksonModule` like so: + +```java +GitHub github = Feign.create(GitHub.class, "https://api.github.com", new JacksonModule()); +``` diff --git a/jackson/src/main/java/feign/jackson/JacksonDecoder.java b/jackson/src/main/java/feign/jackson/JacksonDecoder.java new file mode 100644 index 0000000000..f0734d3768 --- /dev/null +++ b/jackson/src/main/java/feign/jackson/JacksonDecoder.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.jackson; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.RuntimeJsonMappingException; +import feign.Response; +import feign.codec.Decoder; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; + +public class JacksonDecoder implements Decoder { + private final ObjectMapper mapper; + + public JacksonDecoder() { + this(new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)); + } + + public JacksonDecoder(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override public Object decode(Response response, Type type) throws IOException { + if (response.body() == null) { + return null; + } + InputStream inputStream = response.body().asInputStream(); + try { + return mapper.readValue(inputStream, mapper.constructType(type)); + } catch (RuntimeJsonMappingException e) { + if (e.getCause() != null && e.getCause() instanceof IOException) { + throw IOException.class.cast(e.getCause()); + } + throw e; + } + } +} diff --git a/jackson/src/main/java/feign/jackson/JacksonEncoder.java b/jackson/src/main/java/feign/jackson/JacksonEncoder.java new file mode 100644 index 0000000000..1cc6895f2b --- /dev/null +++ b/jackson/src/main/java/feign/jackson/JacksonEncoder.java @@ -0,0 +1,46 @@ +/* + * 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.jackson; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; + +public class JacksonEncoder implements Encoder { + private final ObjectMapper mapper; + + public JacksonEncoder() { + this(new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(SerializationFeature.INDENT_OUTPUT, true)); + } + + public JacksonEncoder(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override public void encode(Object object, RequestTemplate template) throws EncodeException { + try { + template.body(mapper.writeValueAsString(object)); + } catch (JsonProcessingException e) { + throw new EncodeException(e.getMessage(), e); + } + } +} diff --git a/jackson/src/main/java/feign/jackson/JacksonModule.java b/jackson/src/main/java/feign/jackson/JacksonModule.java new file mode 100644 index 0000000000..7826118afa --- /dev/null +++ b/jackson/src/main/java/feign/jackson/JacksonModule.java @@ -0,0 +1,103 @@ +/* + * 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.jackson; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import dagger.Provides; +import feign.Feign; +import feign.codec.Decoder; +import feign.codec.Encoder; + +import javax.inject.Singleton; +import java.util.Collections; +import java.util.Set; + +/** + *

Custom serializers/deserializers

+ *
+ * In order to specify custom json parsing, Jackson's {@code ObjectMapper} supports {@link JsonSerializer serializers} + * and {@link JsonDeserializer deserializers}, which can be bundled together in {@link Module modules}. + *

+ *
+ * Here's an example of adding a custom module. + *

+ *

+ * public class ObjectIdSerializer extends StdSerializer<ObjectId> {
+ *     public ObjectIdSerializer() {
+ *         super(ObjectId.class);
+ *     }
+ *
+ *     @Override
+ *     public void serialize(ObjectId value, JsonGenerator jsonGenerator, SerializerProvider provider) throws IOException {
+ *         jsonGenerator.writeString(value.toString());
+ *     }
+ * }
+ *
+ * public class ObjectIdDeserializer extends StdDeserializer<ObjectId> {
+ *     public ObjectIdDeserializer() {
+ *         super(ObjectId.class);
+ *     }
+ *
+ *     @Override
+ *     public ObjectId deserialize(JsonParser jsonParser, DeserializationContext context) throws IOException {
+ *         return ObjectId.massageToObjectId(jsonParser.getValueAsString());
+ *     }
+ * }
+ *
+ * public class ObjectIdModule extends SimpleModule {
+ *     public ObjectIdModule() {
+ *         // first deserializers
+ *         addDeserializer(ObjectId.class, new ObjectIdDeserializer());
+ *
+ *         // then serializers:
+ *         addSerializer(ObjectId.class, new ObjectIdSerializer());
+ *     }
+ * }
+ *
+ * @Provides(type = Provides.Type.SET)
+ * Module objectIdModule() {
+ *     return new ObjectIdModule();
+ * }
+ * 
+ */ +@dagger.Module(injects = Feign.class, addsTo = Feign.Defaults.class) +public final class JacksonModule { + @Provides Encoder encoder(ObjectMapper mapper) { + return new JacksonEncoder(mapper); + } + + @Provides Decoder decoder(ObjectMapper mapper) { + return new JacksonDecoder(mapper); + } + + @Provides @Singleton ObjectMapper mapper(Set modules) { + return new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .configure(SerializationFeature.INDENT_OUTPUT, true) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModules(modules); + } + + @Provides(type = Provides.Type.SET_VALUES) Set noDefaultModules() { + return Collections.emptySet(); + } +} diff --git a/jackson/src/test/java/feign/jackson/JacksonModuleTest.java b/jackson/src/test/java/feign/jackson/JacksonModuleTest.java new file mode 100644 index 0000000000..a4f9dfa8ef --- /dev/null +++ b/jackson/src/test/java/feign/jackson/JacksonModuleTest.java @@ -0,0 +1,188 @@ +package feign.jackson; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.google.common.reflect.TypeToken; +import dagger.Module; +import dagger.ObjectGraph; +import dagger.Provides; +import feign.RequestTemplate; +import feign.Response; +import feign.codec.Decoder; +import feign.codec.Encoder; +import org.testng.annotations.Test; + +import javax.inject.Inject; +import java.io.IOException; +import java.util.*; + +import static org.testng.Assert.assertEquals; + +import static feign.Util.UTF_8; + +@Test +public class JacksonModuleTest { + @Module(includes = JacksonModule.class, injects = EncoderAndDecoderBindings.class) + static class EncoderAndDecoderBindings { + @Inject + Encoder encoder; + @Inject + Decoder decoder; + } + + @Test + public void providesEncoderDecoder() throws Exception { + EncoderAndDecoderBindings bindings = new EncoderAndDecoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + assertEquals(bindings.encoder.getClass(), JacksonEncoder.class); + assertEquals(bindings.decoder.getClass(), JacksonDecoder.class); + } + + @Module(includes = JacksonModule.class, injects = EncoderBindings.class) + static class EncoderBindings { + @Inject Encoder encoder; + } + + @Test public void encodesMapObjectNumericalValuesAsInteger() throws Exception { + EncoderBindings bindings = new EncoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + Map map = new LinkedHashMap(); + map.put("foo", 1); + + RequestTemplate template = new RequestTemplate(); + bindings.encoder.encode(map, template); + assertEquals(new String(template.body(), UTF_8), ""// + + "{\n" // + + " \"foo\" : 1\n" // + + "}"); + } + + @Test public void encodesFormParams() throws Exception { + EncoderBindings bindings = new EncoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + Map form = new LinkedHashMap(); + form.put("foo", 1); + form.put("bar", Arrays.asList(2, 3)); + + RequestTemplate template = new RequestTemplate(); + bindings.encoder.encode(form, template); + assertEquals(new String(template.body(), UTF_8), ""// + + "{\n" // + + " \"foo\" : 1,\n" // + + " \"bar\" : [ 2, 3 ]\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 = JacksonModule.class, injects = DecoderBindings.class) + static class DecoderBindings { + @Inject Decoder decoder; + } + + @Test public void decodes() throws Exception { + DecoderBindings bindings = new DecoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + List zones = new LinkedList(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "ABCD")); + + Response response = + Response.create(200, "OK", Collections.>emptyMap(), zonesJson, UTF_8); + assertEquals(bindings.decoder.decode(response, new TypeToken>() { + }.getType()), zones); + } + + @Test public void nullBodyDecodesToNull() throws Exception { + DecoderBindings bindings = new DecoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + Response response = Response.create(204, "OK", Collections.>emptyMap(), null); + assertEquals(bindings.decoder.decode(response, String.class), null); + } + + private String zonesJson = ""// + + "[\n"// + + " {\n"// + + " \"name\": \"denominator.io.\"\n"// + + " },\n"// + + " {\n"// + + " \"name\": \"denominator.io.\",\n"// + + " \"id\": \"ABCD\"\n"// + + " }\n"// + + "]\n"; + + static class ZoneDeserializer extends StdDeserializer { + public ZoneDeserializer() { + super(Zone.class); + } + + @Override + public Zone deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { + Zone zone = new Zone(); + jp.nextToken(); + while (jp.nextToken() != JsonToken.END_OBJECT) { + String name = jp.getCurrentName(); + String value = jp.getValueAsString(); + if (value != null) { + zone.put(name, value.toUpperCase()); + } + } + return zone; + } + } + + static class ZoneModule extends SimpleModule { + public ZoneModule() { + addDeserializer(Zone.class, new ZoneDeserializer()); + } + } + + @Module(includes = JacksonModule.class, injects = CustomJacksonModule.class) + static class CustomJacksonModule { + @Inject Decoder decoder; + + @Provides(type = Provides.Type.SET) + com.fasterxml.jackson.databind.Module upperZone() { + return new ZoneModule(); + } + } + + @Test public void customDecoder() throws Exception { + CustomJacksonModule bindings = new CustomJacksonModule(); + ObjectGraph.create(bindings).inject(bindings); + + List zones = new LinkedList(); + zones.add(new Zone("DENOMINATOR.IO.")); + zones.add(new Zone("DENOMINATOR.IO.", "ABCD")); + + Response response = + Response.create(200, "OK", Collections.>emptyMap(), zonesJson, UTF_8); + assertEquals(bindings.decoder.decode(response, new TypeToken>() { + }.getType()), zones); + } +} diff --git a/jackson/src/test/java/feign/jackson/examples/GitHubExample.java b/jackson/src/test/java/feign/jackson/examples/GitHubExample.java new file mode 100644 index 0000000000..24f490efb3 --- /dev/null +++ b/jackson/src/test/java/feign/jackson/examples/GitHubExample.java @@ -0,0 +1,40 @@ +package feign.jackson.examples; + +import feign.Feign; +import feign.RequestLine; +import feign.jackson.JacksonDecoder; + +import javax.inject.Named; +import java.util.List; + +/** + * 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); + } + + static class Contributor { + private String login; + private int contributions; + + void setLogin(String login) { + this.login = login; + } + + void setContributions(int contributions) { + this.contributions = contributions; + } + } + + public static void main(String... args) throws InterruptedException { + GitHub github = Feign.builder().decoder(new JacksonDecoder()).target(GitHub.class, "https://api.github.com"); + 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 + ")"); + } + } +} diff --git a/jaxb/README.md b/jaxb/README.md new file mode 100644 index 0000000000..a8c84503e2 --- /dev/null +++ b/jaxb/README.md @@ -0,0 +1,26 @@ +JAXB Codec +=================== + +This module adds support for encoding and decoding XML via JAXB. + +Add `JAXBEncoder` and/or `JAXBDecoder` to your `Feign.Builder` like so: + +```java +//The context factory should be reused across requests. Recreating it will be a performance +//bottleneck as it has to recreate the JAXBContext. +JAXBContextFactory jaxbFactory = new JAXBContextFactory.Builder() + .withMarshallerJAXBEncoding("UTF-8") + .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd") + .build(); + +Response response = Feign.builder() + .encoder(new JAXBEncoder(jaxbFactory)) + .decoder(new JAXBDecoder(jaxbFactory)) + .target(Response.class, "https://apihost"); +``` + +Alternatively, you can add the encoder and decoder to your Dagger object graph using the provided JAXBModule like so: + +```java +Response response = Feign.create(Response.class, "https://apihost", new JAXBModule()); +``` \ No newline at end of file diff --git a/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java b/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java new file mode 100644 index 0000000000..adf86f5119 --- /dev/null +++ b/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java @@ -0,0 +1,128 @@ +/* + * Copyright 2014 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.jaxb; + +import javax.xml.bind.JAXBContext; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Marshaller; +import javax.xml.bind.PropertyException; +import javax.xml.bind.Unmarshaller; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Creates and caches JAXBContexts as well as creates Marshallers and Unmarshallers for each context. + */ +public final class JAXBContextFactory { + private final ConcurrentHashMap jaxbContexts = new ConcurrentHashMap(64); + private final Map properties; + + private JAXBContextFactory(Map properties) { + this.properties = properties; + } + + /** + * Creates a new {@link javax.xml.bind.Unmarshaller} that handles the supplied class. + */ + public Unmarshaller createUnmarshaller(Class clazz) throws JAXBException { + JAXBContext ctx = getContext(clazz); + return ctx.createUnmarshaller(); + } + + /** + * Creates a new {@link javax.xml.bind.Marshaller} that handles the supplied class. + */ + public Marshaller createMarshaller(Class clazz) throws JAXBException { + JAXBContext ctx = getContext(clazz); + Marshaller marshaller = ctx.createMarshaller(); + setMarshallerProperties(marshaller); + return marshaller; + } + + private void setMarshallerProperties(Marshaller marshaller) throws PropertyException { + Iterator keys = properties.keySet().iterator(); + + while(keys.hasNext()) { + String key = keys.next(); + marshaller.setProperty(key, properties.get(key)); + } + } + + private JAXBContext getContext(Class clazz) throws JAXBException { + JAXBContext jaxbContext = this.jaxbContexts.get(clazz); + if (jaxbContext == null) { + jaxbContext = JAXBContext.newInstance(clazz); + this.jaxbContexts.putIfAbsent(clazz, jaxbContext); + } + return jaxbContext; + } + + /** + * Creates instances of {@link feign.jaxb.JAXBContextFactory} + */ + public static class Builder { + private final Map properties = new HashMap(5); + + /** + * Sets the jaxb.encoding property of any Marshaller created by this factory. + */ + public Builder withMarshallerJAXBEncoding(String value) { + properties.put(Marshaller.JAXB_ENCODING, value); + return this; + } + + /** + * Sets the jaxb.schemaLocation property of any Marshaller created by this factory. + */ + public Builder withMarshallerSchemaLocation(String value) { + properties.put(Marshaller.JAXB_SCHEMA_LOCATION, value); + return this; + } + + /** + * Sets the jaxb.noNamespaceSchemaLocation property of any Marshaller created by this factory. + */ + public Builder withMarshallerNoNamespaceSchemaLocation(String value) { + properties.put(Marshaller.JAXB_NO_NAMESPACE_SCHEMA_LOCATION, value); + return this; + } + + /** + * Sets the jaxb.formatted.output property of any Marshaller created by this factory. + */ + public Builder withMarshallerFormattedOutput(Boolean value) { + properties.put(Marshaller.JAXB_FORMATTED_OUTPUT, value); + return this; + } + + /** + * Sets the jaxb.fragment property of any Marshaller created by this factory. + */ + public Builder withMarshallerFragment(Boolean value) { + properties.put(Marshaller.JAXB_FRAGMENT, value); + return this; + } + + /** + * Creates a new {@link feign.jaxb.JAXBContextFactory} instance. + */ + public JAXBContextFactory build() { + return new JAXBContextFactory(properties); + } + } +} diff --git a/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java new file mode 100644 index 0000000000..e2a5cff7e6 --- /dev/null +++ b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java @@ -0,0 +1,46 @@ +/* + * Copyright 2014 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.jaxb; + +import feign.FeignException; +import feign.Response; +import feign.codec.DecodeException; +import feign.codec.Decoder; + +import javax.inject.Inject; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Unmarshaller; +import java.io.IOException; +import java.lang.reflect.Type; + +public class JAXBDecoder implements Decoder { + private final JAXBContextFactory jaxbContextFactory; + + @Inject + public JAXBDecoder(JAXBContextFactory jaxbContextFactory) { + this.jaxbContextFactory = jaxbContextFactory; + } + + @Override + public Object decode(Response response, Type type) throws IOException, FeignException { + try { + Unmarshaller unmarshaller = jaxbContextFactory.createUnmarshaller((Class) type); + return unmarshaller.unmarshal(response.body().asInputStream()); + } catch (JAXBException e) { + throw new DecodeException(e.getMessage(), e); + } + } +} diff --git a/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java new file mode 100644 index 0000000000..ccaaf53169 --- /dev/null +++ b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java @@ -0,0 +1,46 @@ +/* + * Copyright 2014 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.jaxb; + +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; + +import javax.inject.Inject; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Marshaller; +import java.io.StringWriter; + +public class JAXBEncoder implements Encoder { + private final JAXBContextFactory jaxbContextFactory; + + @Inject + public JAXBEncoder(JAXBContextFactory jaxbContextFactory) { + this.jaxbContextFactory = jaxbContextFactory; + } + + @Override + public void encode(Object object, RequestTemplate template) throws EncodeException { + try { + Marshaller marshaller = jaxbContextFactory.createMarshaller(object.getClass()); + StringWriter stringWriter = new StringWriter(); + marshaller.marshal(object, stringWriter); + template.body(stringWriter.toString()); + } catch (JAXBException e) { + throw new EncodeException(e.getMessage(), e); + } + } +} diff --git a/jaxb/src/main/java/feign/jaxb/JAXBModule.java b/jaxb/src/main/java/feign/jaxb/JAXBModule.java new file mode 100644 index 0000000000..952af2091b --- /dev/null +++ b/jaxb/src/main/java/feign/jaxb/JAXBModule.java @@ -0,0 +1,63 @@ +/* + * Copyright 2014 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.jaxb; + +import dagger.Provides; +import feign.Feign; +import feign.codec.Decoder; +import feign.codec.Encoder; + +import javax.inject.Singleton; + +/** + * Provides an Encoder and Decoder for handling XML responses with JAXB annotated classes. + *

+ *
+ * Here is an example of configuring a custom JAXBContextFactory: + *

+ *
+ *    JAXBContextFactory jaxbFactory = new JAXBContextFactory.Builder()
+ *               .withMarshallerJAXBEncoding("UTF-8")
+ *               .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd")
+ *               .build();
+ *
+ *    Response response = Feign.create(Response.class, "http://apihost", new JAXBModule(jaxbFactory));
+ * 
+ */ +@dagger.Module(injects = Feign.class, addsTo = Feign.Defaults.class) +public final class JAXBModule { + private final JAXBContextFactory jaxbContextFactory; + + public JAXBModule() { + this.jaxbContextFactory = new JAXBContextFactory.Builder().build(); + } + + public JAXBModule(JAXBContextFactory jaxbContextFactory) { + this.jaxbContextFactory = jaxbContextFactory; + } + + @Provides Encoder encoder(JAXBEncoder jaxbEncoder) { + return jaxbEncoder; + } + + @Provides Decoder decoder(JAXBDecoder jaxbDecoder) { + return jaxbDecoder; + } + + @Provides @Singleton JAXBContextFactory jaxbContextFactory() { + return this.jaxbContextFactory; + } +} diff --git a/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java b/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java new file mode 100644 index 0000000000..b7544cc0fe --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2014 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.jaxb; + +import org.testng.annotations.Test; + +import javax.xml.bind.Marshaller; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +public class JAXBContextFactoryTest { + @Test + public void buildsMarshallerWithJAXBEncodingProperty() throws Exception { + JAXBContextFactory factory = new JAXBContextFactory.Builder() + .withMarshallerJAXBEncoding("UTF-16") + .build(); + + Marshaller marshaller = factory.createMarshaller(Object.class); + assertEquals(marshaller.getProperty(Marshaller.JAXB_ENCODING), "UTF-16"); + } + + @Test + public void buildsMarshallerWithSchemaLocationProperty() throws Exception { + JAXBContextFactory factory = new JAXBContextFactory.Builder() + .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd") + .build(); + + Marshaller marshaller = factory.createMarshaller(Object.class); + assertEquals(marshaller.getProperty(Marshaller.JAXB_SCHEMA_LOCATION), + "http://apihost http://apihost/schema.xsd"); + } + + @Test + public void buildsMarshallerWithNoNamespaceSchemaLocationProperty() throws Exception { + JAXBContextFactory factory = new JAXBContextFactory.Builder() + .withMarshallerNoNamespaceSchemaLocation("http://apihost/schema.xsd") + .build(); + + Marshaller marshaller = factory.createMarshaller(Object.class); + assertEquals(marshaller.getProperty(Marshaller.JAXB_NO_NAMESPACE_SCHEMA_LOCATION), "http://apihost/schema.xsd"); + } + + @Test + public void buildsMarshallerWithFormattedOutputProperty() throws Exception { + JAXBContextFactory factory = new JAXBContextFactory.Builder() + .withMarshallerFormattedOutput(true) + .build(); + + Marshaller marshaller = factory.createMarshaller(Object.class); + assertTrue((Boolean) marshaller.getProperty(Marshaller.JAXB_FORMATTED_OUTPUT)); + } + + @Test + public void buildsMarshallerWithFragmentProperty() throws Exception { + JAXBContextFactory factory = new JAXBContextFactory.Builder() + .withMarshallerFragment(true) + .build(); + + Marshaller marshaller = factory.createMarshaller(Object.class); + assertTrue((Boolean) marshaller.getProperty(Marshaller.JAXB_FRAGMENT)); + } +} diff --git a/jaxb/src/test/java/feign/jaxb/JAXBModuleTest.java b/jaxb/src/test/java/feign/jaxb/JAXBModuleTest.java new file mode 100644 index 0000000000..104d66d080 --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/JAXBModuleTest.java @@ -0,0 +1,219 @@ +/* + * Copyright 2014 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.jaxb; + +import com.google.common.reflect.TypeToken; +import dagger.Module; +import dagger.ObjectGraph; +import feign.RequestTemplate; +import feign.Response; +import feign.codec.Decoder; +import feign.codec.Encoder; +import org.testng.annotations.Test; + +import javax.inject.Inject; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import java.util.Collection; +import java.util.Collections; + +import static feign.Util.UTF_8; +import static org.testng.Assert.assertEquals; + +@Test +public class JAXBModuleTest { + @Module(includes = JAXBModule.class, injects = EncoderAndDecoderBindings.class) + static class EncoderAndDecoderBindings { + @Inject + Encoder encoder; + + @Inject + Decoder decoder; + } + + @Module(includes = JAXBModule.class, injects = EncoderBindings.class) + static class EncoderBindings { + @Inject Encoder encoder; + } + + @Module(includes = JAXBModule.class, injects = DecoderBindings.class) + static class DecoderBindings { + @Inject Decoder decoder; + } + + @Test + public void providesEncoderDecoder() throws Exception { + EncoderAndDecoderBindings bindings = new EncoderAndDecoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + assertEquals(bindings.encoder.getClass(), JAXBEncoder.class); + assertEquals(bindings.decoder.getClass(), JAXBDecoder.class); + } + + @XmlRootElement + @XmlAccessorType(XmlAccessType.FIELD) + static class MockObject { + + @XmlElement + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + MockObject that = (MockObject) o; + + if (value != null ? !value.equals(that.value) : that.value != null) return false; + + return true; + } + + @Override + public int hashCode() { + return value != null ? value.hashCode() : 0; + } + } + + @Test + public void encodesXml() throws Exception { + EncoderBindings bindings = new EncoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + MockObject mock = new MockObject(); + mock.setValue("Test"); + + RequestTemplate template = new RequestTemplate(); + bindings.encoder.encode(mock, template); + + assertEquals(new String(template.body(), UTF_8), "Test"); + } + + @Test + public void encodesXmlWithCustomJAXBEncoding() throws Exception { + JAXBContextFactory jaxbContextFactory = new JAXBContextFactory.Builder() + .withMarshallerJAXBEncoding("UTF-16") + .build(); + + JAXBModule jaxbModule = new JAXBModule(jaxbContextFactory); + Encoder encoder = jaxbModule.encoder(new JAXBEncoder(jaxbContextFactory)); + + MockObject mock = new MockObject(); + mock.setValue("Test"); + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, template); + + assertEquals(new String(template.body(), UTF_8), "Test"); + } + + @Test + public void encodesXmlWithCustomJAXBSchemaLocation() throws Exception { + JAXBContextFactory jaxbContextFactory = new JAXBContextFactory.Builder() + .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd") + .build(); + + JAXBModule jaxbModule = new JAXBModule(jaxbContextFactory); + Encoder encoder = jaxbModule.encoder(new JAXBEncoder(jaxbContextFactory)); + + MockObject mock = new MockObject(); + mock.setValue("Test"); + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, template); + + assertEquals(new String(template.body(), UTF_8), "" + + "Test"); + } + + @Test + public void encodesXmlWithCustomJAXBNoNamespaceSchemaLocation() throws Exception { + JAXBContextFactory jaxbContextFactory = new JAXBContextFactory.Builder() + .withMarshallerNoNamespaceSchemaLocation("http://apihost/schema.xsd") + .build(); + + JAXBModule jaxbModule = new JAXBModule(jaxbContextFactory); + Encoder encoder = jaxbModule.encoder(new JAXBEncoder(jaxbContextFactory)); + + MockObject mock = new MockObject(); + mock.setValue("Test"); + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, template); + + assertEquals(new String(template.body(), UTF_8), "" + + "Test"); + } + + @Test + public void encodesXmlWithCustomJAXBFormattedOutput() { + JAXBContextFactory jaxbContextFactory = new JAXBContextFactory.Builder() + .withMarshallerFormattedOutput(true) + .build(); + + JAXBModule jaxbModule = new JAXBModule(jaxbContextFactory); + Encoder encoder = jaxbModule.encoder(new JAXBEncoder(jaxbContextFactory)); + + MockObject mock = new MockObject(); + mock.setValue("Test"); + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, template); + + String NEWLINE = System.getProperty("line.separator"); + + StringBuilder expectedXml = new StringBuilder(); + expectedXml.append("").append(NEWLINE) + .append("").append(NEWLINE) + .append(" Test").append(NEWLINE) + .append("").append(NEWLINE); + + assertEquals(new String(template.body(), UTF_8), expectedXml.toString()); + } + + @Test + public void decodesXml() throws Exception { + DecoderBindings bindings = new DecoderBindings(); + ObjectGraph.create(bindings).inject(bindings); + + MockObject mock = new MockObject(); + mock.setValue("Test"); + + String mockXml = "" + + "Test"; + + Response response = + Response.create(200, "OK", Collections.>emptyMap(), mockXml, UTF_8); + + assertEquals(bindings.decoder.decode(response, new TypeToken() {}.getType()), mock); + } +} diff --git a/jaxb/src/test/java/feign/jaxb/examples/AWSSignatureVersion4.java b/jaxb/src/test/java/feign/jaxb/examples/AWSSignatureVersion4.java new file mode 100644 index 0000000000..0d9e3b84b5 --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/examples/AWSSignatureVersion4.java @@ -0,0 +1,163 @@ +/* + * Copyright 2014 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.jaxb.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 feign.Request; +import feign.RequestTemplate; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +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 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; + synchronized (iso8601) { + 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)) + String bodyText = + input.charset() != null && input.body() != null ? new String(input.body(), input.charset()) : null; + if (bodyText != null) { + canonicalRequest.append(base16().lowerCase().encode(sha256().hashString(bodyText, 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 == null ? null : 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/jaxb/src/test/java/feign/jaxb/examples/IAMExample.java b/jaxb/src/test/java/feign/jaxb/examples/IAMExample.java new file mode 100644 index 0000000000..dd661017c2 --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/examples/IAMExample.java @@ -0,0 +1,192 @@ +/* + * Copyright 2014 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.jaxb.examples; + +import feign.Feign; +import feign.Request; +import feign.RequestLine; +import feign.RequestTemplate; +import feign.Target; +import feign.jaxb.JAXBContextFactory; +import feign.jaxb.JAXBDecoder; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.XmlRootElement; +import javax.xml.bind.annotation.XmlType; + +public class IAMExample { + + interface IAM { + @RequestLine("GET /?Action=GetUser&Version=2010-05-08") GetUserResponse userResponse(); + } + + public static void main(String... args) { + IAM iam = Feign.builder() + .decoder(new JAXBDecoder(new JAXBContextFactory.Builder().build())) + .target(new IAMTarget(args[0], args[1])); + + GetUserResponse response = iam.userResponse(); + System.out.println("UserId: " + response.getUserResult().getUser().getUserId()); + System.out.println("UserName: " + response.getUserResult().getUser().getUsername()); + } + + 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); + } + } + + @XmlRootElement(name = "GetUserResponse", namespace = "https://iam.amazonaws.com/doc/2010-05-08/") + @XmlAccessorType(XmlAccessType.FIELD) + static class GetUserResponse { + @XmlElement(name = "GetUserResult") + private GetUserResult userResult; + + @XmlElement(name = "ResponseMetadata") + private ResponseMetadata responseMetadata; + + public GetUserResult getUserResult() { + return userResult; + } + + public void setUserResult(GetUserResult userResult) { + this.userResult = userResult; + } + + public ResponseMetadata getResponseMetadata() { + return responseMetadata; + } + + public void setResponseMetadata(ResponseMetadata responseMetadata) { + this.responseMetadata = responseMetadata; + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "ResponseMetadata") + static class ResponseMetadata { + @XmlElement(name = "RequestId") + private String requestId; + + public ResponseMetadata() {} + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "GetUserResult") + static class GetUserResult { + @XmlElement(name = "User") + private User user; + + public GetUserResult() {} + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "User") + static class User { + @XmlElement(name = "UserId") + private String userId; + + @XmlElement(name = "Path") + private String path; + + @XmlElement(name = "UserName") + private String username; + + @XmlElement(name = "Arn") + private String arn; + + @XmlElement(name = "CreateDate") + private String createDate; + + public User() {} + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getArn() { + return arn; + } + + public void setArn(String arn) { + this.arn = arn; + } + + public String getCreateDate() { + return createDate; + } + + public void setCreateDate(String createDate) { + this.createDate = createDate; + } + } +} diff --git a/jaxb/src/test/java/feign/jaxb/examples/package-info.java b/jaxb/src/test/java/feign/jaxb/examples/package-info.java new file mode 100644 index 0000000000..0038947aa9 --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/examples/package-info.java @@ -0,0 +1,17 @@ +/* + * Copyright 2014 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. + */ +@javax.xml.bind.annotation.XmlSchema(namespace = "https://iam.amazonaws.com/doc/2010-05-08/", elementFormDefault = javax.xml.bind.annotation.XmlNsForm.QUALIFIED) +package feign.jaxb.examples; 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..1560058f3c --- /dev/null +++ b/jaxrs/src/main/java/feign/jaxrs/JAXRSModule.java @@ -0,0 +1,133 @@ +/* + * 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()); + if (!pathValue.startsWith("/")) { + pathValue = "/" + pathValue; + } + 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()); + String methodAnnotationValue = Path.class.cast(methodAnnotation).value(); + if (!methodAnnotationValue.startsWith("/") && !data.template().toString().endsWith("/")) { + methodAnnotationValue = "/" + methodAnnotationValue; + } + data.template().append(methodAnnotationValue); + } 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..9a16e6c9c7 --- /dev/null +++ b/jaxrs/src/test/java/feign/jaxrs/JAXRSContractTest.java @@ -0,0 +1,375 @@ +/* + * 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.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.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)); + } + + @Path("base") + interface PathsWithoutAnySlashes { + @GET @Path("specific") Response get(); + } + + @Test public void pathsWithoutSlashesParseCorrectly() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(PathsWithoutAnySlashes.class.getDeclaredMethod("get")); + assertEquals(md.template().url(), "/base/specific"); + } + + @Path("/base") + interface PathsWithSomeSlashes { + @GET @Path("specific") Response get(); + } + + @Test public void pathsWithSomeSlashesParseCorrectly() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(PathsWithSomeSlashes.class.getDeclaredMethod("get")); + assertEquals(md.template().url(), "/base/specific"); + } + + @Path("base") + interface PathsWithSomeOtherSlashes { + @GET @Path("/specific") Response get(); + } + + @Test public void pathsWithSomeOtherSlashesParseCorrectly() throws Exception { + MethodMetadata md = contract.parseAndValidatateMetadata(PathsWithSomeOtherSlashes.class.getDeclaredMethod("get")); + assertEquals(md.template().url(), "/base/specific"); + } +} 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..5e99424460 --- /dev/null +++ b/jaxrs/src/test/java/feign/jaxrs/examples/GitHubExample.java @@ -0,0 +1,69 @@ +/* + * 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.gson.GsonModule; +import feign.jaxrs.JAXRSModule; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import java.util.List; + +/** + * 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); + } + + 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 + ")"); + } + } + + /** + * 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(); + } + } +} 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..a6d79205a2 --- /dev/null +++ b/ribbon/src/main/java/feign/ribbon/LBClient.java @@ -0,0 +1,145 @@ +/* + * 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.charset()) + .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..0894ed4817 --- /dev/null +++ b/ribbon/src/main/java/feign/ribbon/LoadBalancingTarget.java @@ -0,0 +1,116 @@ +/* + * 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 (obj == null) + return false; + 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..5dc36aeb75 --- /dev/null +++ b/ribbon/src/main/java/feign/ribbon/RibbonModule.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.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) { + if (e.getCause() instanceof IOException) { + throw IOException.class.cast(e.getCause()); + } + 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..70c34bc8f8 --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/LoadBalancingTargetTest.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.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 feign.Util.UTF_8; +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().setBody("success!".getBytes(UTF_8))); + server1.play(); + MockWebServer server2 = new MockWebServer(); + server2.enqueue(new MockResponse().setBody("success!".getBytes(UTF_8))); + server2.play(); + + getConfigInstance().setProperty(serverListKey, hostAndPort(server1.getUrl("")) + "," + hostAndPort(server2.getUrl(""))); + + try { + LoadBalancingTarget target = LoadBalancingTarget.create(TestInterface.class, "http://" + name); + TestInterface api = Feign.builder().target(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..fb97b8debd --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/RibbonClientTest.java @@ -0,0 +1,155 @@ +/* + * 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 com.google.mockwebserver.SocketPolicy; +import dagger.Provides; +import feign.Feign; +import feign.RequestLine; +import feign.codec.Decoder; +import feign.codec.Encoder; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.net.URL; + +import static com.netflix.config.ConfigurationManager.getConfigInstance; +import static feign.Util.UTF_8; +import static org.testng.Assert.assertEquals; + +import javax.inject.Named; + +@Test +public class RibbonClientTest { + interface TestInterface { + @RequestLine("POST /") void post(); + @RequestLine("GET /?a={a}") void getWithQueryParameters(@Named("a") String a); + + @dagger.Module(injects = Feign.class, overrides = true, addsTo = Feign.Defaults.class) + static class Module { + @Provides Decoder defaultDecoder() { + return new Decoder.Default(); + } + + @Provides Encoder defaultEncoder() { + return new Encoder.Default(); + } + } + } + + @Test + public void loadBalancingDefaultPolicyRoundRobin() throws IOException, InterruptedException { + String client = "RibbonClientTest-loadBalancingDefaultPolicyRoundRobin"; + String serverListKey = client + ".ribbon.listOfServers"; + + MockWebServer server1 = new MockWebServer(); + server1.enqueue(new MockResponse().setBody("success!".getBytes(UTF_8))); + server1.play(); + MockWebServer server2 = new MockWebServer(); + server2.enqueue(new MockResponse().setBody("success!".getBytes(UTF_8))); + server2.play(); + + getConfigInstance().setProperty(serverListKey, hostAndPort(server1.getUrl("")) + "," + hostAndPort(server2.getUrl(""))); + + try { + + TestInterface api = Feign.create(TestInterface.class, "http://" + client, new TestInterface.Module(), 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); + } + } + + @Test + public void ioExceptionRetry() throws IOException, InterruptedException { + String client = "RibbonClientTest-ioExceptionRetry"; + String serverListKey = client + ".ribbon.listOfServers"; + + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + server.enqueue(new MockResponse().setBody("success!".getBytes(UTF_8))); + server.play(); + + getConfigInstance().setProperty(serverListKey, hostAndPort(server.getUrl(""))); + + try { + + TestInterface api = Feign.create(TestInterface.class, "http://" + client, new TestInterface.Module(), new RibbonModule()); + + api.post(); + + assertEquals(server.getRequestCount(), 2); + // TODO: verify ribbon stats match + // assertEquals(target.lb().getLoadBalancerStats().getSingleServerStat()) + } finally { + server.shutdown(); + getConfigInstance().clearProperty(serverListKey); + } + } + + /* + This test-case replicates a bug that occurs when using RibbonRequest with a query string. + + The querystrings would not be URL-encoded, leading to invalid HTTP-requests if the query string contained + invalid characters (ex. space). + */ + @Test public void urlEncodeQueryStringParameters () throws IOException, InterruptedException { + String client = "RibbonClientTest-urlEncodeQueryStringParameters"; + String serverListKey = client + ".ribbon.listOfServers"; + + String queryStringValue = "some string with space"; + String expectedQueryStringValue = "some+string+with+space"; + String expectedRequestLine = String.format("GET /?a=%s HTTP/1.1", expectedQueryStringValue); + + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("success!".getBytes(UTF_8))); + server.play(); + + getConfigInstance().setProperty(serverListKey, hostAndPort(server.getUrl(""))); + + try { + + TestInterface api = Feign.create(TestInterface.class, "http://" + client, new TestInterface.Module(), new RibbonModule()); + + api.getWithQueryParameters(queryStringValue); + + final String recordedRequestLine = server.takeRequest().getRequestLine(); + + assertEquals(recordedRequestLine, expectedRequestLine); + } finally { + server.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/sax/README.md b/sax/README.md new file mode 100644 index 0000000000..1c901ed653 --- /dev/null +++ b/sax/README.md @@ -0,0 +1,14 @@ +Sax Decoder +=================== + +This module adds support for decoding xml via SAX. + +Add this to your object graph like so: + +```java +api = Feign.builder() + .decoder(SAXDecoder.builder() + .registerContentHandler(UserIdHandler.class) + .build()) + .target(Api.class, "https://apihost"); +``` diff --git a/sax/src/main/java/feign/sax/SAXDecoder.java b/sax/src/main/java/feign/sax/SAXDecoder.java new file mode 100644 index 0000000000..0afc817737 --- /dev/null +++ b/sax/src/main/java/feign/sax/SAXDecoder.java @@ -0,0 +1,168 @@ +/* + * 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.sax; + +import feign.Response; +import feign.codec.DecodeException; +import feign.codec.Decoder; +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.InputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Type; +import java.util.LinkedHashMap; +import java.util.Map; + +import static feign.Util.checkNotNull; +import static feign.Util.checkState; +import static feign.Util.ensureClosed; +import static feign.Util.resolveLastTypeParameter; + +/** + * Decodes responses using SAX, which is supported both in normal JVM environments, as well Android. + *
+ *

Basic example with with Feign.Builder

+ *
+ *
+ * api = Feign.builder()
+ *            .decoder(SAXDecoder.builder()
+ *                               .registerContentHandler(ContentHandlerForFoo.class)
+ *                               .registerContentHandler(ContentHandlerForBar.class)
+ *                               .build())
+ *            .target(MyApi.class, "http://api");
+ * 
+ *

+ *

Advanced example with Dagger

+ *
+ *
+ * @Provides
+ * Decoder saxDecoder(Provider<ContentHandlerForFoo> foo, //
+ *         Provider<ContentHandlerForBar> bar) {
+ *     return SAXDecoder.builder() //
+ *             .registerContentHandler(Foo.class, foo) //
+ *             .registerContentHandler(Bar.class, bar) //
+ *             .build();
+ * }
+ * 
+ */ +public class SAXDecoder implements Decoder { + + public static Builder builder() { + return new Builder(); + } + + // builder as dagger doesn't support wildcard bindings, map bindings, or set bindings of providers. + public static class Builder { + private final Map>> handlerProviders = + new LinkedHashMap>>(); + + /** + * Will call {@link Constructor#newInstance(Object...)} on {@code handlerClass} for each content stream. + *

+ *

Note

+ *
+ * While this is costly vs {@code new}, it may not affect real performance due to the high cost of reading streams. + * + * @throws IllegalArgumentException if there's no no-arg constructor on {@code handlerClass}. + */ + public > Builder registerContentHandler(Class handlerClass) { + Type type = resolveLastTypeParameter(checkNotNull(handlerClass, "handlerClass"), ContentHandlerWithResult.class); + return registerContentHandler(type, new NewInstanceProvider(handlerClass)); + } + + private static class NewInstanceProvider> implements Provider { + private final Constructor ctor; + + private NewInstanceProvider(Class clazz) { + try { + this.ctor = clazz.getDeclaredConstructor(); + // allow private or package protected ctors + ctor.setAccessible(true); + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException("ensure " + clazz + " has a no-args constructor", e); + } + } + + @Override public T get() { + try { + return ctor.newInstance(); + } catch (Exception e) { + throw new IllegalArgumentException("exception attempting to instantiate " + ctor, e); + } + } + } + + /** + * Will call {@link Provider#get()} on {@code handler} for each content stream. + * The {@code handler} is expected to have a generic parameter of {@code type}. + */ + public Builder registerContentHandler(Type type, Provider> handler) { + this.handlerProviders.put(checkNotNull(type, "type"), checkNotNull(handler, "handler")); + return this; + } + + public SAXDecoder build() { + return new SAXDecoder(handlerProviders); + } + } + + /** + * 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 Map>> handlerProviders; + + private SAXDecoder(Map>> handlerProviders) { + this.handlerProviders = handlerProviders; + } + + @Override + public Object decode(Response response, Type type) throws IOException, DecodeException { + if (response.body() == null) { + return null; + } + Provider> handlerProvider = handlerProviders.get(type); + checkState(handlerProvider != null, "type %s not in configured handlers %s", type, handlerProviders.keySet()); + ContentHandlerWithResult handler = handlerProvider.get(); + 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); + InputStream inputStream = response.body().asInputStream(); + try { + xmlReader.parse(new InputSource(inputStream)); + } finally { + ensureClosed(inputStream); + } + return handler.result(); + } catch (SAXException e) { + throw new DecodeException(e.getMessage(), e); + } + } +} diff --git a/sax/src/test/java/feign/sax/SAXDecoderTest.java b/sax/src/test/java/feign/sax/SAXDecoderTest.java new file mode 100644 index 0000000000..c4b9abf07c --- /dev/null +++ b/sax/src/test/java/feign/sax/SAXDecoderTest.java @@ -0,0 +1,145 @@ +/* + * 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.sax; + +import dagger.ObjectGraph; +import dagger.Provides; +import feign.Response; +import feign.codec.Decoder; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; +import org.xml.sax.helpers.DefaultHandler; + +import javax.inject.Inject; +import javax.inject.Provider; +import java.io.IOException; +import java.text.ParseException; +import java.util.Collection; +import java.util.Collections; + +import static org.testng.Assert.assertEquals; + +import static feign.Util.UTF_8; + +// unbound wildcards are not currently injectable in dagger. +@SuppressWarnings("rawtypes") +public class SAXDecoderTest { + + @dagger.Module(injects = SAXDecoderTest.class) + static class Module { + @Provides Decoder saxDecoder(Provider networkStatus) { + return SAXDecoder.builder() // + .registerContentHandler(NetworkStatus.class, networkStatus) // + .registerContentHandler(NetworkStatusStringHandler.class) // + .build(); + } + } + + @Inject Decoder decoder; + + @BeforeClass void inject() { + ObjectGraph.create(new Module()).inject(this); + } + + @Test public void parsesConfiguredTypes() throws ParseException, IOException { + assertEquals(decoder.decode(statusFailedResponse(), NetworkStatus.class), NetworkStatus.FAILED); + assertEquals(decoder.decode(statusFailedResponse(), String.class), "Failed"); + } + + @Test(expectedExceptions = IllegalStateException.class, expectedExceptionsMessageRegExp = + "type int not in configured handlers \\[class .*NetworkStatus, class java.lang.String\\]") + public void niceErrorOnUnconfiguredType() throws ParseException, IOException { + decoder.decode(statusFailedResponse(), int.class); + } + + private Response statusFailedResponse() { + return Response.create(200, "OK", Collections.>emptyMap(), statusFailed, UTF_8); + } + + static String statusFailed = ""// + + "\n"// + + " \n"// + + " \n"// + + " Failed\n"// + + " \n"// + + " \n"// + + ""; + + static enum NetworkStatus { + GOOD, FAILED; + } + + static class NetworkStatusStringHandler extends DefaultHandler implements + SAXDecoder.ContentHandlerWithResult { + @Inject NetworkStatusStringHandler() { + } + + private StringBuilder currentText = new StringBuilder(); + + private String status; + + @Override + public String result() { + return status; + } + + @Override + public void endElement(String uri, String name, String qName) { + if (qName.equals("NeustarNetworkStatus")) { + this.status = currentText.toString().trim(); + } + currentText = new StringBuilder(); + } + + @Override + public void characters(char ch[], int start, int length) { + currentText.append(ch, start, length); + } + } + + static class NetworkStatusHandler extends DefaultHandler implements + SAXDecoder.ContentHandlerWithResult { + @Inject NetworkStatusHandler() { + } + + private StringBuilder currentText = new StringBuilder(); + + private NetworkStatus status; + + @Override + public NetworkStatus result() { + return status; + } + + @Override + public void endElement(String uri, String name, String qName) { + if (qName.equals("NeustarNetworkStatus")) { + this.status = NetworkStatus.valueOf(currentText.toString().trim().toUpperCase()); + } + currentText = new StringBuilder(); + } + + @Override + public void characters(char ch[], int start, int length) { + currentText.append(ch, start, length); + } + } + + @Test public void nullBodyDecodesToNull() throws Exception { + Response response = Response.create(204, "OK", Collections.>emptyMap(), null); + assertEquals(decoder.decode(response, String.class), null); + } +} diff --git a/sax/src/test/java/feign/sax/examples/AWSSignatureVersion4.java b/sax/src/test/java/feign/sax/examples/AWSSignatureVersion4.java new file mode 100644 index 0000000000..c229587b8c --- /dev/null +++ b/sax/src/test/java/feign/sax/examples/AWSSignatureVersion4.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.sax.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; + synchronized (iso8601) { + 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)) + String bodyText = + input.charset() != null && input.body() != null ? new String(input.body(), input.charset()) : null; + if (bodyText != null) { + canonicalRequest.append(base16().lowerCase().encode(sha256().hashString(bodyText, 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 == null ? null : 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/sax/src/test/java/feign/sax/examples/IAMExample.java b/sax/src/test/java/feign/sax/examples/IAMExample.java new file mode 100644 index 0000000000..e00b7be493 --- /dev/null +++ b/sax/src/test/java/feign/sax/examples/IAMExample.java @@ -0,0 +1,87 @@ +/* + * 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.sax.examples; + +import feign.Feign; +import feign.Request; +import feign.RequestLine; +import feign.RequestTemplate; +import feign.Target; +import feign.sax.SAXDecoder; +import org.xml.sax.helpers.DefaultHandler; + +public class IAMExample { + + interface IAM { + @RequestLine("GET /?Action=GetUser&Version=2010-05-08") Long userId(); + } + + public static void main(String... args) { + IAM iam = Feign.builder()// + .decoder(SAXDecoder.builder().registerContentHandler(UserIdHandler.class).build())// + .target(new IAMTarget(args[0], args[1])); + System.out.println(iam.userId()); + } + + 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); + } + } + + static class UserIdHandler extends DefaultHandler implements SAXDecoder.ContentHandlerWithResult { + + private StringBuilder currentText = new StringBuilder(); + + private Long userId; + + @Override + public Long result() { + return userId; + } + + @Override + public void endElement(String uri, String name, String qName) { + if (qName.equals("UserId")) { + this.userId = Long.parseLong(currentText.toString().trim()); + } + currentText = new StringBuilder(); + } + + @Override + public void characters(char ch[], int start, int length) { + currentText.append(ch, start, length); + } + } +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000000..8f9b3cfd36 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,8 @@ +rootProject.name='feign' +include 'core', 'sax', 'gson', 'jackson', 'jaxb', 'jaxrs', 'ribbon', 'slf4j', 'example-github', 'example-wikipedia' + +rootProject.children.each { childProject -> + childProject.name = 'feign-' + childProject.name +} + + diff --git a/slf4j/README.md b/slf4j/README.md new file mode 100644 index 0000000000..e2c21fd0a2 --- /dev/null +++ b/slf4j/README.md @@ -0,0 +1,12 @@ +SLF4J +=================== + +This module allows directing Feign's logging to [SLF4J](http://www.slf4j.org/), allowing you to easily use a logging backend of your choice (Logback, Log4J, etc.) + +To use SLF4J with Feign, add both the SLF4J module and an SLF4J binding of your choice to your classpath. Then, configure Feign to use the Slf4jLogger: + +```java +GitHub github = Feign.builder() + .logger(new Slf4jLogger()) + .target(GitHub.class, "https://api.github.com"); +``` diff --git a/slf4j/src/main/java/feign/slf4j/Slf4jLogger.java b/slf4j/src/main/java/feign/slf4j/Slf4jLogger.java new file mode 100644 index 0000000000..724d7c60ba --- /dev/null +++ b/slf4j/src/main/java/feign/slf4j/Slf4jLogger.java @@ -0,0 +1,66 @@ +/* + * 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.slf4j; + +import feign.Request; +import feign.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** + * Logs to SLF4J at the debug level, if the underlying logger has debug logging enabled. The underlying logger can + * be specified at construction-time, defaulting to the logger for {@link feign.Logger}. + */ +public class Slf4jLogger extends feign.Logger { + private final Logger logger; + + public Slf4jLogger() { + this(feign.Logger.class); + } + + public Slf4jLogger(Class clazz) { + this(LoggerFactory.getLogger(clazz)); + } + + public Slf4jLogger(String name) { + this(LoggerFactory.getLogger(name)); + } + + Slf4jLogger(Logger logger) { + this.logger = logger; + } + + @Override protected void logRequest(String configKey, Level logLevel, Request request) { + if (logger.isDebugEnabled()) { + super.logRequest(configKey, logLevel, request); + } + } + + @Override protected Response logAndRebufferResponse(String configKey, Level logLevel, Response response, long elapsedTime) throws IOException { + if (logger.isDebugEnabled()) { + return super.logAndRebufferResponse(configKey, logLevel, response, elapsedTime); + } + return response; + } + + @Override protected void log(String configKey, String format, Object... args) { + // Not using SLF4J's support for parameterized messages (even though it would be more efficient) because it would + // require the incoming message formats to be SLF4J-specific. + logger.debug(String.format(methodTag(configKey) + format, args)); + } +} diff --git a/slf4j/src/test/java/feign/slf4j/ReflectionUtil.java b/slf4j/src/test/java/feign/slf4j/ReflectionUtil.java new file mode 100644 index 0000000000..2fa083bc68 --- /dev/null +++ b/slf4j/src/test/java/feign/slf4j/ReflectionUtil.java @@ -0,0 +1,37 @@ +/* + * 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.slf4j; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +/** + * Lightweight approach to using reflection to bypass access restrictions for testing. If this class grows, it may be + * better to use a testing library instead, such as Powermock. + */ +class ReflectionUtil { + static void setStaticField(Class declaringClass, String fieldName, Object fieldValue) throws Exception { + Field field = declaringClass.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(null, fieldValue); + } + + static void invokeVoidNoArgMethod(Class declaringClass, String methodName, Object instance) throws Exception { + Method method = declaringClass.getDeclaredMethod(methodName); + method.setAccessible(true); + method.invoke(instance); + } +} diff --git a/slf4j/src/test/java/feign/slf4j/SimpleLoggerUtil.java b/slf4j/src/test/java/feign/slf4j/SimpleLoggerUtil.java new file mode 100644 index 0000000000..e676e1470e --- /dev/null +++ b/slf4j/src/test/java/feign/slf4j/SimpleLoggerUtil.java @@ -0,0 +1,47 @@ +/* + * 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.slf4j; + +import org.slf4j.LoggerFactory; +import org.slf4j.impl.SimpleLogger; +import org.slf4j.impl.SimpleLoggerFactory; + +import java.io.File; + +/** + * A testing utility to allow control over {@link SimpleLogger}. In some cases, reflection is used to bypass access + * restrictions. + */ +class SimpleLoggerUtil { + static void initialize(File file, String logLevel) throws Exception { + System.setProperty(SimpleLogger.SHOW_THREAD_NAME_KEY, "false"); + System.setProperty(SimpleLogger.LOG_FILE_KEY, file.getAbsolutePath()); + System.setProperty(SimpleLogger.DEFAULT_LOG_LEVEL_KEY, logLevel); + resetSlf4j(); + } + + static void resetToDefaults() throws Exception { + System.clearProperty(SimpleLogger.SHOW_THREAD_NAME_KEY); + System.clearProperty(SimpleLogger.LOG_FILE_KEY); + System.clearProperty(SimpleLogger.DEFAULT_LOG_LEVEL_KEY); + resetSlf4j(); + } + + private static void resetSlf4j() throws Exception { + ReflectionUtil.setStaticField(SimpleLogger.class, "INITIALIZED", false); + ReflectionUtil.invokeVoidNoArgMethod(SimpleLoggerFactory.class, "reset", LoggerFactory.getILoggerFactory()); + } +} diff --git a/slf4j/src/test/java/feign/slf4j/Slf4jLoggerTest.java b/slf4j/src/test/java/feign/slf4j/Slf4jLoggerTest.java new file mode 100644 index 0000000000..8b4ec16f2c --- /dev/null +++ b/slf4j/src/test/java/feign/slf4j/Slf4jLoggerTest.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.slf4j; + +import feign.Feign; +import feign.Logger; +import feign.Request; +import feign.RequestTemplate; +import feign.Response; +import feign.Util; +import org.slf4j.LoggerFactory; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.FileReader; +import java.util.Collection; +import java.util.Collections; + +import static org.testng.Assert.assertEquals; + +public class Slf4jLoggerTest { + private static final String CONFIG_KEY = "someMethod()"; + private static final Request REQUEST = + new RequestTemplate().method("GET").append("http://api.example.com").request(); + private static final Response RESPONSE = + Response.create(200, "OK", Collections.>emptyMap(), new byte[0]); + + private File logFile; + private Slf4jLogger logger; + + @AfterMethod + void tearDown() throws Exception { + SimpleLoggerUtil.resetToDefaults(); + logFile.delete(); + } + + @Test public void useFeignLoggerByDefault() throws Exception { + initializeSimpleLogger("debug"); + logger = new Slf4jLogger(); + logger.log(CONFIG_KEY, "This is my message"); + assertLoggedMessages("DEBUG feign.Logger - [someMethod] This is my message\n"); + } + + @Test public void useLoggerByNameIfRequested() throws Exception { + initializeSimpleLogger("debug"); + logger = new Slf4jLogger("named.logger"); + logger.log(CONFIG_KEY, "This is my message"); + assertLoggedMessages("DEBUG named.logger - [someMethod] This is my message\n"); + } + + @Test public void useLoggerByClassIfRequested() throws Exception { + initializeSimpleLogger("debug"); + logger = new Slf4jLogger(Feign.class); + logger.log(CONFIG_KEY, "This is my message"); + assertLoggedMessages("DEBUG feign.Feign - [someMethod] This is my message\n"); + } + + @Test public void useSpecifiedLoggerIfRequested() throws Exception { + initializeSimpleLogger("debug"); + logger = new Slf4jLogger(LoggerFactory.getLogger("specified.logger")); + logger.log(CONFIG_KEY, "This is my message"); + assertLoggedMessages("DEBUG specified.logger - [someMethod] This is my message\n"); + } + + @Test public void logOnlyIfDebugEnabled() throws Exception { + initializeSimpleLogger("info"); + logger = new Slf4jLogger(); + logger.log(CONFIG_KEY, "A message with %d formatting %s.", 2, "tokens"); + logger.logRequest(CONFIG_KEY, Logger.Level.BASIC, REQUEST); + logger.logAndRebufferResponse(CONFIG_KEY, Logger.Level.BASIC, RESPONSE, 273); + assertLoggedMessages(""); + } + + @Test public void logRequestsAndResponses() throws Exception { + initializeSimpleLogger("debug"); + logger = new Slf4jLogger(); + logger.log(CONFIG_KEY, "A message with %d formatting %s.", 2, "tokens"); + logger.logRequest(CONFIG_KEY, Logger.Level.BASIC, REQUEST); + logger.logAndRebufferResponse(CONFIG_KEY, Logger.Level.BASIC, RESPONSE, 273); + assertLoggedMessages( + "DEBUG feign.Logger - [someMethod] A message with 2 formatting tokens.\n" + + "DEBUG feign.Logger - [someMethod] ---> GET http://api.example.com HTTP/1.1\n" + + "DEBUG feign.Logger - [someMethod] <--- HTTP/1.1 200 OK (273ms)\n" + ); + } + + private void initializeSimpleLogger(String logLevel) throws Exception { + logFile = File.createTempFile(getClass().getName(), ".log"); + SimpleLoggerUtil.initialize(logFile, logLevel); + } + + private void assertLoggedMessages(String expectedMessages) throws Exception { + assertEquals(Util.toString(new FileReader(logFile)), expectedMessages); + } +}