diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..4e740783fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# 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 + +# 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/.mvn/jvm.config b/.mvn/jvm.config new file mode 100644 index 0000000000..0e7dabeff6 --- /dev/null +++ b/.mvn/jvm.config @@ -0,0 +1 @@ +-Xmx1024m -XX:CICompilerCount=1 -XX:TieredStopAtLevel=1 -Djava.security.egd=file:/dev/./urandom \ No newline at end of file diff --git a/.mvn/maven.config b/.mvn/maven.config new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/.mvn/maven.config @@ -0,0 +1 @@ + diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000000..c6feb8bb6f Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..6637cedb28 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.3.9/apache-maven-3.3.9-bin.zip \ No newline at end of file diff --git a/.settings.xml b/.settings.xml new file mode 100644 index 0000000000..96fa90dfa0 --- /dev/null +++ b/.settings.xml @@ -0,0 +1,28 @@ + + + + sonatype + ${env.SONATYPE_USER} + ${env.SONATYPE_PASSWORD} + + + bintray + ${env.BINTRAY_USER} + ${env.BINTRAY_KEY} + + + jfrog-snapshots + ${env.BINTRAY_USER} + ${env.BINTRAY_KEY} + + + github.com + ${env.GH_USER} + ${env.GH_TOKEN} + + + + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..6a20ee9132 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,54 @@ +# Run `travis lint` when changing this file to avoid breaking the build. +# Default JDK is really old: 1.8.0_31; Trusty's is less old: 1.8.0_51 +# https://docs.travis-ci.com/user/ci-environment/#Virtualization-environments +sudo: required +dist: trusty + +cache: + directories: + - $HOME/.m2 + +language: java + +jdk: + - oraclejdk8 + + +before_install: + # Parameters used during release + - git config user.name "$GH_USER" + - git config user.email "$GH_USER_EMAIL" + # setup https authentication credentials, used by ./mvnw release:prepare + - git config credential.helper "store --file=.git/credentials" + - echo "https://$GH_TOKEN:@github.com" > .git/credentials + +install: + # Override default travis to use the maven wrapper + - ./mvnw install -DskipTests=true -Dmaven.javadoc.skip=true -B -V + +script: + - ./travis/publish.sh + +# Don't build release tags. This avoids publish conflicts because the version commit exists both on master and the release tag. +# See https://github.com/travis-ci/travis-ci/issues/1532 +branches: + except: + - /^[0-9]/ + +env: + global: + # Ex. travis encrypt BINTRAY_USER=your_github_account + - secure: "VeTOgXwhZLf8uwlnYpB9tuY+NV6kiooRN0FMDoWCXuPPSz/tX2mqmshBXDYsJu0EcRtZb21MkbQwbdJ8Th9K/bvj4sGNK1PrBm9Hmz6e2AvAcxn3ROv86GMTkd7O25OsipTT+/qWrbR3s3lHQxYo5WMsrlEmJ/EF5y5Go5wx90c=" + # Ex. travis encrypt BINTRAY_KEY=xxx-https://bintray.com/profile/edit-xxx --add + - secure: "WND+fjAqpdHArSbXAK7l0dpQLrX0hL/XymV02rhe0pVT1g0J1V32ncqDCVnn/73wTiECTen6y3o1vq3ByIdT9tUErt3o8oEROQsI/cVX9IhvJ/DtcW1lqafXKmQZwDQsifVxhKroW1VuZQbGrKnqVUzfqx5OzxgoNVWpkkxhf50=" + # Ex. travis encrypt GH_USER_EMAIL=for_github@domain.com --add + - secure: "KS/vYN2LZzIiFXVuPoStNG2343Jn7TzTEa3sWBlih075I8TNO1WUlGTzuQH/9xLRZ7wvjXYWQrQmPmA9jXEF6BCmVC3QYZPbXS/CR6L5O3EvFxX0oFE0NkUZ2ZiIIh1uRIjwIVqb715ktHO52XFZjEt69z97YQtS76CvRJtRKFI=" + # Ex. travis encrypt GH_USER=your_github_account --add + - secure: "DW7Q0jChnosR9hBcugAeqfy48VFHRRDPOve1c1XVSmla7VgGgSDlIy8p/vTLEpquWHabRPSbkDisLBPcJxjyXnSx3EobNO8tcQXzQs45aRmcdLrmWOjJpmoNA3wQ6VQX9w9lKoXr6tBVyBuhQfX/QvOls1sRT/bSzstrffhHHv0=" + # Ex. travis encrypt GH_TOKEN=XXX-https://github.com/settings/tokens-XXX --add + - secure: "bXCvr8DvpIbamiiR5XiEqyA6LIQWBmdKCpm0h5M4aUjmfpT18L5PcarxCu147l/yZiituitw4Ywz+nc4j4UKxtz9Oe84ouiDRZ2ynKZhUBOap3RWa7vOJ1Pj9sz20uSvyibX3R2b7lyOlk8PEhVywbhfWb6UE+bqMxQ10lgx6n8=" + # Ex. travis encrypt SONATYPE_USER=your_sonatype_account + - secure: "ONAU76S0WBGcQGf0mr7KxKQjFvhhu73GNuQG8j47pxhJojNlNpWBbu+EGkgaInWKMtO89iBtpicVlXZc06HtbSqv7L93gbMo+xgp5daLlQg4gocDixjB1I2oPPITFFoztu76nOA1IBWRLTKu+w+Y2tKOmzWm+5v2UKD6fz7SYoo=" + # Ex. travis encrypt SONATYPE_PASSWORD=your_sonatype_password + - secure: "UaVTxnw8klS36WLAdcmubqrHIgS4o5NcIqQMPIihk0tv3VEvCJSGvc2b7EPyQZMvm5TR3mXq5IJUAHp8j3seAHfYWmLIZWzvn7Y5mLRw8Kh9up7GzXl8Idui0AEHAAL2mfvE9smlOKPS5D13LKc6tOGFER66itHW3Jg1QoijDmQ=" + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..75c7066e1e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,247 @@ +### Version 9.4 +* Adds Builder class to JAXBDecoder for disabling namespace-awareness (defaults to true). + +### Version 9.3 +* Adds `FallbackFactory`, allowing access to the cause of a Hystrix fallback +* Adds support for encoded parameters via `@Param(encoded = true)` + +### Version 9.2 +* Adds Hystrix `SetterFactory` to customize group and command keys +* Supports context path when using Ribbon `LoadBalancingTarget` +* Adds builder methods for the Response object +* Deprecates Response factory methods +* Adds nullable Request field to the Response object + +### Version 9.1 +* Allows query parameters to match on a substring. Ex `q=body:{body}` + +### Version 9.0 +* Migrates to maven from gradle +* Changes maven groupId to `io.github.openfeign` + +### Version 8.18 +* Adds support for expansion of @Param lists +* Content-Length response bodies with lengths greater than Integer.MAX_VALUE report null length + * Previously the OkhttpClient would throw an exception, and ApacheHttpClient + would report a wrong, possibly negative value +* Adds support for encoded query parameters in `@QueryMap` via `@QueryMap(encoded = true)` +* Keys in `Response.headers` are now lower-cased. This map is now case-insensitive with regards to keys, + and iterates in lexicographic order. + * This is a step towards supporting http2, as header names in http1 are treated as case-insensitive + and http2 down-cases header names. + +### Version 8.17 +* Adds support to RxJava Completable via `HystrixFeign` builder with fallback support +* Upgraded hystrix-core to 1.4.26 +* Upgrades dependency version for OkHttp/MockWebServer 3.2.0 + +### Version 8.16 +* Adds `@HeaderMap` annotation to support dynamic header fields and values +* Add support for default and static methods on interfaces + +### Version 8.15 +* Adds `@QueryMap` annotation to support dynamic query parameters +* Supports runtime injection of `Param.Expander` via `MethodMetadata.indexToExpander` +* Adds fallback support for HystrixCommand, Observable, and Single results +* Supports PUT without a body parameter +* Supports substitutions in `@Headers` like in `@Body`. (#326) + * **Note:** You might need to URL-encode literal values of `{` or `%` in your existing code. + +### Version 8.14 +* Add support for RxJava Observable and Single return types via the `HystrixFeign` builder. +* Adds fallback implementation configuration to the `HystrixFeign` builder +* Bumps dependency versions, most notably Gson 2.5 and OkHttp 2.7 + +### Version 8.13 +* Never expands >8kb responses into memory + +### Version 8.12 +* Adds `Feign.Builder.decode404()` to reduce boilerplate for empty semantics. + +### Version 8.11 +* Adds support for Hystrix via a `HystrixFeign` builder. + +### Version 8.10 +* Adds HTTP status to FeignException for easier response handling +* Reads class-level @Produces/@Consumes JAX-RS annotations +* Supports POST without a body parameter + +### Version 8.9 +* Skips error handling when return type is `Response` + +### 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..4b4f9cb35b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing to Feign +Please read [HACKING](./HACKING.md) prior to raising change. + +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`). + +## Pull Requests +Pull requests eventually need to resolve to a single commit. The commit log should be easy to read as a change log. We use the following form to accomplish that. +* First line is a <=72 character description in present tense, explaining what this does. + * Ex. "Fixes regression on encoding vnd headers" > "Fixed encoding bug", which forces the reader to look at code to understand impact. +* Do not include issue links in the first line as that makes pull requests look weird. + * Ex. "Addresses #345" becomes a pull request title: "Addresses #345 #346" +* After the first line, use markdown to concisely summarize the implementation. + * This isn't in leiu of comments, and it assumes the reader isn't intimately familar with code structure. +* If the change closes an issue, note that at the end of the commit description ex. "Fixes #345" + * GitHub will automatically close change with this syntax. +* If the change is notable, also update the [change log](./CHANGELOG.md) with your summary description. + * The unreleased minor version is often a good default. + +## Code Style +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 13 using [this file](https://google.github.io/styleguide/intellij-java-google-style.xml) and with IntelliJ 15 using [this file](https://raw.githubusercontent.com/garukun/styleguide/add-intellij-15-java/intellij-15-java-google-style.xml). + +## License + +By contributing your code, you agree to license your contribution under the terms of the [APLv2](./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/HACKING.md b/HACKING.md new file mode 100644 index 0000000000..cbe8d4421f --- /dev/null +++ b/HACKING.md @@ -0,0 +1,62 @@ +# Hacking Feign +Feign is optimized for maintenance vs flexibility. It prefers small +features that have been asked for repeated times, that are insured with +tests, and have clear use cases. This limits the lines of code and count +of modules in Feign's repo. + +Code design is opinionated including below: + +* Classes and methods default to package, not public visibility. +* Changing certain implementation classes may be unsupported. +* 3rd-party dependencies, and gnarly apis like java.beans are avoided. + +## How to request change +The best way to approach something not yet supported is to ask on +[gitter](https://gitter.im/Netflix/feign) or [raise an issue](https://github.com/Netflix/feign/issues). +Asking for the feature you need (like how to deal with command groups) +vs a specific implementation (like making a private type public) will +give you more options to accomplish your goal. + +Advice usually comes in two parts: advice and workaround. Advice may be +to change Feign's code, or to fork until the feature is more widely +requested. + +## How change works +High quality pull requests that have clear scope and tests that reflect +the intent of the feature are often merged and released in days. If a +merged change isn't immediately released and it is of priority to you, +nag (make a comment) on your merged pull request until it is released. + +## How to experiment +Changes to Feign's code are best addressed by the feature requestor in a +pull request *after* discussing in an issue or on gitter. By discussing +first, there's less chance of a mutually disappointing experience where +a pull request is rejected. Moreover, the feature may be already present! + +Albeit rare, some features will be deferred or rejected for inclusion in +Feign's main repository. In these cases, the choices are typically to +either fork the repository, or make your own repository containing the +change. + +### Forks are welcome! +Forking isn't bad. It is a natural place to experiment and vet a feature +before it ends up in Feign's main repository. Large features or those +which haven't satisfied diverse need are often deferred to forks or +separate repositories (see [Rule of Three](http://blog.codinghorror.com/rule-of-three/)). + +### Large integrations -> separate repositories +If you look carefully, you'll notice Feign integrations are often less +than 1000 lines of code including tests. Some features are rejected for +inclusion solely due to the amount of maintenance. For example, adding +some features might imply tying up maintainers for several days or weeks +and resulting in a large percentage increase in the size of feign. + +Large integrations aren't bad, but to be sustainable, they need to be +isolated where the maintenance of that feature doesn't endanger the +maintainability of Feign itself. Feign has been going since 2012, without +the need of full-time attention. This is largely because maintenance is +low and approachable. + +A good example of a large integration is [spring-cloud-netflix](https://github.com/spring-cloud/spring-cloud-netflix/tree/master/spring-cloud-netflix-core/src/main/java/org/springframework/cloud/netflix/feign). +Spring Cloud Netflix is sustainable as it has had several people +maintaining it, including Q&A support for years. 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/OSSMETADATA b/OSSMETADATA new file mode 100644 index 0000000000..b6f4252ce1 --- /dev/null +++ b/OSSMETADATA @@ -0,0 +1 @@ +osslifecycle=archived diff --git a/README.md b/README.md index ebf660a86f..49a00afab4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,467 @@ -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) +[![Build Status](https://travis-ci.org/OpenFeign/feign.svg?branch=master)](https://travis-ci.org/OpenFeign/feign) + +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/src/main/java/com/example/retrofit/SimpleService.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 +CloudDNS cloudDNS = Feign.builder().target(new CloudIdentityTarget(user, apiKey)); +``` + +### Examples +Feign includes example [GitHub](./example-github) and [Wikipedia](./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](./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](./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](./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](./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](./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](./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](./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"); + +``` + +### Hystrix +[HystrixFeign](./hystrix) configures circuit breaker support provided by [Hystrix](https://github.com/Netflix/Hystrix). + +To use Hystrix with Feign, add the Hystrix module to your classpath. Then use the `HystrixFeign` builder: + +```java +MyService api = HystrixFeign.builder().target(MyService.class, "https://myAppProd"); + +``` + +### SLF4J +[SLF4JModule](./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"} +``` + +### Headers +Feign supports settings headers on requests either as part of the api or as part of the client +depending on the use case. + +#### Set headers using apis +In cases where specific interfaces or calls should always have certain header values set, it +makes sense to define headers as part of the api. + +Static headers can be set on an api interface or method using the `@Headers` annotation. + +```java +@Headers("Accept: application/json") +interface BaseApi { + @Headers("Content-Type: application/json") + @RequestLine("PUT /api/{key}") + void put(@Param("key") String, V value); +} +``` + +Methods can specify dynamic content for static headers using variable expansion in `@Headers`. + +```java + @RequestLine("POST /") + @Headers("X-Ping: {token}") + void post(@Param("token") String token); +``` + +In cases where both the header field keys and values are dynamic and the range of possible keys cannot +be known ahead of time and may vary between different method calls in the same api/client (e.g. custom +metadata header fields such as "x-amz-meta-\*" or "x-goog-meta-\*"), a Map parameter can be annotated +with `HeaderMap` to construct a query that uses the contents of the map as its header parameters. + +```java + @RequestLine("POST /") + void post(@HeaderMap Map headerMap); +``` + +These approaches specify header entries as part of the api and do not require any customizations +when building the Feign client. + +#### Setting headers per target +In cases where headers should differ for the same api based on different endpoints or where per-request +customization is required, headers can be set as part of the client using a `RequestInterceptor` or a +`Target`. + +For an example of setting headers using a `RequestInterceptor`, see the `Request Interceptors` section. + +Headers can be set as part of a custom `Target`. + +```java + static class DynamicAuthTokenTarget implements Target { + public DynamicAuthTokenTarget(Class clazz, + UrlAndTokenProvider provider, + ThreadLocal requestIdProvider); + ... + @Override + public Request apply(RequestTemplate input) { + TokenIdAndPublicURL urlAndToken = provider.get(); + if (input.url().indexOf("http") != 0) { + input.insert(0, urlAndToken.publicURL); + } + input.header("X-Auth-Token", urlAndToken.tokenId); + input.header("X-Request-ID", requestIdProvider.get()); + + return input.request(); + } + } + ... + Bank bank = Feign.builder() + .target(new DynamicAuthTokenTarget(Bank.class, provider, requestIdProvider)); +``` + +These approaches depend on the custom `RequestInterceptor` or `Target` being set on the Feign +client when it is built and can be used as a way to set headers on all api calls on a per-client +basis. This can be useful for doing things such as setting an authentication token in the header +of all api requests on a per-client basis. The methods are run when the api call is made on the +thread that invokes the api call, which allows the headers to be set dynamically at call time and +in a context-specific manner -- for example, thread-local storage can be used to set different +header values depending on the invoking thread, which can be useful for things such as setting +thread-specific trace identifiers for requests. + +### 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 key); + + @RequestLine("GET /api") + List list(); + + @Headers("Content-Type: application/json") + @RequestLine("PUT /api/{key}") + void put(@Param("key") String key, 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); +``` + +#### Dynamic Query Parameters +A Map parameter can be annotated with `QueryMap` to construct a query that uses the contents of the map as its query parameters. + +```java +@RequestLine("GET /find") +V find(@QueryMap Map queryMap); +``` + +#### Static and Default Methods +Interfaces targeted by Feign may have static or default methods (if using Java 8+). +These allows Feign clients to contain logic that is not expressly defined by the underlying API. +For example, static methods make it easy to specify common client build configurations; default methods can be used to compose queries or define default parameters. + +```java +interface GitHub { + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("GET /users/{username}/repos?sort={sort}") + List repos(@Param("username") String owner, @Param("sort") String sort); + + default List repos(String owner) { + return repos(owner, "full_name"); + } + + /** + * Lists all contributors for all repos owned by a user. + */ + default List contributors(String user) { + MergingContributorList contributors = new MergingContributorList(); + for(Repo repo : this.repos(owner)) { + contributors.addAll(this.contributors(user, repo.getName())); + } + return contributors.mergeResult(); + } + + static GitHub connect() { + return Feign.builder() + .decoder(new GsonDecoder()) + .target(GitHub.class, "https://api.github.com"); + } +} +``` diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000000..c65336e564 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,40 @@ +# Feign Release Process + +This repo uses [semantic versions](http://semver.org/). Please keep this in mind when choosing version numbers. + +1. **Alert others you are releasing** + + There should be no commits made to master while the release is in progress (about 10 minutes). Before you start + a release, alert others on [gitter](https://gitter.im/OpenFeign/feign) so that they don't accidentally merge + anything. If they do, and the build fails because of that, you'll have to recreate the release tag described below. + +1. **Push a git tag** + + The tag should be of the format `release-N.M.L`, for example `release-8.18.0`. + +1. **Wait for Travis CI** + + This part is controlled by [`travis/publish.sh`](travis/publish.sh). It creates a couple commits, bumps the version, + publishes artifacts, syncs to Maven Central. + +## Credentials + +Credentials of various kind are needed for the release process to work. If you notice something +failing due to unauthorized, re-encrypt them using instructions at the bottom of the `.travis.yml` + +Ex You'll see comments like this: +```yaml +env: + global: + # Ex. travis encrypt BINTRAY_USER=your_github_account + - secure: "VeTO... +``` + +To re-encrypt, you literally run the commands with relevant values and replace the "secure" key with the output: + +```bash +$ travis encrypt BINTRAY_USER=adrianmole +Please add the following to your .travis.yml file: + + secure: "mQnECL+dXc5l9wCYl/wUz+AaYFGt/1G31NAZcTLf2RbhKo8mUenc4hZNjHCEv+4ZvfYLd/NoTNMhTCxmtBMz1q4CahPKLWCZLoRD1ExeXwRymJPIhxZUPzx9yHPHc5dmgrSYOCJLJKJmHiOl9/bJi123456=" +``` 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..4dc1fad60b --- /dev/null +++ b/benchmark/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + + org.sonatype.oss + oss-parent + 9 + + + + com.netflix.feign + feign-benchmark + jar + 8.1.0-SNAPSHOT + Feign Benchmark (JMH) + + + 1.11.2 + + + + + com.netflix.feign + feign-core + ${project.version} + + + com.netflix.feign + feign-okhttp + ${project.version} + + + com.squareup.okhttp + mockwebserver + 2.7.1 + + + org.bouncycastle + bcprov-jdk15on + + + + + io.reactivex + rxnetty + 0.4.14 + + + io.reactivex + rxjava + 1.0.17 + + + io.netty + netty-codec-http + 4.1.0.Beta8 + + + 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.2 + + + 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..518755fcc1 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/RealRequestBenchmarks.java @@ -0,0 +1,85 @@ +package feign.benchmark; + +import okhttp3.OkHttpClient; +import okhttp3.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/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..967624a2d7 --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,17 @@ +apply plugin: 'java' + +sourceCompatibility = 1.6 + +dependencies { + compile 'org.jvnet:animal-sniffer-annotation:1.0' + testCompile 'junit:junit:4.12' + testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7 + testCompile 'com.squareup.okhttp3:mockwebserver:3.2.0' + testCompile 'com.google.code.gson:gson:2.5' // for example + testCompile 'org.springframework:spring-context:4.2.5.RELEASE' // for example +} + +configure(compileTestJava) { + sourceCompatibility = 1.8 + targetCompatibility = 1.8 +} diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 0000000000..0d1bacff18 --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + + io.github.openfeign + parent + 9.4.1-SNAPSHOT + + + feign-core + Feign Core + Feign Core + + + ${project.basedir}/.. + + + + + org.jvnet + animal-sniffer-annotation + + + + com.squareup.okhttp3 + mockwebserver + test + + + + com.google.code.gson + gson + test + + + + org.springframework + spring-context + 4.2.5.RELEASE + test + + + + + + + maven-jar-plugin + + + + test-jar + + + + + + + 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..dcfa5cff1c --- /dev/null +++ b/core/src/main/java/feign/Client.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; + +import static java.lang.String.format; + +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).toBuilder().request(request).build(); + } + + 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(); + + if (status < 0) { + throw new IOException(format("Invalid status(%s) executing %s %s", status, + connection.getRequestMethod(), connection.getURL())); + } + + 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.builder() + .status(status) + .reason(reason) + .headers(headers) + .body(stream, length) + .build(); + } + } +} diff --git a/core/src/main/java/feign/Contract.java b/core/src/main/java/feign/Contract.java new file mode 100644 index 0000000000..54e9a39e08 --- /dev/null +++ b/core/src/main/java/feign/Contract.java @@ -0,0 +1,315 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +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 || + (method.getModifiers() & Modifier.STATIC) != 0 || + Util.isDefault(method)) { + 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)); + + if(targetType.getInterfaces().length == 1) { + processAnnotationOnClass(data, targetType.getInterfaces()[0]); + } + 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])); + } + } + + if (data.headerMapIndex() != null) { + checkState(Map.class.isAssignableFrom(parameterTypes[data.headerMapIndex()]), + "HeaderMap parameter must be a Map: %s", parameterTypes[data.headerMapIndex()]); + } + + if (data.queryMapIndex() != null) { + checkState(Map.class.isAssignableFrom(parameterTypes[data.queryMapIndex()]), + "QueryMap parameter must be a Map: %s", parameterTypes[data.queryMapIndex()]); + } + + 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); + + /** + * @deprecated dead-code will remove in feign 10 + */ + @Deprecated + // deprecated as only used in a sub-type + 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) { + checkState(requestLine.indexOf('/') == -1, + "RequestLine annotation didn't start with an HTTP verb on method %s.", + method.getName()); + 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) { + Param paramAnnotation = (Param) annotation; + String name = paramAnnotation.value(); + checkState(emptyToNull(name) != null, "Param annotation was empty on param %s.", paramIndex); + nameParam(data, name, paramIndex); + Class expander = paramAnnotation.expander(); + if (expander != Param.ToStringExpander.class) { + data.indexToExpanderClass().put(paramIndex, expander); + } + data.indexToEncoded().put(paramIndex, paramAnnotation.encoded()); + isHttpAnnotation = true; + String varName = '{' + name + '}'; + if (!data.template().url().contains(varName) && + !searchMapValuesContainsSubstring(data.template().queries(), varName) && + !searchMapValuesContainsSubstring(data.template().headers(), varName)) { + data.formParams().add(name); + } + } else if (annotationType == QueryMap.class) { + checkState(data.queryMapIndex() == null, "QueryMap annotation was present on multiple parameters."); + data.queryMapIndex(paramIndex); + data.queryMapEncoded(QueryMap.class.cast(annotation).encoded()); + isHttpAnnotation = true; + } else if (annotationType == HeaderMap.class) { + checkState(data.headerMapIndex() == null, "HeaderMap annotation was present on multiple parameters."); + data.headerMapIndex(paramIndex); + isHttpAnnotation = true; + } + } + return isHttpAnnotation; + } + + private static boolean searchMapValuesContainsSubstring(Map> map, + String search) { + Collection> values = map.values(); + if (values == null) { + return false; + } + + for (Collection entry : values) { + for (String value : entry) { + if (value.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/DefaultMethodHandler.java b/core/src/main/java/feign/DefaultMethodHandler.java new file mode 100644 index 0000000000..f24a13b480 --- /dev/null +++ b/core/src/main/java/feign/DefaultMethodHandler.java @@ -0,0 +1,62 @@ +package feign; + +import feign.InvocationHandlerFactory.MethodHandler; +import org.jvnet.animal_sniffer.IgnoreJRERequirement; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles.Lookup; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +/** + * Handles default methods by directly invoking the default method code on the interface. + * The bindTo method must be called on the result before invoke is called. + */ +@IgnoreJRERequirement +final class DefaultMethodHandler implements MethodHandler { + // Uses Java 7 MethodHandle based reflection. As default methods will only exist when + // run on a Java 8 JVM this will not affect use on legacy JVMs. + // When Feign upgrades to Java 7, remove the @IgnoreJRERequirement annotation. + private final MethodHandle unboundHandle; + + // handle is effectively final after bindTo has been called. + private MethodHandle handle; + + public DefaultMethodHandler(Method defaultMethod) { + try { + Class declaringClass = defaultMethod.getDeclaringClass(); + Field field = Lookup.class.getDeclaredField("IMPL_LOOKUP"); + field.setAccessible(true); + Lookup lookup = (Lookup) field.get(null); + + this.unboundHandle = lookup.unreflectSpecial(defaultMethod, declaringClass); + } catch (NoSuchFieldException ex) { + throw new IllegalStateException(ex); + } catch (IllegalAccessException ex) { + throw new IllegalStateException(ex); + } + } + + /** + * Bind this handler to a proxy object. After bound, DefaultMethodHandler#invoke will act as if it was called + * on the proxy object. Must be called once and only once for a given instance of DefaultMethodHandler + */ + public void bindTo(Object proxy) { + if(handle != null) { + throw new IllegalStateException("Attempted to rebind a default method handler that was already bound"); + } + handle = unboundHandle.bindTo(proxy); + } + + /** + * Invoke this method. DefaultMethodHandler#bindTo must be called before the first + * time invoke is called. + */ + @Override + public Object invoke(Object[] argv) throws Throwable { + if(handle == null) { + throw new IllegalStateException("Default method handler invoked before proxy has been bound."); + } + return handle.invokeWithArguments(argv); + } +} diff --git a/core/src/main/java/feign/Feign.java b/core/src/main/java/feign/Feign.java new file mode 100644 index 0000000000..2618365f73 --- /dev/null +++ b/core/src/main/java/feign/Feign.java @@ -0,0 +1,222 @@ +/* + * 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. This method exposes that format, in case you need to create the same value as + * {@link MethodMetadata#configKey()} for correlation purposes. + * + *

