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/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..458df6ca52 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,22 @@ +language: java +sudo: false +notifications: + webhooks: + urls: + - https://webhooks.gitter.im/e/110a7c0daf817ba48ccc + on_success: change # options: [always|never|change] default: always + on_failure: always # options: [always|never|change] default: always + on_start: false # default: false +jdk: +- oraclejdk7 +install: ./installViaTravis.sh +script: ./buildViaTravis.sh +cache: + directories: + - $HOME/.gradle/caches/ +env: + global: + - secure: Ps7+oTi5FfjwJ7tQ8G51wk30ZHqB1P4ZNXsGisn+r/8IJRxPs6VNIF/dPSkWLERs1tBvMG2sRz9nxaIwxXJwHXOkoBL/SXZHzOYwKTlsNcu72B6czxepIb0OAGWKAv6aCk9vAJkRzGX2Ogy+Hnn8AuQlPAPOplRjZm1AXj6smGM= + - secure: C/RoUcQGZ6wB1nHnLN7dGMCbpjOObaviuXFxv5ZtocKfALmOZg6gOye5/LyJwvLwMaKtI434dHFvY29FIO0ntclx48xPYCjg6GsmzJOwQcwlLqIV1HQMczFDiYlMFSUbHn9d+JbwXxdd13g98aHtEif73bI0SXevyiqv4n/XsVo= + - secure: LfLmAImQdX2LksJNJvo5R2tX/VEmBSudVgkZBIUhcTObmxcNvBzue0QyLa6w107s9U5G6PxfPOv4BB3qZogC3FmsY/qQus2JV9/0eP/hGVNZER1FlAe5mgHgzaoa39qNLQYdyb0jAmIR0r0X0DcF6yR+IAgj4rbN/wzXLc1Cw+s= + - secure: XYdDt7fPTpIX2qvBbin4VR3ndfQ00xyebokpw0eXYQ6yvbS2H3lhFqBKuVnN40MTJXNL8ZBIm/wVe67QdAP0uJNlq6YhOf35XY45TfLTSZ0zJd+nJZMDIi8P0zrKDxv6vxnceaZwTrJy8Q1JiUrG4VA4Hb/T4zqftzvad9RT7lc= diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..c49d3b08ba --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,176 @@ +### Version 8.8 +* Adds jackson-jaxb codec +* Bumps dependency versions for integrations + * OkHttp/MockWebServer 2.5.0 + * Jackson 2.6.1 + * Apache Http Client 4.5 + * JMH 1.10.5 + +### Version 8.7 +* Bumps dependency versions for integrations + * OkHttp/MockWebServer 2.4.0 + * Gson 2.3.1 + * Jackson 2.6.0 + * Ribbon 2.1.0 + * SLF4J 1.7.12 + +### Version 8.6 +* Adds base api support via single-inheritance interfaces + +### Version 7.5/8.5 +* Added possibility to leave slash encoded in path parameters + +### Version 8.4 +* Correct Retryer bug that prevented it from retrying requests after the first 5 retry attempts. + * **Note:** If you have a custom `feign.Retryer` implementation you now must now implement `public Retryer clone()`. + It is suggested that you simply return a new instance of your Retryer class. + +### Version 8.3 +* Adds client implementation for Apache Http Client + +### Version 8.2 +* Allows customized request construction by exposing `Request.create()` +* Adds JMH benchmark module +* Enforces source compatibility with animal-sniffer + +### Version 8.1 +* Allows `@Headers` to be applied to a type + +### Version 8.0 +* Removes Dagger 1.x Dependency +* Removes support for parameters annotated with `javax.inject.@Named`. Use `feign.@Param` instead. +* Makes body parameter type explicit. + +### Version 7.4 +* Allows `@Headers` to be applied to a type + +### Version 7.3 +* Adds Request.Options support to RibbonClient +* Adds LBClientFactory to enable caching of Ribbon LBClients +* Updates to Ribbon 2.0-RC13 +* Updates to Jackson 2.5.1 +* Supports query parameters without values + +### Version 7.2 +* Adds `Feign.Builder.build()` +* Opens constructor for Gson and Jackson codecs which accepts type adapters +* Adds EmptyTarget for interfaces who exclusively declare URI methods +* Reformats code according to [Google Java Style](https://google-styleguide.googlecode.com/svn/trunk/javaguide.html) + +### Version 7.1 +* Introduces feign.@Param to annotate template parameters. Users must migrate from `javax.inject.@Named` to `feign.@Param` before updating to Feign 8.0. + * Supports custom expansion via `@Param(value = "name", expander = CustomExpander.class)` +* Adds OkHttp integration +* Allows multiple headers with the same name. +* Ensures Accept headers default to `*/*` + +### Version 7.0 +* Expose reflective dispatch hook: InvocationHandlerFactory +* Add JAXB integration +* Add SLF4J integration +* Upgrade to Dagger 1.2.2. + * **Note:** Dagger-generated code prior to version 1.2.0 is incompatible with Dagger 1.2.0 and beyond. Dagger users should upgrade Dagger to at least version 1.2.0, and recompile any dependency-injected classes. + +### Version 6.1.3 +* Updates to Ribbon 2.0-RC5 + +### 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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..d843b8d1b7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to Feign + +If you would like to contribute code you can do so through GitHub by forking the repository and sending a pull request (on a branch other than `master` or `gh-pages`). + +When submitting code, please ensure you follow the [Google Style Guide](http://google-styleguide.googlecode.com/svn/trunk/javaguide.html). For example, you can format code with Intellij using [this file](https://google-styleguide.googlecode.com/svn/trunk/intellij-java-google-style.xml). + +## License + +By contributing your code, you agree to license your contribution under the terms of the APLv2: https://github.com/Netflix/Feign/blob/master/LICENSE + +All files are released with the Apache 2.0 license. + +If you are adding a new file it should have a header like this: + +``` +/** + * 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. + */ + ``` 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..11567fc000 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,331 @@ -feign -===== +# Feign makes writing java http clients easier + +[![Join the chat at https://gitter.im/Netflix/feign](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/Netflix/feign?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +Feign is a java to http client binder inspired by [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/samples/github-client/src/main/java/com/example/retrofit/GitHubClient.java). + +```java +interface GitHub { + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("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(@Param("id") String id); +} +... +Bank bank = Feign.builder().decoder(new AccountDecoder()).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 +Feign feign = Feign.builder().build(); +CloudDNS cloudDNS = feign.target(new CloudIdentityTarget(user, apiKey)); +``` + +### Examples +Feign includes example [GitHub](https://github.com/Netflix/feign/tree/master/example-github) and [Wikipedia](https://github.com/Netflix/feign/tree/master/example-wikipedia) clients. The denominator project can also be scraped for Feign in practice. Particularly, look at its [example daemon](https://github.com/Netflix/denominator/tree/master/example-daemon). + +### Integrations +Feign intends to work well within Netflix and other Open Source communities. Modules are welcome to integrate with your favorite projects! + +### Gson +[Gson](https://github.com/Netflix/feign/tree/master/gson) includes an encoder and decoder you can use 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 +[Jackson](https://github.com/Netflix/feign/tree/master/jackson) includes 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 +[JAXB](https://github.com/Netflix/feign/tree/master/jaxb) includes an encoder and decoder you can use with an XML API. + +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 +[JAXRSContract](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); +} +``` +```java +GitHub github = Feign.builder() + .contract(new JAXRSContract()) + .target(GitHub.class, "https://api.github.com"); +``` +### OkHttp +[OkHttpClient](https://github.com/Netflix/feign/tree/master/okhttp) directs Feign's http requests to [OkHttp](http://square.github.io/okhttp/), which enables SPDY and better network control. + +To use OkHttp with Feign, add the OkHttp module to your classpath. Then, configure Feign to use the OkHttpClient: + +```java +GitHub github = Feign.builder() + .client(new OkHttpClient()) + .target(GitHub.class, "https://api.github.com"); +``` + +### Ribbon +[RibbonClient](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.builder().client(RibbonClient.create()).target(MyService.class, "https://myAppProd"); + +``` + +### 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 +The simplest way to send a request body to a server is to define a `POST` method that has a `String` or `byte[]` parameter without any annotations on it. You will likely need to add a `Content-Type` header. + +```java +interface LoginClient { + @RequestLine("POST /") + @Headers("Content-Type: application/json") + void login(String content); +} +... +client.login("{\"user_name\": \"denominator\", \"password\": \"secret\"}"); +``` + +By configuring an `Encoder`, you can send a type-safe request body. Here's an example using the `feign-gson` extension: + +```java +static class Credentials { + final String user_name; + final String password; + + Credentials(String user_name, String password) { + this.user_name = user_name; + this.password = password; + } +} + +interface LoginClient { + @RequestLine("POST /") + void login(Credentials creds); +} +... +LoginClient client = Feign.builder() + .encoder(new GsonEncoder()) + .target(LoginClient.class, "https://foo.com"); + +client.login(new Credentials("denominator", "secret")); +``` + +### @Body templates +The `@Body` annotation indicates a template to expand using parameters annotated with `@Param`. You will likely need to add a `Content-Type` header. + +```java +interface LoginClient { + + @RequestLine("POST /") + @Headers("Content-Type: application/xml") + @Body("") + void xml(@Param("user_name") String user, @Param("password") String password); + + @RequestLine("POST /") + @Headers("Content-Type: application/json") + // json curly braces must be escaped! + @Body("%7B\"user_name\": \"{user_name}\", \"password\": \"{password}\"%7D") + void json(@Param("user_name") String user, @Param("password") String password); +} +... +client.xml("denominator", "secret"); // +client.json("denominator", "secret"); // {"user_name": "denominator", "password": "secret"} +``` + +### Advanced usage + +#### Base Apis +In many cases, apis for a service follow the same conventions. Feign supports this pattern via single-inheritance interfaces. + +Consider the example: +```java +interface BaseAPI { + @RequestLine("GET /health") + String health(); + + @RequestLine("GET /all") + List all(); +} +``` + +You can define and target a specific api, inheriting the base methods. +```java +interface CustomAPI extends BaseAPI { + @RequestLine("GET /custom") + String custom(); +} +``` + +In many cases, resource representations are also consistent. For this reason, type parameters are supported on the base api interface. + +```java +@Headers("Accept: application/json") +interface BaseApi { + + @RequestLine("GET /api/{key}") + V get(@Param("key") String); + + @RequestLine("GET /api") + List list(); + + @Headers("Content-Type: application/json") + @RequestLine("PUT /api/{key}") + void put(@Param("key") String, V value); +} + +interface FooApi extends BaseApi { } + +interface BarApi extends BaseApi { } +``` + +#### 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 SLF4JLogger (see above) may also be of interest. + + +#### 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"); +``` + +#### Custom @Param Expansion +Parameters annotated with `Param` expand based on their `toString`. By +specifying a custom `Param.Expander`, users can control this behavior, +for example formatting dates. + +```java +@RequestLine("GET /?since={date}") Result list(@Param(value = "date", expander = DateToMillis.class) Date date); +``` diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000000..43decf7825 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,10 @@ +Feign Benchmarks +=================== + +This module includes [JMH](http://openjdk.java.net/projects/code-tools/jmh/) benchmarks for Feign. + +=== Building the benchmark +Install and run `mvn -Dfeign.version=8.1.0` to produce `target/benchmark` pinned to version `8.1.0` + +=== Running the benchmark +Execute `target/benchmark` diff --git a/benchmark/pom.xml b/benchmark/pom.xml new file mode 100644 index 0000000000..ff7abe2400 --- /dev/null +++ b/benchmark/pom.xml @@ -0,0 +1,114 @@ + + + 4.0.0 + + + org.sonatype.oss + oss-parent + 9 + + + com.netflix.feign + feign-benchmark + jar + 8.1.0-SNAPSHOT + Feign Benchmark (JMH) + + + 1.10.5 + + + + + com.netflix.feign + feign-core + ${project.version} + + + com.netflix.feign + feign-okhttp + ${project.version} + + + com.squareup.okhttp + mockwebserver + 2.5.0 + + + org.bouncycastle + bcprov-jdk15on + + + + + io.reactivex + rxnetty + 0.4.11 + + + io.reactivex + rxjava + 1.0.13 + + + io.netty + netty-codec-http + 4.0.30.Final + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + provided + + + + + package + + + org.apache.maven.plugins + maven-shade-plugin + 2.4.1 + + + package + + shade + + + + + org.openjdk.jmh.Main + + + false + + + + + + org.skife.maven + really-executable-jar-maven-plugin + 1.4.1 + + benchmark + + + + package + + really-executable-jar + + + + + + + diff --git a/benchmark/src/main/java/feign/benchmark/FeignTestInterface.java b/benchmark/src/main/java/feign/benchmark/FeignTestInterface.java new file mode 100644 index 0000000000..bfe66619b2 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/FeignTestInterface.java @@ -0,0 +1,37 @@ +package feign.benchmark; + +import java.util.List; + +import feign.Body; +import feign.Headers; +import feign.Param; +import feign.RequestLine; +import feign.Response; + +@Headers("Accept: application/json") +interface FeignTestInterface { + + @RequestLine("GET /?Action=GetUser&Version=2010-05-08&limit=1") + Response query(); + + @RequestLine("GET /domains/{domainId}/records?name={name}&type={type}") + Response mixedParams(@Param("domainId") int id, + @Param("name") String nameFilter, + @Param("type") String typeFilter); + + @RequestLine("PATCH /") + Response customMethod(); + + @RequestLine("PUT /") + @Headers("Content-Type: application/json") + void bodyParam(List body); + + @RequestLine("POST /") + @Body("%7B\"customer_name\": \"{customer_name}\", \"user_name\": \"{user_name}\", \"password\": \"{password}\"%7D") + void form(@Param("customer_name") String customer, @Param("user_name") String user, + @Param("password") String password); + + @RequestLine("POST /") + @Headers({"Happy: sad", "Auth-Token: {authToken}"}) + void headers(@Param("authToken") String token); +} diff --git a/benchmark/src/main/java/feign/benchmark/RealRequestBenchmarks.java b/benchmark/src/main/java/feign/benchmark/RealRequestBenchmarks.java new file mode 100644 index 0000000000..fdbd401f29 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/RealRequestBenchmarks.java @@ -0,0 +1,85 @@ +package feign.benchmark; + +import com.squareup.okhttp.OkHttpClient; +import com.squareup.okhttp.Request; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import feign.Feign; +import feign.Response; +import io.netty.buffer.ByteBuf; +import io.reactivex.netty.RxNetty; +import io.reactivex.netty.protocol.http.server.HttpServer; +import io.reactivex.netty.protocol.http.server.HttpServerRequest; +import io.reactivex.netty.protocol.http.server.HttpServerResponse; +import io.reactivex.netty.protocol.http.server.RequestHandler; + +@Measurement(iterations = 5, time = 1) +@Warmup(iterations = 10, time = 1) +@Fork(3) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@State(Scope.Benchmark) +public class RealRequestBenchmarks { + + private static final int SERVER_PORT = 8765; + private HttpServer server; + private OkHttpClient client; + private FeignTestInterface okFeign; + private Request queryRequest; + + @Setup + public void setup() { + server = RxNetty.createHttpServer(SERVER_PORT, new RequestHandler() { + public rx.Observable handle(HttpServerRequest request, + HttpServerResponse response) { + return response.flush(); + } + }); + server.start(); + client = new OkHttpClient(); + client.setRetryOnConnectionFailure(false); + okFeign = Feign.builder() + .client(new feign.okhttp.OkHttpClient(client)) + .target(FeignTestInterface.class, "http://localhost:" + SERVER_PORT); + queryRequest = new Request.Builder() + .url("http://localhost:" + SERVER_PORT + "/?Action=GetUser&Version=2010-05-08&limit=1") + .build(); + } + + @TearDown + public void tearDown() throws InterruptedException { + server.shutdown(); + } + + /** + * How fast can we execute get commands synchronously? + */ + @Benchmark + public com.squareup.okhttp.Response query_baseCaseUsingOkHttp() throws IOException { + com.squareup.okhttp.Response result = client.newCall(queryRequest).execute(); + result.body().close(); + return result; + } + + /** + * How fast can we execute get commands synchronously using Feign? + */ + @Benchmark + public Response query_feignUsingOkHttp() { + return okFeign.query(); + } +} diff --git a/benchmark/src/main/java/feign/benchmark/WhatShouldWeCacheBenchmarks.java b/benchmark/src/main/java/feign/benchmark/WhatShouldWeCacheBenchmarks.java new file mode 100644 index 0000000000..239e7b7550 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/WhatShouldWeCacheBenchmarks.java @@ -0,0 +1,109 @@ +package feign.benchmark; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +import java.io.IOException; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import feign.Client; +import feign.Contract; +import feign.Feign; +import feign.MethodMetadata; +import feign.Request; +import feign.Response; +import feign.Target.HardCodedTarget; + +@Measurement(iterations = 5, time = 1) +@Warmup(iterations = 10, time = 1) +@Fork(3) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@State(Scope.Thread) +public class WhatShouldWeCacheBenchmarks { + + private Contract feignContract; + private Contract cachedContact; + private Client fakeClient; + private Feign cachedFakeFeign; + private FeignTestInterface cachedFakeApi; + + @Setup + public void setup() { + feignContract = new Contract.Default(); + cachedContact = new Contract() { + private final List cached = + new Default().parseAndValidatateMetadata(FeignTestInterface.class); + + public List parseAndValidatateMetadata(Class declaring) { + return cached; + } + }; + fakeClient = new Client() { + public Response execute(Request request, Request.Options options) throws IOException { + Map> headers = new LinkedHashMap>(); + return Response.create(200, "ok", headers, (byte[]) null); + } + }; + cachedFakeFeign = Feign.builder().client(fakeClient).build(); + cachedFakeApi = cachedFakeFeign.newInstance( + new HardCodedTarget(FeignTestInterface.class, "http://localhost")); + } + + /** + * How fast is parsing an api interface? + */ + @Benchmark + public List parseFeignContract() { + return feignContract.parseAndValidatateMetadata(FeignTestInterface.class); + } + + /** + * How fast is creating a feign instance for each http request, without considering network? + */ + @Benchmark + public Response buildAndQuery_fake() { + return Feign.builder().client(fakeClient) + .target(FeignTestInterface.class, "http://localhost").query(); + } + + /** + * How fast is creating a feign instance for each http request, without considering network, and + * without re-parsing the annotated http api? + */ + @Benchmark + public Response buildAndQuery_fake_cachedContract() { + return Feign.builder().contract(cachedContact).client(fakeClient) + .target(FeignTestInterface.class, "http://localhost").query(); + } + + /** + * How fast re-parsing the annotated http api for each http request, without considering network? + */ + @Benchmark + public Response buildAndQuery_fake_cachedFeign() { + return cachedFakeFeign.newInstance( + new HardCodedTarget(FeignTestInterface.class, "http://localhost")) + .query(); + } + + /** + * How fast is our advice to use a cached api for each http request, without considering network? + */ + @Benchmark + public Response buildAndQuery_fake_cachedApi() { + return cachedFakeApi.query(); + } +} diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000000..d976e49e16 --- /dev/null +++ b/build.gradle @@ -0,0 +1,28 @@ +buildscript { + repositories { jcenter() } + dependencies { + classpath 'be.insaneprogramming.gradle:animalsniffer-gradle-plugin:1.4.0' + } +} + +plugins { + id 'nebula.netflixoss' version '2.2.10' +} + +ext { + githubProjectName = rootProject.name // Change if github project name is not the same as the root project's name +} + +subprojects { + apply plugin: 'nebula.netflixoss' + + repositories { + jcenter() + } + group = "com.netflix.${githubProjectName}" // TEMPLATE: Set to organization of project + apply plugin: 'be.insaneprogramming.gradle.animalsniffer' + + animalsniffer { // Don't use apis that may not be available on Android + signature = "org.codehaus.mojo.signature:java16:+@signature" + } +} diff --git a/buildViaTravis.sh b/buildViaTravis.sh new file mode 100755 index 0000000000..17a33a5fb9 --- /dev/null +++ b/buildViaTravis.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# This script will build the project. + +if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then + echo -e "Build Pull Request #$TRAVIS_PULL_REQUEST => Branch [$TRAVIS_BRANCH]" + ./gradlew build +elif [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_TAG" == "" ]; then + echo -e 'Build Branch with Snapshot => Branch ['$TRAVIS_BRANCH']' + ./gradlew -Prelease.travisci=true -PbintrayUser="${bintrayUser}" -PbintrayKey="${bintrayKey}" -PsonatypeUsername="${sonatypeUsername}" -PsonatypePassword="${sonatypePassword}" build snapshot +elif [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_TAG" != "" ]; then + echo -e 'Build Branch for Release => Branch ['$TRAVIS_BRANCH'] Tag ['$TRAVIS_TAG']' + case "$TRAVIS_TAG" in + *-rc\.*) + ./gradlew -Prelease.travisci=true -Prelease.useLastTag=true -PbintrayUser="${bintrayUser}" -PbintrayKey="${bintrayKey}" -PsonatypeUsername="${sonatypeUsername}" -PsonatypePassword="${sonatypePassword}" candidate + ;; + *) + ./gradlew -Prelease.travisci=true -Prelease.useLastTag=true -PbintrayUser="${bintrayUser}" -PbintrayKey="${bintrayKey}" -PsonatypeUsername="${sonatypeUsername}" -PsonatypePassword="${sonatypePassword}" final + ;; + esac +else + echo -e 'WARN: Should not be here => Branch ['$TRAVIS_BRANCH'] Tag ['$TRAVIS_TAG'] Pull Request ['$TRAVIS_PULL_REQUEST']' + ./gradlew build +fi 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/build.gradle b/core/build.gradle new file mode 100644 index 0000000000..0adc32f471 --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,10 @@ +apply plugin: 'java' + +sourceCompatibility = 1.6 + +dependencies { + testCompile 'junit:junit:4.12' + testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7 + testCompile 'com.squareup.okhttp:mockwebserver:2.5.0' + testCompile 'com.google.code.gson:gson:2.3.1' // for example +} diff --git a/core/src/main/java/feign/Body.java b/core/src/main/java/feign/Body.java new file mode 100644 index 0000000000..1c9d58a3c0 --- /dev/null +++ b/core/src/main/java/feign/Body.java @@ -0,0 +1,26 @@ +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(@Param("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..5edc02916e --- /dev/null +++ b/core/src/main/java/feign/Client.java @@ -0,0 +1,174 @@ +/* + * 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.DeflaterOutputStream; +import java.util.zip.GZIPOutputStream; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; + +import feign.Request.Options; + +import static feign.Util.CONTENT_ENCODING; +import static feign.Util.CONTENT_LENGTH; +import static feign.Util.ENCODING_DEFLATE; +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 SSLSocketFactory sslContextFactory; + private final HostnameVerifier hostnameVerifier; + + /** + * Null parameters imply platform defaults. + */ + public Default(SSLSocketFactory sslContextFactory, HostnameVerifier 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; + if (sslContextFactory != null) { + sslCon.setSSLSocketFactory(sslContextFactory); + } + if (hostnameVerifier != null) { + sslCon.setHostnameVerifier(hostnameVerifier); + } + } + 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); + boolean + deflateEncodedRequest = + contentEncodingValues != null && contentEncodingValues.contains(ENCODING_DEFLATE); + + boolean hasAcceptHeader = false; + Integer contentLength = null; + for (String field : request.headers().keySet()) { + if (field.equalsIgnoreCase("Accept")) { + hasAcceptHeader = true; + } + for (String value : request.headers().get(field)) { + if (field.equals(CONTENT_LENGTH)) { + if (!gzipEncodedRequest && !deflateEncodedRequest) { + contentLength = Integer.valueOf(value); + connection.addRequestProperty(field, value); + } + } else { + connection.addRequestProperty(field, value); + } + } + } + // Some servers choke on the default accept string. + if (!hasAcceptHeader) { + connection.addRequestProperty("Accept", "*/*"); + } + + 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); + } else if (deflateEncodedRequest) { + out = new DeflaterOutputStream(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..3cc6d12849 --- /dev/null +++ b/core/src/main/java/feign/Contract.java @@ -0,0 +1,282 @@ +/* + * 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.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +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. + * + * @param targetType {@link feign.Target#type() type} of the Feign interface. + */ + // TODO: break this and correct spelling at some point + List parseAndValidatateMetadata(Class targetType); + + abstract class BaseContract implements Contract { + + @Override + public List parseAndValidatateMetadata(Class targetType) { + checkState(targetType.getTypeParameters().length == 0, "Parameterized types unsupported: %s", + targetType.getSimpleName()); + checkState(targetType.getInterfaces().length <= 1, "Only single inheritance supported: %s", + targetType.getSimpleName()); + if (targetType.getInterfaces().length == 1) { + checkState(targetType.getInterfaces()[0].getInterfaces().length == 0, + "Only single-level inheritance supported: %s", + targetType.getSimpleName()); + } + Map result = new LinkedHashMap(); + for (Method method : targetType.getMethods()) { + if (method.getDeclaringClass() == Object.class) { + continue; + } + MethodMetadata metadata = parseAndValidateMetadata(targetType, method); + checkState(!result.containsKey(metadata.configKey()), "Overrides unsupported: %s", + metadata.configKey()); + result.put(metadata.configKey(), metadata); + } + return new ArrayList(result.values()); + } + + /** + * @deprecated use {@link #parseAndValidateMetadata(Class, Method)} instead. + */ + @Deprecated + public MethodMetadata parseAndValidatateMetadata(Method method) { + return parseAndValidateMetadata(method.getDeclaringClass(), method); + } + + /** + * Called indirectly by {@link #parseAndValidatateMetadata(Class)}. + */ + protected MethodMetadata parseAndValidateMetadata(Class targetType, Method method) { + MethodMetadata data = new MethodMetadata(); + data.returnType(Types.resolve(targetType, targetType, method.getGenericReturnType())); + data.configKey(Feign.configKey(targetType, method)); + + processAnnotationOnClass(data, method.getDeclaringClass()); + if (method.getDeclaringClass() != targetType) { + processAnnotationOnClass(data, targetType); + } + + 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(Types.resolve(targetType, targetType, method.getGenericParameterTypes()[i])); + } + } + return data; + } + + /** + * Called by parseAndValidateMetadata twice, first on the declaring class, then on the + * target type (unless they are the same). + * + * @param data metadata collected so far relating to the current java method. + * @param clz the class to process + */ + protected abstract void processAnnotationOnClass(MethodMetadata data, Class clz); + + /** + * @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); + } + } + + class Default extends BaseContract { + @Override + protected void processAnnotationOnClass(MethodMetadata data, Class targetType) { + if (targetType.isAnnotationPresent(Headers.class)) { + String[] headersOnType = targetType.getAnnotation(Headers.class).value(); + checkState(headersOnType.length > 0, "Headers annotation was empty on type %s.", + targetType.getName()); + Map> headers = toMap(headersOnType); + headers.putAll(data.template().headers()); + data.template().headers(null); // to clear + data.template().headers(headers); + } + } + + @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(' '))); + } + + data.template().decodeSlash(RequestLine.class.cast(methodAnnotation).decodeSlash()); + + } 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[] headersOnMethod = Headers.class.cast(methodAnnotation).value(); + checkState(headersOnMethod.length > 0, "Headers annotation was empty on method %s.", + method.getName()); + data.template().headers(toMap(headersOnMethod)); + } + } + + @Override + protected boolean processAnnotationsOnParameter(MethodMetadata data, Annotation[] annotations, + int paramIndex) { + boolean isHttpAnnotation = false; + for (Annotation annotation : annotations) { + Class annotationType = annotation.annotationType(); + if (annotationType == Param.class) { + String name = ((Param) annotation).value(); + checkState(emptyToNull(name) != null, "Param annotation was empty on param %s.", + paramIndex); + nameParam(data, name, paramIndex); + if (annotationType == Param.class) { + Class expander = ((Param) annotation).expander(); + if (expander != Param.ToStringExpander.class) { + data.indexToExpanderClass().put(paramIndex, expander); + } + } + 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 static 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; + } + + private static Map> toMap(String[] input) { + Map> + result = + new LinkedHashMap>(input.length); + for (String header : input) { + int colon = header.indexOf(':'); + String name = header.substring(0, colon); + if (!result.containsKey(name)) { + result.put(name, new ArrayList(1)); + } + result.get(name).add(header.substring(colon + 2)); + } + return result; + } + } +} diff --git a/core/src/main/java/feign/Feign.java b/core/src/main/java/feign/Feign.java new file mode 100644 index 0000000000..3d86a2b374 --- /dev/null +++ b/core/src/main/java/feign/Feign.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 java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +import feign.Logger.NoOpLogger; +import feign.ReflectiveFeign.ParseHandlersByName; +import feign.Request.Options; +import feign.Target.HardCodedTarget; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; + +/** + * 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 { + + public static Builder builder() { + return new Builder(); + } + + /** + *
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! + * + * @param targetType {@link feign.Target#type() type} of the Feign interface. + * @param method invoked method, present on {@code type} or its super. + */ + public static String configKey(Class targetType, Method method) { + StringBuilder builder = new StringBuilder(); + builder.append(targetType.getSimpleName()); + builder.append('#').append(method.getName()).append('('); + for (Type param : method.getGenericParameterTypes()) { + param = Types.resolve(targetType, targetType, param); + builder.append(Types.getRawType(param).getSimpleName()).append(','); + } + if (method.getParameterTypes().length > 0) { + builder.deleteCharAt(builder.length() - 1); + } + return builder.append(')').toString(); + } + + /** + * @deprecated use {@link #configKey(Class, Method)} instead. + */ + @Deprecated + public static String configKey(Method method) { + return configKey(method.getDeclaringClass(), method); + } + + /** + * 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 class Builder { + + private final List + requestInterceptors = + new ArrayList(); + private Logger.Level logLevel = Logger.Level.NONE; + private Contract contract = new Contract.Default(); + private Client client = new Client.Default(null, null); + private Retryer retryer = new Retryer.Default(); + private Logger logger = new NoOpLogger(); + private Encoder encoder = new Encoder.Default(); + private Decoder decoder = new Decoder.Default(); + private ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + private Options options = new Options(); + private InvocationHandlerFactory + invocationHandlerFactory = + new InvocationHandlerFactory.Default(); + + 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 build().newInstance(target); + } + + public Feign build() { + SynchronousMethodHandler.Factory synchronousMethodHandlerFactory = + new SynchronousMethodHandler.Factory(client, retryer, requestInterceptors, logger, + logLevel); + ParseHandlersByName + handlersByName = + new ParseHandlersByName(contract, options, encoder, decoder, + errorDecoder, synchronousMethodHandlerFactory); + return new ReflectiveFeign(handlersByName, 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..a85b911366 --- /dev/null +++ b/core/src/main/java/feign/FeignException.java @@ -0,0 +1,60 @@ +/* + * 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 static java.lang.String.format; + +/** + * Origin exception type for all Http Apis. + */ +public class FeignException extends RuntimeException { + + private static final long serialVersionUID = 0; + + protected FeignException(String message, Throwable cause) { + super(message, cause); + } + + protected FeignException(String message) { + super(message); + } + + static FeignException errorReading(Request request, Response ignored, IOException cause) { + return new FeignException( + format("%s reading %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("%s executing %s %s", cause.getMessage(), request.method(), request.url()), cause, + null); + } +} diff --git a/core/src/main/java/feign/Headers.java b/core/src/main/java/feign/Headers.java new file mode 100644 index 0000000000..f7f4137086 --- /dev/null +++ b/core/src/main/java/feign/Headers.java @@ -0,0 +1,49 @@ +package feign; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Expands headers supplied in the {@code value}. Variables are permitted as values.
+ *
+ * @Headers("Content-Type: application/xml")
+ * interface SoapApi {
+ * ...   
+ * @RequestLine("GET /")
+ * @Headers("Cache-Control: max-age=640000")
+ * ...
+ *
+ * @RequestLine("POST /")
+ * @Headers({
+ *   "X-Foo: Bar",
+ *   "X-Ping: {token}"
+ * }) void post(@Param("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, TYPE}) +@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..1df508b079 --- /dev/null +++ b/core/src/main/java/feign/InvocationHandlerFactory.java @@ -0,0 +1,45 @@ +/* + * 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 { + + InvocationHandler create(Target target, Map dispatch); + + /** + * Like {@link InvocationHandler#invoke(Object, java.lang.reflect.Method, Object[])}, except for a + * single method. + */ + interface MethodHandler { + + Object invoke(Object[] argv) throws Throwable; + } + + 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..58ae0d1e20 --- /dev/null +++ b/core/src/main/java/feign/Logger.java @@ -0,0 +1,220 @@ +/* + * 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.UTF_8; +import static feign.Util.decodeOrDefault; +import static feign.Util.valuesOrEmpty; + +/** + * Simple logging abstraction for debug messages. Adapted from {@code retrofit.RestAdapter.Log}. + */ +public abstract class Logger { + + protected static String methodTag(String configKey) { + return new StringBuilder().append('[').append(configKey.substring(0, configKey.indexOf('('))) + .append("] ").toString(); + } + + /** + * 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(Class, 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; + } + + /** + * 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 { + @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) { + } + } +} diff --git a/core/src/main/java/feign/MethodMetadata.java b/core/src/main/java/feign/MethodMetadata.java new file mode 100644 index 0000000000..91635bf0c8 --- /dev/null +++ b/core/src/main/java/feign/MethodMetadata.java @@ -0,0 +1,112 @@ +/* + * 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; + +import feign.Param.Expander; + +public final class MethodMetadata implements Serializable { + + private static final long serialVersionUID = 1L; + 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>(); + private Map> indexToExpanderClass = + new LinkedHashMap>(); + + MethodMetadata() { + } + + /** + * @see Feign#configKey(Class, java.lang.reflect.Method) + */ + public String configKey() { + return configKey; + } + + MethodMetadata configKey(String configKey) { + this.configKey = configKey; + return this; + } + + 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; + } + + /** + * Type corresponding to {@link #bodyIndex()}. + */ + 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; + } + + public Map> indexToExpanderClass() { + return indexToExpanderClass; + } +} diff --git a/core/src/main/java/feign/Param.java b/core/src/main/java/feign/Param.java new file mode 100644 index 0000000000..46c4ede7cb --- /dev/null +++ b/core/src/main/java/feign/Param.java @@ -0,0 +1,56 @@ +/* + * Copyright 2015 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.annotation.Retention; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * A named template parameter applied to {@link Headers}, {@linkplain RequestLine} or {@linkplain + * Body} + */ +@Retention(RUNTIME) +@java.lang.annotation.Target(PARAMETER) +public @interface Param { + + /** + * The name of the template parameter. + */ + String value(); + + /** + * How to expand the value of this parameter, if {@link ToStringExpander} isn't adequate. + */ + Class expander() default ToStringExpander.class; + + interface Expander { + + /** + * Expands the value into a string. Does not accept or return null. + */ + String expand(Object value); + } + + final class ToStringExpander implements Expander { + + @Override + public String expand(Object value) { + return value.toString(); + } + } +} diff --git a/core/src/main/java/feign/ReflectiveFeign.java b/core/src/main/java/feign/ReflectiveFeign.java new file mode 100644 index 0000000000..97cb735cf8 --- /dev/null +++ b/core/src/main/java/feign/ReflectiveFeign.java @@ -0,0 +1,261 @@ +/* + * 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.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Param.Expander; +import feign.Request.Options; +import feign.codec.Decoder; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; + +import static feign.Util.checkArgument; +import static feign.Util.checkNotNull; + +public class ReflectiveFeign extends Feign { + + private final ParseHandlersByName targetToHandlersByName; + private final InvocationHandlerFactory factory; + + 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().getMethods()) { + if (method.getDeclaringClass() == Object.class) { + continue; + } + methodToHandler.put(method, nameToHandler.get(Feign.configKey(target.type(), 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; + } + } else if ("hashCode".equals(method.getName())) { + return hashCode(); + } else if ("toString".equals(method.getName())) { + return toString(); + } + return dispatch.get(method).invoke(args); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof FeignInvocationHandler) { + FeignInvocationHandler other = (FeignInvocationHandler) obj; + return target.equals(other.target); + } + return false; + } + + @Override + public int hashCode() { + return target.hashCode(); + } + + @Override + public String toString() { + return target.toString(); + } + } + + 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; + + 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 final Map indexToExpander = new LinkedHashMap(); + + private BuildTemplateByResolvingArgs(MethodMetadata metadata) { + this.metadata = metadata; + if (metadata.indexToExpanderClass().isEmpty()) { + return; + } + for (Entry> indexToExpanderClass : metadata + .indexToExpanderClass().entrySet()) { + try { + indexToExpander + .put(indexToExpanderClass.getKey(), indexToExpanderClass.getValue().newInstance()); + } catch (InstantiationException e) { + throw new IllegalStateException(e); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } + } + } + + @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()) { + int i = entry.getKey(); + Object value = argv[entry.getKey()]; + if (value != null) { // Null values are skipped. + if (indexToExpander.containsKey(i)) { + value = indexToExpander.get(i).expand(value); + } + 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, Types.MAP_STRING_WILDCARD, 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, metadata.bodyType(), 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..3f833542d9 --- /dev/null +++ b/core/src/main/java/feign/Request.java @@ -0,0 +1,136 @@ +/* + * 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.Map; + +import static feign.Util.checkNotNull; +import static feign.Util.valuesOrEmpty; + +/** + * An immutable request to an http server. + */ +public final class Request { + + /** + * No parameters can be null except {@code body} and {@code charset}. All parameters must be + * effectively immutable, via safe copies, not mutating or otherwise. + */ + public static Request create(String method, String url, Map> headers, + byte[] body, Charset charset) { + return new Request(method, url, headers, body, charset); + } + + 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"); + this.headers = checkNotNull(headers, "headers of %s %s", method, url); + 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; + } + + @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(); + } + + /* 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; + } + } +} diff --git a/core/src/main/java/feign/RequestInterceptor.java b/core/src/main/java/feign/RequestInterceptor.java new file mode 100644 index 0000000000..7378bcaaac --- /dev/null +++ b/core/src/main/java/feign/RequestInterceptor.java @@ -0,0 +1,44 @@ +/* + * 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 {@link + * Feign.Builder#requestInterceptors}.

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..0666f70cc2 --- /dev/null +++ b/core/src/main/java/feign/RequestLine.java @@ -0,0 +1,51 @@ +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(@Param("serverId") String serverId, @Param("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(@Param("serverId") String serverId, @Param("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(); + boolean decodeSlash() default true; +} diff --git a/core/src/main/java/feign/RequestTemplate.java b/core/src/main/java/feign/RequestTemplate.java new file mode 100644 index 0000000000..3160d8aa72 --- /dev/null +++ b/core/src/main/java/feign/RequestTemplate.java @@ -0,0 +1,620 @@ +/* + * 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 { + + private static final long serialVersionUID = 1L; + private final Map> queries = + new LinkedHashMap>(); + private final Map> headers = + new LinkedHashMap>(); + private String method; + /* final to encourage mutable use vs replacing the object. */ + private StringBuilder url = new StringBuilder(); + private transient Charset charset; + private byte[] body; + private String bodyTemplate; + private boolean decodeSlash = true; + + 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; + this.decodeSlash = toCopy.decodeSlash; + } + + 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); + } + } + + private static boolean isHttpUrl(CharSequence value) { + return value.length() >= 4 && value.subSequence(0, 3).equals("http".substring(0, 3)); + } + + private static CharSequence removeTrailingSlash(CharSequence charSequence) { + if (charSequence != null && charSequence.length() > 0 && charSequence.charAt(charSequence.length() - 1) == '/') { + return charSequence.subSequence(0, charSequence.length() - 1); + } else { + return charSequence; + } + } + + /** + * 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(); + } + + private static Map> parseAndDecodeQueries(String queryLine) { + Map> map = new LinkedHashMap>(); + if (emptyToNull(queryLine) == null) { + return map; + } + if (queryLine.indexOf('&') == -1) { + putKV(queryLine, map); + } 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); + } + + /** + * Resolves any template parameters 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("+", "%20"); + if (decodeSlash) { + resolvedUrl = resolvedUrl.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 = expand(value, unencoded); + } 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() { + Map> safeCopy = new LinkedHashMap>(); + safeCopy.putAll(headers); + return Request.create( + method, + new StringBuilder(url).append(queryLine()).toString(), + Collections.unmodifiableMap(safeCopy), + body, charset + ); + } + + /* @see Request#method() */ + public RequestTemplate method(String method) { + this.method = checkNotNull(method, "method"); + return this; + } + + /* @see Request#method() */ + public String method() { + return method; + } + + public RequestTemplate decodeSlash(boolean decodeSlash) { + this.decodeSlash = decodeSlash; + return this; + } + + public boolean decodeSlash() { + return decodeSlash; + } + + /* @see #url() */ + public RequestTemplate append(CharSequence value) { + url.append(value); + url = pullAnyQueriesOutOfUrl(url); + return this; + } + + /* @see #url() */ + public RequestTemplate insert(int pos, CharSequence value) { + if(isHttpUrl(value)) { + value = removeTrailingSlash(value); + if(url.length() > 0 && url.charAt(0) != '/') { + url.insert(0, '/'); + } + } + 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 name the name 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 name, String... values) { + checkNotNull(name, "header name"); + if (values == null || (values.length == 1 && values[0] == null)) { + headers.remove(name); + } else { + List headers = new ArrayList(); + headers.addAll(Arrays.asList(values)); + this.headers.put(name, headers); + } + return this; + } + + /* @see #header(String, String...) */ + public RequestTemplate header(String name, Iterable values) { + if (values != null) { + return header(name, toArray(values, String.class)); + } + return header(name, (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(mapOf("X-Application-Version", asList("{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 == null || values.isEmpty()) { + return true; + } + for (String val : values) { + if (val != null) { + return false; + } + } + return true; + } + + @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(); + } + + interface Factory { + + /** + * create a request template using args passed to a method invocation. + */ + RequestTemplate create(Object[] argv); + } +} diff --git a/core/src/main/java/feign/Response.java b/core/src/main/java/feign/Response.java new file mode 100644 index 0000000000..b4b639dc47 --- /dev/null +++ b/core/src/main/java/feign/Response.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 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.checkNotNull; +import static feign.Util.checkState; +import static feign.Util.decodeOrDefault; +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; + + 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 + } + + 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)); + } + + public static Response create(int status, String reason, Map> headers, + Body body) { + return new Response(status, reason, headers, body); + } + + /** + * 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; + } + + @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(); + } + + 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 final InputStream inputStream; + private final Integer length; + private InputStreamBody(InputStream inputStream, Integer length) { + this.inputStream = inputStream; + this.length = length; + } + + private static Body orNull(InputStream inputStream, Integer length) { + if (inputStream == null) { + return null; + } + return new InputStreamBody(inputStream, 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 final byte[] data; + + public ByteArrayBody(byte[] data) { + this.data = data; + } + + 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)); + } + + @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"); + } + } +} diff --git a/core/src/main/java/feign/RetryableException.java b/core/src/main/java/feign/RetryableException.java new file mode 100644 index 0000000000..ff91ba0db4 --- /dev/null +++ b/core/src/main/java/feign/RetryableException.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; + +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..890e5ed547 --- /dev/null +++ b/core/src/main/java/feign/Retryer.java @@ -0,0 +1,99 @@ +/* + * 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; + +/** + * Cloned 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 extends Cloneable { + + /** + * if retry is permitted, return (possibly after sleeping). Otherwise propagate the exception. + */ + void continueOrPropagate(RetryableException e); + + Retryer clone(); + + public static class Default implements Retryer { + + private final int maxAttempts; + private final long period; + private final long maxPeriod; + 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; + } + + // visible for testing; + protected long currentTimeMillis() { + return System.currentTimeMillis(); + } + + 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; + } + + @Override + public Retryer clone() { + return new Default(period, maxPeriod, maxAttempts); + } + } +} diff --git a/core/src/main/java/feign/SynchronousMethodHandler.java b/core/src/main/java/feign/SynchronousMethodHandler.java new file mode 100644 index 0000000000..57d120babb --- /dev/null +++ b/core/src/main/java/feign/SynchronousMethodHandler.java @@ -0,0 +1,179 @@ +/* + * 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.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Request.Options; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; + +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 { + + private final MethodMetadata metadata; + private final Target target; + private final Client client; + private final Retryer retryer; + private final List requestInterceptors; + private final Logger logger; + private final Logger.Level logLevel; + private final RequestTemplate.Factory buildTemplateFromArgs; + private final Options options; + private final Decoder decoder; + private final ErrorDecoder errorDecoder; + private SynchronousMethodHandler(Target target, Client client, Retryer retryer, + List requestInterceptors, Logger logger, + Logger.Level 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.clone(); + while (true) { + try { + return executeAndDecode(template); + } catch (RetryableException e) { + retryer.continueOrPropagate(e); + if (logLevel != Logger.Level.NONE) { + logger.logRetry(metadata.configKey(), logLevel); + } + continue; + } + } + } + + Object executeAndDecode(RequestTemplate template) throws Throwable { + Request request = targetRequest(template); + + if (logLevel != Logger.Level.NONE) { + logger.logRequest(metadata.configKey(), logLevel, request); + } + + Response response; + long start = System.nanoTime(); + try { + response = client.execute(request, options); + } catch (IOException e) { + if (logLevel != Logger.Level.NONE) { + logger.logIOException(metadata.configKey(), logLevel, e, elapsedTime(start)); + } + throw errorExecuting(request, e); + } + long elapsedTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + + try { + if (logLevel != Logger.Level.NONE) { + response = + logger.logAndRebufferResponse(metadata.configKey(), logLevel, 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 != Logger.Level.NONE) { + logger.logIOException(metadata.configKey(), logLevel, 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); + } + } + + static class Factory { + + private final Client client; + private final Retryer retryer; + private final List requestInterceptors; + private final Logger logger; + private final Logger.Level logLevel; + + Factory(Client client, Retryer retryer, List requestInterceptors, + Logger logger, Logger.Level 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); + } + } +} diff --git a/core/src/main/java/feign/Target.java b/core/src/main/java/feign/Target.java new file mode 100644 index 0000000000..2c82067fbd --- /dev/null +++ b/core/src/main/java/feign/Target.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 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 boolean equals(Object obj) { + if (obj instanceof HardCodedTarget) { + HardCodedTarget other = (HardCodedTarget) obj; + return type.equals(other.type) + && name.equals(other.name) + && url.equals(other.url); + } + return false; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + type.hashCode(); + result = 31 * result + name.hashCode(); + result = 31 * result + url.hashCode(); + return result; + } + + @Override + public String toString() { + if (name.equals(url)) { + return "HardCodedTarget(type=" + type.getSimpleName() + ", url=" + url + ")"; + } + return "HardCodedTarget(type=" + type.getSimpleName() + ", name=" + name + ", url=" + url + + ")"; + } + } + + public static final class EmptyTarget implements Target { + + private final Class type; + private final String name; + + EmptyTarget(Class type, String name) { + this.type = checkNotNull(type, "type"); + this.name = checkNotNull(emptyToNull(name), "name"); + } + + public static EmptyTarget create(Class type) { + return new EmptyTarget(type, "empty:" + type.getSimpleName()); + } + + public static EmptyTarget create(Class type, String name) { + return new EmptyTarget(type, name); + } + + @Override + public Class type() { + return type; + } + + @Override + public String name() { + return name; + } + + @Override + public String url() { + throw new UnsupportedOperationException("Empty targets don't have URLs"); + } + + @Override + public Request apply(RequestTemplate input) { + if (input.url().indexOf("http") != 0) { + throw new UnsupportedOperationException( + "Request with non-absolute URL not supported with empty target"); + } + return input.request(); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof EmptyTarget) { + EmptyTarget other = (EmptyTarget) obj; + return type.equals(other.type) + && name.equals(other.name); + } + return false; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + type.hashCode(); + result = 31 * result + name.hashCode(); + return result; + } + + @Override + public String toString() { + if (name.equals("empty:" + type.getSimpleName())) { + return "EmptyTarget(type=" + type.getSimpleName() + ")"; + } + return "EmptyTarget(type=" + type.getSimpleName() + ", name=" + name + ")"; + } + } +} diff --git a/core/src/main/java/feign/Types.java b/core/src/main/java/feign/Types.java new file mode 100644 index 0000000000..ffab3b9c76 --- /dev/null +++ b/core/src/main/java/feign/Types.java @@ -0,0 +1,475 @@ +/* + * 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.Map; +import java.util.NoSuchElementException; + +/** + * Static methods for working with types. + * + * @author Bob Lee + * @author Jesse Wilson + */ +final class Types { + + /** + * Type literal for {@code Map}. + */ + static final Type MAP_STRING_WILDCARD = new ParameterizedTypeImpl(null, Map.class, String.class, + new WildcardTypeImpl( + new Type[]{Object.class}, + new Type[]{})); + + 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..f37db36788 --- /dev/null +++ b/core/src/main/java/feign/Util.java @@ -0,0 +1,252 @@ +/* + * 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 { + + /** + * 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"; + /** + * Value for the Content-Encoding header that indicates that DEFLATE encoding is in use. + */ + public static final String ENCODING_DEFLATE = "deflate"; + /** + * UTF-8: eight-bit UCS Transformation Format. + */ + public static final Charset UTF_8 = Charset.forName("UTF-8"); + + // com.google.common.base.Charsets + /** + * ISO-8859-1: ISO Latin Alphabet Number 1 (ISO-LATIN-1). + */ + public static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1"); + private static final int BUF_SIZE = 0x800; // 2K chars (4K bytes) + + private Util() { // no instances + } + + /** + * 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) != null ? 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]; + } + + /** + * 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..c565bc7c84 --- /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 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', '+', '/' + }; + + 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; + } + + 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..7539e7620d --- /dev/null +++ b/core/src/main/java/feign/auth/BasicAuthRequestInterceptor.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.auth; + +import java.nio.charset.Charset; + +import feign.RequestInterceptor; +import feign.RequestTemplate; + +import static feign.Util.ISO_8859_1; +import static feign.Util.checkNotNull; + +/** + * 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)); + } + + /* + * 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); + } + + @Override + public void apply(RequestTemplate template) { + template.header("Authorization", headerValue); + } +} + 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..ca834270ea --- /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 { + + private static final long serialVersionUID = 1L; + + /** + * @param message the reason for the failure. + */ + public DecodeException(String message) { + super(checkNotNull(message, "message")); + } + + /** + * @param message possibly null reason for the failure. + * @param cause the cause of the error. + */ + public DecodeException(String message, Throwable cause) { + super(message, checkNotNull(cause, "cause")); + } +} 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..58502afb6e --- /dev/null +++ b/core/src/main/java/feign/codec/Decoder.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.codec; + +import java.io.IOException; +import java.lang.reflect.Type; + +import feign.FeignException; +import feign.Response; +import feign.Util; + +/** + * 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..aafee3e1ea --- /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 { + + private static final long serialVersionUID = 1L; + + /** + * @param message the reason for the failure. + */ + public EncodeException(String message) { + super(checkNotNull(message, "message")); + } + + /** + * @param message possibly null reason for the failure. + * @param cause the cause of the error. + */ + public EncodeException(String message, Throwable cause) { + super(message, checkNotNull(cause, "cause")); + } +} 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..a49afbbf61 --- /dev/null +++ b/core/src/main/java/feign/codec/Encoder.java @@ -0,0 +1,89 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import java.lang.reflect.Type; + +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, Type bodyType, RequestTemplate template) {
+ *     template.body(gson.toJson(object, bodyType));
+ *   }
+ * }
+ * 
+ * + *

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(@Param("username") String username, @Param("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 bodyType the type the object should be encoded as. {@code Map}, if form + * encoding. + * @param template the request template to populate. + * @throws EncodeException when encoding failed due to a checked exception. + */ + void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException; + + /** + * Default implementation of {@code Encoder}. + */ + class Default implements Encoder { + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + if (bodyType == String.class) { + template.body(object.toString()); + } else if (bodyType == byte[].class) { + 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..cd4d09834f --- /dev/null +++ b/core/src/main/java/feign/codec/ErrorDecoder.java @@ -0,0 +1,143 @@ +/* + * 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); + } + + RetryAfterDecoder(DateFormat rfc822Format) { + this.rfc822Format = checkNotNull(rfc822Format, "rfc822Format"); + } + + protected long currentTimeMillis() { + return System.currentTimeMillis(); + } + + /** + * 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 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..261d0357f9 --- /dev/null +++ b/core/src/main/java/feign/codec/StringDecoder.java @@ -0,0 +1,39 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import java.io.IOException; +import java.lang.reflect.Type; + +import feign.Response; +import feign.Util; + +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/BaseApiTest.java b/core/src/test/java/feign/BaseApiTest.java new file mode 100644 index 0000000000..c29ce5fa37 --- /dev/null +++ b/core/src/test/java/feign/BaseApiTest.java @@ -0,0 +1,115 @@ +/* + * Copyright 2015 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.gson.reflect.TypeToken; + +import com.squareup.okhttp.mockwebserver.MockResponse; +import com.squareup.okhttp.mockwebserver.MockWebServer; + +import org.junit.Rule; +import org.junit.Test; + +import java.lang.reflect.Type; +import java.util.List; + +import feign.codec.Decoder; +import feign.codec.Encoder; + +import static feign.assertj.MockWebServerAssertions.assertThat; + +public class BaseApiTest { + + @Rule + public final MockWebServer server = new MockWebServer(); + + interface BaseApi { + + @RequestLine("GET /api/{key}") + Entity get(@Param("key") K key); + + @RequestLine("POST /api") + Entities getAll(Keys keys); + } + + static class Keys { + + List keys; + } + + static class Entity { + + K key; + M model; + } + + static class Entities { + + List> entities; + } + + interface MyApi extends BaseApi { + + } + + @Test + public void resolvesParameterizedResult() throws InterruptedException { + server.enqueue(new MockResponse().setBody("foo")); + + String baseUrl = server.getUrl("/default").toString(); + + Feign.builder() + .decoder(new Decoder() { + @Override + public Object decode(Response response, Type type) { + assertThat(type) + .isEqualTo(new TypeToken>() { + }.getType()); + return null; + } + }) + .target(MyApi.class, baseUrl).get("foo"); + + assertThat(server.takeRequest()).hasPath("/default/api/foo"); + } + + @Test + public void resolvesBodyParameter() throws InterruptedException { + server.enqueue(new MockResponse().setBody("foo")); + + String baseUrl = server.getUrl("/default").toString(); + + Feign.builder() + .encoder(new Encoder() { + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + assertThat(bodyType) + .isEqualTo(new TypeToken>() { + }.getType()); + } + }) + .decoder(new Decoder() { + @Override + public Object decode(Response response, Type type) { + assertThat(type) + .isEqualTo(new TypeToken>() { + }.getType()); + return null; + } + }) + .target(MyApi.class, baseUrl).getAll(new Keys()); + } +} diff --git a/core/src/test/java/feign/DefaultContractTest.java b/core/src/test/java/feign/DefaultContractTest.java new file mode 100644 index 0000000000..b0e3eda17e --- /dev/null +++ b/core/src/test/java/feign/DefaultContractTest.java @@ -0,0 +1,537 @@ +/* + * 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.gson.reflect.TypeToken; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.net.URI; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static feign.assertj.FeignAssertions.assertThat; +import static java.util.Arrays.asList; +import static org.assertj.core.data.MapEntry.entry; + +/** + * Tests interfaces defined per {@link Contract.Default} are interpreted into expected {@link feign + * .RequestTemplate template} instances. + */ +public class DefaultContractTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + Contract.Default contract = new Contract.Default(); + + @Test + public void httpMethods() throws Exception { + assertThat(parseAndValidateMetadata(Methods.class, "post").template()) + .hasMethod("POST"); + + assertThat(parseAndValidateMetadata(Methods.class, "put").template()) + .hasMethod("PUT"); + + assertThat(parseAndValidateMetadata(Methods.class, "get").template()) + .hasMethod("GET"); + + assertThat(parseAndValidateMetadata(Methods.class, "delete").template()) + .hasMethod("DELETE"); + } + + @Test + public void bodyParamIsGeneric() throws Exception { + MethodMetadata md = parseAndValidateMetadata(BodyParams.class, "post", List.class); + + assertThat(md.bodyIndex()) + .isEqualTo(0); + assertThat(md.bodyType()) + .isEqualTo(new TypeToken>() { + }.getType()); + } + + @Test + public void tooManyBodies() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Method has too many Body"); + parseAndValidateMetadata(BodyParams.class, "tooMany", List.class, List.class); + } + + @Test + public void customMethodWithoutPath() throws Exception { + assertThat(parseAndValidateMetadata(CustomMethod.class, "patch").template()) + .hasMethod("PATCH") + .hasUrl(""); + } + + @Test + public void queryParamsInPathExtract() throws Exception { + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "none").template()) + .hasUrl("/") + .hasQueries(); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "one").template()) + .hasUrl("/") + .hasQueries( + entry("Action", asList("GetUser")) + ); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "two").template()) + .hasUrl("/") + .hasQueries( + entry("Action", asList("GetUser")), + entry("Version", asList("2010-05-08")) + ); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "three").template()) + .hasUrl("/") + .hasQueries( + entry("Action", asList("GetUser")), + entry("Version", asList("2010-05-08")), + entry("limit", asList("1")) + ); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "twoAndOneEmpty").template()) + .hasUrl("/") + .hasQueries( + entry("flag", asList(new String[]{null})), + entry("Action", asList("GetUser")), + entry("Version", asList("2010-05-08")) + ); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "oneEmpty").template()) + .hasUrl("/") + .hasQueries( + entry("flag", asList(new String[]{null})) + ); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "twoEmpty").template()) + .hasUrl("/") + .hasQueries( + entry("flag", asList(new String[]{null})), + entry("NoErrors", asList(new String[]{null})) + ); + } + + @Test + public void bodyWithoutParameters() throws Exception { + MethodMetadata md = parseAndValidateMetadata(BodyWithoutParameters.class, "post"); + + assertThat(md.template()) + .hasBody(""); + } + + @Test + public void headersOnMethodAddsContentTypeHeader() throws Exception { + MethodMetadata md = parseAndValidateMetadata(BodyWithoutParameters.class, "post"); + + assertThat(md.template()) + .hasHeaders( + entry("Content-Type", asList("application/xml")), + entry("Content-Length", asList(String.valueOf(md.template().body().length))) + ); + } + + @Test + public void headersOnTypeAddsContentTypeHeader() throws Exception { + MethodMetadata md = parseAndValidateMetadata(HeadersOnType.class, "post"); + + assertThat(md.template()) + .hasHeaders( + entry("Content-Type", asList("application/xml")), + entry("Content-Length", asList(String.valueOf(md.template().body().length))) + ); + } + + @Test + public void withPathAndURIParam() throws Exception { + MethodMetadata md = parseAndValidateMetadata(WithURIParam.class, + "uriParam", String.class, URI.class, String.class); + + assertThat(md.indexToName()) + .containsExactly( + entry(0, asList("1")), + // Skips 1 as it is a url index! + entry(2, asList("2")) + ); + + assertThat(md.urlIndex()).isEqualTo(1); + } + + @Test + public void pathAndQueryParams() throws Exception { + MethodMetadata md = parseAndValidateMetadata(WithPathAndQueryParams.class, + "recordsByNameAndType", int.class, String.class, + String.class); + + assertThat(md.template()) + .hasQueries(entry("name", asList("{name}")), entry("type", asList("{type}"))); + + assertThat(md.indexToName()).containsExactly( + entry(0, asList("domainId")), + entry(1, asList("name")), + entry(2, asList("type")) + ); + } + + @Test + public void bodyWithTemplate() throws Exception { + MethodMetadata md = parseAndValidateMetadata(FormParams.class, + "login", String.class, String.class, String.class); + + assertThat(md.template()) + .hasBodyTemplate( + "%7B\"customer_name\": \"{customer_name}\", \"user_name\": \"{user_name}\", \"password\": \"{password}\"%7D"); + } + + @Test + public void formParamsParseIntoIndexToName() throws Exception { + MethodMetadata md = parseAndValidateMetadata(FormParams.class, + "login", String.class, String.class, String.class); + + assertThat(md.formParams()) + .containsExactly("customer_name", "user_name", "password"); + + assertThat(md.indexToName()).containsExactly( + entry(0, asList("customer_name")), + entry(1, asList("user_name")), + entry(2, asList("password")) + ); + } + + /** + * Body type is only for the body param. + */ + @Test + public void formParamsDoesNotSetBodyType() throws Exception { + MethodMetadata md = parseAndValidateMetadata(FormParams.class, + "login", String.class, String.class, String.class); + + assertThat(md.bodyType()).isNull(); + } + + @Test + public void headerParamsParseIntoIndexToName() throws Exception { + MethodMetadata md = parseAndValidateMetadata(HeaderParams.class, "logout", String.class); + + assertThat(md.template()) + .hasHeaders(entry("Auth-Token", asList("{authToken}", "Foo"))); + + assertThat(md.indexToName()) + .containsExactly(entry(0, asList("authToken"))); + } + + @Test + public void customExpander() throws Exception { + MethodMetadata md = parseAndValidateMetadata(CustomExpander.class, "date", Date.class); + + assertThat(md.indexToExpanderClass()) + .containsExactly(entry(0, DateToMillis.class)); + } + + @Test + public void slashAreEncodedWhenNeeded() throws Exception { + MethodMetadata md = parseAndValidateMetadata(SlashNeedToBeEncoded.class, + "getQueues", String.class); + + assertThat(md.template().decodeSlash()).isFalse(); + + md = parseAndValidateMetadata(SlashNeedToBeEncoded.class, "getZone", String.class); + + assertThat(md.template().decodeSlash()).isTrue(); + } + + interface Methods { + + @RequestLine("POST /") + void post(); + + @RequestLine("PUT /") + void put(); + + @RequestLine("GET /") + void get(); + + @RequestLine("DELETE /") + void delete(); + } + + interface BodyParams { + + @RequestLine("POST") + Response post(List body); + + @RequestLine("POST") + Response tooMany(List body, List body2); + } + + interface CustomMethod { + + @RequestLine("PATCH") + Response patch(); + } + + 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 twoAndOneEmpty(); + + @RequestLine("GET /?flag") + Response oneEmpty(); + + @RequestLine("GET /?flag&NoErrors") + Response twoEmpty(); + } + + interface BodyWithoutParameters { + + @RequestLine("POST /") + @Headers("Content-Type: application/xml") + @Body("") + Response post(); + } + + @Headers("Content-Type: application/xml") + interface HeadersOnType { + + @RequestLine("POST /") + @Body("") + Response post(); + } + + interface WithURIParam { + + @RequestLine("GET /{1}/{2}") + Response uriParam(@Param("1") String one, URI endpoint, @Param("2") String two); + } + + interface WithPathAndQueryParams { + + @RequestLine("GET /domains/{domainId}/records?name={name}&type={type}") + Response recordsByNameAndType(@Param("domainId") int id, @Param("name") String nameFilter, + @Param("type") String typeFilter); + } + + interface FormParams { + + @RequestLine("POST /") + @Body("%7B\"customer_name\": \"{customer_name}\", \"user_name\": \"{user_name}\", \"password\": \"{password}\"%7D") + void login( + @Param("customer_name") String customer, + @Param("user_name") String user, @Param("password") String password); + } + + interface HeaderParams { + + @RequestLine("POST /") + @Headers({"Auth-Token: {authToken}", "Auth-Token: Foo"}) + void logout(@Param("authToken") String token); + } + + interface CustomExpander { + + @RequestLine("POST /?date={date}") + void date(@Param(value = "date", expander = DateToMillis.class) Date date); + } + + class DateToMillis implements Param.Expander { + + @Override + public String expand(Object value) { + return String.valueOf(((Date) value).getTime()); + } + } + + interface SlashNeedToBeEncoded { + @RequestLine(value = "GET /api/queues/{vhost}", decodeSlash = false) + String getQueues(@Param("vhost") String vhost); + + @RequestLine("GET /api/{zoneId}") + String getZone(@Param("ZoneId") String vhost); + } + + @Headers("Foo: Bar") + interface SimpleParameterizedBaseApi { + + @RequestLine("GET /api/{zoneId}") + M get(@Param("key") String key); + } + + interface SimpleParameterizedApi extends SimpleParameterizedBaseApi { + + } + + @Test + public void simpleParameterizedBaseApi() throws Exception { + List md = contract.parseAndValidatateMetadata(SimpleParameterizedApi.class); + + assertThat(md).hasSize(1); + + assertThat(md.get(0).configKey()) + .isEqualTo("SimpleParameterizedApi#get(String)"); + assertThat(md.get(0).returnType()) + .isEqualTo(String.class); + assertThat(md.get(0).template()) + .hasHeaders(entry("Foo", asList("Bar"))); + } + + @Test + public void parameterizedApiUnsupported() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Parameterized types unsupported: SimpleParameterizedBaseApi"); + contract.parseAndValidatateMetadata(SimpleParameterizedBaseApi.class); + } + + interface OverrideParameterizedApi extends SimpleParameterizedBaseApi { + + @Override + @RequestLine("GET /api/{zoneId}") + String get(@Param("key") String key); + } + + @Test + public void overrideBaseApiUnsupported() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Overrides unsupported: OverrideParameterizedApi#get(String)"); + contract.parseAndValidatateMetadata(OverrideParameterizedApi.class); + } + + interface Child extends SimpleParameterizedBaseApi> { + + } + + interface GrandChild extends Child { + + } + + @Test + public void onlySingleLevelInheritanceSupported() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Only single-level inheritance supported: GrandChild"); + contract.parseAndValidatateMetadata(GrandChild.class); + } + + @Headers("Foo: Bar") + interface ParameterizedBaseApi { + + @RequestLine("GET /api/{key}") + Entity get(@Param("key") K key); + + @RequestLine("POST /api") + Entities getAll(Keys keys); + } + + static class Keys { + + List keys; + } + + static class Entity { + + K key; + M model; + } + + static class Entities { + + private List> entities; + } + + @Headers("Version: 1") + interface ParameterizedApi extends ParameterizedBaseApi { + + } + + @Test + public void parameterizedBaseApi() throws Exception { + List md = contract.parseAndValidatateMetadata(ParameterizedApi.class); + + Map byConfigKey = new LinkedHashMap(); + for (MethodMetadata m : md) { + byConfigKey.put(m.configKey(), m); + } + + assertThat(byConfigKey) + .containsOnlyKeys("ParameterizedApi#get(String)", "ParameterizedApi#getAll(Keys)"); + + assertThat(byConfigKey.get("ParameterizedApi#get(String)").returnType()) + .isEqualTo(new TypeToken>() { + }.getType()); + assertThat(byConfigKey.get("ParameterizedApi#get(String)").template()).hasHeaders( + entry("Version", asList("1")), + entry("Foo", asList("Bar")) + ); + + assertThat(byConfigKey.get("ParameterizedApi#getAll(Keys)").returnType()) + .isEqualTo(new TypeToken>() { + }.getType()); + assertThat(byConfigKey.get("ParameterizedApi#getAll(Keys)").bodyType()) + .isEqualTo(new TypeToken>() { + }.getType()); + assertThat(byConfigKey.get("ParameterizedApi#getAll(Keys)").template()).hasHeaders( + entry("Version", asList("1")), + entry("Foo", asList("Bar")) + ); + } + + @Headers("Authorization: {authHdr}") + interface ParameterizedHeaderExpandApi { + @RequestLine("GET /api/{zoneId}") + @Headers("Accept: application/json") + String getZone(@Param("zoneId") String vhost, @Param("authHdr") String authHdr); + } + + @Test + public void parameterizedHeaderExpandApi() throws Exception { + List md = contract.parseAndValidatateMetadata(ParameterizedHeaderExpandApi.class); + + assertThat(md).hasSize(1); + + assertThat(md.get(0).configKey()) + .isEqualTo("ParameterizedHeaderExpandApi#getZone(String,String)"); + assertThat(md.get(0).returnType()) + .isEqualTo(String.class); + assertThat(md.get(0).template()) + .hasHeaders(entry("Authorization", asList("{authHdr}")), entry("Accept", asList("application/json"))); + // Ensure that the authHdr expansion was properly detected and did not create a formParam + assertThat(md.get(0).formParams()) + .isEmpty(); + } + + private MethodMetadata parseAndValidateMetadata(Class targetType, String method, + Class... parameterTypes) + throws NoSuchMethodException { + return contract.parseAndValidateMetadata(targetType, + targetType.getMethod(method, parameterTypes)); + } +} diff --git a/core/src/test/java/feign/DefaultRetryerTest.java b/core/src/test/java/feign/DefaultRetryerTest.java new file mode 100644 index 0000000000..0d5702a10a --- /dev/null +++ b/core/src/test/java/feign/DefaultRetryerTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.util.Date; + +import feign.Retryer.Default; + +import static org.junit.Assert.assertEquals; + +public class DefaultRetryerTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void only5TriesAllowedAndExponentialBackoff() throws Exception { + RetryableException e = new RetryableException(null, null, null); + Default retryer = new Retryer.Default(); + assertEquals(1, retryer.attempt); + assertEquals(0, retryer.sleptForMillis); + + retryer.continueOrPropagate(e); + assertEquals(2, retryer.attempt); + assertEquals(150, retryer.sleptForMillis); + + retryer.continueOrPropagate(e); + assertEquals(3, retryer.attempt); + assertEquals(375, retryer.sleptForMillis); + + retryer.continueOrPropagate(e); + assertEquals(4, retryer.attempt); + assertEquals(712, retryer.sleptForMillis); + + retryer.continueOrPropagate(e); + assertEquals(5, retryer.attempt); + assertEquals(1218, retryer.sleptForMillis); + + thrown.expect(RetryableException.class); + retryer.continueOrPropagate(e); + } + + @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(2, retryer.attempt); + assertEquals(1000, retryer.sleptForMillis); + } +} diff --git a/core/src/test/java/feign/EmptyTargetTest.java b/core/src/test/java/feign/EmptyTargetTest.java new file mode 100644 index 0000000000..a36968ed59 --- /dev/null +++ b/core/src/test/java/feign/EmptyTargetTest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2015 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.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.net.URI; + +import feign.Target.EmptyTarget; + +import static feign.assertj.FeignAssertions.assertThat; + +public class EmptyTargetTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void whenNameNotSupplied() { + assertThat(EmptyTarget.create(UriInterface.class)) + .isEqualTo(EmptyTarget.create(UriInterface.class, "empty:UriInterface")); + } + + @Test + public void toString_withoutName() { + assertThat(EmptyTarget.create(UriInterface.class).toString()) + .isEqualTo("EmptyTarget(type=UriInterface)"); + } + + @Test + public void toString_withName() { + assertThat(EmptyTarget.create(UriInterface.class, "manager-access").toString()) + .isEqualTo("EmptyTarget(type=UriInterface, name=manager-access)"); + } + + @Test + public void mustApplyToAbsoluteUrl() { + thrown.expect(UnsupportedOperationException.class); + thrown.expectMessage("Request with non-absolute URL not supported with empty target"); + + EmptyTarget.create(UriInterface.class) + .apply(new RequestTemplate().method("GET").append("/relative")); + } + + interface UriInterface { + + @RequestLine("GET /") + Response get(URI endpoint); + } +} diff --git a/core/src/test/java/feign/FeignBuilderTest.java b/core/src/test/java/feign/FeignBuilderTest.java new file mode 100644 index 0000000000..44b47513d2 --- /dev/null +++ b/core/src/test/java/feign/FeignBuilderTest.java @@ -0,0 +1,223 @@ +/* + * 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.squareup.okhttp.mockwebserver.MockResponse; +import com.squareup.okhttp.mockwebserver.MockWebServer; + +import org.junit.Rule; +import org.junit.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 feign.codec.Decoder; +import feign.codec.Encoder; + +import static feign.assertj.MockWebServerAssertions.assertThat; +import static org.junit.Assert.assertEquals; + +public class FeignBuilderTest { + + @Rule + public final MockWebServer server = new MockWebServer(); + + @Test + public void testDefaults() throws Exception { + server.enqueue(new MockResponse().setBody("response data")); + + String url = "http://localhost:" + server.getPort(); + TestInterface api = Feign.builder().target(TestInterface.class, url); + + Response response = api.codecPost("request data"); + assertEquals("response data", Util.toString(response.body().asReader())); + + assertThat(server.takeRequest()) + .hasBody("request data"); + } + + @Test + public void testUrlPathConcatUrlTrailingSlash() throws Exception { + server.enqueue(new MockResponse().setBody("response data")); + + String url = "http://localhost:" + server.getPort() + "/"; + TestInterface api = Feign.builder().target(TestInterface.class, url); + + api.codecPost("request data"); + assertThat(server.takeRequest()).hasPath("/"); + } + + @Test + public void testUrlPathConcatNoPathOnRequestLine() throws Exception { + server.enqueue(new MockResponse().setBody("response data")); + + String url = "http://localhost:" + server.getPort() + "/"; + TestInterface api = Feign.builder().target(TestInterface.class, url); + + api.getNoPath(); + assertThat(server.takeRequest()).hasPath("/"); + } + + @Test + public void testUrlPathConcatNoInitialSlashOnPath() throws Exception { + server.enqueue(new MockResponse().setBody("response data")); + + String url = "http://localhost:" + server.getPort() + "/"; + TestInterface api = Feign.builder().target(TestInterface.class, url); + + api.getNoInitialSlashOnSlash(); + assertThat(server.takeRequest()).hasPath("/api/thing"); + } + + @Test + public void testUrlPathConcatNoInitialSlashOnPathNoTrailingSlashOnUrl() throws Exception { + server.enqueue(new MockResponse().setBody("response data")); + + String url = "http://localhost:" + server.getPort(); + TestInterface api = Feign.builder().target(TestInterface.class, url); + + api.getNoInitialSlashOnSlash(); + assertThat(server.takeRequest()).hasPath("/api/thing"); + } + + @Test + public void testOverrideEncoder() throws Exception { + server.enqueue(new MockResponse().setBody("response data")); + + String url = "http://localhost:" + server.getPort(); + Encoder encoder = new Encoder() { + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + template.body(object.toString()); + } + }; + + TestInterface api = Feign.builder().encoder(encoder).target(TestInterface.class, url); + api.encodedPost(Arrays.asList("This", "is", "my", "request")); + + assertThat(server.takeRequest()) + .hasBody("[This, is, my, request]"); + } + + @Test + public void testOverrideDecoder() throws Exception { + server.enqueue(new MockResponse().setBody("success!")); + + String url = "http://localhost:" + server.getPort(); + Decoder decoder = new Decoder() { + @Override + public Object decode(Response response, Type type) { + return "fail"; + } + }; + + TestInterface api = Feign.builder().decoder(decoder).target(TestInterface.class, url); + assertEquals("fail", api.decodedPost()); + + assertEquals(1, server.getRequestCount()); + } + + @Test + public void testProvideRequestInterceptors() throws Exception { + server.enqueue(new MockResponse().setBody("response data")); + + String url = "http://localhost:" + server.getPort(); + RequestInterceptor requestInterceptor = new RequestInterceptor() { + @Override + public void apply(RequestTemplate template) { + template.header("Content-Type", "text/plain"); + } + }; + + TestInterface + api = + Feign.builder().requestInterceptor(requestInterceptor).target(TestInterface.class, url); + Response response = api.codecPost("request data"); + assertEquals(Util.toString(response.body().asReader()), "response data"); + + assertThat(server.takeRequest()) + .hasHeaders("Content-Type: text/plain") + .hasBody("request data"); + } + + @Test + public void testProvideInvocationHandlerFactory() throws Exception { + server.enqueue(new MockResponse().setBody("response data")); + + 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); + } + }; + + TestInterface + api = + Feign.builder().invocationHandlerFactory(factory).target(TestInterface.class, url); + Response response = api.codecPost("request data"); + assertEquals("response data", Util.toString(response.body().asReader())); + assertEquals(1, callCount.get()); + + assertThat(server.takeRequest()) + .hasBody("request data"); + } + + @Test + public void testSlashIsEncodedInPathParams() throws Exception { + server.enqueue(new MockResponse().setBody("response data")); + + String url = "http://localhost:" + server.getPort(); + + TestInterface + api = + Feign.builder().target(TestInterface.class, url); + api.getQueues("/"); + + assertThat(server.takeRequest()) + .hasPath("/api/queues/%2F"); + } + + interface TestInterface { + @RequestLine("GET") + Response getNoPath(); + + @RequestLine("GET api/thing") + Response getNoInitialSlashOnSlash(); + + @RequestLine("POST /") + Response codecPost(String data); + + @RequestLine("POST /") + void encodedPost(List data); + + @RequestLine("POST /") + String decodedPost(); + + @RequestLine(value = "GET /api/queues/{vhost}", decodeSlash = false) + String getQueues(@Param("vhost") String vhost); + } +} diff --git a/core/src/test/java/feign/FeignTest.java b/core/src/test/java/feign/FeignTest.java new file mode 100644 index 0000000000..afb598ef02 --- /dev/null +++ b/core/src/test/java/feign/FeignTest.java @@ -0,0 +1,582 @@ +/* + * 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.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import com.squareup.okhttp.mockwebserver.MockResponse; +import com.squareup.okhttp.mockwebserver.SocketPolicy; +import com.squareup.okhttp.mockwebserver.MockWebServer; + +import okio.Buffer; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.net.URI; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import feign.Target.HardCodedTarget; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; +import feign.codec.StringDecoder; + +import static feign.Util.UTF_8; +import static feign.assertj.MockWebServerAssertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class FeignTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + @Rule + public final MockWebServer server = new MockWebServer(); + + @Test + public void iterableQueryParams() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + api.queryParams("user", Arrays.asList("apple", "pear")); + + assertThat(server.takeRequest()) + .hasPath("/?1=user&2=apple&2=pear"); + } + + @Test + public void postTemplateParamsResolve() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + api.login("netflix", "denominator", "password"); + + assertThat(server.takeRequest()) + .hasBody( + "{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"}"); + } + + @Test + public void responseCoercesToStringBody() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + Response response = api.response(); + assertTrue(response.body().isRepeatable()); + assertEquals("foo", response.body().toString()); + } + + @Test + public void postFormParams() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + api.form("netflix", "denominator", "password"); + + assertThat(server.takeRequest()) + .hasBody( + "{\"customer_name\":\"netflix\",\"user_name\":\"denominator\",\"password\":\"password\"}"); + } + + @Test + public void postBodyParam() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + api.body(Arrays.asList("netflix", "denominator", "password")); + + assertThat(server.takeRequest()) + .hasHeaders("Content-Length: 32") + .hasBody("[netflix, denominator, password]"); + } + + /** + * The type of a parameter value may not be the desired type to encode as. Prefer the interface + * type. + */ + @Test + public void bodyTypeCorrespondsWithParameterType() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final AtomicReference encodedType = new AtomicReference(); + TestInterface api = new TestInterfaceBuilder() + .encoder(new Encoder.Default() { + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + encodedType.set(bodyType); + } + }) + .target("http://localhost:" + server.getPort()); + + api.body(Arrays.asList("netflix", "denominator", "password")); + + server.takeRequest(); + + assertThat(encodedType.get()).isEqualTo(new TypeToken>() { + }.getType()); + } + + @Test + public void postGZIPEncodedBodyParam() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + api.gzipBody(Arrays.asList("netflix", "denominator", "password")); + + assertThat(server.takeRequest()) + .hasNoHeaderNamed("Content-Length") + .hasGzippedBody("[netflix, denominator, password]".getBytes(UTF_8)); + } + + @Test + public void postDeflateEncodedBodyParam() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + api.deflateBody(Arrays.asList("netflix", "denominator", "password")); + + assertThat(server.takeRequest()) + .hasNoHeaderNamed("Content-Length") + .hasDeflatedBody("[netflix, denominator, password]".getBytes(UTF_8)); + } + + @Test + public void singleInterceptor() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = new TestInterfaceBuilder() + .requestInterceptor(new ForwardedForInterceptor()) + .target("http://localhost:" + server.getPort()); + + api.post(); + + assertThat(server.takeRequest()) + .hasHeaders("X-Forwarded-For: origin.host.com"); + } + + @Test + public void multipleInterceptor() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = new TestInterfaceBuilder() + .requestInterceptor(new ForwardedForInterceptor()) + .requestInterceptor(new UserAgentInterceptor()) + .target("http://localhost:" + server.getPort()); + + api.post(); + + assertThat(server.takeRequest()).hasHeaders("X-Forwarded-For: origin.host.com", + "User-Agent: Feign"); + } + + @Test + public void customExpander() throws Exception { + server.enqueue(new MockResponse()); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + api.expand(new Date(1234l)); + + assertThat(server.takeRequest()) + .hasPath("/?date=1234"); + } + + @Test + public void configKeyFormatsAsExpected() throws Exception { + assertEquals("TestInterface#post()", + Feign.configKey(TestInterface.class.getDeclaredMethod("post"))); + assertEquals("TestInterface#uriParam(String,URI,String)", + Feign.configKey(TestInterface.class + .getDeclaredMethod("uriParam", String.class, URI.class, + String.class))); + } + + @Test + public void configKeyUsesChildType() throws Exception { + assertEquals("List#iterator()", + Feign.configKey(List.class, Iterable.class.getDeclaredMethod("iterator"))); + } + + @Test + public void canOverrideErrorDecoder() throws Exception { + server.enqueue(new MockResponse().setResponseCode(404).setBody("foo")); + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("zone not found"); + + TestInterface api = new TestInterfaceBuilder() + .errorDecoder(new IllegalArgumentExceptionOn404()) + .target("http://localhost:" + server.getPort()); + + api.post(); + } + + @Test + public void retriesLostConnectionBeforeRead() throws Exception { + server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + server.enqueue(new MockResponse().setBody("success!")); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + api.post(); + + assertEquals(2, server.getRequestCount()); + } + + @Test + public void overrideTypeSpecificDecoder() throws Exception { + server.enqueue(new MockResponse().setBody("success!")); + + TestInterface api = new TestInterfaceBuilder() + .decoder(new Decoder() { + @Override + public Object decode(Response response, Type type) { + return "fail"; + } + }).target("http://localhost:" + server.getPort()); + + assertEquals(api.post(), "fail"); + } + + /** + * when you must parse a 2xx status to determine if the operation succeeded or not. + */ + @Test + public void retryableExceptionInDecoder() throws Exception { + server.enqueue(new MockResponse().setBody("retry!")); + server.enqueue(new MockResponse().setBody("success!")); + + TestInterface api = new TestInterfaceBuilder() + .decoder(new StringDecoder() { + @Override + public Object decode(Response response, Type type) throws IOException { + String string = super.decode(response, type).toString(); + if ("retry!".equals(string)) { + throw new RetryableException(string, null); + } + return string; + } + }).target("http://localhost:" + server.getPort()); + + assertEquals(api.post(), "success!"); + assertEquals(2, server.getRequestCount()); + } + + @Test + public void doesntRetryAfterResponseIsSent() throws Exception { + server.enqueue(new MockResponse().setBody("success!")); + thrown.expect(FeignException.class); + thrown.expectMessage("timeout reading POST http://"); + + TestInterface api = new TestInterfaceBuilder() + .decoder(new Decoder() { + @Override + public Object decode(Response response, Type type) throws IOException { + throw new IOException("timeout"); + } + }).target("http://localhost:" + server.getPort()); + + api.post(); + } + + @Test + public void ensureRetryerClonesItself() { + server.enqueue(new MockResponse().setResponseCode(503).setBody("foo 1")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("foo 2")); + server.enqueue(new MockResponse().setResponseCode(503).setBody("foo 3")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("foo 4")); + + MockRetryer retryer = new MockRetryer(); + + TestInterface api = Feign.builder() + .retryer(retryer) + .errorDecoder(new ErrorDecoder() + { + @Override + public Exception decode(String methodKey, Response response) + { + return new RetryableException("play it again sam!", null); + } + }).target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.response(); + api.response(); // if retryer instance was reused, this statement will throw an exception + assertEquals(4, server.getRequestCount()); + } + + private static class MockRetryer implements Retryer + { + boolean tripped; + + @Override + public void continueOrPropagate(RetryableException e) { + if (tripped) { + throw new RuntimeException("retryer instance should never be reused"); + } + tripped = true; + return; + } + + @Override + public Retryer clone() { + return new MockRetryer(); + } + } + + @Test + public void okIfDecodeRootCauseHasNoMessage() throws Exception { + server.enqueue(new MockResponse().setBody("success!")); + thrown.expect(DecodeException.class); + + TestInterface api = new TestInterfaceBuilder() + .decoder(new Decoder() { + @Override + public Object decode(Response response, Type type) throws IOException { + throw new RuntimeException(); + } + }).target("http://localhost:" + server.getPort()); + + api.post(); + } + + @Test + public void okIfEncodeRootCauseHasNoMessage() throws Exception { + server.enqueue(new MockResponse().setBody("success!")); + thrown.expect(EncodeException.class); + + TestInterface api = new TestInterfaceBuilder() + .encoder(new Encoder() { + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + throw new RuntimeException(); + } + }).target("http://localhost:" + server.getPort()); + + api.body(Arrays.asList("foo")); + } + + @Test + public void equalsHashCodeAndToStringWork() { + Target + t1 = + new HardCodedTarget(TestInterface.class, "http://localhost:8080"); + Target + t2 = + new HardCodedTarget(TestInterface.class, "http://localhost:8888"); + Target t3 = + new HardCodedTarget(OtherTestInterface.class, "http://localhost:8080"); + TestInterface i1 = Feign.builder().target(t1); + TestInterface i2 = Feign.builder().target(t1); + TestInterface i3 = Feign.builder().target(t2); + OtherTestInterface i4 = Feign.builder().target(t3); + + assertThat(i1) + .isEqualTo(i2) + .isNotEqualTo(i3) + .isNotEqualTo(i4); + + assertThat(i1.hashCode()) + .isEqualTo(i2.hashCode()) + .isNotEqualTo(i3.hashCode()) + .isNotEqualTo(i4.hashCode()); + + assertThat(i1.toString()) + .isEqualTo(i2.toString()) + .isNotEqualTo(i3.toString()) + .isNotEqualTo(i4.toString()); + + assertThat(t1) + .isNotEqualTo(i1); + + assertThat(t1.hashCode()) + .isEqualTo(i1.hashCode()); + + assertThat(t1.toString()) + .isEqualTo(i1.toString()); + } + + @Test + public void decodeLogicSupportsByteArray() throws Exception { + byte[] expectedResponse = {12, 34, 56}; + server.enqueue(new MockResponse().setBody(new Buffer().write(expectedResponse))); + + OtherTestInterface + api = + Feign.builder().target(OtherTestInterface.class, "http://localhost:" + server.getPort()); + + assertThat(api.binaryResponseBody()) + .containsExactly(expectedResponse); + } + + @Test + public void encodeLogicSupportsByteArray() throws Exception { + byte[] expectedRequest = {12, 34, 56}; + server.enqueue(new MockResponse()); + + OtherTestInterface + api = + Feign.builder().target(OtherTestInterface.class, "http://localhost:" + server.getPort()); + + api.binaryRequestBody(expectedRequest); + + assertThat(server.takeRequest()) + .hasBody(expectedRequest); + } + + 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( + @Param("customer_name") String customer, @Param("user_name") String user, + @Param("password") String password); + + @RequestLine("POST /") + void body(List contents); + + @RequestLine("POST /") + @Headers("Content-Encoding: gzip") + void gzipBody(List contents); + + @RequestLine("POST /") + @Headers("Content-Encoding: deflate") + void deflateBody(List contents); + + @RequestLine("POST /") + void form( + @Param("customer_name") String customer, @Param("user_name") String user, + @Param("password") String password); + + @RequestLine("GET /{1}/{2}") + Response uriParam(@Param("1") String one, URI endpoint, @Param("2") String two); + + @RequestLine("GET /?1={1}&2={2}") + Response queryParams(@Param("1") String one, @Param("2") Iterable twos); + + @RequestLine("POST /?date={date}") + void expand(@Param(value = "date", expander = DateToMillis.class) Date date); + + class DateToMillis implements Param.Expander { + + @Override + public String expand(Object value) { + return String.valueOf(((Date) value).getTime()); + } + } + } + + + interface OtherTestInterface { + + @RequestLine("POST /") + String post(); + + @RequestLine("POST /") + byte[] binaryResponseBody(); + + @RequestLine("POST /") + void binaryRequestBody(byte[] contents); + } + + static class ForwardedForInterceptor implements RequestInterceptor { + + @Override + public void apply(RequestTemplate template) { + template.header("X-Forwarded-For", "origin.host.com"); + } + } + + static class UserAgentInterceptor implements RequestInterceptor { + + @Override + public void apply(RequestTemplate template) { + template.header("User-Agent", "Feign"); + } + } + + static class IllegalArgumentExceptionOn404 extends 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); + } + } + + static final class TestInterfaceBuilder { + + private final Feign.Builder delegate = new Feign.Builder() + .decoder(new Decoder.Default()) + .encoder(new Encoder() { + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + if (object instanceof Map) { + template.body(new Gson().toJson(object)); + } else { + template.body(object.toString()); + } + } + }); + + TestInterfaceBuilder requestInterceptor(RequestInterceptor requestInterceptor) { + delegate.requestInterceptor(requestInterceptor); + return this; + } + + TestInterfaceBuilder encoder(Encoder encoder) { + delegate.encoder(encoder); + return this; + } + + TestInterfaceBuilder decoder(Decoder decoder) { + delegate.decoder(decoder); + return this; + } + + TestInterfaceBuilder errorDecoder(ErrorDecoder errorDecoder) { + delegate.errorDecoder(errorDecoder); + return this; + } + + TestInterface target(String url) { + return delegate.target(TestInterface.class, url); + } + } +} diff --git a/core/src/test/java/feign/LoggerTest.java b/core/src/test/java/feign/LoggerTest.java new file mode 100644 index 0000000000..c7459bbad7 --- /dev/null +++ b/core/src/test/java/feign/LoggerTest.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 com.squareup.okhttp.mockwebserver.MockResponse; +import com.squareup.okhttp.mockwebserver.MockWebServer; + +import org.assertj.core.api.SoftAssertions; +import org.junit.Rule; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.rules.ExpectedException; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.junit.runners.model.Statement; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import feign.Logger.Level; + +@RunWith(Enclosed.class) +public class LoggerTest { + + @Rule + public final MockWebServer server = new MockWebServer(); + @Rule + public final RecordingLogger logger = new RecordingLogger(); + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + interface SendsStuff { + + @RequestLine("POST /") + @Headers("Content-Type: application/json") + @Body("%7B\"customer_name\": \"{customer_name}\", \"user_name\": \"{user_name}\", \"password\": \"{password}\"%7D") + String login( + @Param("customer_name") String customer, + @Param("user_name") String user, @Param("password") String password); + } + + @RunWith(Parameterized.class) + public static class LogLevelEmitsTest extends LoggerTest { + + private final Level logLevel; + + public LogLevelEmitsTest(Level logLevel, List expectedMessages) { + this.logLevel = logLevel; + logger.expectMessages(expectedMessages); + } + + @Parameters + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {Level.NONE, Arrays.asList()}, + {Level.BASIC, Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1", + "\\[SendsStuff#login\\] <--- HTTP/1.1 200 OK \\([0-9]+ms\\)")}, + {Level.HEADERS, 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\\)")}, + {Level.FULL, 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\\)")} + }); + } + + @Test + public void levelEmits() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setBody("foo")); + + SendsStuff api = Feign.builder() + .logger(logger) + .logLevel(logLevel) + .target(SendsStuff.class, "http://localhost:" + server.getUrl("").getPort()); + + api.login("netflix", "denominator", "password"); + } + } + + @RunWith(Parameterized.class) + public static class ReadTimeoutEmitsTest extends LoggerTest { + + private final Level logLevel; + + public ReadTimeoutEmitsTest(Level logLevel, List expectedMessages) { + this.logLevel = logLevel; + logger.expectMessages(expectedMessages); + } + + @Parameters + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {Level.NONE, Arrays.asList()}, + {Level.BASIC, 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\\)")}, + {Level.HEADERS, 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\\)")}, + {Level.FULL, 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")} + }); + } + + @Test + public void levelEmitsOnReadTimeout() throws IOException, InterruptedException { + server.enqueue(new MockResponse().throttleBody(1, 1, TimeUnit.SECONDS).setBody("foo")); + thrown.expect(FeignException.class); + + SendsStuff api = Feign.builder() + .logger(logger) + .logLevel(logLevel) + .options(new Request.Options(10 * 1000, 50)) + .target(SendsStuff.class, "http://localhost:" + server.getUrl("").getPort()); + + api.login("netflix", "denominator", "password"); + } + } + + @RunWith(Parameterized.class) + public static class UnknownHostEmitsTest extends LoggerTest { + + private final Level logLevel; + + public UnknownHostEmitsTest(Level logLevel, List expectedMessages) { + this.logLevel = logLevel; + logger.expectMessages(expectedMessages); + } + + @Parameters + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {Level.NONE, Arrays.asList()}, + {Level.BASIC, Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://robofu.abc/ HTTP/1.1", + "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: robofu.abc \\([0-9]+ms\\)")}, + {Level.HEADERS, 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\\)")}, + {Level.FULL, 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")} + }); + } + + @Test + public void unknownHostEmits() throws IOException, InterruptedException { + SendsStuff api = Feign.builder() + .logger(logger) + .logLevel(logLevel) + .retryer(new Retryer() { + @Override + public void continueOrPropagate(RetryableException e) { + throw e; + } + @Override public Retryer clone() { + return this; + } + }) + .target(SendsStuff.class, "http://robofu.abc"); + + thrown.expect(FeignException.class); + + api.login("netflix", "denominator", "password"); + } + } + + @RunWith(Parameterized.class) + public static class RetryEmitsTest extends LoggerTest { + + private final Level logLevel; + + public RetryEmitsTest(Level logLevel, List expectedMessages) { + this.logLevel = logLevel; + logger.expectMessages(expectedMessages); + } + + @Parameters + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {Level.NONE, Arrays.asList()}, + {Level.BASIC, 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\\)")} + }); + } + + @Test + public void retryEmits() throws IOException, InterruptedException { + thrown.expect(FeignException.class); + + SendsStuff api = Feign.builder() + .logger(logger) + .logLevel(logLevel) + .retryer(new Retryer() { + boolean retried; + + @Override + public void continueOrPropagate(RetryableException e) { + if (!retried) { + retried = true; + return; + } + throw e; + } + + @Override + public Retryer clone() { + return this; + } + }) + .target(SendsStuff.class, "http://robofu.abc"); + + api.login("netflix", "denominator", "password"); + } + } + + private static final class RecordingLogger extends Logger implements TestRule { + + private final List messages = new ArrayList(); + private final List expectedMessages = new ArrayList(); + + RecordingLogger expectMessages(List expectedMessages) { + this.expectedMessages.addAll(expectedMessages); + return this; + } + + @Override + protected void log(String configKey, String format, Object... args) { + messages.add(methodTag(configKey) + String.format(format, args)); + } + + @Override + public Statement apply(final Statement base, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + base.evaluate(); + SoftAssertions softly = new SoftAssertions(); + for (int i = 0; i < messages.size(); i++) { + softly.assertThat(messages.get(i)).matches(expectedMessages.get(i)); + } + softly.assertAll(); + } + }; + } + } +} diff --git a/core/src/test/java/feign/RequestTemplateTest.java b/core/src/test/java/feign/RequestTemplateTest.java new file mode 100644 index 0000000000..2208f9bbdf --- /dev/null +++ b/core/src/test/java/feign/RequestTemplateTest.java @@ -0,0 +1,256 @@ +/* + * 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.junit.Test; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import static feign.RequestTemplate.expand; +import static feign.assertj.FeignAssertions.assertThat; +import static java.util.Arrays.asList; +import static org.assertj.core.data.MapEntry.entry; + +public class RequestTemplateTest { + + /** + * Avoid depending on guava solely for map literals. + */ + private static Map mapOf(String key, Object val) { + Map result = new LinkedHashMap(); + result.put(key, val); + return result; + } + + private static Map mapOf(String k1, Object v1, String k2, Object v2) { + Map result = mapOf(k1, v1); + result.put(k2, v2); + return result; + } + + private static Map mapOf(String k1, Object v1, String k2, Object v2, String k3, + Object v3) { + Map result = mapOf(k1, v1, k2, v2); + result.put(k3, v3); + return result; + } + + @Test + public void expandNotUrlEncoded() { + for (String val : Arrays.asList("apples", "sp ace", "unic???de", "qu?stion")) { + assertThat(expand("/users/{user}", mapOf("user", val))) + .isEqualTo("/users/" + val); + } + } + + @Test + public void expandMultipleParams() { + assertThat(expand("/users/{user}/{repo}", mapOf("user", "unic???de", "repo", "foo"))) + .isEqualTo("/users/unic???de/foo"); + } + + @Test + public void expandParamKeyHyphen() { + assertThat(expand("/{user-dir}", mapOf("user-dir", "foo"))) + .isEqualTo("/foo"); + } + + @Test + public void expandMissingParamProceeds() { + assertThat(expand("/{user-dir}", mapOf("user_dir", "foo"))) + .isEqualTo("/{user-dir}"); + } + + @Test + public void resolveTemplateWithParameterizedPathSkipsEncodingSlash() { + RequestTemplate template = new RequestTemplate().method("GET") + .append("{zoneId}"); + + template.resolve(mapOf("zoneId", "/hostedzone/Z1PA6795UKMFR9")); + + assertThat(template) + .hasUrl("/hostedzone/Z1PA6795UKMFR9"); + } + + @Test + public void canInsertAbsoluteHref() { + RequestTemplate template = new RequestTemplate().method("GET") + .append("/hostedzone/Z1PA6795UKMFR9"); + + template.insert(0, "https://route53.amazonaws.com/2012-12-12"); + + assertThat(template) + .hasUrl("https://route53.amazonaws.com/2012-12-12/hostedzone/Z1PA6795UKMFR9"); + } + + @Test + public void resolveTemplateWithBaseAndParameterizedQuery() { + RequestTemplate template = new RequestTemplate().method("GET") + .append("/?Action=DescribeRegions").query("RegionName.1", "{region}"); + + template.resolve(mapOf("region", "eu-west-1")); + + assertThat(template) + .hasQueries( + entry("Action", asList("DescribeRegions")), + entry("RegionName.1", asList("eu-west-1")) + ); + } + + @Test + public void resolveTemplateWithBaseAndParameterizedIterableQuery() { + RequestTemplate template = new RequestTemplate().method("GET") + .append("/?Query=one").query("Queries", "{queries}"); + + template.resolve(mapOf("queries", Arrays.asList("us-east-1", "eu-west-1"))); + + assertThat(template) + .hasQueries( + entry("Query", asList("one")), + entry("Queries", asList("us-east-1", "eu-west-1")) + ); + } + + @Test + public void resolveTemplateWithHeaderSubstitutions() { + RequestTemplate template = new RequestTemplate().method("GET") + .header("Auth-Token", "{authToken}"); + + template.resolve(mapOf("authToken", "1234")); + + assertThat(template) + .hasHeaders(entry("Auth-Token", asList("1234"))); + } + + @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( + mapOf("domainId", 1001, "name", "denominator.io", "type", "CNAME") + ); + + assertThat(template) + .hasUrl("/domains/1001/records") + .hasQueries( + entry("name", asList("denominator.io")), + entry("type", asList("CNAME")) + ); + } + + @Test + public void insertHasQueryParams() throws Exception { + RequestTemplate template = new RequestTemplate().method("GET")// + .append("/domains/1001/records")// + .query("name", "denominator.io")// + .query("type", "CNAME"); + + template.insert(0, "https://host/v1.0/1234?provider=foo"); + + assertThat(template) + .hasUrl("https://host/v1.0/1234/domains/1001/records") + .hasQueries( + entry("provider", asList("foo")), + entry("name", asList("denominator.io")), + entry("type", asList("CNAME")) + ); + } + + @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( + mapOf( + "customer_name", "netflix", + "user_name", "denominator", + "password", "password" + ) + ); + + assertThat(template) + .hasBody( + "{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"}") + .hasHeaders( + entry("Content-Length", asList(String.valueOf(template.body().length))) + ); + } + + @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(mapOf( + "domainId", 1001, + "nameVariable", "denominator.io" + ) + ); + + assertThat(template) + .hasUrl("/domains/1001/records") + .hasQueries( + entry("name", asList("denominator.io")) + ); + } + + @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(mapOf("domainId", 1001)); + + assertThat(template) + .hasUrl("/domains/1001/records") + .hasQueries(); + } + + @Test + public void spaceEncodingInUrlParam() { + RequestTemplate template = new RequestTemplate().method("GET")// + .append("/api/{value1}?key={value2}"); + + template = template.resolve(mapOf("value1", "ABC 123", "value2", "XYZ 123")); + + assertThat(template.request().url()) + .isEqualTo("/api/ABC%20123?key=XYZ+123"); + } + + @Test + public void encodeSlashTest() throws Exception { + RequestTemplate template = new RequestTemplate().method("GET") + .append("/api/{vhost}") + .decodeSlash(false); + + template.resolve(mapOf("vhost", "/")); + + assertThat(template) + .hasUrl("/api/%2F"); + } +} diff --git a/core/src/test/java/feign/TargetTest.java b/core/src/test/java/feign/TargetTest.java new file mode 100644 index 0000000000..5d7f5c690b --- /dev/null +++ b/core/src/test/java/feign/TargetTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2015 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.squareup.okhttp.mockwebserver.MockResponse; +import com.squareup.okhttp.mockwebserver.MockWebServer; + +import org.junit.Rule; +import org.junit.Test; + +import feign.Target.HardCodedTarget; + +import static feign.assertj.MockWebServerAssertions.assertThat; + +public class TargetTest { + + @Rule + public final MockWebServer server = new MockWebServer(); + + interface TestQuery { + + @RequestLine("GET /{path}?query={query}") + Response get(@Param("path") String path, @Param("query") String query); + } + + @Test + public void baseCaseQueryParamsArePercentEncoded() throws InterruptedException { + server.enqueue(new MockResponse()); + + String baseUrl = server.getUrl("/default").toString(); + + Feign.builder().target(TestQuery.class, baseUrl).get("slash/foo", "slash/bar"); + + assertThat(server.takeRequest()).hasPath("/default/slash/foo?query=slash%2Fbar"); + } + + /** + * Per #227, some may want to opt out of + * percent encoding. Here's how. + */ + @Test + public void targetCanCreateCustomRequest() throws InterruptedException { + server.enqueue(new MockResponse()); + + String baseUrl = server.getUrl("/default").toString(); + Target custom = new HardCodedTarget(TestQuery.class, baseUrl) { + + @Override + public Request apply(RequestTemplate input) { + Request urlEncoded = super.apply(input); + return Request.create( + urlEncoded.method(), + urlEncoded.url().replace("%2F", "/"), + urlEncoded.headers(), + urlEncoded.body(), urlEncoded.charset() + ); + } + }; + + Feign.builder().target(custom).get("slash/foo", "slash/bar"); + + assertThat(server.takeRequest()).hasPath("/default/slash/foo?query=slash/bar"); + } +} diff --git a/core/src/test/java/feign/UtilTest.java b/core/src/test/java/feign/UtilTest.java new file mode 100644 index 0000000000..4998cc0ac2 --- /dev/null +++ b/core/src/test/java/feign/UtilTest.java @@ -0,0 +1,107 @@ +/* + * 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.junit.Test; + +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.List; + +import feign.codec.Decoder; + +import static feign.Util.resolveLastTypeParameter; +import static org.junit.Assert.assertEquals; + +public class UtilTest { + + @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(listStringType, last); + } + + @Test + public void lastTypeFromInstance() throws Exception { + Parameterized instance = new ParameterizedSubtype(); + Type last = resolveLastTypeParameter(instance.getClass(), Parameterized.class); + assertEquals(String.class, last); + } + + @Test + public void lastTypeFromAnonymous() throws Exception { + Parameterized instance = new Parameterized() { + }; + Type last = resolveLastTypeParameter(instance.getClass(), Parameterized.class); + assertEquals(Reader.class, last); + } + + @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(listStringType, last); + } + + @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(listStringType, last); + } + + @Test + public void unboundWildcardIsObject() throws Exception { + Type + context = + LastTypeParameter.class.getDeclaredField("PARAMETERIZED_DECODER_UNBOUND").getGenericType(); + Type last = resolveLastTypeParameter(context, ParameterizedDecoder.class); + assertEquals(Object.class, last); + } + + 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 { + + } +} diff --git a/core/src/test/java/feign/assertj/FeignAssertions.java b/core/src/test/java/feign/assertj/FeignAssertions.java new file mode 100644 index 0000000000..b0805d79c1 --- /dev/null +++ b/core/src/test/java/feign/assertj/FeignAssertions.java @@ -0,0 +1,27 @@ +/* + * Copyright 2015 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.assertj; + +import org.assertj.core.api.Assertions; + +import feign.RequestTemplate; + +public class FeignAssertions extends Assertions { + + public static RequestTemplateAssert assertThat(RequestTemplate actual) { + return new RequestTemplateAssert(actual); + } +} diff --git a/core/src/test/java/feign/assertj/MockWebServerAssertions.java b/core/src/test/java/feign/assertj/MockWebServerAssertions.java new file mode 100644 index 0000000000..ba536ce798 --- /dev/null +++ b/core/src/test/java/feign/assertj/MockWebServerAssertions.java @@ -0,0 +1,27 @@ +/* + * Copyright 2015 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.assertj; + +import com.squareup.okhttp.mockwebserver.RecordedRequest; + +import org.assertj.core.api.Assertions; + +public class MockWebServerAssertions extends Assertions { + + public static RecordedRequestAssert assertThat(RecordedRequest actual) { + return new RecordedRequestAssert(actual); + } +} diff --git a/core/src/test/java/feign/assertj/RecordedRequestAssert.java b/core/src/test/java/feign/assertj/RecordedRequestAssert.java new file mode 100644 index 0000000000..b1ae6bcbe0 --- /dev/null +++ b/core/src/test/java/feign/assertj/RecordedRequestAssert.java @@ -0,0 +1,146 @@ +/* + * Copyright 2015 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.assertj; + +import com.squareup.okhttp.Headers; +import com.squareup.okhttp.mockwebserver.RecordedRequest; + +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.data.MapEntry; +import org.assertj.core.internal.ByteArrays; +import org.assertj.core.internal.Failures; +import org.assertj.core.internal.Maps; +import org.assertj.core.internal.Objects; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.GZIPInputStream; +import java.util.zip.InflaterInputStream; + +import feign.Util; + +import static org.assertj.core.data.MapEntry.entry; +import static org.assertj.core.error.ShouldNotContain.shouldNotContain; + +public final class RecordedRequestAssert + extends AbstractAssert { + + ByteArrays arrays = ByteArrays.instance(); + Objects objects = Objects.instance(); + Maps maps = Maps.instance(); + Failures failures = Failures.instance(); + + public RecordedRequestAssert(RecordedRequest actual) { + super(actual, RecordedRequestAssert.class); + } + + public RecordedRequestAssert hasMethod(String expected) { + isNotNull(); + objects.assertEqual(info, actual.getMethod(), expected); + return this; + } + + public RecordedRequestAssert hasPath(String expected) { + isNotNull(); + objects.assertEqual(info, actual.getPath(), expected); + return this; + } + + public RecordedRequestAssert hasBody(String utf8Expected) { + isNotNull(); + objects.assertEqual(info, actual.getBody().readUtf8(), utf8Expected); + return this; + } + + public RecordedRequestAssert hasGzippedBody(byte[] expectedUncompressed) { + isNotNull(); + byte[] compressedBody = actual.getBody().readByteArray(); + byte[] uncompressedBody; + try { + uncompressedBody = + Util.toByteArray(new GZIPInputStream(new ByteArrayInputStream(compressedBody))); + } catch (IOException e) { + throw new RuntimeException(e); + } + arrays.assertContains(info, uncompressedBody, expectedUncompressed); + return this; + } + + public RecordedRequestAssert hasDeflatedBody(byte[] expectedUncompressed) { + isNotNull(); + byte[] compressedBody = actual.getBody().readByteArray(); + byte[] uncompressedBody; + try { + uncompressedBody = + Util.toByteArray(new InflaterInputStream(new ByteArrayInputStream(compressedBody))); + } catch (IOException e) { + throw new RuntimeException(e); + } + arrays.assertContains(info, uncompressedBody, expectedUncompressed); + return this; + } + + public RecordedRequestAssert hasBody(byte[] expected) { + isNotNull(); + arrays.assertContains(info, actual.getBody().readByteArray(), expected); + return this; + } + + /** + * @deprecated use {@link #hasHeaders(MapEntry...)} + */ + @Deprecated + public RecordedRequestAssert hasHeaders(String... headerLines) { + isNotNull(); + Headers.Builder builder = new Headers.Builder(); + for (String next : headerLines) { + builder.add(next); + } + List expected = new ArrayList(); + for (Map.Entry> next : builder.build().toMultimap().entrySet()) { + expected.add(entry(next.getKey(), next.getValue())); + } + hasHeaders(expected.toArray(new MapEntry[expected.size()])); + return this; + } + + public RecordedRequestAssert hasHeaders(MapEntry... expected) { + isNotNull(); + maps.assertContains(info, actual.getHeaders().toMultimap(), expected); + return this; + } + + public RecordedRequestAssert hasNoHeaderNamed(final String... names) { + isNotNull(); + Set found = new LinkedHashSet(); + for (String header : actual.getHeaders().names()) { + for (String name : names) { + if (header.toLowerCase().startsWith(name.toLowerCase() + ":")) { + found.add(header); + } + } + } + if (found.isEmpty()) { + return this; + } + throw failures.failure(info, shouldNotContain(actual.getHeaders(), names, found)); + } +} diff --git a/core/src/test/java/feign/assertj/RequestTemplateAssert.java b/core/src/test/java/feign/assertj/RequestTemplateAssert.java new file mode 100644 index 0000000000..ca18fd715a --- /dev/null +++ b/core/src/test/java/feign/assertj/RequestTemplateAssert.java @@ -0,0 +1,89 @@ +/* + * Copyright 2015 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.assertj; + +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.data.MapEntry; +import org.assertj.core.internal.ByteArrays; +import org.assertj.core.internal.Maps; +import org.assertj.core.internal.Objects; + +import feign.RequestTemplate; + +import static feign.Util.UTF_8; + +public final class RequestTemplateAssert + extends AbstractAssert { + + ByteArrays arrays = ByteArrays.instance(); + Objects objects = Objects.instance(); + Maps maps = Maps.instance(); + + public RequestTemplateAssert(RequestTemplate actual) { + super(actual, RequestTemplateAssert.class); + } + + public RequestTemplateAssert hasMethod(String expected) { + isNotNull(); + objects.assertEqual(info, actual.method(), expected); + return this; + } + + public RequestTemplateAssert hasUrl(String expected) { + isNotNull(); + objects.assertEqual(info, actual.url(), expected); + return this; + } + + public RequestTemplateAssert hasBody(String utf8Expected) { + isNotNull(); + if (actual.bodyTemplate() != null) { + failWithMessage("\nExpecting bodyTemplate to be null, but was:<%s>", actual.bodyTemplate()); + } + objects.assertEqual(info, new String(actual.body(), UTF_8), utf8Expected); + return this; + } + + public RequestTemplateAssert hasBody(byte[] expected) { + isNotNull(); + if (actual.bodyTemplate() != null) { + failWithMessage("\nExpecting bodyTemplate to be null, but was:<%s>", actual.bodyTemplate()); + } + arrays.assertContains(info, actual.body(), expected); + return this; + } + + public RequestTemplateAssert hasBodyTemplate(String expected) { + isNotNull(); + if (actual.body() != null) { + failWithMessage("\nExpecting body to be null, but was:<%s>", actual.bodyTemplate()); + } + objects.assertEqual(info, actual.bodyTemplate(), expected); + return this; + } + + public RequestTemplateAssert hasQueries(MapEntry... entries) { + isNotNull(); + maps.assertContainsExactly(info, actual.queries(), entries); + return this; + } + + public RequestTemplateAssert hasHeaders(MapEntry... entries) { + isNotNull(); + maps.assertContainsExactly(info, actual.headers(), entries); + return this; + } +} 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..df136dd590 --- /dev/null +++ b/core/src/test/java/feign/auth/BasicAuthRequestInterceptorTest.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.auth; + +import org.junit.Test; + +import feign.RequestTemplate; + +import static feign.assertj.FeignAssertions.assertThat; +import static java.util.Arrays.asList; +import static org.assertj.core.data.MapEntry.entry; + +public class BasicAuthRequestInterceptorTest { + + @Test + public void addsAuthorizationHeader() { + RequestTemplate template = new RequestTemplate(); + BasicAuthRequestInterceptor + interceptor = + new BasicAuthRequestInterceptor("Aladdin", "open sesame"); + interceptor.apply(template); + + assertThat(template) + .hasHeaders( + entry("Authorization", asList("Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==")) + ); + } + + @Test + public void addsAuthorizationHeader_longUserAndPassword() { + RequestTemplate template = new RequestTemplate(); + BasicAuthRequestInterceptor + interceptor = + new BasicAuthRequestInterceptor("IOIOIOIOIOIOIOIOIOIOIOIOIOIOIOIOIOIOIO", + "101010101010101010101010101010101010101010"); + interceptor.apply(template); + + assertThat(template) + .hasHeaders( + entry("Authorization", asList( + "Basic SU9JT0lPSU9JT0lPSU9JT0lPSU9JT0lPSU9JT0lPSU9JT0lPSU86MTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEw")) + ); + } +} diff --git a/core/src/test/java/feign/client/DefaultClientTest.java b/core/src/test/java/feign/client/DefaultClientTest.java new file mode 100644 index 0000000000..27f090ca42 --- /dev/null +++ b/core/src/test/java/feign/client/DefaultClientTest.java @@ -0,0 +1,198 @@ +/* + * Copyright 2015 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.client; + +import com.squareup.okhttp.mockwebserver.MockResponse; +import com.squareup.okhttp.mockwebserver.SocketPolicy; +import com.squareup.okhttp.mockwebserver.MockWebServer; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.ProtocolException; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; + +import feign.Client; +import feign.Feign; +import feign.FeignException; +import feign.Headers; +import feign.Logger; +import feign.RequestLine; +import feign.Response; + +import static feign.Util.UTF_8; +import static feign.assertj.MockWebServerAssertions.assertThat; +import static java.util.Arrays.asList; +import static org.hamcrest.core.Is.isA; +import static org.junit.Assert.assertEquals; + +public class DefaultClientTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + @Rule + public final MockWebServer server = new MockWebServer(); + Client trustSSLSockets = new Client.Default(TrustingSSLSocketFactory.get(), null); + Client disableHostnameVerification = + new Client.Default(TrustingSSLSocketFactory.get(), new HostnameVerifier() { + @Override + public boolean verify(String s, SSLSession sslSession) { + return true; + } + }); + + @Test + public void parsesRequestAndResponse() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setBody("foo").addHeader("Foo: Bar")); + + TestInterface + api = + Feign.builder().target(TestInterface.class, "http://localhost:" + server.getPort()); + + Response response = api.post("foo"); + + assertThat(response.status()).isEqualTo(200); + assertThat(response.reason()).isEqualTo("OK"); + assertThat(response.headers()) + .containsEntry("Content-Length", asList("3")) + .containsEntry("Foo", asList("Bar")); + assertThat(response.body().asInputStream()) + .hasContentEqualTo(new ByteArrayInputStream("foo".getBytes(UTF_8))); + + assertThat(server.takeRequest()).hasMethod("POST") + .hasPath("/?foo=bar&foo=baz&qux=") + .hasHeaders("Foo: Bar", "Foo: Baz", "Qux: ", "Accept: */*", "Content-Length: 3") + .hasBody("foo"); + } + + @Test + public void parsesErrorResponse() throws IOException, InterruptedException { + thrown.expect(FeignException.class); + thrown.expectMessage("status 500 reading TestInterface#post(String); content:\n" + "ARGHH"); + + server.enqueue(new MockResponse().setResponseCode(500).setBody("ARGHH")); + + TestInterface + api = + Feign.builder().target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.post("foo"); + } + + /** + * We currently don't include the 60-line + * workaround jersey uses to overcome the lack of support for PATCH. For now, prefer okhttp. + * + * @see java.net.HttpURLConnection#setRequestMethod + */ + @Test + public void patchUnsupported() throws IOException, InterruptedException { + thrown.expectCause(isA(ProtocolException.class)); + + TestInterface + api = + Feign.builder().target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.patch(); + } + + @Test + public void canOverrideSSLSocketFactory() throws IOException, InterruptedException { + server.useHttps(TrustingSSLSocketFactory.get("localhost"), false); + server.enqueue(new MockResponse()); + + TestInterface api = Feign.builder() + .client(trustSSLSockets) + .target(TestInterface.class, "https://localhost:" + server.getPort()); + + api.post("foo"); + } + + @Test + public void canOverrideHostnameVerifier() throws IOException, InterruptedException { + server.useHttps(TrustingSSLSocketFactory.get("bad.example.com"), false); + server.enqueue(new MockResponse()); + + TestInterface api = Feign.builder() + .client(disableHostnameVerification) + .target(TestInterface.class, "https://localhost:" + server.getPort()); + + api.post("foo"); + } + + @Test + public void retriesFailedHandshake() throws IOException, InterruptedException { + server.useHttps(TrustingSSLSocketFactory.get("localhost"), false); + server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.FAIL_HANDSHAKE)); + server.enqueue(new MockResponse()); + + TestInterface api = Feign.builder() + .client(trustSSLSockets) + .target(TestInterface.class, "https://localhost:" + server.getPort()); + + api.post("foo"); + assertEquals(2, server.getRequestCount()); + } + + @Test + public void safeRebuffering() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = Feign.builder() + .logger(new Logger(){ + @Override + protected void log(String configKey, String format, Object... args) { + } + }) + .logLevel(Logger.Level.FULL) // rebuffers the body + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.post("foo"); + } + + /** This shows that is a no-op or otherwise doesn't cause an NPE when there's no content. */ + @Test + public void safeRebuffering_noContent() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setResponseCode(204)); + + TestInterface api = Feign.builder() + .logger(new Logger(){ + @Override + protected void log(String configKey, String format, Object... args) { + } + }) + .logLevel(Logger.Level.FULL) // rebuffers the body + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.post("foo"); + } + + interface TestInterface { + + @RequestLine("POST /?foo=bar&foo=baz&qux=") + @Headers({"Foo: Bar", "Foo: Baz", "Qux: ", "Content-Type: text/plain"}) + Response post(String body); + + @RequestLine("PATCH /") + @Headers("Accept: text/plain") + String patch(); + } +} diff --git a/core/src/test/java/feign/client/TrustingSSLSocketFactory.java b/core/src/test/java/feign/client/TrustingSSLSocketFactory.java new file mode 100644 index 0000000000..c3bec6afe9 --- /dev/null +++ b/core/src/test/java/feign/client/TrustingSSLSocketFactory.java @@ -0,0 +1,186 @@ +/* + * 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.client; + +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 java.util.LinkedHashMap; +import java.util.Map; + +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; + +/** + * Used for ssl tests to simplify setup. + */ +public final class TrustingSSLSocketFactory extends SSLSocketFactory + implements X509TrustManager, X509KeyManager { + + private static final Map + sslSocketFactories = + new LinkedHashMap(); + private static final char[] KEYSTORE_PASSWORD = "password".toCharArray(); + private final static String[] ENABLED_CIPHER_SUITES = {"SSL_RSA_WITH_RC4_128_MD5"}; + 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 new RuntimeException(e); + } + this.serverAlias = serverAlias; + if (serverAlias.isEmpty()) { + this.privateKey = null; + this.certificateChain = null; + } else { + try { + KeyStore + keyStore = + loadKeyStore(TrustingSSLSocketFactory.class.getResourceAsStream("/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 new RuntimeException(e); + } + } + } + + public static SSLSocketFactory get() { + return get(""); + } + + public synchronized static SSLSocketFactory get(String serverAlias) { + if (!sslSocketFactories.containsKey(serverAlias)) { + sslSocketFactories.put(serverAlias, new TrustingSSLSocketFactory(serverAlias)); + } + return sslSocketFactories.get(serverAlias); + } + + static Socket setEnabledCipherSuites(Socket socket) { + SSLSocket.class.cast(socket).setEnabledCipherSuites(ENABLED_CIPHER_SUITES); + return socket; + } + + private static KeyStore loadKeyStore(InputStream inputStream) throws IOException { + try { + KeyStore keyStore = KeyStore.getInstance("JKS"); + keyStore.load(inputStream, KEYSTORE_PASSWORD); + return keyStore; + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + inputStream.close(); + } + } + + @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)); + } + + @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; + } +} 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..5bfffd4708 --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultDecoderTest.java @@ -0,0 +1,84 @@ +/* + * 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.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +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 feign.Response; + +import static feign.Util.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class DefaultDecoderTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private final Decoder decoder = new Decoder.Default(); + + @Test + public void testDecodesToString() throws Exception { + Response response = knownResponse(); + Object decodedObject = decoder.decode(response, String.class); + assertEquals(String.class, decodedObject.getClass()); + assertEquals("response body", decodedObject.toString()); + } + + @Test + public void testDecodesToByteArray() throws Exception { + Response response = knownResponse(); + Object decodedObject = decoder.decode(response, byte[].class); + assertEquals(byte[].class, decodedObject.getClass()); + assertEquals("response body", new String((byte[]) decodedObject, UTF_8)); + } + + @Test + public void testDecodesNullBodyToNull() throws Exception { + assertNull(decoder.decode(nullBodyResponse(), Document.class)); + } + + @Test + public void testRefusesToDecodeOtherTypes() throws Exception { + thrown.expect(DecodeException.class); + thrown.expectMessage(" is not a type supported by this decoder."); + + 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(), (byte[]) 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..70e17602e1 --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultEncoderTest.java @@ -0,0 +1,61 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.util.Arrays; +import java.util.Date; + +import feign.RequestTemplate; + +import static feign.Util.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class DefaultEncoderTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + 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, String.class, template); + assertEquals(content, new String(template.body(), UTF_8)); + } + + @Test + public void testEncodesByteArray() throws Exception { + byte[] content = {12, 34, 56}; + RequestTemplate template = new RequestTemplate(); + encoder.encode(content, byte[].class, template); + assertTrue(Arrays.equals(content, template.body())); + } + + @Test + public void testRefusesToEncodeOtherTypes() throws Exception { + thrown.expect(EncodeException.class); + thrown.expectMessage("is not a type supported by this encoder."); + + encoder.encode(new Date(), Date.class, 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..bd49984e54 --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; + +import feign.FeignException; +import feign.Response; + +import static feign.Util.RETRY_AFTER; +import static feign.Util.UTF_8; + +public class DefaultErrorDecoderTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + + Map> headers = new LinkedHashMap>(); + + @Test + public void throwsFeignException() throws Throwable { + thrown.expect(FeignException.class); + thrown.expectMessage("status 500 reading Service#foo()"); + + Response response = Response.create(500, "Internal server error", headers, (byte[]) null); + + throw errorDecoder.decode("Service#foo()", response); + } + + @Test + public void throwsFeignExceptionIncludingBody() throws Throwable { + thrown.expect(FeignException.class); + thrown.expectMessage("status 500 reading Service#foo(); content:\nhello world"); + + Response + response = + Response.create(500, "Internal server error", headers, "hello world", UTF_8); + + throw errorDecoder.decode("Service#foo()", response); + } + + @Test + public void retryAfterHeaderThrowsRetryableException() throws Throwable { + thrown.expect(FeignException.class); + thrown.expectMessage("status 503 reading Service#foo()"); + + headers.put(RETRY_AFTER, Arrays.asList("Sat, 1 Jan 2000 00:00:00 GMT")); + Response response = Response.create(503, "Service Unavailable", headers, (byte[]) 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..222bd63fc9 --- /dev/null +++ b/core/src/test/java/feign/codec/RetryAfterDecoderTest.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.codec; + +import org.junit.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.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class RetryAfterDecoderTest { + + private RetryAfterDecoder decoder = new RetryAfterDecoder(RFC822_FORMAT) { + protected long currentTimeMillis() { + try { + return RFC822_FORMAT.parse("Sat, 1 Jan 2000 00:00:00 GMT").getTime(); + } catch (ParseException e) { + throw new RuntimeException(e); + } + } + }; + + @Test + public void malformDateFailsGracefully() { + assertFalse(decoder.apply("Fri, 31 Dec 1999 23:59:59 ZBW") != null); + } + + @Test + public void rfc822Parses() throws ParseException { + assertEquals(RFC822_FORMAT.parse("Fri, 31 Dec 1999 23:59:59 GMT"), + decoder.apply("Fri, 31 Dec 1999 23:59:59 GMT")); + } + + @Test + public void relativeSecondsParses() throws ParseException { + assertEquals(RFC822_FORMAT.parse("Sun, 2 Jan 2000 00:00:00 GMT"), decoder.apply("86400")); + } +} 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..ae41a8f677 --- /dev/null +++ b/core/src/test/java/feign/examples/GitHubExample.java @@ -0,0 +1,91 @@ +/* + * 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 java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.List; + +import feign.Feign; +import feign.Logger; +import feign.Param; +import feign.RequestLine; +import feign.Response; +import feign.codec.Decoder; + +import static feign.Util.ensureClosed; + +/** + * adapted from {@code com.example.retrofit.GitHubClient} + */ +public class GitHubExample { + + public static void main(String... args) { + GitHub github = Feign.builder() + .decoder(new GsonDecoder()) + .logger(new Logger.ErrorLogger()) + .logLevel(Logger.Level.BASIC) + .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 + ")"); + } + } + + interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + } + + static class Contributor { + + String login; + int 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/example-github/README.md b/example-github/README.md new file mode 100644 index 0000000000..d6ae311657 --- /dev/null +++ b/example-github/README.md @@ -0,0 +1,10 @@ +GitHub Example +=================== + +This is an example of a simple json client. + +=== Building example with Gradle +Install and run `gradle` to produce `build/github` + +=== Building example with Maven +Install and run `mvn` to produce `target/github` diff --git a/example-github/build.gradle b/example-github/build.gradle new file mode 100644 index 0000000000..02c0c17ebe --- /dev/null +++ b/example-github/build.gradle @@ -0,0 +1,55 @@ +// NOTE: This module is intended to be a stand-alone example which does depend on nebula. +defaultTasks 'clean', 'fatJar' + +apply plugin: 'java' + +repositories { + mavenCentral() +} + +configurations { + compile +} + +dependencies { + compile 'com.netflix.feign:feign-core:8.7.0' + compile 'com.netflix.feign:feign-gson:8.7.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) + } +} diff --git a/example-github/pom.xml b/example-github/pom.xml new file mode 100644 index 0000000000..9ecc4ad171 --- /dev/null +++ b/example-github/pom.xml @@ -0,0 +1,73 @@ + + + 4.0.0 + + + org.sonatype.oss + oss-parent + 7 + + + com.netflix.feign + feign-example-github + jar + 8.7.0 + GitHub Example + + + + com.netflix.feign + feign-core + ${project.version} + + + com.netflix.feign + feign-gson + ${project.version} + + + + + package + + + org.apache.maven.plugins + maven-shade-plugin + 2.4.1 + + + package + + shade + + + + + feign.example.github.GitHubExample + + + false + + + + + + org.skife.maven + really-executable-jar-maven-plugin + 1.4.1 + + github + + + + package + + really-executable-jar + + + + + + + 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..26058c8048 --- /dev/null +++ b/example-github/src/main/java/feign/example/github/GitHubExample.java @@ -0,0 +1,95 @@ +/* + * 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 java.io.IOException; +import java.util.List; + +import feign.Feign; +import feign.Logger; +import feign.Param; +import feign.RequestLine; +import feign.Response; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; +import feign.gson.GsonDecoder; + +/** + * Inspired by {@code com.example.retrofit.GitHubClient} + */ +public class GitHubExample { + + interface GitHub { + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + } + + static class Contributor { + String login; + int contributions; + } + + static class GitHubClientError extends RuntimeException { + private String message; // parsed from json + + @Override + public String getMessage() { + return message; + } + } + + public static void main(String... args) { + Decoder decoder = new GsonDecoder(); + GitHub github = Feign.builder() + .decoder(decoder) + .errorDecoder(new GitHubErrorDecoder(decoder)) + .logger(new Logger.ErrorLogger()) + .logLevel(Logger.Level.BASIC) + .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 + ")"); + } + + System.out.println("Now, let's cause an error."); + try { + github.contributors("netflix", "some-unknown-project"); + } catch (GitHubClientError e) { + System.out.println(e.getMessage()); + } + } + + static class GitHubErrorDecoder implements ErrorDecoder { + + final Decoder decoder; + final ErrorDecoder defaultDecoder = new ErrorDecoder.Default(); + + GitHubErrorDecoder(Decoder decoder) { + this.decoder = decoder; + } + + @Override + public Exception decode(String methodKey, Response response) { + try { + return (Exception) decoder.decode(response, GitHubClientError.class); + } catch (IOException fallbackToDefault) { + return defaultDecoder.decode(methodKey, response); + } + } + } +} diff --git a/example-wikipedia/README.md b/example-wikipedia/README.md new file mode 100644 index 0000000000..e9094c6a6f --- /dev/null +++ b/example-wikipedia/README.md @@ -0,0 +1,10 @@ +Wikipedia Example +=================== + +This is an example of advanced json response parsing, including pagination. + +=== Building example with Gradle +Install and run `gradle` to produce `build/wikipedia` + +=== Building example with Maven +Install and run `mvn` to produce `target/wikipedia` diff --git a/example-wikipedia/build.gradle b/example-wikipedia/build.gradle new file mode 100644 index 0000000000..2efb9e58fc --- /dev/null +++ b/example-wikipedia/build.gradle @@ -0,0 +1,55 @@ +// NOTE: This module is intended to be a stand-alone example which does depend on nebula. +defaultTasks 'clean', 'fatJar' + +apply plugin: 'java' + +repositories { + mavenCentral() +} + +configurations { + compile +} + +dependencies { + compile 'com.netflix.feign:feign-core:8.7.0' + compile 'com.netflix.feign:feign-gson:8.7.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) + } +} diff --git a/example-wikipedia/pom.xml b/example-wikipedia/pom.xml new file mode 100644 index 0000000000..a98e4b19c0 --- /dev/null +++ b/example-wikipedia/pom.xml @@ -0,0 +1,78 @@ + + + 4.0.0 + + + org.sonatype.oss + oss-parent + 7 + + + com.netflix.feign + feign-example-wikipedia + jar + 8.7.0 + Wikipedia Example + + + + com.netflix.feign + feign-core + ${project.version} + + + com.netflix.feign + feign-gson + ${project.version} + + + com.google.code.gson + gson + 2.2.4 + + + + + package + + + org.apache.maven.plugins + maven-shade-plugin + 2.4.1 + + + package + + shade + + + + + feign.example.wikipedia.WikipediaExample + + + false + + + + + + org.skife.maven + really-executable-jar-maven-plugin + 1.3.0 + + wikipedia + + + + package + + really-executable-jar + + + + + + + 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..c7c243622d --- /dev/null +++ b/example-wikipedia/src/main/java/feign/example/wikipedia/ResponseAdapter.java @@ -0,0 +1,81 @@ +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 ("continue".equals(nextName)) { + reader.beginObject(); + while (reader.hasNext()) { + if ("gsroffset".equals(reader.nextName())) { + pages.nextOffset = reader.nextLong(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + 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..f0b7b40cfa --- /dev/null +++ b/example-wikipedia/src/main/java/feign/example/wikipedia/WikipediaExample.java @@ -0,0 +1,138 @@ +/* + * 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.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; + +import feign.Feign; +import feign.Logger; +import feign.Param; +import feign.RequestLine; +import feign.gson.GsonDecoder; + +public class WikipediaExample { + + static ResponseAdapter pagesAdapter = 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; + } + }; + + public static void main(String... args) throws InterruptedException { + Gson gson = new GsonBuilder() + .registerTypeAdapter(new TypeToken>() { + }.getType(), pagesAdapter) + .create(); + + Wikipedia wikipedia = Feign.builder() + .decoder(new GsonDecoder(gson)) + .logger(new Logger.ErrorLogger()) + .logLevel(Logger.Level.BASIC) + .target(Wikipedia.class, "https://en.wikipedia.org"); + + 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(); + } + }; + } + + public static interface Wikipedia { + + @RequestLine("GET /w/api.php?action=query&continue=&generator=search&prop=info&format=json&gsrsearch={search}") + Response search(@Param("search") String search); + + @RequestLine("GET /w/api.php?action=query&continue=&generator=search&prop=info&format=json&gsrsearch={search}&gsroffset={offset}") + Response resumeSearch(@Param("search") String search, @Param("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; + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..c97a8bdb90 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..f1151d2ba2 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Aug 02 08:22:13 PDT 2015 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-2.2.1-all.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..37c05e0c77 --- /dev/null +++ b/gson/README.md @@ -0,0 +1,13 @@ +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"); +``` diff --git a/gson/build.gradle b/gson/build.gradle new file mode 100644 index 0000000000..c876388bf1 --- /dev/null +++ b/gson/build.gradle @@ -0,0 +1,11 @@ +apply plugin: 'java' + +sourceCompatibility = 1.6 + +dependencies { + compile project(':feign-core') + compile 'com.google.code.gson:gson:2.3.1' + testCompile 'junit:junit:4.12' + testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7 + testCompile project(':feign-core').sourceSets.test.output // for assertions +} 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..9de868998f --- /dev/null +++ b/gson/src/main/java/feign/gson/DoubleToIntMapTypeAdapter.java @@ -0,0 +1,60 @@ +/* + * 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/GsonDecoder.java b/gson/src/main/java/feign/gson/GsonDecoder.java new file mode 100644 index 0000000000..cd5fcf4b2f --- /dev/null +++ b/gson/src/main/java/feign/gson/GsonDecoder.java @@ -0,0 +1,65 @@ +/* + * 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 com.google.gson.TypeAdapter; + +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.Collections; + +import feign.Response; +import feign.codec.Decoder; + +import static feign.Util.ensureClosed; + +public class GsonDecoder implements Decoder { + + private final Gson gson; + + public GsonDecoder(Iterable> adapters) { + this(GsonFactory.create(adapters)); + } + + public GsonDecoder() { + this(Collections.>emptyList()); + } + + 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..5c00177660 --- /dev/null +++ b/gson/src/main/java/feign/gson/GsonEncoder.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.gson; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; + +import java.lang.reflect.Type; +import java.util.Collections; + +import feign.RequestTemplate; +import feign.codec.Encoder; + +public class GsonEncoder implements Encoder { + + private final Gson gson; + + public GsonEncoder(Iterable> adapters) { + this(GsonFactory.create(adapters)); + } + + public GsonEncoder() { + this(Collections.>emptyList()); + } + + public GsonEncoder(Gson gson) { + this.gson = gson; + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + template.body(gson.toJson(object, bodyType)); + } +} diff --git a/gson/src/main/java/feign/gson/GsonFactory.java b/gson/src/main/java/feign/gson/GsonFactory.java new file mode 100644 index 0000000000..ca6b428a3f --- /dev/null +++ b/gson/src/main/java/feign/gson/GsonFactory.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.gson; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.TypeAdapter; +import com.google.gson.reflect.TypeToken; + +import java.lang.reflect.Type; +import java.util.Map; + +import static feign.Util.resolveLastTypeParameter; + +final class GsonFactory { + + private GsonFactory() { + } + + /** + * Registers type adapters by implicit type. Adds one to read numbers in a {@code Map} as Integers. + */ + static Gson create(Iterable> adapters) { + GsonBuilder builder = new GsonBuilder().setPrettyPrinting(); + builder.registerTypeAdapter(new TypeToken>() { + }.getType(), new DoubleToIntMapTypeAdapter()); + for (TypeAdapter adapter : adapters) { + Type type = resolveLastTypeParameter(adapter.getClass(), TypeAdapter.class); + builder.registerTypeAdapter(type, adapter); + } + return builder.create(); + } +} diff --git a/gson/src/test/java/feign/gson/GsonCodecTest.java b/gson/src/test/java/feign/gson/GsonCodecTest.java new file mode 100644 index 0000000000..5ed5a2b177 --- /dev/null +++ b/gson/src/test/java/feign/gson/GsonCodecTest.java @@ -0,0 +1,213 @@ +/* + * 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 org.junit.Test; + +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 feign.RequestTemplate; +import feign.Response; + +import static feign.Util.UTF_8; +import static feign.assertj.FeignAssertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class GsonCodecTest { + + @Test + public void encodesMapObjectNumericalValuesAsInteger() throws Exception { + Map map = new LinkedHashMap(); + map.put("foo", 1); + + RequestTemplate template = new RequestTemplate(); + new GsonEncoder().encode(map, map.getClass(), template); + + assertThat(template).hasBody("" // + + "{\n" // + + " \"foo\": 1\n" // + + "}"); + } + + @Test + public void decodesMapObjectNumericalValuesAsInteger() throws Exception { + Map map = new LinkedHashMap(); + map.put("foo", 1); + + Response response = + Response.create(200, "OK", Collections.>emptyMap(), + "{\"foo\": 1}", UTF_8); + assertEquals(new GsonDecoder().decode(response, new TypeToken>() { + }.getType()), map); + } + + @Test + public void encodesFormParams() throws Exception { + + Map form = new LinkedHashMap(); + form.put("foo", 1); + form.put("bar", Arrays.asList(2, 3)); + + RequestTemplate template = new RequestTemplate(); + new GsonEncoder().encode(form, new TypeToken>() { + }.getType(), template); + + assertThat(template).hasBody("" // + + "{\n" // + + " \"foo\": 1,\n" // + + " \"bar\": [\n" // + + " 2,\n" // + + " 3\n" // + + " ]\n" // + + "}"); + } + + static class Zone extends LinkedHashMap { + + Zone() { + // for reflective instantiation. + } + + Zone(String name) { + this(name, null); + } + + Zone(String name, String id) { + put("name", name); + if (id != null) { + put("id", id); + } + } + + private static final long serialVersionUID = 1L; + } + + @Test + public void decodes() throws Exception { + + 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(zones, new GsonDecoder().decode(response, new TypeToken>() { + }.getType())); + } + + @Test + public void nullBodyDecodesToNull() throws Exception { + Response response = Response.create(204, "OK", + Collections.>emptyMap(), + (byte[]) null); + assertNull(new GsonDecoder().decode(response, String.class)); + } + + @Test + public void emptyBodyDecodesToNull() throws Exception { + Response response = Response.create(204, "OK", + Collections.>emptyMap(), + new byte[0]); + assertNull(new GsonDecoder().decode(response, String.class)); + } + + private String zonesJson = ""// + + "[\n"// + + " {\n"// + + " \"name\": \"denominator.io.\"\n"// + + " },\n"// + + " {\n"// + + " \"name\": \"denominator.io.\",\n"// + + " \"id\": \"ABCD\"\n"// + + " }\n"// + + "]\n"; + + final TypeAdapter upperZone = new TypeAdapter() { + + @Override + public void write(JsonWriter out, Zone value) throws IOException { + out.beginObject(); + for (Map.Entry entry : value.entrySet()) { + out.name(entry.getKey()).value(entry.getValue().toString().toUpperCase()); + } + out.endObject(); + } + + @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; + } + }; + + @Test + public void customDecoder() throws Exception { + GsonDecoder decoder = new GsonDecoder(Arrays.>asList(upperZone)); + + 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(zones, decoder.decode(response, new TypeToken>() { + }.getType())); + } + + @Test + public void customEncoder() throws Exception { + GsonEncoder encoder = new GsonEncoder(Arrays.>asList(upperZone)); + + List zones = new LinkedList(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "abcd")); + + RequestTemplate template = new RequestTemplate(); + encoder.encode(zones, new TypeToken>() { + }.getType(), template); + + assertThat(template).hasBody("" // + + "[\n" // + + " {\n" // + + " \"name\": \"DENOMINATOR.IO.\"\n" // + + " },\n" // + + " {\n" // + + " \"name\": \"DENOMINATOR.IO.\",\n" // + + " \"id\": \"ABCD\"\n" // + + " }\n" // + + "]"); + } +} 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..5d021b61cb --- /dev/null +++ b/gson/src/test/java/feign/gson/examples/GitHubExample.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.gson.examples; + +import java.util.List; + +import feign.Feign; +import feign.Param; +import feign.RequestLine; +import feign.gson.GsonDecoder; + +/** + * adapted from {@code com.example.retrofit.GitHubClient} + */ +public class GitHubExample { + + public static void main(String... args) { + 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 + ")"); + } + } + + interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + } + + static class Contributor { + + String login; + int contributions; + } +} diff --git a/httpclient/README.md b/httpclient/README.md new file mode 100644 index 0000000000..ed20b439a6 --- /dev/null +++ b/httpclient/README.md @@ -0,0 +1,12 @@ +Apache HttpClient +=================== + +This module directs Feign's http requests to Apache's [HttpClient](https://hc.apache.org/httpcomponents-client-ga/). + +To use HttpClient with Feign, add the HttpClient module to your classpath. Then, configure Feign to use the HttpClient: + +```java +GitHub github = Feign.builder() + .client(new ApacheHttpClient()) + .target(GitHub.class, "https://api.github.com"); +``` diff --git a/httpclient/build.gradle b/httpclient/build.gradle new file mode 100644 index 0000000000..4d03273214 --- /dev/null +++ b/httpclient/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'java' + +sourceCompatibility = 1.6 + +dependencies { + compile project(':feign-core') + compile 'org.apache.httpcomponents:httpclient:4.5' + testCompile 'junit:junit:4.12' + testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7 + testCompile 'com.squareup.okhttp:mockwebserver:2.5.0' + testCompile project(':feign-core').sourceSets.test.output // for assertions +} \ No newline at end of file diff --git a/httpclient/src/main/java/feign/httpclient/ApacheHttpClient.java b/httpclient/src/main/java/feign/httpclient/ApacheHttpClient.java new file mode 100644 index 0000000000..36e3541a8e --- /dev/null +++ b/httpclient/src/main/java/feign/httpclient/ApacheHttpClient.java @@ -0,0 +1,202 @@ +/* + * Copyright 2015 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.httpclient; + +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.NameValuePair; +import org.apache.http.StatusLine; +import org.apache.http.client.HttpClient; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.client.utils.URIBuilder; +import org.apache.http.client.utils.URLEncodedUtils; +import org.apache.http.entity.ByteArrayEntity; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.util.EntityUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import feign.Client; +import feign.Request; +import feign.Response; +import feign.Util; + +import static feign.Util.UTF_8; + +/** + * This module directs Feign's http requests to Apache's + * HttpClient. Ex. + *
+ * GitHub github = Feign.builder().client(new ApacheHttpClient()).target(GitHub.class,
+ * "https://api.github.com");
+ */
+/*
+ * Based on Square, Inc's Retrofit ApacheClient implementation
+ */
+public final class ApacheHttpClient implements Client {
+  private static final String ACCEPT_HEADER_NAME = "Accept";
+
+  private final HttpClient client;
+
+  public ApacheHttpClient() {
+    this(HttpClientBuilder.create().build());
+  }
+
+  public ApacheHttpClient(HttpClient client) {
+    this.client = client;
+  }
+
+  @Override
+  public Response execute(Request request, Request.Options options) throws IOException {
+    HttpUriRequest httpUriRequest;
+    try {
+      httpUriRequest = toHttpUriRequest(request, options);
+    } catch (URISyntaxException e) {
+      throw new IOException("URL '" + request.url() + "' couldn't be parsed into a URI", e);
+    }
+    HttpResponse httpResponse = client.execute(httpUriRequest);
+    return toFeignResponse(httpResponse);
+  }
+
+  HttpUriRequest toHttpUriRequest(Request request, Request.Options options) throws
+          UnsupportedEncodingException, MalformedURLException, URISyntaxException {
+    RequestBuilder requestBuilder = RequestBuilder.create(request.method());
+
+    //per request timeouts
+    RequestConfig requestConfig = RequestConfig
+            .custom()
+            .setConnectTimeout(options.connectTimeoutMillis())
+            .setSocketTimeout(options.readTimeoutMillis())
+            .build();
+    requestBuilder.setConfig(requestConfig);
+
+    URI uri = new URIBuilder(request.url()).build();
+
+    //request url
+    requestBuilder.setUri(uri.getScheme() + "://" + uri.getAuthority() + uri.getPath());
+
+    //request query params
+    List queryParams = URLEncodedUtils.parse(uri, requestBuilder.getCharset().name());
+    for (NameValuePair queryParam: queryParams) {
+      requestBuilder.addParameter(queryParam);
+    }
+
+    //request body
+    if (request.body() != null) {
+      HttpEntity entity = request.charset() != null ?
+              new StringEntity(new String(request.body(), request.charset())) :
+              new ByteArrayEntity(request.body());
+      requestBuilder.setEntity(entity);
+    }
+
+    //request headers
+    boolean hasAcceptHeader = false;
+    for (Map.Entry> headerEntry : request.headers().entrySet()) {
+      String headerName = headerEntry.getKey();
+      if (headerName.equalsIgnoreCase(ACCEPT_HEADER_NAME)) {
+        hasAcceptHeader = true;
+      }
+      if (headerName.equalsIgnoreCase(Util.CONTENT_LENGTH) &&
+              requestBuilder.getHeaders(headerName) != null) {
+        //if the 'Content-Length' header is already present, it's been set from HttpEntity, so we
+        //won't add it again
+        continue;
+      }
+
+      for (String headerValue : headerEntry.getValue()) {
+        requestBuilder.addHeader(headerName, headerValue);
+      }
+    }
+    //some servers choke on the default accept string, so we'll set it to anything
+    if (!hasAcceptHeader) {
+      requestBuilder.addHeader(ACCEPT_HEADER_NAME, "*/*");
+    }
+
+    return requestBuilder.build();
+  }
+
+  Response toFeignResponse(HttpResponse httpResponse) throws IOException {
+    StatusLine statusLine = httpResponse.getStatusLine();
+    int statusCode = statusLine.getStatusCode();
+
+    String reason = statusLine.getReasonPhrase();
+
+    Map> headers = new HashMap>();
+    for (Header header : httpResponse.getAllHeaders()) {
+      String name = header.getName();
+      String value = header.getValue();
+
+      Collection headerValues = headers.get(name);
+      if (headerValues == null) {
+        headerValues = new ArrayList();
+        headers.put(name, headerValues);
+      }
+      headerValues.add(value);
+    }
+
+    return Response.create(statusCode, reason, headers, toFeignBody(httpResponse));
+  }
+
+  Response.Body toFeignBody(HttpResponse httpResponse) throws IOException {
+    final HttpEntity entity = httpResponse.getEntity();
+    if (entity == null) {
+      return null;
+    }
+    return new Response.Body() {
+
+      @Override
+      public Integer length() {
+        return entity.getContentLength() < 0 ? (int) entity.getContentLength() : null;
+      }
+
+      @Override
+      public boolean isRepeatable() {
+        return entity.isRepeatable();
+      }
+
+      @Override
+      public InputStream asInputStream() throws IOException {
+        return entity.getContent();
+      }
+
+      @Override
+      public Reader asReader() throws IOException {
+        return new InputStreamReader(asInputStream(), UTF_8);
+      }
+
+      @Override
+      public void close() throws IOException {
+        EntityUtils.consume(entity);
+      }
+    };
+  }
+}
diff --git a/httpclient/src/test/java/feign/httpclient/ApacheHttpClientTest.java b/httpclient/src/test/java/feign/httpclient/ApacheHttpClientTest.java
new file mode 100644
index 0000000000..f32b4f0b45
--- /dev/null
+++ b/httpclient/src/test/java/feign/httpclient/ApacheHttpClientTest.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2015 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.httpclient;
+
+import com.squareup.okhttp.mockwebserver.MockResponse;
+import com.squareup.okhttp.mockwebserver.MockWebServer;
+import feign.Feign;
+import feign.FeignException;
+import feign.Headers;
+import feign.Logger;
+import feign.RequestLine;
+import feign.Response;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
+import static feign.Util.UTF_8;
+import static feign.assertj.MockWebServerAssertions.assertThat;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertEquals;
+
+public class ApacheHttpClientTest {
+
+  @Rule
+  public final ExpectedException thrown = ExpectedException.none();
+  @Rule
+  public final MockWebServer server = new MockWebServer();
+
+  @Test
+  public void parsesRequestAndResponse() throws IOException, InterruptedException {
+    server.enqueue(new MockResponse().setBody("foo").addHeader("Foo: Bar"));
+
+    TestInterface api = Feign.builder()
+        .client(new ApacheHttpClient())
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    Response response = api.post("foo");
+
+    assertThat(response.status()).isEqualTo(200);
+    assertThat(response.reason()).isEqualTo("OK");
+    assertThat(response.headers())
+        .containsEntry("Content-Length", asList("3"))
+        .containsEntry("Foo", asList("Bar"));
+    assertThat(response.body().asInputStream())
+        .hasContentEqualTo(new ByteArrayInputStream("foo".getBytes(UTF_8)));
+
+    assertThat(server.takeRequest()).hasMethod("POST")
+        .hasPath("/?foo=bar&foo=baz&qux=")
+        .hasHeaders("Foo: Bar", "Foo: Baz", "Qux: ", "Accept: */*", "Content-Length: 3")
+        .hasBody("foo");
+  }
+
+  @Test
+  public void parsesErrorResponse() throws IOException, InterruptedException {
+    thrown.expect(FeignException.class);
+    thrown.expectMessage("status 500 reading TestInterface#post(String); content:\n" + "ARGHH");
+
+    server.enqueue(new MockResponse().setResponseCode(500).setBody("ARGHH"));
+
+    TestInterface api = Feign.builder()
+        .client(new ApacheHttpClient())
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    api.post("foo");
+  }
+
+  @Test
+  public void patch() throws IOException, InterruptedException {
+    server.enqueue(new MockResponse().setBody("foo"));
+    server.enqueue(new MockResponse());
+
+    TestInterface api = Feign.builder()
+        .client(new ApacheHttpClient())
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    assertEquals("foo", api.patch());
+
+    assertThat(server.takeRequest())
+        .hasHeaders("Accept: text/plain")
+        .hasNoHeaderNamed("Content-Type")
+        .hasMethod("PATCH");
+  }
+
+  @Test
+  public void safeRebuffering() throws IOException, InterruptedException {
+    server.enqueue(new MockResponse().setBody("foo"));
+
+    TestInterface api = Feign.builder()
+        .client(new ApacheHttpClient())
+        .logger(new Logger(){
+          @Override
+          protected void log(String configKey, String format, Object... args) {
+          }
+        })
+        .logLevel(Logger.Level.FULL) // rebuffers the body
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    api.post("foo");
+  }
+
+  /** This shows that is a no-op or otherwise doesn't cause an NPE when there's no content. */
+  @Test
+  public void safeRebuffering_noContent() throws IOException, InterruptedException {
+    server.enqueue(new MockResponse().setResponseCode(204));
+
+    TestInterface api = Feign.builder()
+        .client(new ApacheHttpClient())
+        .logger(new Logger(){
+          @Override
+          protected void log(String configKey, String format, Object... args) {
+          }
+        })
+        .logLevel(Logger.Level.FULL) // rebuffers the body
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    api.post("foo");
+  }
+
+  interface TestInterface {
+
+    @RequestLine("POST /?foo=bar&foo=baz&qux=")
+    @Headers({"Foo: Bar", "Foo: Baz", "Qux: ", "Content-Type: text/plain"})
+    Response post(String body);
+
+    @RequestLine("PATCH /")
+    @Headers("Accept: text/plain")
+    String patch();
+  }
+}
diff --git a/installViaTravis.sh b/installViaTravis.sh
new file mode 100755
index 0000000000..68e45a05f5
--- /dev/null
+++ b/installViaTravis.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+# This script will build the project.
+
+if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then
+  echo -e "Assemble Pull Request #$TRAVIS_PULL_REQUEST => Branch [$TRAVIS_BRANCH]"
+  ./gradlew assemble
+elif [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_TAG" == "" ]; then
+  echo -e 'Assemble Branch with Snapshot => Branch ['$TRAVIS_BRANCH']'
+  ./gradlew -Prelease.travisci=true assemble 
+elif [ "$TRAVIS_PULL_REQUEST" == "false" ] && [ "$TRAVIS_TAG" != "" ]; then
+  echo -e 'Assemble Branch for Release => Branch ['$TRAVIS_BRANCH']  Tag ['$TRAVIS_TAG']'
+  ./gradlew -Prelease.travisci=true -Prelease.useLastTag=true assemble
+else
+  echo -e 'WARN: Should not be here => Branch ['$TRAVIS_BRANCH']  Tag ['$TRAVIS_TAG']  Pull Request ['$TRAVIS_PULL_REQUEST']'
+  ./gradlew assemble
+fi
diff --git a/jackson-jaxb/README.md b/jackson-jaxb/README.md
new file mode 100644
index 0000000000..a9bdbf4caa
--- /dev/null
+++ b/jackson-jaxb/README.md
@@ -0,0 +1,27 @@
+Jackson-Jaxb Codec
+===================
+
+This module adds support for encoding and decoding JSON via JAXB.
+
+Add `JacksonJaxbJsonEncoder` and/or `JacksonJaxbJsonDecoder` to your `Feign.Builder` like so:
+
+```java
+GitHub github = Feign.builder()
+                     .encoder(new JacksonJaxbJsonEncoder())
+                     .decoder(new JacksonJaxbJsonDecoder())
+                     .target(GitHub.class, "https://api.github.com");
+```
+
+If you want to customize the `ObjectMapper` that is used, provide it to the `JacksonJaxbJsonEncoder` and `JacksonJaxbJsonDecoder`:
+
+```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 JacksonJaxbJsonEncoder(mapper))
+                     .decoder(new JacksonJaxbJsonDecoder(mapper))
+                     .target(GitHub.class, "https://api.github.com");
+```
diff --git a/jackson-jaxb/build.gradle b/jackson-jaxb/build.gradle
new file mode 100644
index 0000000000..4372c168af
--- /dev/null
+++ b/jackson-jaxb/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'java'
+
+sourceCompatibility = 1.6
+
+dependencies {
+    compile project(':feign-core')
+    compile 'javax.ws.rs:jsr311-api:1.1.1'
+    compile 'com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider:2.6.1'
+    testRuntime 'com.sun.jersey:jersey-client:1.19' // for RuntimeDelegateImpl
+    testCompile 'junit:junit:4.12'
+    testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7
+    testCompile project(':feign-core').sourceSets.test.output // for assertions
+}
\ No newline at end of file
diff --git a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java
new file mode 100644
index 0000000000..52bdf39dc9
--- /dev/null
+++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java
@@ -0,0 +1,31 @@
+package feign.jackson.jaxb;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider;
+
+import java.io.IOException;
+import java.lang.reflect.Type;
+
+import feign.FeignException;
+import feign.Response;
+import feign.codec.Decoder;
+
+import static com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider.DEFAULT_ANNOTATIONS;
+import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE;
+
+public final class JacksonJaxbJsonDecoder implements Decoder {
+  private final JacksonJaxbJsonProvider jacksonJaxbJsonProvider;
+
+  public JacksonJaxbJsonDecoder() {
+    this.jacksonJaxbJsonProvider = new JacksonJaxbJsonProvider();
+  }
+
+  public JacksonJaxbJsonDecoder(ObjectMapper objectMapper) {
+    this.jacksonJaxbJsonProvider = new JacksonJaxbJsonProvider(objectMapper, DEFAULT_ANNOTATIONS);
+  }
+
+  @Override
+  public Object decode(Response response, Type type) throws IOException, FeignException {
+    return jacksonJaxbJsonProvider.readFrom(Object.class, type, null, APPLICATION_JSON_TYPE, null, response.body().asInputStream());
+  }
+}
diff --git a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java
new file mode 100644
index 0000000000..36ef8f869c
--- /dev/null
+++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java
@@ -0,0 +1,40 @@
+package feign.jackson.jaxb;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.nio.charset.Charset;
+
+import feign.RequestTemplate;
+import feign.codec.EncodeException;
+import feign.codec.Encoder;
+
+import static com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider.DEFAULT_ANNOTATIONS;
+import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE;
+
+public final class JacksonJaxbJsonEncoder implements Encoder {
+  private final JacksonJaxbJsonProvider jacksonJaxbJsonProvider;
+
+  public JacksonJaxbJsonEncoder() {
+    this.jacksonJaxbJsonProvider = new JacksonJaxbJsonProvider();
+  }
+
+  public JacksonJaxbJsonEncoder(ObjectMapper objectMapper) {
+    this.jacksonJaxbJsonProvider = new JacksonJaxbJsonProvider(objectMapper, DEFAULT_ANNOTATIONS);
+  }
+
+
+  @Override
+  public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException {
+    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+    try {
+      jacksonJaxbJsonProvider.writeTo(object, bodyType.getClass(), null, null, APPLICATION_JSON_TYPE, null, outputStream);
+      template.body(outputStream.toByteArray(), Charset.defaultCharset());
+    } catch (IOException e) {
+      throw new EncodeException(e.getMessage(), e);
+    }
+  }
+}
diff --git a/jackson-jaxb/src/test/java/feign/jackson/jaxb/JacksonJaxbCodecTest.java b/jackson-jaxb/src/test/java/feign/jackson/jaxb/JacksonJaxbCodecTest.java
new file mode 100644
index 0000000000..282f638a60
--- /dev/null
+++ b/jackson-jaxb/src/test/java/feign/jackson/jaxb/JacksonJaxbCodecTest.java
@@ -0,0 +1,69 @@
+package feign.jackson.jaxb;
+
+import org.junit.Test;
+
+import java.util.Collection;
+import java.util.Collections;
+
+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 feign.RequestTemplate;
+import feign.Response;
+
+import static feign.Util.UTF_8;
+import static feign.assertj.FeignAssertions.assertThat;
+
+public class JacksonJaxbCodecTest {
+
+  @Test
+  public void encodeTest() {
+    JacksonJaxbJsonEncoder encoder = new JacksonJaxbJsonEncoder();
+    RequestTemplate template = new RequestTemplate();
+
+    encoder.encode(new MockObject("Test"), MockObject.class, template);
+
+    assertThat(template).hasBody("{\"value\":\"Test\"}");
+  }
+
+  @Test
+  public void decodeTest() throws Exception {
+    Response response =
+        Response.create(200, "OK", Collections.>emptyMap(), "{\"value\":\"Test\"}", UTF_8);
+    JacksonJaxbJsonDecoder decoder = new JacksonJaxbJsonDecoder();
+
+    assertThat(decoder.decode(response, MockObject.class))
+        .isEqualTo(new MockObject("Test"));
+  }
+
+  @XmlRootElement
+  @XmlAccessorType(XmlAccessType.FIELD)
+  static class MockObject {
+
+    @XmlElement
+    private String value;
+
+    MockObject() {
+    }
+
+    MockObject(String value) {
+      this.value = value;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+      if (obj instanceof MockObject) {
+        MockObject other = (MockObject) obj;
+        return value.equals(other.value);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return value != null ? value.hashCode() : 0;
+    }
+  }
+}
diff --git a/jackson/README.md b/jackson/README.md
new file mode 100644
index 0000000000..8be632779e
--- /dev/null
+++ b/jackson/README.md
@@ -0,0 +1,27 @@
+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");
+```
diff --git a/jackson/build.gradle b/jackson/build.gradle
new file mode 100644
index 0000000000..e660a12011
--- /dev/null
+++ b/jackson/build.gradle
@@ -0,0 +1,11 @@
+apply plugin: 'java'
+
+sourceCompatibility = 1.6
+
+dependencies {
+    compile project(':feign-core')
+    compile 'com.fasterxml.jackson.core:jackson-databind:2.6.1'
+    testCompile 'junit:junit:4.12'
+    testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7
+    testCompile project(':feign-core').sourceSets.test.output // for assertions
+}
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..1a2cb7821c
--- /dev/null
+++ b/jackson/src/main/java/feign/jackson/JacksonDecoder.java
@@ -0,0 +1,73 @@
+/*
+ * 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.Module;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.RuntimeJsonMappingException;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.Reader;
+import java.lang.reflect.Type;
+import java.util.Collections;
+
+import feign.Response;
+import feign.codec.Decoder;
+
+public class JacksonDecoder implements Decoder {
+
+  private final ObjectMapper mapper;
+
+  public JacksonDecoder() {
+    this(Collections.emptyList());
+  }
+
+  public JacksonDecoder(Iterable modules) {
+    this(new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+             .registerModules(modules));
+  }
+
+  public JacksonDecoder(ObjectMapper mapper) {
+    this.mapper = mapper;
+  }
+
+  @Override
+  public Object decode(Response response, Type type) throws IOException {
+    if (response.body() == null) {
+      return null;
+    }
+    Reader reader = response.body().asReader();
+    if (!reader.markSupported()) {
+      reader = new BufferedReader(reader, 1);
+    }
+    try {
+      // Read the first byte to see if we have any data
+      reader.mark(1);
+      if (reader.read() == -1) {
+        return null; // Eagerly returning null avoids "No content to map due to end-of-input"
+      }
+      reader.reset();
+      return mapper.readValue(reader, 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..4a5879fb9f
--- /dev/null
+++ b/jackson/src/main/java/feign/jackson/JacksonEncoder.java
@@ -0,0 +1,60 @@
+/*
+ * 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.JavaType;
+import com.fasterxml.jackson.databind.Module;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+
+import java.lang.reflect.Type;
+import java.util.Collections;
+
+import feign.RequestTemplate;
+import feign.codec.EncodeException;
+import feign.codec.Encoder;
+
+public class JacksonEncoder implements Encoder {
+
+  private final ObjectMapper mapper;
+
+  public JacksonEncoder() {
+    this(Collections.emptyList());
+  }
+
+  public JacksonEncoder(Iterable modules) {
+    this(new ObjectMapper()
+             .setSerializationInclusion(JsonInclude.Include.NON_NULL)
+             .configure(SerializationFeature.INDENT_OUTPUT, true)
+             .registerModules(modules));
+  }
+
+  public JacksonEncoder(ObjectMapper mapper) {
+    this.mapper = mapper;
+  }
+
+  @Override
+  public void encode(Object object, Type bodyType, RequestTemplate template) {
+    try {
+      JavaType javaType = mapper.getTypeFactory().constructType(bodyType);
+      template.body(mapper.writerFor(javaType).writeValueAsString(object));
+    } catch (JsonProcessingException e) {
+      throw new EncodeException(e.getMessage(), e);
+    }
+  }
+}
diff --git a/jackson/src/test/java/feign/jackson/JacksonCodecTest.java b/jackson/src/test/java/feign/jackson/JacksonCodecTest.java
new file mode 100644
index 0000000000..bcb098cba2
--- /dev/null
+++ b/jackson/src/test/java/feign/jackson/JacksonCodecTest.java
@@ -0,0 +1,204 @@
+package feign.jackson;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.Module;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.databind.ser.std.StdSerializer;
+
+import org.junit.Test;
+
+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 feign.RequestTemplate;
+import feign.Response;
+
+import static feign.Util.UTF_8;
+import static feign.assertj.FeignAssertions.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class JacksonCodecTest {
+
+  private String zonesJson = ""//
+                             + "[\n"//
+                             + "  {\n"//
+                             + "    \"name\": \"denominator.io.\"\n"//
+                             + "  },\n"//
+                             + "  {\n"//
+                             + "    \"name\": \"denominator.io.\",\n"//
+                             + "    \"id\": \"ABCD\"\n"//
+                             + "  }\n"//
+                             + "]\n";
+
+  @Test
+  public void encodesMapObjectNumericalValuesAsInteger() throws Exception {
+    Map map = new LinkedHashMap();
+    map.put("foo", 1);
+
+    RequestTemplate template = new RequestTemplate();
+    new JacksonEncoder().encode(map, map.getClass(), template);
+
+    assertThat(template).hasBody(""//
+                                 + "{\n" //
+                                 + "  \"foo\" : 1\n" //
+                                 + "}");
+  }
+
+  @Test
+  public void encodesFormParams() throws Exception {
+    Map form = new LinkedHashMap();
+    form.put("foo", 1);
+    form.put("bar", Arrays.asList(2, 3));
+
+    RequestTemplate template = new RequestTemplate();
+    new JacksonEncoder().encode(form, new TypeReference>() {
+    }.getType(), template);
+
+    assertThat(template).hasBody(""//
+                                 + "{\n" //
+                                 + "  \"foo\" : 1,\n" //
+                                 + "  \"bar\" : [ 2, 3 ]\n" //
+                                 + "}");
+  }
+
+  @Test
+  public void decodes() throws Exception {
+    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(zones, new JacksonDecoder().decode(response, new TypeReference>() {
+    }.getType()));
+  }
+
+  @Test
+  public void nullBodyDecodesToNull() throws Exception {
+    Response
+        response =
+        Response
+            .create(204, "OK", Collections.>emptyMap(), (byte[]) null);
+    assertNull(new JacksonDecoder().decode(response, String.class));
+  }
+
+  @Test
+  public void emptyBodyDecodesToNull() throws Exception {
+    Response response = Response.create(204, "OK",
+                                        Collections.>emptyMap(),
+                                        new byte[0]);
+    assertNull(new JacksonDecoder().decode(response, String.class));
+  }
+
+  @Test
+  public void customDecoder() throws Exception {
+    JacksonDecoder decoder = new JacksonDecoder(
+        Arrays.asList(
+            new SimpleModule().addDeserializer(Zone.class, new ZoneDeserializer())));
+
+    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(zones, decoder.decode(response, new TypeReference>() {
+    }.getType()));
+  }
+
+  @Test
+  public void customEncoder() throws Exception {
+    JacksonEncoder encoder = new JacksonEncoder(
+        Arrays.asList(new SimpleModule().addSerializer(Zone.class, new ZoneSerializer())));
+
+    List zones = new LinkedList();
+    zones.add(new Zone("denominator.io."));
+    zones.add(new Zone("denominator.io.", "abcd"));
+
+    RequestTemplate template = new RequestTemplate();
+    encoder.encode(zones, new TypeReference>() {
+    }.getType(), template);
+
+    assertThat(template).hasBody("" //
+                                 + "[ {\n"
+                                 + "  \"name\" : \"DENOMINATOR.IO.\"\n"
+                                 + "}, {\n"
+                                 + "  \"name\" : \"DENOMINATOR.IO.\",\n"
+                                 + "  \"id\" : \"ABCD\"\n"
+                                 + "} ]");
+  }
+
+  static class Zone extends LinkedHashMap {
+
+    private static final long serialVersionUID = 1L;
+
+    Zone() {
+      // for reflective instantiation.
+    }
+
+    Zone(String name) {
+      this(name, null);
+    }
+
+    Zone(String name, String id) {
+      put("name", name);
+      if (id != null) {
+        put("id", id);
+      }
+    }
+  }
+
+  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 ZoneSerializer extends StdSerializer {
+
+    public ZoneSerializer() {
+      super(Zone.class);
+    }
+
+    @Override
+    public void serialize(Zone value, JsonGenerator jgen, SerializerProvider provider)
+        throws IOException {
+      jgen.writeStartObject();
+      for (Map.Entry entry : value.entrySet()) {
+        jgen.writeFieldName(entry.getKey());
+        jgen.writeString(entry.getValue().toString().toUpperCase());
+      }
+      jgen.writeEndObject();
+    }
+  }
+}
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..992637ec65
--- /dev/null
+++ b/jackson/src/test/java/feign/jackson/examples/GitHubExample.java
@@ -0,0 +1,46 @@
+package feign.jackson.examples;
+
+import java.util.List;
+
+import feign.Feign;
+import feign.Param;
+import feign.RequestLine;
+import feign.jackson.JacksonDecoder;
+
+/**
+ * adapted from {@code com.example.retrofit.GitHubClient}
+ */
+public class GitHubExample {
+
+  public static void main(String... args) {
+    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 + ")");
+    }
+  }
+
+  interface GitHub {
+
+    @RequestLine("GET /repos/{owner}/{repo}/contributors")
+    List contributors(@Param("owner") String owner, @Param("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;
+    }
+  }
+}
diff --git a/jaxb/README.md b/jaxb/README.md
new file mode 100644
index 0000000000..2c658a3af2
--- /dev/null
+++ b/jaxb/README.md
@@ -0,0 +1,18 @@
+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
+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");
+```
diff --git a/jaxb/build.gradle b/jaxb/build.gradle
new file mode 100644
index 0000000000..1a13f7f4e9
--- /dev/null
+++ b/jaxb/build.gradle
@@ -0,0 +1,8 @@
+apply plugin: 'java'
+
+dependencies {
+    compile project(':feign-core')
+    testCompile 'junit:junit:4.12'
+    testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7
+    testCompile project(':feign-core').sourceSets.test.output // for assertions
+}
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..c3d191656f
--- /dev/null
+++ b/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java
@@ -0,0 +1,134 @@
+/*
+ * 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 java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+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;
+
+/**
+ * Creates and caches JAXB contexts 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..3e593ff695
--- /dev/null
+++ b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java
@@ -0,0 +1,68 @@
+/*
+ * 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 java.io.IOException;
+import java.lang.reflect.Type;
+
+import javax.xml.bind.JAXBException;
+import javax.xml.bind.Unmarshaller;
+
+import feign.Response;
+import feign.codec.DecodeException;
+import feign.codec.Decoder;
+
+/**
+ * Decodes responses using JAXB. 

Basic example with with Feign.Builder:

+ *
+ * JAXBContextFactory jaxbFactory = new JAXBContextFactory.Builder()
+ *      .withMarshallerJAXBEncoding("UTF-8")
+ *      .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd")
+ *      .build();
+ *
+ * api = Feign.builder()
+ *            .decoder(new JAXBDecoder(jaxbFactory))
+ *            .target(MyApi.class, "http://api");
+ * 
+ *

The JAXBContextFactory should be reused across requests as it caches the created JAXB + * contexts.

+ */ +public class JAXBDecoder implements Decoder { + + private final JAXBContextFactory jaxbContextFactory; + + public JAXBDecoder(JAXBContextFactory jaxbContextFactory) { + this.jaxbContextFactory = jaxbContextFactory; + } + + @Override + public Object decode(Response response, Type type) throws IOException { + if (!(type instanceof Class)) { + throw new UnsupportedOperationException( + "JAXB only supports decoding raw types. Found " + type); + } + try { + Unmarshaller unmarshaller = jaxbContextFactory.createUnmarshaller((Class) type); + return unmarshaller.unmarshal(response.body().asInputStream()); + } catch (JAXBException e) { + throw new DecodeException(e.toString(), e); + } finally { + if (response.body() != null) { + response.body().close(); + } + } + } +} 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..9ed39ae380 --- /dev/null +++ b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java @@ -0,0 +1,66 @@ +/* + * 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 java.io.StringWriter; +import java.lang.reflect.Type; + +import javax.xml.bind.JAXBException; +import javax.xml.bind.Marshaller; + +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; + +/** + * Encodes requests using JAXB.

Basic example with with Feign.Builder:

+ *
+ * JAXBContextFactory jaxbFactory = new JAXBContextFactory.Builder()
+ *      .withMarshallerJAXBEncoding("UTF-8")
+ *      .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd")
+ *      .build();
+ *
+ * api = Feign.builder()
+ *            .encoder(new JAXBEncoder(jaxbFactory))
+ *            .target(MyApi.class, "http://api");
+ * 
+ *

The JAXBContextFactory should be reused across requests as it caches the created JAXB + * contexts.

+ */ +public class JAXBEncoder implements Encoder { + + private final JAXBContextFactory jaxbContextFactory; + + public JAXBEncoder(JAXBContextFactory jaxbContextFactory) { + this.jaxbContextFactory = jaxbContextFactory; + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + if (!(bodyType instanceof Class)) { + throw new UnsupportedOperationException( + "JAXB only supports encoding raw types. Found " + bodyType); + } + try { + Marshaller marshaller = jaxbContextFactory.createMarshaller((Class) bodyType); + StringWriter stringWriter = new StringWriter(); + marshaller.marshal(object, stringWriter); + template.body(stringWriter.toString()); + } catch (JAXBException e) { + throw new EncodeException(e.toString(), e); + } + } +} diff --git a/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java b/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java new file mode 100644 index 0000000000..bf8f395492 --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java @@ -0,0 +1,222 @@ +/* + * 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.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +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 feign.RequestTemplate; +import feign.Response; +import feign.codec.Encoder; + +import static feign.Util.UTF_8; +import static feign.assertj.FeignAssertions.assertThat; +import static org.junit.Assert.assertEquals; + +public class JAXBCodecTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void encodesXml() throws Exception { + MockObject mock = new MockObject(); + mock.value = "Test"; + + RequestTemplate template = new RequestTemplate(); + new JAXBEncoder(new JAXBContextFactory.Builder().build()) + .encode(mock, MockObject.class, template); + + assertThat(template).hasBody( + "Test"); + } + + @Test + public void doesntEncodeParameterizedTypes() throws Exception { + thrown.expect(UnsupportedOperationException.class); + thrown.expectMessage( + "JAXB only supports encoding raw types. Found java.util.Map"); + + class ParameterizedHolder { + + Map field; + } + Type parameterized = ParameterizedHolder.class.getDeclaredField("field").getGenericType(); + + RequestTemplate template = new RequestTemplate(); + new JAXBEncoder(new JAXBContextFactory.Builder().build()) + .encode(Collections.emptyMap(), parameterized, template); + } + + @Test + public void encodesXmlWithCustomJAXBEncoding() throws Exception { + JAXBContextFactory jaxbContextFactory = + new JAXBContextFactory.Builder().withMarshallerJAXBEncoding("UTF-16").build(); + + Encoder encoder = new JAXBEncoder(jaxbContextFactory); + + MockObject mock = new MockObject(); + mock.value = "Test"; + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, MockObject.class, template); + + assertThat(template).hasBody("Test"); + } + + @Test + public void encodesXmlWithCustomJAXBSchemaLocation() throws Exception { + JAXBContextFactory jaxbContextFactory = + new JAXBContextFactory.Builder() + .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd") + .build(); + + Encoder encoder = new JAXBEncoder(jaxbContextFactory); + + MockObject mock = new MockObject(); + mock.value = "Test"; + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, MockObject.class, template); + + assertThat(template).hasBody("" + + + "Test"); + } + + @Test + public void encodesXmlWithCustomJAXBNoNamespaceSchemaLocation() throws Exception { + JAXBContextFactory jaxbContextFactory = + new JAXBContextFactory.Builder() + .withMarshallerNoNamespaceSchemaLocation("http://apihost/schema.xsd").build(); + + Encoder encoder = new JAXBEncoder(jaxbContextFactory); + + MockObject mock = new MockObject(); + mock.value = "Test"; + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, MockObject.class, template); + + assertThat(template).hasBody("" + + "Test"); + } + + @Test + public void encodesXmlWithCustomJAXBFormattedOutput() { + JAXBContextFactory jaxbContextFactory = + new JAXBContextFactory.Builder().withMarshallerFormattedOutput(true).build(); + + Encoder encoder = new JAXBEncoder(jaxbContextFactory); + + MockObject mock = new MockObject(); + mock.value = "Test"; + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, MockObject.class, template); + + String NEWLINE = System.getProperty("line.separator"); + + assertThat(template).hasBody( + new StringBuilder().append("") + .append(NEWLINE) + .append("") + .append(NEWLINE) + .append(" Test") + .append(NEWLINE) + .append("") + .append(NEWLINE) + .toString()); + } + + @Test + public void decodesXml() throws Exception { + MockObject mock = new MockObject(); + mock.value = "Test"; + + String mockXml = "" + + "Test"; + + Response + response = + Response + .create(200, "OK", Collections.>emptyMap(), mockXml, UTF_8); + + JAXBDecoder decoder = new JAXBDecoder(new JAXBContextFactory.Builder().build()); + + assertEquals(mock, decoder.decode(response, MockObject.class)); + } + + @Test + public void doesntDecodeParameterizedTypes() throws Exception { + thrown.expect(UnsupportedOperationException.class); + thrown.expectMessage( + "JAXB only supports decoding raw types. Found java.util.Map"); + + class ParameterizedHolder { + + Map field; + } + Type parameterized = ParameterizedHolder.class.getDeclaredField("field").getGenericType(); + + Response + response = + Response + .create(200, "OK", Collections.>emptyMap(), "", UTF_8); + + new JAXBDecoder(new JAXBContextFactory.Builder().build()).decode(response, parameterized); + } + + @XmlRootElement + @XmlAccessorType(XmlAccessType.FIELD) + static class MockObject { + + @XmlElement + private String value; + + @Override + public boolean equals(Object obj) { + if (obj instanceof MockObject) { + MockObject other = (MockObject) obj; + return value.equals(other.value); + } + return false; + } + + @Override + public int hashCode() { + return value != null ? value.hashCode() : 0; + } + } +} 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..daf4fa71b1 --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java @@ -0,0 +1,79 @@ +/* + * 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.junit.Test; + +import javax.xml.bind.Marshaller; + +import static org.junit.Assert.assertEquals; +import static org.junit.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("UTF-16", marshaller.getProperty(Marshaller.JAXB_ENCODING)); + } + + @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("http://apihost http://apihost/schema.xsd", + marshaller.getProperty(Marshaller.JAXB_SCHEMA_LOCATION)); + } + + @Test + public void buildsMarshallerWithNoNamespaceSchemaLocationProperty() throws Exception { + JAXBContextFactory factory = + new JAXBContextFactory.Builder() + .withMarshallerNoNamespaceSchemaLocation("http://apihost/schema.xsd").build(); + + Marshaller marshaller = factory.createMarshaller(Object.class); + assertEquals("http://apihost/schema.xsd", + marshaller.getProperty(Marshaller.JAXB_NO_NAMESPACE_SCHEMA_LOCATION)); + } + + @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/examples/AWSSignatureVersion4.java b/jaxb/src/test/java/feign/jaxb/examples/AWSSignatureVersion4.java new file mode 100644 index 0000000000..fbeb22a8aa --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/examples/AWSSignatureVersion4.java @@ -0,0 +1,169 @@ +/* + * 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.jaxb.examples; + +import java.net.URI; +import java.security.MessageDigest; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import feign.Request; +import feign.RequestTemplate; + +import static feign.Util.UTF_8; + +// http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html +public class AWSSignatureVersion4 { + + private static final String + EMPTY_STRING_HASH = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + private static final SimpleDateFormat iso8601 = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'"); + static { + iso8601.setTimeZone(TimeZone.getTimeZone("GMT")); + } + String region = "us-east-1"; + String service = "iam"; + String accessKey; + String secretKey; + + public AWSSignatureVersion4(String accessKey, String secretKey) { + this.accessKey = accessKey; + this.secretKey = secretKey; + } + + 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 new RuntimeException(e); + } + } + + private static String canonicalString(RequestTemplate input, String host) { + 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' + + canonicalRequest.append("host:").append(host).append('\n'); + + canonicalRequest.append('\n'); + + // SignedHeaders + '\n' + + canonicalRequest.append("host").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(hex(sha256(bodyText))); + } else { + canonicalRequest.append(EMPTY_STRING_HASH); + } + return canonicalRequest.toString(); + } + + private static 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(hex(sha256(canonicalRequest))); + return toSign.toString(); + } + + private static String hex(byte[] data) { + StringBuilder result = new StringBuilder(data.length * 2); + for (byte b : data) { + result.append(String.format("%02x", b & 0xff)); + } + return result.toString(); + } + + static byte[] sha256(String data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return digest.digest(data.getBytes(UTF_8)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public Request apply(RequestTemplate input) { + if (!input.headers().isEmpty()) { + throw new UnsupportedOperationException("headers not supported"); + } + if (input.body() != null) { + throw new UnsupportedOperationException("body not supported"); + } + + String host = URI.create(input.url()).getHost(); + + String timestamp; + synchronized (iso8601) { + timestamp = iso8601.format(new Date()); + } + + String + credentialScope = + String.format("%s/%s/%s/%s", 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", "host"); + input.header("Host", host); + + String canonicalString = canonicalString(input, host); + String toSign = toSign(timestamp, credentialScope, canonicalString); + + byte[] signatureKey = signatureKey(secretKey, timestamp); + String signature = hex(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; + } +} 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..8318ce1e67 --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/examples/IAMExample.java @@ -0,0 +1,100 @@ +/* + * 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 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; + +import feign.Feign; +import feign.Request; +import feign.RequestLine; +import feign.RequestTemplate; +import feign.Target; +import feign.jaxb.JAXBContextFactory; +import feign.jaxb.JAXBDecoder; + +public class IAMExample { + + 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.result.user.id); + } + + interface IAM { + + @RequestLine("GET /?Action=GetUser&Version=2010-05-08") + GetUserResponse userResponse(); + } + + static class IAMTarget extends AWSSignatureVersion4 implements Target { + + private IAMTarget(String accessKey, String secretKey) { + super(accessKey, secretKey); + } + + @Override + public Class type() { + return IAM.class; + } + + @Override + public String name() { + return "iam"; + } + + @Override + public String url() { + return "https://iam.amazonaws.com"; + } + + @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 result; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "GetUserResult") + static class GetUserResult { + + @XmlElement(name = "User") + private User user; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "User") + static class User { + + @XmlElement(name = "UserId") + private String id; + } +} 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..d52c85ad5e --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/examples/package-info.java @@ -0,0 +1,16 @@ +/* + * 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/build.gradle b/jaxrs/build.gradle new file mode 100644 index 0000000000..2ed4549e2e --- /dev/null +++ b/jaxrs/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'java' + +sourceCompatibility = 1.6 + +dependencies { + compile project(':feign-core') + compile 'javax.ws.rs:jsr311-api:1.1.1' + testCompile 'junit:junit:4.12' + testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7 + testCompile project(':feign-core').sourceSets.test.output // for assertions + testCompile project(':feign-gson') // for github example +} diff --git a/jaxrs/src/main/java/feign/jaxrs/JAXRSContract.java b/jaxrs/src/main/java/feign/jaxrs/JAXRSContract.java new file mode 100644 index 0000000000..3346ec184c --- /dev/null +++ b/jaxrs/src/main/java/feign/jaxrs/JAXRSContract.java @@ -0,0 +1,146 @@ +/* + * 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 java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Collection; + +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 feign.Contract; +import feign.MethodMetadata; + +import static feign.Util.checkState; +import static feign.Util.emptyToNull; + +/** + * Please refer to the Feign + * JAX-RS README. + */ +public final class JAXRSContract extends Contract.BaseContract { + + static final String ACCEPT = "Accept"; + static final String CONTENT_TYPE = "Content-Type"; + + // Protected so unittest can call us + // XXX: Should parseAndValidateMetadata(Class, Method) be public instead? The old deprecated parseAndValidateMetadata(Method) was public.. + @Override + protected MethodMetadata parseAndValidateMetadata(Class targetType, Method method) { + return super.parseAndValidateMetadata(targetType, method); + } + + @Override + protected void processAnnotationOnClass(MethodMetadata data, Class clz) { + Path path = clz.getAnnotation(Path.class); + if (path != null) { + String pathValue = emptyToNull(path.value()); + checkState(pathValue != null, "Path.value() was empty on type %s", clz.getName()); + if (!pathValue.startsWith("/")) { + pathValue = "/" + pathValue; + } + if (pathValue.endsWith("/")) { + // Strip off any trailing slashes, since the template has already had slashes appropriately added + pathValue = pathValue.substring(0, pathValue.length()-1); + } + data.template().insert(0, pathValue); + } + } + + @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; + } + // jax-rs allows whitespace around the param name, as well as an optional regex. The contract should + // strip these out appropriately. + methodAnnotationValue = methodAnnotationValue.replaceAll("\\{\\s*(.+?)\\s*(:.+?)?\\}", "\\{$1\\}"); + 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..7b619817a2 --- /dev/null +++ b/jaxrs/src/test/java/feign/jaxrs/JAXRSContractTest.java @@ -0,0 +1,561 @@ +/* + * 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 org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +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 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 feign.MethodMetadata; +import feign.Response; + +import static feign.assertj.FeignAssertions.assertThat; +import static java.util.Arrays.asList; +import static org.assertj.core.data.MapEntry.entry; + +/** + * Tests interfaces defined per {@link JAXRSContract} are interpreted into expected {@link feign + * .RequestTemplate template} instances. + */ +public class JAXRSContractTest { + + private static final List STRING_LIST = null; + @Rule + public final ExpectedException thrown = ExpectedException.none(); + JAXRSContract contract = new JAXRSContract(); + + @Test + public void httpMethods() throws Exception { + assertThat(parseAndValidateMetadata(Methods.class, "post").template()) + .hasMethod("POST"); + + assertThat(parseAndValidateMetadata(Methods.class, "put").template()) + .hasMethod("PUT"); + + assertThat(parseAndValidateMetadata(Methods.class, "get").template()) + .hasMethod("GET"); + + assertThat(parseAndValidateMetadata(Methods.class, "delete").template()) + .hasMethod("DELETE"); + } + + @Test + public void customMethodWithoutPath() throws Exception { + assertThat(parseAndValidateMetadata(CustomMethod.class, "patch").template()) + .hasMethod("PATCH") + .hasUrl(""); + } + + @Test + public void queryParamsInPathExtract() throws Exception { + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "none").template()) + .hasUrl("/") + .hasQueries(); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "one").template()) + .hasUrl("/") + .hasQueries( + entry("Action", asList("GetUser")) + ); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "two").template()) + .hasUrl("/") + .hasQueries( + entry("Action", asList("GetUser")), + entry("Version", asList("2010-05-08")) + ); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "three").template()) + .hasUrl("/") + .hasQueries( + entry("Action", asList("GetUser")), + entry("Version", asList("2010-05-08")), + entry("limit", asList("1")) + ); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "empty").template()) + .hasUrl("/") + .hasQueries( + entry("flag", asList(new String[]{null})), + entry("Action", asList("GetUser")), + entry("Version", asList("2010-05-08")) + ); + } + + @Test + public void producesAddsAcceptHeader() throws Exception { + MethodMetadata md = parseAndValidateMetadata(ProducesAndConsumes.class, "produces"); + + assertThat(md.template()) + .hasHeaders(entry("Accept", asList("application/xml"))); + } + + @Test + public void producesNada() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Produces.value() was empty on method producesNada"); + + parseAndValidateMetadata(ProducesAndConsumes.class, "producesNada"); + } + + @Test + public void producesEmpty() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Produces.value() was empty on method producesEmpty"); + + parseAndValidateMetadata(ProducesAndConsumes.class, "producesEmpty"); + } + + @Test + public void consumesAddsContentTypeHeader() throws Exception { + MethodMetadata md = parseAndValidateMetadata(ProducesAndConsumes.class, "consumes"); + + assertThat(md.template()) + .hasHeaders(entry("Content-Type", asList("application/xml"))); + } + + @Test + public void consumesNada() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Consumes.value() was empty on method consumesNada"); + + parseAndValidateMetadata(ProducesAndConsumes.class, "consumesNada"); + } + + @Test + public void consumesEmpty() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Consumes.value() was empty on method consumesEmpty"); + + parseAndValidateMetadata(ProducesAndConsumes.class, "consumesEmpty"); + } + + @Test + public void bodyParamIsGeneric() throws Exception { + MethodMetadata md = parseAndValidateMetadata(BodyParams.class, "post", List.class); + + assertThat(md.bodyIndex()) + .isEqualTo(0); + assertThat(md.bodyType()) + .isEqualTo(getClass().getDeclaredField("STRING_LIST").getGenericType()); + } + + @Test + public void tooManyBodies() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Method has too many Body"); + + parseAndValidateMetadata(BodyParams.class, "tooMany", List.class, List.class); + } + + @Test + public void emptyPathOnType() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Path.value() was empty on type "); + + parseAndValidateMetadata(EmptyPathOnType.class, "base"); + } + + @Test + public void parsePathMethod() throws Exception { + assertThat(parseAndValidateMetadata(PathOnType.class,"base").template()) + .hasUrl("/base"); + + assertThat(parseAndValidateMetadata(PathOnType.class,"get").template()) + .hasUrl("/base/specific"); + } + + @Test + public void emptyPathOnMethod() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Path.value() was empty on method emptyPath"); + + parseAndValidateMetadata(PathOnType.class,"emptyPath"); + } + + @Test + public void emptyPathParam() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("PathParam.value() was empty on parameter 0"); + + parseAndValidateMetadata(PathOnType.class, "emptyPathParam", String.class); + } + + @Test + public void pathParamWithSpaces() throws Exception { + assertThat(parseAndValidateMetadata( + PathOnType.class, "pathParamWithSpaces", String.class).template()) + .hasUrl("/base/{param}"); + } + + @Test + public void regexPathOnMethod() throws Exception { + assertThat(parseAndValidateMetadata( + PathOnType.class, "pathParamWithRegex", String.class).template()) + .hasUrl("/base/regex/{param}"); + + assertThat(parseAndValidateMetadata( + PathOnType.class, "pathParamWithMultipleRegex", String.class, String.class).template()) + .hasUrl("/base/regex/{param1}/{param2}"); + } + + @Test + public void withPathAndURIParams() throws Exception { + MethodMetadata md = parseAndValidateMetadata(WithURIParam.class, + "uriParam", String.class, URI.class, String.class); + + assertThat(md.indexToName()).containsExactly( + entry(0, asList("1")), + // Skips 1 as it is a url index! + entry(2, asList("2"))); + + assertThat(md.urlIndex()).isEqualTo(1); + } + + @Test + public void pathAndQueryParams() throws Exception { + MethodMetadata md = + parseAndValidateMetadata(WithPathAndQueryParams.class, + "recordsByNameAndType", int.class, String.class, String.class); + + assertThat(md.template()) + .hasQueries(entry("name", asList("{name}")), entry("type", asList("{type}"))); + + assertThat(md.indexToName()).containsExactly(entry(0, asList("domainId")), + entry(1, asList("name")), + entry(2, asList("type"))); + } + + @Test + public void emptyQueryParam() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("QueryParam.value() was empty on parameter 0"); + + parseAndValidateMetadata(WithPathAndQueryParams.class, "empty", String.class); + } + + @Test + public void formParamsParseIntoIndexToName() throws Exception { + MethodMetadata md = parseAndValidateMetadata(FormParams.class, + "login", String.class, String.class, String.class); + + assertThat(md.formParams()) + .containsExactly("customer_name", "user_name", "password"); + + assertThat(md.indexToName()).containsExactly( + entry(0, asList("customer_name")), + entry(1, asList("user_name")), + entry(2, asList("password")) + ); + } + + /** + * Body type is only for the body param. + */ + @Test + public void formParamsDoesNotSetBodyType() throws Exception { + MethodMetadata md = parseAndValidateMetadata(FormParams.class, + "login", String.class, String.class, String.class); + + assertThat(md.bodyType()).isNull(); + } + + @Test + public void emptyFormParam() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("FormParam.value() was empty on parameter 0"); + + parseAndValidateMetadata(FormParams.class, "emptyFormParam", String.class); + } + + @Test + public void headerParamsParseIntoIndexToName() throws Exception { + MethodMetadata md = parseAndValidateMetadata(HeaderParams.class, "logout", String.class); + + assertThat(md.template()) + .hasHeaders(entry("Auth-Token", asList("{Auth-Token}"))); + + assertThat(md.indexToName()) + .containsExactly(entry(0, asList("Auth-Token"))); + } + + @Test + public void emptyHeaderParam() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("HeaderParam.value() was empty on parameter 0"); + + parseAndValidateMetadata(HeaderParams.class, "emptyHeaderParam", String.class); + } + + @Test + public void pathsWithoutSlashesParseCorrectly() throws Exception { + assertThat(parseAndValidateMetadata(PathsWithoutAnySlashes.class, "get").template()) + .hasUrl("/base/specific"); + } + + @Test + public void pathsWithSomeSlashesParseCorrectly() throws Exception { + assertThat(parseAndValidateMetadata(PathsWithSomeSlashes.class, "get").template()) + .hasUrl("/base/specific"); + } + + @Test + public void pathsWithSomeOtherSlashesParseCorrectly() throws Exception { + assertThat(parseAndValidateMetadata(PathsWithSomeOtherSlashes.class, "get").template()) + .hasUrl("/base/specific"); + } + + @Test + public void classWithRootPathParsesCorrectly() throws Exception { + assertThat(parseAndValidateMetadata(ClassRootPath.class, "get").template()) + .hasUrl("/specific"); + } + + @Test + public void classPathWithTrailingSlashParsesCorrectly() throws Exception { + assertThat(parseAndValidateMetadata(ClassPathWithTrailingSlash.class, "get").template()) + .hasUrl("/base/specific"); + } + + interface Methods { + + @POST + void post(); + + @PUT + void put(); + + @GET + void get(); + + @DELETE + void delete(); + } + + interface CustomMethod { + + @PATCH + Response patch(); + + @Target({ElementType.METHOD}) + @Retention(RetentionPolicy.RUNTIME) + @HttpMethod("PATCH") + public @interface PATCH { + + } + } + + 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(); + } + + interface ProducesAndConsumes { + + @GET + @Produces("application/xml") + Response produces(); + + @GET + @Produces({}) + Response producesNada(); + + @GET + @Produces({""}) + Response producesEmpty(); + + @POST + @Consumes("application/xml") + Response consumes(); + + @POST + @Consumes({}) + Response consumesNada(); + + @POST + @Consumes({""}) + Response consumesEmpty(); + } + + interface BodyParams { + + @POST + Response post(List body); + + @POST + Response tooMany(List body, List body2); + } + + @Path("") + interface EmptyPathOnType { + + @GET + Response 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); + + @GET + @Path("/{ param }") + Response pathParamWithSpaces(@PathParam("param") String path); + + @GET + @Path("regex/{param:.+}") + Response pathParamWithRegex(@PathParam("param") String path); + + @GET + @Path("regex/{param1:[0-9]*}/{ param2 : .+}") + Response pathParamWithMultipleRegex(@PathParam("param1") String param1, @PathParam("param2") String param2); + } + + interface WithURIParam { + + @GET + @Path("/{1}/{2}") + Response uriParam(@PathParam("1") String one, URI endpoint, @PathParam("2") String two); + } + + interface WithPathAndQueryParams { + + @GET + @Path("/domains/{domainId}/records") + Response recordsByNameAndType(@PathParam("domainId") int id, + @QueryParam("name") String nameFilter, + @QueryParam("type") String typeFilter); + + @GET + Response empty(@QueryParam("") String empty); + } + + 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); + } + + interface HeaderParams { + + @POST + void logout(@HeaderParam("Auth-Token") String token); + + @GET + Response emptyHeaderParam(@HeaderParam("") String empty); + } + + @Path("base") + interface PathsWithoutAnySlashes { + + @GET + @Path("specific") + Response get(); + } + + @Path("/base") + interface PathsWithSomeSlashes { + + @GET + @Path("specific") + Response get(); + } + + @Path("base") + interface PathsWithSomeOtherSlashes { + + @GET + @Path("/specific") + Response get(); + } + + @Path("/") + interface ClassRootPath { + @GET + @Path("/specific") + Response get(); + } + + @Path("/base/") + interface ClassPathWithTrailingSlash { + @GET + @Path("/specific") + Response get(); + } + + private MethodMetadata parseAndValidateMetadata(Class targetType, String method, + Class... parameterTypes) + throws NoSuchMethodException { + return contract.parseAndValidateMetadata(targetType, + targetType.getMethod(method, parameterTypes)); + } +} 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..83249ec66f --- /dev/null +++ b/jaxrs/src/test/java/feign/jaxrs/examples/GitHubExample.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.jaxrs.examples; + +import java.util.List; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; + +import feign.Feign; +import feign.jaxrs.JAXRSContract; + +/** + * adapted from {@code com.example.retrofit.GitHubClient} + */ +public class GitHubExample { + + public static void main(String... args) throws InterruptedException { + GitHub github = Feign.builder() + .contract(new JAXRSContract()) + .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 + ")"); + } + } + + 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; + } +} diff --git a/okhttp/README.md b/okhttp/README.md new file mode 100644 index 0000000000..81f68373eb --- /dev/null +++ b/okhttp/README.md @@ -0,0 +1,12 @@ +OkHttp +=================== + +This module directs Feign's http requests to [OkHttp](http://square.github.io/okhttp/), which enables SPDY and better network control. + +To use OkHttp with Feign, add the OkHttp module to your classpath. Then, configure Feign to use the OkHttpClient: + +```java +GitHub github = Feign.builder() + .client(new OkHttpClient()) + .target(GitHub.class, "https://api.github.com"); +``` diff --git a/okhttp/build.gradle b/okhttp/build.gradle new file mode 100644 index 0000000000..1988666db9 --- /dev/null +++ b/okhttp/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'java' + +sourceCompatibility = 1.6 + +dependencies { + compile project(':feign-core') + compile 'com.squareup.okhttp:okhttp:2.5.0' + testCompile 'junit:junit:4.12' + testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7 + testCompile 'com.squareup.okhttp:mockwebserver:2.5.0' + testCompile project(':feign-core').sourceSets.test.output // for assertions +} diff --git a/okhttp/src/main/java/feign/okhttp/OkHttpClient.java b/okhttp/src/main/java/feign/okhttp/OkHttpClient.java new file mode 100644 index 0000000000..da3da5f738 --- /dev/null +++ b/okhttp/src/main/java/feign/okhttp/OkHttpClient.java @@ -0,0 +1,148 @@ +/* + * Copyright 2015 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.okhttp; + +import com.squareup.okhttp.Headers; +import com.squareup.okhttp.MediaType; +import com.squareup.okhttp.Request; +import com.squareup.okhttp.RequestBody; +import com.squareup.okhttp.Response; +import com.squareup.okhttp.ResponseBody; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import feign.Client; + +/** + * This module directs Feign's http requests to OkHttp, + * which enables SPDY and better network control. Ex. + *
+ * GitHub github = Feign.builder().client(new OkHttpClient()).target(GitHub.class,
+ * "https://api.github.com");
+ */
+public final class OkHttpClient implements Client {
+
+  private final com.squareup.okhttp.OkHttpClient delegate;
+
+  public OkHttpClient() {
+    this(new com.squareup.okhttp.OkHttpClient());
+  }
+
+  public OkHttpClient(com.squareup.okhttp.OkHttpClient delegate) {
+    this.delegate = delegate;
+  }
+
+  static Request toOkHttpRequest(feign.Request input) {
+    Request.Builder requestBuilder = new Request.Builder();
+    requestBuilder.url(input.url());
+
+    MediaType mediaType = null;
+    boolean hasAcceptHeader = false;
+    for (String field : input.headers().keySet()) {
+      if (field.equalsIgnoreCase("Accept")) {
+        hasAcceptHeader = true;
+      }
+
+      for (String value : input.headers().get(field)) {
+        if (field.equalsIgnoreCase("Content-Type")) {
+          mediaType = MediaType.parse(value);
+          if (input.charset() != null) {
+            mediaType.charset(input.charset());
+          }
+        } else {
+          requestBuilder.addHeader(field, value);
+        }
+      }
+    }
+    // Some servers choke on the default accept string.
+    if (!hasAcceptHeader) {
+      requestBuilder.addHeader("Accept", "*/*");
+    }
+
+    RequestBody body = input.body() != null ? RequestBody.create(mediaType, input.body()) : null;
+    requestBuilder.method(input.method(), body);
+    return requestBuilder.build();
+  }
+
+  private static feign.Response toFeignResponse(Response input) throws IOException {
+    return feign.Response
+        .create(input.code(), input.message(), toMap(input.headers()), toBody(input.body()));
+  }
+
+  private static Map> toMap(Headers headers) {
+    return (Map) headers.toMultimap();
+  }
+
+  private static feign.Response.Body toBody(final ResponseBody input) throws IOException {
+    if (input == null || input.contentLength() == 0) {
+      return null;
+    }
+    if (input.contentLength() > Integer.MAX_VALUE) {
+      throw new UnsupportedOperationException("Length too long " + input.contentLength());
+    }
+    final Integer length = input.contentLength() != -1 ? (int) input.contentLength() : null;
+
+    return new feign.Response.Body() {
+
+      @Override
+      public void close() throws IOException {
+        input.close();
+      }
+
+      @Override
+      public Integer length() {
+        return length;
+      }
+
+      @Override
+      public boolean isRepeatable() {
+        return false;
+      }
+
+      @Override
+      public InputStream asInputStream() throws IOException {
+        return input.byteStream();
+      }
+
+      @Override
+      public Reader asReader() throws IOException {
+        return input.charStream();
+      }
+    };
+  }
+
+  @Override
+  public feign.Response execute(feign.Request input, feign.Request.Options options)
+      throws IOException {
+    com.squareup.okhttp.OkHttpClient requestScoped;
+    if (delegate.getConnectTimeout() != options.connectTimeoutMillis()
+        || delegate.getReadTimeout() != options.readTimeoutMillis()) {
+      requestScoped = delegate.clone();
+      requestScoped.setConnectTimeout(options.connectTimeoutMillis(), TimeUnit.MILLISECONDS);
+      requestScoped.setReadTimeout(options.readTimeoutMillis(), TimeUnit.MILLISECONDS);
+    } else {
+      requestScoped = delegate;
+    }
+    Request request = toOkHttpRequest(input);
+    Response response = requestScoped.newCall(request).execute();
+    return toFeignResponse(response);
+  }
+}
diff --git a/okhttp/src/test/java/feign/okhttp/OkHttpClientTest.java b/okhttp/src/test/java/feign/okhttp/OkHttpClientTest.java
new file mode 100644
index 0000000000..6e70376a80
--- /dev/null
+++ b/okhttp/src/test/java/feign/okhttp/OkHttpClientTest.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2015 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.okhttp;
+
+import com.squareup.okhttp.mockwebserver.MockResponse;
+import com.squareup.okhttp.mockwebserver.MockWebServer;
+
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+
+import feign.Feign;
+import feign.FeignException;
+import feign.Headers;
+import feign.Logger;
+import feign.RequestLine;
+import feign.Response;
+
+import static feign.Util.UTF_8;
+import static feign.assertj.MockWebServerAssertions.assertThat;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertEquals;
+
+public class OkHttpClientTest {
+
+  @Rule
+  public final ExpectedException thrown = ExpectedException.none();
+  @Rule
+  public final MockWebServer server = new MockWebServer();
+
+  @Test
+  public void parsesRequestAndResponse() throws IOException, InterruptedException {
+    server.enqueue(new MockResponse().setBody("foo").addHeader("Foo: Bar"));
+
+    TestInterface api = Feign.builder()
+        .client(new OkHttpClient())
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    Response response = api.post("foo");
+
+    assertThat(response.status()).isEqualTo(200);
+    assertThat(response.reason()).isEqualTo("OK");
+    assertThat(response.headers())
+        .containsEntry("Content-Length", asList("3"))
+        .containsEntry("Foo", asList("Bar"));
+    assertThat(response.body().asInputStream())
+        .hasContentEqualTo(new ByteArrayInputStream("foo".getBytes(UTF_8)));
+
+    assertThat(server.takeRequest()).hasMethod("POST")
+        .hasPath("/?foo=bar&foo=baz&qux=")
+        .hasHeaders("Foo: Bar", "Foo: Baz", "Qux: ", "Accept: */*", "Content-Length: 3")
+        .hasBody("foo");
+  }
+
+  @Test
+  public void parsesErrorResponse() throws IOException, InterruptedException {
+    thrown.expect(FeignException.class);
+    thrown.expectMessage("status 500 reading TestInterface#post(String); content:\n" + "ARGHH");
+
+    server.enqueue(new MockResponse().setResponseCode(500).setBody("ARGHH"));
+
+    TestInterface api = Feign.builder()
+        .client(new OkHttpClient())
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    api.post("foo");
+  }
+
+  @Test
+  @Ignore // TODO: Remove on OkHttp 2.5 https://github.com/square/okhttp/issues/1778
+  public void patch() throws IOException, InterruptedException {
+    server.enqueue(new MockResponse().setBody("foo"));
+    server.enqueue(new MockResponse());
+
+    TestInterface api = Feign.builder()
+        .client(new OkHttpClient())
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    assertEquals("foo", api.patch());
+
+    assertThat(server.takeRequest())
+        .hasHeaders("Accept: text/plain", "Content-Length: 0") // Note: OkHttp adds content length.
+        .hasNoHeaderNamed("Content-Type")
+        .hasMethod("PATCH");
+  }
+
+  @Test
+  public void safeRebuffering() throws IOException, InterruptedException {
+    server.enqueue(new MockResponse().setBody("foo"));
+
+    TestInterface api = Feign.builder()
+        .client(new OkHttpClient())
+        .logger(new Logger(){
+          @Override
+          protected void log(String configKey, String format, Object... args) {
+          }
+        })
+        .logLevel(Logger.Level.FULL) // rebuffers the body
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    api.post("foo");
+  }
+
+  /** This shows that is a no-op or otherwise doesn't cause an NPE when there's no content. */
+  @Test
+  public void safeRebuffering_noContent() throws IOException, InterruptedException {
+    server.enqueue(new MockResponse().setResponseCode(204));
+
+    TestInterface api = Feign.builder()
+        .client(new OkHttpClient())
+        .logger(new Logger(){
+          @Override
+          protected void log(String configKey, String format, Object... args) {
+          }
+        })
+        .logLevel(Logger.Level.FULL) // rebuffers the body
+        .target(TestInterface.class, "http://localhost:" + server.getPort());
+
+    api.post("foo");
+  }
+
+  interface TestInterface {
+
+    @RequestLine("POST /?foo=bar&foo=baz&qux=")
+    @Headers({"Foo: Bar", "Foo: Baz", "Qux: ", "Content-Type: text/plain"})
+    Response post(String body);
+
+    @RequestLine("PATCH /")
+    @Headers("Accept: text/plain")
+    String patch();
+  }
+}
diff --git a/ribbon/README.md b/ribbon/README.md
new file mode 100644
index 0000000000..4de2eba3f8
--- /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.
+
+### RibbonClient
+Adding `RibbonClient` overrides URL resolution of Feign's client, adding smart routing and resiliency capabilities provided by Ribbon.
+
+#### Usage
+instead of 
+```java
+MyService api = Feign.builder().target(MyService.class, "https://myAppProd-1234567890.us-east-1.elb.amazonaws.com");
+```
+do
+```java
+MyService api = Feign.builder().client(new RibbonClient()).target(MyService.class, "https://myAppProd");
+```
+### LoadBalancingTarget
+Using or extending `LoadBalancingTarget` will enable dynamic url discovery via ribbon including incrementing server request counts.
+
+#### Usage
+instead of 
+```java
+MyService api = Feign.builder().target(MyService.class, "https://myAppProd-1234567890.us-east-1.elb.amazonaws.com");
+```
+do
+```java
+MyService api = Feign.builder().target(LoadBalancingTarget.create(MyService.class, "https://myAppProd"));
+```
diff --git a/ribbon/build.gradle b/ribbon/build.gradle
new file mode 100644
index 0000000000..eebd4ec30b
--- /dev/null
+++ b/ribbon/build.gradle
@@ -0,0 +1,12 @@
+apply plugin: 'java'
+
+sourceCompatibility = 1.6
+
+dependencies {
+    compile project(':feign-core')
+    compile 'com.netflix.ribbon:ribbon-loadbalancer:2.1.0'
+    testCompile 'junit:junit:4.12'
+    testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7
+    testCompile 'com.squareup.okhttp:mockwebserver:2.5.0'
+    testCompile project(':feign-core').sourceSets.test.output
+}
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..0d5a7b9886
--- /dev/null
+++ b/ribbon/src/main/java/feign/ribbon/LBClient.java
@@ -0,0 +1,163 @@
+/*
+ * 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.RequestSpecificRetryHandler;
+import com.netflix.client.RetryHandler;
+import com.netflix.client.config.CommonClientConfigKey;
+import com.netflix.client.config.IClientConfig;
+import com.netflix.loadbalancer.ILoadBalancer;
+
+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;
+
+public final class LBClient extends
+    AbstractLoadBalancerAwareClient {
+
+  private final int connectTimeout;
+  private final int readTimeout;
+  private final IClientConfig clientConfig;
+
+  public static LBClient create(ILoadBalancer lb, IClientConfig clientConfig) {
+    return new LBClient(lb, clientConfig);
+  }
+
+  LBClient(ILoadBalancer lb, IClientConfig clientConfig) {
+    super(lb, clientConfig);
+    this.setRetryHandler(RetryHandler.DEFAULT);
+    this.clientConfig = clientConfig;
+    connectTimeout = clientConfig.get(CommonClientConfigKey.ConnectTimeout);
+    readTimeout = clientConfig.get(CommonClientConfigKey.ReadTimeout);
+  }
+
+  @Override
+  public RibbonResponse execute(RibbonRequest request, IClientConfig configOverride)
+      throws IOException {
+    Request.Options options;
+    if (configOverride != null) {
+      options =
+          new Request.Options(
+              configOverride.get(CommonClientConfigKey.ConnectTimeout, connectTimeout),
+              (configOverride.get(CommonClientConfigKey.ReadTimeout, readTimeout)));
+    } else {
+      options = new Request.Options(connectTimeout, readTimeout);
+    }
+    Response response = request.client().execute(request.toRequest(), options);
+    return new RibbonResponse(request.getUri(), response);
+  }
+
+  @Override
+  public RequestSpecificRetryHandler getRequestSpecificRetryHandler(
+      RibbonRequest request, IClientConfig requestConfig) {
+    if (clientConfig.get(CommonClientConfigKey.OkToRetryOnAllOperations, false)) {
+      return new RequestSpecificRetryHandler(true, true, this.getRetryHandler(), requestConfig);
+    }
+    if (!request.toRequest().method().equals("GET")) {
+      return new RequestSpecificRetryHandler(true, false, this.getRetryHandler(), requestConfig);
+    } else {
+      return new RequestSpecificRetryHandler(true, true, this.getRetryHandler(), requestConfig);
+    }
+  }
+
+  static class RibbonRequest extends ClientRequest implements Cloneable {
+
+    private final Request request;
+    private final Client client;
+
+    RibbonRequest(Client client, Request request, URI uri) {
+      this.client = client;
+      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();
+    }
+
+    Client client() {
+      return client;
+    }
+
+    public Object clone() {
+      return new RibbonRequest(client, 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;
+    }
+
+    @Override
+    public void close() throws IOException {
+      if (response != null && response.body() != null) {
+        response.body().close();
+      }
+    }
+
+  }
+
+}
diff --git a/ribbon/src/main/java/feign/ribbon/LBClientFactory.java b/ribbon/src/main/java/feign/ribbon/LBClientFactory.java
new file mode 100644
index 0000000000..30bd8c98b6
--- /dev/null
+++ b/ribbon/src/main/java/feign/ribbon/LBClientFactory.java
@@ -0,0 +1,22 @@
+package feign.ribbon;
+
+import com.netflix.client.ClientFactory;
+import com.netflix.client.config.IClientConfig;
+import com.netflix.loadbalancer.ILoadBalancer;
+
+public interface LBClientFactory {
+
+  LBClient create(String clientName);
+
+  /**
+   * Uses {@link ClientFactory} static factories from ribbon to create an LBClient.
+   */
+  public static final class Default implements LBClientFactory {
+    @Override
+    public LBClient create(String clientName) {
+      IClientConfig config = ClientFactory.getNamedConfig(clientName);
+      ILoadBalancer lb = ClientFactory.getNamedLoadBalancer(clientName);
+      return LBClient.create(lb, config);
+    }
+  }
+}
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..81c3f7cc1a
--- /dev/null
+++ b/ribbon/src/main/java/feign/ribbon/LoadBalancingTarget.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2013 Netflix, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package feign.ribbon;
+
+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.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.builder().target(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 { + + 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())); + } + + /** + * 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()); + } + + @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 boolean equals(Object obj) { + if (obj instanceof LoadBalancingTarget) { + LoadBalancingTarget other = (LoadBalancingTarget) obj; + return type.equals(other.type) + && name.equals(other.name); + } + return false; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + type.hashCode(); + result = 31 * result + name.hashCode(); + return result; + } + + @Override + public String toString() { + return "LoadBalancingTarget(type=" + type.getSimpleName() + ", name=" + name + ")"; + } +} diff --git a/ribbon/src/main/java/feign/ribbon/RibbonClient.java b/ribbon/src/main/java/feign/ribbon/RibbonClient.java new file mode 100644 index 0000000000..d95d9bb3a4 --- /dev/null +++ b/ribbon/src/main/java/feign/ribbon/RibbonClient.java @@ -0,0 +1,131 @@ +package feign.ribbon; + +import java.io.IOException; +import java.net.URI; + +import com.netflix.client.ClientException; +import com.netflix.client.config.CommonClientConfigKey; +import com.netflix.client.config.DefaultClientConfigImpl; + +import feign.Client; +import feign.Request; +import feign.Response; + +/** + * RibbonClient can be used in Feign builder to activate smart routing and resiliency capabilities + * provided by Ribbon. Ex. + * + *
+ * MyService api = Feign.builder.client(RibbonClient.create()).target(MyService.class,
+ *     "http://myAppProd");
+ * 
+ * + * Where {@code myAppProd} is the ribbon client name and {@code myAppProd.ribbon.listOfServers} + * configuration is set. + */ +public class RibbonClient implements Client { + + private final Client delegate; + private final LBClientFactory lbClientFactory; + + + public static RibbonClient create() { + return builder().build(); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * @deprecated Use the {@link RibbonClient#create()} + */ + @Deprecated + public RibbonClient() { + this(new Client.Default(null, null)); + } + + /** + * @deprecated Use the {@link RibbonClient#create()} + */ + @Deprecated + public RibbonClient(Client delegate) { + this(delegate, new LBClientFactory.Default()); + } + + RibbonClient(Client delegate, LBClientFactory lbClientFactory) { + this.delegate = delegate; + this.lbClientFactory = lbClientFactory; + } + + @Override + public Response execute(Request request, Request.Options options) throws IOException { + try { + URI asUri = URI.create(request.url()); + String clientName = asUri.getHost(); + URI uriWithoutHost = cleanUrl(request.url(), clientName); + LBClient.RibbonRequest ribbonRequest = + new LBClient.RibbonRequest(delegate, request, uriWithoutHost); + return lbClient(clientName).executeWithLoadBalancer(ribbonRequest, + new FeignOptionsClientConfig(options)).toResponse(); + } catch (ClientException e) { + if (e.getCause() instanceof IOException) { + throw IOException.class.cast(e.getCause()); + } + throw new RuntimeException(e); + } + } + + static URI cleanUrl(String originalUrl, String host) { + return URI.create(originalUrl.replaceFirst(host, "")); + } + + private LBClient lbClient(String clientName) { + return lbClientFactory.create(clientName); + } + + static class FeignOptionsClientConfig extends DefaultClientConfigImpl { + + public FeignOptionsClientConfig(Request.Options options) { + setProperty(CommonClientConfigKey.ConnectTimeout, options.connectTimeoutMillis()); + setProperty(CommonClientConfigKey.ReadTimeout, options.readTimeoutMillis()); + } + + @Override + public void loadProperties(String clientName) { + + } + + @Override + public void loadDefaultValues() { + + } + + } + + public static final class Builder { + + Builder() { + } + + private Client delegate; + private LBClientFactory lbClientFactory; + + public Builder delegate(Client delegate) { + this.delegate = delegate; + return this; + } + + public Builder lbClientFactory(LBClientFactory lbClientFactory) { + this.lbClientFactory = lbClientFactory; + return this; + } + + public RibbonClient build() { + return new RibbonClient( + delegate != null ? delegate : new Client.Default(null, null), + lbClientFactory != null ? lbClientFactory : new LBClientFactory.Default() + ); + } + } +} diff --git a/ribbon/src/test/java/feign/ribbon/LBClientFactoryTest.java b/ribbon/src/test/java/feign/ribbon/LBClientFactoryTest.java new file mode 100644 index 0000000000..3eccf50a2d --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/LBClientFactoryTest.java @@ -0,0 +1,18 @@ +package feign.ribbon; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import com.netflix.client.ClientFactory; + +public class LBClientFactoryTest { + + @Test + public void testCreateLBClient() { + LBClientFactory.Default lbClientFactory = new LBClientFactory.Default(); + LBClient client = lbClientFactory.create("clientName"); + assertEquals("clientName", client.getClientName()); + assertEquals(ClientFactory.getNamedLoadBalancer("clientName"), client.getLoadBalancer()); + } +} 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..da45080eaa --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/LoadBalancingTargetTest.java @@ -0,0 +1,80 @@ +/* + * 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.squareup.okhttp.mockwebserver.MockResponse; +import com.squareup.okhttp.mockwebserver.MockWebServer; + +import org.junit.Rule; +import org.junit.Test; + +import java.io.IOException; +import java.net.URL; + +import feign.Feign; +import feign.RequestLine; + +import static com.netflix.config.ConfigurationManager.getConfigInstance; +import static org.junit.Assert.assertEquals; + +public class LoadBalancingTargetTest { + + @Rule + public final MockWebServer server1 = new MockWebServer(); + @Rule + public final MockWebServer server2 = new MockWebServer(); + + static String hostAndPort(URL url) { + // our build slaves have underscores in their hostnames which aren't permitted by ribbon + return "localhost:" + url.getPort(); + } + + @Test + public void loadBalancingDefaultPolicyRoundRobin() throws IOException, InterruptedException { + String name = "LoadBalancingTargetTest-loadBalancingDefaultPolicyRoundRobin"; + String serverListKey = name + ".ribbon.listOfServers"; + + server1.enqueue(new MockResponse().setBody("success!")); + server2.enqueue(new MockResponse().setBody("success!")); + + 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(1, server1.getRequestCount()); + assertEquals(1, server2.getRequestCount()); + // TODO: verify ribbon stats match + // assertEquals(target.lb().getLoadBalancerStats().getSingleServerStat()) + } finally { + getConfigInstance().clearProperty(serverListKey); + } + } + + interface TestInterface { + + @RequestLine("POST /") + void post(); + } +} 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..c7227833a1 --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/RibbonClientTest.java @@ -0,0 +1,209 @@ +/* + * 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 static com.netflix.config.ConfigurationManager.getConfigInstance; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; + +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestName; + +import com.netflix.client.config.CommonClientConfigKey; +import com.netflix.client.config.IClientConfig; +import com.squareup.okhttp.mockwebserver.MockResponse; +import com.squareup.okhttp.mockwebserver.SocketPolicy; +import com.squareup.okhttp.mockwebserver.MockWebServer; + +import feign.Client; +import feign.Feign; +import feign.Param; +import feign.Request; +import feign.RequestLine; +import feign.client.TrustingSSLSocketFactory; + +public class RibbonClientTest { + + @Rule + public final TestName testName = new TestName(); + @Rule + public final MockWebServer server1 = new MockWebServer(); + @Rule + public final MockWebServer server2 = new MockWebServer(); + + static String hostAndPort(URL url) { + // our build slaves have underscores in their hostnames which aren't permitted by ribbon + return "localhost:" + url.getPort(); + } + + @Test + public void loadBalancingDefaultPolicyRoundRobin() throws IOException, InterruptedException { + server1.enqueue(new MockResponse().setBody("success!")); + server2.enqueue(new MockResponse().setBody("success!")); + + getConfigInstance().setProperty(serverListKey(), + hostAndPort(server1.getUrl("")) + "," + hostAndPort( + server2.getUrl(""))); + + TestInterface + api = + Feign.builder().client(RibbonClient.create()) + .target(TestInterface.class, "http://" + client()); + + api.post(); + api.post(); + + assertEquals(1, server1.getRequestCount()); + assertEquals(1, server2.getRequestCount()); + // TODO: verify ribbon stats match + // assertEquals(target.lb().getLoadBalancerStats().getSingleServerStat()) + } + + @Test + public void ioExceptionRetry() throws IOException, InterruptedException { + server1.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + server1.enqueue(new MockResponse().setBody("success!")); + + getConfigInstance().setProperty(serverListKey(), hostAndPort(server1.getUrl(""))); + + TestInterface + api = + Feign.builder().client(RibbonClient.create()) + .target(TestInterface.class, "http://" + client()); + + api.post(); + + assertEquals(2, server1.getRequestCount()); + // TODO: verify ribbon stats match + // assertEquals(target.lb().getLoadBalancerStats().getSingleServerStat()) + } + + /* + 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 queryStringValue = "some string with space"; + String expectedQueryStringValue = "some+string+with+space"; + String expectedRequestLine = String.format("GET /?a=%s HTTP/1.1", expectedQueryStringValue); + + server1.enqueue(new MockResponse().setBody("success!")); + + getConfigInstance().setProperty(serverListKey(), hostAndPort(server1.getUrl(""))); + + TestInterface + api = + Feign.builder().client(RibbonClient.create()) + .target(TestInterface.class, "http://" + client()); + + api.getWithQueryParameters(queryStringValue); + + final String recordedRequestLine = server1.takeRequest().getRequestLine(); + + assertEquals(recordedRequestLine, expectedRequestLine); + } + + + @Test + public void testHTTPSViaRibbon() { + + Client trustSSLSockets = new Client.Default(TrustingSSLSocketFactory.get(), null); + + server1.useHttps(TrustingSSLSocketFactory.get("localhost"), false); + server1.enqueue(new MockResponse().setBody("success!")); + + getConfigInstance().setProperty(serverListKey(), hostAndPort(server1.getUrl(""))); + + TestInterface api = + Feign.builder().client(RibbonClient.builder().delegate(trustSSLSockets).build()) + .target(TestInterface.class, "https://" + client()); + api.post(); + assertEquals(1, server1.getRequestCount()); + + } + + @Test + public void ioExceptionRetryWithBuilder() throws IOException, InterruptedException { + server1.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + server1.enqueue(new MockResponse().setBody("success!")); + + getConfigInstance().setProperty(serverListKey(), hostAndPort(server1.getUrl(""))); + + TestInterface api = + Feign.builder().client(RibbonClient.create()) + .target(TestInterface.class, "http://" + client()); + + api.post(); + + assertEquals(server1.getRequestCount(), 2); + // TODO: verify ribbon stats match + // assertEquals(target.lb().getLoadBalancerStats().getSingleServerStat()) + } + + @Test + public void testFeignOptionsClientConfig() { + Request.Options options = new Request.Options(1111, 22222); + IClientConfig config = new RibbonClient.FeignOptionsClientConfig(options); + assertThat(config.get(CommonClientConfigKey.ConnectTimeout), + equalTo(options.connectTimeoutMillis())); + assertThat(config.get(CommonClientConfigKey.ReadTimeout), equalTo(options.readTimeoutMillis())); + assertEquals(2, config.getProperties().size()); + } + + @Test + public void testCleanUrlWithMatchingHostAndPart() throws IOException { + URI uri = RibbonClient.cleanUrl("http://questions/questions/answer/123", "questions"); + assertEquals("http:///questions/answer/123", uri.toString()); + } + + @Test + public void testCleanUrl() throws IOException { + URI uri = RibbonClient.cleanUrl("http://myservice/questions/answer/123", "myservice"); + assertEquals("http:///questions/answer/123", uri.toString()); + } + + private String client() { + return testName.getMethodName(); + } + + private String serverListKey() { + return client() + ".ribbon.listOfServers"; + } + + @After + public void clearServerList() { + getConfigInstance().clearProperty(serverListKey()); + } + + interface TestInterface { + + @RequestLine("POST /") + void post(); + + @RequestLine("GET /?a={a}") + void getWithQueryParameters(@Param("a") String a); + } +} 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/build.gradle b/sax/build.gradle new file mode 100644 index 0000000000..5b03301051 --- /dev/null +++ b/sax/build.gradle @@ -0,0 +1,9 @@ +apply plugin: 'java' + +sourceCompatibility = 1.6 + +dependencies { + compile project(':feign-core') + testCompile 'junit:junit:4.12' + testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7 +} 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..a0af0fd2ad --- /dev/null +++ b/sax/src/main/java/feign/sax/SAXDecoder.java @@ -0,0 +1,166 @@ +/* + * 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 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 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 feign.Response; +import feign.codec.DecodeException; +import feign.codec.Decoder; + +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");
+ * 
+ */ +public class SAXDecoder implements Decoder { + + private final Map> handlerFactories; + + private SAXDecoder(Map> handlerFactories) { + this.handlerFactories = handlerFactories; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public Object decode(Response response, Type type) throws IOException, DecodeException { + if (response.body() == null) { + return null; + } + ContentHandlerWithResult.Factory handlerFactory = handlerFactories.get(type); + checkState(handlerFactory != null, "type %s not in configured handlers %s", type, + handlerFactories.keySet()); + ContentHandlerWithResult handler = handlerFactory.create(); + 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); + } + } + + /** + * 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(); + + public interface Factory { + + ContentHandlerWithResult create(); + } + } + + public static class Builder { + + private final Map> handlerFactories = + 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 NewInstanceContentHandlerWithResultFactory(handlerClass)); + } + + /** + * Will call {@link ContentHandlerWithResult.Factory#create()} 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, ContentHandlerWithResult.Factory handler) { + this.handlerFactories.put(checkNotNull(type, "type"), checkNotNull(handler, "handler")); + return this; + } + + public SAXDecoder build() { + return new SAXDecoder(handlerFactories); + } + + private static class NewInstanceContentHandlerWithResultFactory + implements ContentHandlerWithResult.Factory { + + private final Constructor> ctor; + + private NewInstanceContentHandlerWithResultFactory(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 ContentHandlerWithResult create() { + try { + return ctor.newInstance(); + } catch (Exception e) { + throw new IllegalArgumentException("exception attempting to instantiate " + ctor, 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..063018ab7e --- /dev/null +++ b/sax/src/test/java/feign/sax/SAXDecoderTest.java @@ -0,0 +1,144 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.sax; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.xml.sax.helpers.DefaultHandler; + +import java.io.IOException; +import java.text.ParseException; +import java.util.Collection; +import java.util.Collections; + +import feign.Response; +import feign.codec.Decoder; + +import static feign.Util.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class SAXDecoderTest { + + static String statusFailed = ""// + + "\n" +// + + " \n"// + + " \n" +// + + " Failed\n" +// + + " \n"// + + " \n"// + + ""; + @Rule + public final ExpectedException thrown = ExpectedException.none(); + Decoder decoder = SAXDecoder.builder() // + .registerContentHandler(NetworkStatus.class, + new SAXDecoder.ContentHandlerWithResult.Factory() { + @Override + public SAXDecoder.ContentHandlerWithResult create() { + return new NetworkStatusHandler(); + } + }) // + .registerContentHandler(NetworkStatusStringHandler.class) // + .build(); + + @Test + public void parsesConfiguredTypes() throws ParseException, IOException { + assertEquals(NetworkStatus.FAILED, decoder.decode(statusFailedResponse(), NetworkStatus.class)); + assertEquals("Failed", decoder.decode(statusFailedResponse(), String.class)); + } + + @Test + public void niceErrorOnUnconfiguredType() throws ParseException, IOException { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("type int not in configured handlers"); + + decoder.decode(statusFailedResponse(), int.class); + } + + private Response statusFailedResponse() { + return Response + .create(200, "OK", Collections.>emptyMap(), statusFailed, UTF_8); + } + + @Test + public void nullBodyDecodesToNull() throws Exception { + Response + response = + Response + .create(204, "OK", Collections.>emptyMap(), (byte[]) null); + assertNull(decoder.decode(response, String.class)); + } + + static enum NetworkStatus { + GOOD, FAILED; + } + + static class NetworkStatusStringHandler extends DefaultHandler implements + SAXDecoder.ContentHandlerWithResult { + + 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 { + + 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); + } + } +} 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..60dd84945d --- /dev/null +++ b/sax/src/test/java/feign/sax/examples/AWSSignatureVersion4.java @@ -0,0 +1,170 @@ +/* + * 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 java.net.URI; +import java.security.MessageDigest; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import feign.Request; +import feign.RequestTemplate; + +import static feign.Util.UTF_8; + +// http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html +public class AWSSignatureVersion4 { + + private static final String + EMPTY_STRING_HASH = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + private static final SimpleDateFormat iso8601 = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'"); + static { + iso8601.setTimeZone(TimeZone.getTimeZone("GMT")); + } + String region = "us-east-1"; + String service = "iam"; + String accessKey; + String secretKey; + + public AWSSignatureVersion4(String accessKey, String secretKey) { + this.accessKey = accessKey; + this.secretKey = secretKey; + } + + 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 new RuntimeException(e); + } + } + + private static String canonicalString(RequestTemplate input, String host) { + 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' + + canonicalRequest.append("host:").append(host).append('\n'); + + canonicalRequest.append('\n'); + + // SignedHeaders + '\n' + + canonicalRequest.append("host").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(hex(sha256(bodyText))); + } else { + canonicalRequest.append(EMPTY_STRING_HASH); + } + return canonicalRequest.toString(); + } + + private static 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(hex(sha256(canonicalRequest))); + return toSign.toString(); + } + + + private static String hex(byte[] data) { + StringBuilder result = new StringBuilder(data.length * 2); + for (byte b : data) { + result.append(String.format("%02x", b & 0xff)); + } + return result.toString(); + } + + static byte[] sha256(String data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return digest.digest(data.getBytes(UTF_8)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public Request apply(RequestTemplate input) { + if (!input.headers().isEmpty()) { + throw new UnsupportedOperationException("headers not supported"); + } + if (input.body() != null) { + throw new UnsupportedOperationException("body not supported"); + } + + String host = URI.create(input.url()).getHost(); + + String timestamp; + synchronized (iso8601) { + timestamp = iso8601.format(new Date()); + } + + String + credentialScope = + String.format("%s/%s/%s/%s", 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", "host"); + input.header("Host", host); + + String canonicalString = canonicalString(input, host); + String toSign = toSign(timestamp, credentialScope, canonicalString); + + byte[] signatureKey = signatureKey(secretKey, timestamp); + String signature = hex(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; + } +} 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..decf57fd54 --- /dev/null +++ b/sax/src/test/java/feign/sax/examples/IAMExample.java @@ -0,0 +1,95 @@ +/* + * 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 org.xml.sax.helpers.DefaultHandler; + +import feign.Feign; +import feign.Request; +import feign.RequestLine; +import feign.RequestTemplate; +import feign.Target; +import feign.sax.SAXDecoder; + +public class IAMExample { + + 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()); + } + + interface IAM { + + @RequestLine("GET /?Action=GetUser&Version=2010-05-08") + Long userId(); + } + + static class IAMTarget extends AWSSignatureVersion4 implements Target { + + private IAMTarget(String accessKey, String secretKey) { + super(accessKey, secretKey); + } + + @Override + public Class type() { + return IAM.class; + } + + @Override + public String name() { + return "iam"; + } + + @Override + public String url() { + return "https://iam.amazonaws.com"; + } + + @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..ef27845c63 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,7 @@ +rootProject.name='feign' +include 'core', 'sax', 'gson', 'httpclient', 'jackson', 'jaxb', 'jaxrs', 'okhttp', 'ribbon', 'slf4j', 'jackson-jaxb' + +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/build.gradle b/slf4j/build.gradle new file mode 100644 index 0000000000..26e26a2fc1 --- /dev/null +++ b/slf4j/build.gradle @@ -0,0 +1,9 @@ +apply plugin: 'java' + +dependencies { + compile project(':feign-core') + compile 'org.slf4j:slf4j-api:1.7.12' + testCompile 'junit:junit:4.12' + testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7 + testCompile 'org.slf4j:slf4j-simple:1.7.12' +} 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..90888c4ffb --- /dev/null +++ b/slf4j/src/main/java/feign/slf4j/Slf4jLogger.java @@ -0,0 +1,73 @@ +/* + * 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.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +import feign.Request; +import feign.Response; + +/** + * 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/RecordingSimpleLogger.java b/slf4j/src/test/java/feign/slf4j/RecordingSimpleLogger.java new file mode 100644 index 0000000000..ae6919e278 --- /dev/null +++ b/slf4j/src/test/java/feign/slf4j/RecordingSimpleLogger.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.slf4j; + +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.slf4j.LoggerFactory; +import org.slf4j.impl.SimpleLogger; +import org.slf4j.impl.SimpleLoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import static org.junit.Assert.assertEquals; +import static org.slf4j.impl.SimpleLogger.DEFAULT_LOG_LEVEL_KEY; +import static org.slf4j.impl.SimpleLogger.SHOW_THREAD_NAME_KEY; + +/** + * A testing utility to allow control over {@link org.slf4j.impl.SimpleLogger}. In some cases, + * reflection is used to bypass access restrictions. + */ +final class RecordingSimpleLogger implements TestRule { + + private String expectedMessages = ""; + + /** + * Resets {@link org.slf4j.impl.SimpleLogger} to the new log level. + */ + RecordingSimpleLogger logLevel(String logLevel) throws Exception { + System.setProperty(SHOW_THREAD_NAME_KEY, "false"); + System.setProperty(DEFAULT_LOG_LEVEL_KEY, logLevel); + + Field field = SimpleLogger.class.getDeclaredField("INITIALIZED"); + field.setAccessible(true); + field.set(null, false); + + Method method = SimpleLoggerFactory.class.getDeclaredMethod("reset"); + method.setAccessible(true); + method.invoke(LoggerFactory.getILoggerFactory()); + return this; + } + + /** + * Newline delimited output that would be sent to stderr. + */ + RecordingSimpleLogger expectMessages(String expectedMessages) { + this.expectedMessages = expectedMessages; + return this; + } + + /** + * Steals the output of stderr as that's where the log events go. + */ + @Override + public Statement apply(final Statement base, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + ByteArrayOutputStream buff = new ByteArrayOutputStream(); + PrintStream stderr = System.err; + try { + System.setErr(new PrintStream(buff)); + base.evaluate(); + assertEquals(expectedMessages, buff.toString()); + } finally { + System.setErr(stderr); + } + } + }; + } +} 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..dc9d6ab457 --- /dev/null +++ b/slf4j/src/test/java/feign/slf4j/Slf4jLoggerTest.java @@ -0,0 +1,101 @@ +/* + * 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.junit.Rule; +import org.junit.Test; +import org.slf4j.LoggerFactory; + +import java.util.Collection; +import java.util.Collections; + +import feign.Feign; +import feign.Logger; +import feign.Request; +import feign.RequestTemplate; +import feign.Response; + +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]); + @Rule + public final RecordingSimpleLogger slf4j = new RecordingSimpleLogger(); + private Slf4jLogger logger; + + @Test + public void useFeignLoggerByDefault() throws Exception { + slf4j.logLevel("debug"); + slf4j.expectMessages("DEBUG feign.Logger - [someMethod] This is my message\n"); + + logger = new Slf4jLogger(); + logger.log(CONFIG_KEY, "This is my message"); + } + + @Test + public void useLoggerByNameIfRequested() throws Exception { + slf4j.logLevel("debug"); + slf4j.expectMessages("DEBUG named.logger - [someMethod] This is my message\n"); + + logger = new Slf4jLogger("named.logger"); + logger.log(CONFIG_KEY, "This is my message"); + } + + @Test + public void useLoggerByClassIfRequested() throws Exception { + slf4j.logLevel("debug"); + slf4j.expectMessages("DEBUG feign.Feign - [someMethod] This is my message\n"); + + logger = new Slf4jLogger(Feign.class); + logger.log(CONFIG_KEY, "This is my message"); + } + + @Test + public void useSpecifiedLoggerIfRequested() throws Exception { + slf4j.logLevel("debug"); + slf4j.expectMessages("DEBUG specified.logger - [someMethod] This is my message\n"); + + logger = new Slf4jLogger(LoggerFactory.getLogger("specified.logger")); + logger.log(CONFIG_KEY, "This is my message"); + } + + @Test + public void logOnlyIfDebugEnabled() throws Exception { + slf4j.logLevel("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); + } + + @Test + public void logRequestsAndResponses() throws Exception { + slf4j.logLevel("debug"); + slf4j.expectMessages("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"); + + 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); + } +}