Here are some sample encodings: + * + *

+   * 
    + *
  • {@code Route53}: would match a class {@code route53.Route53}
  • + *
  • {@code Route53#list()}: would match a method {@code route53.Route53#list()}
  • + *
  • {@code Route53#listAt(Marker)}: would match a method {@code + * route53.Route53#listAt(Marker)}
  • + *
  • {@code Route53#listByNameAndType(String, String)}: would match a method {@code + * 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. + * @see MethodMetadata#configKey() + */ + 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(); + private boolean decode404; + + 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; + } + + /** + * This flag indicates that the {@link #decoder(Decoder) decoder} should process responses with + * 404 status, specifically returning null or empty instead of throwing {@link FeignException}. + * + *

All first-party (ex gson) decoders return well-known empty values defined by {@link + * Util#emptyValueOf}. To customize further, wrap an existing {@link #decoder(Decoder) decoder} + * or make your own. + * + *

This flag only works with 404, as opposed to all or arbitrary status codes. This was an + * explicit decision: 404 -> empty is safe, common and doesn't complicate redirection, retry or + * fallback policy. If your server returns a different status for not-found, correct via a + * custom {@link #client(Client) client}. + * + * @since 8.12 + */ + public Builder decode404() { + this.decode404 = true; + 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, decode404); + 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..c24f861174 --- /dev/null +++ b/core/src/main/java/feign/FeignException.java @@ -0,0 +1,70 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign; + +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; + private int status; + + protected FeignException(String message, Throwable cause) { + super(message, cause); + } + + protected FeignException(String message) { + super(message); + } + + protected FeignException(int status, String message) { + super(message); + this.status = status; + } + + public int status() { + return this.status; + } + + 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(response.status(), 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/HeaderMap.java b/core/src/main/java/feign/HeaderMap.java new file mode 100644 index 0000000000..3da400be1e --- /dev/null +++ b/core/src/main/java/feign/HeaderMap.java @@ -0,0 +1,55 @@ +package feign; + +import java.lang.annotation.Retention; +import java.util.List; +import java.util.Map; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * A template parameter that can be applied to a Map that contains header + * entries, where the keys are Strings that are the header field names and the + * values are the header field values. The headers specified by the map will be + * applied to the request after all other processing, and will take precedence + * over any previously specified header parameters. + *
+ * This parameter is useful in cases where different header fields and values + * need to be set on an API method on a per-request basis in a thread-safe manner + * and independently of Feign client construction. A concrete example of a case + * like this are custom metadata header fields (e.g. as "x-amz-meta-*" or + * "x-goog-meta-*") where the header field names are dynamic and the range of keys + * cannot be determined a priori. The {@link Headers} annotation does not allow this + * because the header fields that it defines are static (it is not possible to add or + * remove fields on a per-request basis), and doing this using a custom {@link Target} + * or {@link RequestInterceptor} can be cumbersome (it requires more code for per-method + * customization, it is difficult to implement in a thread-safe manner and it requires + * customization when the Feign client for the API is built). + *
+ *

+ * ...
+ * @RequestLine("GET /servers/{serverId}")
+ * void get(@Param("serverId") String serverId, @HeaderMap Map);
+ * ...
+ * 
+ * The annotated parameter must be an instance of {@link Map}, and the keys must + * be Strings. The header field value of a key will be the value of its toString + * method, except in the following cases: + *
+ *
+ *
    + *
  • if the value is null, the value will remain null (rather than converting + * to the String "null") + *
  • if the value is an {@link Iterable}, it is converted to a {@link List} of + * String objects where each value in the list is either null if the original + * value was null or the value's toString representation otherwise. + *
+ *
+ * Once this conversion is applied, the query keys and resulting String values + * follow the same contract as if they were set using + * {@link RequestTemplate#header(String, String...)}. + */ +@Retention(RUNTIME) +@java.lang.annotation.Target(PARAMETER) +public @interface HeaderMap { +} diff --git a/core/src/main/java/feign/Headers.java b/core/src/main/java/feign/Headers.java new file mode 100644 index 0000000000..c00d9a9961 --- /dev/null +++ b/core/src/main/java/feign/Headers.java @@ -0,0 +1,53 @@ +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 to the the right of the colon are expanded.
+ *
+ * @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);
+ * ...
+ * 
+ *
Notes: + *
    + *
  • If you'd like curly braces literally in the header, urlencode them first.
  • + *
  • 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..a3384b8f94 --- /dev/null +++ b/core/src/main/java/feign/Logger.java @@ -0,0 +1,227 @@ +/* + * 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); + } + } + + protected void logRetry(String configKey, Level logLevel) { + log(configKey, "---> RETRYING"); + } + + protected Response logAndRebufferResponse(String configKey, Level logLevel, Response response, + long elapsedTime) throws IOException { + String reason = response.reason() != null && logLevel.compareTo(Level.NONE) > 0 ? + " " + response.reason() : ""; + int status = response.status(); + log(configKey, "<--- HTTP/1.1 %s%s (%sms)", status, 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 && !(status == 204 || status == 205)) { + // HTTP 204 No Content "...response MUST NOT include a message-body" + // HTTP 205 Reset Content "...response MUST NOT include an entity" + 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.toBuilder().body(bodyData).build(); + } else { + log(configKey, "<--- END HTTP (%s-byte body)", bodyLength); + } + } + return response; + } + + protected 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 System.err. + */ + 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) { + if (logger.isLoggable(java.util.logging.Level.FINE)) { + logger.fine(String.format(methodTag(configKey) + format, args)); + } + } + + /** + * Helper that configures java.util.logging 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..be0affd828 --- /dev/null +++ b/core/src/main/java/feign/MethodMetadata.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; + +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 Integer headerMapIndex; + private Integer queryMapIndex; + private boolean queryMapEncoded; + private transient Type bodyType; + private RequestTemplate template = new RequestTemplate(); + private List formParams = new ArrayList(); + private Map> indexToName = + new LinkedHashMap>(); + private Map> indexToExpanderClass = + new LinkedHashMap>(); + private Map indexToEncoded = new LinkedHashMap(); + private transient Map indexToExpander; + + MethodMetadata() { + } + + /** + * Used as a reference to this method. For example, {@link Logger#log(String, String, Object...) + * logging} or {@link ReflectiveFeign reflective dispatch}. + * + * @see Feign#configKey(Class, java.lang.reflect.Method) + */ + public String configKey() { + return configKey; + } + + public MethodMetadata configKey(String configKey) { + this.configKey = configKey; + return this; + } + + public Type returnType() { + return returnType; + } + + public MethodMetadata returnType(Type returnType) { + this.returnType = returnType; + return this; + } + + public Integer urlIndex() { + return urlIndex; + } + + public MethodMetadata urlIndex(Integer urlIndex) { + this.urlIndex = urlIndex; + return this; + } + + public Integer bodyIndex() { + return bodyIndex; + } + + public MethodMetadata bodyIndex(Integer bodyIndex) { + this.bodyIndex = bodyIndex; + return this; + } + + public Integer headerMapIndex() { + return headerMapIndex; + } + + public MethodMetadata headerMapIndex(Integer headerMapIndex) { + this.headerMapIndex = headerMapIndex; + return this; + } + + public Integer queryMapIndex() { + return queryMapIndex; + } + + public MethodMetadata queryMapIndex(Integer queryMapIndex) { + this.queryMapIndex = queryMapIndex; + return this; + } + + public boolean queryMapEncoded() { + return queryMapEncoded; + } + + public MethodMetadata queryMapEncoded(boolean queryMapEncoded) { + this.queryMapEncoded = queryMapEncoded; + return this; + } + + /** + * Type corresponding to {@link #bodyIndex()}. + */ + public Type bodyType() { + return bodyType; + } + + public 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 indexToEncoded() { + return indexToEncoded; + } + + /** + * If {@link #indexToExpander} is null, classes here will be instantiated by newInstance. + */ + public Map> indexToExpanderClass() { + return indexToExpanderClass; + } + + /** + * After {@link #indexToExpanderClass} is populated, this is set by contracts that support runtime + * injection. + */ + public MethodMetadata indexToExpander(Map indexToExpander) { + this.indexToExpander = indexToExpander; + return this; + } + + /** + * When not null, this value will be used instead of {@link #indexToExpander()}. + */ + public Map indexToExpander() { + return indexToExpander; + } +} diff --git a/core/src/main/java/feign/Param.java b/core/src/main/java/feign/Param.java new file mode 100644 index 0000000000..995ec8d142 --- /dev/null +++ b/core/src/main/java/feign/Param.java @@ -0,0 +1,64 @@ +/* + * 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; + + /** + * Specifies whether argument is already encoded + * The value is ignored for headers (headers are never encoded) + * + * @see QueryMap#encoded + */ + boolean encoded() default false; + + 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/QueryMap.java b/core/src/main/java/feign/QueryMap.java new file mode 100644 index 0000000000..ff3957fc40 --- /dev/null +++ b/core/src/main/java/feign/QueryMap.java @@ -0,0 +1,69 @@ +/* + * 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 java.util.List; +import java.util.Map; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * A template parameter that can be applied to a Map that contains query + * parameters, where the keys are Strings that are the parameter names and the + * values are the parameter values. The queries specified by the map will be + * applied to the request after all other processing, and will take precedence + * over any previously specified query parameters. It is not necessary to + * reference the parameter map as a variable.
+ *
+ *
+ * ...
+ * @RequestLine("POST /servers")
+ * void servers(@QueryMap Map);
+ * ...
+ *
+ * @RequestLine("GET /servers/{serverId}?count={count}")
+ * void get(@Param("serverId") String serverId, @Param("count") int count, @QueryMap Map);
+ * ...
+ * 
+ * The annotated parameter must be an instance of {@link Map}, and the keys must + * be Strings. The query value of a key will be the value of its toString + * method, except in the following cases: + *
+ *
+ *
    + *
  • if the value is null, the value will remain null (rather than converting + * to the String "null") + *
  • if the value is an {@link Iterable}, it is converted to a {@link List} of + * String objects where each value in the list is either null if the original + * value was null or the value's toString representation otherwise. + *
+ *
+ * Once this conversion is applied, the query keys and resulting String values + * follow the same contract as if they were set using + * {@link RequestTemplate#query(String, String...)}. + */ +@Retention(RUNTIME) +@java.lang.annotation.Target(PARAMETER) +public @interface QueryMap { + /** + * Specifies whether parameter names and values are already encoded. + * + * @see Param#encoded + */ + boolean encoded() default false; +} diff --git a/core/src/main/java/feign/ReflectiveFeign.java b/core/src/main/java/feign/ReflectiveFeign.java new file mode 100644 index 0000000000..edcebca3b9 --- /dev/null +++ b/core/src/main/java/feign/ReflectiveFeign.java @@ -0,0 +1,359 @@ +/* + * 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.*; +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; +import static feign.Util.checkState; + +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(); + List defaultMethodHandlers = new LinkedList(); + + for (Method method : target.type().getMethods()) { + if (method.getDeclaringClass() == Object.class) { + continue; + } else if(Util.isDefault(method)) { + DefaultMethodHandler handler = new DefaultMethodHandler(method); + defaultMethodHandlers.add(handler); + methodToHandler.put(method, handler); + } else { + methodToHandler.put(method, nameToHandler.get(Feign.configKey(target.type(), method))); + } + } + InvocationHandler handler = factory.create(target, methodToHandler); + T proxy = (T) Proxy.newProxyInstance(target.type().getClassLoader(), new Class[]{target.type()}, handler); + + for(DefaultMethodHandler defaultMethodHandler : defaultMethodHandlers) { + defaultMethodHandler.bindTo(proxy); + } + return proxy; + } + + 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.indexToExpander() != null) { + indexToExpander.putAll(metadata.indexToExpander()); + return; + } + 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 = expandElements(indexToExpander.get(i), value); + } + for (String name : entry.getValue()) { + varBuilder.put(name, value); + } + } + } + + RequestTemplate template = resolve(argv, mutable, varBuilder); + if (metadata.queryMapIndex() != null) { + // add query map parameters after initial resolve so that they take + // precedence over any predefined values + template = addQueryMapQueryParameters(argv, template); + } + + if (metadata.headerMapIndex() != null) { + template = addHeaderMapHeaders(argv, template); + } + + return template; + } + + private Object expandElements(Expander expander, Object value) { + if (value instanceof Iterable) { + return expandIterable(expander, (Iterable) value); + } + return expander.expand(value); + } + + private List expandIterable(Expander expander, Iterable value) { + List values = new ArrayList(); + for (Object element : (Iterable) value) { + if (element!=null) { + values.add(expander.expand(element)); + } + } + return values; + } + + @SuppressWarnings("unchecked") + private RequestTemplate addHeaderMapHeaders(Object[] argv, RequestTemplate mutable) { + Map headerMap = (Map) argv[metadata.headerMapIndex()]; + for (Entry currEntry : headerMap.entrySet()) { + checkState(currEntry.getKey().getClass() == String.class, "HeaderMap key must be a String: %s", currEntry.getKey()); + + Collection values = new ArrayList(); + + Object currValue = currEntry.getValue(); + if (currValue instanceof Iterable) { + Iterator iter = ((Iterable) currValue).iterator(); + while (iter.hasNext()) { + Object nextObject = iter.next(); + values.add(nextObject == null ? null : nextObject.toString()); + } + } else { + values.add(currValue == null ? null : currValue.toString()); + } + + mutable.header((String) currEntry.getKey(), values); + } + return mutable; + } + + @SuppressWarnings("unchecked") + private RequestTemplate addQueryMapQueryParameters(Object[] argv, RequestTemplate mutable) { + Map queryMap = (Map) argv[metadata.queryMapIndex()]; + for (Entry currEntry : queryMap.entrySet()) { + checkState(currEntry.getKey().getClass() == String.class, "QueryMap key must be a String: %s", currEntry.getKey()); + + Collection values = new ArrayList(); + + Object currValue = currEntry.getValue(); + if (currValue instanceof Iterable) { + Iterator iter = ((Iterable) currValue).iterator(); + while (iter.hasNext()) { + Object nextObject = iter.next(); + values.add(nextObject == null ? null : nextObject.toString()); + } + } else { + values.add(currValue == null ? null : currValue.toString()); + } + + mutable.query(metadata.queryMapEncoded(), (String) currEntry.getKey(), values); + } + return mutable; + } + + protected RequestTemplate resolve(Object[] argv, RequestTemplate mutable, + Map variables) { + // Resolving which variable names are already encoded using their indices + Map variableToEncoded = new LinkedHashMap(); + for (Entry entry : metadata.indexToEncoded().entrySet()) { + Collection names = metadata.indexToName().get(entry.getKey()); + for (String name : names) { + variableToEncoded.put(name, entry.getValue()); + } + } + return mutable.resolve(variables, variableToEncoded); + } + } + + 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, Encoder.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..c0864dec6c --- /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.header("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..19da161892 --- /dev/null +++ b/core/src/main/java/feign/RequestTemplate.java @@ -0,0 +1,681 @@ +/* + * 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.checkArgument; +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; + } + 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 '{': + if (inVar) { + // '{{' is an escape: write the brace and don't interpret as a variable + builder.append("{"); + inVar = false; + break; + } + inVar = true; + break; + case '}': + if (!inVar) { // then write the brace literally + builder.append('}'); + break; + } + 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); + } + + /** {@link #resolve(Map, Map)}, which assumes no parameter is encoded */ + public RequestTemplate resolve(Map unencoded) { + return resolve(unencoded, Collections.emptyMap()); + } + + /** + * 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 + */ + RequestTemplate resolve(Map unencoded, Map alreadyEncoded) { + replaceQueryValues(unencoded, alreadyEncoded); + Map encoded = new LinkedHashMap(); + for (Entry entry : unencoded.entrySet()) { + final String key = entry.getKey(); + final Object objectValue = entry.getValue(); + String encodedValue = encodeValueIfNotEncoded(key, objectValue, alreadyEncoded); + encoded.put(key, encodedValue); + } + 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 = expand(value, unencoded); + resolvedValues.add(resolved); + } + resolvedHeaders.put(field, resolvedValues); + } + headers.clear(); + headers.putAll(resolvedHeaders); + if (bodyTemplate != null) { + body(urlDecode(expand(bodyTemplate, encoded))); + } + return this; + } + + private String encodeValueIfNotEncoded(String key, Object objectValue, Map alreadyEncoded) { + String value = String.valueOf(objectValue); + final Boolean isEncoded = alreadyEncoded.get(key); + if (isEncoded == null || !isEncoded) { + value = urlEncode(value); + } + return value; + } + + /* roughly analogous to {@code javax.ws.rs.client.Target.request()}. */ + public Request request() { + Map> safeCopy = new LinkedHashMap>(); + safeCopy.putAll(headers); + return Request.create( + method, url + queryLine(), + Collections.unmodifiableMap(safeCopy), + body, charset + ); + } + + /* @see Request#method() */ + public RequestTemplate method(String method) { + this.method = checkNotNull(method, "method"); + checkArgument(method.matches("^[A-Z]+$"), "Invalid HTTP Method: %s", 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 name} with the {@code values} supplied. + *
Values can be passed in decoded or in url-encoded form depending on the value of the + * {@code encoded} parameter. + *
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}");
+   * 
+ *
Note: behavior of RequestTemplate is not consistent if a query parameter with + * unsafe characters is passed as both encoded and unencoded, although no validation is performed. + *
ex.
+ *
+   * template.query(true, "param[]", "value");
+   * template.query(false, "param[]", "value");
+   * 
+ * + * @param encoded whether name and values are already url-encoded + * @param name the name 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(boolean encoded, String name, String... values) { + return doQuery(encoded, name, values); + } + + /* @see #query(boolean, String, String...) */ + public RequestTemplate query(boolean encoded, String name, Iterable values) { + return doQuery(encoded, name, values); + } + + /** + * Shortcut for {@code query(false, String, String...)} + * @see #query(boolean, String, String...) + */ + public RequestTemplate query(String name, String... values) { + return doQuery(false, name, values); + } + + /** + * Shortcut for {@code query(false, String, Iterable)} + * @see #query(boolean, String, String...) + */ + public RequestTemplate query(String name, Iterable values) { + return doQuery(false, name, values); + } + + private RequestTemplate doQuery(boolean encoded, String name, String... values) { + checkNotNull(name, "name"); + String paramName = encoded ? name : encodeIfNotVariable(name); + queries.remove(paramName); + if (values != null && values.length > 0 && values[0] != null) { + ArrayList paramValues = new ArrayList(); + for (String value : values) { + paramValues.add(encoded ? value : encodeIfNotVariable(value)); + } + this.queries.put(paramName, paramValues); + } + return this; + } + + private RequestTemplate doQuery(boolean encoded, String name, Iterable values) { + if (values != null) { + return doQuery(encoded, name, toArray(values, String.class)); + } + return doQuery(encoded, name, (String[]) null); + } + + private static 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)) { + //Queries 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(); + } + + /** {@link #replaceQueryValues(Map, Map)}, which assumes no parameter is encoded */ + public void replaceQueryValues(Map unencoded) { + replaceQueryValues(unencoded, Collections.emptyMap()); + } + + /** + * Replaces query values which are templated with corresponding values from the {@code unencoded} + * map. Any unresolved queries are removed. + */ + void replaceQueryValues(Map unencoded, Map alreadyEncoded) { + 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)) { + String encodedValue = encodeValueIfNotEncoded(entry.getKey(), val, alreadyEncoded); + values.add(encodedValue); + } + } else { + String encodedValue = encodeValueIfNotEncoded(entry.getKey(), variableValue, alreadyEncoded); + values.add(encodedValue); + } + } 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..e9f03fda5d --- /dev/null +++ b/core/src/main/java/feign/Response.java @@ -0,0 +1,388 @@ +/* + * 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.LinkedList; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; + +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 implements Closeable { + + private final int status; + private final String reason; + private final Map> headers; + private final Body body; + private final Request request; + + private Response(Builder builder) { + checkState(builder.status >= 200, "Invalid status code: %s", builder.status); + this.status = builder.status; + this.reason = builder.reason; //nullable + this.headers = Collections.unmodifiableMap(caseInsensitiveCopyOf(builder.headers)); + this.body = builder.body; //nullable + this.request = builder.request; //nullable + } + + /** + * @deprecated To be removed in Feign 10 + */ + @Deprecated + public static Response create(int status, String reason, Map> headers, + InputStream inputStream, Integer length) { + return Response.builder() + .status(status) + .reason(reason) + .headers(headers) + .body(InputStreamBody.orNull(inputStream, length)) + .build(); + } + + /** + * @deprecated To be removed in Feign 10 + */ + @Deprecated + public static Response create(int status, String reason, Map> headers, + byte[] data) { + return Response.builder() + .status(status) + .reason(reason) + .headers(headers) + .body(ByteArrayBody.orNull(data)) + .build(); + } + + /** + * @deprecated To be removed in Feign 10 + */ + @Deprecated + public static Response create(int status, String reason, Map> headers, + String text, Charset charset) { + return Response.builder() + .status(status) + .reason(reason) + .headers(headers) + .body(ByteArrayBody.orNull(text, charset)) + .build(); + } + + /** + * @deprecated To be removed in Feign 10 + */ + @Deprecated + public static Response create(int status, String reason, Map> headers, + Body body) { + return Response.builder() + .status(status) + .reason(reason) + .headers(headers) + .body(body) + .build(); + } + + public Builder toBuilder(){ + return new Builder(this); + } + + public static Builder builder(){ + return new Builder(); + } + + public static final class Builder { + int status; + String reason; + Map> headers; + Body body; + Request request; + + Builder() { + } + + Builder(Response source) { + this.status = source.status; + this.reason = source.reason; + this.headers = source.headers; + this.body = source.body; + this.request = source.request; + } + + /** @see Response#status*/ + public Builder status(int status) { + this.status = status; + return this; + } + + /** @see Response#reason */ + public Builder reason(String reason) { + this.reason = reason; + return this; + } + + /** @see Response#headers */ + public Builder headers(Map> headers) { + this.headers = headers; + return this; + } + + /** @see Response#body */ + public Builder body(Body body) { + this.body = body; + return this; + } + + /** @see Response#body */ + public Builder body(InputStream inputStream, Integer length) { + this.body = InputStreamBody.orNull(inputStream, length); + return this; + } + + /** @see Response#body */ + public Builder body(byte[] data) { + this.body = ByteArrayBody.orNull(data); + return this; + } + + /** @see Response#body */ + public Builder body(String text, Charset charset) { + this.body = ByteArrayBody.orNull(text, charset); + return this; + } + + /** @see Response#request + * + * NOTE: will add null check in version 10 which may require changes + * to custom feign.Client or loggers + */ + public Builder request(Request request) { + this.request = request; + return this; + } + + public Response build() { + return new Response(this); + } + } + + /** + * status code. ex {@code 200} + * + * See rfc2616 + */ + public int status() { + return status; + } + + /** + * Nullable and not set when using http/2 + * + * See https://github.com/http2/http2-spec/issues/202 + */ + public String reason() { + return reason; + } + + /** + * Returns a case-insensitive mapping of header names to their values. + */ + public Map> headers() { + return headers; + } + + /** + * if present, the response had a body + */ + public Body body() { + return body; + } + + /** + * if present, the request that generated this response + */ + public Request request() { + return request; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("HTTP/1.1 ").append(status); + if (reason != null) builder.append(' ').append(reason); + builder.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(); + } + + @Override + public void close() { + Util.ensureClosed(body); + } + + public interface Body extends Closeable { + + /** + * length in bytes, if known. Null if unknown or greater than {@link Integer#MAX_VALUE}. + * + *


Note
This is an integer as + * most implementations cannot do bodies greater than 2GB. + */ + 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"); + } + } + + private static Map> caseInsensitiveCopyOf(Map> headers) { + Map> result = new TreeMap>(String.CASE_INSENSITIVE_ORDER); + + for (Map.Entry> entry : headers.entrySet()) { + String headerName = entry.getKey(); + if (!result.containsKey(headerName)) { + result.put(headerName.toLowerCase(Locale.ROOT), new LinkedList()); + } + result.get(headerName).addAll(entry.getValue()); + } + return result; + } +} 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..8a29d34cf0 --- /dev/null +++ b/core/src/main/java/feign/Retryer.java @@ -0,0 +1,115 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign; + +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); + } + } + + /** + * Implementation that never retries request. It propagates the RetryableException. + */ + Retryer NEVER_RETRY = new Retryer() { + + @Override + public void continueOrPropagate(RetryableException e) { + throw e; + } + + @Override + public Retryer clone() { + return this; + } + }; +} diff --git a/core/src/main/java/feign/SynchronousMethodHandler.java b/core/src/main/java/feign/SynchronousMethodHandler.java new file mode 100644 index 0000000000..481ca567cf --- /dev/null +++ b/core/src/main/java/feign/SynchronousMethodHandler.java @@ -0,0 +1,200 @@ +/* + * 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 static final long MAX_RESPONSE_BUFFER_SIZE = 8192L; + + 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 final boolean decode404; + + 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, boolean decode404) { + 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); + this.decode404 = decode404; + } + + @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); + // ensure the request is set. TODO: remove in Feign 10 + response.toBuilder().request(request).build(); + } 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); + + boolean shouldClose = true; + try { + if (logLevel != Logger.Level.NONE) { + response = + logger.logAndRebufferResponse(metadata.configKey(), logLevel, response, elapsedTime); + // ensure the request is set. TODO: remove in Feign 10 + response.toBuilder().request(request).build(); + } + if (Response.class == metadata.returnType()) { + if (response.body() == null) { + return response; + } + if (response.body().length() == null || + response.body().length() > MAX_RESPONSE_BUFFER_SIZE) { + shouldClose = false; + return response; + } + // Ensure the response body is disconnected + byte[] bodyData = Util.toByteArray(response.body().asInputStream()); + return response.toBuilder().body(bodyData).build(); + } + if (response.status() >= 200 && response.status() < 300) { + if (void.class == metadata.returnType()) { + return null; + } else { + return decode(response); + } + } else if (decode404 && response.status() == 404) { + 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 { + if (shouldClose) { + 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; + private final boolean decode404; + + Factory(Client client, Retryer retryer, List requestInterceptors, + Logger logger, Logger.Level logLevel, boolean decode404) { + 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"); + this.decode404 = decode404; + } + + 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, decode404); + } + } +} 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..2b8e74f0a5 --- /dev/null +++ b/core/src/main/java/feign/Types.java @@ -0,0 +1,467 @@ +/* + * 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 { + + 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(); + } + } + + 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. + */ + 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..46f6ec9bb6 --- /dev/null +++ b/core/src/main/java/feign/Util.java @@ -0,0 +1,328 @@ +/* + * 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.Method; +import java.lang.reflect.Modifier; +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.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; + +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) + + + /** + * Type literal for {@code Map}. + */ + public static final Type MAP_STRING_WILDCARD = + new Types.ParameterizedTypeImpl(null, Map.class, String.class, + new Types.WildcardTypeImpl(new Type[]{Object.class}, new Type[0])); + + 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)); + } + } + + /** + * Identifies a method as a default instance method. + */ + public static boolean isDefault(Method method) { + // Default methods are public non-abstract, non-synthetic, and non-static instance methods + // declared in an interface. + // method.isDefault() is not sufficient for our usage as it does not check + // for synthetic methods. As a result, it picks up overridden methods as well as actual default methods. + final int SYNTHETIC = 0x00001000; + return ((method.getModifiers() & (Modifier.ABSTRACT | Modifier.PUBLIC | Modifier.STATIC | SYNTHETIC)) == + Modifier.PUBLIC) && method.getDeclaringClass().isInterface(); + } + + /** + * 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]; + } + + /** + * This returns well known empty values for well-known java types. This returns null for types not + * in the following list. + * + *

    + *
  • {@code [Bb]oolean}
  • + *
  • {@code byte[]}
  • + *
  • {@code Collection}
  • + *
  • {@code Iterator}
  • + *
  • {@code List}
  • + *
  • {@code Map}
  • + *
  • {@code Set}
  • + *
+ * + *

When {@link Feign.Builder#decode404() decoding HTTP 404 status}, you'll need to teach + * decoders a default empty value for a type. This method cheaply supports typical types by only + * looking at the raw type (vs type hierarchy). Decorate for sophistication. + */ + public static Object emptyValueOf(Type type) { + return EMPTIES.get(Types.getRawType(type)); + } + + private static final Map, Object> EMPTIES; + static { + Map, Object> empties = new LinkedHashMap, Object>(); + empties.put(boolean.class, false); + empties.put(Boolean.class, false); + empties.put(byte[].class, new byte[0]); + empties.put(Collection.class, Collections.emptyList()); + empties.put(Iterator.class, new Iterator() { // Collections.emptyIterator is a 1.7 api + public boolean hasNext() { + return false; + } + + public Object next() { + throw new NoSuchElementException(); + } + + public void remove() { + throw new IllegalStateException(); + } + }); + empties.put(List.class, Collections.emptyList()); + empties.put(Map.class, Collections.emptyMap()); + empties.put(Set.class, Collections.emptySet()); + EMPTIES = Collections.unmodifiableMap(empties); + } + + /** + * 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; + } + + public 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..b5fdcab22d --- /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.Feign; +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}. + *

Note on exception propagation

Exceptions thrown by {@link Decoder}s get wrapped in + * a {@link DecodeException} unless they are a subclass of {@link FeignException} already, and unless + * the client was configured with {@link Feign.Builder#decode404()}. + */ +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 { + if (response.status() == 404) return Util.emptyValueOf(type); + if (response.body() == null) return null; + if (byte[].class.equals(type)) { + return Util.toByteArray(response.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..10729a081e --- /dev/null +++ b/core/src/main/java/feign/codec/Encoder.java @@ -0,0 +1,94 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import java.lang.reflect.Type; + +import feign.RequestTemplate; +import feign.Util; + +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 map. + * + *

Ex. The following is a form. Notice the parameters aren't consumed in the request line. A map + * including "username" and "password" keys will passed to the encoder, and the body type will be + * {@link #MAP_STRING_WILDCARD}. + *

+ * @RequestLine("POST /")
+ * Session login(@Param("username") String username, @Param("password") String
+ * password);
+ * 
+ */ +public interface Encoder { + /** Type literal for {@code Map}, indicating the object to encode is a form. */ + Type MAP_STRING_WILDCARD = Util.MAP_STRING_WILDCARD; + + /** + * 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. {@link #MAP_STRING_WILDCARD} + * indicates 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..3d37ffd32e --- /dev/null +++ b/core/src/main/java/feign/codec/ErrorDecoder.java @@ -0,0 +1,153 @@ +/* + * 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 implements ErrorDecoder {
+ *
+ *   @Override
+ *   public Exception decode(String methodKey, Response response) {
+ *    if (response.status() == 400)
+ *        throw new IllegalArgumentException("bad zone name");
+ *    return new ErrorDecoder.Default().decode(methodKey, 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}). + * + *

Not Found Semantics + *

It is commonly the case that 404 (Not Found) status has semantic value in HTTP apis. While + * the default behavior is to raise exeception, users can alternatively enable 404 processing via + * {@link feign.Feign.Builder#decode404()}. + */ +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..121f67a29c --- /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 okhttp3.mockwebserver.MockResponse; +import okhttp3.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.url("/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.url("/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/ContractWithRuntimeInjectionTest.java b/core/src/test/java/feign/ContractWithRuntimeInjectionTest.java new file mode 100644 index 0000000000..cd8e8d9fbe --- /dev/null +++ b/core/src/test/java/feign/ContractWithRuntimeInjectionTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2016 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 okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +import org.junit.Rule; +import org.junit.Test; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static feign.assertj.MockWebServerAssertions.assertThat; + +public class ContractWithRuntimeInjectionTest { + + static class CaseExpander implements Param.Expander { + + private final boolean lowercase; + + CaseExpander() { + this(false); + } + + CaseExpander(boolean lowercase) { + this.lowercase = lowercase; + } + + + @Override + public String expand(Object value) { + return lowercase ? value.toString().toLowerCase() : value.toString(); + } + } + + @Rule + public final MockWebServer server = new MockWebServer(); + + interface TestExpander { + + @RequestLine("GET /path?query={query}") + Response get(@Param(value = "query", expander = CaseExpander.class) String query); + } + + @Test + public void baseCaseExpanderNewInstance() throws InterruptedException { + server.enqueue(new MockResponse()); + + String baseUrl = server.url("/default").toString(); + + Feign.builder().target(TestExpander.class, baseUrl).get("FOO"); + + assertThat(server.takeRequest()).hasPath("/default/path?query=FOO"); + } + + @Configuration + static class FeignConfiguration { + + @Bean + CaseExpander lowercaseExpander() { + return new CaseExpander(true); + } + + @Bean + Contract contract(BeanFactory beanFactory) { + return new ContractWithRuntimeInjection(beanFactory); + } + } + + static class ContractWithRuntimeInjection extends Contract.Default { + final BeanFactory beanFactory; + + ContractWithRuntimeInjection(BeanFactory beanFactory) { + this.beanFactory = beanFactory; + } + + /** + * Injects {@link MethodMetadata#indexToExpander(Map)} via {@link BeanFactory#getBean(Class)}. + */ + @Override + public List parseAndValidatateMetadata(Class targetType) { + List result = super.parseAndValidatateMetadata(targetType); + for (MethodMetadata md : result) { + Map indexToExpander = new LinkedHashMap(); + for (Map.Entry> entry : md.indexToExpanderClass().entrySet()) { + indexToExpander.put(entry.getKey(), beanFactory.getBean(entry.getValue())); + } + md.indexToExpander(indexToExpander); + } + return result; + } + } + + @Test + public void contractWithRuntimeInjection() throws InterruptedException { + server.enqueue(new MockResponse()); + + String baseUrl = server.url("/default").toString(); + ApplicationContext context = new AnnotationConfigApplicationContext(FeignConfiguration.class); + + Feign.builder() + .contract(context.getBean(Contract.class)) + .target(TestExpander.class, baseUrl).get("FOO"); + + assertThat(server.takeRequest()).hasPath("/default/path?query=foo"); + } +} diff --git a/core/src/test/java/feign/DefaultContractTest.java b/core/src/test/java/feign/DefaultContractTest.java new file mode 100644 index 0000000000..911058f806 --- /dev/null +++ b/core/src/test/java/feign/DefaultContractTest.java @@ -0,0 +1,802 @@ +/* + * 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.assertj.core.api.Fail; +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 java.util.SortedMap; + +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 bodyParamWithPathParam() throws Exception { + MethodMetadata md = parseAndValidateMetadata(BodyParams.class, "post", int.class, List.class); + + assertThat(md.bodyIndex()) + .isEqualTo(1); + assertThat(md.indexToName()).containsOnly( + entry(0, asList("id")) + ); + } + + @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"))); + assertThat(md.formParams()).isEmpty(); + } + + @Test + public void headerParamsParseIntoIndexToNameNotAtStart() throws Exception { + MethodMetadata md = parseAndValidateMetadata(HeaderParamsNotAtStart.class, "logout", String.class); + + assertThat(md.template()) + .hasHeaders(entry("Authorization", asList("Bearer {authToken}", "Foo"))); + + assertThat(md.indexToName()) + .containsExactly(entry(0, asList("authToken"))); + assertThat(md.formParams()).isEmpty(); + } + + @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 queryMap() throws Exception { + MethodMetadata md = parseAndValidateMetadata(QueryMapTestInterface.class, "queryMap", Map.class); + + assertThat(md.queryMapIndex()).isEqualTo(0); + } + + @Test + public void queryMapEncodedDefault() throws Exception { + MethodMetadata md = parseAndValidateMetadata(QueryMapTestInterface.class, "queryMap", Map.class); + + assertThat(md.queryMapEncoded()).isFalse(); + } + + @Test + public void queryMapEncodedTrue() throws Exception { + MethodMetadata md = parseAndValidateMetadata(QueryMapTestInterface.class, "queryMapEncoded", Map.class); + + assertThat(md.queryMapEncoded()).isTrue(); + } + + @Test + public void queryMapEncodedFalse() throws Exception { + MethodMetadata md = parseAndValidateMetadata(QueryMapTestInterface.class, "queryMapNotEncoded", Map.class); + + assertThat(md.queryMapEncoded()).isFalse(); + } + + @Test + public void queryMapMapSubclass() throws Exception { + MethodMetadata md = parseAndValidateMetadata(QueryMapTestInterface.class, "queryMapMapSubclass", SortedMap.class); + + assertThat(md.queryMapIndex()).isEqualTo(0); + } + + @Test + public void onlyOneQueryMapAnnotationPermitted() throws Exception { + try { + parseAndValidateMetadata(QueryMapTestInterface.class, "multipleQueryMap", Map.class, Map.class); + Fail.failBecauseExceptionWasNotThrown(IllegalStateException.class); + } catch (IllegalStateException ex) { + assertThat(ex).hasMessage("QueryMap annotation was present on multiple parameters."); + } + } + + @Test + public void queryMapMustBeInstanceOfMap() throws Exception { + try { + parseAndValidateMetadata(QueryMapTestInterface.class, "nonMapQueryMap", String.class); + Fail.failBecauseExceptionWasNotThrown(IllegalStateException.class); + } catch (IllegalStateException ex) { + assertThat(ex).hasMessage("QueryMap parameter must be a Map: class java.lang.String"); + } + } + + @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(); + } + + @Test + public void onlyOneHeaderMapAnnotationPermitted() throws Exception { + try { + parseAndValidateMetadata(HeaderMapInterface.class, "multipleHeaderMap", Map.class, Map.class); + Fail.failBecauseExceptionWasNotThrown(IllegalStateException.class); + } catch (IllegalStateException ex) { + assertThat(ex).hasMessage("HeaderMap annotation was present on multiple parameters."); + } + } + + 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("PUT /offers/{id}") + void post(@Param("id") int id, 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 HeaderMapInterface { + + @RequestLine("POST /") + void multipleHeaderMap(@HeaderMap Map headers, @HeaderMap Map queries); + } + + interface HeaderParams { + + @RequestLine("POST /") + @Headers({"Auth-Token: {authToken}", "Auth-Token: Foo"}) + void logout(@Param("authToken") String token); + } + + interface HeaderParamsNotAtStart { + + @RequestLine("POST /") + @Headers({"Authorization: Bearer {authToken}", "Authorization: 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 QueryMapTestInterface { + + @RequestLine("POST /") + void queryMap(@QueryMap Map queryMap); + + @RequestLine("POST /") + void queryMapMapSubclass(@QueryMap SortedMap queryMap); + + @RequestLine("POST /") + void queryMapEncoded(@QueryMap(encoded = true) Map queryMap); + + @RequestLine("POST /") + void queryMapNotEncoded(@QueryMap(encoded = false) Map queryMap); + + // invalid + @RequestLine("POST /") + void multipleQueryMap(@QueryMap Map mapOne, @QueryMap Map mapTwo); + + // invalid + @RequestLine("POST /") + void nonMapQueryMap(@QueryMap String notAMap); + } + + 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(); + } + + @Test + public void parameterizedHeaderNotStartingWithCurlyBraceExpandApi() throws Exception { + List + md = + contract.parseAndValidatateMetadata( + ParameterizedHeaderNotStartingWithCurlyBraceExpandApi.class); + + assertThat(md).hasSize(1); + + assertThat(md.get(0).configKey()) + .isEqualTo("ParameterizedHeaderNotStartingWithCurlyBraceExpandApi#getZone(String,String)"); + assertThat(md.get(0).returnType()) + .isEqualTo(String.class); + assertThat(md.get(0).template()) + .hasHeaders(entry("Authorization", asList("Bearer {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(); + } + + @Headers("Authorization: Bearer {authHdr}") + interface ParameterizedHeaderNotStartingWithCurlyBraceExpandApi { + @RequestLine("GET /api/{zoneId}") + @Headers("Accept: application/json") + String getZone(@Param("zoneId") String vhost, @Param("authHdr") String authHdr); + } + + @Headers("Authorization: {authHdr}") + interface ParameterizedHeaderBase { + } + + interface ParameterizedHeaderExpandInheritedApi extends ParameterizedHeaderBase { + @RequestLine("GET /api/{zoneId}") + @Headers("Accept: application/json") + String getZoneAccept(@Param("zoneId") String vhost, @Param("authHdr") String authHdr); + + @RequestLine("GET /api/{zoneId}") + String getZone(@Param("zoneId") String vhost, @Param("authHdr") String authHdr); + } + + @Test + public void parameterizedHeaderExpandApiBaseClass() throws Exception { + List mds = contract.parseAndValidatateMetadata(ParameterizedHeaderExpandInheritedApi.class); + + Map byConfigKey = new LinkedHashMap(); + for (MethodMetadata m : mds) { + byConfigKey.put(m.configKey(), m); + } + + assertThat(byConfigKey) + .containsOnlyKeys("ParameterizedHeaderExpandInheritedApi#getZoneAccept(String,String)", + "ParameterizedHeaderExpandInheritedApi#getZone(String,String)"); + + MethodMetadata md = byConfigKey.get("ParameterizedHeaderExpandInheritedApi#getZoneAccept(String,String)"); + assertThat(md.returnType()) + .isEqualTo(String.class); + assertThat(md.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.formParams()) + .isEmpty(); + + md = byConfigKey.get("ParameterizedHeaderExpandInheritedApi#getZone(String,String)"); + assertThat(md.returnType()) + .isEqualTo(String.class); + assertThat(md.template()) + .hasHeaders(entry("Authorization", asList("{authHdr}"))); + assertThat(md.formParams()) + .isEmpty(); + } + + private MethodMetadata parseAndValidateMetadata(Class targetType, String method, + Class... parameterTypes) + throws NoSuchMethodException { + return contract.parseAndValidateMetadata(targetType, + targetType.getMethod(method, parameterTypes)); + } + + interface MissingMethod { + @RequestLine("/path?queryParam={queryParam}") + Response updateSharing(@Param("queryParam") long queryParam, String bodyParam); + } + + /** Let's help folks not lose time when they mistake request line for a URI! */ + @Test + public void missingMethod() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("RequestLine annotation didn't start with an HTTP verb on method updateSharing"); + + contract.parseAndValidatateMetadata(MissingMethod.class); + } + + interface StaticMethodOnInterface { + @RequestLine("GET /api/{key}") + String get(@Param("key") String key); + + static String staticMethod() { + return "value"; + } + } + + @Test + public void staticMethodsOnInterfaceIgnored() throws Exception { + List mds = contract.parseAndValidatateMetadata(StaticMethodOnInterface.class); + assertThat(mds).hasSize(1); + MethodMetadata md = mds.get(0); + assertThat(md.configKey()).isEqualTo("StaticMethodOnInterface#get(String)"); + } + + interface DefaultMethodOnInterface { + @RequestLine("GET /api/{key}") + String get(@Param("key") String key); + + default String defaultGet(String key) { + return get(key); + } + } + + @Test + public void defaultMethodsOnInterfaceIgnored() throws Exception { + List mds = contract.parseAndValidatateMetadata(DefaultMethodOnInterface.class); + assertThat(mds).hasSize(1); + MethodMetadata md = mds.get(0); + assertThat(md.configKey()).isEqualTo("DefaultMethodOnInterface#get(String)"); + } + + interface SubstringQuery { + @RequestLine("GET /_search?q=body:{body}") + String paramIsASubstringOfAQuery(@Param("body") String body); + } + + @Test + public void paramIsASubstringOfAQuery() throws Exception { + List mds = contract.parseAndValidatateMetadata(SubstringQuery.class); + + assertThat(mds.get(0).template().queries()).containsExactly( + entry("q", asList("body:{body}")) + ); + assertThat(mds.get(0).formParams()).isEmpty(); // Prevent issue 424 + } +} 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..8d5823fe43 --- /dev/null +++ b/core/src/test/java/feign/FeignBuilderTest.java @@ -0,0 +1,272 @@ +/* + * 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 okhttp3.mockwebserver.MockResponse; +import okhttp3.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.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +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"); + } + + /** Shows exception handling isn't required to coerce 404 to null or empty */ + @Test + public void testDecode404() throws Exception { + server.enqueue(new MockResponse().setResponseCode(404)); + server.enqueue(new MockResponse().setResponseCode(404)); + server.enqueue(new MockResponse().setResponseCode(400)); + + String url = "http://localhost:" + server.getPort(); + TestInterface api = Feign.builder().decode404().target(TestInterface.class, url); + + assertThat(api.getQueues("/")).isEmpty(); // empty, not null! + assertThat(api.decodedPost()).isNull(); // null, not empty! + + try { // ensure other 400 codes are not impacted. + api.decodedPost(); + failBecauseExceptionWasNotThrown(FeignException.class); + } catch (FeignException e) { + assertThat(e.status()).isEqualTo(400); + } + } + + @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"); + } + + @Test + public void testBasicDefaultMethod() throws Exception { + String url = "http://localhost:" + server.getPort(); + + TestInterface api = Feign.builder().target(TestInterface.class, url); + String result = api.independentDefaultMethod(); + + assertThat(result.equals("default result")); + } + + @Test + public void testDefaultCallingProxiedMethod() 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.defaultMethodPassthrough(); + assertEquals("response data", Util.toString(response.body().asReader())); + assertThat(server.takeRequest()).hasPath("/"); + } + + 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) + byte[] getQueues(@Param("vhost") String vhost); + + default String independentDefaultMethod() { + return "default result"; + } + + default Response defaultMethodPassthrough() { + return getNoPath(); + } + } +} diff --git a/core/src/test/java/feign/FeignTest.java b/core/src/test/java/feign/FeignTest.java new file mode 100644 index 0000000000..15f92173d5 --- /dev/null +++ b/core/src/test/java/feign/FeignTest.java @@ -0,0 +1,816 @@ +/* + * 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 okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.SocketPolicy; +import okhttp3.mockwebserver.MockWebServer; + +import java.util.Collection; +import java.util.LinkedHashMap; +import okio.Buffer; +import org.assertj.core.api.Fail; +import org.assertj.core.data.MapEntry; +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.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +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.hamcrest.CoreMatchers.isA; +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 customExpanderListParam() throws Exception { + server.enqueue(new MockResponse()); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + api.expandList(Arrays.asList(new Date(1234l), new Date(12345l))); + + assertThat(server.takeRequest()) + .hasPath("/?date=1234&date=12345"); + } + + @Test + public void customExpanderNullParam() throws Exception { + server.enqueue(new MockResponse()); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + api.expandList(Arrays.asList(new Date(1234l), null)); + + assertThat(server.takeRequest()) + .hasPath("/?date=1234"); + } + + @Test + public void headerMap() throws Exception { + server.enqueue(new MockResponse()); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + Map headerMap = new LinkedHashMap(); + headerMap.put("Content-Type", "myContent"); + headerMap.put("Custom-Header", "fooValue"); + api.headerMap(headerMap); + + assertThat(server.takeRequest()) + .hasHeaders( + MapEntry.entry("Content-Type", Arrays.asList("myContent")), + MapEntry.entry("Custom-Header", Arrays.asList("fooValue"))); + } + + @Test + public void headerMapWithHeaderAnnotations() throws Exception { + server.enqueue(new MockResponse()); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + Map headerMap = new LinkedHashMap(); + headerMap.put("Custom-Header", "fooValue"); + api.headerMapWithHeaderAnnotations(headerMap); + + // header map should be additive for headers provided by annotations + assertThat(server.takeRequest()) + .hasHeaders( + MapEntry.entry("Content-Encoding", Arrays.asList("deflate")), + MapEntry.entry("Custom-Header", Arrays.asList("fooValue"))); + + server.enqueue(new MockResponse()); + headerMap.put("Content-Encoding", "overrideFromMap"); + + api.headerMapWithHeaderAnnotations(headerMap); + + // if header map has entry that collides with annotation, value specified + // by header map should be used + assertThat(server.takeRequest()) + .hasHeaders( + MapEntry.entry("Content-Encoding", Arrays.asList("overrideFromMap")), + MapEntry.entry("Custom-Header", Arrays.asList("fooValue"))); + } + + @Test + public void queryMap() throws Exception { + server.enqueue(new MockResponse()); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + Map queryMap = new LinkedHashMap(); + queryMap.put("name", "alice"); + queryMap.put("fooKey", "fooValue"); + api.queryMap(queryMap); + + assertThat(server.takeRequest()) + .hasPath("/?name=alice&fooKey=fooValue"); + } + + @Test + public void queryMapIterableValuesExpanded() throws Exception { + server.enqueue(new MockResponse()); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + Map queryMap = new LinkedHashMap(); + queryMap.put("name", Arrays.asList("Alice", "Bob")); + queryMap.put("fooKey", "fooValue"); + queryMap.put("emptyListKey", new ArrayList()); + queryMap.put("emptyStringKey", ""); + api.queryMap(queryMap); + + assertThat(server.takeRequest()) + .hasPath("/?name=Alice&name=Bob&fooKey=fooValue&emptyStringKey="); + } + + @Test + public void queryMapWithQueryParams() throws Exception { + TestInterface api = new TestInterfaceBuilder() + .target("http://localhost:" + server.getPort()); + + server.enqueue(new MockResponse()); + Map queryMap = new LinkedHashMap(); + queryMap.put("fooKey", "fooValue"); + api.queryMapWithQueryParams("alice", queryMap); + // query map should be expanded after built-in parameters + assertThat(server.takeRequest()) + .hasPath("/?name=alice&fooKey=fooValue"); + + server.enqueue(new MockResponse()); + queryMap = new LinkedHashMap(); + queryMap.put("name", "bob"); + api.queryMapWithQueryParams("alice", queryMap); + // query map keys take precedence over built-in parameters + assertThat(server.takeRequest()) + .hasPath("/?name=bob"); + + server.enqueue(new MockResponse()); + queryMap = new LinkedHashMap(); + queryMap.put("name", null); + api.queryMapWithQueryParams("alice", queryMap); + // null value for a query map key removes query parameter + assertThat(server.takeRequest()) + .hasPath("/"); + } + + @Test + public void queryMapKeysMustBeStrings() throws Exception { + server.enqueue(new MockResponse()); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + Map queryMap = new LinkedHashMap(); + queryMap.put(Integer.valueOf(42), "alice"); + + try { + api.queryMap((Map) queryMap); + Fail.failBecauseExceptionWasNotThrown(IllegalStateException.class); + } catch (IllegalStateException ex) { + assertThat(ex).hasMessage("QueryMap key must be a String: 42"); + } + } + + @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(400).setBody("foo")); + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("bad zone name"); + + TestInterface api = new TestInterfaceBuilder() + .errorDecoder(new IllegalArgumentExceptionOn400()) + .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.post(); + api.post(); // if retryer instance was reused, this statement will throw an exception + assertEquals(4, server.getRequestCount()); + } + + @Test + public void whenReturnTypeIsResponseNoErrorHandling() { + Map> headers = new LinkedHashMap>(); + headers.put("Location", Arrays.asList("http://bar.com")); + final Response response = Response.builder() + .status(302) + .reason("Found") + .headers(headers) + .body(new byte[0]) + .build(); + + TestInterface api = Feign.builder() + .client(new Client() { // fake client as Client.Default follows redirects. + public Response execute(Request request, Request.Options options) { + return response; + } + }) + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + assertEquals(api.response().headers().get("Location"), Arrays.asList("http://bar.com")); + } + + 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 decodingExceptionGetWrappedInDecode404Mode() throws Exception { + server.enqueue(new MockResponse().setResponseCode(404)); + thrown.expect(DecodeException.class); + thrown.expectCause(isA(NoSuchElementException.class));; + + TestInterface api = new TestInterfaceBuilder() + .decode404() + .decoder(new Decoder() { + @Override + public Object decode(Response response, Type type) throws IOException { + assertEquals(404, response.status()); + throw new NoSuchElementException(); + } + }).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); + } + + @Test + public void encodedQueryParam() throws Exception { + server.enqueue(new MockResponse()); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + api.encodedQueryParam("5.2FSi+"); + + assertThat(server.takeRequest()) + .hasPath("/?trim=5.2FSi+"); + } + + 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); + + @RequestLine("GET /?date={date}") + void expandList(@Param(value = "date", expander = DateToMillis.class) List dates); + + @RequestLine("GET /?date={date}") + void expandArray(@Param(value = "date", expander = DateToMillis.class) Date[] dates); + + @RequestLine("GET /") + void headerMap(@HeaderMap Map headerMap); + + @RequestLine("GET /") + @Headers("Content-Encoding: deflate") + void headerMapWithHeaderAnnotations(@HeaderMap Map headerMap); + + @RequestLine("GET /") + void queryMap(@QueryMap Map queryMap); + + @RequestLine("GET /?name={name}") + void queryMapWithQueryParams(@Param("name") String name, @QueryMap Map queryMap); + + @RequestLine("GET /?trim={trim}") + void encodedQueryParam(@Param(value = "trim", encoded = true) String trim); + + 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 IllegalArgumentExceptionOn400 extends ErrorDecoder.Default { + + @Override + public Exception decode(String methodKey, Response response) { + if (response.status() == 400) { + return new IllegalArgumentException("bad zone name"); + } + 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; + } + + TestInterfaceBuilder decode404() { + delegate.decode404(); + 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..d5eb8e1008 --- /dev/null +++ b/core/src/test/java/feign/LoggerTest.java @@ -0,0 +1,344 @@ +/* + * 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 okhttp3.mockwebserver.MockResponse; +import okhttp3.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.getPort()); + + api.login("netflix", "denominator", "password"); + } + } + + @RunWith(Parameterized.class) + public static class ReasonPhraseOptional extends LoggerTest { + + private final Level logLevel; + + public ReasonPhraseOptional(Level logLevel, List expectedMessages) { + this.logLevel = logLevel; + logger.expectMessages(expectedMessages); + } + + @Parameters + public static Iterable data() { + return Arrays.asList(new Object[][]{ + {Level.BASIC, Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://localhost:[0-9]+/ HTTP/1.1", + "\\[SendsStuff#login\\] <--- HTTP/1.1 200 \\([0-9]+ms\\)")}, + }); + } + + @Test + public void reasonPhraseOptional() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setStatus("HTTP/1.1 " + 200)); + + SendsStuff api = Feign.builder() + .logger(logger) + .logLevel(logLevel) + .target(SendsStuff.class, "http://localhost:" + server.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.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..da752b0cb9 --- /dev/null +++ b/core/src/test/java/feign/RequestTemplateTest.java @@ -0,0 +1,365 @@ +/* + * 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.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 { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + /** + * Avoid depending on guava solely for map literals. + */ + private static Map mapOf(K key, V val) { + Map result = new LinkedHashMap(); + result.put(key, val); + return result; + } + + private static Map mapOf(K k1, V v1, K k2, V v2) { + Map result = mapOf(k1, v1); + result.put(k2, v2); + return result; + } + + private static Map mapOf(K k1, V v1, K k2, V v2, K k3, V 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 resolveTemplateWithHeaderSubstitutionsNotAtStart() { + RequestTemplate template = new RequestTemplate().method("GET") + .header("Authorization", "Bearer {token}"); + + template.resolve(mapOf("token", "1234")); + + assertThat(template) + .hasHeaders(entry("Authorization", asList("Bearer 1234"))); + } + + @Test + public void resolveTemplateWithHeaderWithEscapedCurlyBrace() { + RequestTemplate template = new RequestTemplate().method("GET") + .header("Encoded", "{{{{dont_expand_me}}"); + + template.resolve(mapOf("dont_expand_me", "1234")); + + assertThat(template) + .hasHeaders(entry("Encoded", asList("{{dont_expand_me}}"))); + } + + /** This ensures we don't mess up vnd types */ + @Test + public void resolveTemplateWithHeaderIncludingSpecialCharacters() { + RequestTemplate template = new RequestTemplate().method("GET") + .header("Accept", "application/vnd.github.v3+{type}"); + + template.resolve(mapOf("type", "json")); + + assertThat(template) + .hasHeaders(entry("Accept", asList("application/vnd.github.v3+json"))); + } + + @Test + public void resolveTemplateWithHeaderEmptyResult() { + RequestTemplate template = new RequestTemplate().method("GET") + .header("Encoded", "{var}"); + + template.resolve(mapOf("var", "")); + + assertThat(template) + .hasHeaders(entry("Encoded", asList(""))); + } + + @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 resolveTemplateWithBodyTemplateDoesNotDoubleDecode() { + 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", "abc+123%25d8" + ) + ); + + assertThat(template) + .hasBody( + "{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"abc+123%25d8\"}" + ); + } + + @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"); + } + + /** Implementations have a bug if they pass junk as the http method. */ + @Test + public void uriStuffedIntoMethod() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Invalid HTTP Method: /path?queryParam={queryParam}"); + + new RequestTemplate().method("/path?queryParam={queryParam}"); + } + + @Test + public void encodedQueryClearedOnNull() throws Exception { + RequestTemplate template = new RequestTemplate(); + + template.query("param[]", "value"); + assertThat(template).hasQueries(entry("param[]", asList("value"))); + + template.query("param[]", (String[]) null); + assertThat(template.queries()).isEmpty(); + } + + @Test + public void encodedQuery() throws Exception { + RequestTemplate template = new RequestTemplate().query(true, "params[]", "foo%20bar"); + + assertThat(template.queryLine()).isEqualTo("?params[]=foo%20bar"); + assertThat(template).hasQueries(entry("params[]", asList("foo bar"))); + } + + @Test + public void encodedQueryWithUnsafeCharactersMixedWithUnencoded() throws Exception { + RequestTemplate template = new RequestTemplate() + .query(false, "params[]", "not encoded") // stored as "param%5D%5B" + .query(true, "params[]", "encoded"); // stored as "param[]" + + // We can't ensure consistent behavior, because decode("param[]") == decode("param%5B%5D") + assertThat(template.queryLine()).isEqualTo("?params%5B%5D=not+encoded¶ms[]=encoded"); + assertThat(template.queries()).doesNotContain(entry("params[]", asList("not encoded"))); + assertThat(template.queries()).contains(entry("params[]", asList("encoded"))); + } +} diff --git a/core/src/test/java/feign/ResponseTest.java b/core/src/test/java/feign/ResponseTest.java new file mode 100644 index 0000000000..a81ab4aa6f --- /dev/null +++ b/core/src/test/java/feign/ResponseTest.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; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static feign.assertj.FeignAssertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +public class ResponseTest { + + @Test + public void reasonPhraseIsOptional() { + Response response = Response.builder() + .status(200) + .headers(Collections.>emptyMap()) + .body(new byte[0]) + .build(); + + assertThat(response.reason()).isNull(); + assertThat(response.toString()).isEqualTo("HTTP/1.1 200\n\n"); + } + + @Test + public void canAccessHeadersCaseInsensitively() { + Map> headersMap = new LinkedHashMap(); + List valueList = Collections.singletonList("application/json"); + headersMap.put("Content-Type", valueList); + Response response = Response.builder() + .status(200) + .headers(headersMap) + .body(new byte[0]) + .build(); + assertThat(response.headers().get("content-type")).isEqualTo(valueList); + assertThat(response.headers().get("Content-Type")).isEqualTo(valueList); + } + + @Test + public void headerValuesWithSameNameOnlyVaryingInCaseAreMerged() { + Map> headersMap = new LinkedHashMap(); + headersMap.put("Set-Cookie", Arrays.asList("Cookie-A=Value", "Cookie-B=Value")); + headersMap.put("set-cookie", Arrays.asList("Cookie-C=Value")); + + Response response = Response.builder() + .status(200) + .headers(headersMap) + .body(new byte[0]) + .build(); + + List expectedHeaderValue = Arrays.asList("Cookie-A=Value", "Cookie-B=Value", "Cookie-C=Value"); + assertThat(response.headers()).containsOnly(entry(("set-cookie"), expectedHeaderValue)); + } +} diff --git a/core/src/test/java/feign/RetryerTest.java b/core/src/test/java/feign/RetryerTest.java new file mode 100644 index 0000000000..fa6dc9a3d6 --- /dev/null +++ b/core/src/test/java/feign/RetryerTest.java @@ -0,0 +1,77 @@ +/* + * 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 RetryerTest { + + @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); + } + + @Test(expected = RetryableException.class) + public void neverRetryAlwaysPropagates() { + Retryer.NEVER_RETRY.continueOrPropagate(new RetryableException(null, null, new Date(5000))); + } +} diff --git a/core/src/test/java/feign/TargetTest.java b/core/src/test/java/feign/TargetTest.java new file mode 100644 index 0000000000..118875ef2b --- /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 okhttp3.mockwebserver.MockResponse; +import okhttp3.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.url("/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.url("/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..ed6720f24a --- /dev/null +++ b/core/src/test/java/feign/UtilTest.java @@ -0,0 +1,134 @@ +/* + * 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.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import feign.codec.Decoder; + +import static feign.Util.resolveLastTypeParameter; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; + +public class UtilTest { + + @Test + public void emptyValueOf() throws Exception { + assertEquals(false, Util.emptyValueOf(boolean.class)); + assertEquals(false, Util.emptyValueOf(Boolean.class)); + assertThat((byte[]) Util.emptyValueOf(byte[].class)).isEmpty(); + assertEquals(Collections.emptyList(), Util.emptyValueOf(Collection.class)); + assertThat((Iterator) Util.emptyValueOf(Iterator.class)).isEmpty(); + assertEquals(Collections.emptyList(), Util.emptyValueOf(List.class)); + assertEquals(Collections.emptyMap(), Util.emptyValueOf(Map.class)); + assertEquals(Collections.emptySet(), Util.emptyValueOf(Set.class)); + } + + /** In other words, {@code List} is as empty as {@code List}. */ + @Test + public void emptyValueOf_considersRawType() throws Exception { + Type listStringType = LastTypeParameter.class.getDeclaredField("LIST_STRING").getGenericType(); + assertThat((List) Util.emptyValueOf(listStringType)).isEmpty(); + } + + /** Ex. your {@code Foo} object would be null, but so would things like Number. */ + @Test + public void emptyValueOf_nullForUndefined() throws Exception { + assertThat(Util.emptyValueOf(Number.class)).isNull(); + assertThat(Util.emptyValueOf(Parameterized.class)).isNull(); + } + + @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..e3fee7dae6 --- /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 okhttp3.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..a34b73d6ea --- /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 okhttp3.Headers; +import okhttp3.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/AbstractClientTest.java b/core/src/test/java/feign/client/AbstractClientTest.java new file mode 100644 index 0000000000..704d88b425 --- /dev/null +++ b/core/src/test/java/feign/client/AbstractClientTest.java @@ -0,0 +1,275 @@ +package feign.client; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import feign.Client; +import feign.Feign.Builder; +import feign.FeignException; +import feign.Headers; +import feign.Logger; +import feign.Param; +import feign.RequestLine; +import feign.Response; +import feign.Util; +import feign.assertj.MockWebServerAssertions; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +import static java.util.Arrays.asList; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; + +import static feign.Util.UTF_8; + +/** + * {@link AbstractClientTest} can be extended to run a set of tests against any {@link Client} implementation. + */ +public abstract class AbstractClientTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + @Rule + public final MockWebServer server = new MockWebServer(); + + /** + * Create a Feign {@link Builder} with a client configured + */ + public abstract Builder newBuilder(); + + /** + * Some client implementation tests should override this + * test if the PATCH operation is unsupported. + */ + @Test + public void testPatch() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + server.enqueue(new MockResponse()); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + assertEquals("foo", api.patch("")); + + MockWebServerAssertions.assertThat(server.takeRequest()) + .hasHeaders("Accept: text/plain", "Content-Length: 0") // Note: OkHttp adds content length. + .hasNoHeaderNamed("Content-Type") + .hasMethod("PATCH"); + } + + @Test + public void parsesRequestAndResponse() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setBody("foo").addHeader("Foo: Bar")); + + TestInterface api = newBuilder() + .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))); + + MockWebServerAssertions.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 reasonPhraseIsOptional() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setStatus("HTTP/1.1 " + 200)); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Response response = api.post("foo"); + + assertThat(response.status()).isEqualTo(200); + assertThat(response.reason()).isNullOrEmpty(); + } + + @Test + public void parsesErrorResponse() throws IOException, InterruptedException { + thrown.expect(FeignException.class); + thrown.expectMessage("status 500 reading TestInterface#get(); content:\n" + "ARGHH"); + + server.enqueue(new MockResponse().setResponseCode(500).setBody("ARGHH")); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.get(); + } + + @Test + public void safeRebuffering() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = newBuilder() + .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 = newBuilder() + .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"); + } + + @Test + public void noResponseBodyForPost() { + server.enqueue(new MockResponse()); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.noPostBody(); + } + + @Test + public void noResponseBodyForPut() { + server.enqueue(new MockResponse()); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.noPutBody(); + } + + @Test + public void parsesResponseMissingLength() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setChunkedBody("foo", 1)); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Response response = api.post("testing"); + assertThat(response.status()).isEqualTo(200); + assertThat(response.reason()).isEqualTo("OK"); + assertThat(response.body().length()).isNull(); + assertThat(response.body().asInputStream()) + .hasContentEqualTo(new ByteArrayInputStream("foo".getBytes(UTF_8))); + } + + @Test + public void postWithSpacesInPath() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Response response = api.post("current documents", "foo"); + + MockWebServerAssertions.assertThat(server.takeRequest()).hasMethod("POST") + .hasPath("/path/current%20documents/resource") + .hasBody("foo"); + } + + @Test + public void testVeryLongResponseNullLength() throws Exception { + server.enqueue(new MockResponse() + .setBody("AAAAAAAA") + .addHeader("Content-Length", Long.MAX_VALUE)); + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Response response = api.post("foo"); + // Response length greater than Integer.MAX_VALUE should be null + assertThat(response.body().length()).isNull(); + } + + @Test + public void testResponseLength() throws Exception { + server.enqueue(new MockResponse() + .setBody("test")); + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Integer expected = 4; + Response response = api.post(""); + Integer actual = response.body().length(); + assertEquals(expected, actual); + } + + @Test + public void testContentTypeWithCharset() throws Exception { + server.enqueue(new MockResponse() + .setBody("AAAAAAAA")); + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Response response = api.postWithContentType("foo", "text/plain;charset=utf-8"); + // Response length should not be null + assertEquals("AAAAAAAA", Util.toString(response.body().asReader())); + } + + @Test + public void testContentTypeWithoutCharset() throws Exception { + server.enqueue(new MockResponse() + .setBody("AAAAAAAA")); + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Response response = api.postWithContentType("foo", "text/plain"); + // Response length should not be null + assertEquals("AAAAAAAA", Util.toString(response.body().asReader())); + } + + public interface TestInterface { + + @RequestLine("POST /?foo=bar&foo=baz&qux=") + @Headers({"Foo: Bar", "Foo: Baz", "Qux: ", "Content-Type: text/plain"}) + Response post(String body); + + @RequestLine("POST /path/{to}/resource") + @Headers("Accept: text/plain") + Response post(@Param("to") String to, String body); + + @RequestLine("GET /") + @Headers("Accept: text/plain") + String get(); + + @RequestLine("PATCH /") + @Headers("Accept: text/plain") + String patch(String body); + + @RequestLine("POST") + String noPostBody(); + + @RequestLine("PUT") + String noPutBody(); + + @RequestLine("POST /?foo=bar&foo=baz&qux=") + @Headers({"Foo: Bar", "Foo: Baz", "Qux: ", "Content-Type: {contentType}"}) + Response postWithContentType(String body, @Param("contentType") String contentType); + } + +} 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..f90d7a70ed --- /dev/null +++ b/core/src/test/java/feign/client/DefaultClientTest.java @@ -0,0 +1,105 @@ +/* + * 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 java.io.IOException; +import java.net.ProtocolException; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; + +import org.junit.Test; + +import feign.Client; +import feign.Feign; +import feign.Feign.Builder; +import feign.RetryableException; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.SocketPolicy; + +import static org.hamcrest.core.Is.isA; +import static org.junit.Assert.assertEquals; + +/** + * Tests client-specific behavior, such as ensuring Content-Length is sent when specified. + */ +public class DefaultClientTest extends AbstractClientTest { + + Client disableHostnameVerification = + new Client.Default(TrustingSSLSocketFactory.get(), new HostnameVerifier() { + @Override + public boolean verify(String s, SSLSession sslSession) { + return true; + } + }); + + @Override + public Builder newBuilder() { + return Feign.builder().client(new Client.Default(TrustingSSLSocketFactory.get(), null)); + } + + @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 = newBuilder() + .target(TestInterface.class, "https://localhost:" + server.getPort()); + + api.post("foo"); + assertEquals(2, server.getRequestCount()); + } + + @Test + public void canOverrideSSLSocketFactory() throws IOException, InterruptedException { + server.useHttps(TrustingSSLSocketFactory.get("localhost"), false); + server.enqueue(new MockResponse()); + + TestInterface api = newBuilder() + .target(TestInterface.class, "https://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 + @Override + public void testPatch() throws Exception { + thrown.expect(RetryableException.class); + thrown.expectCause(isA(ProtocolException.class)); + super.testPatch(); + } + + + @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"); + } + +} 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..21740d3046 --- /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_3DES_EDE_CBC_SHA"}; + 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..103081221b --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultDecoderTest.java @@ -0,0 +1,92 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import 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.builder() + .status(200) + .reason("OK") + .headers(headers) + .body(inputStream, content.length()) + .build(); + } + + private Response nullBodyResponse() { + return Response.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .build(); + } +} 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..e2969dfa22 --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java @@ -0,0 +1,100 @@ +/* + * 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; +import static org.assertj.core.api.Assertions.assertThat; + +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.builder() + .status(500) + .reason("Internal server error") + .headers(headers) + .build(); + + 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.builder() + .status(500) + .reason("Internal server error") + .headers(headers) + .body("hello world", UTF_8) + .build(); + + throw errorDecoder.decode("Service#foo()", response); + } + + @Test + public void testFeignExceptionIncludesStatus() throws Throwable { + Response response = Response.builder() + .status(400) + .reason("Bad request") + .headers(headers) + .build(); + + Exception exception = errorDecoder.decode("Service#foo()", response); + + assertThat(exception).isInstanceOf(FeignException.class); + assertThat(((FeignException) exception).status()).isEqualTo(400); + } + + @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.builder() + .status(503) + .reason("Service Unavailable") + .headers(headers) + .build(); + + 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..aff5fe80f2 --- /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 'io.github.openfeign:feign-core:9.0.0' + compile 'io.github.openfeign:feign-gson:9.0.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..68053be38c --- /dev/null +++ b/example-github/pom.xml @@ -0,0 +1,75 @@ + + + 4.0.0 + + io.github.openfeign + feign-example-github + jar + 9.0.0 + GitHub Example + + + + io.github.openfeign + feign-core + ${project.version} + + + io.github.openfeign + feign-gson + ${project.version} + + + + + package + + + org.apache.maven.plugins + maven-shade-plugin + 2.4.3 + + + package + + shade + + + + + feign.example.github.GitHubExample + + + false + + + + + + org.skife.maven + really-executable-jar-maven-plugin + 1.5.0 + + github + + + + package + + really-executable-jar + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + + + + 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..5f92a3ad1b --- /dev/null +++ b/example-github/src/main/java/feign/example/github/GitHubExample.java @@ -0,0 +1,116 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.example.github; + +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; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Inspired by {@code com.example.retrofit.GitHubClient} + */ +public class GitHubExample { + + interface GitHub { + + class Repository { + String name; + } + + class Contributor { + String login; + } + + @RequestLine("GET /users/{username}/repos?sort=full_name") + List repos(@Param("username") String owner); + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + + /** Lists all contributors for all repos owned by a user. */ + default List contributors(String owner) { + return repos(owner).stream() + .flatMap(repo -> contributors(owner, repo.name).stream()) + .map(c -> c.login) + .distinct() + .collect(Collectors.toList()); + } + + static GitHub connect() { + Decoder decoder = new GsonDecoder(); + return Feign.builder() + .decoder(decoder) + .errorDecoder(new GitHubErrorDecoder(decoder)) + .logger(new Logger.ErrorLogger()) + .logLevel(Logger.Level.BASIC) + .target(GitHub.class, "https://api.github.com"); + } + } + + + static class GitHubClientError extends RuntimeException { + private String message; // parsed from json + + @Override + public String getMessage() { + return message; + } + } + + public static void main(String... args) { + GitHub github = GitHub.connect(); + + System.out.println("Let's fetch and print a list of the contributors to this org."); + List contributors = github.contributors("netflix"); + for (String contributor : contributors) { + System.out.println(contributor); + } + + 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..e2e1948385 --- /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 'io.github.openfeign:feign-core:9.0.0' + compile 'io.github.openfeign:feign-gson:9.0.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..13935636f9 --- /dev/null +++ b/example-wikipedia/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + io.github.openfeign + feign-example-wikipedia + jar + 9.0.0 + Wikipedia Example + + + + io.github.openfeign + feign-core + ${project.version} + + + io.github.openfeign + feign-gson + ${project.version} + + + + + package + + + org.apache.maven.plugins + maven-shade-plugin + 2.4.3 + + + package + + shade + + + + + feign.example.wikipedia.WikipediaExample + + + false + + + + + + org.skife.maven + really-executable-jar-maven-plugin + 1.5.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/gson/README.md b/gson/README.md new file mode 100644 index 0000000000..d26c16470d --- /dev/null +++ b/gson/README.md @@ -0,0 +1,20 @@ +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"); +``` + +### Map and Numbers +The default constructors of `GsonEncoder` and `GsonDecoder` decoder numbers in +`Map` as Integer type. This prevents reading `{"counter", "1"}` +as `Map.of("counter", 1.0)`. + +To change this, please use constructors that accept a Gson object. diff --git a/gson/build.gradle b/gson/build.gradle new file mode 100644 index 0000000000..778a7d0174 --- /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.5' + 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/pom.xml b/gson/pom.xml new file mode 100644 index 0000000000..dba9764019 --- /dev/null +++ b/gson/pom.xml @@ -0,0 +1,36 @@ + + 4.0.0 + + + io.github.openfeign + parent + 9.4.1-SNAPSHOT + + + feign-gson + Feign Gson + Feign Gson + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + com.google.code.gson + gson + + + + ${project.groupId} + feign-core + test-jar + test + + + 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..77ec9471d3 --- /dev/null +++ b/gson/src/main/java/feign/gson/DoubleToIntMapTypeAdapter.java @@ -0,0 +1,49 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.gson; + +import com.google.gson.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 java.io.IOException; +import java.util.Map; + +/** + * Deals with scenario where Gson Object type treats all numbers as doubles. + */ +public class DoubleToIntMapTypeAdapter extends TypeAdapter> { + private final TypeAdapter> delegate = + new Gson().getAdapter(new TypeToken>() { + }); + + @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..c56c73f5fe --- /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.Util; +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.status() == 404) return Util.emptyValueOf(type); + 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..bbb2cfb460 --- /dev/null +++ b/gson/src/test/java/feign/gson/GsonCodecTest.java @@ -0,0 +1,239 @@ +/* + * 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.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .body("{\"foo\": 1}", UTF_8) + .build(); + 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.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .body(zonesJson, UTF_8) + .build(); + assertEquals(zones, new GsonDecoder().decode(response, new TypeToken>() { + }.getType())); + } + + @Test + public void nullBodyDecodesToNull() throws Exception { + Response response = Response.builder() + .status(204) + .reason("OK") + .headers(Collections.>emptyMap()) + .build(); + assertNull(new GsonDecoder().decode(response, String.class)); + } + + @Test + public void emptyBodyDecodesToNull() throws Exception { + Response response = Response.builder() + .status(204) + .reason("OK") + .headers(Collections.>emptyMap()) + .body(new byte[0]) + .build(); + 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.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .body(zonesJson, UTF_8) + .build(); + 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" // + + "]"); + } + + /** Enabled via {@link feign.Feign.Builder#decode404()} */ + @Test + public void notFoundDecodesToEmpty() throws Exception { + Response response = Response.builder() + .status(404) + .reason("NOT FOUND") + .headers(Collections.>emptyMap()) + .build(); + assertThat((byte[]) new GsonDecoder().decode(response, byte[].class)).isEmpty(); + } +} 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..bcdf2e1bd8 --- /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.1' + testCompile 'junit:junit:4.12' + testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7 + testCompile 'com.squareup.okhttp3:mockwebserver:3.2.0' + testCompile project(':feign-core').sourceSets.test.output // for assertions +} diff --git a/httpclient/pom.xml b/httpclient/pom.xml new file mode 100644 index 0000000000..103862b493 --- /dev/null +++ b/httpclient/pom.xml @@ -0,0 +1,43 @@ + + 4.0.0 + + + io.github.openfeign + parent + 9.4.1-SNAPSHOT + + + feign-httpclient + Feign Apache HttpClient + Feign Apache HttpClient + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + org.apache.httpcomponents + httpclient + 4.5.1 + + + + ${project.groupId} + feign-core + test-jar + test + + + + com.squareup.okhttp3 + mockwebserver + test + + + 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..18fbc7512e --- /dev/null +++ b/httpclient/src/main/java/feign/httpclient/ApacheHttpClient.java @@ -0,0 +1,227 @@ +/* + * 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.ContentType; +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).toBuilder().request(request).build();
+  }
+
+  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();
+
+    requestBuilder.setUri(uri.getScheme() + "://" + uri.getAuthority() + uri.getRawPath());
+
+    //request query params
+    List queryParams = URLEncodedUtils.parse(uri, requestBuilder.getCharset().name());
+    for (NameValuePair queryParam: queryParams) {
+      requestBuilder.addParameter(queryParam);
+    }
+
+    //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)) {
+        // The 'Content-Length' header is always set by the Apache client and it
+        // doesn't like us to set it as well.
+        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, "*/*");
+    }
+
+    //request body
+    if (request.body() != null) {
+      HttpEntity entity = null;
+      if (request.charset() != null) {
+        ContentType contentType = getContentType(request);
+        String content = new String(request.body(), request.charset());
+        entity = new StringEntity(content, contentType);
+      } else {
+        entity = new ByteArrayEntity(request.body());
+      }
+
+      requestBuilder.setEntity(entity);
+    }
+
+    return requestBuilder.build();
+  }
+
+  private ContentType getContentType(Request request) {
+    ContentType contentType = ContentType.DEFAULT_TEXT;
+    for (Map.Entry> entry : request.headers().entrySet())
+      if (entry.getKey().equalsIgnoreCase("Content-Type")) {
+        Collection values = entry.getValue();
+        if (values != null && !values.isEmpty()) {
+          contentType = ContentType.parse(values.iterator().next());
+          break;
+        }
+      }
+    return contentType;
+  }
+
+  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.builder()
+            .status(statusCode)
+            .reason(reason)
+            .headers(headers)
+            .body(toFeignBody(httpResponse))
+            .build();
+  }
+
+  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 && entity.getContentLength() <= Integer.MAX_VALUE ?
+                (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..7ba8a28d93
--- /dev/null
+++ b/httpclient/src/test/java/feign/httpclient/ApacheHttpClientTest.java
@@ -0,0 +1,31 @@
+/*
+ * 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 feign.Feign;
+import feign.Feign.Builder;
+import feign.client.AbstractClientTest;
+
+/**
+ * Tests client-specific behavior, such as ensuring Content-Length is sent when specified.
+ */
+public class ApacheHttpClientTest extends AbstractClientTest {
+
+    @Override
+    public Builder newBuilder() {
+        return Feign.builder().client(new ApacheHttpClient());
+    }
+}
diff --git a/hystrix/README.md b/hystrix/README.md
new file mode 100644
index 0000000000..4473acdb5e
--- /dev/null
+++ b/hystrix/README.md
@@ -0,0 +1,131 @@
+Hystrix
+===================
+
+This module wraps Feign's http requests in [Hystrix](https://github.com/Netflix/Hystrix/), which enables the [Circuit Breaker Pattern](https://en.wikipedia.org/wiki/Circuit_breaker_design_pattern).
+
+To use Hystrix with Feign, add the Hystrix module to your classpath. Then, configure Feign to use the `HystrixInvocationHandler`:
+
+```java
+GitHub github = HystrixFeign.builder()
+        .target(GitHub.class, "https://api.github.com");
+```
+
+For asynchronous or reactive use, return `HystrixCommand`.
+
+For RxJava compatibility, use `rx.Observable` or `rx.Single`. Rx types are cold, which means a http call isn't made until there's a subscriber.
+
+Methods that do *not* return [`HystrixCommand`](https://netflix.github.io/Hystrix/javadoc/com/netflix/hystrix/HystrixCommand.html), [`rx.Observable`](http://reactivex.io/RxJava/javadoc/rx/Observable.html) or [`rx.Single`] are still wrapped in a `HystrixCommand`, but `execute()` is automatically called for you.
+
+```java
+interface YourApi {
+  @RequestLine("GET /yourtype/{id}")
+  HystrixCommand getYourType(@Param("id") String id);
+
+  @RequestLine("GET /yourtype/{id}")
+  Observable getYourTypeObservable(@Param("id") String id);
+
+  @RequestLine("GET /yourtype/{id}")
+  Single getYourTypeSingle(@Param("id") String id);
+
+  @RequestLine("GET /yourtype/{id}")
+  YourType getYourTypeSynchronous(@Param("id") String id);
+}
+
+YourApi api = HystrixFeign.builder()
+                  .target(YourApi.class, "https://example.com");
+
+// for reactive
+api.getYourType("a").toObservable
+
+// or apply hystrix to RxJava methods
+api.getYourTypeObservable("a")
+
+// for asynchronous
+api.getYourType("a").queue();
+
+// for synchronous
+api.getYourType("a").execute();
+
+// or to apply hystrix to existing feign methods.
+api.getYourTypeSynchronous("a");
+```
+
+### Group and Command keys
+
+By default, Hystrix group keys match the target name, and the target name is usually the base url.
+Hystrix command keys are the same as logging keys, which are equivalent to javadoc references.
+
+For example, for the canonical GitHub example...
+
+* the group key would be "https://api.github.com" and
+* the command key would be "GitHub#contributors(String,String)"
+
+You can use `HystrixFeign.Builder#setterFactory(SetterFactory)` to customize this, for example, to
+read key mappings from configuration or annotations.
+
+Ex.
+```java
+SetterFactory commandKeyIsRequestLine = (target, method) -> {
+  String groupKey = target.name();
+  String commandKey = method.getAnnotation(RequestLine.class).value();
+  return HystrixCommand.Setter
+      .withGroupKey(HystrixCommandGroupKey.Factory.asKey(groupKey))
+      .andCommandKey(HystrixCommandKey.Factory.asKey(commandKey));
+};
+
+api = HystrixFeign.builder()
+                  .setterFactory(commandKeyIsRequestLine)
+                  ...
+```
+
+### Fallback support
+
+Fallbacks are known values, which you return when there's an error invoking an http method.
+For example, you can return a cached result as opposed to raising an error to the caller. To use
+this feature, pass a safe implementation of your target interface as the last parameter to `HystrixFeign.Builder.target`.
+
+Here's an example:
+
+```java
+// When dealing with fallbacks, it is less tedious to keep interfaces small.
+interface GitHub {
+  @RequestLine("GET /repos/{owner}/{repo}/contributors")
+  List contributors(@Param("owner") String owner, @Param("repo") String repo);
+}
+
+// This instance will be invoked if there are errors of any kind.
+GitHub fallback = (owner, repo) -> {
+  if (owner.equals("Netflix") && repo.equals("feign")) {
+    return Arrays.asList("stuarthendren"); // inspired this approach!
+  } else {
+    return Collections.emptyList();
+  }
+};
+
+GitHub github = HystrixFeign.builder()
+                            ...
+                            .target(GitHub.class, "https://api.github.com", fallback);
+```
+
+#### Considering the cause
+
+The cause of the fallback is logged by default to FINE level. You can programmatically inspect
+the cause by making your own `FallbackFactory`. In many cases, the cause will be a `FeignException`,
+which includes the http status.
+
+Here's an example of using `FallbackFactory`:
+
+```java
+// This instance will be invoked if there are errors of any kind.
+FallbackFactory fallbackFactory = cause -> (owner, repo) -> {
+  if (cause instanceof FeignException && ((FeignException) cause).status() == 403) {
+    return Collections.emptyList();
+  } else {
+    return Arrays.asList("yogi");
+  }
+};
+
+GitHub github = HystrixFeign.builder()
+                            ...
+                            .target(GitHub.class, "https://api.github.com", fallbackFactory);
+```
diff --git a/hystrix/build.gradle b/hystrix/build.gradle
new file mode 100644
index 0000000000..60dcd38a9c
--- /dev/null
+++ b/hystrix/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'java'
+
+sourceCompatibility = 1.6
+
+dependencies {
+    compile project(':feign-core')
+    compile 'com.netflix.hystrix:hystrix-core:1.4.26'
+    testCompile 'junit:junit:4.12'
+    testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7
+    testCompile 'com.squareup.okhttp3:mockwebserver:3.2.0'
+    testCompile project(':feign-gson')
+    testCompile project(':feign-core').sourceSets.test.output // for assertions
+}
diff --git a/hystrix/pom.xml b/hystrix/pom.xml
new file mode 100644
index 0000000000..dfb4b7e415
--- /dev/null
+++ b/hystrix/pom.xml
@@ -0,0 +1,55 @@
+
+  4.0.0
+
+  
+    io.github.openfeign
+    parent
+    9.4.1-SNAPSHOT
+  
+
+  feign-hystrix
+  Feign Hystrix
+  Feign Hystrix
+
+  
+    ${project.basedir}/..
+  
+
+  
+    
+      ${project.groupId}
+      feign-core
+    
+
+    
+      com.netflix.archaius
+      archaius-core
+      0.6.6
+    
+
+    
+      com.netflix.hystrix
+      hystrix-core
+      1.4.26
+    
+
+    
+      ${project.groupId}
+      feign-core
+      test-jar
+      test
+    
+
+    
+      ${project.groupId}
+      feign-gson
+      test
+    
+
+    
+      com.squareup.okhttp3
+      mockwebserver
+      test
+    
+  
+
diff --git a/hystrix/src/main/java/feign/hystrix/FallbackFactory.java b/hystrix/src/main/java/feign/hystrix/FallbackFactory.java
new file mode 100644
index 0000000000..4caca5d147
--- /dev/null
+++ b/hystrix/src/main/java/feign/hystrix/FallbackFactory.java
@@ -0,0 +1,69 @@
+package feign.hystrix;
+
+import feign.FeignException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static feign.Util.checkNotNull;
+
+/**
+ * Used to control the fallback given its cause.
+ *
+ * Ex.
+ * 
{@code
+ * // This instance will be invoked if there are errors of any kind.
+ * FallbackFactory fallbackFactory = cause -> (owner, repo) -> {
+ *   if (cause instanceof FeignException && ((FeignException) cause).status() == 403) {
+ *     return Collections.emptyList();
+ *   } else {
+ *     return Arrays.asList("yogi");
+ *   }
+ * };
+ *
+ * GitHub github = HystrixFeign.builder()
+ *                             ...
+ *                             .target(GitHub.class, "https://api.github.com", fallbackFactory);
+ * }
+ * 
+ * + * @param the feign interface type + */ +public interface FallbackFactory { + + /** + * Returns an instance of the fallback appropriate for the given cause + * + * @param cause corresponds to {@link com.netflix.hystrix.AbstractCommand#getExecutionException()} + * often, but not always an instance of {@link FeignException}. + */ + T create(Throwable cause); + + /** Returns a constant fallback after logging the cause to FINE level. */ + final class Default implements FallbackFactory { + // jul to not add a dependency + final Logger logger; + final T constant; + + public Default(T constant) { + this(constant, Logger.getLogger(Default.class.getName())); + } + + Default(T constant, Logger logger) { + this.constant = checkNotNull(constant, "fallback"); + this.logger = checkNotNull(logger, "logger"); + } + + @Override + public T create(Throwable cause) { + if (logger.isLoggable(Level.FINE)) { + logger.log(Level.FINE, "fallback due to: " + cause.getMessage(), cause); + } + return constant; + } + + @Override + public String toString() { + return constant.toString(); + } + } +} diff --git a/hystrix/src/main/java/feign/hystrix/HystrixDelegatingContract.java b/hystrix/src/main/java/feign/hystrix/HystrixDelegatingContract.java new file mode 100644 index 0000000000..5d64eaaa87 --- /dev/null +++ b/hystrix/src/main/java/feign/hystrix/HystrixDelegatingContract.java @@ -0,0 +1,55 @@ +package feign.hystrix; + +import static feign.Util.resolveLastTypeParameter; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.List; + +import com.netflix.hystrix.HystrixCommand; + +import feign.Contract; +import feign.MethodMetadata; +import rx.Completable; +import rx.Observable; +import rx.Single; + +/** + * This special cases methods that return {@link HystrixCommand}, {@link Observable}, or {@link Single} so that they + * are decoded properly. + * + *

For example, {@literal HystrixCommand} and {@literal Observable} will decode {@code Foo}. + */ +// Visible for use in custom Hystrix invocation handlers +public final class HystrixDelegatingContract implements Contract { + + private final Contract delegate; + + public HystrixDelegatingContract(Contract delegate) { + this.delegate = delegate; + } + + @Override + public List parseAndValidatateMetadata(Class targetType) { + List metadatas = this.delegate.parseAndValidatateMetadata(targetType); + + for (MethodMetadata metadata : metadatas) { + Type type = metadata.returnType(); + + if (type instanceof ParameterizedType && ((ParameterizedType) type).getRawType().equals(HystrixCommand.class)) { + Type actualType = resolveLastTypeParameter(type, HystrixCommand.class); + metadata.returnType(actualType); + } else if (type instanceof ParameterizedType && ((ParameterizedType) type).getRawType().equals(Observable.class)) { + Type actualType = resolveLastTypeParameter(type, Observable.class); + metadata.returnType(actualType); + } else if (type instanceof ParameterizedType && ((ParameterizedType) type).getRawType().equals(Single.class)) { + Type actualType = resolveLastTypeParameter(type, Single.class); + metadata.returnType(actualType); + } else if (type instanceof ParameterizedType && ((ParameterizedType) type).getRawType().equals(Completable.class)) { + metadata.returnType(void.class); + } + } + + return metadatas; + } +} diff --git a/hystrix/src/main/java/feign/hystrix/HystrixFeign.java b/hystrix/src/main/java/feign/hystrix/HystrixFeign.java new file mode 100644 index 0000000000..400283674b --- /dev/null +++ b/hystrix/src/main/java/feign/hystrix/HystrixFeign.java @@ -0,0 +1,192 @@ +package feign.hystrix; + +import com.netflix.hystrix.HystrixCommand; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.util.Map; + +import feign.Client; +import feign.Contract; +import feign.Feign; +import feign.InvocationHandlerFactory; +import feign.Logger; +import feign.Request; +import feign.RequestInterceptor; +import feign.Retryer; +import feign.Target; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; + +/** + * Allows Feign interfaces to return HystrixCommand or rx.Observable or rx.Single objects. Also + * decorates normal Feign methods with circuit breakers, but calls {@link HystrixCommand#execute()} + * directly. + */ +public final class HystrixFeign { + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder extends Feign.Builder { + + private Contract contract = new Contract.Default(); + private SetterFactory setterFactory = new SetterFactory.Default(); + + /** + * Allows you to override hystrix properties such as thread pools and command keys. + */ + public Builder setterFactory(SetterFactory setterFactory) { + this.setterFactory = setterFactory; + return this; + } + + /** + * @see #target(Class, String, Object) + */ + public T target(Target target, T fallback) { + return build(fallback != null ? new FallbackFactory.Default(fallback) : null) + .newInstance(target); + } + + /** + * @see #target(Class, String, FallbackFactory) + */ + public T target(Target target, FallbackFactory fallbackFactory) { + return build(fallbackFactory).newInstance(target); + } + + /** + * Like {@link Feign#newInstance(Target)}, except with {@link HystrixCommand#getFallback() + * fallback} support. + * + *

Fallbacks are known values, which you return when there's an error invoking an http + * method. For example, you can return a cached result as opposed to raising an error to the + * caller. To use this feature, pass a safe implementation of your target interface as the last + * parameter. + * + * Here's an example: + *

+     * {@code
+     *
+     * // When dealing with fallbacks, it is less tedious to keep interfaces small.
+     * interface GitHub {
+     *   @RequestLine("GET /repos/{owner}/{repo}/contributors")
+     *   List contributors(@Param("owner") String owner, @Param("repo") String repo);
+     * }
+     *
+     * // This instance will be invoked if there are errors of any kind.
+     * GitHub fallback = (owner, repo) -> {
+     *   if (owner.equals("Netflix") && repo.equals("feign")) {
+     *     return Arrays.asList("stuarthendren"); // inspired this approach!
+     *   } else {
+     *     return Collections.emptyList();
+     *   }
+     * };
+     *
+     * GitHub github = HystrixFeign.builder()
+     *                             ...
+     *                             .target(GitHub.class, "https://api.github.com", fallback);
+     * }
+ * + * @see #target(Target, Object) + */ + public T target(Class apiType, String url, T fallback) { + return target(new Target.HardCodedTarget(apiType, url), fallback); + } + + /** + * Same as {@link #target(Class, String, T)}, except you can inspect a source exception before + * creating a fallback object. + */ + public T target(Class apiType, String url, FallbackFactory fallbackFactory) { + return target(new Target.HardCodedTarget(apiType, url), fallbackFactory); + } + + @Override + public Feign.Builder invocationHandlerFactory(InvocationHandlerFactory invocationHandlerFactory) { + throw new UnsupportedOperationException(); + } + + @Override + public Builder contract(Contract contract) { + this.contract = contract; + return this; + } + + @Override + public Feign build() { + return build(null); + } + + /** Configures components needed for hystrix integration. */ + Feign build(final FallbackFactory nullableFallbackFactory) { + super.invocationHandlerFactory(new InvocationHandlerFactory() { + @Override public InvocationHandler create(Target target, + Map dispatch) { + return new HystrixInvocationHandler(target, dispatch, setterFactory, nullableFallbackFactory); + } + }); + super.contract(new HystrixDelegatingContract(contract)); + return super.build(); + } + + // Covariant overrides to support chaining to new fallback method. + @Override + public Builder logLevel(Logger.Level logLevel) { + return (Builder) super.logLevel(logLevel); + } + + @Override + public Builder client(Client client) { + return (Builder) super.client(client); + } + + @Override + public Builder retryer(Retryer retryer) { + return (Builder) super.retryer(retryer); + } + + @Override + public Builder logger(Logger logger) { + return (Builder) super.logger(logger); + } + + @Override + public Builder encoder(Encoder encoder) { + return (Builder) super.encoder(encoder); + } + + @Override + public Builder decoder(Decoder decoder) { + return (Builder) super.decoder(decoder); + } + + @Override + public Builder decode404() { + return (Builder) super.decode404(); + } + + @Override + public Builder errorDecoder(ErrorDecoder errorDecoder) { + return (Builder) super.errorDecoder(errorDecoder); + } + + @Override + public Builder options(Request.Options options) { + return (Builder) super.options(options); + } + + @Override + public Builder requestInterceptor(RequestInterceptor requestInterceptor) { + return (Builder) super.requestInterceptor(requestInterceptor); + } + + @Override + public Builder requestInterceptors(Iterable requestInterceptors) { + return (Builder) super.requestInterceptors(requestInterceptors); + } + } +} diff --git a/hystrix/src/main/java/feign/hystrix/HystrixInvocationHandler.java b/hystrix/src/main/java/feign/hystrix/HystrixInvocationHandler.java new file mode 100644 index 0000000000..6c3819c02e --- /dev/null +++ b/hystrix/src/main/java/feign/hystrix/HystrixInvocationHandler.java @@ -0,0 +1,196 @@ +/* + * 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.hystrix; + +import com.netflix.hystrix.HystrixCommand; +import com.netflix.hystrix.HystrixCommand.Setter; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Target; +import rx.Completable; +import rx.Observable; +import rx.Single; + +import static feign.Util.checkNotNull; + +final class HystrixInvocationHandler implements InvocationHandler { + + private final Target target; + private final Map dispatch; + private final FallbackFactory fallbackFactory; // Nullable + private final Map fallbackMethodMap; + private final Map setterMethodMap; + + HystrixInvocationHandler(Target target, Map dispatch, + SetterFactory setterFactory, FallbackFactory fallbackFactory) { + this.target = checkNotNull(target, "target"); + this.dispatch = checkNotNull(dispatch, "dispatch"); + this.fallbackFactory = fallbackFactory; + this.fallbackMethodMap = toFallbackMethod(dispatch); + this.setterMethodMap = toSetters(setterFactory, target, dispatch.keySet()); + } + + /** + * If the method param of InvocationHandler.invoke is not accessible, i.e in a package-private + * interface, the fallback call in hystrix command will fail cause of access restrictions. But + * methods in dispatch are copied methods. So setting access to dispatch method doesn't take + * effect to the method in InvocationHandler.invoke. Use map to store a copy of method to invoke + * the fallback to bypass this and reducing the count of reflection calls. + * + * @return cached methods map for fallback invoking + */ + static Map toFallbackMethod(Map dispatch) { + Map result = new LinkedHashMap(); + for (Method method : dispatch.keySet()) { + method.setAccessible(true); + result.put(method, method); + } + return result; + } + + /** + * Process all methods in the target so that appropriate setters are created. + */ + static Map toSetters(SetterFactory setterFactory, Target target, + Set methods) { + Map result = new LinkedHashMap(); + for (Method method : methods) { + method.setAccessible(true); + result.put(method, setterFactory.create(target, method)); + } + return result; + } + + @Override + public Object invoke(final Object proxy, final Method method, final Object[] args) + throws Throwable { + // early exit if the invoked method is from java.lang.Object + // code is the same as ReflectiveFeign.FeignInvocationHandler + 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(); + } + + HystrixCommand hystrixCommand = new HystrixCommand(setterMethodMap.get(method)) { + @Override + protected Object run() throws Exception { + try { + return HystrixInvocationHandler.this.dispatch.get(method).invoke(args); + } catch (Exception e) { + throw e; + } catch (Throwable t) { + throw (Error) t; + } + } + + @Override + protected Object getFallback() { + if (fallbackFactory == null) { + return super.getFallback(); + } + try { + Object fallback = fallbackFactory.create(getExecutionException()); + Object result = fallbackMethodMap.get(method).invoke(fallback, args); + if (isReturnsHystrixCommand(method)) { + return ((HystrixCommand) result).execute(); + } else if (isReturnsObservable(method)) { + // Create a cold Observable + return ((Observable) result).toBlocking().first(); + } else if (isReturnsSingle(method)) { + // Create a cold Observable as a Single + return ((Single) result).toObservable().toBlocking().first(); + } else if (isReturnsCompletable(method)) { + ((Completable) result).await(); + return null; + } else { + return result; + } + } catch (IllegalAccessException e) { + // shouldn't happen as method is public due to being an interface + throw new AssertionError(e); + } catch (InvocationTargetException e) { + // Exceptions on fallback are tossed by Hystrix + throw new AssertionError(e.getCause()); + } + } + }; + + if (isReturnsHystrixCommand(method)) { + return hystrixCommand; + } else if (isReturnsObservable(method)) { + // Create a cold Observable + return hystrixCommand.toObservable(); + } else if (isReturnsSingle(method)) { + // Create a cold Observable as a Single + return hystrixCommand.toObservable().toSingle(); + } else if (isReturnsCompletable(method)) { + return hystrixCommand.toObservable().toCompletable(); + } + return hystrixCommand.execute(); + } + + private boolean isReturnsCompletable(Method method) { + return Completable.class.isAssignableFrom(method.getReturnType()); + } + + private boolean isReturnsHystrixCommand(Method method) { + return HystrixCommand.class.isAssignableFrom(method.getReturnType()); + } + + private boolean isReturnsObservable(Method method) { + return Observable.class.isAssignableFrom(method.getReturnType()); + } + + private boolean isReturnsSingle(Method method) { + return Single.class.isAssignableFrom(method.getReturnType()); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof HystrixInvocationHandler) { + HystrixInvocationHandler other = (HystrixInvocationHandler) obj; + return target.equals(other.target); + } + return false; + } + + @Override + public int hashCode() { + return target.hashCode(); + } + + @Override + public String toString() { + return target.toString(); + } +} diff --git a/hystrix/src/main/java/feign/hystrix/SetterFactory.java b/hystrix/src/main/java/feign/hystrix/SetterFactory.java new file mode 100644 index 0000000000..b020e01da0 --- /dev/null +++ b/hystrix/src/main/java/feign/hystrix/SetterFactory.java @@ -0,0 +1,44 @@ +package feign.hystrix; + +import com.netflix.hystrix.HystrixCommand; +import com.netflix.hystrix.HystrixCommandGroupKey; +import com.netflix.hystrix.HystrixCommandKey; + +import java.lang.reflect.Method; + +import feign.Feign; +import feign.Target; + +/** + * Used to control properties of a hystrix command. Use cases include reading from static + * configuration or custom annotations. + * + *

This is parsed up-front, like {@link feign.Contract}, so will not be invoked for each command + * invocation. + * + *

Note: when deciding the {@link com.netflix.hystrix.HystrixCommand.Setter#andCommandKey(HystrixCommandKey) + * command key}, recall it lives in a shared cache, so make sure it is unique. + */ +public interface SetterFactory { + + /** + * Returns a hystrix setter appropriate for the given target and method + */ + HystrixCommand.Setter create(Target target, Method method); + + /** + * Default behavior is to derive the group key from {@link Target#name()} and the command key from + * {@link Feign#configKey(Class, Method)}. + */ + final class Default implements SetterFactory { + + @Override + public HystrixCommand.Setter create(Target target, Method method) { + String groupKey = target.name(); + String commandKey = Feign.configKey(target.type(), method); + return HystrixCommand.Setter + .withGroupKey(HystrixCommandGroupKey.Factory.asKey(groupKey)) + .andCommandKey(HystrixCommandKey.Factory.asKey(commandKey)); + } + } +} \ No newline at end of file diff --git a/hystrix/src/test/java/feign/hystrix/FallbackFactoryTest.java b/hystrix/src/test/java/feign/hystrix/FallbackFactoryTest.java new file mode 100644 index 0000000000..2389b3b254 --- /dev/null +++ b/hystrix/src/test/java/feign/hystrix/FallbackFactoryTest.java @@ -0,0 +1,156 @@ +package feign.hystrix; + +import feign.FeignException; +import feign.RequestLine; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static feign.assertj.MockWebServerAssertions.assertThat; + +public class FallbackFactoryTest { + + interface TestInterface { + @RequestLine("POST /") String invoke(); + } + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + @Rule + public final MockWebServer server = new MockWebServer(); + + @Test + public void fallbackFactory_example_lambda() { + server.enqueue(new MockResponse().setResponseCode(500)); + server.enqueue(new MockResponse().setResponseCode(404)); + + TestInterface api = target(cause -> () -> { + assertThat(cause).isInstanceOf(FeignException.class); + return ((FeignException) cause).status() == 500 ? "foo" : "bar"; + }); + + assertThat(api.invoke()).isEqualTo("foo"); + assertThat(api.invoke()).isEqualTo("bar"); + } + + static class FallbackApiWithCtor implements TestInterface { + final Throwable cause; + + FallbackApiWithCtor(Throwable cause) { + this.cause = cause; + } + + @Override public String invoke() { + return "foo"; + } + } + + @Test + public void fallbackFactory_example_ctor() { + server.enqueue(new MockResponse().setResponseCode(500)); + + // method reference + TestInterface api = target(FallbackApiWithCtor::new); + + assertThat(api.invoke()).isEqualTo("foo"); + + server.enqueue(new MockResponse().setResponseCode(500)); + + // lambda factory + api = target(throwable -> new FallbackApiWithCtor(throwable)); + + server.enqueue(new MockResponse().setResponseCode(500)); + + // old school + api = target(new FallbackFactory() { + @Override public TestInterface create(Throwable cause) { + return new FallbackApiWithCtor(cause); + } + }); + + assertThat(api.invoke()).isEqualTo("foo"); + } + + // retrofit so people don't have to track 2 classes + static class FallbackApiRetro implements TestInterface, FallbackFactory { + + @Override public FallbackApiRetro create(Throwable cause) { + return new FallbackApiRetro(cause); + } + + final Throwable cause; // nullable + + public FallbackApiRetro() { + this(null); + } + + FallbackApiRetro(Throwable cause) { + this.cause = cause; + } + + @Override public String invoke() { + return cause != null ? cause.getMessage() : "foo"; + } + } + + @Test + public void fallbackFactory_example_retro() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(new FallbackApiRetro()); + + assertThat(api.invoke()).isEqualTo("status 500 reading TestInterface#invoke()"); + } + + @Test + public void defaultFallbackFactory_delegates() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(new FallbackFactory.Default<>(() -> "foo")); + + assertThat(api.invoke()) + .isEqualTo("foo"); + } + + @Test + public void defaultFallbackFactory_doesntLogByDefault() { + server.enqueue(new MockResponse().setResponseCode(500)); + + Logger logger = new Logger("", null) { + @Override public void log(Level level, String msg, Throwable thrown) { + throw new AssertionError("logged eventhough not FINE level"); + } + }; + + target(new FallbackFactory.Default<>(() -> "foo", logger)).invoke(); + } + + @Test + public void defaultFallbackFactory_logsAtFineLevel() { + server.enqueue(new MockResponse().setResponseCode(500)); + + AtomicBoolean logged = new AtomicBoolean(); + Logger logger = new Logger("", null) { + @Override public void log(Level level, String msg, Throwable thrown) { + logged.set(true); + + assertThat(msg).isEqualTo("fallback due to: status 500 reading TestInterface#invoke()"); + assertThat(thrown).isInstanceOf(FeignException.class); + } + }; + logger.setLevel(Level.FINE); + + target(new FallbackFactory.Default<>(() -> "foo", logger)).invoke(); + assertThat(logged.get()).isTrue(); + } + + TestInterface target(FallbackFactory factory) { + return HystrixFeign.builder() + .target(TestInterface.class, "http://localhost:" + server.getPort(), factory); + } +} diff --git a/hystrix/src/test/java/feign/hystrix/HystrixBuilderTest.java b/hystrix/src/test/java/feign/hystrix/HystrixBuilderTest.java new file mode 100644 index 0000000000..08f9953bc9 --- /dev/null +++ b/hystrix/src/test/java/feign/hystrix/HystrixBuilderTest.java @@ -0,0 +1,721 @@ +package feign.hystrix; + +import com.netflix.hystrix.HystrixCommand; +import com.netflix.hystrix.HystrixCommandGroupKey; +import com.netflix.hystrix.exception.HystrixRuntimeException; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +import org.assertj.core.api.Assertions; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import feign.FeignException; +import feign.Headers; +import feign.Param; +import feign.RequestLine; +import feign.Target; +import feign.Target.HardCodedTarget; +import feign.gson.GsonDecoder; +import rx.Completable; +import rx.Observable; +import rx.Single; +import rx.observers.TestSubscriber; + +import static feign.assertj.MockWebServerAssertions.assertThat; +import static org.hamcrest.core.Is.isA; + +public class HystrixBuilderTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + @Rule + public final MockWebServer server = new MockWebServer(); + + @Test + public void hystrixCommand() { + server.enqueue(new MockResponse().setBody("\"foo\"")); + + TestInterface api = target(); + + HystrixCommand command = api.command(); + + assertThat(command).isNotNull(); + assertThat(command.execute()).isEqualTo("foo"); + } + + @Test + public void hystrixCommandFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + HystrixCommand command = api.command(); + + assertThat(command).isNotNull(); + assertThat(command.execute()).isEqualTo("fallback"); + } + + @Test + public void hystrixCommandInt() { + server.enqueue(new MockResponse().setBody("1")); + + TestInterface api = target(); + + HystrixCommand command = api.intCommand(); + + assertThat(command).isNotNull(); + assertThat(command.execute()).isEqualTo(new Integer(1)); + } + + @Test + public void hystrixCommandIntFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + HystrixCommand command = api.intCommand(); + + assertThat(command).isNotNull(); + assertThat(command.execute()).isEqualTo(new Integer(0)); + } + + @Test + public void hystrixCommandList() { + server.enqueue(new MockResponse().setBody("[\"foo\",\"bar\"]")); + + TestInterface api = target(); + + HystrixCommand> command = api.listCommand(); + + assertThat(command).isNotNull(); + assertThat(command.execute()).containsExactly("foo", "bar"); + } + + @Test + public void hystrixCommandListFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + HystrixCommand> command = api.listCommand(); + + assertThat(command).isNotNull(); + assertThat(command.execute()).containsExactly("fallback"); + } + + // When dealing with fallbacks, it is less tedious to keep interfaces small. + interface GitHub { + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + } + + interface GitHubHystrix { + @RequestLine("GET /repos/{owner}/{repo}/contributors") + HystrixCommand> contributorsHystrixCommand(@Param("owner") String owner, + @Param("repo") String repo); + } + + @Test + public void fallbacksApplyOnError() { + server.enqueue(new MockResponse().setResponseCode(500)); + + GitHub fallback = new GitHub() { + @Override + public List contributors(String owner, String repo) { + if (owner.equals("Netflix") && repo.equals("feign")) { + return Arrays.asList("stuarthendren"); // inspired this approach! + } else { + return Collections.emptyList(); + } + } + }; + + GitHub api = HystrixFeign.builder() + .target(GitHub.class, "http://localhost:" + server.getPort(), fallback); + + List result = api.contributors("Netflix", "feign"); + + assertThat(result).containsExactly("stuarthendren"); + } + + @Test + public void errorInFallbackHasExpectedBehavior() { + thrown.expect(HystrixRuntimeException.class); + thrown.expectMessage("GitHub#contributors(String,String) failed and fallback failed."); + thrown.expectCause( + isA(FeignException.class)); // as opposed to RuntimeException (from the fallback) + + server.enqueue(new MockResponse().setResponseCode(500)); + + GitHub fallback = new GitHub() { + @Override + public List contributors(String owner, String repo) { + throw new RuntimeException("oops"); + } + }; + + GitHub api = HystrixFeign.builder() + .target(GitHub.class, "http://localhost:" + server.getPort(), fallback); + + api.contributors("Netflix", "feign"); + } + + @Test + public void hystrixRuntimeExceptionPropagatesOnException() { + thrown.expect(HystrixRuntimeException.class); + thrown.expectMessage("GitHub#contributors(String,String) failed and no fallback available."); + thrown.expectCause(isA(FeignException.class)); + + server.enqueue(new MockResponse().setResponseCode(500)); + + GitHub api = HystrixFeign.builder() + .target(GitHub.class, "http://localhost:" + server.getPort()); + + api.contributors("Netflix", "feign"); + } + + @Test + public void rxObservable() { + server.enqueue(new MockResponse().setBody("\"foo\"")); + + TestInterface api = target(); + + Observable observable = api.observable(); + + assertThat(observable).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber testSubscriber = new TestSubscriber(); + observable.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + Assertions.assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo("foo"); + } + + @Test + public void rxObservableFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + Observable observable = api.observable(); + + assertThat(observable).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber testSubscriber = new TestSubscriber(); + observable.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + Assertions.assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo("fallback"); + } + + @Test + public void rxObservableInt() { + server.enqueue(new MockResponse().setBody("1")); + + TestInterface api = target(); + + Observable observable = api.intObservable(); + + assertThat(observable).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber testSubscriber = new TestSubscriber(); + observable.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + Assertions.assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo(new Integer(1)); + } + + @Test + public void rxObservableIntFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + Observable observable = api.intObservable(); + + assertThat(observable).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber testSubscriber = new TestSubscriber(); + observable.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + Assertions.assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo(new Integer(0)); + } + + @Test + public void rxObservableList() { + server.enqueue(new MockResponse().setBody("[\"foo\",\"bar\"]")); + + TestInterface api = target(); + + Observable> observable = api.listObservable(); + + assertThat(observable).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber> testSubscriber = new TestSubscriber>(); + observable.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + assertThat(testSubscriber.getOnNextEvents().get(0)).containsExactly("foo", "bar"); + } + + @Test + public void rxObservableListFall() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + Observable> observable = api.listObservable(); + + assertThat(observable).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber> testSubscriber = new TestSubscriber>(); + observable.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + assertThat(testSubscriber.getOnNextEvents().get(0)).containsExactly("fallback"); + } + + @Test + public void rxObservableListFall_noFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = targetWithoutFallback(); + + Observable> observable = api.listObservable(); + + assertThat(observable).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber> testSubscriber = new TestSubscriber>(); + observable.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + + assertThat(testSubscriber.getOnNextEvents()).isEmpty(); + assertThat(testSubscriber.getOnErrorEvents().get(0)) + .isInstanceOf(HystrixRuntimeException.class) + .hasMessage("TestInterface#listObservable() failed and no fallback available."); + } + + @Test + public void rxSingle() { + server.enqueue(new MockResponse().setBody("\"foo\"")); + + TestInterface api = target(); + + Single single = api.single(); + + assertThat(single).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber testSubscriber = new TestSubscriber(); + single.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + Assertions.assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo("foo"); + } + + @Test + public void rxSingleFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + Single single = api.single(); + + assertThat(single).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber testSubscriber = new TestSubscriber(); + single.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + Assertions.assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo("fallback"); + } + + @Test + public void rxSingleInt() { + server.enqueue(new MockResponse().setBody("1")); + + TestInterface api = target(); + + Single single = api.intSingle(); + + assertThat(single).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber testSubscriber = new TestSubscriber(); + single.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + Assertions.assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo(new Integer(1)); + } + + @Test + public void rxSingleIntFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + Single single = api.intSingle(); + + assertThat(single).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber testSubscriber = new TestSubscriber(); + single.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + Assertions.assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo(new Integer(0)); + } + + @Test + public void rxSingleList() { + server.enqueue(new MockResponse().setBody("[\"foo\",\"bar\"]")); + + TestInterface api = target(); + + Single> single = api.listSingle(); + + assertThat(single).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber> testSubscriber = new TestSubscriber>(); + single.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + assertThat(testSubscriber.getOnNextEvents().get(0)).containsExactly("foo", "bar"); + } + + @Test + public void rxSingleListFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + Single> single = api.listSingle(); + + assertThat(single).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber> testSubscriber = new TestSubscriber>(); + single.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + assertThat(testSubscriber.getOnNextEvents().get(0)).containsExactly("fallback"); + } + + @Test + public void rxCompletableEmptyBody() { + server.enqueue(new MockResponse()); + + TestInterface api = target(); + + Completable completable = api.completable(); + + assertThat(completable).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber testSubscriber = new TestSubscriber(); + completable.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + + testSubscriber.assertCompleted(); + testSubscriber.assertNoErrors(); + } + + @Test + public void rxCompletableWithBody() { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = target(); + + Completable completable = api.completable(); + + assertThat(completable).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber testSubscriber = new TestSubscriber(); + completable.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + + testSubscriber.assertCompleted(); + testSubscriber.assertNoErrors(); + } + + @Test + public void rxCompletableFailWithoutFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = HystrixFeign.builder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Completable completable = api.completable(); + + assertThat(completable).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber testSubscriber = new TestSubscriber(); + completable.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + + testSubscriber.assertError(HystrixRuntimeException.class); + } + + @Test + public void rxCompletableFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + Completable completable = api.completable(); + + assertThat(completable).isNotNull(); + assertThat(server.getRequestCount()).isEqualTo(0); + + TestSubscriber testSubscriber = new TestSubscriber(); + completable.subscribe(testSubscriber); + testSubscriber.awaitTerminalEvent(); + + testSubscriber.assertCompleted(); + } + + @Test + public void plainString() { + server.enqueue(new MockResponse().setBody("\"foo\"")); + + TestInterface api = target(); + + String string = api.get(); + + assertThat(string).isEqualTo("foo"); + } + + @Test + public void plainStringFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + String string = api.get(); + + assertThat(string).isEqualTo("fallback"); + } + + @Test + public void plainList() { + server.enqueue(new MockResponse().setBody("[\"foo\",\"bar\"]")); + + TestInterface api = target(); + + List list = api.getList(); + + assertThat(list).isNotNull().containsExactly("foo", "bar"); + } + + @Test + public void plainListFallback() { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + List list = api.getList(); + + assertThat(list).isNotNull().containsExactly("fallback"); + } + + @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 = HystrixFeign.builder().target(t1); + TestInterface i2 = HystrixFeign.builder().target(t1); + TestInterface i3 = HystrixFeign.builder().target(t2); + OtherTestInterface i4 = HystrixFeign.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()); + } + + private TestInterface target() { + return HystrixFeign.builder() + .decoder(new GsonDecoder()) + .target(TestInterface.class, "http://localhost:" + server.getPort(), + new FallbackTestInterface()); + } + + private TestInterface targetWithoutFallback() { + return HystrixFeign.builder() + .decoder(new GsonDecoder()) + .target(TestInterface.class, "http://localhost:" + server.getPort()); + } + + interface OtherTestInterface { + + @RequestLine("GET /") + @Headers("Accept: application/json") + HystrixCommand> listCommand(); + } + + interface TestInterface { + + @RequestLine("GET /") + @Headers("Accept: application/json") + HystrixCommand> listCommand(); + + @RequestLine("GET /") + @Headers("Accept: application/json") + HystrixCommand command(); + + @RequestLine("GET /") + @Headers("Accept: application/json") + HystrixCommand intCommand(); + + @RequestLine("GET /") + @Headers("Accept: application/json") + Observable> listObservable(); + + @RequestLine("GET /") + @Headers("Accept: application/json") + Observable observable(); + + @RequestLine("GET /") + @Headers("Accept: application/json") + Single intSingle(); + + @RequestLine("GET /") + @Headers("Accept: application/json") + Single> listSingle(); + + @RequestLine("GET /") + @Headers("Accept: application/json") + Single single(); + + @RequestLine("GET /") + @Headers("Accept: application/json") + Observable intObservable(); + + + @RequestLine("GET /") + @Headers("Accept: application/json") + String get(); + + @RequestLine("GET /") + @Headers("Accept: application/json") + List getList(); + + @RequestLine("GET /") + Completable completable(); + } + + class FallbackTestInterface implements TestInterface { + @Override + public HystrixCommand command() { + return new HystrixCommand(HystrixCommandGroupKey.Factory.asKey("Test")) { + @Override + protected String run() throws Exception { + return "fallback"; + } + }; + } + + @Override + public HystrixCommand> listCommand() { + return new HystrixCommand>(HystrixCommandGroupKey.Factory.asKey("Test")) { + @Override + protected List run() throws Exception { + List fallbackResult = new ArrayList(); + fallbackResult.add("fallback"); + return fallbackResult; + } + }; + } + + @Override + public HystrixCommand intCommand() { + return new HystrixCommand(HystrixCommandGroupKey.Factory.asKey("Test")) { + @Override + protected Integer run() throws Exception { + return 0; + } + }; + } + + @Override + public Observable> listObservable() { + List fallbackResult = new ArrayList(); + fallbackResult.add("fallback"); + return Observable.just(fallbackResult); + } + + @Override + public Observable observable() { + return Observable.just("fallback"); + } + + @Override + public Single intSingle() { + return Single.just(0); + } + + @Override + public Single> listSingle() { + List fallbackResult = new ArrayList(); + fallbackResult.add("fallback"); + return Single.just(fallbackResult); + } + + @Override + public Single single() { + return Single.just("fallback"); + } + + @Override + public Observable intObservable() { + return Observable.just(0); + } + + @Override + public String get() { + return "fallback"; + } + + @Override + public List getList() { + List fallbackResult = new ArrayList(); + fallbackResult.add("fallback"); + return fallbackResult; + } + + @Override + public Completable completable() { + return Completable.complete(); + } + } +} diff --git a/hystrix/src/test/java/feign/hystrix/SetterFactoryTest.java b/hystrix/src/test/java/feign/hystrix/SetterFactoryTest.java new file mode 100644 index 0000000000..29b9598b9d --- /dev/null +++ b/hystrix/src/test/java/feign/hystrix/SetterFactoryTest.java @@ -0,0 +1,49 @@ +package feign.hystrix; + +import com.netflix.hystrix.HystrixCommand; +import com.netflix.hystrix.HystrixCommandGroupKey; +import com.netflix.hystrix.HystrixCommandKey; +import com.netflix.hystrix.exception.HystrixRuntimeException; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import feign.RequestLine; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +public class SetterFactoryTest { + + interface TestInterface { + @RequestLine("POST /") + String invoke(); + } + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + @Rule + public final MockWebServer server = new MockWebServer(); + + @Test + public void customSetter() { + thrown.expect(HystrixRuntimeException.class); + thrown.expectMessage("POST / failed and no fallback available."); + + server.enqueue(new MockResponse().setResponseCode(500)); + +SetterFactory commandKeyIsRequestLine = (target, method) -> { + String groupKey = target.name(); + String commandKey = method.getAnnotation(RequestLine.class).value(); + return HystrixCommand.Setter + .withGroupKey(HystrixCommandGroupKey.Factory.asKey(groupKey)) + .andCommandKey(HystrixCommandKey.Factory.asKey(commandKey)); +}; + + TestInterface api = HystrixFeign.builder() + .setterFactory(commandKeyIsRequestLine) + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.invoke(); + } +} 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..804bf644d1 --- /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.4' + 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 +} diff --git a/jackson-jaxb/pom.xml b/jackson-jaxb/pom.xml new file mode 100644 index 0000000000..87930979c5 --- /dev/null +++ b/jackson-jaxb/pom.xml @@ -0,0 +1,57 @@ + + 4.0.0 + + + io.github.openfeign + parent + 9.4.1-SNAPSHOT + + + feign-jackson-jaxb + Feign Jackson JAXB + Feign Jackson JAXB + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + javax.ws.rs + jsr311-api + 1.1.1 + + + + com.fasterxml.jackson.jaxrs + jackson-jaxrs-json-provider + 2.6.4 + + + + + com.sun.jersey + jersey-client + 1.19 + test + + + + ${project.groupId} + feign-core + test-jar + test + + + 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..1c9f772406 --- /dev/null +++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java @@ -0,0 +1,34 @@ +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.Util; +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 { + if (response.status() == 404) return Util.emptyValueOf(type); + if (response.body() == null) return null; + 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..dd928a85a0 --- /dev/null +++ b/jackson-jaxb/src/test/java/feign/jackson/jaxb/JacksonJaxbCodecTest.java @@ -0,0 +1,84 @@ +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.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .body("{\"value\":\"Test\"}", UTF_8) + .build(); + JacksonJaxbJsonDecoder decoder = new JacksonJaxbJsonDecoder(); + + assertThat(decoder.decode(response, MockObject.class)) + .isEqualTo(new MockObject("Test")); + } + + /** Enabled via {@link feign.Feign.Builder#decode404()} */ + @Test + public void notFoundDecodesToEmpty() throws Exception { + Response response = Response.builder() + .status(404) + .reason("NOT FOUND") + .headers(Collections.>emptyMap()) + .build(); + assertThat((byte[]) new JacksonJaxbJsonDecoder().decode(response, byte[].class)).isEmpty(); + } + + @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..d69eff9886 --- /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.4' + 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/pom.xml b/jackson/pom.xml new file mode 100644 index 0000000000..bdab19222d --- /dev/null +++ b/jackson/pom.xml @@ -0,0 +1,37 @@ + + 4.0.0 + + + io.github.openfeign + parent + 9.4.1-SNAPSHOT + + + feign-jackson + Feign Jackson + Feign Jackson + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + ${project.groupId} + feign-core + test-jar + test + + + 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..a907c9cf3d --- /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.Util; +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.status() == 404) return Util.emptyValueOf(type); + 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..36af87f490 --- /dev/null +++ b/jackson/src/test/java/feign/jackson/JacksonCodecTest.java @@ -0,0 +1,225 @@ +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.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .body(zonesJson, UTF_8) + .build(); + assertEquals(zones, new JacksonDecoder().decode(response, new TypeReference>() { + }.getType())); + } + + @Test + public void nullBodyDecodesToNull() throws Exception { + Response response = Response.builder() + .status(204) + .reason("OK") + .headers(Collections.>emptyMap()) + .build(); + assertNull(new JacksonDecoder().decode(response, String.class)); + } + + @Test + public void emptyBodyDecodesToNull() throws Exception { + Response response = Response.builder() + .status(204) + .reason("OK") + .headers(Collections.>emptyMap()) + .body(new byte[0]) + .build(); + 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.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .body(zonesJson, UTF_8) + .build(); + 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(); + } + } + + /** Enabled via {@link feign.Feign.Builder#decode404()} */ + @Test + public void notFoundDecodesToEmpty() throws Exception { + Response response = Response.builder() + .status(404) + .reason("NOT FOUND") + .headers(Collections.>emptyMap()) + .build(); + assertThat((byte[]) new JacksonDecoder().decode(response, byte[].class)).isEmpty(); + } +} 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..35d57b8844 --- /dev/null +++ b/jaxb/README.md @@ -0,0 +1,27 @@ +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"); +``` + +`JAXBDecoder` can also be created with a builder to allow overriding some default parser options: + +```java +JAXBDecoder jaxbDecoder = new JAXBDecoder.Builder() + .withJAXBContextFactory(jaxbFactory) + .withNamespaceAware(false) // true by default + .build(); +``` diff --git a/jaxb/build.gradle b/jaxb/build.gradle new file mode 100644 index 0000000000..13084b044a --- /dev/null +++ b/jaxb/build.gradle @@ -0,0 +1,10 @@ +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 + testCompile project(':feign-core').sourceSets.test.output // for assertions +} diff --git a/jaxb/pom.xml b/jaxb/pom.xml new file mode 100644 index 0000000000..6a24d4620b --- /dev/null +++ b/jaxb/pom.xml @@ -0,0 +1,31 @@ + + 4.0.0 + + + io.github.openfeign + parent + 9.4.1-SNAPSHOT + + + feign-jaxb + Feign JAXB + Feign JAXB + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + ${project.groupId} + feign-core + test-jar + test + + + 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..dfacd008ee --- /dev/null +++ b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java @@ -0,0 +1,125 @@ +/* + * 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 javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParserFactory; +import javax.xml.transform.Source; +import javax.xml.transform.sax.SAXSource; + +import feign.Response; +import feign.Util; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +/** + * 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; + private final boolean namespaceAware; + + public JAXBDecoder(JAXBContextFactory jaxbContextFactory) { + this.jaxbContextFactory = jaxbContextFactory; + this.namespaceAware = true; + } + + private JAXBDecoder(Builder builder) { + this.jaxbContextFactory = builder.jaxbContextFactory; + this.namespaceAware = builder.namespaceAware; + } + + @Override + public Object decode(Response response, Type type) throws IOException { + if (response.status() == 404) return Util.emptyValueOf(type); + if (response.body() == null) return null; + if (!(type instanceof Class)) { + throw new UnsupportedOperationException( + "JAXB only supports decoding raw types. Found " + type); + } + + + try { + SAXParserFactory saxParserFactory = SAXParserFactory.newInstance(); + /* Explicitly control sax configuration to prevent XXE attacks */ + saxParserFactory.setFeature("http://xml.org/sax/features/external-general-entities", false); + saxParserFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + saxParserFactory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false); + saxParserFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + saxParserFactory.setNamespaceAware(namespaceAware); + + Source source = new SAXSource(saxParserFactory.newSAXParser().getXMLReader(), new InputSource(response.body().asInputStream())); + Unmarshaller unmarshaller = jaxbContextFactory.createUnmarshaller((Class) type); + return unmarshaller.unmarshal(source); + } catch (JAXBException e) { + throw new DecodeException(e.toString(), e); + } catch (ParserConfigurationException e) { + throw new DecodeException(e.toString(), e); + } catch (SAXException e) { + throw new DecodeException(e.toString(), e); + } finally { + if (response.body() != null) { + response.body().close(); + } + } + } + + public static class Builder { + private boolean namespaceAware = true; + private JAXBContextFactory jaxbContextFactory; + + /** + * Controls whether the underlying XML parser is namespace aware. + * Default is true. + */ + public Builder withNamespaceAware(boolean namespaceAware) { + this.namespaceAware = namespaceAware; + return this; + } + + public Builder withJAXBContextFactory(JAXBContextFactory jaxbContextFactory) { + this.jaxbContextFactory = jaxbContextFactory; + return this; + } + + public JAXBDecoder build() { + if (jaxbContextFactory == null) { + throw new IllegalStateException("JAXBContextFactory must be non-null"); + } + return new JAXBDecoder(this); + } + } +} 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..b06dce6715 --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java @@ -0,0 +1,238 @@ +/* + * 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.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .body(mockXml, UTF_8) + .build(); + + 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.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .body("", UTF_8) + .build(); + + new JAXBDecoder(new JAXBContextFactory.Builder().build()).decode(response, parameterized); + } + + /** Enabled via {@link feign.Feign.Builder#decode404()} */ + @Test + public void notFoundDecodesToEmpty() throws Exception { + Response response = Response.builder() + .status(404) + .reason("NOT FOUND") + .headers(Collections.>emptyMap()) + .build(); + assertThat((byte[]) new JAXBDecoder(new JAXBContextFactory.Builder().build()) + .decode(response, byte[].class)).isEmpty(); + } + + @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/pom.xml b/jaxrs/pom.xml new file mode 100644 index 0000000000..c58af10154 --- /dev/null +++ b/jaxrs/pom.xml @@ -0,0 +1,44 @@ + + 4.0.0 + + + io.github.openfeign + parent + 9.4.1-SNAPSHOT + + + feign-jaxrs + Feign JAX-RS + Feign JAX-RS + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + javax.ws.rs + jsr311-api + 1.1.1 + + + + + ${project.groupId} + feign-gson + test + + + + ${project.groupId} + feign-core + test-jar + test + + + 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..f711d397d7 --- /dev/null +++ b/jaxrs/src/main/java/feign/jaxrs/JAXRSContract.java @@ -0,0 +1,171 @@ +/* + * 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.ArrayList; +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); + } + Consumes consumes = clz.getAnnotation(Consumes.class); + if (consumes != null) { + handleConsumesAnnotation(data, consumes, clz.getName()); + } + Produces produces = clz.getAnnotation(Produces.class); + if (produces != null) { + handleProducesAnnotation(data, produces, clz.getName()); + } + } + + @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().url().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) { + handleProducesAnnotation(data, (Produces) methodAnnotation, "method " + method.getName()); + } else if (annotationType == Consumes.class) { + handleConsumesAnnotation(data, (Consumes) methodAnnotation, "method " + method.getName()); + } + } + + private void handleProducesAnnotation(MethodMetadata data, Produces produces, String name) { + String[] serverProduces = produces.value(); + String clientAccepts = serverProduces.length == 0 ? null : emptyToNull(serverProduces[0]); + checkState(clientAccepts != null, "Produces.value() was empty on %s", name); + data.template().header(ACCEPT, (String) null); // remove any previous produces + data.template().header(ACCEPT, clientAccepts); + } + + private void handleConsumesAnnotation(MethodMetadata data, Consumes consumes, String name) { + String[] serverConsumes = consumes.value(); + String clientProduces = serverConsumes.length == 0 ? null : emptyToNull(serverConsumes[0]); + checkState(clientProduces != null, "Consumes.value() was empty on %s", name); + data.template().header(CONTENT_TYPE, (String) null); // remove any previous consumes + 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; + } + + // Not using override as the super-type's method is deprecated and will be removed. + protected Collection addTemplatedParam(Collection possiblyNull, String name) { + if (possiblyNull == null) { + possiblyNull = new ArrayList(); + } + possiblyNull.add(String.format("{%s}", name)); + return possiblyNull; + } +} 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..3a904b39b4 --- /dev/null +++ b/jaxrs/src/test/java/feign/jaxrs/JAXRSContractTest.java @@ -0,0 +1,590 @@ +/* + * 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 javax.ws.rs.core.MediaType; + +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("Content-Type", asList("application/json")), + 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("Accept", asList("text/html")), 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 producesAndConsumesOnClassAddsHeader() throws Exception { + MethodMetadata md = parseAndValidateMetadata(ProducesAndConsumes.class, "producesAndConsumes"); + + assertThat(md.template()) + .hasHeaders(entry("Content-Type", asList("application/json")), entry("Accept", asList("text/html"))); + } + + @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"); + } + + @Test + public void methodPathWithoutLeadingSlashParsesCorrectly() throws Exception { + assertThat(parseAndValidateMetadata(MethodWithFirstPathThenGetWithoutLeadingSlash.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(); + } + + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.TEXT_HTML) + 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(); + + @POST + Response producesAndConsumes(); + } + + 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(); + } + + @Path("/base/") + interface MethodWithFirstPathThenGetWithoutLeadingSlash { + @Path("specific") + @GET + 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/mvnw b/mvnw new file mode 100755 index 0000000000..fc7efd17d0 --- /dev/null +++ b/mvnw @@ -0,0 +1,234 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven2 Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # + # Look for the Apple JDKs first to preserve the existing behaviour, and then look + # for the new JDKs provided by Oracle. + # + if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK ] ; then + # + # Apple JDKs + # + export JAVA_HOME=/System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Java/JavaVirtualMachines/CurrentJDK ] ; then + # + # Apple JDKs + # + export JAVA_HOME=/System/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -L "/Library/Java/JavaVirtualMachines/CurrentJDK" ] ; then + # + # Oracle JDKs + # + export JAVA_HOME=/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -x "/usr/libexec/java_home" ]; then + # + # Apple JDKs + # + export JAVA_HOME=`/usr/libexec/java_home` + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + 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 + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Migwn, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" + # TODO classpath? +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + 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 + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + local basedir=$(pwd) + local wdir=$(pwd) + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + wdir=$(cd "$wdir/.."; pwd) + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-$(find_maven_basedir)} +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CMD_LINE_ARGS + diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000000..0d49a2de0a --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,145 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven2 Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +set MAVEN_CMD_LINE_ARGS=%MAVEN_CONFIG% %* + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" + +set WRAPPER_JAR=""%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CMD_LINE_ARGS% +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% 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..6c0fc24db0 --- /dev/null +++ b/okhttp/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'java' + +sourceCompatibility = 1.6 + +dependencies { + compile project(':feign-core') + compile 'com.squareup.okhttp3:okhttp:3.2.0' + testCompile 'junit:junit:4.12' + testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7 + testCompile 'com.squareup.okhttp3:mockwebserver:3.2.0' + testCompile project(':feign-core').sourceSets.test.output // for assertions +} diff --git a/okhttp/pom.xml b/okhttp/pom.xml new file mode 100644 index 0000000000..8ed1b3f19a --- /dev/null +++ b/okhttp/pom.xml @@ -0,0 +1,42 @@ + + 4.0.0 + + + io.github.openfeign + parent + 9.4.1-SNAPSHOT + + + feign-okhttp + Feign OkHttp + Feign OkHttp + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + com.squareup.okhttp3 + okhttp + + + + ${project.groupId} + feign-core + test-jar + test + + + + com.squareup.okhttp3 + mockwebserver + test + + + 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..4c3a014e02 --- /dev/null +++ b/okhttp/src/main/java/feign/okhttp/OkHttpClient.java @@ -0,0 +1,160 @@ +/* + * 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 okhttp3.Headers; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.util.Arrays; +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 okhttp3.OkHttpClient delegate;
+
+  public OkHttpClient() {
+    this(new okhttp3.OkHttpClient());
+  }
+
+  public OkHttpClient(okhttp3.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", "*/*");
+    }
+
+    byte[] inputBody = input.body();
+    boolean isMethodWithBody = "POST".equals(input.method()) || "PUT".equals(input.method());
+    if (isMethodWithBody && inputBody == null) {
+      // write an empty BODY to conform with okhttp 2.4.0+
+      // http://johnfeng.github.io/blog/2015/06/30/okhttp-updates-post-wouldnt-be-allowed-to-have-null-body/
+      inputBody = new byte[0];
+    }
+
+    RequestBody body = inputBody != null ? RequestBody.create(mediaType, inputBody) : null;
+    requestBuilder.method(input.method(), body);
+    return requestBuilder.build();
+  }
+
+  private static feign.Response toFeignResponse(Response input) throws IOException {
+    return feign.Response.builder()
+            .status(input.code())
+            .reason(input.message())
+            .headers(toMap(input.headers()))
+            .body(toBody(input.body()))
+            .build();
+  }
+
+  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;
+    }
+    final Integer length = input.contentLength() >= 0 && input.contentLength() <= Integer.MAX_VALUE ?
+            (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 {
+    okhttp3.OkHttpClient requestScoped;
+    if (delegate.connectTimeoutMillis() != options.connectTimeoutMillis()
+        || delegate.readTimeoutMillis() != options.readTimeoutMillis()) {
+       requestScoped = delegate.newBuilder()
+               .connectTimeout(options.connectTimeoutMillis(), TimeUnit.MILLISECONDS)
+               .readTimeout(options.readTimeoutMillis(), TimeUnit.MILLISECONDS)
+               .build();
+    } else {
+      requestScoped = delegate;
+    }
+    Request request = toOkHttpRequest(input);
+    Response response = requestScoped.newCall(request).execute();
+    return toFeignResponse(response).toBuilder().request(input).build();
+  }
+}
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..e2d68340b2
--- /dev/null
+++ b/okhttp/src/test/java/feign/okhttp/OkHttpClientTest.java
@@ -0,0 +1,30 @@
+/*
+ * 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 feign.Feign.Builder;
+import feign.client.AbstractClientTest;
+
+import feign.Feign;
+
+/** Tests client-specific behavior, such as ensuring Content-Length is sent when specified. */
+public class OkHttpClientTest extends AbstractClientTest {
+
+  @Override
+  public Builder newBuilder() {
+    return Feign.builder().client(new OkHttpClient());
+  }
+}
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000000..8fffe2d324
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,400 @@
+
+
+  4.0.0
+
+  io.github.openfeign
+  parent
+  9.4.1-SNAPSHOT
+  pom
+
+  
+    core
+    gson
+    httpclient
+    hystrix
+    jackson-jaxb
+    jackson
+    jaxb
+    jaxrs
+    okhttp
+    ribbon
+    sax
+    slf4j
+  
+
+  
+    UTF-8
+    UTF-8
+
+    
+    1.6
+    java16
+
+    
+    1.8
+    1.8
+
+    ${project.basedir}
+
+    3.2.0
+    2.5
+
+    4.12
+    
+    1.7.1
+    2.6.4
+
+    1.15
+    0.3.3
+    3.5.1
+    2.5.2
+    3.0.0
+    2.10.3
+    2.6
+    2.5.3
+    3.2.0
+    0.1.0
+  
+
+  Feign (Parent)
+  Feign makes writing java http clients easier
+  https://github.com/openfeign/feign
+  2012
+
+  
+    OpenFeign
+    https://github.com/openfeign
+  
+
+  
+    
+      The Apache Software License, Version 2.0
+      http://www.apache.org/licenses/LICENSE-2.0.txt
+      repo
+    
+  
+
+  
+    https://github.com/openfeign/feign
+    scm:git:https://github.com/openfeign/feign.git
+    scm:git:https://github.com/openfeign/feign.git
+    HEAD
+  
+
+  
+    
+      adriancole
+      Adrian Cole
+      acole@pivotal.io
+    
+    
+      spencergibb
+      Spencer Gibb
+      spencer@gibb.us
+    
+  
+
+  
+    
+      bintray
+      https://api.bintray.com/maven/openfeign/maven/feign/;publish=1
+    
+    
+      jfrog-snapshots
+      http://oss.jfrog.org/artifactory/oss-snapshot-local
+    
+  
+
+  
+    Github
+    https://github.com/openfeign/feign/issues
+  
+
+  
+    
+      
+        ${project.groupId}
+        feign-core
+        ${project.version}
+      
+
+      
+        ${project.groupId}
+        feign-core
+        ${project.version}
+        test-jar
+      
+
+      
+        ${project.groupId}
+        feign-gson
+        ${project.version}
+      
+
+      
+        ${project.groupId}
+        feign-httpclient
+        ${project.version}
+      
+
+      
+        ${project.groupId}
+        feign-hystrix
+        ${project.version}
+      
+
+      
+        ${project.groupId}
+        feign-jackson-jaxb
+        ${project.version}
+      
+
+      
+        ${project.groupId}
+        feign-jackson
+        ${project.version}
+      
+
+      
+        ${project.groupId}
+        feign-jaxb
+        ${project.version}
+      
+
+      
+        ${project.groupId}
+        feign-jaxrs
+        ${project.version}
+      
+
+      
+        ${project.groupId}
+        feign-okhttp
+        ${project.version}
+      
+
+      
+        ${project.groupId}
+        feign-ribbon
+        ${project.version}
+      
+
+      
+        ${project.groupId}
+        feign-sax
+        ${project.version}
+      
+
+      
+        ${project.groupId}
+        feign-slf4j
+        ${project.version}
+      
+
+      
+        junit
+        junit
+        ${junit.version}
+      
+
+      
+        org.jvnet
+        animal-sniffer-annotation
+        1.0
+      
+
+      
+        com.google.code.gson
+        gson
+        ${gson.version}
+      
+
+      
+        org.assertj
+        assertj-core
+        ${assertj.version}
+      
+
+      
+        com.squareup.okhttp3
+        okhttp
+        ${okhttp3.version}
+      
+
+      
+        com.squareup.okhttp3
+        mockwebserver
+        ${okhttp3.version}
+      
+    
+  
+
+  
+    
+      junit
+      junit
+      test
+    
+
+    
+      org.assertj
+      assertj-core
+      test
+    
+  
+
+  
+    
+      
+        
+        
+          io.takari
+          maven
+          ${maven-plugin.version}
+        
+
+        
+          maven-compiler-plugin
+          ${maven-compiler-plugin.version}
+        
+
+        
+          maven-jar-plugin
+          ${maven-jar-plugin.version}
+          
+            
+              ${project.build.outputDirectory}/META-INF/MANIFEST.MF
+            
+          
+        
+      
+    
+
+    
+      
+        true
+        maven-compiler-plugin
+        
+          
+          
+            default-compile
+            compile
+            
+              compile
+            
+            
+              ${main.java.version}
+              ${main.java.version}
+            
+          
+        
+      
+
+      
+        org.codehaus.mojo
+        animal-sniffer-maven-plugin
+        ${animal-sniffer-maven-plugin.version}
+        
+          
+            org.codehaus.mojo.signature
+            ${main.signature.artifact}
+            1.0
+          
+        
+        
+          
+            
+              check
+            
+          
+        
+      
+
+      
+      
+        maven-install-plugin
+        ${maven-install-plugin.version}
+        
+          true
+        
+      
+
+      
+        maven-release-plugin
+        ${maven-release-plugin.version}
+        
+          false
+          release
+          true
+          @{project.version}
+        
+      
+
+      
+        io.zipkin.centralsync-maven-plugin
+        centralsync-maven-plugin
+        ${centralsync-maven-plugin.version}
+        
+          openfeign
+          maven
+          feign
+        
+      
+
+      
+        org.apache.felix
+        maven-bundle-plugin
+        ${maven-bundle-plugin.version}
+        
+          
+            bundle-manifest
+            process-classes
+            
+              manifest
+            
+          
+        
+      
+    
+  
+
+  
+    
+      release
+      
+        
+          
+          
+            maven-source-plugin
+            ${maven-source-plugin.version}
+            
+              
+                attach-sources
+                
+                  jar
+                
+              
+            
+          
+
+          
+            maven-javadoc-plugin
+            ${maven-javadoc-plugin.version}
+            
+              false
+            
+            
+              
+                attach-javadocs
+                
+                  jar
+                
+                package
+              
+            
+          
+        
+      
+    
+  
+
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..63e86e3e05
--- /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.1'
+    testCompile 'junit:junit:4.12'
+    testCompile 'org.assertj:assertj-core:1.7.1' // last version supporting JDK 7
+    testCompile 'com.squareup.okhttp3:mockwebserver:3.2.0'
+    testCompile project(':feign-core').sourceSets.test.output
+}
diff --git a/ribbon/pom.xml b/ribbon/pom.xml
new file mode 100644
index 0000000000..51f6599825
--- /dev/null
+++ b/ribbon/pom.xml
@@ -0,0 +1,50 @@
+
+  4.0.0
+
+  
+    io.github.openfeign
+    parent
+    9.4.1-SNAPSHOT
+  
+
+  feign-ribbon
+  Feign Ribbon
+  Feign Ribbon
+
+  
+    ${project.basedir}/..
+    2.1.1
+  
+
+  
+    
+      ${project.groupId}
+      feign-core
+    
+
+    
+      com.netflix.ribbon
+      ribbon-core
+      ${ribbon-version}
+    
+
+    
+      com.netflix.ribbon
+      ribbon-loadbalancer
+      ${ribbon-version}
+    
+
+    
+      ${project.groupId}
+      feign-core
+      test-jar
+      test
+    
+
+    
+      com.squareup.okhttp3
+      mockwebserver
+      test
+    
+  
+
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..3cd4a079d6
--- /dev/null
+++ b/ribbon/src/main/java/feign/ribbon/LBClient.java
@@ -0,0 +1,162 @@
+/*
+ * 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.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..0aaa3ff759
--- /dev/null
+++ b/ribbon/src/main/java/feign/ribbon/LBClientFactory.java
@@ -0,0 +1,30 @@
+package feign.ribbon;
+
+import com.netflix.client.ClientFactory;
+import com.netflix.client.config.DefaultClientConfigImpl;
+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, DisableAutoRetriesByDefaultClientConfig.class);
+      ILoadBalancer lb = ClientFactory.getNamedLoadBalancer(clientName);
+      return LBClient.create(lb, config);
+    }
+  }
+
+  final class DisableAutoRetriesByDefaultClientConfig extends DefaultClientConfigImpl {
+    @Override
+    public int getDefaultMaxAutoRetriesNextServer() {
+      return 0;
+    }
+  }
+}
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..e9ec9adcb0
--- /dev/null
+++ b/ribbon/src/main/java/feign/ribbon/LoadBalancingTarget.java
@@ -0,0 +1,141 @@
+/*
+ * 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 String path; + private final Class type; + private final AbstractLoadBalancer lb; + + /** + * @Deprecated will be removed in Feign 10 + */ + @Deprecated + protected LoadBalancingTarget(Class type, String scheme, String name) { + this.type = checkNotNull(type, "type"); + this.scheme = checkNotNull(scheme, "scheme"); + this.name = checkNotNull(name, "name"); + this.path = ""; + this.lb = AbstractLoadBalancer.class.cast(getNamedLoadBalancer(name())); + } + + protected LoadBalancingTarget(Class type, String scheme, String name, String path) { + this.type = checkNotNull(type, "type"); + this.scheme = checkNotNull(scheme, "scheme"); + this.name = checkNotNull(name, "name"); + this.path = checkNotNull(path, "path"); + 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 url naming convention is {@code https://name} or {@code http://name/api/v2} where name + * corresponds to {@link com.netflix.client.ClientFactory#getNamedLoadBalancer(String)} + */ + public static LoadBalancingTarget create(Class type, String url) { + URI asUri = URI.create(url); + return new LoadBalancingTarget(type, asUri.getScheme(), asUri.getHost(), asUri.getPath()); + } + + @Override + public Class type() { + return type; + } + + @Override + public String name() { + return name; + } + + @Override + public String url() { + return String.format("%s://%s", scheme, path); + } + + /** + * 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%s", scheme, currentServer.getHostPort(), path); + 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 + ", path=" + path + ")"; + } +} 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..2086e0bb1e --- /dev/null +++ b/ribbon/src/main/java/feign/ribbon/RibbonClient.java @@ -0,0 +1,136 @@ +package feign.ribbon; + +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; +import java.io.IOException; +import java.net.URI; + +/** + * 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) { + propagateFirstIOException(e); + throw new RuntimeException(e); + } + } + + static void propagateFirstIOException(Throwable throwable) throws IOException { + while (throwable != null) { + if (throwable instanceof IOException) { + throw (IOException) throwable; + } + throwable = throwable.getCause(); + } + } + + 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..4456adfed5 --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/LoadBalancingTargetTest.java @@ -0,0 +1,108 @@ +/* + * 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 okhttp3.mockwebserver.MockResponse; +import okhttp3.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.url("").url()) + "," + hostAndPort( + server2.url("").url())); + + 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); + } + } + + @Test + public void loadBalancingTargetPath() throws InterruptedException { + String name = "LoadBalancingTargetTest-loadBalancingDefaultPolicyRoundRobin"; + String serverListKey = name + ".ribbon.listOfServers"; + + server1.enqueue(new MockResponse().setBody("success!")); + + getConfigInstance().setProperty(serverListKey, + hostAndPort(server1.url("").url())); + + try { + LoadBalancingTarget + target = + LoadBalancingTarget.create(TestInterface.class, "http://" + name + "/context-path"); + TestInterface api = Feign.builder().target(target); + + api.get(); + + assertEquals("http:///context-path", target.url()); + assertEquals("/context-path/servers", server1.takeRequest().getPath()); + } finally { + getConfigInstance().clearProperty(serverListKey); + } + } + + interface TestInterface { + + @RequestLine("POST /") + void post(); + + @RequestLine("GET /servers") + void get(); + } +} diff --git a/ribbon/src/test/java/feign/ribbon/PropagateFirstIOExceptionTest.java b/ribbon/src/test/java/feign/ribbon/PropagateFirstIOExceptionTest.java new file mode 100644 index 0000000000..25a2be511e --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/PropagateFirstIOExceptionTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.ribbon; + +import com.netflix.client.ClientException; +import java.io.IOException; +import java.net.ConnectException; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.hamcrest.CoreMatchers.isA; + +public class PropagateFirstIOExceptionTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void propagatesNestedIOE() throws IOException { + thrown.expect(IOException.class); + + RibbonClient.propagateFirstIOException(new ClientException(new IOException())); + } + + @Test + public void propagatesFirstNestedIOE() throws IOException { + thrown.expect(IOException.class); + thrown.expectCause(isA(IOException.class)); + + RibbonClient.propagateFirstIOException(new ClientException(new IOException(new IOException()))); + } + + /** + * Happened in practice; a blocking observable wrapped the connect exception in a runtime + * exception + */ + @Test + public void propagatesDoubleNestedIOE() throws IOException { + thrown.expect(ConnectException.class); + + RibbonClient.propagateFirstIOException( + new ClientException(new RuntimeException(new ConnectException()))); + } + + @Test + public void doesntPropagateWhenNotIOE() throws IOException { + RibbonClient.propagateFirstIOException( + new ClientException(new RuntimeException())); + } +} 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..de766475bd --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/RibbonClientTest.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.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 static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; + +import org.junit.After; +import org.junit.AfterClass; +import org.junit.BeforeClass; +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 okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.SocketPolicy; +import okhttp3.mockwebserver.MockWebServer; + +import feign.Client; +import feign.Feign; +import feign.Param; +import feign.Request; +import feign.RequestLine; +import feign.RetryableException; +import feign.Retryer; +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(); + + private static String oldRetryConfig = null; + + private static final String SUN_RETRY_PROPERTY = "sun.net.http.retryPost"; + + @BeforeClass + public static void disableSunRetry() throws Exception { + // The Sun HTTP Client retries all requests once on an IOException, which makes testing retry code harder than would + // be ideal. We can only disable it for post, so lets at least do that. + oldRetryConfig = System.setProperty(SUN_RETRY_PROPERTY, "false"); + } + + @AfterClass + public static void resetSunRetry() throws Exception { + if (oldRetryConfig == null) { + System.clearProperty(SUN_RETRY_PROPERTY); + } else { + System.setProperty(SUN_RETRY_PROPERTY, oldRetryConfig); + } + } + + 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.url("").url()) + "," + hostAndPort( + server2.url("").url())); + + 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.url("").url())); + + 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()) + } + + @Test + public void ioExceptionFailsAfterTooManyFailures() throws IOException, InterruptedException { + server1.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + server1.enqueue(new MockResponse().setBody("success!")); + + getConfigInstance().setProperty(serverListKey(), hostAndPort(server1.url("").url())); + + TestInterface + api = + Feign.builder().client(RibbonClient.create()).retryer(Retryer.NEVER_RETRY) + .target(TestInterface.class, "http://" + client()); + + try { + api.post(); + fail("No exception thrown"); + } catch (RetryableException ignored) { + + } + assertEquals(1, server1.getRequestCount()); + // TODO: verify ribbon stats match + // assertEquals(target.lb().getLoadBalancerStats().getSingleServerStat()) + } + + @Test + public void ribbonRetryConfigurationOnSameServer() throws IOException, InterruptedException { + server1.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + server1.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + server2.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + server2.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + + getConfigInstance().setProperty(serverListKey(), hostAndPort(server1.url("").url()) + "," + hostAndPort(server2.url("").url())); + getConfigInstance().setProperty(client() + ".ribbon.MaxAutoRetries", 1); + + TestInterface + api = + Feign.builder().client(RibbonClient.create()).retryer(Retryer.NEVER_RETRY) + .target(TestInterface.class, "http://" + client()); + + try { + api.post(); + fail("No exception thrown"); + } catch (RetryableException ignored) { + + } + assertTrue(server1.getRequestCount() == 2 || server2.getRequestCount() == 2); + assertEquals(2, server1.getRequestCount() + server2.getRequestCount()); + // TODO: verify ribbon stats match + // assertEquals(target.lb().getLoadBalancerStats().getSingleServerStat()) + } + + @Test + public void ribbonRetryConfigurationOnMultipleServers() throws IOException, InterruptedException { + server1.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + server1.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + server2.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + server2.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)); + + getConfigInstance().setProperty(serverListKey(), hostAndPort(server1.url("").url()) + "," + hostAndPort(server2.url("").url())); + getConfigInstance().setProperty(client() + ".ribbon.MaxAutoRetriesNextServer", 1); + + TestInterface + api = + Feign.builder().client(RibbonClient.create()).retryer(Retryer.NEVER_RETRY) + .target(TestInterface.class, "http://" + client()); + + try { + api.post(); + fail("No exception thrown"); + } catch (RetryableException ignored) { + + } + assertEquals(1, server1.getRequestCount()); + assertEquals(1, server2.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.url("").url())); + + 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.url("").url())); + + 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.url("").url())); + + 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/pom.xml b/sax/pom.xml new file mode 100644 index 0000000000..85da80fef7 --- /dev/null +++ b/sax/pom.xml @@ -0,0 +1,31 @@ + + 4.0.0 + + + io.github.openfeign + parent + 9.4.1-SNAPSHOT + + + feign-sax + Feign SAX + Feign SAX + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + ${project.groupId} + feign-core + test-jar + test + + + 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..b00817055d --- /dev/null +++ b/sax/src/main/java/feign/sax/SAXDecoder.java @@ -0,0 +1,171 @@ +/* + * 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.Util; +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.status() == 404) return Util.emptyValueOf(type); + 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); + /* Explicitly control sax configuration to prevent XXE attacks */ + xmlReader.setFeature("http://xml.org/sax/features/external-general-entities", false); + xmlReader.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + xmlReader.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false); + xmlReader.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", 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..211576f9bf --- /dev/null +++ b/sax/src/test/java/feign/sax/SAXDecoderTest.java @@ -0,0 +1,161 @@ +/* + * 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.assertj.core.api.Assertions.assertThat; +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.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .body(statusFailed, UTF_8) + .build(); + } + + @Test + public void nullBodyDecodesToNull() throws Exception { + Response response = Response.builder() + .status(204) + .reason("OK") + .headers(Collections.>emptyMap()) + .build(); + assertNull(decoder.decode(response, String.class)); + } + + /** Enabled via {@link feign.Feign.Builder#decode404()} */ + @Test + public void notFoundDecodesToEmpty() throws Exception { + Response response = Response.builder() + .status(404) + .reason("NOT FOUND") + .headers(Collections.>emptyMap()) + .build(); + assertThat((byte[]) decoder.decode(response, byte[].class)).isEmpty(); + } + + 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/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..0dbc444542 --- /dev/null +++ b/slf4j/build.gradle @@ -0,0 +1,11 @@ +apply plugin: 'java' + +sourceCompatibility = 1.6 + +dependencies { + compile project(':feign-core') + compile 'org.slf4j:slf4j-api:1.7.13' + 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.13' +} diff --git a/slf4j/pom.xml b/slf4j/pom.xml new file mode 100644 index 0000000000..46f9b5144c --- /dev/null +++ b/slf4j/pom.xml @@ -0,0 +1,45 @@ + + 4.0.0 + + + io.github.openfeign + parent + 9.4.1-SNAPSHOT + + + feign-slf4j + Feign SLF4J + Feign SLF4J + + + ${project.basedir}/.. + 1.7.13 + + + + + ${project.groupId} + feign-core + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + ${project.groupId} + feign-core + test-jar + test + + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + 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..6f3d684da7 --- /dev/null +++ b/slf4j/src/main/java/feign/slf4j/Slf4jLogger.java @@ -0,0 +1,75 @@ +/* + * Copyright 2013 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.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. + if (logger.isDebugEnabled()) { + 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..f2fc035074 --- /dev/null +++ b/slf4j/src/test/java/feign/slf4j/Slf4jLoggerTest.java @@ -0,0 +1,106 @@ +/* + * 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.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .body(new byte[0]) + .build(); + @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); + } +} diff --git a/travis/publish.sh b/travis/publish.sh new file mode 100755 index 0000000000..8b2f607459 --- /dev/null +++ b/travis/publish.sh @@ -0,0 +1,119 @@ +# taken from OpenZipkin + +set -euo pipefail +set -x + +build_started_by_tag() { + if [ "${TRAVIS_TAG}" == "" ]; then + echo "[Publishing] This build was not started by a tag, publishing snapshot" + return 1 + else + echo "[Publishing] This build was started by the tag ${TRAVIS_TAG}, publishing release" + return 0 + fi +} + +is_pull_request() { + if [ "${TRAVIS_PULL_REQUEST}" != "false" ]; then + echo "[Not Publishing] This is a Pull Request" + return 0 + else + echo "[Publishing] This is not a Pull Request" + return 1 + fi +} + +is_travis_branch_master() { + if [ "${TRAVIS_BRANCH}" = master ]; then + echo "[Publishing] Travis branch is master" + return 0 + else + echo "[Not Publishing] Travis branch is not master" + return 1 + fi +} + +check_travis_branch_equals_travis_tag() { + #Weird comparison comparing branch to tag because when you 'git push --tags' + #the branch somehow becomes the tag value + #github issue: https://github.com/travis-ci/travis-ci/issues/1675 + if [ "${TRAVIS_BRANCH}" != "${TRAVIS_TAG}" ]; then + echo "Travis branch does not equal Travis tag, which it should, bailing out." + echo " github issue: https://github.com/travis-ci/travis-ci/issues/1675" + exit 1 + else + echo "[Publishing] Branch (${TRAVIS_BRANCH}) same as Tag (${TRAVIS_TAG})" + fi +} + +check_release_tag() { + tag="${TRAVIS_TAG}" + if [[ "$tag" =~ ^[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$ ]]; then + echo "Build started by version tag $tag. During the release process tags like this" + echo "are created by the 'release' Maven plugin. Nothing to do here." + exit 0 + elif [[ ! "$tag" =~ ^release-[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$ ]]; then + echo "You must specify a tag of the format 'release-0.0.0' to release this project." + echo "The provided tag ${tag} doesn't match that. Aborting." + exit 1 + fi +} + +is_release_commit() { + project_version=$(./mvnw help:evaluate -N -Dexpression=project.version|grep -v '\[') + if [[ "$project_version" =~ ^[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+$ ]]; then + echo "Build started by release commit $project_version. Will synchronize to maven central." + return 0 + else + return 1 + fi +} + +release_version() { + echo "${TRAVIS_TAG}" | sed 's/^release-//' +} + +safe_checkout_master() { + # We need to be on a branch for release:perform to be able to create commits, and we want that branch to be master. + # But we also want to make sure that we build and release exactly the tagged version, so we verify that the remote + # master is where our tag is. + git checkout -B master + git fetch origin master:origin/master + commit_local_master="$(git show --pretty='format:%H' master)" + commit_remote_master="$(git show --pretty='format:%H' origin/master)" + if [ "$commit_local_master" != "$commit_remote_master" ]; then + echo "Master on remote 'origin' has commits since the version under release, aborting" + exit 1 + fi +} + +#---------------------- +# MAIN +#---------------------- + +if ! is_pull_request && build_started_by_tag; then + check_travis_branch_equals_travis_tag + check_release_tag +fi + +./mvnw install -nsu + +# If we are on a pull request, our only job is to run tests, which happened above via ./mvnw install +if is_pull_request; then + true +# If we are on master, we will deploy the latest snapshot or release version +# - If a release commit fails to deploy for a transient reason, delete the broken version from bintray and click rebuild +elif is_travis_branch_master; then + ./mvnw --batch-mode -s ./.settings.xml -Prelease -nsu -DskipTests deploy + + # If the deployment succeeded, sync it to Maven Central. Note: this needs to be done once per project, not module, hence -N + if is_release_commit; then + ./mvnw --batch-mode -s ./.settings.xml -nsu -N io.zipkin.centralsync-maven-plugin:centralsync-maven-plugin:sync + fi + +# If we are on a release tag, the following will update any version references and push a version tag for deployment. +elif build_started_by_tag; then + safe_checkout_master + ./mvnw --batch-mode -s ./.settings.xml -Prelease -nsu -DreleaseVersion="$(release_version)" -Darguments="-DskipTests" release:prepare +fi +