diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..4e740783f --- /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 000000000..0e7dabeff --- /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 000000000..8b1378917 --- /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 000000000..c6feb8bb6 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 000000000..c9023edfe --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.5.4/apache-maven-3.5.4-bin.zip \ No newline at end of file diff --git a/.settings.xml b/.settings.xml new file mode 100644 index 000000000..7a3e2f9d4 --- /dev/null +++ b/.settings.xml @@ -0,0 +1,43 @@ + + + + + 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 000000000..5b5ac1ab7 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,62 @@ +# 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 +dist: trusty +sudo: false +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 + +cache: + directories: + - $HOME/.m2 + +matrix: + include: + - os: linux + jdk: oraclejdk8 + addons: + apt: + packages: + - oracle-java8-installer + - os: linux + jdk: openjdk8 + - os: linux + jdk: openjdk11 + +# 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: "dG1Qt8bqe3TsmLOmYpWYsI55N0zLWCsupdpS7zMOedpM2q0laac56uc2gGV6qQIPdJQdCWzr9CE/h1nG4lJdJfreC13reQ3PDF79Yh8tMvdO1iwrSeIQ7eeRY6hs72GUtdIhfwetUgwCgIJpmBHS7O3yJhxQAOmu5twAuABiuSE=" + # Ex. travis encrypt GH_USER=your_github_account --add + - secure: "DY28uU8wadasLCWSpl6KJyilGAAjSKzr3VPQ8by02eLDaAgCVq5KeYM0tjM804Rzhq3bjcXofaldj9QpWNTYC5SL6IIN5I5W+dWIZ8JzZ/rjOZgtJMMr4zcjOc5set9MsTUirB694m3c8bzhQZkah9YwUa/OuX1D8Ym/806igsE=" + # Ex. travis encrypt GH_TOKEN=XXX-https://github.com/settings/tokens-XXX --add + - secure: "NmydUhuJLZ/Eg0cpCz6eZiYvsLHtSYrLIAOT2VHfUdzl/Q3PGXoodTpTqRkW7Uuj5lSYYw6cQnhiTly2dvomQYj+es5hSfIzFLvlF0x7L+aFX2IySJhn2Cg8tp5H0hn2UL8t6jDfmdJrLwGKT6EsiXYIgt4dPWJ7ZZ1SRDFp2Cg=" + # 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 000000000..bc37ebba4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,278 @@ +### Version 10.1 +* Refactoring RequestTemplate to RFC6570 (#778) +* Allow JAXB context caching in factory (#761) +* Reactive Wrapper Support (#795) +* Introduced native http2 client using Java 11 (#806) +* Unwrap RetryableException and throw cause (#737) +* Supports PATCH without a body paramter (#824) +* Feign-Ribbon integration now depends on Ribbon 2.3.0, updated from Ribbon 2.1.1 (#826) + +### Version 10.0 +* Feign baseline is now JDK 8 + - Feign is now being built and tested with OpenJDK 11 as well. Releases and code base will use JDK 8, we are just testing compatibility with JDK 11. +* Removed @Deprecated methods marked for removal on feign 10. +* `RetryException` includes the `Method` used for the offending `Request`. +* `Response` objects now contain the `Request` used. + +### Version 9.6 +* Feign builder now supports flag `doNotCloseAfterDecode` to support lazy iteration of responses. +* Adds `JacksonIteratorDecoder` and `StreamDecoder` to decode responses as `java.util.Iterator` or `java.util.stream.Stream`. + +### Version 9.5.1 +* When specified, Content-Type header is now included on OkHttp requests lacking a body. +* Sets empty HttpEntity if apache request body is null. + +### Version 9.5 +* Introduces `feign-java8` with support for `java.util.Optional` +* Adds `Feign.Builder.mapAndDecode()` to allow response preprocessing before decoding it. + +### Version 9.4.1 +* 404 responses are no longer swallowed for `void` return types. + +### 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 000000000..126217304 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# 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 use the feign code format conventions. If you use Eclipse `m2eclipse` should take care of all settings automatically. +You can also import formatter settings using the [`eclipse-java-style.xml`](https://github.com/OpenFeign/feign/blob/master/src/config/eclipse-java-style.xml) file. +If using IntelliJ IDEA, you can use the [Eclipse Code Formatter Plugin](http://plugins.jetbrains.com/plugin/6546) to import the same file. + +## 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 2012 The Feign Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT 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 000000000..7e15d3166 --- /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/OpenFeign/feign) or [raise an issue](https://github.com/OpenFeign/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 000000000..8c9fd075f --- /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 The Feign Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT 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 000000000..f547124cf --- /dev/null +++ b/NOTICE @@ -0,0 +1,4 @@ +Feign +Copyright 2012 The Feign Authors. + +Portions of this software developed by Commerce Technologies, Inc. diff --git a/OSSMETADATA b/OSSMETADATA new file mode 100644 index 000000000..b6f4252ce --- /dev/null +++ b/OSSMETADATA @@ -0,0 +1 @@ +osslifecycle=archived diff --git a/README.md b/README.md index ebf660a86..a7bac304e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,868 @@ -feign -===== +# Feign makes writing java http clients easier + +[![Join the chat at https://gitter.im/OpenFeign/feign](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/OpenFeign/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) +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.github.openfeign/feign-core/badge.png)](https://search.maven.org/artifact/io.github.openfeign/feign-core/) + +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? + +Feign uses tools like Jersey and CXF to write java clients for ReST or SOAP services. Furthermore, Feign allows you to write your own code on top of http libraries such as Apache HC. Feign connects your code to http APIs with minimal overhead and code via customizable decoders and error handling, which can be written to any text-based http API. + +### How does Feign work? + +Feign works by processing annotations into a templatized request. Arguments are applied to these templates in a straightforward fashion before output. Although Feign is limited to supporting text-based APIs, it dramatically simplifies system aspects such as replaying requests. Furthermore, Feign makes it easy to unit test your conversions knowing this. + +### Java Version Compatibility + +Feign 10.x and above are built on Java 8 and should work on Java 9, 10, and 11. For those that need JDK 6 compatibility, please use Feign 9.x + +### 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); +} + +public static class Contributor { + String login; + int contributions; +} + +public class MyApp { + 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("OpenFeign", "feign"); + for (Contributor contributor : contributors) { + System.out.println(contributor.login + " (" + contributor.contributions + ")"); + } + } +} +``` + +### Interface Annotations + +Feign annotations define the `Contract` between the interface and how the underlying client +should work. Feign's default contract defines the following annotations: + +| Annotation | Interface Target | Usage | +|----------------|------------------|-------| +| `@RequestLine` | Method | Defines the `HttpMethod` and `UriTemplate` for request. `Expressions`, values wrapped in curly-braces `{expression}` are resolved using their corresponding `@Param` annotated parameters. | +| `@Param` | Parameter | Defines a template variable, whose value will be used to resolve the corresponding template `Expression`, by name. | +| `@Headers` | Method, Type | Defines a `HeaderTemplate`; a variation on a `UriTemplate`. that uses `@Param` annotated values to resolve the corresponding `Expressions`. When used on a `Type`, the template will be applied to every request. When used on a `Method`, the template will apply only to the annotated method. | +| `@QueryMap` | Parameter | Defines a `Map` of name-value pairs, or POJO, to expand into a query string. | +| `@HeaderMap` | Parameter | Defines a `Map` of name-value pairs, to expand into `Http Headers` | +| `@Body` | Method | Defines a `Template`, similar to a `UriTemplate` and `HeaderTemplate`, that uses `@Param` annotated values to resolve the corresponding `Expressions`.| + +### Templates and Expressions + +Feign `Expressions` represent Simple String Expressions (Level 1) as defined by [URI Template - RFC 6570](https://tools.ietf.org/html/rfc6570). `Expressions` are expanded using +their corresponding `Param` annotated method parameters. + +*Example* + +```java +public interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repository); + + class Contributor { + String login; + int contributions; + } +} + +public class MyApp { + public static void main(String[] args) { + GitHub github = Feign.builder() + .decoder(new GsonDecoder()) + .target(GitHub.class, "https://api.github.com"); + + /* The owner and repository parameters will be used to expand the owner and repo expressions + * defined in the RequestLine. + * + * the resulting uri will be https://api.github.com/repos/OpenFeign/feign/contributors + */ + github.contributors("OpenFeign", "feign"); + } +} +``` + +Expressions must be enclosed in curly braces `{}` and may contain regular expression patterns, separated by a colon `:` to restrict +resolved values. *Example* `owner` must be alphabetic. `{owner:[a-zA-Z]*}` + +#### Request Parameter Expansion + +`RequestLine` and `QueryMap` templates follow the [URI Template - RFC 6570](https://tools.ietf.org/html/rfc6570) specification for Level 1 templates, which specifies the following: + +* Unresolved expressions are omitted. +* All literals and variable values are pct-encoded, if not already encoded or marked `encoded` via a `@Param` annotation. + +#### Undefined vs. Empty Values #### + +Undefined expressions are expressions where the value for the expression is an explicit `null` or no value is provided. +Per [URI Template - RFC 6570](https://tools.ietf.org/html/rfc6570), it is possible to provide an empty value +for an expression. When Feign resolves an expression, it first determines if the value is defined, if it is then +the query parameter will remain. If the expression is undefined, the query parameter is removed. See below +for a complete breakdown. + +*Empty String* +```java +public void test() { + Map parameters = new LinkedHashMap<>(); + parameters.put("param", ""); + this.demoClient.test(parameters); +} +``` +Result +``` +http://localhost:8080/test?param= +``` + +*Missing* +```java +public void test() { + Map parameters = new LinkedHashMap<>(); + this.demoClient.test(parameters); +} +``` +Result +``` +http://localhost:8080/test +``` + +*Undefined* +```java +public void test() { + Map parameters = new LinkedHashMap<>(); + parameters.put("param", null); + this.demoClient.test(parameters); +} +``` +Result +``` +http://localhost:8080/test +``` + +See [Advanced Usage](#advanced-usage) for more examples. + +> **What about slashes? `/`** +> +> `@RequestLine` and `@QueryMap` templates do not encode slash `/` characters by default. To change this behavior, set the `decodeSlash` property on the `@RequestLine` to `false`. + +> **What about plus? `+`** +> +> Per the URI specification, a `+` sign is allowed in both the path and query segments of a URI, however, handling of +> the symbol on the query can be inconsistent. In some legacy systems, the `+` is equivalent to the a space. Feign takes the approach of modern systems, where a +> `+` symbol should not represent a space and is explicitly encoded as `%2B` when found on a query string. +> +> If you wish to use `+` as a space, then use the literal ` ` character or encode the value directly as `%20` + +##### Custom Expansion + +The `@Param` annotation has an optional property `expander` allowing for complete control over the individual parameter's expansion. +The `expander` property must reference a class that implements the `Expander` interface: + +```java +public interface Expander { + String expand(Object value); +} +``` +The result of this method adheres to the same rules stated above. If the result is `null` or an empty string, +the value is omitted. If the value is not pct-encoded, it will be. See [Custom @Param Expansion](#custom-param-expansion) for more examples. + +#### Request Headers Expansion + +`Headers` and `HeaderMap` templates follow the same rules as [Request Parameter Expansion](#request-parameter-expansion) +with the following alterations: + +* Unresolved expressions are omitted. If the result is an empty header value, the entire header is removed. +* No pct-encoding is performed. + +See [Headers](#headers) for examples. + +> **A Note on `@Param` parameters and their names**: +> +> All expressions with the same name, regardless of their position on the `@RequestLine`, `@QueryMap`, `@BodyTemplate`, or `@Headers` will resolve to the same value. +> In the following example, the value of `contentType`, will be used to resolve both the header and path expression: +> +> ```java +> public interface ContentService { +> @RequestLine("GET /api/documents/{contentType}") +> @Headers("Accept: {contentType}") +> String getDocumentByType(@Param("contentType") String type); +> } +>``` +> +> Keep this in mind when designing your interfaces. + +#### Request Body Expansion + +`Body` templates follow the same rules as [Request Parameter Expansion](#request-parameter-expansion) +with the following alterations: + +* Unresolved expressions are omitted. +* Expanded value will **not** be passed through an `Encoder` before being placed on the request body. +* A `Content-Type` header must be specified. See [Body Templates](#body-templates) for examples. + +--- +### 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); +} + +public class BankService { + public static void main(String[] args) { + 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 +public class CloudService { + public static void main(String[] args) { + CloudDNS cloudDNS = Feign.builder() + .target(new CloudIdentityTarget(user, apiKey)); + } + + class CloudIdentityTarget extends Target { + /* implementation of a Target */ + } +} +``` + +### 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 with other Open Source tools. 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 +public class Example { + public static void main(String[] args) { + 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 +public class Example { + public static void main(String[] args) { + 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 +public class Example { + public static void main(String[] args) { + Api 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 +public class Example { + public static void main(String[] args) { + Api 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); +} + +public class Example { + public static void main(String[] args) { + 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 +public class Example { + public static void main(String[] args) { + 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 +public class Example { + public static void main(String[] args) { + MyService api = Feign.builder() + .client(RibbonClient.create()) + .target(MyService.class, "https://myAppProd"); + } +} +``` + +### Java 11 Http2 +[Http2Client](./java11) directs Feign's http requests to Java11 [New HTTP/2 Client](http://www.javamagazine.mozaicreader.com/JulyAug2017#&pageSet=39&page=0) that implements HTTP/2. + +To use New HTTP/2 Client with Feign, use Java SDK 11. Then, configure Feign to use the Http2Client: + +```java +GitHub github = Feign.builder() + .client(new Http2Client()) + .target(GitHub.class, "https://api.github.com"); +``` + +### 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 +public class Example { + public static void main(String[] args) { + MyService api = HystrixFeign.builder().target(MyService.class, "https://myAppProd"); + } +} +``` + +### SOAP +[SOAP](./soap) includes an encoder and decoder you can use with an XML API. + + +This module adds support for encoding and decoding SOAP Body objects via JAXB and SOAPMessage. It also provides SOAPFault decoding capabilities by wrapping them into the original `javax.xml.ws.soap.SOAPFaultException`, so that you'll only need to catch `SOAPFaultException` in order to handle SOAPFault. + +Add `SOAPEncoder` and/or `SOAPDecoder` to your `Feign.Builder` like so: + +```java +public class Example { + public static void main(String[] args) { + Api api = Feign.builder() + .encoder(new SOAPEncoder(jaxbFactory)) + .decoder(new SOAPDecoder(jaxbFactory)) + .errorDecoder(new SOAPErrorDecoder()) + .target(MyApi.class, "http://api"); + } +} +``` + +NB: you may also need to add `SOAPErrorDecoder` if SOAP Faults are returned in response with error http codes (4xx, 5xx, ...) + +### 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 +public class Example { + public static void main(String[] args) { + 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 +public class Example { + public static void main(String[] args) { + GitHub github = Feign.builder() + .decoder(new GsonDecoder()) + .target(GitHub.class, "https://api.github.com"); + } +} +``` + +If you need to pre-process the response before give it to the Decoder, you can use the `mapAndDecode` builder method. +An example use case is dealing with an API that only serves jsonp, you will maybe need to unwrap the jsonp before +send it to the Json decoder of your choice: + +```java +public class Example { + public static void main(String[] args) { + JsonpApi jsonpApi = Feign.builder() + .mapAndDecode((response, type) -> jsopUnwrap(response, type), new GsonDecoder()) + .target(JsonpApi.class, "https://some-jsonp-api.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); +} + +public class Example { + public static void main(String[] args) { + 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); +} + +public class Example { + public static void main(String[] args) { + 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); +} + +public class Example { + public static void main(String[] args) { + 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 key, V value); +} +``` + +Methods can specify dynamic content for static headers using variable expansion in `@Headers`. + +```java +public interface Api { + @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 +public interface Api { + @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(); + } + } + + public class Example { + public static void main(String[] args) { + 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 +public class Example { + public static void main(String[] args) { + 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"); + } +} + +public class Example { + public static void main(String[] args) { + 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 +public class Example { + public static void main(String[] args) { + 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 +public interface Api { + @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 +public interface Api { + @RequestLine("GET /find") + V find(@QueryMap Map queryMap); +} +``` + +This may also be used to generate the query parameters from a POJO object using a `QueryMapEncoder`. + +```java +public interface Api { + @RequestLine("GET /find") + V find(@QueryMap CustomPojo customPojo); +} +``` + +When used in this manner, without specifying a custom `QueryMapEncoder`, the query map will be generated using member variable names as query parameter names. The following POJO will generate query params of "/find?name={name}&number={number}" (order of included query parameters not guaranteed, and as usual, if any value is null, it will be left out). + +```java +public class CustomPojo { + private final String name; + private final int number; + + public CustomPojo (String name, int number) { + this.name = name; + this.number = number; + } +} +``` + +To setup a custom `QueryMapEncoder`: + +```java +public class Example { + public static void main(String[] args) { + MyApi myApi = Feign.builder() + .queryMapEncoder(new MyCustomQueryMapEncoder()) + .target(MyApi.class, "https://api.hostname.com"); + } +} +``` + +When annotating objects with @QueryMap, the default encoder uses reflection to inspect provided objects Fields to expand the objects values into a query string. If you prefer that the query string be built using getter and setter methods, as defined in the Java Beans API, please use the BeanQueryMapEncoder + +```java +public class Example { + public static void main(String[] args) { + MyApi myApi = Feign.builder() + .queryMapEncoder(new BeanQueryMapEncoder()) + .target(MyApi.class, "https://api.hostname.com"); + } +} +``` + +### Error Handling +If you need more control over handling unexpected responses, Feign instances can +register a custom `ErrorDecoder` via the builder. + +```java +public class Example { + public static void main(String[] args) { + MyApi myApi = Feign.builder() + .errorDecoder(new MyErrorDecoder()) + .target(MyApi.class, "https://api.hostname.com"); + } +} +``` + +All responses that result in an HTTP status not in the 2xx range will trigger the `ErrorDecoder`'s `decode` method, allowing +you to handle the response, wrap the failure into a custom exception or perform any additional processing. +If you want to retry the request again, throw a `RetryableException`. This will invoke the registered +`Retryer`. + +### Retry +Feign, by default, will automatically retry `IOException`s, regardless of HTTP method, treating them as transient network +related exceptions, and any `RetryableException` thrown from an `ErrorDecoder`. To customize this +behavior, register a custom `Retryer` instance via the builder. + +```java +public class Example { + public static void main(String[] args) { + MyApi myApi = Feign.builder() + .retryer(new MyRetryer()) + .target(MyApi.class, "https://api.hostname.com"); + } +} +``` + +`Retryer`s are responsible for determining if a retry should occur by returning either a `true` or +`false` from the method `continueOrPropagate(RetryableException e);` A `Retryer` instance will be +created for each `Client` execution, allowing you to maintain state bewteen each request if desired. + +If the retry is determined to be unsuccessful, the last `RetryException` will be thrown. To throw the original +cause that led to the unsuccessful retry, build your Feign client with the `exceptionPropagationPolicy()` option. + +#### 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 000000000..78285c2b1 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,72 @@ +# 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=" +``` + +### Troubleshooting invalid credentials + +If you receive a '401 unauthorized' failure from jCenter or Bintray, it is +likely `BINTRAY_USER` or `BINTRAY_KEY` entries are invalid, or possibly the user +associated with them does not have rights to upload. + +The least destructive test is to try to publish a snapshot manually. By passing +the values Travis would use, you can kick off a snapshot from your laptop. This +is a good way to validate that your unencrypted credentials are authorized. + +Here's an example of a snapshot deploy with specified credentials. +```bash +$ BINTRAY_USER=adrianmole BINTRAY_KEY=ed6f20bde9123bbb2312b221 TRAVIS_PULL_REQUEST=false TRAVIS_TAG= TRAVIS_BRANCH=master travis/publish.sh +``` + +## First release of the year + +The license plugin verifies license headers of files include a copyright notice indicating the years a file was affected. +This information is taken from git history. There's a once-a-year problem with files that include version numbers (pom.xml). +When a release tag is made, it increments version numbers, then commits them to git. On the first release of the year, +further commands will fail due to the version increments invalidating the copyright statement. The way to sort this out is +the following: + +Before you do the first release of the year, move the SNAPSHOT version back and forth from whatever the current is. +In-between, re-apply the licenses. +```bash +$ ./mvnw versions:set -DnewVersion=1.3.3-SNAPSHOT -DgenerateBackupPoms=false +$ ./mvnw com.mycila:license-maven-plugin:format +$ ./mvnw versions:set -DnewVersion=1.3.2-SNAPSHOT -DgenerateBackupPoms=false +$ git commit -am"Adjusts copyright headers for this year" +``` diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 000000000..43decf782 --- /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 000000000..0ce1d9189 --- /dev/null +++ b/benchmark/pom.xml @@ -0,0 +1,155 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + + feign-benchmark + Feign Benchmark (JMH) + + + 1.20 + + 1.8 + java18 + ${project.basedir}/.. + 1.8 + 1.8 + + + + + ${project.groupId} + feign-core + ${project.version} + + + ${project.groupId} + feign-okhttp + ${project.version} + + + ${project.groupId} + feign-jackson + ${project.version} + + + com.squareup.okhttp3 + mockwebserver + + + org.bouncycastle + bcprov-jdk15on + 1.60 + + + io.reactivex + rxnetty-http + 0.5.2 + + + io.reactivex + rxnetty-spectator-http + 0.5.2 + + + io.reactivex + rxnetty-common + 0.5.2 + + + io.reactivex + rxnetty-tcp + 0.5.2 + + + io.netty + netty-buffer + 4.1.5.Final + compile + + + io.reactivex + rxjava + 1.2.6 + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + provided + + + org.slf4j + slf4j-nop + + + + + + 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/DecoderIteratorsBenchmark.java b/benchmark/src/main/java/feign/benchmark/DecoderIteratorsBenchmark.java new file mode 100644 index 000000000..c412df9b5 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/DecoderIteratorsBenchmark.java @@ -0,0 +1,123 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.benchmark; + +import com.fasterxml.jackson.core.type.TypeReference; +import feign.Request; +import feign.Request.HttpMethod; +import feign.Response; +import feign.Util; +import feign.codec.Decoder; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonIteratorDecoder; +import feign.stream.StreamDecoder; +import org.openjdk.jmh.annotations.*; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +/** + * This test shows up how fast different json array response processing implementations are. + */ +@State(Scope.Thread) +public class DecoderIteratorsBenchmark { + + @Param({"list", "iterator", "stream"}) + private String api; + + @Param({"10", "100"}) + private String size; + + private Response response; + + private Decoder decoder; + private Type type; + + @Benchmark + @Warmup(iterations = 5, time = 1) + @Measurement(iterations = 10, time = 1) + @Fork(3) + @BenchmarkMode(Mode.AverageTime) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public void decode() throws Exception { + fetch(decoder.decode(response, type)); + } + + @SuppressWarnings("unchecked") + private void fetch(Object o) { + Iterator cars; + + if (o instanceof Collection) { + cars = ((Collection) o).iterator(); + } else if (o instanceof Stream) { + cars = ((Stream) o).iterator(); + } else { + cars = (Iterator) o; + } + + while (cars.hasNext()) { + cars.next(); + } + } + + @Setup(Level.Invocation) + public void buildResponse() { + response = Response.builder() + .status(200) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(carsJson(Integer.valueOf(size)), Util.UTF_8) + .build(); + } + + @Setup(Level.Trial) + public void buildDecoder() { + switch (api) { + case "list": + decoder = new JacksonDecoder(); + type = new TypeReference>() {}.getType(); + break; + case "iterator": + decoder = JacksonIteratorDecoder.create(); + type = new TypeReference>() {}.getType(); + break; + case "stream": + decoder = StreamDecoder.create(JacksonIteratorDecoder.create()); + type = new TypeReference>() {}.getType(); + break; + default: + throw new IllegalStateException("Unknown api: " + api); + } + } + + private String carsJson(int count) { + String car = "{\"name\":\"c4\",\"manufacturer\":\"Citroën\"}"; + StringBuilder builder = new StringBuilder("["); + builder.append(car); + for (int i = 1; i < count; i++) { + builder.append(",").append(car); + } + return builder.append("]").toString(); + } + + static class Car { + public String name; + public String manufacturer; + } +} 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 000000000..d7a0fe0d2 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/FeignTestInterface.java @@ -0,0 +1,50 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +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 000000000..146b619d5 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/RealRequestBenchmarks.java @@ -0,0 +1,104 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.benchmark; + + + +import feign.Feign; +import feign.Logger; +import feign.Logger.Level; +import feign.Response; +import feign.Retryer; +import io.reactivex.netty.protocol.http.HttpHandlerNames; +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; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import io.netty.buffer.ByteBuf; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.internal.http.HttpHeaders; +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 rx.Observable; + +@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 = HttpServer.newServer(SERVER_PORT) + .start((request, response) -> null); + client = new OkHttpClient(); + client.retryOnConnectionFailure(); + okFeign = Feign.builder() + .client(new feign.okhttp.OkHttpClient(client)) + .logLevel(Level.NONE) + .logger(new Logger.ErrorLogger()) + .retryer(new Retryer.Default()) + .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 okhttp3.Response query_baseCaseUsingOkHttp() throws IOException { + okhttp3.Response result = client.newCall(queryRequest).execute(); + result.body().close(); + return result; + } + + /** + * How fast can we execute get commands synchronously using Feign? + */ + @Benchmark + public boolean query_feignUsingOkHttp() { + /* auto close the response */ + try (Response ignored = okFeign.query()) { + return true; + } + } +} 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 000000000..9bdda2ea2 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/WhatShouldWeCacheBenchmarks.java @@ -0,0 +1,126 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +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.builder() + .body((byte[]) null) + .status(200) + .headers(headers) + .reason("ok") + .request(request) + .build(); + } + }; + 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 000000000..3c5706e5c --- /dev/null +++ b/codequality/checkstyle.xml @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 000000000..b369dc17b --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,82 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + + feign-core + Feign Core + Feign Core + + + ${project.basedir}/.. + + + + + org.jvnet + animal-sniffer-annotation + true + + + + com.squareup.okhttp3 + mockwebserver + test + + + + com.google.code.gson + gson + test + + + + org.springframework + spring-context + 4.2.5.RELEASE + test + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + 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 000000000..5f9ac346b --- /dev/null +++ b/core/src/main/java/feign/Body.java @@ -0,0 +1,42 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.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 000000000..eb30c2e04 --- /dev/null +++ b/core/src/main/java/feign/Client.java @@ -0,0 +1,178 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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, request); + } + + 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(options.isFollowRedirects()); + connection.setRequestMethod(request.httpMethod().name()); + + 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, Request request) 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) + .request(request) + .body(stream, length) + .build(); + } + } +} diff --git a/core/src/main/java/feign/CollectionFormat.java b/core/src/main/java/feign/CollectionFormat.java new file mode 100644 index 000000000..14909da41 --- /dev/null +++ b/core/src/main/java/feign/CollectionFormat.java @@ -0,0 +1,96 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.template.UriUtils; +import java.nio.charset.Charset; +import java.util.Collection; + +/** + * Various ways to encode collections in URL parameters. + * + *

+ * These specific cases are inspired by the OpenAPI + * specification. + *

+ */ +public enum CollectionFormat { + /** Comma separated values, eg foo=bar,baz */ + CSV(","), + /** Space separated values, eg foo=bar baz */ + SSV(" "), + /** Tab separated values, eg foo=bar[tab]baz */ + TSV("\t"), + /** Values separated with the pipe (|) character, eg foo=bar|baz */ + PIPES("|"), + /** Parameter name repeated for each value, eg foo=bar&foo=baz */ + // Using null as a special case since there is no single separator character + EXPLODED(null); + + private final String separator; + + CollectionFormat(String separator) { + this.separator = separator; + } + + /** + * Joins the field and possibly multiple values with the given separator. + * + *

+ * Calling EXPLODED.join("foo", ["bar"]) will return "foo=bar". + *

+ * + *

+ * Calling CSV.join("foo", ["bar", "baz"]) will return "foo=bar,baz". + *

+ * + *

+ * Null values are treated somewhat specially. With EXPLODED, the field is repeated without any + * "=" for backwards compatibility. With all other formats, null values are not included in the + * joined value list. + *

+ * + * @param field The field name corresponding to these values. + * @param values A collection of value strings for the given field. + * @param charset to encode the sequence + * @return The formatted char sequence of the field and joined values. If the value collection is + * empty, an empty char sequence will be returned. + */ + public CharSequence join(String field, Collection values, Charset charset) { + StringBuilder builder = new StringBuilder(); + int valueCount = 0; + for (String value : values) { + if (separator == null) { + // exploded + builder.append(valueCount++ == 0 ? "" : "&"); + builder.append(UriUtils.queryEncode(field, charset)); + if (value != null) { + builder.append('='); + builder.append(UriUtils.queryEncode(value, charset)); + } + } else { + // delimited with a separator character + if (builder.length() == 0) { + builder.append(UriUtils.queryEncode(field, charset)); + } + if (value == null) { + continue; + } + builder.append(valueCount++ == 0 ? "=" : separator); + builder.append(UriUtils.queryEncode(value, charset)); + } + } + return builder; + } +} diff --git a/core/src/main/java/feign/Contract.java b/core/src/main/java/feign/Contract.java new file mode 100644 index 000000000..42bf9b587 --- /dev/null +++ b/core/src/main/java/feign/Contract.java @@ -0,0 +1,323 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.checkState; +import static feign.Util.emptyToNull; +import feign.Request.HttpMethod; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +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 java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 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(); + Type[] genericParameterTypes = method.getGenericParameterTypes(); + + 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, genericParameterTypes[i])); + } + } + + if (data.headerMapIndex() != null) { + checkMapString("HeaderMap", parameterTypes[data.headerMapIndex()], + genericParameterTypes[data.headerMapIndex()]); + } + + if (data.queryMapIndex() != null) { + if (Map.class.isAssignableFrom(parameterTypes[data.queryMapIndex()])) { + checkMapKeys("QueryMap", genericParameterTypes[data.queryMapIndex()]); + } + } + + return data; + } + + private static void checkMapString(String name, Class type, Type genericType) { + checkState(Map.class.isAssignableFrom(type), + "%s parameter must be a Map: %s", name, type); + checkMapKeys(name, genericType); + } + + private static void checkMapKeys(String name, Type genericType) { + Class keyClass = null; + + // assume our type parameterized + if (ParameterizedType.class.isAssignableFrom(genericType.getClass())) { + Type[] parameterTypes = ((ParameterizedType) genericType).getActualTypeArguments(); + keyClass = (Class) parameterTypes[0]; + } else if (genericType instanceof Class) { + // raw class, type parameters cannot be inferred directly, but we can scan any extended + // interfaces looking for any explict types + Type[] interfaces = ((Class) genericType).getGenericInterfaces(); + if (interfaces != null) { + for (Type extended : interfaces) { + if (ParameterizedType.class.isAssignableFrom(extended.getClass())) { + // use the first extended interface we find. + Type[] parameterTypes = ((ParameterizedType) extended).getActualTypeArguments(); + keyClass = (Class) parameterTypes[0]; + break; + } + } + } + } + + if (keyClass != null) { + checkState(String.class.equals(keyClass), + "%s key must be a String: %s", name, keyClass.getSimpleName()); + } + } + + + /** + * 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); + + /** + * 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 { + + static final Pattern REQUEST_LINE_PATTERN = Pattern.compile("^([A-Z]+)[ ]*(.*)$"); + + @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()); + + Matcher requestLineMatcher = REQUEST_LINE_PATTERN.matcher(requestLine); + if (!requestLineMatcher.find()) { + throw new IllegalStateException(String.format( + "RequestLine annotation didn't start with an HTTP verb on method %s", + method.getName())); + } else { + data.template().method(HttpMethod.valueOf(requestLineMatcher.group(1))); + data.template().uri(requestLineMatcher.group(2)); + } + data.template().decodeSlash(RequestLine.class.cast(methodAnnotation).decodeSlash()); + data.template() + .collectionFormat(RequestLine.class.cast(methodAnnotation).collectionFormat()); + + } 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; + if (!data.template().hasRequestVariable(name)) { + 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 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 + 1).trim()); + } + 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 000000000..5a596ce86 --- /dev/null +++ b/core/src/main/java/feign/DefaultMethodHandler.java @@ -0,0 +1,77 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.InvocationHandlerFactory.MethodHandler; +import 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/ExceptionPropagationPolicy.java b/core/src/main/java/feign/ExceptionPropagationPolicy.java new file mode 100644 index 000000000..359777c43 --- /dev/null +++ b/core/src/main/java/feign/ExceptionPropagationPolicy.java @@ -0,0 +1,18 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +public enum ExceptionPropagationPolicy { + NONE, UNWRAP +} diff --git a/core/src/main/java/feign/Feign.java b/core/src/main/java/feign/Feign.java new file mode 100644 index 000000000..bd6cc0b9e --- /dev/null +++ b/core/src/main/java/feign/Feign.java @@ -0,0 +1,280 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.io.IOException; +import java.lang.reflect.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; +import static feign.ExceptionPropagationPolicy.NONE; + +/** + * 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 QueryMapEncoder queryMapEncoder = new QueryMapEncoder.Default(); + private ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + private Options options = new Options(); + private InvocationHandlerFactory invocationHandlerFactory = + new InvocationHandlerFactory.Default(); + private boolean decode404; + private boolean closeAfterDecode = true; + private ExceptionPropagationPolicy propagationPolicy = NONE; + + public Builder logLevel(Logger.Level logLevel) { + this.logLevel = logLevel; + return this; + } + + public Builder contract(Contract contract) { + this.contract = contract; + return this; + } + + public Builder client(Client client) { + this.client = client; + return this; + } + + public Builder retryer(Retryer retryer) { + this.retryer = retryer; + return this; + } + + public Builder logger(Logger logger) { + this.logger = logger; + return this; + } + + public Builder encoder(Encoder encoder) { + this.encoder = encoder; + return this; + } + + public Builder decoder(Decoder decoder) { + this.decoder = decoder; + return this; + } + + public Builder queryMapEncoder(QueryMapEncoder queryMapEncoder) { + this.queryMapEncoder = queryMapEncoder; + return this; + } + + /** + * Allows to map the response before passing it to the decoder. + */ + public Builder mapAndDecode(ResponseMapper mapper, Decoder decoder) { + this.decoder = new ResponseMappingDecoder(mapper, 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; + } + + /** + * This flag indicates that the response should not be automatically closed upon completion of + * decoding the message. This should be set if you plan on processing the response into a + * lazy-evaluated construct, such as a {@link java.util.Iterator}. + * + *

+ * Feign standard decoders do not have built in support for this flag. If you are using this + * flag, you MUST also use a custom Decoder, and be sure to close all resources appropriately + * somewhere in the Decoder (you can use {@link Util#ensureClosed} for convenience). + * + * @since 9.6 + * + */ + public Builder doNotCloseAfterDecode() { + this.closeAfterDecode = false; + return this; + } + + public Builder exceptionPropagationPolicy(ExceptionPropagationPolicy propagationPolicy) { + this.propagationPolicy = propagationPolicy; + 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, closeAfterDecode, propagationPolicy); + ParseHandlersByName handlersByName = + new ParseHandlersByName(contract, options, encoder, decoder, queryMapEncoder, + errorDecoder, synchronousMethodHandlerFactory); + return new ReflectiveFeign(handlersByName, invocationHandlerFactory, queryMapEncoder); + } + } + + static class ResponseMappingDecoder implements Decoder { + + private final ResponseMapper mapper; + private final Decoder delegate; + + ResponseMappingDecoder(ResponseMapper mapper, Decoder decoder) { + this.mapper = mapper; + this.delegate = decoder; + } + + @Override + public Object decode(Response response, Type type) throws IOException { + return delegate.decode(mapper.map(response, type), type); + } + } +} diff --git a/core/src/main/java/feign/FeignException.java b/core/src/main/java/feign/FeignException.java new file mode 100644 index 000000000..4cba68baa --- /dev/null +++ b/core/src/main/java/feign/FeignException.java @@ -0,0 +1,232 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.UTF_8; +import static java.lang.String.format; +import java.io.IOException; + +/** + * Origin exception type for all Http Apis. + */ +public class FeignException extends RuntimeException { + + private static final long serialVersionUID = 0; + private int status; + private byte[] content; + + protected FeignException(int status, String message, Throwable cause) { + super(message, cause); + this.status = status; + } + + protected FeignException(int status, String message, Throwable cause, byte[] content) { + super(message, cause); + this.status = status; + this.content = content; + } + + protected FeignException(int status, String message) { + super(message); + this.status = status; + } + + protected FeignException(int status, String message, byte[] content) { + super(message); + this.status = status; + this.content = content; + } + + public int status() { + return this.status; + } + + public byte[] content() { + return this.content; + } + + public String contentUTF8() { + if (content != null) { + return new String(content, UTF_8); + } else { + return ""; + } + } + + static FeignException errorReading(Request request, Response response, IOException cause) { + return new FeignException( + response.status(), + format("%s reading %s %s", cause.getMessage(), request.httpMethod(), request.url()), + cause, + request.body()); + } + + public static FeignException errorStatus(String methodKey, Response response) { + String message = format("status %s reading %s", response.status(), methodKey); + + byte[] body = {}; + try { + if (response.body() != null) { + body = Util.toByteArray(response.body().asInputStream()); + } + } catch (IOException ignored) { // NOPMD + } + + return errorStatus(response.status(), message, body); + } + + private static FeignException errorStatus(int status, String message, byte[] body) { + switch (status) { + case 400: + return new BadRequest(message, body); + case 401: + return new Unauthorized(message, body); + case 403: + return new Forbidden(message, body); + case 404: + return new NotFound(message, body); + case 405: + return new MethodNotAllowed(message, body); + case 406: + return new NotAcceptable(message, body); + case 409: + return new Conflict(message, body); + case 410: + return new Gone(message, body); + case 415: + return new UnsupportedMediaType(message, body); + case 429: + return new TooManyRequests(message, body); + case 422: + return new UnprocessableEntity(message, body); + case 500: + return new InternalServerError(message, body); + case 501: + return new NotImplemented(message, body); + case 502: + return new BadGateway(message, body); + case 503: + return new ServiceUnavailable(message, body); + case 504: + return new GatewayTimeout(message, body); + default: + return new FeignException(status, message, body); + } + } + + static FeignException errorExecuting(Request request, IOException cause) { + return new RetryableException( + -1, + format("%s executing %s %s", cause.getMessage(), request.httpMethod(), request.url()), + request.httpMethod(), + cause, + null); + } + + public static class BadRequest extends FeignException { + public BadRequest(String message, byte[] body) { + super(400, message, body); + } + } + + public static class Unauthorized extends FeignException { + public Unauthorized(String message, byte[] body) { + super(401, message, body); + } + } + + public static class Forbidden extends FeignException { + public Forbidden(String message, byte[] body) { + super(403, message, body); + } + } + + public static class NotFound extends FeignException { + public NotFound(String message, byte[] body) { + super(404, message, body); + } + } + + public static class MethodNotAllowed extends FeignException { + public MethodNotAllowed(String message, byte[] body) { + super(405, message, body); + } + } + + public static class NotAcceptable extends FeignException { + public NotAcceptable(String message, byte[] body) { + super(406, message, body); + } + } + + public static class Conflict extends FeignException { + public Conflict(String message, byte[] body) { + super(409, message, body); + } + } + + public static class Gone extends FeignException { + public Gone(String message, byte[] body) { + super(410, message, body); + } + } + + public static class UnsupportedMediaType extends FeignException { + public UnsupportedMediaType(String message, byte[] body) { + super(415, message, body); + } + } + + public static class TooManyRequests extends FeignException { + public TooManyRequests(String message, byte[] body) { + super(429, message, body); + } + } + + public static class UnprocessableEntity extends FeignException { + public UnprocessableEntity(String message, byte[] body) { + super(422, message, body); + } + } + + public static class InternalServerError extends FeignException { + public InternalServerError(String message, byte[] body) { + super(500, message, body); + } + } + + public static class NotImplemented extends FeignException { + public NotImplemented(String message, byte[] body) { + super(501, message, body); + } + } + + public static class BadGateway extends FeignException { + public BadGateway(String message, byte[] body) { + super(502, message, body); + } + } + + public static class ServiceUnavailable extends FeignException { + public ServiceUnavailable(String message, byte[] body) { + super(503, message, body); + } + } + + public static class GatewayTimeout extends FeignException { + public GatewayTimeout(String message, byte[] body) { + super(504, message, body); + } + } +} diff --git a/core/src/main/java/feign/HeaderMap.java b/core/src/main/java/feign/HeaderMap.java new file mode 100644 index 000000000..2f06879d1 --- /dev/null +++ b/core/src/main/java/feign/HeaderMap.java @@ -0,0 +1,63 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 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 000000000..c48581fa8 --- /dev/null +++ b/core/src/main/java/feign/Headers.java @@ -0,0 +1,78 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.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 000000000..73f4a84e2 --- /dev/null +++ b/core/src/main/java/feign/InvocationHandlerFactory.java @@ -0,0 +1,43 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..28e7a8e04 --- /dev/null +++ b/core/src/main/java/feign/Logger.java @@ -0,0 +1,233 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.httpMethod().name(), 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, "%s", 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 000000000..a4ae6e9bd --- /dev/null +++ b/core/src/main/java/feign/MethodMetadata.java @@ -0,0 +1,166 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..47bdc4304 --- /dev/null +++ b/core/src/main/java/feign/Param.java @@ -0,0 +1,61 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..c50a46cb5 --- /dev/null +++ b/core/src/main/java/feign/QueryMap.java @@ -0,0 +1,65 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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/QueryMapEncoder.java b/core/src/main/java/feign/QueryMapEncoder.java new file mode 100644 index 000000000..f7bb8fa7d --- /dev/null +++ b/core/src/main/java/feign/QueryMapEncoder.java @@ -0,0 +1,45 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.querymap.FieldQueryMapEncoder; +import feign.querymap.BeanQueryMapEncoder; +import java.util.Map; + +/** + * A QueryMapEncoder encodes Objects into maps of query parameter names to values. + * + * @see FieldQueryMapEncoder + * @see BeanQueryMapEncoder + * + */ +public interface QueryMapEncoder { + + /** + * Encodes the given object into a query map. + * + * @param object the object to encode + * @return the map represented by the object + */ + Map encode(Object object); + + /** + * @deprecated use {@link BeanQueryMapEncoder} instead. default encoder uses reflection to inspect + * provided objects Fields to expand the objects values into a query string. If you + * prefer that the query string be built using getter and setter methods, as defined + * in the Java Beans API, please use the {@link BeanQueryMapEncoder} + */ + class Default extends FieldQueryMapEncoder { + } +} diff --git a/core/src/main/java/feign/ReflectiveFeign.java b/core/src/main/java/feign/ReflectiveFeign.java new file mode 100644 index 000000000..a71bed0e6 --- /dev/null +++ b/core/src/main/java/feign/ReflectiveFeign.java @@ -0,0 +1,381 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.template.UriUtils; +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; + +public class ReflectiveFeign extends Feign { + + private final ParseHandlersByName targetToHandlersByName; + private final InvocationHandlerFactory factory; + private final QueryMapEncoder queryMapEncoder; + + ReflectiveFeign(ParseHandlersByName targetToHandlersByName, InvocationHandlerFactory factory, + QueryMapEncoder queryMapEncoder) { + this.targetToHandlersByName = targetToHandlersByName; + this.factory = factory; + this.queryMapEncoder = queryMapEncoder; + } + + /** + * 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 QueryMapEncoder queryMapEncoder; + private final SynchronousMethodHandler.Factory factory; + + ParseHandlersByName( + Contract contract, + Options options, + Encoder encoder, + Decoder decoder, + QueryMapEncoder queryMapEncoder, + ErrorDecoder errorDecoder, + SynchronousMethodHandler.Factory factory) { + this.contract = contract; + this.options = options; + this.factory = factory; + this.errorDecoder = errorDecoder; + this.queryMapEncoder = queryMapEncoder; + 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, queryMapEncoder); + } else if (md.bodyIndex() != null) { + buildTemplate = new BuildEncodedTemplateFromArgs(md, encoder, queryMapEncoder); + } else { + buildTemplate = new BuildTemplateByResolvingArgs(md, queryMapEncoder); + } + result.put(md.configKey(), + factory.create(key, md, buildTemplate, options, decoder, errorDecoder)); + } + return result; + } + } + + private static class BuildTemplateByResolvingArgs implements RequestTemplate.Factory { + + private final QueryMapEncoder queryMapEncoder; + + protected final MethodMetadata metadata; + private final Map indexToExpander = new LinkedHashMap(); + + private BuildTemplateByResolvingArgs(MethodMetadata metadata, QueryMapEncoder queryMapEncoder) { + this.metadata = metadata; + this.queryMapEncoder = queryMapEncoder; + 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 = RequestTemplate.from(metadata.template()); + if (metadata.urlIndex() != null) { + int urlIndex = metadata.urlIndex(); + checkArgument(argv[urlIndex] != null, "URI parameter %s was null", urlIndex); + mutable.target(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 + Object value = argv[metadata.queryMapIndex()]; + Map queryMap = toQueryMap(value); + template = addQueryMapQueryParameters(queryMap, template); + } + + if (metadata.headerMapIndex() != null) { + template = + addHeaderMapHeaders((Map) argv[metadata.headerMapIndex()], template); + } + + return template; + } + + private Map toQueryMap(Object value) { + if (value instanceof Map) { + return (Map) value; + } + try { + return queryMapEncoder.encode(value); + } catch (EncodeException e) { + throw new IllegalStateException(e); + } + } + + 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 : value) { + if (element != null) { + values.add(expander.expand(element)); + } + } + return values; + } + + @SuppressWarnings("unchecked") + private RequestTemplate addHeaderMapHeaders(Map headerMap, + RequestTemplate mutable) { + for (Entry currEntry : headerMap.entrySet()) { + 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(currEntry.getKey(), values); + } + return mutable; + } + + @SuppressWarnings("unchecked") + private RequestTemplate addQueryMapQueryParameters(Map queryMap, + RequestTemplate mutable) { + for (Entry currEntry : queryMap.entrySet()) { + Collection values = new ArrayList(); + + boolean encoded = metadata.queryMapEncoded(); + 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 + : encoded ? nextObject.toString() + : UriUtils.encode(nextObject.toString())); + } + } else { + values.add(currValue == null ? null + : encoded ? currValue.toString() : UriUtils.encode(currValue.toString())); + } + + mutable.query(encoded ? currEntry.getKey() : UriUtils.encode(currEntry.getKey()), values); + } + return mutable; + } + + protected RequestTemplate resolve(Object[] argv, + RequestTemplate mutable, + Map variables) { + return mutable.resolve(variables); + } + } + + private static class BuildFormEncodedTemplateFromArgs extends BuildTemplateByResolvingArgs { + + private final Encoder encoder; + + private BuildFormEncodedTemplateFromArgs(MethodMetadata metadata, Encoder encoder, + QueryMapEncoder queryMapEncoder) { + super(metadata, queryMapEncoder); + 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, + QueryMapEncoder queryMapEncoder) { + super(metadata, queryMapEncoder); + 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 000000000..f5e265703 --- /dev/null +++ b/core/src/main/java/feign/Request.java @@ -0,0 +1,275 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.valuesOrEmpty; +import java.net.HttpURLConnection; +import java.nio.charset.Charset; +import java.util.*; +import feign.template.BodyTemplate; + +/** + * An immutable request to an http server. + */ +public final class Request { + + public static class Body { + + private final byte[] data; + private final Charset encoding; + private final BodyTemplate bodyTemplate; + + private Body(byte[] data, Charset encoding, BodyTemplate bodyTemplate) { + super(); + this.data = data; + this.encoding = encoding; + this.bodyTemplate = bodyTemplate; + } + + public Request.Body expand(Map variables) { + if (bodyTemplate == null) + return this; + + return encoded(bodyTemplate.expand(variables).getBytes(encoding), encoding); + } + + public List getVariables() { + if (bodyTemplate == null) + return Collections.emptyList(); + return bodyTemplate.getVariables(); + } + + public static Request.Body encoded(byte[] bodyData, Charset encoding) { + return new Request.Body(bodyData, encoding, null); + } + + public int length() { + /* calculate the content length based on the data provided */ + return data != null ? data.length : 0; + } + + public byte[] asBytes() { + return data; + } + + public static Request.Body bodyTemplate(String bodyTemplate, Charset encoding) { + return new Request.Body(null, encoding, BodyTemplate.create(bodyTemplate)); + } + + public String bodyTemplate() { + return (bodyTemplate != null) ? bodyTemplate.toString() : null; + } + + public String asString() { + return encoding != null && data != null + ? new String(data, encoding) + : "Binary data"; + } + + public static Body empty() { + return new Request.Body(null, null, null); + } + + } + + public enum HttpMethod { + GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH + } + + /** + * No parameters can be null except {@code body} and {@code charset}. All parameters must be + * effectively immutable, via safe copies, not mutating or otherwise. + * + * @deprecated {@link #create(HttpMethod, String, Map, byte[], Charset)} + */ + public static Request create(String method, + String url, + Map> headers, + byte[] body, + Charset charset) { + checkNotNull(method, "httpMethod of %s", method); + HttpMethod httpMethod = HttpMethod.valueOf(method.toUpperCase()); + return create(httpMethod, url, headers, body, charset); + } + + /** + * Builds a Request. All parameters must be effectively immutable, via safe copies. + * + * @param httpMethod for the request. + * @param url for the request. + * @param headers to include. + * @param body of the request, can be {@literal null} + * @param charset of the request, can be {@literal null} + * @return a Request + */ + public static Request create(HttpMethod httpMethod, + String url, + Map> headers, + byte[] body, + Charset charset) { + return create(httpMethod, url, headers, Body.encoded(body, charset)); + } + + /** + * Builds a Request. All parameters must be effectively immutable, via safe copies. + * + * @param httpMethod for the request. + * @param url for the request. + * @param headers to include. + * @param body of the request, can be {@literal null} + * @return a Request + */ + public static Request create(HttpMethod httpMethod, + String url, + Map> headers, + Body body) { + return new Request(httpMethod, url, headers, body); + } + + private final HttpMethod httpMethod; + private final String url; + private final Map> headers; + private final Body body; + + Request(HttpMethod method, String url, Map> headers, Body body) { + this.httpMethod = checkNotNull(method, "httpMethod of %s", method.name()); + this.url = checkNotNull(url, "url"); + this.headers = checkNotNull(headers, "headers of %s %s", method, url); + this.body = body; + } + + /** + * Http Method for this request. + * + * @return the HttpMethod string + * @deprecated @see {@link #httpMethod()} + */ + public String method() { + return httpMethod.name(); + } + + /** + * Http Method for the request. + * + * @return the HttpMethod. + */ + public HttpMethod httpMethod() { + return this.httpMethod; + } + + /* 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. + * + * @deprecated use {@link #requestBody()} instead + */ + public Charset charset() { + return body.encoding; + } + + /** + * If present, this is the replayable body to send to the server. In some cases, this may be + * interpretable as text. + * + * @see #charset() + * @deprecated use {@link #requestBody()} instead + */ + public byte[] body() { + return body.data; + } + + public Body requestBody() { + return body; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(httpMethod).append(' ').append(url).append(" HTTP/1.1\n"); + for (String field : headers.keySet()) { + for (String value : valuesOrEmpty(headers, field)) { + builder.append(field).append(": ").append(value).append('\n'); + } + } + if (body != null) { + builder.append('\n').append(body.asString()); + } + 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; + private final boolean followRedirects; + + public Options(int connectTimeoutMillis, int readTimeoutMillis, boolean followRedirects) { + this.connectTimeoutMillis = connectTimeoutMillis; + this.readTimeoutMillis = readTimeoutMillis; + this.followRedirects = followRedirects; + } + + public Options(int connectTimeoutMillis, int readTimeoutMillis) { + this(connectTimeoutMillis, readTimeoutMillis, true); + } + + 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; + } + + + /** + * Defaults to true. {@code false} tells the client to not follow the redirections. + * + * @see HttpURLConnection#getFollowRedirects() + */ + public boolean isFollowRedirects() { + return followRedirects; + } + } +} diff --git a/core/src/main/java/feign/RequestInterceptor.java b/core/src/main/java/feign/RequestInterceptor.java new file mode 100644 index 000000000..7ff56348c --- /dev/null +++ b/core/src/main/java/feign/RequestInterceptor.java @@ -0,0 +1,56 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..948eee4a1 --- /dev/null +++ b/core/src/main/java/feign/RequestLine.java @@ -0,0 +1,35 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Expands the uri template supplied in the {@code value}, permitting path and query variables, or + * just the http method. Templates should conform to + * RFC 6570. Support is limited to Simple String + * expansion and Reserved Expansion (Level 1 and Level 2) expressions. + */ +@java.lang.annotation.Target(METHOD) +@Retention(RUNTIME) +public @interface RequestLine { + + String value(); + + boolean decodeSlash() default true; + + CollectionFormat collectionFormat() default CollectionFormat.EXPLODED; +} diff --git a/core/src/main/java/feign/RequestTemplate.java b/core/src/main/java/feign/RequestTemplate.java new file mode 100644 index 000000000..1560086e2 --- /dev/null +++ b/core/src/main/java/feign/RequestTemplate.java @@ -0,0 +1,899 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.Request.HttpMethod; +import feign.template.HeaderTemplate; +import feign.template.QueryTemplate; +import feign.template.UriTemplate; +import feign.template.UriUtils; +import java.io.Serializable; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.*; +import java.util.Map.Entry; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import static feign.Util.*; + +/** + * Request Builder for an HTTP Target. + *

+ * This class is a variation on a UriTemplate, where, in addition to the uri, Headers and Query + * information also support template expressions. + *

+ */ +@SuppressWarnings({"WeakerAccess", "UnusedReturnValue"}) +public final class RequestTemplate implements Serializable { + + private static final Pattern QUERY_STRING_PATTERN = Pattern.compile("(? queries = new LinkedHashMap<>(); + private final Map headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + private String target; + private boolean resolved = false; + private UriTemplate uriTemplate; + private HttpMethod method; + private transient Charset charset = Util.UTF_8; + private Request.Body body = Request.Body.empty(); + private boolean decodeSlash = true; + private CollectionFormat collectionFormat = CollectionFormat.EXPLODED; + + /** + * Create a new Request Template. + */ + public RequestTemplate() { + super(); + } + + /** + * Create a new Request Template. + * + * @param target for the template. + * @param uriTemplate for the template. + * @param method of the request. + * @param charset for the request. + * @param body of the request, may be null + * @param decodeSlash if the request uri should encode slash characters. + * @param collectionFormat when expanding collection based variables. + */ + private RequestTemplate(String target, + UriTemplate uriTemplate, + HttpMethod method, + Charset charset, + Request.Body body, + boolean decodeSlash, + CollectionFormat collectionFormat) { + this.target = target; + this.uriTemplate = uriTemplate; + this.method = method; + this.charset = charset; + this.body = body; + this.decodeSlash = decodeSlash; + this.collectionFormat = + (collectionFormat != null) ? collectionFormat : CollectionFormat.EXPLODED; + } + + /** + * Create a Request Template from an existing Request Template. + * + * @param requestTemplate to copy from. + * @return a new Request Template. + */ + public static RequestTemplate from(RequestTemplate requestTemplate) { + RequestTemplate template = + new RequestTemplate(requestTemplate.target, requestTemplate.uriTemplate, + requestTemplate.method, requestTemplate.charset, + requestTemplate.body, requestTemplate.decodeSlash, requestTemplate.collectionFormat); + + if (!requestTemplate.queries().isEmpty()) { + template.queries.putAll(requestTemplate.queries); + } + + if (!requestTemplate.headers().isEmpty()) { + template.headers.putAll(requestTemplate.headers); + } + return template; + } + + /** + * Create a Request Template from an existing Request Template. + * + * @param toCopy template. + * @deprecated replaced by {@link RequestTemplate#from(RequestTemplate)} + */ + @Deprecated + public RequestTemplate(RequestTemplate toCopy) { + checkNotNull(toCopy, "toCopy"); + this.target = toCopy.target; + this.method = toCopy.method; + this.queries.putAll(toCopy.queries); + this.headers.putAll(toCopy.headers); + this.charset = toCopy.charset; + this.body = toCopy.body; + this.decodeSlash = toCopy.decodeSlash; + this.collectionFormat = + (toCopy.collectionFormat != null) ? toCopy.collectionFormat : CollectionFormat.EXPLODED; + this.uriTemplate = toCopy.uriTemplate; + this.resolved = false; + } + + /** + * Resolve all expressions using the variable value substitutions provided. Variable values will + * be pct-encoded, if they are not already. + * + * @param variables containing the variable values to use when resolving expressions. + * @return a new Request Template with all of the variables resolved. + */ + public RequestTemplate resolve(Map variables) { + + StringBuilder uri = new StringBuilder(); + + /* create a new template form this one, but explicitly */ + RequestTemplate resolved = RequestTemplate.from(this); + + if (this.uriTemplate == null) { + /* create a new uri template using the default root */ + this.uriTemplate = UriTemplate.create("", !this.decodeSlash, this.charset); + } + + uri.append(this.uriTemplate.expand(variables)); + + /* + * for simplicity, combine the queries into the uri and use the resulting uri to seed the + * resolved template. + */ + if (!this.queries.isEmpty()) { + /* + * since we only want to keep resolved query values, reset any queries on the resolved copy + */ + resolved.queries(Collections.emptyMap()); + StringBuilder query = new StringBuilder(); + Iterator queryTemplates = this.queries.values().iterator(); + + while (queryTemplates.hasNext()) { + QueryTemplate queryTemplate = queryTemplates.next(); + String queryExpanded = queryTemplate.expand(variables); + if (Util.isNotBlank(queryExpanded)) { + query.append(queryExpanded); + if (queryTemplates.hasNext()) { + query.append("&"); + } + } + } + + String queryString = query.toString(); + if (!queryString.isEmpty()) { + Matcher queryMatcher = QUERY_STRING_PATTERN.matcher(uri); + if (queryMatcher.find()) { + /* the uri already has a query, so any additional queries should be appended */ + uri.append("&"); + } else { + uri.append("?"); + } + uri.append(queryString); + } + } + + /* add the uri to result */ + resolved.uri(uri.toString()); + + /* headers */ + if (!this.headers.isEmpty()) { + /* + * same as the query string, we only want to keep resolved values, so clear the header map on + * the resolved instance + */ + resolved.headers(Collections.emptyMap()); + for (HeaderTemplate headerTemplate : this.headers.values()) { + /* resolve the header */ + String header = headerTemplate.expand(variables); + if (!header.isEmpty()) { + /* split off the header values and add it to the resolved template */ + String headerValues = header.substring(header.indexOf(" ") + 1); + if (!headerValues.isEmpty()) { + resolved.header(headerTemplate.getName(), headerValues); + } + } + } + } + + resolved.body(this.body.expand(variables)); + + /* mark the new template resolved */ + resolved.resolved = true; + return resolved; + } + + /** + * Resolves all expressions, using the variables provided. Values not present in the {@code + * alreadyEncoded} map are pct-encoded. + * + * @param unencoded variable values to substitute. + * @param alreadyEncoded variable names. + * @return a resolved Request Template + * @deprecated use {@link RequestTemplate#resolve(Map)}. Values already encoded are recognized as + * such and skipped. + */ + @SuppressWarnings("unused") + @Deprecated + RequestTemplate resolve(Map unencoded, Map alreadyEncoded) { + return this.resolve(unencoded); + } + + /** + * Creates a {@link Request} from this template. The template must be resolved before calling this + * method, or an {@link IllegalStateException} will be thrown. + * + * @return a new Request instance. + * @throws IllegalStateException if this template has not been resolved. + */ + public Request request() { + if (!this.resolved) { + throw new IllegalStateException("template has not been resolved."); + } + return Request.create(this.method, this.url(), this.headers(), this.requestBody()); + } + + /** + * Set the Http Method. + * + * @param method to use. + * @return a RequestTemplate for chaining. + * @deprecated see {@link RequestTemplate#method(HttpMethod)} + */ + @Deprecated + public RequestTemplate method(String method) { + checkNotNull(method, "method"); + try { + this.method = HttpMethod.valueOf(method); + } catch (IllegalArgumentException iae) { + throw new IllegalArgumentException("Invalid HTTP Method: " + method); + } + return this; + } + + /** + * Set the Http Method. + * + * @param method to use. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate method(HttpMethod method) { + checkNotNull(method, "method"); + this.method = method; + return this; + } + + /** + * The Request Http Method. + * + * @return Http Method. + */ + public String method() { + return (method != null) ? method.name() : null; + } + + /** + * Set whether do encode slash {@literal /} characters when resolving this template. + * + * @param decodeSlash if slash literals should not be encoded. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate decodeSlash(boolean decodeSlash) { + this.decodeSlash = decodeSlash; + this.uriTemplate = + UriTemplate.create(this.uriTemplate.toString(), !this.decodeSlash, this.charset); + return this; + } + + /** + * If slash {@literal /} characters are not encoded when resolving. + * + * @return true if slash literals are not encoded, false otherwise. + */ + public boolean decodeSlash() { + return decodeSlash; + } + + /** + * The Collection Format to use when resolving variables that represent {@link Iterable}s or + * {@link Collection}s + * + * @param collectionFormat to use. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate collectionFormat(CollectionFormat collectionFormat) { + this.collectionFormat = collectionFormat; + return this; + } + + /** + * The Collection Format that will be used when resolving {@link Iterable} and {@link Collection} + * variables. + * + * @return the collection format set + */ + @SuppressWarnings("unused") + public CollectionFormat collectionFormat() { + return collectionFormat; + } + + /** + * Append the value to the template. + *

+ * This method is poorly named and is used primarily to store the relative uri for the request. It + * has been replaced by {@link RequestTemplate#uri(String)} and will be removed in a future + * release. + *

+ * + * @param value to append. + * @return a RequestTemplate for chaining. + * @deprecated see {@link RequestTemplate#uri(String, boolean)} + */ + @Deprecated + public RequestTemplate append(CharSequence value) { + /* proxy to url */ + if (this.uriTemplate != null) { + return this.uri(value.toString(), true); + } + return this.uri(value.toString()); + } + + /** + * Insert the value at the specified point in the template uri. + *

+ * This method is poorly named has undocumented behavior. When the value contains a fully + * qualified http request url, the value is always inserted at the beginning of the uri. + *

+ *

+ * Due to this, use of this method is not recommended and remains for backward compatibility. It + * has been replaced by {@link RequestTemplate#target(String)} and will be removed in a future + * release. + *

+ * + * @param pos in the uri to place the value. + * @param value to insert. + * @return a RequestTemplate for chaining. + * @deprecated see {@link RequestTemplate#target(String)} + */ + @SuppressWarnings("unused") + @Deprecated + public RequestTemplate insert(int pos, CharSequence value) { + return target(value.toString()); + } + + /** + * Set the Uri for the request, replacing the existing uri if set. + * + * @param uri to use, must be a relative uri. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate uri(String uri) { + return this.uri(uri, false); + } + + /** + * Set the uri for the request. + * + * @param uri to use, must be a relative uri. + * @param append if the uri should be appended, if the uri is already set. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate uri(String uri, boolean append) { + /* validate and ensure that the url is always a relative one */ + if (UriUtils.isAbsolute(uri)) { + throw new IllegalArgumentException("url values must be not be absolute."); + } + + if (uri == null) { + uri = "/"; + } else if ((!uri.isEmpty() && !uri.startsWith("/") && !uri.startsWith("{") + && !uri.startsWith("?"))) { + /* if the start of the url is a literal, it must begin with a slash. */ + uri = "/" + uri; + } + + /* + * templates may provide query parameters. since we want to manage those explicity, we will need + * to extract those out, leaving the uriTemplate with only the path to deal with. + */ + Matcher queryMatcher = QUERY_STRING_PATTERN.matcher(uri); + if (queryMatcher.find()) { + String queryString = uri.substring(queryMatcher.start() + 1); + + /* parse the query string */ + this.extractQueryTemplates(queryString, append); + + /* reduce the uri to the path */ + uri = uri.substring(0, queryMatcher.start()); + } + + /* replace the uri template */ + if (append && this.uriTemplate != null) { + this.uriTemplate = UriTemplate.append(this.uriTemplate, uri); + } else { + this.uriTemplate = UriTemplate.create(uri, !this.decodeSlash, this.charset); + } + return this; + } + + /** + * Set the target host for this request. + * + * @param target host for this request. Must be an absolute target. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate target(String target) { + /* target can be empty */ + if (Util.isBlank(target)) { + return this; + } + + /* verify that the target contains the scheme, host and port */ + if (!UriUtils.isAbsolute(target)) { + throw new IllegalArgumentException("target values must be absolute."); + } + if (target.endsWith("/")) { + target = target.substring(0, target.length() - 1); + } + try { + /* parse the target */ + URI targetUri = URI.create(target); + + if (Util.isNotBlank(targetUri.getRawQuery())) { + /* + * target has a query string, we need to make sure that they are recorded as queries + */ + this.extractQueryTemplates(targetUri.getRawQuery(), true); + } + + /* strip the query string */ + this.target = targetUri.getScheme() + "://" + targetUri.getAuthority() + targetUri.getPath(); + } catch (IllegalArgumentException iae) { + /* the uri provided is not a valid one, we can't continue */ + throw new IllegalArgumentException("Target is not a valid URI.", iae); + } + return this; + } + + /** + * The URL for the request. If the template has not been resolved, the url will represent a uri + * template. + * + * @return the url + */ + public String url() { + + /* build the fully qualified url with all query parameters */ + StringBuilder url = new StringBuilder(this.path()); + if (!this.queries.isEmpty()) { + url.append(this.queryLine()); + } + + return url.toString(); + } + + /** + * The Uri Path. + * + * @return the uri path. + */ + public String path() { + /* build the fully qualified url with all query parameters */ + StringBuilder path = new StringBuilder(); + if (this.target != null) { + path.append(this.target); + } + if (this.uriTemplate != null) { + path.append(this.uriTemplate.toString()); + } + if (path.length() == 0) { + /* no path indicates the root uri */ + path.append("/"); + } + return path.toString(); + + } + + /** + * List all of the template variable expressions for this template. + * + * @return a list of template variable names + */ + public List variables() { + /* combine the variables from the uri, query, header, and body templates */ + List variables = new ArrayList<>(this.uriTemplate.getVariables()); + + /* queries */ + for (QueryTemplate queryTemplate : this.queries.values()) { + variables.addAll(queryTemplate.getVariables()); + } + + /* headers */ + for (HeaderTemplate headerTemplate : this.headers.values()) { + variables.addAll(headerTemplate.getVariables()); + } + + /* body */ + variables.addAll(this.body.getVariables()); + + return variables; + } + + /** + * @see RequestTemplate#query(String, Iterable) + */ + public RequestTemplate query(String name, String... values) { + if (values == null) { + return query(name, Collections.emptyList()); + } + return query(name, Arrays.asList(values)); + } + + /** + * Specify a Query String parameter, with the specified values. Values can be literals or template + * expressions. + * + * @param name of the parameter. + * @param values for this parameter. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate query(String name, Iterable values) { + return appendQuery(name, values); + } + + /** + * Appends the query name and values. + * + * @param name of the parameter. + * @param values for the parameter, may be expressions. + * @return a RequestTemplate for chaining. + */ + private RequestTemplate appendQuery(String name, Iterable values) { + if (!values.iterator().hasNext()) { + /* empty value, clear the existing values */ + this.queries.remove(name); + return this; + } + + /* create a new query template out of the information here */ + this.queries.compute(name, (key, queryTemplate) -> { + if (queryTemplate == null) { + return QueryTemplate.create(name, values, this.charset, this.collectionFormat); + } else { + return QueryTemplate.append(queryTemplate, values, this.collectionFormat); + } + }); + // this.queries.put(name, QueryTemplate.create(name, values)); + return this; + } + + /** + * Sets the Query Parameters. + * + * @param queries to use for this request. + * @return a RequestTemplate for chaining. + */ + @SuppressWarnings("unused") + public RequestTemplate queries(Map> queries) { + if (queries == null || queries.isEmpty()) { + this.queries.clear(); + } else { + queries.forEach(this::query); + } + return this; + } + + + /** + * Return an immutable Map of all Query Parameters and their values. + * + * @return registered Query Parameters. + */ + public Map> queries() { + Map> queryMap = new LinkedHashMap<>(); + this.queries.forEach((key, queryTemplate) -> { + List values = new ArrayList<>(queryTemplate.getValues()); + + /* add the expanded collection, but lock it */ + queryMap.put(key, Collections.unmodifiableList(values)); + }); + + return Collections.unmodifiableMap(queryMap); + } + + /** + * @see RequestTemplate#header(String, Iterable) + */ + public RequestTemplate header(String name, String... values) { + return header(name, Arrays.asList(values)); + } + + /** + * Specify a Header, with the specified values. Values can be literals or template expressions. + * + * @param name of the header. + * @param values for this header. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate header(String name, Iterable values) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required."); + } + if (values == null) { + values = Collections.emptyList(); + } + + return appendHeader(name, values); + } + + /** + * Create a Header Template. + * + * @param name of the header + * @param values for the header, may be expressions. + * @return a RequestTemplate for chaining. + */ + private RequestTemplate appendHeader(String name, Iterable values) { + if (!values.iterator().hasNext()) { + /* empty value, clear the existing values */ + this.headers.remove(name); + return this; + } + this.headers.compute(name, (headerName, headerTemplate) -> { + if (headerTemplate == null) { + return HeaderTemplate.create(headerName, values); + } else { + return HeaderTemplate.append(headerTemplate, values); + } + }); + return this; + } + + /** + * Headers for this Request. + * + * @param headers to use. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate headers(Map> headers) { + if (headers != null && !headers.isEmpty()) { + headers.forEach(this::header); + } else { + this.headers.clear(); + } + return this; + } + + /** + * Returns an immutable copy of the Headers for this request. + * + * @return the currently applied headers. + */ + public Map> headers() { + Map> headerMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + this.headers.forEach((key, headerTemplate) -> { + List values = new ArrayList<>(headerTemplate.getValues()); + + /* add the expanded collection, but only if it has values */ + if (!values.isEmpty()) { + headerMap.put(key, Collections.unmodifiableList(values)); + } + }); + return Collections.unmodifiableMap(headerMap); + } + + /** + * Sets the Body and Charset for this request. + * + * @param bodyData to send, can be null. + * @param charset of the encoded data. + * @return a RequestTemplate for chaining. + * @deprecated use {@link RequestTemplate#body(feign.Request.Body)} instead + */ + @Deprecated + public RequestTemplate body(byte[] bodyData, Charset charset) { + this.body(Request.Body.encoded(bodyData, charset)); + + return this; + } + + /** + * Set the Body for this request. Charset is assumed to be UTF_8. Data must be encoded. + * + * @param bodyText to send. + * @return a RequestTemplate for chaining. + * @deprecated use {@link RequestTemplate#body(feign.Request.Body)} instead + */ + @Deprecated + public RequestTemplate body(String bodyText) { + byte[] bodyData = bodyText != null ? bodyText.getBytes(UTF_8) : null; + return body(bodyData, UTF_8); + } + + /** + * Set the Body for this request. + * + * @param body to send. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate body(Request.Body body) { + this.body = body; + + header(CONTENT_LENGTH); + if (body.length() > 0) { + header(CONTENT_LENGTH, String.valueOf(body.length())); + } + + return this; + } + + /** + * Charset of the Request Body, if known. + * + * @return the currently applied Charset. + */ + public Charset requestCharset() { + return charset; + } + + /** + * The Request Body. + * + * @return the request body. + * @deprecated replaced by {@link RequestTemplate#requestBody()} + */ + @Deprecated + public byte[] body() { + return body.asBytes(); + } + + + /** + * Specify the Body Template to use. Can contain literals and expressions. + * + * @param bodyTemplate to use. + * @return a RequestTemplate for chaining. + * @deprecated replaced by {@link RequestTemplate#body(feign.Request.Body)} + */ + @Deprecated + public RequestTemplate bodyTemplate(String bodyTemplate) { + this.body(Request.Body.bodyTemplate(bodyTemplate, Util.UTF_8)); + return this; + } + + /** + * Body Template to resolve. + * + * @return the unresolved body template. + */ + public String bodyTemplate() { + return body.bodyTemplate(); + } + + @Override + public String toString() { + return request().toString(); + } + + /** + * Return if the variable exists on the uri, query, or headers, in this template. + * + * @param variable to look for. + * @return true if the variable exists, false otherwise. + */ + public boolean hasRequestVariable(String variable) { + return this.getRequestVariables().contains(variable); + } + + /** + * Retrieve all uri, header, and query template variables. + * + * @return a List of all the variable names. + */ + public Collection getRequestVariables() { + final Collection variables = new LinkedHashSet<>(this.uriTemplate.getVariables()); + this.queries.values().forEach(queryTemplate -> variables.addAll(queryTemplate.getVariables())); + this.headers.values() + .forEach(headerTemplate -> variables.addAll(headerTemplate.getVariables())); + return variables; + } + + /** + * If this template has been resolved. + * + * @return true if the template has been resolved, false otherwise. + */ + @SuppressWarnings("unused") + public boolean resolved() { + return this.resolved; + } + + /** + * The Query String for the template. Expressions are not resolved. + * + * @return the Query String. + */ + public String queryLine() { + StringBuilder queryString = new StringBuilder(); + + if (!this.queries.isEmpty()) { + Iterator iterator = this.queries.values().iterator(); + while (iterator.hasNext()) { + QueryTemplate queryTemplate = iterator.next(); + String query = queryTemplate.toString(); + if (query != null && !query.isEmpty()) { + queryString.append(query); + if (iterator.hasNext()) { + queryString.append("&"); + } + } + } + } + /* remove any trailing ampersands */ + String result = queryString.toString(); + if (result.endsWith("&")) { + result = result.substring(0, result.length() - 1); + } + + if (!result.isEmpty()) { + result = "?" + result; + } + + return result; + } + + private void extractQueryTemplates(String queryString, boolean append) { + /* split the query string up into name value pairs */ + Map> queryParameters = + Arrays.stream(queryString.split("&")) + .map(this::splitQueryParameter) + .collect(Collectors.groupingBy( + SimpleImmutableEntry::getKey, + LinkedHashMap::new, + Collectors.mapping(Entry::getValue, Collectors.toList()))); + + /* add them to this template */ + if (!append) { + /* clear the queries and use the new ones */ + this.queries.clear(); + } + queryParameters.forEach(this::query); + } + + private SimpleImmutableEntry splitQueryParameter(String pair) { + int eq = pair.indexOf("="); + final String name = (eq > 0) ? pair.substring(0, eq) : pair; + final String value = (eq > 0 && eq < pair.length()) ? pair.substring(eq + 1) : null; + return new SimpleImmutableEntry<>(name, value); + } + + public Request.Body requestBody() { + return this.body; + } + + /** + * Factory for creating RequestTemplate. + */ + 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 000000000..8165b0403 --- /dev/null +++ b/core/src/main/java/feign/Response.java @@ -0,0 +1,353 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.io.ByteArrayInputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.Charset; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.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.request != null, "original request is required"); + this.status = builder.status; + this.request = builder.request; + this.reason = builder.reason; // nullable + this.headers = (builder.headers != null) + ? Collections.unmodifiableMap(caseInsensitiveCopyOf(builder.headers)) + : new LinkedHashMap<>(); + this.body = builder.body; // nullable + } + + 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 + */ + public Builder request(Request request) { + checkNotNull(request, "request is required"); + 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; + } + + /** + * 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; + + /** + * + */ + Reader asReader(Charset charset) 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 Reader asReader(Charset charset) throws IOException { + checkNotNull(charset, "charset should not be null"); + return new InputStreamReader(inputStream, charset); + } + + @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 Reader asReader(Charset charset) throws IOException { + checkNotNull(charset, "charset should not be null"); + return new InputStreamReader(asInputStream(), charset); + } + + @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/ResponseMapper.java b/core/src/main/java/feign/ResponseMapper.java new file mode 100644 index 000000000..fd2983b65 --- /dev/null +++ b/core/src/main/java/feign/ResponseMapper.java @@ -0,0 +1,42 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.Type; + +/** + * Map function to apply to the response before decoding it. + * + *
+ * {@code
+ * new ResponseMapper() {
+ *      @Override
+ *      public Response map(Response response, Type type) {
+ *          try {
+ *            return response
+ *              .toBuilder()
+ *              .body(Util.toString(response.body().asReader()).toUpperCase().getBytes())
+ *              .build();
+ *          } catch (IOException e) {
+ *              throw new RuntimeException(e);
+ *          }
+ *      }
+ *  };
+ * }
+ * 
+ */ +public interface ResponseMapper { + + Response map(Response response, Type type); +} diff --git a/core/src/main/java/feign/RetryableException.java b/core/src/main/java/feign/RetryableException.java new file mode 100644 index 000000000..2aa3e8fed --- /dev/null +++ b/core/src/main/java/feign/RetryableException.java @@ -0,0 +1,60 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.Request.HttpMethod; +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; + private final HttpMethod httpMethod; + + /** + * @param retryAfter usually corresponds to the {@link feign.Util#RETRY_AFTER} header. + */ + public RetryableException(int status, String message, HttpMethod httpMethod, Throwable cause, + Date retryAfter) { + super(status, message, cause); + this.httpMethod = httpMethod; + this.retryAfter = retryAfter != null ? retryAfter.getTime() : null; + } + + /** + * @param retryAfter usually corresponds to the {@link feign.Util#RETRY_AFTER} header. + */ + public RetryableException(int status, String message, HttpMethod httpMethod, Date retryAfter) { + super(status, message); + this.httpMethod = httpMethod; + 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; + } + + public HttpMethod method() { + return this.httpMethod; + } +} diff --git a/core/src/main/java/feign/Retryer.java b/core/src/main/java/feign/Retryer.java new file mode 100644 index 000000000..6b91072eb --- /dev/null +++ b/core/src/main/java/feign/Retryer.java @@ -0,0 +1,114 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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(); + throw e; + } + 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 000000000..1dbd9d348 --- /dev/null +++ b/core/src/main/java/feign/SynchronousMethodHandler.java @@ -0,0 +1,219 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.ExceptionPropagationPolicy.UNWRAP; +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 final boolean closeAfterDecode; + private final ExceptionPropagationPolicy propagationPolicy; + + 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, + boolean closeAfterDecode, ExceptionPropagationPolicy propagationPolicy) { + 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; + this.closeAfterDecode = closeAfterDecode; + this.propagationPolicy = propagationPolicy; + } + + @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) { + try { + retryer.continueOrPropagate(e); + } catch (RetryableException th) { + Throwable cause = th.getCause(); + if (propagationPolicy == UNWRAP && cause != null) { + throw cause; + } else { + throw th; + } + } + if (logLevel != Logger.Level.NONE) { + logger.logRetry(metadata.configKey(), logLevel); + } + continue; + } + } + } + + Object executeAndDecode(RequestTemplate template) throws Throwable { + Request request = targetRequest(template); + + if (logLevel != Logger.Level.NONE) { + logger.logRequest(metadata.configKey(), logLevel, request); + } + + Response response; + long start = System.nanoTime(); + try { + response = client.execute(request, options); + } catch (IOException e) { + if (logLevel != Logger.Level.NONE) { + logger.logIOException(metadata.configKey(), logLevel, e, elapsedTime(start)); + } + throw errorExecuting(request, e); + } + long elapsedTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + + boolean shouldClose = true; + try { + if (logLevel != Logger.Level.NONE) { + response = + logger.logAndRebufferResponse(metadata.configKey(), logLevel, response, elapsedTime); + } + 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 { + Object result = decode(response); + shouldClose = closeAfterDecode; + return result; + } + } else if (decode404 && response.status() == 404 && void.class != metadata.returnType()) { + Object result = decode(response); + shouldClose = closeAfterDecode; + return result; + } 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(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(response.status(), 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; + private final boolean closeAfterDecode; + private final ExceptionPropagationPolicy propagationPolicy; + + Factory(Client client, Retryer retryer, List requestInterceptors, + Logger logger, Logger.Level logLevel, boolean decode404, boolean closeAfterDecode, + ExceptionPropagationPolicy propagationPolicy) { + 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; + this.closeAfterDecode = closeAfterDecode; + this.propagationPolicy = propagationPolicy; + } + + 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, closeAfterDecode, propagationPolicy); + } + } +} diff --git a/core/src/main/java/feign/Target.java b/core/src/main/java/feign/Target.java new file mode 100644 index 000000000..5dc548f6d --- /dev/null +++ b/core/src/main/java/feign/Target.java @@ -0,0 +1,204 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.target(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 000000000..180292270 --- /dev/null +++ b/core/src/main/java/feign/Types.java @@ -0,0 +1,469 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 + */ +public final class Types { + + private static final Type[] EMPTY_TYPE_ARRAY = new Type[0]; + + private Types() { + // No instances. + } + + public 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 000000000..8f5c52e01 --- /dev/null +++ b/core/src/main/java/feign/Util.java @@ -0,0 +1,363 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.Buffer; +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.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Stream; +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; + } + + /** + * Removes values from the array that meet the criteria for removal via the supplied + * {@link Predicate} value + */ + @SuppressWarnings("unchecked") + public static T[] removeValues(T[] values, Predicate shouldRemove, Class type) { + Collection collection = new ArrayList<>(values.length); + for (T value : values) { + if (shouldRemove.negate().test(value)) { + collection.add(value); + } + } + T[] array = (T[]) Array.newInstance(type, collection.size()); + return collection.toArray(array); + } + + /** + * 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.getOrDefault(Types.getRawType(type), () -> null).get(); + } + + private static final Map, Supplier> EMPTIES; + static { + final Map, Supplier> empties = new LinkedHashMap, Supplier>(); + 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, Collections::emptyIterator); + empties.put(List.class, Collections::emptyList); + empties.put(Map.class, Collections::emptyMap); + empties.put(Set.class, Collections::emptySet); + empties.put(Optional.class, Optional::empty); + empties.put(Stream.class, Stream::empty); + 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 charBuf = CharBuffer.allocate(BUF_SIZE); + // must cast to super class Buffer otherwise break when running with java 11 + Buffer buf = charBuf; + while (reader.read(charBuf) != -1) { + buf.flip(); + to.append(charBuf); + 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; + } + } + + /** + * If the provided String is not null or empty. + * + * @param value to evaluate. + * @return true of the value is not null and not empty. + */ + public static boolean isNotBlank(String value) { + return value != null && !value.isEmpty(); + } + + /** + * If the provided String is null or empty. + * + * @param value to evaluate. + * @return true if the value is null or empty. + */ + public static boolean isBlank(String value) { + return value == null || value.isEmpty(); + } +} 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 000000000..434acc43c --- /dev/null +++ b/core/src/main/java/feign/auth/Base64.java @@ -0,0 +1,158 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..d5527cb6d --- /dev/null +++ b/core/src/main/java/feign/auth/BasicAuthRequestInterceptor.java @@ -0,0 +1,68 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..036914582 --- /dev/null +++ b/core/src/main/java/feign/codec/DecodeException.java @@ -0,0 +1,42 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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(int status, String message) { + super(status, checkNotNull(message, "message")); + } + + /** + * @param message possibly null reason for the failure. + * @param cause the cause of the error. + */ + public DecodeException(int status, String message, Throwable cause) { + super(status, 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 000000000..effd4cabb --- /dev/null +++ b/core/src/main/java/feign/codec/Decoder.java @@ -0,0 +1,95 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 || response.status() == 204) + 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 000000000..57c586881 --- /dev/null +++ b/core/src/main/java/feign/codec/EncodeException.java @@ -0,0 +1,42 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 static feign.Util.checkNotNull; +import feign.FeignException; + +/** + * 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(-1, 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(-1, 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 000000000..dc4f0164d --- /dev/null +++ b/core/src/main/java/feign/codec/Encoder.java @@ -0,0 +1,98 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..ab99aa396 --- /dev/null +++ b/core/src/main/java/feign/codec/ErrorDecoder.java @@ -0,0 +1,159 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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( + response.status(), + exception.getMessage(), + response.request().httpMethod(), + 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 000000000..2307a7c5b --- /dev/null +++ b/core/src/main/java/feign/codec/StringDecoder.java @@ -0,0 +1,36 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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(response.status(), + format("%s is not a type supported by this decoder.", type)); + } +} diff --git a/core/src/main/java/feign/optionals/OptionalDecoder.java b/core/src/main/java/feign/optionals/OptionalDecoder.java new file mode 100644 index 000000000..f641de0a2 --- /dev/null +++ b/core/src/main/java/feign/optionals/OptionalDecoder.java @@ -0,0 +1,52 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.optionals; + +import feign.Response; +import feign.Util; +import feign.codec.Decoder; +import java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Objects; +import java.util.Optional; + +public final class OptionalDecoder implements Decoder { + final Decoder delegate; + + public OptionalDecoder(Decoder delegate) { + Objects.requireNonNull(delegate, "Decoder must not be null. "); + this.delegate = delegate; + } + + @Override + public Object decode(Response response, Type type) throws IOException { + if (!isOptional(type)) { + return delegate.decode(response, type); + } + if (response.status() == 404 || response.status() == 204) { + return Optional.empty(); + } + Type enclosedType = Util.resolveLastTypeParameter(type, Optional.class); + return Optional.ofNullable(delegate.decode(response, enclosedType)); + } + + static boolean isOptional(Type type) { + if (!(type instanceof ParameterizedType)) { + return false; + } + ParameterizedType parameterizedType = (ParameterizedType) type; + return parameterizedType.getRawType().equals(Optional.class); + } +} diff --git a/core/src/main/java/feign/querymap/BeanQueryMapEncoder.java b/core/src/main/java/feign/querymap/BeanQueryMapEncoder.java new file mode 100644 index 000000000..abd830b23 --- /dev/null +++ b/core/src/main/java/feign/querymap/BeanQueryMapEncoder.java @@ -0,0 +1,85 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.querymap; + +import feign.QueryMapEncoder; +import feign.codec.EncodeException; +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.lang.reflect.InvocationTargetException; +import java.util.*; + +/** + * the query map will be generated using java beans accessible getter property as query parameter + * names. + * + * eg: "/uri?name={name}&number={number}" + * + * order of included query parameters not guaranteed, and as usual, if any value is null, it will be + * left out + */ +public class BeanQueryMapEncoder implements QueryMapEncoder { + private final Map, ObjectParamMetadata> classToMetadata = + new HashMap, ObjectParamMetadata>(); + + @Override + public Map encode(Object object) throws EncodeException { + try { + ObjectParamMetadata metadata = getMetadata(object.getClass()); + Map propertyNameToValue = new HashMap(); + for (PropertyDescriptor pd : metadata.objectProperties) { + Object value = pd.getReadMethod().invoke(object); + if (value != null && value != object) { + propertyNameToValue.put(pd.getName(), value); + } + } + return propertyNameToValue; + } catch (IllegalAccessException | IntrospectionException | InvocationTargetException e) { + throw new EncodeException("Failure encoding object into query map", e); + } + } + + private ObjectParamMetadata getMetadata(Class objectType) throws IntrospectionException { + ObjectParamMetadata metadata = classToMetadata.get(objectType); + if (metadata == null) { + metadata = ObjectParamMetadata.parseObjectType(objectType); + classToMetadata.put(objectType, metadata); + } + return metadata; + } + + private static class ObjectParamMetadata { + + private final List objectProperties; + + private ObjectParamMetadata(List objectProperties) { + this.objectProperties = Collections.unmodifiableList(objectProperties); + } + + private static ObjectParamMetadata parseObjectType(Class type) + throws IntrospectionException { + List properties = new ArrayList(); + + for (PropertyDescriptor pd : Introspector.getBeanInfo(type).getPropertyDescriptors()) { + boolean isGetterMethod = pd.getReadMethod() != null && !"class".equals(pd.getName()); + if (isGetterMethod) { + properties.add(pd); + } + } + + return new ObjectParamMetadata(properties); + } + } +} diff --git a/core/src/main/java/feign/querymap/FieldQueryMapEncoder.java b/core/src/main/java/feign/querymap/FieldQueryMapEncoder.java new file mode 100644 index 000000000..1e0818f5c --- /dev/null +++ b/core/src/main/java/feign/querymap/FieldQueryMapEncoder.java @@ -0,0 +1,77 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.querymap; + +import feign.QueryMapEncoder; +import feign.codec.EncodeException; +import java.lang.reflect.Field; +import java.util.*; +import java.util.stream.Collectors; + +/** + * the query map will be generated using member variable names as query parameter names. + * + * eg: "/uri?name={name}&number={number}" + * + * order of included query parameters not guaranteed, and as usual, if any value is null, it will be + * left out + */ +public class FieldQueryMapEncoder implements QueryMapEncoder { + + private final Map, ObjectParamMetadata> classToMetadata = + new HashMap, ObjectParamMetadata>(); + + @Override + public Map encode(Object object) throws EncodeException { + try { + ObjectParamMetadata metadata = getMetadata(object.getClass()); + Map fieldNameToValue = new HashMap(); + for (Field field : metadata.objectFields) { + Object value = field.get(object); + if (value != null && value != object) { + fieldNameToValue.put(field.getName(), value); + } + } + return fieldNameToValue; + } catch (IllegalAccessException e) { + throw new EncodeException("Failure encoding object into query map", e); + } + } + + private ObjectParamMetadata getMetadata(Class objectType) { + ObjectParamMetadata metadata = classToMetadata.get(objectType); + if (metadata == null) { + metadata = ObjectParamMetadata.parseObjectType(objectType); + classToMetadata.put(objectType, metadata); + } + return metadata; + } + + private static class ObjectParamMetadata { + + private final List objectFields; + + private ObjectParamMetadata(List objectFields) { + this.objectFields = Collections.unmodifiableList(objectFields); + } + + private static ObjectParamMetadata parseObjectType(Class type) { + return new ObjectParamMetadata( + Arrays.stream(type.getDeclaredFields()) + .filter(field -> !field.isSynthetic()) + .peek(field -> field.setAccessible(true)) + .collect(Collectors.toList())); + } + } +} diff --git a/core/src/main/java/feign/stream/StreamDecoder.java b/core/src/main/java/feign/stream/StreamDecoder.java new file mode 100644 index 000000000..64ee79b36 --- /dev/null +++ b/core/src/main/java/feign/stream/StreamDecoder.java @@ -0,0 +1,107 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.stream; + +import feign.FeignException; +import feign.Response; +import feign.codec.Decoder; +import java.io.Closeable; +import java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Iterator; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import static feign.Util.ensureClosed; + +/** + * Iterator based decoder that support streaming. + *

+ *

+ * Example:
+ * + *

+ * 
+ * Feign.builder()
+ *   .decoder(StreamDecoder.create(JacksonIteratorDecoder.create()))
+ *   .doNotCloseAfterDecode() // Required for streaming
+ *   .target(GitHub.class, "https://api.github.com");
+ * interface GitHub {
+ *  {@literal @}RequestLine("GET /repos/{owner}/{repo}/contributors")
+ *   Stream contributors(@Param("owner") String owner, @Param("repo") String repo);
+ * }
+ * 
+ */ +public final class StreamDecoder implements Decoder { + + private final Decoder iteratorDecoder; + + StreamDecoder(Decoder iteratorDecoder) { + this.iteratorDecoder = iteratorDecoder; + } + + @Override + public Object decode(Response response, Type type) + throws IOException, FeignException { + if (!(type instanceof ParameterizedType)) { + throw new IllegalArgumentException("StreamDecoder supports only stream: unknown " + type); + } + ParameterizedType streamType = (ParameterizedType) type; + if (!Stream.class.equals(streamType.getRawType())) { + throw new IllegalArgumentException("StreamDecoder supports only stream: unknown " + type); + } + Iterator iterator = + (Iterator) iteratorDecoder.decode(response, new IteratorParameterizedType(streamType)); + + return StreamSupport.stream( + Spliterators.spliteratorUnknownSize(iterator, 0), false) + .onClose(() -> { + if (iterator instanceof Closeable) { + ensureClosed((Closeable) iterator); + } else { + ensureClosed(response); + } + }); + } + + public static StreamDecoder create(Decoder iteratorDecoder) { + return new StreamDecoder(iteratorDecoder); + } + + static final class IteratorParameterizedType implements ParameterizedType { + + private final ParameterizedType streamType; + + IteratorParameterizedType(ParameterizedType streamType) { + this.streamType = streamType; + } + + @Override + public Type[] getActualTypeArguments() { + return streamType.getActualTypeArguments(); + } + + @Override + public Type getRawType() { + return Iterator.class; + } + + @Override + public Type getOwnerType() { + return null; + } + } +} diff --git a/core/src/main/java/feign/template/BodyTemplate.java b/core/src/main/java/feign/template/BodyTemplate.java new file mode 100644 index 000000000..6371afd22 --- /dev/null +++ b/core/src/main/java/feign/template/BodyTemplate.java @@ -0,0 +1,44 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +import feign.Util; +import java.nio.charset.Charset; +import java.util.Map; + +/** + * Template for @{@link feign.Body} annotated Templates. Unresolved expressions are preserved as + * literals and literals are not URI encoded. + */ +public final class BodyTemplate extends Template { + + /** + * Create a new Body Template. + * + * @param template to parse. + * @return a Body Template instance. + */ + public static BodyTemplate create(String template) { + return new BodyTemplate(template, Util.UTF_8); + } + + private BodyTemplate(String value, Charset charset) { + super(value, ExpansionOptions.ALLOW_UNRESOLVED, EncodingOptions.NOT_REQUIRED, false, charset); + } + + @Override + public String expand(Map variables) { + return UriUtils.decode(super.expand(variables), Util.UTF_8); + } +} diff --git a/core/src/main/java/feign/template/Expression.java b/core/src/main/java/feign/template/Expression.java new file mode 100644 index 000000000..3040aa1c3 --- /dev/null +++ b/core/src/main/java/feign/template/Expression.java @@ -0,0 +1,75 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * URI Template Expression. + */ +abstract class Expression implements TemplateChunk { + + private String name; + private Pattern pattern; + + /** + * Create a new Expression. + * + * @param name of the variable + * @param pattern the resolved variable must adhere to, optional. + */ + Expression(String name, String pattern) { + this.name = name; + Optional.ofNullable(pattern).ifPresent(s -> this.pattern = Pattern.compile(s)); + } + + abstract String expand(Object variable, boolean encode); + + public String getName() { + return this.name; + } + + Pattern getPattern() { + return pattern; + } + + /** + * Checks if the provided value matches the variable pattern, if one is defined. Always true if no + * pattern is defined. + * + * @param value to check. + * @return true if it matches. + */ + boolean matches(String value) { + if (pattern == null) { + return true; + } + return pattern.matcher(value).matches(); + } + + @Override + public String getValue() { + if (this.pattern != null) { + return "{" + this.name + ":" + this.pattern + "}"; + } + return "{" + this.name + "}"; + } + + @Override + public String toString() { + return this.getValue(); + } +} diff --git a/core/src/main/java/feign/template/Expressions.java b/core/src/main/java/feign/template/Expressions.java new file mode 100644 index 000000000..558766a6d --- /dev/null +++ b/core/src/main/java/feign/template/Expressions.java @@ -0,0 +1,122 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +import feign.Util; +import feign.template.UriUtils.FragmentType; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class Expressions { + private static Map> expressions; + + static { + expressions = new LinkedHashMap<>(); + expressions.put(Pattern.compile("(\\w[-\\w.]*[ ]*)(:(.+))?"), SimpleExpression.class); + } + + public static Expression create(final String value, final FragmentType type) { + + /* remove the start and end braces */ + final String expression = stripBraces(value); + if (expression == null || expression.isEmpty()) { + throw new IllegalArgumentException("an expression is required."); + } + + Optional>> matchedExpressionEntry = + expressions.entrySet() + .stream() + .filter(entry -> entry.getKey().matcher(expression).matches()) + .findFirst(); + + if (!matchedExpressionEntry.isPresent()) { + /* not a valid expression */ + return null; + } + + Entry> matchedExpression = matchedExpressionEntry.get(); + Pattern expressionPattern = matchedExpression.getKey(); + + /* create a new regular expression matcher for the expression */ + String variableName = null; + String variablePattern = null; + Matcher matcher = expressionPattern.matcher(expression); + if (matcher.matches()) { + /* we have a valid variable expression, extract the name from the first group */ + variableName = matcher.group(1).trim(); + if (matcher.group(2) != null && matcher.group(3) != null) { + /* this variable contains an optional pattern */ + variablePattern = matcher.group(3); + } + } + + return new SimpleExpression(variableName, variablePattern, type); + } + + private static String stripBraces(String expression) { + if (expression == null) { + return null; + } + if (expression.startsWith("{") && expression.endsWith("}")) { + return expression.substring(1, expression.length() - 1); + } + return expression; + } + + /** + * Expression that adheres to Simple String Expansion as outlined in values; + private String name; + + public static HeaderTemplate create(String name, Iterable values) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required."); + } + + if (values == null) { + throw new IllegalArgumentException("values are required"); + } + + /* construct a uri template from the name and values */ + StringBuilder template = new StringBuilder(); + template.append(name) + .append(" "); + + /* create a comma separated template for the header values */ + Iterator iterator = values.iterator(); + while (iterator.hasNext()) { + template.append(iterator.next()); + if (iterator.hasNext()) { + template.append(", "); + } + } + return new HeaderTemplate(template.toString(), name, values, Util.UTF_8); + } + + /** + * Append values to a Header Template. + * + * @param headerTemplate to append to. + * @param values to append. + * @return a new Header Template with the values added. + */ + public static HeaderTemplate append(HeaderTemplate headerTemplate, Iterable values) { + Set headerValues = new LinkedHashSet<>(headerTemplate.getValues()); + headerValues.addAll(StreamSupport.stream(values.spliterator(), false) + .filter(Util::isNotBlank) + .collect(Collectors.toSet())); + return create(headerTemplate.getName(), headerValues); + } + + /** + * Creates a new Header Template. + * + * @param template to parse. + */ + private HeaderTemplate(String template, String name, Iterable values, Charset charset) { + super(template, ExpansionOptions.REQUIRED, EncodingOptions.NOT_REQUIRED, false, charset); + this.values = StreamSupport.stream(values.spliterator(), false) + .filter(Util::isNotBlank) + .collect(Collectors.toSet()); + this.name = name; + } + + public Collection getValues() { + return Collections.unmodifiableCollection(values); + } + + public String getName() { + return name; + } +} diff --git a/core/src/main/java/feign/template/Literal.java b/core/src/main/java/feign/template/Literal.java new file mode 100644 index 000000000..36ce1b891 --- /dev/null +++ b/core/src/main/java/feign/template/Literal.java @@ -0,0 +1,49 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +/** + * URI Template Literal. + */ +class Literal implements TemplateChunk { + + private final String value; + + /** + * Create a new Literal. + * + * @param value of the literal. + * @return the new Literal. + */ + public static Literal create(String value) { + return new Literal(value); + } + + /** + * Create a new Literal. + * + * @param value of the literal. + */ + Literal(String value) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException("a value is required."); + } + this.value = value; + } + + @Override + public String getValue() { + return this.value; + } +} diff --git a/core/src/main/java/feign/template/QueryTemplate.java b/core/src/main/java/feign/template/QueryTemplate.java new file mode 100644 index 000000000..90a48eff0 --- /dev/null +++ b/core/src/main/java/feign/template/QueryTemplate.java @@ -0,0 +1,197 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +import feign.CollectionFormat; +import feign.Util; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Template for a Query String parameter. + */ +public final class QueryTemplate extends Template { + + public static final String UNDEF = "undef"; + /* cache a copy of the variables for lookup later */ + private List values; + private final Template name; + private final CollectionFormat collectionFormat; + private boolean pure = false; + + /** + * Create a new Query Template. + * + * @param name of the query parameter. + * @param values in the template. + * @param charset for the template. + * @return a QueryTemplate. + */ + public static QueryTemplate create(String name, Iterable values, Charset charset) { + return create(name, values, charset, CollectionFormat.EXPLODED); + } + + /** + * Create a new Query Template. + * + * @param name of the query parameter. + * @param values in the template. + * @param charset for the template. + * @param collectionFormat to use. + * @return a QueryTemplate + */ + public static QueryTemplate create(String name, + Iterable values, + Charset charset, + CollectionFormat collectionFormat) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required."); + } + + if (values == null) { + throw new IllegalArgumentException("values are required"); + } + + /* remove all empty values from the array */ + Collection remaining = StreamSupport.stream(values.spliterator(), false) + .filter(Util::isNotBlank) + .collect(Collectors.toList()); + + StringBuilder template = new StringBuilder(); + Iterator iterator = remaining.iterator(); + while (iterator.hasNext()) { + template.append(iterator.next()); + if (iterator.hasNext()) { + template.append(","); + } + } + + return new QueryTemplate(template.toString(), name, remaining, charset, collectionFormat); + } + + /** + * Append a value to the Query Template. + * + * @param queryTemplate to append to. + * @param values to append. + * @return a new QueryTemplate with value appended. + */ + public static QueryTemplate append(QueryTemplate queryTemplate, + Iterable values, + CollectionFormat collectionFormat) { + List queryValues = new ArrayList<>(queryTemplate.getValues()); + queryValues.addAll(StreamSupport.stream(values.spliterator(), false) + .filter(Util::isNotBlank) + .collect(Collectors.toList())); + return create(queryTemplate.getName(), queryValues, queryTemplate.getCharset(), + collectionFormat); + } + + /** + * Create a new Query Template. + * + * @param template for the Query String. + * @param name of the query parameter. + * @param values for the parameter. + * @param collectionFormat to use. + */ + private QueryTemplate( + String template, + String name, + Iterable values, + Charset charset, + CollectionFormat collectionFormat) { + super(template, ExpansionOptions.REQUIRED, EncodingOptions.REQUIRED, true, charset); + this.name = new Template(name, ExpansionOptions.ALLOW_UNRESOLVED, EncodingOptions.REQUIRED, + false, charset); + this.collectionFormat = collectionFormat; + this.values = StreamSupport.stream(values.spliterator(), false) + .filter(Util::isNotBlank) + .collect(Collectors.toList()); + if (this.values.isEmpty()) { + /* in this case, we have a pure parameter */ + this.pure = true; + + } + } + + public List getValues() { + return values; + } + + public String getName() { + return name.toString(); + } + + @Override + public String toString() { + return this.queryString(this.name.toString(), super.toString()); + } + + /** + * Expand this template. Unresolved variables are removed. If all values remain unresolved, the + * result is an empty string. + * + * @param variables containing the values for expansion. + * @return the expanded template. + */ + @Override + public String expand(Map variables) { + String name = this.name.expand(variables); + return this.queryString(name, super.expand(variables)); + } + + @Override + protected String resolveExpression(Expression expression, Map variables) { + if (variables.containsKey(expression.getName())) { + if (variables.get(expression.getName()) == null) { + /* explicit undefined */ + return UNDEF; + } + return super.resolveExpression(expression, variables); + } + + /* mark the variable as undefined */ + return UNDEF; + } + + private String queryString(String name, String values) { + if (this.pure) { + return name; + } + + /* covert the comma separated values into a value query string */ + List resolved = Arrays.stream(values.split(",")) + .filter(Objects::nonNull) + .filter(s -> !UNDEF.equalsIgnoreCase(s)) + .collect(Collectors.toList()); + + if (!resolved.isEmpty()) { + return this.collectionFormat.join(name, resolved, this.getCharset()).toString(); + } + + /* nothing to return, all values are unresolved */ + return null; + } + +} diff --git a/core/src/main/java/feign/template/Template.java b/core/src/main/java/feign/template/Template.java new file mode 100644 index 000000000..8c8eb70a2 --- /dev/null +++ b/core/src/main/java/feign/template/Template.java @@ -0,0 +1,347 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +import feign.Util; +import feign.template.UriUtils.FragmentType; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * A Generic representation of a Template Expression as defined by + * RFC 6570, with some relaxed rules, allowing the + * concept to be used in areas outside of the uri. + */ +public class Template { + + private static final Logger logger = Logger.getLogger(Template.class.getName()); + private static final Pattern QUERY_STRING_PATTERN = Pattern.compile("(? templateChunks = new ArrayList<>(); + + /** + * Create a new Template. + * + * @param value of the template. + * @param allowUnresolved if unresolved expressions should remain. + * @param encode all values. + * @param encodeSlash if slash characters should be encoded. + */ + Template( + String value, ExpansionOptions allowUnresolved, EncodingOptions encode, boolean encodeSlash, + Charset charset) { + if (value == null) { + throw new IllegalArgumentException("template is required."); + } + this.template = value; + this.allowUnresolved = ExpansionOptions.ALLOW_UNRESOLVED == allowUnresolved; + this.encode = encode; + this.encodeSlash = encodeSlash; + this.charset = charset; + this.parseTemplate(); + } + + /** + * Expand the template. + * + * @param variables containing the values for expansion. + * @return a fully qualified URI with the variables expanded. + */ + public String expand(Map variables) { + if (variables == null) { + throw new IllegalArgumentException("variable map is required."); + } + + /* resolve all expressions within the template */ + StringBuilder resolved = new StringBuilder(); + for (TemplateChunk chunk : this.templateChunks) { + if (chunk instanceof Expression) { + String resolvedExpression = this.resolveExpression((Expression) chunk, variables); + if (resolvedExpression != null) { + resolved.append(resolvedExpression); + } + } else { + /* chunk is a literal value */ + resolved.append(chunk.getValue()); + } + } + return resolved.toString(); + } + + protected String resolveExpression(Expression expression, Map variables) { + String resolved = null; + Object value = variables.get(expression.getName()); + if (value != null) { + String expanded = expression.expand(value, this.encode.isEncodingRequired()); + if (Util.isNotBlank(expanded)) { + if (this.encodeSlash) { + logger.fine("Explicit slash decoding specified, decoding all slashes in uri"); + expanded = expanded.replaceAll("/", "%2F"); + } + resolved = expanded; + } + } else { + if (this.allowUnresolved) { + /* unresolved variables are treated as literals */ + resolved = encode(expression.toString()); + } + } + return resolved; + } + + /** + * Uri Encode the value. + * + * @param value to encode. + * @return the encoded value. + */ + private String encode(String value) { + return this.encode.isEncodingRequired() ? UriUtils.encode(value, this.charset) : value; + } + + /** + * Uri Encode the value. + * + * @param value to encode + * @param query indicating this value is on a query string. + * @return the encoded value + */ + private String encode(String value, boolean query) { + if (this.encode.isEncodingRequired()) { + return query ? UriUtils.queryEncode(value, this.charset) + : UriUtils.pathEncode(value, this.charset); + } else { + return value; + } + } + + /** + * Variable names contained in the template. + * + * @return a List of Variable Names. + */ + public List getVariables() { + return this.templateChunks.stream() + .filter(templateChunk -> Expression.class.isAssignableFrom(templateChunk.getClass())) + .map(templateChunk -> ((Expression) templateChunk).getName()) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * List of all Literals in the Template. + * + * @return list of Literal values. + */ + public List getLiterals() { + return this.templateChunks.stream() + .filter(templateChunk -> Literal.class.isAssignableFrom(templateChunk.getClass())) + .map(TemplateChunk::toString) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + /** + * Flag to indicate that this template is a literal string, with no variable expressions. + * + * @return true if this template is made up entirely of literal strings. + */ + public boolean isLiteral() { + return this.getVariables().isEmpty(); + } + + /** + * Parse the template into {@link TemplateChunk}s. + */ + private void parseTemplate() { + /* + * query string and path literals have different reserved characters and different encoding + * requirements. to ensure compliance with RFC 6570, we'll need to encode query literals + * differently from path literals. let's look at the template to see if it contains a query + * string and if so, keep track of where it starts. + */ + Matcher queryStringMatcher = QUERY_STRING_PATTERN.matcher(this.template); + if (queryStringMatcher.find()) { + /* + * the template contains a query string, split the template into two parts, the path and query + */ + String path = this.template.substring(0, queryStringMatcher.start()); + String query = this.template.substring(queryStringMatcher.end() - 1); + this.parseFragment(path, false); + this.parseFragment(query, true); + } else { + /* parse the entire template */ + this.parseFragment(this.template, false); + } + } + + /** + * Parse a template fragment. + * + * @param fragment to parse + * @param query if the fragment is part of a query string. + */ + private void parseFragment(String fragment, boolean query) { + ChunkTokenizer tokenizer = new ChunkTokenizer(fragment); + + while (tokenizer.hasNext()) { + /* check to see if we have an expression or a literal */ + String chunk = tokenizer.next(); + + if (chunk.startsWith("{")) { + /* it's an expression, defer encoding until resolution */ + FragmentType type = (query) ? FragmentType.QUERY : FragmentType.PATH_SEGMENT; + + Expression expression = Expressions.create(chunk, type); + if (expression == null) { + this.templateChunks.add(Literal.create(encode(chunk, query))); + } else { + this.templateChunks.add(expression); + } + } else { + /* it's a literal, pct-encode it */ + this.templateChunks.add(Literal.create(encode(chunk, query))); + } + } + } + + @Override + public String toString() { + return this.templateChunks.stream() + .map(TemplateChunk::getValue).collect(Collectors.joining()); + } + + public boolean encode() { + return encode.isEncodingRequired(); + } + + boolean encodeSlash() { + return encodeSlash; + } + + /** + * The Charset for the template. + * + * @return the Charset, if set. Defaults to UTF-8 + */ + public Charset getCharset() { + return this.charset; + } + + /** + * Splits a Uri into Chunks that exists inside and outside of an expression, delimited by curly + * braces "{}". Nested expressions are treated as literals, for example "foo{bar{baz}}" will be + * treated as "foo, {bar{baz}}". Inspired by Apache CXF Jax-RS. + */ + static class ChunkTokenizer { + + private List tokens = new ArrayList<>(); + private int index; + + ChunkTokenizer(String template) { + boolean outside = true; + int level = 0; + int lastIndex = 0; + int idx; + + /* loop through the template, character by character */ + for (idx = 0; idx < template.length(); idx++) { + if (template.charAt(idx) == '{') { + /* start of an expression */ + if (outside) { + /* outside of an expression */ + if (lastIndex < idx) { + /* this is the start of a new token */ + tokens.add(template.substring(lastIndex, idx)); + } + lastIndex = idx; + + /* + * no longer outside of an expression, additional characters will be treated as in an + * expression + */ + outside = false; + } else { + /* nested braces, increase our nesting level */ + level++; + } + } else if (template.charAt(idx) == '}' && !outside) { + /* the end of an expression */ + if (level > 0) { + /* + * sometimes we see nested expressions, we only want the outer most expression + * boundaries. + */ + level--; + } else { + /* outermost boundary */ + if (lastIndex < idx) { + /* this is the end of an expression token */ + tokens.add(template.substring(lastIndex, idx + 1)); + } + lastIndex = idx + 1; + + /* outside an expression */ + outside = true; + } + } + } + if (lastIndex < idx) { + /* grab the remaining chunk */ + tokens.add(template.substring(lastIndex, idx)); + } + } + + public boolean hasNext() { + return this.tokens.size() > this.index; + } + + public String next() { + if (hasNext()) { + return this.tokens.get(this.index++); + } + throw new IllegalStateException("No More Elements"); + } + } + + public enum EncodingOptions { + REQUIRED(true), NOT_REQUIRED(false); + + private boolean shouldEncode; + + EncodingOptions(boolean shouldEncode) { + this.shouldEncode = shouldEncode; + } + + public boolean isEncodingRequired() { + return this.shouldEncode; + } + } + + public enum ExpansionOptions { + ALLOW_UNRESOLVED, REQUIRED + } + +} diff --git a/core/src/main/java/feign/template/TemplateChunk.java b/core/src/main/java/feign/template/TemplateChunk.java new file mode 100644 index 000000000..64910ea61 --- /dev/null +++ b/core/src/main/java/feign/template/TemplateChunk.java @@ -0,0 +1,24 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +/** + * Represents the parts of a URI Template. + */ +@FunctionalInterface +interface TemplateChunk { + + String getValue(); + +} diff --git a/core/src/main/java/feign/template/UriTemplate.java b/core/src/main/java/feign/template/UriTemplate.java new file mode 100644 index 000000000..4e45530d9 --- /dev/null +++ b/core/src/main/java/feign/template/UriTemplate.java @@ -0,0 +1,75 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +import java.nio.charset.Charset; + +/** + * URI Template, as defined by RFC 6570, + * supporting Level 1 expressions, + * with the following differences: + * + *
    + *
  1. unresolved variables are preserved as literals
  2. + *
  3. all literals are pct-encoded
  4. + *
+ */ +public class UriTemplate extends Template { + + /** + * Create a Uri Template. + * + * @param template representing the uri. + * @param charset for encoding. + * @return a new Uri Template instance. + */ + public static UriTemplate create(String template, Charset charset) { + return new UriTemplate(template, true, charset); + } + + /** + * Create a Uri Template. + * + * @param template representing the uri + * @param encodeSlash flag if slash characters should be encoded. + * @param charset for the template. + * @return a new Uri Template instance. + */ + public static UriTemplate create(String template, boolean encodeSlash, Charset charset) { + return new UriTemplate(template, encodeSlash, charset); + } + + /** + * Append a uri fragment to the template. + * + * @param uriTemplate to append to. + * @param fragment to append. + * @return a new UriTemplate with the fragment appended. + */ + public static UriTemplate append(UriTemplate uriTemplate, String fragment) { + return new UriTemplate(uriTemplate.toString() + fragment, uriTemplate.encodeSlash(), + uriTemplate.getCharset()); + } + + /** + * Create a new Uri Template. + * + * @param template for the uri. + * @param encodeSlash flag for encoding slash characters. + * @param charset to use when encoding. + */ + private UriTemplate(String template, boolean encodeSlash, Charset charset) { + super(template, ExpansionOptions.REQUIRED, EncodingOptions.REQUIRED, encodeSlash, charset); + } +} diff --git a/core/src/main/java/feign/template/UriUtils.java b/core/src/main/java/feign/template/UriUtils.java new file mode 100644 index 000000000..1fd17dcc8 --- /dev/null +++ b/core/src/main/java/feign/template/UriUtils.java @@ -0,0 +1,284 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +import feign.Util; +import java.io.ByteArrayOutputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.Charset; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class UriUtils { + + + // private static final String QUERY_RESERVED_CHARACTERS = "="; + // private static final String PATH_RESERVED_CHARACTERS = "/=@:!$&\'(),;~"; + private static final Pattern PCT_ENCODED_PATTERN = Pattern.compile("%[0-9A-Fa-f][0-9A-Fa-f]"); + + /** + * Determines if the value is already pct-encoded. + * + * @param value to check. + * @return {@literal true} if the value is already pct-encoded + */ + public static boolean isEncoded(String value) { + return PCT_ENCODED_PATTERN.matcher(value).matches(); + } + + /** + * Uri Encode the value, using the default Charset. Already encoded values are skipped. + * + * @param value to encode. + * @return the encoded value. + */ + public static String encode(String value) { + return encodeReserved(value, FragmentType.URI, Util.UTF_8); + } + + /** + * Uri Encode the value. Already encoded values are skipped. + * + * @param value to encode. + * @param charset to use. + * @return the encoded value. + */ + public static String encode(String value, Charset charset) { + return encodeReserved(value, FragmentType.URI, charset); + } + + /** + * Uri Decode the value. + * + * @param value to decode + * @param charset to use. + * @return the decoded value. + */ + public static String decode(String value, Charset charset) { + try { + /* there is nothing special between uri and url decoding */ + return URLDecoder.decode(value, charset.name()); + } catch (UnsupportedEncodingException uee) { + /* since the encoding is not supported, return the original value */ + return value; + } + } + + /** + * Uri Encode a Path Fragment. + * + * @param path containing the path fragment. + * @param charset to use. + * @return the encoded path fragment. + */ + public static String pathEncode(String path, Charset charset) { + return encodeReserved(path, FragmentType.PATH_SEGMENT, charset); + + /* + * path encoding is not equivalent to query encoding, there are few differences, namely dealing + * with spaces, !, ', (, ), and ~ characters. we will need to manually process those values. + */ + // return encoded.replaceAll("\\+", "%20"); + } + + /** + * Uri Encode a Query Fragment. + * + * @param query containing the query fragment + * @param charset to use. + * @return the encoded query fragment. + */ + public static String queryEncode(String query, Charset charset) { + return encodeReserved(query, FragmentType.QUERY, charset); + + /* spaces will be encoded as 'plus' symbols here, we want them pct-encoded */ + // return encoded.replaceAll("\\+", "%20"); + } + + /** + * Uri Encode a Query Parameter name or value. + * + * @param queryParam containing the query parameter. + * @param charset to use. + * @return the encoded query fragment. + */ + public static String queryParamEncode(String queryParam, Charset charset) { + return encodeReserved(queryParam, FragmentType.QUERY_PARAM, charset); + } + + /** + * Determines if the provided uri is an absolute uri. + * + * @param uri to evaluate. + * @return true if the uri is absolute. + */ + public static boolean isAbsolute(String uri) { + return uri != null && !uri.isEmpty() && uri.startsWith("http"); + } + + + /** + * Encodes the value, preserving all reserved characters.. Values that are already pct-encoded are + * ignored. + * + * @param value inspect. + * @param type identifying which uri fragment rules to apply. + * @param charset to use. + * @return a new String with the reserved characters preserved. + */ + public static String encodeReserved(String value, FragmentType type, Charset charset) { + /* value is encoded, we need to split it up and skip the parts that are already encoded */ + Matcher matcher = PCT_ENCODED_PATTERN.matcher(value); + + if (!matcher.find()) { + return encodeChunk(value, type, charset); + } + + int length = value.length(); + StringBuilder encoded = new StringBuilder(length + 8); + int index = 0; + do { + /* split out the value before the encoded value */ + String before = value.substring(index, matcher.start()); + + /* encode it */ + encoded.append(encodeChunk(before, type, charset)); + + /* append the encoded value */ + encoded.append(matcher.group()); + + /* update the string search index */ + index = matcher.end(); + } while (matcher.find()); + + /* append the rest of the string */ + String tail = value.substring(index, length); + encoded.append(encodeChunk(tail, type, charset)); + return encoded.toString(); + } + + /** + * Encode a Uri Chunk, ensuring that all reserved characters are also encoded. + * + * @param value to encode. + * @param type identifying which uri fragment rules to apply. + * @param charset to use. + * @return an encoded uri chunk. + */ + private static String encodeChunk(String value, FragmentType type, Charset charset) { + byte[] data = value.getBytes(charset); + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + + for (byte b : data) { + if (type.isAllowed(b)) { + encoded.write(b); + } else { + /* percent encode the byte */ + pctEncode(b, encoded); + } + } + return new String(encoded.toByteArray()); + } + + /** + * Percent Encode the provided byte. + * + * @param data to encode + * @param bos with the output stream to use. + */ + private static void pctEncode(byte data, ByteArrayOutputStream bos) { + bos.write('%'); + char hex1 = Character.toUpperCase(Character.forDigit((data >> 4) & 0xF, 16)); + char hex2 = Character.toUpperCase(Character.forDigit(data & 0xF, 16)); + bos.write(hex1); + bos.write(hex2); + } + + enum FragmentType { + + URI { + @Override + boolean isAllowed(int c) { + return isUnreserved(c); + } + }, + RESERVED { + @Override + boolean isAllowed(int c) { + return isUnreserved(c) || isReserved(c); + } + }, + PATH_SEGMENT { + @Override + boolean isAllowed(int c) { + return this.isPchar(c) || (c == '/'); + } + }, + QUERY { + @Override + boolean isAllowed(int c) { + /* although plus signs are allowed, their use is inconsistent. force encoding */ + if (c == '+') { + return false; + } + + return this.isPchar(c) || c == '/' || c == '?'; + } + }, + QUERY_PARAM { + @Override + boolean isAllowed(int c) { + /* explicitly encode equals, ampersands, questions */ + if (c == '=' || c == '&' || c == '?') { + return false; + } + return QUERY.isAllowed(c); + } + }; + + abstract boolean isAllowed(int c); + + protected boolean isAlpha(int c) { + return (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'); + } + + protected boolean isDigit(int c) { + return (c >= '0' && c <= '9'); + } + + protected boolean isGenericDelimiter(int c) { + return (c == ':') || (c == '/') || (c == '?') || (c == '#') || (c == '[') || (c == ']') + || (c == '@'); + } + + protected boolean isSubDelimiter(int c) { + return (c == '!') || (c == '$') || (c == '&') || (c == '\'') || (c == '(') || (c == ')') + || (c == '*') || (c == '+') || (c == ',') || (c == ';') || (c == '='); + } + + protected boolean isUnreserved(int c) { + return this.isAlpha(c) || this.isDigit(c) || c == '-' || c == '.' || c == '_' || c == '~'; + } + + protected boolean isReserved(int c) { + return this.isGenericDelimiter(c) || this.isSubDelimiter(c); + } + + protected boolean isPchar(int c) { + return this.isUnreserved(c) || this.isSubDelimiter(c) || c == ':' || c == '@'; + } + + } +} diff --git a/core/src/test/java/feign/BaseApiTest.java b/core/src/test/java/feign/BaseApiTest.java new file mode 100644 index 000000000..0ef20a6be --- /dev/null +++ b/core/src/test/java/feign/BaseApiTest.java @@ -0,0 +1,105 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..e1c97dad2 --- /dev/null +++ b/core/src/test/java/feign/ContractWithRuntimeInjectionTest.java @@ -0,0 +1,123 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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/CustomPojo.java b/core/src/test/java/feign/CustomPojo.java new file mode 100644 index 000000000..1bbfaeb5d --- /dev/null +++ b/core/src/test/java/feign/CustomPojo.java @@ -0,0 +1,25 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +public class CustomPojo { + + private final String name; + private final Integer number; + + CustomPojo(String name, Integer number) { + this.name = name; + this.number = number; + } +} diff --git a/core/src/test/java/feign/DefaultContractTest.java b/core/src/test/java/feign/DefaultContractTest.java new file mode 100644 index 000000000..de534cbd1 --- /dev/null +++ b/core/src/test/java/feign/DefaultContractTest.java @@ -0,0 +1,869 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 java.util.ArrayList; +import java.util.Collections; +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()) + .hasPath("/") + .hasQueries( + entry("Action", asList("GetUser"))); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "two").template()) + .hasPath("/") + .hasQueries( + entry("Action", asList("GetUser")), + entry("Version", asList("2010-05-08"))); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "three").template()) + .hasPath("/") + .hasQueries( + entry("Action", asList("GetUser")), + entry("Version", asList("2010-05-08")), + entry("limit", asList("1"))); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "twoAndOneEmpty").template()) + .hasPath("/") + .hasQueries( + entry("flag", new ArrayList<>()), + entry("Action", asList("GetUser")), + entry("Version", asList("2010-05-08"))); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "oneEmpty").template()) + .hasPath("/") + .hasQueries( + entry("flag", new ArrayList<>())); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "twoEmpty").template()) + .hasPath("/") + .hasQueries( + entry("flag", new ArrayList<>()), + entry("NoErrors", new ArrayList<>())); + } + + @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().requestBody().asBytes().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().requestBody().asBytes().length)))); + } + + @Test + public void headersContainsWhitespaces() throws Exception { + MethodMetadata md = parseAndValidateMetadata(HeadersContainsWhitespaces.class, "post"); + + assertThat(md.template()) + .hasHeaders( + entry("Content-Type", asList("application/xml")), + entry("Content-Length", + asList(String.valueOf(md.template().requestBody().asBytes().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 queryMapKeysMustBeStrings() throws Exception { + try { + parseAndValidateMetadata(QueryMapTestInterface.class, "nonStringKeyQueryMap", Map.class); + Fail.failBecauseExceptionWasNotThrown(IllegalStateException.class); + } catch (IllegalStateException ex) { + assertThat(ex).hasMessage("QueryMap key must be a String: Integer"); + } + } + + @Test + public void queryMapPojoObject() throws Exception { + MethodMetadata md = + parseAndValidateMetadata(QueryMapTestInterface.class, "pojoObject", Object.class); + + assertThat(md.queryMapIndex()).isEqualTo(0); + } + + @Test + public void queryMapPojoObjectEncoded() throws Exception { + MethodMetadata md = + parseAndValidateMetadata(QueryMapTestInterface.class, "pojoObjectEncoded", Object.class); + + assertThat(md.queryMapIndex()).isEqualTo(0); + assertThat(md.queryMapEncoded()).isTrue(); + } + + @Test + public void queryMapPojoObjectNotEncoded() throws Exception { + MethodMetadata md = + parseAndValidateMetadata(QueryMapTestInterface.class, "pojoObjectNotEncoded", Object.class); + + assertThat(md.queryMapIndex()).isEqualTo(0); + assertThat(md.queryMapEncoded()).isFalse(); + } + + @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."); + } + } + + @Test + public void headerMapSubclass() throws Exception { + MethodMetadata md = parseAndValidateMetadata(HeaderMapInterface.class, "headerMapSubClass", + SubClassHeaders.class); + assertThat(md.headerMapIndex()).isEqualTo(0); + } + + 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(); + } + + @Headers("Content-Type: application/xml ") + interface HeadersContainsWhitespaces { + + @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); + + @RequestLine("POST /") + void headerMapSubClass(@HeaderMap SubClassHeaders httpHeaders); + } + + 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); + + @RequestLine("POST /") + void pojoObject(@QueryMap Object object); + + @RequestLine("POST /") + void pojoObjectEncoded(@QueryMap(encoded = true) Object object); + + @RequestLine("POST /") + void pojoObjectNotEncoded(@QueryMap(encoded = false) Object object); + + // invalid + @RequestLine("POST /") + void multipleQueryMap(@QueryMap Map mapOne, + @QueryMap Map mapTwo); + + // invalid + @RequestLine("POST /") + void nonStringKeyQueryMap(@QueryMap Map queryMap); + } + + interface SlashNeedToBeEncoded { + @RequestLine(value = "GET /api/queues/{vhost}", decodeSlash = false) + String getQueues(@Param("vhost") String vhost); + + @RequestLine(value = "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; + } + + + interface SubClassHeaders extends Map { + + } + + + @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/DefaultQueryMapEncoderTest.java b/core/src/test/java/feign/DefaultQueryMapEncoderTest.java new file mode 100644 index 000000000..8a971c330 --- /dev/null +++ b/core/src/test/java/feign/DefaultQueryMapEncoderTest.java @@ -0,0 +1,77 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.HashMap; +import java.util.Map; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class DefaultQueryMapEncoderTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private final QueryMapEncoder encoder = new QueryMapEncoder.Default(); + + @Test + public void testEncodesObject_visibleFields() { + Map expected = new HashMap<>(); + expected.put("foo", "fooz"); + expected.put("bar", "barz"); + expected.put("baz", "bazz"); + VisibleFieldsObject object = new VisibleFieldsObject(); + object.foo = "fooz"; + object.bar = "barz"; + object.baz = "bazz"; + + Map encodedMap = encoder.encode(object); + assertEquals("Unexpected encoded query map", expected, encodedMap); + } + + @Test + public void testEncodesObject_visibleFields_emptyObject() { + VisibleFieldsObject object = new VisibleFieldsObject(); + Map encodedMap = encoder.encode(object); + assertTrue("Non-empty map generated from null fields: " + encodedMap, encodedMap.isEmpty()); + } + + @Test + public void testEncodesObject_nonVisibleFields() { + Map expected = new HashMap<>(); + expected.put("foo", "fooz"); + expected.put("bar", "barz"); + QueryMapEncoderObject object = new QueryMapEncoderObject("fooz", "barz"); + + Map encodedMap = encoder.encode(object); + assertEquals("Unexpected encoded query map", expected, encodedMap); + } + + @Test + public void testEncodesObject_nonVisibleFields_emptyObject() { + QueryMapEncoderObject object = new QueryMapEncoderObject(null, null); + Map encodedMap = encoder.encode(object); + assertTrue("Non-empty map generated from null fields", encodedMap.isEmpty()); + } + + static class VisibleFieldsObject { + String foo; + String bar; + String baz; + } +} + diff --git a/core/src/test/java/feign/EmptyTargetTest.java b/core/src/test/java/feign/EmptyTargetTest.java new file mode 100644 index 000000000..012a56073 --- /dev/null +++ b/core/src/test/java/feign/EmptyTargetTest.java @@ -0,0 +1,61 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.Request.HttpMethod; +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(HttpMethod.GET).uri("/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 000000000..f60155cbf --- /dev/null +++ b/core/src/test/java/feign/FeignBuilderTest.java @@ -0,0 +1,522 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.data.MapEntry; +import org.junit.Rule; +import org.junit.Test; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.nio.charset.Charset; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +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.assertFalse; +import static org.junit.Assert.assertTrue; +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(404)); + 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.decodedLazyPost()).isEmpty(); // empty, not null! + assertThat(api.optionalContent()).isEmpty(); // empty, not null! + assertThat(api.streamPost()).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); + } + } + + /** Shows exception handling isn't required to coerce 204 to null or empty */ + @Test + public void testDecode204() throws Exception { + server.enqueue(new MockResponse().setResponseCode(204)); + server.enqueue(new MockResponse().setResponseCode(204)); + server.enqueue(new MockResponse().setResponseCode(204)); + server.enqueue(new MockResponse().setResponseCode(204)); + server.enqueue(new MockResponse().setResponseCode(204)); + server.enqueue(new MockResponse().setResponseCode(400)); + + String url = "http://localhost:" + server.getPort(); + TestInterface api = Feign.builder().target(TestInterface.class, url); + + assertThat(api.getQueues("/")).isEmpty(); // empty, not null! + assertThat(api.decodedLazyPost()).isEmpty(); // empty, not null! + assertThat(api.optionalContent()).isEmpty(); // empty, not null! + assertThat(api.streamPost()).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 testNoFollowRedirect() { + server.enqueue(new MockResponse().setResponseCode(302).addHeader("Location", "/")); + + String url = "http://localhost:" + server.getPort(); + TestInterface noFollowApi = Feign.builder() + .options(new Request.Options(100, 600, false)) + .target(TestInterface.class, url); + + Response response = noFollowApi.defaultMethodPassthrough(); + assertThat(response.status()).isEqualTo(302); + assertThat(response.headers().getOrDefault("Location", null)) + .isNotNull() + .isEqualTo(Collections.singletonList("/")); + + server.enqueue(new MockResponse().setResponseCode(302).addHeader("Location", "/")); + server.enqueue(new MockResponse().setResponseCode(200)); + TestInterface defaultApi = Feign.builder() + .options(new Request.Options(100, 600, true)) + .target(TestInterface.class, url); + assertThat(defaultApi.defaultMethodPassthrough().status()).isEqualTo(200); + } + + + @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 testHttpNotFoundError() { + server.enqueue(new MockResponse().setResponseCode(404)); + + String url = "http://localhost:" + server.getPort() + "/"; + TestInterface api = Feign.builder().target(TestInterface.class, url); + + try { + api.getBodyAsString(); + failBecauseExceptionWasNotThrown(FeignException.class); + } catch (FeignException.NotFound e) { + assertThat(e.status()).isEqualTo(404); + } + + } + + @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 testOverrideQueryMapEncoder() throws Exception { + server.enqueue(new MockResponse()); + + String url = "http://localhost:" + server.getPort(); + QueryMapEncoder customMapEncoder = new QueryMapEncoder() { + @Override + public Map encode(Object ignored) { + Map queryMap = new HashMap(); + queryMap.put("key1", "value1"); + queryMap.put("key2", "value2"); + return queryMap; + } + }; + + TestInterface api = + Feign.builder().queryMapEncoder(customMapEncoder).target(TestInterface.class, url); + api.queryMapEncoded("ignored"); + + assertThat(server.takeRequest()).hasQueryParams(Arrays.asList("key1=value1", "key2=value2")); + 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(MapEntry.entry("Content-Type", Collections.singletonList("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("/"); + } + + /** + * This test ensures that the doNotCloseAfterDecode flag functions. + * + * It does so by creating a custom Decoder that lazily retrieves the response body when asked for + * it and pops the value into an Iterator. + * + * Without the doNoCloseAfterDecode flag, the test will fail with a "stream is closed" exception. + * + * @throws Exception + */ + @Test + public void testDoNotCloseAfterDecode() throws Exception { + server.enqueue(new MockResponse().setBody("success!")); + + String url = "http://localhost:" + server.getPort(); + Decoder decoder = new Decoder() { + @Override + public Iterator decode(Response response, Type type) { + return new Iterator() { + private boolean called = false; + + @Override + public boolean hasNext() { + return !called; + } + + @Override + public Object next() { + try { + return Util.toString(response.body().asReader()); + } catch (IOException e) { + fail(e.getMessage()); + return null; + } finally { + Util.ensureClosed(response); + called = true; + } + } + }; + } + }; + + TestInterface api = Feign.builder() + .decoder(decoder) + .doNotCloseAfterDecode() + .target(TestInterface.class, url); + Iterator iterator = api.decodedLazyPost(); + + assertTrue(iterator.hasNext()); + assertEquals("success!", iterator.next()); + assertFalse(iterator.hasNext()); + + assertEquals(1, server.getRequestCount()); + } + + /** + * When {@link Feign.Builder#doNotCloseAfterDecode()} is enabled an an exception is thrown from + * the {@link Decoder}, the response should be closed. + */ + @Test + public void testDoNotCloseAfterDecodeDecoderFailure() throws Exception { + server.enqueue(new MockResponse().setBody("success!")); + + String url = "http://localhost:" + server.getPort(); + Decoder angryDecoder = new Decoder() { + @Override + public Object decode(Response response, Type type) throws IOException { + throw new IOException("Failed to decode the response"); + } + }; + + final AtomicBoolean closed = new AtomicBoolean(); + TestInterface api = Feign.builder() + .client(new Client() { + Client client = new Client.Default(null, null); + + @Override + public Response execute(Request request, Request.Options options) throws IOException { + final Response original = client.execute(request, options); + return Response.builder() + .status(original.status()) + .headers(original.headers()) + .reason(original.reason()) + .request(original.request()) + .body(new Response.Body() { + @Override + public Integer length() { + return original.body().length(); + } + + @Override + public boolean isRepeatable() { + return original.body().isRepeatable(); + } + + @Override + public InputStream asInputStream() throws IOException { + return original.body().asInputStream(); + } + + @Override + public Reader asReader() throws IOException { + return original.body().asReader(); + } + + @Override + public Reader asReader(Charset charset) throws IOException { + return original.body().asReader(charset); + } + + @Override + public void close() throws IOException { + closed.set(true); + original.body().close(); + } + }) + .build(); + } + }) + .decoder(angryDecoder) + .doNotCloseAfterDecode() + .target(TestInterface.class, url); + try { + api.decodedLazyPost(); + fail("Expected an exception"); + } catch (FeignException expected) { + } + assertTrue("Responses must be closed when the decoder fails", closed.get()); + } + + interface TestInterface { + @RequestLine("GET") + Response getNoPath(); + + @RequestLine("GET api/thing") + Response getNoInitialSlashOnSlash(); + + @RequestLine("GET api/thing") + String getBodyAsString(); + + @RequestLine(value = "GET /api/querymap/object") + String queryMapEncoded(@QueryMap Object object); + + @RequestLine("POST /") + Response codecPost(String data); + + @RequestLine("POST /") + void encodedPost(List data); + + @RequestLine("POST /") + String decodedPost(); + + @RequestLine("POST /") + Iterator decodedLazyPost(); + + @RequestLine("POST /") + Optional optionalContent(); + + @RequestLine("POST /") + Stream streamPost(); + + @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 000000000..50adcd480 --- /dev/null +++ b/core/src/test/java/feign/FeignTest.java @@ -0,0 +1,1081 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 feign.Feign.ResponseMappingDecoder; +import feign.Request.HttpMethod; +import feign.Target.HardCodedTarget; +import feign.querymap.BeanQueryMapEncoder; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.SocketPolicy; +import okio.Buffer; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import java.io.IOException; +import java.lang.reflect.Type; +import java.net.URI; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +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.ExceptionPropagationPolicy.UNWRAP; +import static feign.Util.UTF_8; +import static feign.assertj.MockWebServerAssertions.assertThat; +import static org.assertj.core.data.MapEntry.entry; +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(entry("Content-Length", Collections.singletonList("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(entry("X-Forwarded-For", Collections.singletonList("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(entry("X-Forwarded-For", Collections.singletonList("origin.host.com")), + entry("User-Agent", Collections.singletonList("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( + entry("Content-Type", Arrays.asList("myContent")), + 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( + entry("Content-Encoding", Arrays.asList("deflate")), + entry("Custom-Header", Arrays.asList("fooValue"))); + + server.enqueue(new MockResponse()); + headerMap.put("Content-Encoding", "overrideFromMap"); + + api.headerMapWithHeaderAnnotations(headerMap); + + /* + * @HeaderMap map values no longer override @Header parameters. This caused confusion as it is + * valid to have more than one value for a header. + */ + assertThat(server.takeRequest()) + .hasHeaders( + entry("Content-Encoding", Arrays.asList("deflate", "overrideFromMap")), + 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", ""); // empty values are ignored. + 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); + // queries are additive + assertThat(server.takeRequest()) + .hasPath("/?name=alice&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("/?name=alice"); + } + + @Test + public void queryMapValueStartingWithBrace() throws Exception { + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + server.enqueue(new MockResponse()); + Map queryMap = new LinkedHashMap(); + queryMap.put("name", "{alice"); + api.queryMap(queryMap); + assertThat(server.takeRequest()) + .hasPath("/?name=%7Balice"); + + server.enqueue(new MockResponse()); + queryMap = new LinkedHashMap(); + queryMap.put("{name", "alice"); + api.queryMap(queryMap); + assertThat(server.takeRequest()) + .hasPath("/?%7Bname=alice"); + + server.enqueue(new MockResponse()); + queryMap = new LinkedHashMap(); + queryMap.put("name", "%7Balice"); + api.queryMapEncoded(queryMap); + assertThat(server.takeRequest()) + .hasPath("/?name=%7Balice"); + + server.enqueue(new MockResponse()); + queryMap = new LinkedHashMap(); + queryMap.put("%7Bname", "%7Balice"); + api.queryMapEncoded(queryMap); + assertThat(server.takeRequest()) + .hasPath("/?%7Bname=%7Balice"); + } + + @Test + public void queryMapPojoWithFullParams() throws Exception { + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + CustomPojo customPojo = new CustomPojo("Name", 3); + + server.enqueue(new MockResponse()); + api.queryMapPojo(customPojo); + assertThat(server.takeRequest()) + .hasQueryParams(Arrays.asList("name=Name", "number=3")); + } + + @Test + public void queryMapPojoWithPartialParams() throws Exception { + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + CustomPojo customPojo = new CustomPojo("Name", null); + + server.enqueue(new MockResponse()); + api.queryMapPojo(customPojo); + assertThat(server.takeRequest()) + .hasPath("/?name=Name"); + } + + @Test + public void queryMapPojoWithEmptyParams() throws Exception { + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + CustomPojo customPojo = new CustomPojo(null, null); + + server.enqueue(new MockResponse()); + api.queryMapPojo(customPojo); + assertThat(server.takeRequest()) + .hasPath("/"); + } + + @Test + public void configKeyFormatsAsExpected() throws Exception { + assertEquals("TestInterface#post()", + Feign.configKey(TestInterface.class, TestInterface.class.getDeclaredMethod("post"))); + assertEquals("TestInterface#uriParam(String,URI,String)", + Feign.configKey(TestInterface.class, 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(response.status(), string, HttpMethod.POST, 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 throwsFeignExceptionIncludingBody() { + server.enqueue(new MockResponse().setBody("success!")); + + TestInterface api = Feign.builder() + .decoder((response, type) -> { + throw new IOException("timeout"); + }) + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + try { + api.body("Request body"); + } catch (FeignException e) { + assertThat(e.getMessage()) + .isEqualTo("timeout reading POST http://localhost:" + server.getPort() + "/"); + assertThat(e.contentUTF8()).isEqualTo("Request body"); + } + } + + @Test + public void throwsFeignExceptionWithoutBody() { + server.enqueue(new MockResponse().setBody("success!")); + + TestInterface api = Feign.builder() + .decoder((response, type) -> { + throw new IOException("timeout"); + }) + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + try { + api.noContent(); + } catch (FeignException e) { + assertThat(e.getMessage()) + .isEqualTo("timeout reading POST http://localhost:" + server.getPort() + "/"); + assertThat(e.contentUTF8()).isEqualTo(""); + } + } + + @Test + public void ensureRetryerClonesItself() throws Exception { + 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(response.status(), "play it again sam!", HttpMethod.POST, + 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 throwsOriginalExceptionAfterFailedRetries() throws Exception { + server.enqueue(new MockResponse().setResponseCode(503).setBody("foo 1")); + server.enqueue(new MockResponse().setResponseCode(503).setBody("foo 2")); + + final String message = "the innerest"; + thrown.expect(TestInterfaceException.class); + thrown.expectMessage(message); + + TestInterface api = Feign.builder() + .exceptionPropagationPolicy(UNWRAP) + .retryer(new Retryer.Default(1, 1, 2)) + .errorDecoder(new ErrorDecoder() { + @Override + public Exception decode(String methodKey, Response response) { + return new RetryableException(response.status(), "play it again sam!", HttpMethod.POST, + new TestInterfaceException(message), null); + } + }).target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.post(); + } + + @Test + public void throwsRetryableExceptionIfNoUnderlyingCause() throws Exception { + server.enqueue(new MockResponse().setResponseCode(503).setBody("foo 1")); + server.enqueue(new MockResponse().setResponseCode(503).setBody("foo 2")); + + String message = "play it again sam!"; + thrown.expect(RetryableException.class); + thrown.expectMessage(message); + + TestInterface api = Feign.builder() + .exceptionPropagationPolicy(UNWRAP) + .retryer(new Retryer.Default(1, 1, 2)) + .errorDecoder(new ErrorDecoder() { + @Override + public Exception decode(String methodKey, Response response) { + return new RetryableException(response.status(), message, HttpMethod.POST, null); + } + }).target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.post(); + } + + @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) + .request(Request.create(HttpMethod.GET, "/", Collections.emptyMap(), null, Util.UTF_8)) + .body(new byte[0]) + .build(); + + // fake client as Client.Default follows redirects. + TestInterface api = Feign.builder() + .client((request, options) -> response) + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + assertEquals(api.response().headers().get("Location"), + Collections.singletonList("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 decodingDoesNotSwallow404ErrorsInDecode404Mode() throws Exception { + server.enqueue(new MockResponse().setResponseCode(404)); + thrown.expect(IllegalArgumentException.class); + + TestInterface api = new TestInterfaceBuilder() + .decode404() + .errorDecoder(new IllegalArgumentExceptionOn404()) + .target("http://localhost:" + server.getPort()); + api.queryMap(Collections.emptyMap()); + } + + @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%2B"); + } + + @Test + public void responseMapperIsAppliedBeforeDelegate() throws IOException { + ResponseMappingDecoder decoder = + new ResponseMappingDecoder(upperCaseResponseMapper(), new StringDecoder()); + String output = (String) decoder.decode(responseWithText("response"), String.class); + + assertThat(output).isEqualTo("RESPONSE"); + } + + private ResponseMapper upperCaseResponseMapper() { + return new ResponseMapper() { + @Override + public Response map(Response response, Type type) { + try { + return response + .toBuilder() + .body(Util.toString(response.body().asReader()).toUpperCase().getBytes()) + .build(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }; + } + + private Response responseWithText(String text) { + return Response.builder() + .body(text, Util.UTF_8) + .status(200) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(new HashMap<>()) + .build(); + } + + @Test + public void mapAndDecodeExecutesMapFunction() throws Exception { + server.enqueue(new MockResponse().setBody("response!")); + + TestInterface api = new Feign.Builder() + .mapAndDecode(upperCaseResponseMapper(), new StringDecoder()) + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + assertEquals(api.post(), "RESPONSE!"); + } + + @Test + public void beanQueryMapEncoderWithPrivateGetterIgnored() throws Exception { + TestInterface api = new TestInterfaceBuilder().queryMapEndcoder(new BeanQueryMapEncoder()) + .target("http://localhost:" + server.getPort()); + + PropertyPojo.ChildPojoClass propertyPojo = new PropertyPojo.ChildPojoClass(); + propertyPojo.setPrivateGetterProperty("privateGetterProperty"); + propertyPojo.setName("Name"); + propertyPojo.setNumber(1); + + server.enqueue(new MockResponse()); + api.queryMapPropertyPojo(propertyPojo); + assertThat(server.takeRequest()) + .hasQueryParams(Arrays.asList("name=Name", "number=1")); + } + + @Test + public void beanQueryMapEncoderWithNullValueIgnored() throws Exception { + TestInterface api = new TestInterfaceBuilder().queryMapEndcoder(new BeanQueryMapEncoder()) + .target("http://localhost:" + server.getPort()); + + PropertyPojo.ChildPojoClass propertyPojo = new PropertyPojo.ChildPojoClass(); + propertyPojo.setName(null); + propertyPojo.setNumber(1); + + server.enqueue(new MockResponse()); + api.queryMapPropertyPojo(propertyPojo); + assertThat(server.takeRequest()) + .hasQueryParams("number=1"); + } + + @Test + public void beanQueryMapEncoderWithEmptyParams() throws Exception { + TestInterface api = new TestInterfaceBuilder().queryMapEndcoder(new BeanQueryMapEncoder()) + .target("http://localhost:" + server.getPort()); + + PropertyPojo.ChildPojoClass propertyPojo = new PropertyPojo.ChildPojoClass(); + + server.enqueue(new MockResponse()); + api.queryMapPropertyPojo(propertyPojo); + assertThat(server.takeRequest()) + .hasQueryParams("/"); + } + + interface TestInterface { + + @RequestLine("POST /") + Response response(); + + @RequestLine("POST /") + String post() throws TestInterfaceException; + + @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 /") + String body(String content); + + @RequestLine("POST /") + String noContent(); + + @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 /") + void queryMapEncoded(@QueryMap(encoded = true) 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); + + @RequestLine("GET /") + void queryMapPojo(@QueryMap CustomPojo object); + + @RequestLine("GET /") + void queryMapPropertyPojo(@QueryMap PropertyPojo object); + + class DateToMillis implements Param.Expander { + + @Override + public String expand(Object value) { + return String.valueOf(((Date) value).getTime()); + } + } + } + + class TestInterfaceException extends Exception { + TestInterfaceException(String message) { + super(message); + } + } + + 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 class IllegalArgumentExceptionOn404 extends ErrorDecoder.Default { + + @Override + public Exception decode(String methodKey, Response response) { + if (response.status() == 404) { + 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; + } + + TestInterfaceBuilder queryMapEndcoder(QueryMapEncoder queryMapEncoder) { + delegate.queryMapEncoder(queryMapEncoder); + 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 000000000..fe8184cd7 --- /dev/null +++ b/core/src/test/java/feign/LoggerTest.java @@ -0,0 +1,414 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.RuleChain; +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 { + + public final ExpectedException thrown = ExpectedException.none(); + public final MockWebServer server = new MockWebServer(); + public final RecordingLogger logger = new RecordingLogger(); + + /** Ensure expected exception handling is done before logger rule. */ + @Rule + public final RuleChain chain = RuleChain.outerRule(server).around(logger).around(thrown); + + + 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-Length: 80", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[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-Length: 80", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[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\\] <--- 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-Length: 80", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)", + "\\[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-Length: 80", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[SendsStuff#login\\] ", + "\\[SendsStuff#login\\] \\{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"\\}", + "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)", + "\\[SendsStuff#login\\] <--- ERROR SocketTimeoutException: Read timed out \\([0-9]+ms\\)", + "(?s)\\[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)) + .retryer(new Retryer() { + @Override + public void continueOrPropagate(RetryableException e) { + throw e; + } + + @Override + public Retryer clone() { + return this; + } + }) + .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-Length: 80", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[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-Length: 80", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[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\\)", + "(?s)\\[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 FormatCharacterTest + extends LoggerTest { + + private final Level logLevel; + + public FormatCharacterTest(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://sna%fu.abc/ HTTP/1.1", + "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: sna%fu.abc \\([0-9]+ms\\)")}, + {Level.HEADERS, Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://sna%fu.abc/ HTTP/1.1", + "\\[SendsStuff#login\\] Content-Length: 80", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)", + "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: sna%fu.abc \\([0-9]+ms\\)")}, + {Level.FULL, Arrays.asList( + "\\[SendsStuff#login\\] ---> POST http://sna%fu.abc/ HTTP/1.1", + "\\[SendsStuff#login\\] Content-Length: 80", + "\\[SendsStuff#login\\] Content-Type: application/json", + "\\[SendsStuff#login\\] ", + "\\[SendsStuff#login\\] \\{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\": \"password\"\\}", + "\\[SendsStuff#login\\] ---> END HTTP \\(80-byte body\\)", + "\\[SendsStuff#login\\] <--- ERROR UnknownHostException: sna%fu.abc \\([0-9]+ms\\)", + "(?s)\\[SendsStuff#login\\] java.net.UnknownHostException: sna%fu.abc.*", + "\\[SendsStuff#login\\] <--- END ERROR")} + }); + } + + @Test + public void formatCharacterEmits() 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://sna%25fu.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(); + softly.assertThat(messages.size()).isEqualTo(expectedMessages.size()); + for (int i = 0; i < messages.size() && i < expectedMessages.size(); i++) { + softly.assertThat(messages.get(i)).matches(expectedMessages.get(i)); + } + softly.assertAll(); + } + }; + } + } +} diff --git a/core/src/test/java/feign/PropertyPojo.java b/core/src/test/java/feign/PropertyPojo.java new file mode 100644 index 000000000..01f429122 --- /dev/null +++ b/core/src/test/java/feign/PropertyPojo.java @@ -0,0 +1,50 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +public class PropertyPojo { + + private String name; + + public static class ChildPojoClass extends PropertyPojo { + private Integer number; + + private String privateGetterProperty; + + public Integer getNumber() { + return number; + } + + public void setNumber(Integer number) { + this.number = number; + } + + public void setPrivateGetterProperty(String privateGetterProperty) { + this.privateGetterProperty = privateGetterProperty; + } + + private String getPrivateGetterProperty() { + return privateGetterProperty; + } + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + +} diff --git a/core/src/test/java/feign/QueryMapEncoderObject.java b/core/src/test/java/feign/QueryMapEncoderObject.java new file mode 100644 index 000000000..1487c7a87 --- /dev/null +++ b/core/src/test/java/feign/QueryMapEncoderObject.java @@ -0,0 +1,24 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +class QueryMapEncoderObject { + private final String foo; + private final String bar; + + QueryMapEncoderObject(String foo, String bar) { + this.foo = foo; + this.bar = bar; + } +} diff --git a/core/src/test/java/feign/RequestTemplateTest.java b/core/src/test/java/feign/RequestTemplateTest.java new file mode 100644 index 000000000..d2ec33742 --- /dev/null +++ b/core/src/test/java/feign/RequestTemplateTest.java @@ -0,0 +1,423 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.assertj.FeignAssertions.assertThat; +import static java.util.Arrays.asList; +import static org.assertj.core.data.MapEntry.entry; +import static org.junit.Assert.*; +import feign.Request.HttpMethod; +import feign.template.UriUtils; +import java.util.*; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +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; + } + + private static String expand(String template, Map variables) { + RequestTemplate requestTemplate = new RequestTemplate(); + requestTemplate.uri(template); + return requestTemplate.resolve(variables).url(); + } + + @Test + public void expandUrlEncoded() { + for (String val : Arrays.asList("apples", "sp ace", "unic???de", "qu?stion")) { + assertThat(expand("/users/{user}", mapOf("user", val))) + .isEqualTo("/users/" + UriUtils.encode(val, Util.UTF_8)); + } + } + + @Test + public void expandMultipleParams() { + assertThat(expand("/users/{user}/{repo}", mapOf("user", "unic???de", "repo", "foo"))) + .isEqualTo("/users/unic%3F%3F%3Fde/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("/"); + } + + @Test + public void resolveTemplateWithParameterizedPathSkipsEncodingSlash() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET) + .uri("{zoneId}"); + + template = template.resolve(mapOf("zoneId", "/hostedzone/Z1PA6795UKMFR9")); + + assertThat(template) + .hasUrl("/hostedzone/Z1PA6795UKMFR9"); + } + + @Test + public void resolveTemplateWithBinaryBody() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET) + .uri("{zoneId}") + .body(new byte[] {7, 3, -3, -7}, null); + + template = template.resolve(mapOf("zoneId", "/hostedzone/Z1PA6795UKMFR9")); + + assertThat(template) + .hasUrl("/hostedzone/Z1PA6795UKMFR9"); + } + + @Test + public void canInsertAbsoluteHref() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET) + .uri("/hostedzone/Z1PA6795UKMFR9"); + + template.target("https://route53.amazonaws.com/2012-12-12"); + + assertThat(template) + .hasUrl("https://route53.amazonaws.com/2012-12-12/hostedzone/Z1PA6795UKMFR9"); + } + + @Test + public void resolveTemplateWithRelativeUriWithQuery() { + RequestTemplate template = new RequestTemplate() + .method(HttpMethod.GET) + .uri("/wsdl/testcase?wsdl") + .target("https://api.example.com"); + + assertThat(template).hasUrl("https://api.example.com/wsdl/testcase?wsdl"); + } + + @Test + public void resolveTemplateWithBaseAndParameterizedQuery() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET) + .uri("/?Action=DescribeRegions").query("RegionName.1", "{region}"); + + template = template.resolve(mapOf("region", "eu-west-1")); + + assertThat(template) + .hasQueries( + entry("Action", Collections.singletonList("DescribeRegions")), + entry("RegionName.1", Collections.singletonList("eu-west-1"))); + } + + @Test + public void resolveTemplateWithBaseAndParameterizedIterableQuery() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET) + .uri("/?Query=one").query("Queries", "{queries}"); + + template = template.resolve(mapOf("queries", Arrays.asList("us-east-1", "eu-west-1"))); + + assertThat(template) + .hasQueries( + entry("Query", Collections.singletonList("one")), + entry("Queries", asList("us-east-1", "eu-west-1"))); + } + + @Test + public void resolveTemplateWithHeaderSubstitutions() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET) + .header("Auth-Token", "{authToken}"); + + template = template.resolve(mapOf("authToken", "1234")); + + assertThat(template) + .hasHeaders(entry("Auth-Token", Collections.singletonList("1234"))); + } + + @Test + public void resolveTemplateWithHeaderSubstitutionsNotAtStart() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET) + .header("Authorization", "Bearer {token}"); + + template = template.resolve(mapOf("token", "1234")); + + assertThat(template) + .hasHeaders(entry("Authorization", Collections.singletonList("Bearer 1234"))); + } + + @Test + public void resolveTemplateWithHeaderWithEscapedCurlyBrace() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET) + .header("Encoded", "{{{{dont_expand_me}}"); + + template.resolve(mapOf("dont_expand_me", "1234")); + + assertThat(template) + .hasHeaders(entry("Encoded", Collections.singletonList("{{{{dont_expand_me}}"))); + } + + /** This ensures we don't mess up vnd types */ + @Test + public void resolveTemplateWithHeaderIncludingSpecialCharacters() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET) + .header("Accept", "application/vnd.github.v3+{type}"); + + template = template.resolve(mapOf("type", "json")); + + assertThat(template) + .hasHeaders(entry("Accept", Collections.singletonList("application/vnd.github.v3+json"))); + } + + @Test + public void resolveTemplateWithHeaderEmptyResult() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET) + .header("Encoded", "{var}"); + + template = template.resolve(mapOf("var", "")); + + assertThat(template) + .hasNoHeader("Encoded"); + } + + @Test + public void resolveTemplateWithMixedRequestLineParams() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET)// + .uri("/domains/{domainId}/records")// + .query("name", "{name}")// + .query("type", "{type}"); + + template = template.resolve( + mapOf("domainId", 1001, "name", "denominator.io", "type", "CNAME")); + + assertThat(template) + .hasQueries( + entry("name", Collections.singletonList("denominator.io")), + entry("type", Collections.singletonList("CNAME"))); + } + + @Test + public void insertHasQueryParams() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET)// + .uri("/domains/1001/records")// + .query("name", "denominator.io")// + .query("type", "CNAME"); + + template.target("https://host/v1.0/1234?provider=foo"); + + assertThat(template) + .hasPath("https://host/v1.0/1234/domains/1001/records") + .hasQueries( + entry("name", Collections.singletonList("denominator.io")), + entry("type", Collections.singletonList("CNAME")), + entry("provider", Collections.singletonList("foo"))); + } + + @Test + public void resolveTemplateWithBodyTemplateSetsBodyAndContentLength() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.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", + Collections.singletonList(String.valueOf(template.body().length)))); + } + + @Test + public void resolveTemplateWithBodyTemplateDoesNotDoubleDecode() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.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%d8\"}"); + } + + @Test + public void skipUnresolvedQueries() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET) + .uri("/domains/{domainId}/records")// + .query("optional", "{optional}")// + .query("name", "{nameVariable}"); + + template = template.resolve(mapOf( + "domainId", 1001, + "nameVariable", "denominator.io")); + + assertThat(template) + .hasQueries( + entry("name", Collections.singletonList("denominator.io"))); + } + + @Test + public void allQueriesUnresolvable() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET)// + .uri("/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(HttpMethod.GET)// + .uri("/api/{value1}?key={value2}"); + + template = template.resolve(mapOf("value1", "ABC 123", "value2", "XYZ 123")); + + assertThat(template.request().url()) + .isEqualTo("/api/ABC%20123?key=XYZ%20123"); + } + + @Test + public void useCaseInsensitiveHeaderFieldNames() { + final RequestTemplate template = new RequestTemplate(); + + final String value = "value1"; + template.header("TEST", value); + + final String value2 = "value2"; + template.header("tEST", value2); + + final Collection test = template.headers().get("test"); + + final String assertionMessage = "Header field names should be case insensitive"; + + assertNotNull(assertionMessage, test); + assertTrue(assertionMessage, test.contains(value)); + assertTrue(assertionMessage, test.contains(value2)); + assertEquals(1, template.headers().size()); + assertEquals(2, template.headers().get("tesT").size()); + } + + @Test + public void encodeSlashTest() { + RequestTemplate template = new RequestTemplate().method(HttpMethod.GET) + .uri("/api/{vhost}") + .decodeSlash(false); + + template = template.resolve(mapOf("vhost", "/")); + + assertThat(template) + .hasUrl("/api/%2F"); + } + + /** Implementations have a bug if they pass junk as the http method. */ + @SuppressWarnings("deprecation") + @Test + public void uriStuffedIntoMethod() { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Invalid HTTP Method: /path?queryParam={queryParam}"); + new RequestTemplate().method("/path?queryParam={queryParam}"); + } + + @Test + public void encodedQueryClearedOnNull() { + RequestTemplate template = new RequestTemplate(); + + template.query("param[]", "value"); + assertThat(template).hasQueries(entry("param[]", Collections.singletonList("value"))); + + template.query("param[]", (String[]) null); + assertThat(template.queries()).isEmpty(); + } + + @Test + public void encodedQuery() { + RequestTemplate template = new RequestTemplate().query("params[]", "foo%20bar"); + assertThat(template.queryLine()).isEqualTo("?params%5B%5D=foo%20bar"); + assertThat(template).hasQueries(entry("params[]", Collections.singletonList("foo%20bar"))); + } + + @Test + public void encodedQueryWithUnsafeCharactersMixedWithUnencoded() { + RequestTemplate template = new RequestTemplate() + .query("params[]", "not encoded") // stored as "param%5D%5B" + .query("params[]", "encoded"); // stored as "param[]" + + assertThat(template.queryLine()).isEqualTo("?params%5B%5D=not%20encoded¶ms%5B%5D=encoded"); + Map> queries = template.queries(); + assertThat(queries).containsKey("params[]"); + assertThat(queries.get("params[]")).contains("encoded").contains("not encoded"); + } + + @SuppressWarnings("unchecked") + @Test + public void shouldRetrieveHeadersWithoutNull() { + RequestTemplate template = new RequestTemplate() + .header("key1", (String) null) + .header("key2", Collections.emptyList()) + .header("key3", (Collection) null) + .header("key4", "valid") + .header("key5", "valid") + .header("key6", "valid") + .header("key7", "valid"); + + assertThat(template.headers()).hasSize(4); + assertThat(template.headers().keySet()).containsExactly("key4", "key5", "key6", "key7"); + + } + + @SuppressWarnings("ConstantConditions") + @Test(expected = UnsupportedOperationException.class) + public void shouldNotInsertHeadersImmutableMap() { + RequestTemplate template = new RequestTemplate() + .header("key1", "valid"); + + assertThat(template.headers()).hasSize(1); + assertThat(template.headers().keySet()).containsExactly("key1"); + + template.headers().put("key2", Collections.singletonList("other value")); + } +} diff --git a/core/src/test/java/feign/ResponseTest.java b/core/src/test/java/feign/ResponseTest.java new file mode 100644 index 000000000..4a47a2769 --- /dev/null +++ b/core/src/test/java/feign/ResponseTest.java @@ -0,0 +1,109 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.Request.HttpMethod; +import org.assertj.core.util.Lists; +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()) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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)); + } + + @Test + public void headersAreOptional() { + Response response = Response.builder() + .status(200) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .body(new byte[0]) + .build(); + assertThat(response.headers()).isNotNull().isEmpty(); + } + + @Test + public void support1xxStatusCodes() { + Response response = Response.builder() + .status(103) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .body((Response.Body) null) + .build(); + + assertThat(response.status()).isEqualTo(103); + } + + @Test + public void statusCodesOfAnyValueAreAllowed() { + Lists.list(600, 50, 35600).forEach(statusCode -> { + Response response = Response.builder() + .status(statusCode) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .body((Response.Body) null) + .build(); + + assertThat(response.status()).isEqualTo(statusCode); + }); + } +} diff --git a/core/src/test/java/feign/RetryerTest.java b/core/src/test/java/feign/RetryerTest.java new file mode 100644 index 000000000..ea8171105 --- /dev/null +++ b/core/src/test/java/feign/RetryerTest.java @@ -0,0 +1,91 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.Assert; +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(-1, 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(-1, 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(-1, null, null, new Date(5000))); + } + + @Test + public void defaultRetryerFailsOnInterruptedException() { + Default retryer = new Retryer.Default(); + + Thread.currentThread().interrupt(); + RetryableException expected = + new RetryableException(-1, null, null, new Date(System.currentTimeMillis() + 5000)); + try { + retryer.continueOrPropagate(expected); + Thread.interrupted(); // reset interrupted flag in case it wasn't + Assert.fail("Retryer continued despite interruption"); + } catch (RetryableException e) { + Assert.assertTrue("Interrupted status not reset", Thread.interrupted()); + Assert.assertEquals("Retry attempt not registered as expected", 2, retryer.attempt); + Assert.assertEquals("Unexpected exception found", expected, e); + } + } +} diff --git a/core/src/test/java/feign/TargetTest.java b/core/src/test/java/feign/TargetTest.java new file mode 100644 index 000000000..90c2e0a2c --- /dev/null +++ b/core/src/test/java/feign/TargetTest.java @@ -0,0 +1,109 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.assertj.MockWebServerAssertions.assertThat; +import feign.Target.HardCodedTarget; +import java.net.URI; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.Rule; +import org.junit.Test; + +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.httpMethod(), + 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"); + } + + interface UriTarget { + + @RequestLine("GET") + Response get(URI uri); + } + + @Test + public void emptyTarget() throws InterruptedException { + server.enqueue(new MockResponse()); + + UriTarget uriTarget = Feign.builder() + .target(Target.EmptyTarget.create(UriTarget.class)); + + String host = server.getHostName(); + int port = server.getPort(); + + uriTarget.get(URI.create("http://" + host + ":" + port + "/path?query=param")); + + assertThat(server.takeRequest()).hasPath("/path?query=param").hasQueryParams("query=param"); + } + + @Test + public void hardCodedTargetWithURI() throws InterruptedException { + server.enqueue(new MockResponse()); + + String host = server.getHostName(); + int port = server.getPort(); + String base = "http://" + host + ":" + port; + + UriTarget uriTarget = Feign.builder() + .target(UriTarget.class, base); + + uriTarget.get(URI.create("http://" + host + ":" + port + "/path?query=param")); + + assertThat(server.takeRequest()).hasPath("/path?query=param").hasQueryParams("query=param"); + } +} diff --git a/core/src/test/java/feign/UtilTest.java b/core/src/test/java/feign/UtilTest.java new file mode 100644 index 000000000..f4ae7bb3a --- /dev/null +++ b/core/src/test/java/feign/UtilTest.java @@ -0,0 +1,260 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.Assert; +import org.junit.Rule; +import org.junit.rules.ExpectedException; +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.Optional; +import java.util.Set; +import feign.codec.Decoder; +import static feign.Util.emptyToNull; +import static feign.Util.removeValues; +import static feign.Util.resolveLastTypeParameter; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; + +public class UtilTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void removesEmptyStrings() { + String[] values = new String[] {"", null}; + assertThat(removeValues(values, (value) -> emptyToNull(value) == null, String.class)) + .isEmpty(); + } + + @Test + public void removesEvenNumbers() { + Integer[] values = new Integer[] {22, 23}; + assertThat(removeValues(values, (number) -> number % 2 == 0, Integer.class)) + .containsExactly(23); + } + + @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)); + assertEquals(Optional.empty(), Util.emptyValueOf(Optional.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); + } + + @Test + public void checkArgumentInputFalseNotNullNullOutputIllegalArgumentException() { + // Arrange + final boolean expression = false; + final String errorMessageTemplate = ""; + final Object[] errorMessageArgs = null; + // Act + thrown.expect(IllegalArgumentException.class); + Util.checkArgument(expression, errorMessageTemplate, errorMessageArgs); + // Method is not expected to return due to exception thrown + } + + @Test + public void checkNotNullInputNullNotNullNullOutputNullPointerException() { + // Arrange + final Object reference = null; + final String errorMessageTemplate = ""; + final Object[] errorMessageArgs = null; + // Act + thrown.expect(NullPointerException.class); + Util.checkNotNull(reference, errorMessageTemplate, errorMessageArgs); + // Method is not expected to return due to exception thrown + } + + @Test + public void checkNotNullInputZeroNotNull0OutputZero() { + // Arrange + final Object reference = 0; + final String errorMessageTemplate = " "; + final Object[] errorMessageArgs = {}; + // Act + final Object retval = Util.checkNotNull(reference, errorMessageTemplate, errorMessageArgs); + // Assert result + Assert.assertEquals(new Integer(0), retval); + } + + @Test + public void checkStateInputFalseNotNullNullOutputIllegalStateException() { + // Arrange + final boolean expression = false; + final String errorMessageTemplate = ""; + final Object[] errorMessageArgs = null; + // Act + thrown.expect(IllegalStateException.class); + Util.checkState(expression, errorMessageTemplate, errorMessageArgs); + // Method is not expected to return due to exception thrown + } + + @Test + public void emptyToNullInputNotNullOutputNotNull() { + // Arrange + final String string = "AAAAAAAA"; + // Act + final String retval = Util.emptyToNull(string); + // Assert result + Assert.assertEquals("AAAAAAAA", retval); + } + + @Test + public void emptyToNullInputNullOutputNull() { + // Arrange + final String string = null; + // Act + final String retval = Util.emptyToNull(string); + // Assert result + Assert.assertNull(retval); + } + + @Test + public void isBlankInputNotNullOutputFalse() { + // Arrange + final String value = "AAAAAAAA"; + // Act + final boolean retval = Util.isBlank(value); + // Assert result + Assert.assertEquals(false, retval); + } + + @Test + public void isBlankInputNullOutputTrue() { + // Arrange + final String value = null; + // Act + final boolean retval = Util.isBlank(value); + // Assert result + Assert.assertEquals(true, retval); + } + + @Test + public void isNotBlankInputNotNullOutputFalse() { + // Arrange + final String value = ""; + // Act + final boolean retval = Util.isNotBlank(value); + // Assert result + Assert.assertEquals(false, retval); + } + + @Test + public void isNotBlankInputNotNullOutputTrue() { + // Arrange + final String value = "AAAAAAAA"; + // Act + final boolean retval = Util.isNotBlank(value); + // Assert result + Assert.assertEquals(true, retval); + } + + 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 000000000..c9099f67b --- /dev/null +++ b/core/src/test/java/feign/assertj/FeignAssertions.java @@ -0,0 +1,24 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..0f2002264 --- /dev/null +++ b/core/src/test/java/feign/assertj/MockWebServerAssertions.java @@ -0,0 +1,24 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..eae1817d3 --- /dev/null +++ b/core/src/test/java/feign/assertj/RecordedRequestAssert.java @@ -0,0 +1,172 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +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 hasQueryParams(String... expectedParams) { + return hasQueryParams(Arrays.asList(expectedParams)); + } + + public RecordedRequestAssert hasQueryParams(Collection expectedParams) { + isNotNull(); + Collection actualQueryParams = getQueryParams(); + objects.assertEqual(info, expectedParams.size(), actualQueryParams.size()); + for (String expectedParam : expectedParams) { + objects.assertIsIn(info, expectedParam, actualQueryParams); + } + return this; + } + + private Collection getQueryParams() { + String path = actual.getPath(); + int queryStart = path.indexOf("?") + 1; + String[] queryParams = actual.getPath() + .substring(queryStart) + .split("&"); + return Arrays.asList(queryParams); + } + + public RecordedRequestAssert hasOneOfPath(String... expected) { + isNotNull(); + objects.assertIsIn(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.equalsIgnoreCase(name)) { + 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 000000000..bb3c0de09 --- /dev/null +++ b/core/src/test/java/feign/assertj/RequestTemplateAssert.java @@ -0,0 +1,96 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 hasPath(String expected) { + isNotNull(); + objects.assertEqual(info, actual.path(), 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.assertContainsOnly(info, actual.headers(), entries); + return this; + } + + public RequestTemplateAssert hasNoHeader(final String encoded) { + objects.assertNull(info, actual.headers().get(encoded)); + 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 000000000..e9089f339 --- /dev/null +++ b/core/src/test/java/feign/auth/BasicAuthRequestInterceptorTest.java @@ -0,0 +1,49 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..b03333674 --- /dev/null +++ b/core/src/test/java/feign/client/AbstractClientTest.java @@ -0,0 +1,413 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 static feign.Util.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.junit.Assert.assertEquals; +import feign.Client; +import feign.CollectionFormat; +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 java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +/** + * {@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(entry("Accept", Collections.singletonList("text/plain")), + entry("Content-Length", Collections.singletonList("0"))) + .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", Collections.singletonList("3")) + .containsEntry("Foo", Collections.singletonList("Bar")); + assertThat(response.body().asInputStream()) + .hasSameContentAs(new ByteArrayInputStream("foo".getBytes(UTF_8))); + + RecordedRequest recordedRequest = server.takeRequest(); + assertThat(recordedRequest.getMethod()).isEqualToIgnoringCase("POST"); + assertThat(recordedRequest.getHeader("Foo")).isEqualToIgnoringCase("Bar, Baz"); + assertThat(recordedRequest.getHeader("Accept")).isEqualToIgnoringCase("*/*"); + assertThat(recordedRequest.getHeader("Content-Length")).isEqualToIgnoringCase("3"); + assertThat(recordedRequest.getBody().readUtf8()).isEqualToIgnoringCase("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() { + thrown.expect(FeignException.class); + thrown.expectMessage("status 500 reading TestInterface#get()"); + + server.enqueue(new MockResponse().setResponseCode(500).setBody("ARGHH")); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.get(); + } + + @Test + public void parsesErrorResponseBody() { + String expectedResponseBody = "ARGHH"; + + server.enqueue(new MockResponse().setResponseCode(500).setBody("ARGHH")); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + try { + api.get(); + } catch (FeignException e) { + assertThat(e.contentUTF8()).isEqualTo(expectedResponseBody); + } + } + + @Test + public void safeRebuffering() { + 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() { + 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(); + } + + /** + * Some client implementation tests should override this test if the PATCH operation is + * unsupported. + */ + @Test + public void noResponseBodyForPatch() { + server.enqueue(new MockResponse()); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.noPatchBody(); + } + + @Test + public void parsesResponseMissingLength() throws IOException { + 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()) + .hasSameContentAs(new ByteArrayInputStream("foo".getBytes(UTF_8))); + } + + @Test + public void postWithSpacesInPath() throws InterruptedException { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + api.post("current documents", "foo"); + + MockWebServerAssertions.assertThat(server.takeRequest()).hasMethod("POST") + .hasPath("/path/current%20documents/resource") + .hasBody("foo"); + } + + @Test + public void testVeryLongResponseNullLength() { + 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() { + 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())); + } + + @Test + public void testContentTypeDefaultsToRequestCharset() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + // should use utf-8 encoding by default + api.postWithContentType("àáâãäåèéêë", "text/plain"); + + MockWebServerAssertions.assertThat(server.takeRequest()).hasMethod("POST") + .hasBody("àáâãäåèéêë"); + } + + @Test + public void testDefaultCollectionFormat() throws Exception { + server.enqueue(new MockResponse().setBody("body")); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Response response = api.get(Arrays.asList("bar", "baz")); + + assertThat(response.status()).isEqualTo(200); + assertThat(response.reason()).isEqualTo("OK"); + + MockWebServerAssertions.assertThat(server.takeRequest()).hasMethod("GET") + .hasPath("/?foo=bar&foo=baz"); + } + + @Test + public void testHeadersWithNullParams() throws InterruptedException { + server.enqueue(new MockResponse().setBody("body")); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Response response = api.getWithHeaders(null); + + assertThat(response.status()).isEqualTo(200); + assertThat(response.reason()).isEqualTo("OK"); + + MockWebServerAssertions.assertThat(server.takeRequest()).hasMethod("GET") + .hasPath("/").hasNoHeaderNamed("Authorization"); + } + + @Test + public void testHeadersWithNotEmptyParams() throws InterruptedException { + server.enqueue(new MockResponse().setBody("body")); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Response response = api.getWithHeaders("token"); + + assertThat(response.status()).isEqualTo(200); + assertThat(response.reason()).isEqualTo("OK"); + + MockWebServerAssertions.assertThat(server.takeRequest()).hasMethod("GET") + .hasPath("/").hasHeaders(entry("authorization", Collections.singletonList("token"))); + } + + @Test + public void testAlternativeCollectionFormat() throws Exception { + server.enqueue(new MockResponse().setBody("body")); + + TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + Response response = api.getCSV(Arrays.asList("bar", "baz")); + + assertThat(response.status()).isEqualTo(200); + assertThat(response.reason()).isEqualTo("OK"); + + // Some HTTP libraries percent-encode commas in query parameters and others don't. + MockWebServerAssertions.assertThat(server.takeRequest()).hasMethod("GET") + .hasOneOfPath("/?foo=bar,baz", "/?foo=bar%2Cbaz"); + } + + @SuppressWarnings("UnusedReturnValue") + 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("GET /?foo={multiFoo}") + Response get(@Param("multiFoo") List multiFoo); + + @Headers({ + "Authorization: {authorization}" + }) + @RequestLine("GET /") + Response getWithHeaders(@Param("authorization") String authorization); + + @RequestLine(value = "GET /?foo={multiFoo}", collectionFormat = CollectionFormat.CSV) + Response getCSV(@Param("multiFoo") List multiFoo); + + @RequestLine("PATCH /") + @Headers("Accept: text/plain") + String patch(String body); + + @RequestLine("POST") + String noPostBody(); + + @RequestLine("PUT") + String noPutBody(); + + @RequestLine("PATCH") + String noPatchBody(); + + @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 000000000..ecfa050b2 --- /dev/null +++ b/core/src/test/java/feign/client/DefaultClientTest.java @@ -0,0 +1,106 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 static org.hamcrest.core.Is.isA; +import static org.junit.Assert.assertEquals; +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; + +/** + * 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 + @Override + public void noResponseBodyForPatch() { + thrown.expect(RetryableException.class); + thrown.expectCause(isA(ProtocolException.class)); + super.noResponseBodyForPatch(); + } + + @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 000000000..ccc49c37c --- /dev/null +++ b/core/src/test/java/feign/client/TrustingSSLSocketFactory.java @@ -0,0 +1,174 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.*; + +/** + * 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 = {"TLS_RSA_WITH_AES_256_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 000000000..e8d2e3aa6 --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultDecoderTest.java @@ -0,0 +1,92 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 static feign.Util.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import feign.Request.HttpMethod; +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 org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.w3c.dom.Document; +import feign.Request; +import feign.Response; +import feign.Util; + +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) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .body(inputStream, content.length()) + .build(); + } + + private Response nullBodyResponse() { + return Response.builder() + .status(200) + .reason("OK") + .headers(Collections.>emptyMap()) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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 000000000..ee9c63c8a --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultEncoderTest.java @@ -0,0 +1,56 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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/DefaultErrorDecoderHttpErrorTest.java b/core/src/test/java/feign/codec/DefaultErrorDecoderHttpErrorTest.java new file mode 100644 index 000000000..1c49a3cc9 --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultErrorDecoderHttpErrorTest.java @@ -0,0 +1,79 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.codec; + +import feign.FeignException; +import feign.Request; +import feign.Request.HttpMethod; +import feign.Response; +import feign.Util; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(Parameterized.class) +public class DefaultErrorDecoderHttpErrorTest { + + @Parameterized.Parameters(name = "error: [{0}], exception: [{1}]") + public static Object[][] errorCodes() { + return new Object[][] { + {400, FeignException.BadRequest.class}, + {401, FeignException.Unauthorized.class}, + {403, FeignException.Forbidden.class}, + {404, FeignException.NotFound.class}, + {405, FeignException.MethodNotAllowed.class}, + {406, FeignException.NotAcceptable.class}, + {409, FeignException.Conflict.class}, + {429, FeignException.TooManyRequests.class}, + {422, FeignException.UnprocessableEntity.class}, + {500, FeignException.InternalServerError.class}, + {501, FeignException.NotImplemented.class}, + {502, FeignException.BadGateway.class}, + {503, FeignException.ServiceUnavailable.class}, + {504, FeignException.GatewayTimeout.class}, + {599, FeignException.class}, + }; + } + + @Parameterized.Parameter + public int httpStatus; + + @Parameterized.Parameter(1) + public Class expectedExceptionClass; + + private ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + + private Map> headers = new LinkedHashMap<>(); + + @Test + public void testExceptionIsHttpSpecific() throws Throwable { + Response response = Response.builder() + .status(httpStatus) + .reason("anything") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(headers) + .build(); + + Exception exception = errorDecoder.decode("Service#foo()", response); + + assertThat(exception).isInstanceOf(expectedExceptionClass); + assertThat(((FeignException) exception).status()).isEqualTo(httpStatus); + } + +} 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 000000000..8fdfcfe0a --- /dev/null +++ b/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java @@ -0,0 +1,104 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 static feign.Util.RETRY_AFTER; +import static feign.Util.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import feign.FeignException; +import feign.Request; +import feign.Request.HttpMethod; +import feign.Response; +import feign.Util; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +public class DefaultErrorDecoderTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + + private 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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(headers) + .build(); + + throw errorDecoder.decode("Service#foo()", response); + } + + @Test + public void throwsFeignExceptionIncludingBody() throws Throwable { + Response response = Response.builder() + .status(500) + .reason("Internal server error") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(headers) + .body("hello world", UTF_8) + .build(); + + try { + throw errorDecoder.decode("Service#foo()", response); + } catch (FeignException e) { + assertThat(e.getMessage()).isEqualTo("status 500 reading Service#foo()"); + assertThat(e.contentUTF8()).isEqualTo("hello world"); + } + } + + @Test + public void testFeignExceptionIncludesStatus() { + Response response = Response.builder() + .status(400) + .reason("Bad request") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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, Collections.singletonList("Sat, 1 Jan 2000 00:00:00 GMT")); + Response response = Response.builder() + .status(503) + .reason("Service Unavailable") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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 000000000..8baf7d1d8 --- /dev/null +++ b/core/src/test/java/feign/codec/RetryAfterDecoderTest.java @@ -0,0 +1,51 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..88081b05f --- /dev/null +++ b/core/src/test/java/feign/examples/GitHubExample.java @@ -0,0 +1,86 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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/java/feign/optionals/OptionalDecoderTests.java b/core/src/test/java/feign/optionals/OptionalDecoderTests.java new file mode 100644 index 000000000..d316fb396 --- /dev/null +++ b/core/src/test/java/feign/optionals/OptionalDecoderTests.java @@ -0,0 +1,101 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.optionals; + +import feign.Feign; +import feign.RequestLine; +import feign.codec.Decoder; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.Test; +import java.io.IOException; +import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; + +public class OptionalDecoderTests { + + interface OptionalInterface { + @RequestLine("GET /") + Optional getAsOptional(); + + @RequestLine("GET /") + String get(); + } + + @Test + public void simple404OptionalTest() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setResponseCode(404)); + server.enqueue(new MockResponse().setBody("foo")); + + final OptionalInterface api = Feign.builder() + .decode404() + .decoder(new OptionalDecoder(new Decoder.Default())) + .target(OptionalInterface.class, server.url("/").toString()); + + assertThat(api.getAsOptional().isPresent()).isFalse(); + assertThat(api.getAsOptional().get()).isEqualTo("foo"); + } + + @Test + public void simple204OptionalTest() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setResponseCode(204)); + + final OptionalInterface api = Feign.builder() + .decoder(new OptionalDecoder(new Decoder.Default())) + .target(OptionalInterface.class, server.url("/").toString()); + + assertThat(api.getAsOptional().isPresent()).isFalse(); + } + + @Test + public void test200WithOptionalString() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setResponseCode(200).setBody("foo")); + + final OptionalInterface api = Feign.builder() + .decoder(new OptionalDecoder(new Decoder.Default())) + .target(OptionalInterface.class, server.url("/").toString()); + + Optional response = api.getAsOptional(); + + assertThat(response.isPresent()).isTrue(); + assertThat(response).isEqualTo(Optional.of("foo")); + } + + @Test + public void test200WhenResponseBodyIsNull() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setResponseCode(200)); + + final OptionalInterface api = Feign.builder() + .decoder(new OptionalDecoder(((response, type) -> null))) + .target(OptionalInterface.class, server.url("/").toString()); + + assertThat(api.getAsOptional().isPresent()).isFalse(); + } + + @Test + public void test200WhenDecodingNoOptional() throws IOException, InterruptedException { + final MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setResponseCode(200).setBody("foo")); + + final OptionalInterface api = Feign.builder() + .decoder(new OptionalDecoder(new Decoder.Default())) + .target(OptionalInterface.class, server.url("/").toString()); + + assertThat(api.get()).isEqualTo("foo"); + } +} diff --git a/core/src/test/java/feign/querymap/BeanQueryMapEncoderTest.java b/core/src/test/java/feign/querymap/BeanQueryMapEncoderTest.java new file mode 100644 index 000000000..abf77fc14 --- /dev/null +++ b/core/src/test/java/feign/querymap/BeanQueryMapEncoderTest.java @@ -0,0 +1,133 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.querymap; + +import feign.QueryMapEncoder; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import java.util.HashMap; +import java.util.Map; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Test for {@link BeanQueryMapEncoder} + */ +public class BeanQueryMapEncoderTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private final QueryMapEncoder encoder = new BeanQueryMapEncoder(); + + @Test + public void testDefaultEncoder_normalClassWithValues() { + Map expected = new HashMap<>(); + expected.put("foo", "fooz"); + expected.put("bar", "barz"); + expected.put("fooAppendBar", "foozbarz"); + NormalObject normalObject = new NormalObject("fooz", "barz"); + + Map encodedMap = encoder.encode(normalObject); + + assertEquals("Unexpected encoded query map", expected, encodedMap); + } + + @Test + public void testDefaultEncoder_normalClassWithOutValues() { + NormalObject normalObject = new NormalObject(null, null); + + Map encodedMap = encoder.encode(normalObject); + + assertTrue("Non-empty map generated from null getter: " + encodedMap, encodedMap.isEmpty()); + } + + @Test + public void testDefaultEncoder_haveSuperClass() { + Map expected = new HashMap<>(); + expected.put("page", 1); + expected.put("size", 10); + expected.put("query", "queryString"); + SubClass subClass = new SubClass(); + subClass.setPage(1); + subClass.setSize(10); + subClass.setQuery("queryString"); + + Map encodedMap = encoder.encode(subClass); + + assertEquals("Unexpected encoded query map", expected, encodedMap); + } + + + class NormalObject { + + private NormalObject(String foo, String bar) { + this.foo = foo; + this.bar = bar; + } + + private String foo; + private String bar; + + public String getFoo() { + return foo; + } + + public String getBar() { + return bar; + } + + public String getFooAppendBar() { + if (foo != null && bar != null) { + return foo + bar; + } + return null; + } + } + + class SuperClass { + private int page; + private int size; + + public int getPage() { + return page; + } + + public void setPage(int page) { + this.page = page; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + } + + class SubClass extends SuperClass { + + private String query; + + public String getQuery() { + return query; + } + + public void setQuery(String query) { + this.query = query; + } + } +} diff --git a/core/src/test/java/feign/querymap/FieldQueryMapEncoderTest.java b/core/src/test/java/feign/querymap/FieldQueryMapEncoderTest.java new file mode 100644 index 000000000..a37415fc3 --- /dev/null +++ b/core/src/test/java/feign/querymap/FieldQueryMapEncoderTest.java @@ -0,0 +1,67 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.querymap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import java.util.HashMap; +import java.util.Map; +import feign.QueryMapEncoder; + +/** + * Test for {@link FieldQueryMapEncoder} + */ +public class FieldQueryMapEncoderTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private final QueryMapEncoder encoder = new FieldQueryMapEncoder(); + + @Test + public void testDefaultEncoder_normalClassWithValues() { + final Map expected = new HashMap<>(); + expected.put("foo", "fooz"); + expected.put("bar", "barz"); + final NormalObject normalObject = new NormalObject("fooz", "barz"); + + final Map encodedMap = encoder.encode(normalObject); + + assertEquals("Unexpected encoded query map", expected, encodedMap); + } + + @Test + public void testDefaultEncoder_normalClassWithOutValues() { + final NormalObject normalObject = new NormalObject(null, null); + + final Map encodedMap = encoder.encode(normalObject); + + assertTrue("Non-empty map generated from null getter: " + encodedMap, encodedMap.isEmpty()); + } + + class NormalObject { + + private NormalObject(String foo, String bar) { + this.foo = foo; + this.bar = bar; + } + + private final String foo; + private final String bar; + } + +} diff --git a/core/src/test/java/feign/stream/StreamDecoderTest.java b/core/src/test/java/feign/stream/StreamDecoderTest.java new file mode 100644 index 000000000..0bde7dace --- /dev/null +++ b/core/src/test/java/feign/stream/StreamDecoderTest.java @@ -0,0 +1,123 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.stream; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Feign; +import feign.Request; +import feign.Request.HttpMethod; +import feign.RequestLine; +import feign.Response; +import feign.Util; +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.Test; +import static feign.Util.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +public class StreamDecoderTest { + + interface StreamInterface { + @RequestLine("GET /") + Stream get(); + + @RequestLine("GET /cars") + Stream getCars(); + + class Car { + public String name; + public String manufacturer; + } + } + + private String carsJson = ""// + + "[\n"// + + " {\n"// + + " \"name\": \"Megane\",\n"// + + " \"manufacturer\": \"Renault\"\n"// + + " },\n"// + + " {\n"// + + " \"name\": \"C4\",\n"// + + " \"manufacturer\": \"Citroën\"\n"// + + " }\n"// + + "]\n"; + + @Test + public void simpleStreamTest() { + MockWebServer server = new MockWebServer(); + server.enqueue(new MockResponse().setBody("foo\nbar")); + + StreamInterface api = Feign.builder() + .decoder(StreamDecoder.create( + (response, type) -> new BufferedReader(response.body().asReader()).lines().iterator())) + .doNotCloseAfterDecode() + .target(StreamInterface.class, server.url("/").toString()); + + try (Stream stream = api.get()) { + assertThat(stream.collect(Collectors.toList())).isEqualTo(Arrays.asList("foo", "bar")); + } + } + + @Test + public void shouldCloseIteratorWhenStreamClosed() throws IOException { + Response response = Response.builder() + .status(200) + .reason("OK") + .headers(Collections.emptyMap()) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .body("", UTF_8) + .build(); + + TestCloseableIterator it = new TestCloseableIterator(); + StreamDecoder decoder = new StreamDecoder((r, t) -> it); + + try (Stream stream = + (Stream) decoder.decode(response, new TypeReference>() {}.getType())) { + assertThat(stream.collect(Collectors.toList())).hasSize(1); + assertThat(it.called).isTrue(); + } finally { + assertThat(it.closed).isTrue(); + } + } + + static class TestCloseableIterator implements Iterator, Closeable { + boolean called; + boolean closed; + + @Override + public void close() { + this.closed = true; + } + + @Override + public boolean hasNext() { + return !called; + } + + @Override + public String next() { + called = true; + return "feign"; + } + } +} diff --git a/core/src/test/java/feign/template/QueryTemplateTest.java b/core/src/test/java/feign/template/QueryTemplateTest.java new file mode 100644 index 000000000..cff4a7cec --- /dev/null +++ b/core/src/test/java/feign/template/QueryTemplateTest.java @@ -0,0 +1,121 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + + +import static org.assertj.core.api.Assertions.assertThat; +import feign.CollectionFormat; +import feign.Util; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; + +public class QueryTemplateTest { + + @Test + public void templateToQueryString() { + QueryTemplate template = + QueryTemplate.create("name", Arrays.asList("Bob", "James", "Jason"), Util.UTF_8); + assertThat(template.toString()).isEqualToIgnoringCase("name=Bob&name=James&name=Jason"); + } + + @Test + public void expandSingleValue() { + QueryTemplate template = + QueryTemplate.create("name", Collections.singletonList("{value}"), Util.UTF_8); + String expanded = template.expand(Collections.singletonMap("value", "Magnum P.I.")); + assertThat(expanded).isEqualToIgnoringCase("name=Magnum%20P.I."); + } + + @Test + public void expandMultipleValues() { + QueryTemplate template = + QueryTemplate.create("name", Arrays.asList("Bob", "James", "Jason"), Util.UTF_8); + String expanded = template.expand(Collections.emptyMap()); + assertThat(expanded).isEqualToIgnoringCase("name=Bob&name=James&name=Jason"); + } + + @Test + public void unresolvedQuery() { + QueryTemplate template = + QueryTemplate.create("name", Collections.singletonList("{value}"), Util.UTF_8); + String expanded = template.expand(Collections.emptyMap()); + assertThat(expanded).isNullOrEmpty(); + } + + @Test + public void unresolvedMultiValueQueryTemplates() { + QueryTemplate template = + QueryTemplate.create("name", Arrays.asList("{bob}", "{james}", "{jason}"), Util.UTF_8); + String expanded = template.expand(Collections.emptyMap()); + assertThat(expanded).isNullOrEmpty(); + } + + @Test + public void explicitNullValuesAreRemoved() { + QueryTemplate template = + QueryTemplate.create("name", Collections.singletonList("{value}"), Util.UTF_8); + String expanded = template.expand(Collections.singletonMap("value", null)); + assertThat(expanded).isNullOrEmpty(); + } + + @Test + public void emptyParameterRemains() { + QueryTemplate template = + QueryTemplate.create("name", Collections.singletonList("{value}"), Util.UTF_8); + String expanded = template.expand(Collections.singletonMap("value", "")); + assertThat(expanded).isEqualToIgnoringCase("name="); + } + + @Test + public void collectionFormat() { + QueryTemplate template = + QueryTemplate + .create("name", Arrays.asList("James", "Jason"), Util.UTF_8, CollectionFormat.CSV); + String expanded = template.expand(Collections.emptyMap()); + assertThat(expanded).isEqualToIgnoringCase("name=James,Jason"); + } + + @Test + public void expandName() { + QueryTemplate template = + QueryTemplate.create("{name}", Arrays.asList("James", "Jason"), Util.UTF_8); + String expanded = template.expand(Collections.singletonMap("name", "firsts")); + assertThat(expanded).isEqualToIgnoringCase("firsts=James&firsts=Jason"); + } + + @Test + public void expandPureParameter() { + QueryTemplate template = + QueryTemplate.create("{name}", Collections.emptyList(), Util.UTF_8); + String expanded = template.expand(Collections.singletonMap("name", "firsts")); + assertThat(expanded).isEqualToIgnoringCase("firsts"); + } + + @Test + public void expandPureParameterWithSlash() { + QueryTemplate template = + QueryTemplate.create("/path/{name}", Collections.emptyList(), Util.UTF_8); + String expanded = template.expand(Collections.singletonMap("name", "firsts")); + assertThat(expanded).isEqualToIgnoringCase("/path/firsts"); + } + + @Test + public void expandNameUnresolved() { + QueryTemplate template = + QueryTemplate.create("{parameter}", Arrays.asList("James", "Jason"), Util.UTF_8); + String expanded = template.expand(Collections.singletonMap("name", "firsts")); + assertThat(expanded).isEqualToIgnoringCase("%7Bparameter%7D=James&%7Bparameter%7D=Jason"); + } +} diff --git a/core/src/test/java/feign/template/UriTemplateTest.java b/core/src/test/java/feign/template/UriTemplateTest.java new file mode 100644 index 000000000..9176b855d --- /dev/null +++ b/core/src/test/java/feign/template/UriTemplateTest.java @@ -0,0 +1,294 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import feign.Util; +import java.net.URI; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public class UriTemplateTest { + + @Test + public void emptyRelativeTemplate() { + String template = "/"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + assertThat(uriTemplate.expand(Collections.emptyMap())).isEqualToIgnoringCase("/"); + } + + @Test(expected = IllegalArgumentException.class) + public void nullTemplate() { + UriTemplate.create(null, Util.UTF_8); + } + + @Test + public void emptyTemplate() { + UriTemplate.create("", Util.UTF_8); + } + + @Test + public void simpleTemplate() { + String template = "https://www.example.com/foo/{bar}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + + /* verify that the template has 1 variables names foo */ + List uriTemplateVariables = uriTemplate.getVariables(); + assertThat(uriTemplateVariables).contains("bar").hasSize(1); + + /* expand the template */ + Map variables = new LinkedHashMap<>(); + variables.put("bar", "bar"); + String expandedTemplate = uriTemplate.expand(variables); + assertThat(expandedTemplate).isEqualToIgnoringCase("https://www.example.com/foo/bar"); + assertThat(URI.create(expandedTemplate)).isNotNull(); + } + + @Test + public void simpleTemplateMultipleExpressions() { + String template = "https://www.example.com/{foo}/{bar}/details"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + + /* verify that the template has 2 variables names foo and bar */ + List uriTemplateVariables = uriTemplate.getVariables(); + assertThat(uriTemplateVariables).contains("foo", "bar").hasSize(2); + + /* expand the template */ + Map variables = new LinkedHashMap<>(); + variables.put("foo", "first"); + variables.put("bar", "second"); + String expandedTemplate = uriTemplate.expand(variables); + assertThat(expandedTemplate) + .isEqualToIgnoringCase("https://www.example.com/first/second/details"); + assertThat(URI.create(expandedTemplate)).isNotNull(); + } + + @Test + public void simpleTemplateMultipleSequentialExpressions() { + String template = "https://www.example.com/{foo}{bar}/{baz}/details"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + + /* verify that the template has 2 variables names foo and bar */ + List uriTemplateVariables = uriTemplate.getVariables(); + assertThat(uriTemplateVariables).contains("foo", "bar", "baz").hasSize(3); + + /* expand the template */ + Map variables = new LinkedHashMap<>(); + variables.put("foo", "first"); + variables.put("bar", "second"); + variables.put("baz", "third"); + String expandedTemplate = uriTemplate.expand(variables); + assertThat(expandedTemplate) + .isEqualToIgnoringCase("https://www.example.com/firstsecond/third/details"); + assertThat(URI.create(expandedTemplate)).isNotNull(); + } + + @Test + public void simpleTemplateUnresolvedVariablesAreRemoved() { + String template = "https://www.example.com/{foo}?name={name}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + assertThat(uriTemplate.getVariables()).contains("foo", "name").hasSize(2); + + Map variables = new LinkedHashMap<>(); + variables.put("name", "Albert"); + String expandedTemplate = uriTemplate.expand(variables); + assertThat(expandedTemplate).isEqualToIgnoringCase("https://www.example.com/?name=Albert"); + assertThat(URI.create(expandedTemplate)).isNotNull(); + } + + @Test + public void missingVariablesOnPathAreRemoved() { + String template = "https://www.example.com/{foo}/items?name={name}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + assertThat(uriTemplate.getVariables()).contains("foo", "name").hasSize(2); + + Map variables = new LinkedHashMap<>(); + variables.put("name", "Albert"); + String expandedTemplate = uriTemplate.expand(variables); + assertThat(expandedTemplate) + .isEqualToIgnoringCase("https://www.example.com//items?name=Albert"); + assertThat(URI.create(expandedTemplate)).isNotNull(); + } + + @Test + public void simpleTemplateWithRegularExpressions() { + String template = "https://www.example.com/{foo:[0-9]{4}}/{bar}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + assertThat(uriTemplate.getVariables()).contains("foo", "bar").hasSize(2); + + Map variables = new LinkedHashMap<>(); + variables.put("foo", 1234); + variables.put("bar", "stuff"); + String expandedTemplate = uriTemplate.expand(variables); + assertThat(expandedTemplate).isEqualToIgnoringCase("https://www.example.com/1234/stuff"); + assertThat(URI.create(expandedTemplate)).isNotNull(); + } + + @Test(expected = IllegalArgumentException.class) + public void simpleTemplateWithRegularExpressionsValidation() { + String template = "https://www.example.com/{foo:[0-9]{4}}/{bar}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + assertThat(uriTemplate.getVariables()).contains("foo", "bar").hasSize(2); + + Map variables = new LinkedHashMap<>(); + variables.put("foo", "abcd"); + variables.put("bar", "stuff"); + + /* the foo variable must be a number and no more than four, this should fail */ + uriTemplate.expand(variables); + fail("Should not be able to expand, pattern does not match"); + } + + @Test + public void nestedExpressionsAreLiterals() { + /* the template of {foo{bar}}, will be treated as literals as nested templates are ignored */ + String template = "https://www.example.com/{foo{bar}}/{baz}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + assertThat(uriTemplate.getVariables()).contains("baz").hasSize(1); + + Map variables = new LinkedHashMap<>(); + variables.put("baz", "stuff"); + String expandedTemplate = uriTemplate.expand(variables); + assertThat(expandedTemplate) + .isEqualToIgnoringCase("https://www.example.com/%7Bfoo%7Bbar%7D%7D/stuff"); + assertThat(URI.create(expandedTemplate)) + .isNotNull(); // this should fail, the result is not a valid uri + } + + @Test + public void literalTemplate() { + String template = "https://www.example.com/do/stuff"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + String expandedTemplate = uriTemplate.expand(Collections.emptyMap()); + assertThat(expandedTemplate).isEqualToIgnoringCase(template); + assertThat(URI.create(expandedTemplate)).isNotNull(); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectEmptyExpressions() { + String template = "https://www.example.com/{}/things"; + UriTemplate.create(template, Util.UTF_8); + fail("Should not accept empty expressions"); + } + + @Test + public void testToString() { + String template = "https://www.example.com/foo/{bar}/{baz:[0-9]}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + assertThat(uriTemplate.toString()).isEqualToIgnoringCase(template); + } + + @Test + public void encodeVariables() { + String template = "https://www.example.com/{first}/{last}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + Map variables = new LinkedHashMap<>(); + variables.put("first", "John Jacob"); + variables.put("last", "Jingleheimer Schmidt"); + String expandedTemplate = uriTemplate.expand(variables); + assertThat(expandedTemplate) + .isEqualToIgnoringCase("https://www.example.com/John%20Jacob/Jingleheimer%20Schmidt"); + } + + @Test + public void encodeLiterals() { + String template = "https://www.example.com/A Team"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + String expandedTemplate = uriTemplate.expand(Collections.emptyMap()); + assertThat(expandedTemplate) + .isEqualToIgnoringCase("https://www.example.com/A%20Team"); + } + + @Test + public void ensurePlusIsSupportedOnPath() { + String template = "https://www.example.com/sam+adams/beer/{type}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + String expanded = uriTemplate.expand(Collections.emptyMap()); + assertThat(expanded).isEqualToIgnoringCase("https://www.example.com/sam+adams/beer/"); + } + + @Test + public void ensurePlusInEncodedAs2BOnQuery() { + String template = "https://www.example.com/beer?type={type}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + Map parameters = Collections.singletonMap("type", "sam+adams"); + String expanded = uriTemplate.expand(parameters); + assertThat(expanded).isEqualToIgnoringCase("https://www.example.com/beer?type=sam%2Badams"); + } + + @Test + public void incompleteTemplateIsALiteral() { + String template = "https://www.example.com/testing/foo}}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + assertThat(uriTemplate.expand(Collections.emptyMap())) + .isEqualToIgnoringCase("https://www.example.com/testing/foo%7D%7D"); + } + + @Test(expected = IllegalArgumentException.class) + public void substituteNullMap() { + UriTemplate.create("stuff", Util.UTF_8).expand(null); + } + + @Test + public void skipAlreadyEncodedLiteral() { + String template = "https://www.example.com/A%20Team"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + String expandedTemplate = uriTemplate.expand(Collections.emptyMap()); + assertThat(expandedTemplate) + .isEqualToIgnoringCase("https://www.example.com/A%20Team"); + } + + @Test + public void skipAlreadyEncodedVariable() { + String template = "https://www.example.com/testing/{foo}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + String encodedVariable = UriUtils.encode("Johnny Appleseed", Util.UTF_8); + Map variables = new LinkedHashMap<>(); + variables.put("foo", encodedVariable); + assertThat(uriTemplate.expand(variables)) + .isEqualToIgnoringCase("https://www.example.com/testing/" + encodedVariable); + } + + @Test + public void skipSlashes() { + String template = "https://www.example.com/{path}"; + UriTemplate uriTemplate = UriTemplate.create(template, false, Util.UTF_8); + Map variables = new LinkedHashMap<>(); + variables.put("path", "me/you/first"); + String encoded = uriTemplate.expand(variables); + assertThat(encoded).isEqualToIgnoringCase("https://www.example.com/me/you/first"); + } + + @Test + public void encodeSlashes() { + String template = "https://www.example.com/{path}"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + Map variables = new LinkedHashMap<>(); + variables.put("path", "me/you/first"); + String encoded = uriTemplate.expand(variables); + assertThat(encoded).isEqualToIgnoringCase("https://www.example.com/me%2Fyou%2Ffirst"); + } + + @Test + public void testLiteralTemplateWithQueryString() { + String template = "https://api.example.com?wsdl"; + UriTemplate uriTemplate = UriTemplate.create(template, Util.UTF_8); + String expanded = uriTemplate.expand(Collections.emptyMap()); + assertThat(expanded).isEqualToIgnoringCase("https://api.example.com?wsdl"); + } +} diff --git a/core/src/test/java/feign/template/UriUtilsTest.java b/core/src/test/java/feign/template/UriUtilsTest.java new file mode 100644 index 000000000..353d19935 --- /dev/null +++ b/core/src/test/java/feign/template/UriUtilsTest.java @@ -0,0 +1,93 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import java.nio.charset.Charset; +import org.junit.Test; + +public class UriUtilsTest { + + + /** + * Verify that values outside of the allowed characters in a path segment are pct-encoded. The + * list of approved characters used are those listed in + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + + io.github.openfeign + feign-example-github + jar + GitHub Example + + + ${project.basedir}/.. + + + + + 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 000000000..c805dcf5e --- /dev/null +++ b/example-github/src/main/java/feign/example/github/GitHubExample.java @@ -0,0 +1,114 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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("openfeign"); + for (String contributor : contributors) { + System.out.println(contributor); + } + + System.out.println("Now, let's cause an error."); + try { + github.contributors("openfeign", "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 000000000..e9094c6a6 --- /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/pom.xml b/example-wikipedia/pom.xml new file mode 100644 index 000000000..a12b38fe3 --- /dev/null +++ b/example-wikipedia/pom.xml @@ -0,0 +1,98 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + + io.github.openfeign + feign-example-wikipedia + jar + Wikipedia Example + + + ${project.basedir}/.. + + + + + 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 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 6 + 6 + + + + + 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 000000000..532fb9ae1 --- /dev/null +++ b/example-wikipedia/src/main/java/feign/example/wikipedia/ResponseAdapter.java @@ -0,0 +1,97 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.example.wikipedia; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import 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 000000000..7a48f20b2 --- /dev/null +++ b/example-wikipedia/src/main/java/feign/example/wikipedia/WikipediaExample.java @@ -0,0 +1,133 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..d26c16470 --- /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/pom.xml b/gson/pom.xml new file mode 100644 index 000000000..316c7619f --- /dev/null +++ b/gson/pom.xml @@ -0,0 +1,52 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.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 000000000..2eeea2ff8 --- /dev/null +++ b/gson/src/main/java/feign/gson/DoubleToIntMapTypeAdapter.java @@ -0,0 +1,46 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..b1001bba3 --- /dev/null +++ b/gson/src/main/java/feign/gson/GsonDecoder.java @@ -0,0 +1,62 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..06fc4fa19 --- /dev/null +++ b/gson/src/main/java/feign/gson/GsonEncoder.java @@ -0,0 +1,43 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..eddb26ce0 --- /dev/null +++ b/gson/src/main/java/feign/gson/GsonFactory.java @@ -0,0 +1,42 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..e88f3f701 --- /dev/null +++ b/gson/src/test/java/feign/gson/GsonCodecTest.java @@ -0,0 +1,240 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 feign.Request; +import feign.Request.HttpMethod; +import feign.Util; +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() { + 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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body("{\"foo\": 1}", UTF_8) + .build(); + assertEquals( + new GsonDecoder().decode(response, new TypeToken>() {}.getType()), map); + } + + @Test + public void encodesFormParams() { + + 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()) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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()) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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()) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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(Collections.singletonList(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()) + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .body(zonesJson, UTF_8) + .build(); + assertEquals(zones, decoder.decode(response, new TypeToken>() {}.getType())); + } + + @Test + public void customEncoder() { + GsonEncoder encoder = new GsonEncoder(Collections.singletonList(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()) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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 000000000..41d12660b --- /dev/null +++ b/gson/src/test/java/feign/gson/examples/GitHubExample.java @@ -0,0 +1,50 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..ed20b439a --- /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/pom.xml b/httpclient/pom.xml new file mode 100644 index 000000000..e8c81d45e --- /dev/null +++ b/httpclient/pom.xml @@ -0,0 +1,65 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + + feign-httpclient + Feign Apache HttpClient + Feign Apache HttpClient + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + org.apache.httpcomponents + httpclient + 4.5.2 + + + + ${project.groupId} + feign-core + test-jar + test + + + + ${project.groupId} + feign-jaxrs + 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 000000000..eacb27883 --- /dev/null +++ b/httpclient/src/main/java/feign/httpclient/ApacheHttpClient.java @@ -0,0 +1,240 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.Configurable; +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.nio.charset.Charset; +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, request);
+  }
+
+  HttpUriRequest toHttpUriRequest(Request request, Request.Options options)
+      throws URISyntaxException {
+    RequestBuilder requestBuilder = RequestBuilder.create(request.httpMethod().name());
+
+    // per request timeouts
+    RequestConfig requestConfig =
+        (client instanceof Configurable ? RequestConfig.copy(((Configurable) client).getConfig())
+            : 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);
+    } else {
+      requestBuilder.setEntity(new ByteArrayEntity(new byte[0]));
+    }
+
+    return requestBuilder.build();
+  }
+
+  private ContentType getContentType(Request request) {
+    ContentType contentType = null;
+    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());
+          if (contentType.getCharset() == null) {
+            contentType = contentType.withCharset(request.charset());
+          }
+          break;
+        }
+      }
+    return contentType;
+  }
+
+  Response toFeignResponse(HttpResponse httpResponse, Request request) 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)
+        .request(request)
+        .body(toFeignBody(httpResponse))
+        .build();
+  }
+
+  Response.Body toFeignBody(HttpResponse httpResponse) {
+    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 Reader asReader(Charset charset) throws IOException {
+        Util.checkNotNull(charset, "charset should not be null");
+        return new InputStreamReader(asInputStream(), charset);
+      }
+
+      @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 000000000..ef3c67924
--- /dev/null
+++ b/httpclient/src/test/java/feign/httpclient/ApacheHttpClientTest.java
@@ -0,0 +1,75 @@
+/**
+ * Copyright 2012-2019 The Feign Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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;
+import feign.jaxrs.JAXRSContract;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.apache.http.client.HttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.junit.Test;
+import javax.ws.rs.GET;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.QueryParam;
+import java.nio.charset.StandardCharsets;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+/**
+ * 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());
+  }
+
+  @Test
+  public void queryParamsAreRespectedWhenBodyIsEmpty() throws InterruptedException {
+    final HttpClient httpClient = HttpClientBuilder.create().build();
+    final JaxRsTestInterface testInterface = Feign.builder()
+        .contract(new JAXRSContract())
+        .client(new ApacheHttpClient(httpClient))
+        .target(JaxRsTestInterface.class, "http://localhost:" + server.getPort());
+
+    server.enqueue(new MockResponse().setBody("foo"));
+    server.enqueue(new MockResponse().setBody("foo"));
+
+    assertEquals("foo", testInterface.withBody("foo", "bar"));
+    final RecordedRequest request1 = server.takeRequest();
+    assertEquals("/withBody?foo=foo", request1.getPath());
+    assertEquals("bar", request1.getBody().readString(StandardCharsets.UTF_8));
+
+    assertEquals("foo", testInterface.withoutBody("foo"));
+    final RecordedRequest request2 = server.takeRequest();
+    assertEquals("/withoutBody?foo=foo", request2.getPath());
+    assertEquals("", request2.getBody().readString(StandardCharsets.UTF_8));
+  }
+
+  @Path("/")
+  public interface JaxRsTestInterface {
+    @PUT
+    @Path("/withBody")
+    public String withBody(@QueryParam("foo") String foo, String bar);
+
+    @PUT
+    @Path("/withoutBody")
+    public String withoutBody(@QueryParam("foo") String foo);
+  }
+}
diff --git a/hystrix/README.md b/hystrix/README.md
new file mode 100644
index 000000000..dbd700fc7
--- /dev/null
+++ b/hystrix/README.md
@@ -0,0 +1,137 @@
+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` or `CompletableFuture`.
+
+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), `CompletableFuture`, [`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}")
+  CompletableFuture getYourTypeCompletableFuture(@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 for a CompletableFuture
+api.getYourTypeCompletableFuture("a").thenApply(o -> "b");
+
+// 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/pom.xml b/hystrix/pom.xml
new file mode 100644
index 000000000..23007d960
--- /dev/null
+++ b/hystrix/pom.xml
@@ -0,0 +1,85 @@
+
+
+
+  4.0.0
+
+  
+    io.github.openfeign
+    parent
+    10.2.1-SNAPSHOT
+  
+
+  feign-hystrix
+  Feign Hystrix
+  Feign Hystrix
+
+  
+    ${project.basedir}/..
+  
+
+  
+    
+      
+        com.netflix.archaius
+        archaius-core
+        0.7.6
+      
+
+      
+        com.netflix.hystrix
+        hystrix-core
+        1.5.18
+      
+    
+  
+
+  
+    
+      ${project.groupId}
+      feign-core
+    
+
+    
+      com.netflix.archaius
+      archaius-core
+    
+
+    
+      com.netflix.hystrix
+      hystrix-core
+    
+
+    
+      ${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 000000000..effc2ea61
--- /dev/null
+++ b/hystrix/src/main/java/feign/hystrix/FallbackFactory.java
@@ -0,0 +1,83 @@
+/**
+ * Copyright 2012-2019 The Feign Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 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 000000000..8206bf0ba --- /dev/null +++ b/hystrix/src/main/java/feign/hystrix/HystrixDelegatingContract.java @@ -0,0 +1,75 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 static feign.Util.resolveLastTypeParameter; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.List; +import java.util.concurrent.CompletableFuture; +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); + } else if (type instanceof ParameterizedType + && ((ParameterizedType) type).getRawType().equals(CompletableFuture.class)) { + metadata.returnType(resolveLastTypeParameter(type, CompletableFuture.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 000000000..cbd13d46f --- /dev/null +++ b/hystrix/src/main/java/feign/hystrix/HystrixFeign.java @@ -0,0 +1,215 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 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.ResponseMapper; +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 mapAndDecode(ResponseMapper mapper, Decoder decoder) { + return (Builder) super.mapAndDecode(mapper, 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 000000000..85b0c6f98 --- /dev/null +++ b/hystrix/src/main/java/feign/hystrix/HystrixInvocationHandler.java @@ -0,0 +1,211 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Target; +import feign.Util; +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 if (isReturnsCompletableFuture(method)) { + return ((Future) result).get(); + } else { + return result; + } + } catch (IllegalAccessException e) { + // shouldn't happen as method is public due to being an interface + throw new AssertionError(e); + } catch (InvocationTargetException | ExecutionException e) { + // Exceptions on fallback are tossed by Hystrix + throw new AssertionError(e.getCause()); + } catch (InterruptedException e) { + // Exceptions on fallback are tossed by Hystrix + Thread.currentThread().interrupt(); + throw new AssertionError(e.getCause()); + } + } + }; + + if (Util.isDefault(method)) { + return hystrixCommand.execute(); + } else 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(); + } else if (isReturnsCompletableFuture(method)) { + return new ObservableCompletableFuture<>(hystrixCommand); + } + 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 isReturnsCompletableFuture(Method method) { + return CompletableFuture.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/ObservableCompletableFuture.java b/hystrix/src/main/java/feign/hystrix/ObservableCompletableFuture.java new file mode 100644 index 000000000..7cbb815d5 --- /dev/null +++ b/hystrix/src/main/java/feign/hystrix/ObservableCompletableFuture.java @@ -0,0 +1,35 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 rx.Subscription; +import java.util.concurrent.CompletableFuture; + +final class ObservableCompletableFuture extends CompletableFuture { + + private final Subscription sub; + + ObservableCompletableFuture(final HystrixCommand command) { + this.sub = command.toObservable().single().subscribe(ObservableCompletableFuture.this::complete, + ObservableCompletableFuture.this::completeExceptionally); + } + + + @Override + public boolean cancel(final boolean b) { + sub.unsubscribe(); + return super.cancel(b); + } +} 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 000000000..d8f6188c9 --- /dev/null +++ b/hystrix/src/main/java/feign/hystrix/SetterFactory.java @@ -0,0 +1,58 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.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)); + } + } +} 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 000000000..87701eb6d --- /dev/null +++ b/hystrix/src/test/java/feign/hystrix/FallbackFactoryTest.java @@ -0,0 +1,175 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 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 000000000..9edb48753 --- /dev/null +++ b/hystrix/src/test/java/feign/hystrix/HystrixBuilderTest.java @@ -0,0 +1,818 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.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 java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +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 defaultMethodReturningHystrixCommand() { + server.enqueue(new MockResponse().setBody("\"foo\"")); + + TestInterface api = target(); + + HystrixCommand command = api.defaultMethodReturningCommand(); + + assertThat(command).isNotNull(); + assertThat(command.execute()).isEqualTo("foo"); + } + + @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 completableFutureEmptyBody() + throws InterruptedException, ExecutionException, TimeoutException { + server.enqueue(new MockResponse()); + + TestInterface api = target(); + + CompletableFuture completable = api.completableFuture(); + + assertThat(completable).isNotNull(); + + completable.get(5, TimeUnit.SECONDS); + } + + @Test + public void completableFutureWithBody() + throws InterruptedException, ExecutionException, TimeoutException { + server.enqueue(new MockResponse().setBody("foo")); + + TestInterface api = target(); + + CompletableFuture completable = api.completableFuture(); + + assertThat(completable).isNotNull(); + + assertThat(completable.get(5, TimeUnit.SECONDS)).isEqualTo("foo"); + } + + @Test + public void completableFutureFailWithoutFallback() throws TimeoutException, InterruptedException { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = HystrixFeign.builder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + CompletableFuture completable = api.completableFuture(); + + assertThat(completable).isNotNull(); + + try { + completable.get(5, TimeUnit.SECONDS); + } catch (ExecutionException e) { + assertThat(e).hasCauseInstanceOf(HystrixRuntimeException.class); + } + } + + @Test + public void completableFutureFallback() + throws InterruptedException, ExecutionException, TimeoutException { + server.enqueue(new MockResponse().setResponseCode(500)); + + TestInterface api = target(); + + CompletableFuture completable = api.completableFuture(); + + assertThat(completable).isNotNull(); + + assertThat(completable.get(5, TimeUnit.SECONDS)).isEqualTo("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(); + + default HystrixCommand defaultMethodReturningCommand() { + return 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(); + + @RequestLine("GET /") + CompletableFuture completableFuture(); + } + + 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(); + } + + @Override + public CompletableFuture completableFuture() { + return CompletableFuture.completedFuture("fallback"); + } + } +} 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 000000000..00d620f5d --- /dev/null +++ b/hystrix/src/test/java/feign/hystrix/SetterFactoryTest.java @@ -0,0 +1,60 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.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 000000000..a9bdbf4ca --- /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/pom.xml b/jackson-jaxb/pom.xml new file mode 100644 index 000000000..74dc6b2a7 --- /dev/null +++ b/jackson-jaxb/pom.xml @@ -0,0 +1,100 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.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 + + + + + + + 11 + + + + + + javax.xml.bind + jaxb-api + 2.3.1 + + + org.glassfish.jaxb + jaxb-runtime + 2.4.0-b180830.0438 + 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 000000000..c4366f585 --- /dev/null +++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java @@ -0,0 +1,47 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.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 000000000..6abf2522a --- /dev/null +++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java @@ -0,0 +1,52 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.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 000000000..caa7c957b --- /dev/null +++ b/jackson-jaxb/src/test/java/feign/jackson/jaxb/JacksonJaxbCodecTest.java @@ -0,0 +1,98 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.jaxb; + +import static feign.Util.UTF_8; +import static feign.assertj.FeignAssertions.assertThat; +import feign.Request; +import feign.Request.HttpMethod; +import feign.RequestTemplate; +import feign.Response; +import feign.Util; +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 org.junit.Test; + +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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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 000000000..8be632779 --- /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/pom.xml b/jackson/pom.xml new file mode 100644 index 000000000..7b5fed9a6 --- /dev/null +++ b/jackson/pom.xml @@ -0,0 +1,53 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.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 000000000..4c7992edf --- /dev/null +++ b/jackson/src/main/java/feign/jackson/JacksonDecoder.java @@ -0,0 +1,71 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..125ac5dd6 --- /dev/null +++ b/jackson/src/main/java/feign/jackson/JacksonEncoder.java @@ -0,0 +1,56 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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/main/java/feign/jackson/JacksonIteratorDecoder.java b/jackson/src/main/java/feign/jackson/JacksonIteratorDecoder.java new file mode 100644 index 000000000..86d36d69f --- /dev/null +++ b/jackson/src/main/java/feign/jackson/JacksonIteratorDecoder.java @@ -0,0 +1,175 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.RuntimeJsonMappingException; +import feign.Response; +import feign.Util; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.Iterator; +import static feign.Util.ensureClosed; + +/** + * Jackson decoder which return a closeable iterator. Returned iterator auto-close the + * {@code Response} when it reached json array end or failed to parse stream. If this iterator is + * not fetched till the end, it has to be casted to {@code Closeable} and explicity + * {@code Closeable#close} by the consumer. + *

+ *

+ *

+ * Example:
+ * + *

+ * 
+ * Feign.builder()
+ *   .decoder(JacksonIteratorDecoder.create())
+ *   .doNotCloseAfterDecode() // Required to fetch the iterator after the response is processed, need to be close
+ *   .target(GitHub.class, "https://api.github.com");
+ * interface GitHub {
+ *  {@literal @}RequestLine("GET /repos/{owner}/{repo}/contributors")
+ *   Iterator contributors(@Param("owner") String owner, @Param("repo") String repo);
+ * }
+ * 
+ */ +public final class JacksonIteratorDecoder implements Decoder { + + private final ObjectMapper mapper; + + JacksonIteratorDecoder(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 new JacksonIterator(actualIteratorTypeArgument(type), mapper, response, + reader); + } catch (RuntimeJsonMappingException e) { + if (e.getCause() != null && e.getCause() instanceof IOException) { + throw IOException.class.cast(e.getCause()); + } + throw e; + } + } + + private static Type actualIteratorTypeArgument(Type type) { + if (!(type instanceof ParameterizedType)) { + throw new IllegalArgumentException("Not supported type " + type.toString()); + } + ParameterizedType parameterizedType = (ParameterizedType) type; + if (!Iterator.class.equals(parameterizedType.getRawType())) { + throw new IllegalArgumentException( + "Not an iterator type " + parameterizedType.getRawType().toString()); + } + return ((ParameterizedType) type).getActualTypeArguments()[0]; + } + + public static JacksonIteratorDecoder create() { + return create(Collections.emptyList()); + } + + public static JacksonIteratorDecoder create(Iterable modules) { + return new JacksonIteratorDecoder(new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerModules(modules)); + } + + public static JacksonIteratorDecoder create(ObjectMapper objectMapper) { + return new JacksonIteratorDecoder(objectMapper); + } + + static final class JacksonIterator implements Iterator, Closeable { + private final Response response; + private final JsonParser parser; + private final ObjectReader objectReader; + + private T current; + + JacksonIterator(Type type, ObjectMapper mapper, Response response, Reader reader) + throws IOException { + this.response = response; + this.parser = mapper.getFactory().createParser(reader); + this.objectReader = mapper.reader().forType(mapper.constructType(type)); + } + + @Override + public boolean hasNext() { + try { + JsonToken jsonToken = parser.nextToken(); + if (jsonToken == null) { + return false; + } + + if (jsonToken == JsonToken.START_ARRAY) { + jsonToken = parser.nextToken(); + } + + if (jsonToken == JsonToken.END_ARRAY) { + current = null; + ensureClosed(this); + return false; + } + + current = objectReader.readValue(parser); + } catch (IOException e) { + // Input Stream closed automatically by parser + throw new DecodeException(response.status(), e.getMessage(), e); + } + return current != null; + } + + @Override + public T next() { + return current; + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + + @Override + public void close() throws IOException { + ensureClosed(this.response); + } + } +} 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 000000000..d1f9bfb2b --- /dev/null +++ b/jackson/src/test/java/feign/jackson/JacksonCodecTest.java @@ -0,0 +1,305 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.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 feign.Request; +import feign.Request.HttpMethod; +import feign.Util; +import org.junit.Test; +import java.io.Closeable; +import java.io.IOException; +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.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; +import static org.junit.Assert.assertTrue; + +public class JacksonCodecTest { + + private String zonesJson = ""// + + "[" + System.lineSeparator() // + + " {" + System.lineSeparator() // + + " \"name\": \"denominator.io.\"" + System.lineSeparator()// + + " }," + System.lineSeparator()// + + " {" + System.lineSeparator()// + + " \"name\": \"denominator.io.\"," + System.lineSeparator()// + + " \"id\": \"ABCD\"" + System.lineSeparator()// + + " }" + System.lineSeparator()// + + "]" + System.lineSeparator(); + + @Test + public void encodesMapObjectNumericalValuesAsInteger() { + Map map = new LinkedHashMap(); + map.put("foo", 1); + + RequestTemplate template = new RequestTemplate(); + new JacksonEncoder().encode(map, map.getClass(), template); + + assertThat(template).hasBody(""// + + "{" + System.lineSeparator() // + + " \"foo\" : 1" + System.lineSeparator() // + + "}"); + } + + @Test + public void encodesFormParams() { + 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(""// + + "{" + System.lineSeparator() // + + " \"foo\" : 1," + System.lineSeparator() // + + " \"bar\" : [ 2, 3 ]" + System.lineSeparator() // + + "}"); + } + + @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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(zonesJson, UTF_8) + .build(); + assertEquals(zones, decoder.decode(response, new TypeReference>() {}.getType())); + } + + @Test + public void customEncoder() { + 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("" // + + "[ {" + System.lineSeparator() + + " \"name\" : \"DENOMINATOR.IO.\"" + System.lineSeparator() + + "}, {" + System.lineSeparator() + + " \"name\" : \"DENOMINATOR.IO.\"," + System.lineSeparator() + + " \"id\" : \"ABCD\"" + System.lineSeparator() + + "} ]"); + } + + @Test + public void decodesIterator() 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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(zonesJson, UTF_8) + .build(); + Object decoded = JacksonIteratorDecoder.create().decode(response, + new TypeReference>() {}.getType()); + assertTrue(Iterator.class.isAssignableFrom(decoded.getClass())); + assertTrue(Closeable.class.isAssignableFrom(decoded.getClass())); + assertEquals(zones, asList((Iterator) decoded)); + } + + private List asList(Iterator iter) { + final List copy = new ArrayList(); + while (iter.hasNext()) + copy.add(iter.next()); + return copy; + } + + @Test + public void nullBodyDecodesToNullIterator() throws Exception { + Response response = Response.builder() + .status(204) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .build(); + assertNull(JacksonIteratorDecoder.create().decode(response, Iterator.class)); + } + + @Test + public void emptyBodyDecodesToNullIterator() throws Exception { + Response response = Response.builder() + .status(204) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(new byte[0]) + .build(); + assertNull(JacksonIteratorDecoder.create().decode(response, Iterator.class)); + } + + 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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .build(); + assertThat((byte[]) new JacksonDecoder().decode(response, byte[].class)).isEmpty(); + } + + /** Enabled via {@link feign.Feign.Builder#decode404()} */ + @Test + public void notFoundDecodesToEmptyIterator() throws Exception { + Response response = Response.builder() + .status(404) + .reason("NOT FOUND") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .build(); + assertThat((byte[]) JacksonIteratorDecoder.create().decode(response, byte[].class)).isEmpty(); + } +} diff --git a/jackson/src/test/java/feign/jackson/JacksonIteratorTest.java b/jackson/src/test/java/feign/jackson/JacksonIteratorTest.java new file mode 100644 index 000000000..7097fb213 --- /dev/null +++ b/jackson/src/test/java/feign/jackson/JacksonIteratorTest.java @@ -0,0 +1,157 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 static feign.Util.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.core.Is.isA; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Request; +import feign.Request.HttpMethod; +import feign.Response; +import feign.Util; +import feign.codec.DecodeException; +import feign.jackson.JacksonIteratorDecoder.JacksonIterator; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +public class JacksonIteratorTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void shouldDecodePrimitiveArrays() throws IOException { + assertThat(iterator(Integer.class, "[0,1,2,3]")).containsExactly(0, 1, 2, 3); + } + + @Test + public void shouldDecodeObjects() throws IOException { + assertThat(iterator(User.class, "[{\"login\":\"bob\"},{\"login\":\"joe\"}]")) + .containsExactly(new User("bob"), new User("joe")); + } + + @Test + public void malformedObjectThrowsDecodeException() throws IOException { + thrown.expect(DecodeException.class); + thrown.expectCause(isA(IOException.class)); + + assertThat(iterator(User.class, "[{\"login\":\"bob\"},{\"login\":\"joe...")) + .containsOnly(new User("bob")); + } + + @Test + public void emptyBodyDecodesToEmptyIterator() throws IOException { + assertThat(iterator(String.class, "")).isEmpty(); + } + + @Test + public void unmodifiable() throws IOException { + thrown.expect(UnsupportedOperationException.class); + + JacksonIterator it = iterator(String.class, "[\"test\"]"); + + assertThat(it).containsExactly("test"); + it.remove(); + } + + @Test + public void responseIsClosedAfterIteration() throws IOException { + final AtomicBoolean closed = new AtomicBoolean(); + + byte[] jsonBytes = "[false, true]".getBytes(UTF_8); + InputStream inputStream = new ByteArrayInputStream(jsonBytes) { + @Override + public void close() throws IOException { + closed.set(true); + super.close(); + } + }; + Response response = Response.builder() + .status(200) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(inputStream, jsonBytes.length) + .build(); + + assertThat(iterator(Boolean.class, response)).hasSize(2); + assertThat(closed.get()).isTrue(); + } + + @Test + public void responseIsClosedOnParseError() throws IOException { + final AtomicBoolean closed = new AtomicBoolean(); + + byte[] jsonBytes = "[error".getBytes(UTF_8); + InputStream inputStream = new ByteArrayInputStream(jsonBytes) { + @Override + public void close() throws IOException { + closed.set(true); + super.close(); + } + }; + Response response = Response.builder() + .status(200) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(inputStream, jsonBytes.length) + .build(); + + try { + thrown.expect(DecodeException.class); + assertThat(iterator(Boolean.class, response)).hasSize(1); + } finally { + assertThat(closed.get()).isTrue(); + } + } + + static class User extends LinkedHashMap { + + private static final long serialVersionUID = 1L; + + User() { + // for reflective instantiation. + } + + User(String login) { + put("login", login); + } + } + + JacksonIterator iterator(Class type, String json) throws IOException { + Response response = Response.builder() + .status(200) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(json, UTF_8) + .build(); + return iterator(type, response); + } + + JacksonIterator iterator(Class type, Response response) throws IOException { + return new JacksonIterator(type.getGenericSuperclass(), new ObjectMapper(), + response, response.body().asReader()); + } + +} 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 000000000..408899212 --- /dev/null +++ b/jackson/src/test/java/feign/jackson/examples/GitHubExample.java @@ -0,0 +1,58 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.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/jackson/src/test/java/feign/jackson/examples/GitHubIteratorExample.java b/jackson/src/test/java/feign/jackson/examples/GitHubIteratorExample.java new file mode 100644 index 000000000..7dd683c2a --- /dev/null +++ b/jackson/src/test/java/feign/jackson/examples/GitHubIteratorExample.java @@ -0,0 +1,66 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.examples; + +import feign.Feign; +import feign.Param; +import feign.RequestLine; +import feign.jackson.JacksonIteratorDecoder; +import java.io.Closeable; +import java.io.IOException; +import java.util.Iterator; + +/** + * adapted from {@code com.example.retrofit.GitHubClient} + */ +public class GitHubIteratorExample { + + public static void main(String... args) throws IOException { + GitHub github = Feign.builder() + .decoder(JacksonIteratorDecoder.create()) + .doNotCloseAfterDecode() + .target(GitHub.class, "https://api.github.com"); + + System.out.println("Let's fetch and print a list of the contributors to this library."); + Iterator contributors = github.contributors("OpenFeign", "feign"); + try { + while (contributors.hasNext()) { + Contributor contributor = contributors.next(); + System.out.println(contributor.login + " (" + contributor.contributions + ")"); + } + } finally { + ((Closeable) contributors).close(); + } + } + + interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + Iterator 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/java11/README.md b/java11/README.md new file mode 100644 index 000000000..2adb051c7 --- /dev/null +++ b/java11/README.md @@ -0,0 +1,11 @@ +# feign-java11 + +This module directs Feign's http requests to Java11 [New HTTP/2 Client](http://www.javamagazine.mozaicreader.com/JulyAug2017#&pageSet=39&page=0) that implements HTTP/2. + +To use New HTTP/2 Client with Feign, use Java SDK 11. Then, configure Feign to use the Http2Client: + +```java +GitHub github = Feign.builder() + .client(new Http2Client()) + .target(GitHub.class, "https://api.github.com"); +``` diff --git a/java11/pom.xml b/java11/pom.xml new file mode 100644 index 000000000..34323712a --- /dev/null +++ b/java11/pom.xml @@ -0,0 +1,87 @@ + + + + 4.0.0 + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + + feign-java11 + Feign Java 11 + Feign Java 11 + + + + 11 + java18 + ${project.basedir}/.. + 11 + 11 + + + + + ${project.groupId} + feign-core + + + ${project.groupId} + feign-jackson + test + + + com.squareup.okhttp3 + mockwebserver + test + + + + org.assertj + assertj-core + test + + + junit + junit + test + + + io.github.openfeign + feign-core + ${project.version} + tests + jar + test + + + + + + + org.codehaus.mojo + animal-sniffer-maven-plugin + + + true + + + + + + diff --git a/java11/src/main/java/feign/http2client/Http2Client.java b/java11/src/main/java/feign/http2client/Http2Client.java new file mode 100644 index 000000000..d92c65877 --- /dev/null +++ b/java11/src/main/java/feign/http2client/Http2Client.java @@ -0,0 +1,158 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.http2client; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpClient.Redirect; +import java.net.http.HttpClient.Version; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublisher; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpRequest.Builder; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; +import feign.*; +import feign.Request.Options; + +public class Http2Client implements Client { + + private final HttpClient client; + + public Http2Client() { + this(HttpClient.newBuilder() + .followRedirects(Redirect.ALWAYS) + .version(Version.HTTP_2) + .build()); + } + + public Http2Client(HttpClient client) { + this.client = Util.checkNotNull(client, "HttpClient must not be null"); + } + + @Override + public Response execute(Request request, Options options) throws IOException { + final HttpRequest httpRequest = newRequestBuilder(request).build(); + + HttpResponse httpResponse; + try { + httpResponse = client.send(httpRequest, BodyHandlers.ofByteArray()); + } catch (final InterruptedException e) { + throw new IOException("Invalid uri " + request.url(), e); + } + + final OptionalLong length = httpResponse.headers().firstValueAsLong("Content-Length"); + + final Response response = Response.builder() + .body(new ByteArrayInputStream(httpResponse.body()), + length.isPresent() ? (int) length.getAsLong() : null) + .reason(httpResponse.headers().firstValue("Reason-Phrase").orElse("OK")) + .request(request) + .status(httpResponse.statusCode()) + .headers(castMapCollectType(httpResponse.headers().map())) + .build(); + return response; + } + + private Builder newRequestBuilder(Request request) throws IOException { + URI uri; + try { + uri = new URI(request.url()); + } catch (final URISyntaxException e) { + throw new IOException("Invalid uri " + request.url(), e); + } + + final BodyPublisher body; + if (request.body() == null) { + body = BodyPublishers.noBody(); + } else { + body = BodyPublishers.ofByteArray(request.body()); + } + + final Builder requestBuilder = HttpRequest.newBuilder() + .uri(uri) + .version(Version.HTTP_2); + + final Map> headers = filterRestrictedHeaders(request.headers()); + if (!headers.isEmpty()) { + requestBuilder.headers(asString(headers)); + } + + switch (request.httpMethod()) { + case GET: + return requestBuilder.GET(); + case POST: + return requestBuilder.POST(body); + case PUT: + return requestBuilder.PUT(body); + case DELETE: + return requestBuilder.DELETE(); + default: + // fall back scenario, http implementations may restrict some methods + return requestBuilder.method(request.httpMethod().toString(), body); + } + + } + + /** + * There is a bunch o headers that the http2 client do not allow to be set. + * + * @see jdk.internal.net.http.common.Utils.DISALLOWED_HEADERS_SET + */ + private static final Set DISALLOWED_HEADERS_SET; + + static { + // A case insensitive TreeSet of strings. + final TreeSet treeSet = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + treeSet.addAll(Set.of("connection", "content-length", "date", "expect", "from", "host", + "origin", "referer", "upgrade", "via", "warning")); + DISALLOWED_HEADERS_SET = Collections.unmodifiableSet(treeSet); + } + + private Map> filterRestrictedHeaders(Map> headers) { + final Map> filteredHeaders = headers.keySet() + .stream() + .filter(headerName -> !DISALLOWED_HEADERS_SET.contains(headerName)) + .collect(Collectors.toMap( + Function.identity(), + headers::get)); + + filteredHeaders.computeIfAbsent("Accept", key -> List.of("*/*")); + + return filteredHeaders; + } + + private Map> castMapCollectType(Map> map) { + final Map> result = new HashMap<>(); + map.forEach((key, value) -> result.put(key, new HashSet<>(value))); + return result; + } + + private String[] asString(Map> headers) { + return headers.entrySet().stream() + .flatMap(entry -> entry.getValue() + .stream() + .map(value -> Arrays.asList(entry.getKey(), value)) + .flatMap(List::stream)) + .collect(Collectors.toList()) + .toArray(new String[0]); + } + +} diff --git a/java11/src/test/java/feign/http2client/test/Http2ClientTest.java b/java11/src/test/java/feign/http2client/test/Http2ClientTest.java new file mode 100644 index 000000000..6b343974d --- /dev/null +++ b/java11/src/test/java/feign/http2client/test/Http2ClientTest.java @@ -0,0 +1,88 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.http2client.test; + +import static org.assertj.core.api.Assertions.assertThat; +import org.assertj.core.api.Assertions; +import org.junit.Ignore; +import org.junit.Test; +import java.io.IOException; +import feign.*; +import feign.client.AbstractClientTest; +import feign.http2client.Http2Client; +import okhttp3.mockwebserver.MockResponse; + +/** + * Tests client-specific behavior, such as ensuring Content-Length is sent when specified. + */ +@Ignore +public class Http2ClientTest extends AbstractClientTest { + + public interface TestInterface { + @RequestLine("PATCH /patch") + @Headers({"Accept: text/plain"}) + String patch(String var1); + + @RequestLine("PATCH /patch") + @Headers({"Accept: text/plain"}) + String patch(); + } + + @Override + @Test + public void testPatch() throws Exception { + final TestInterface api = + newBuilder().target(TestInterface.class, "https://nghttp2.org/httpbin/"); + Assertions.assertThat(api.patch("")) + .contains("https://nghttp2.org/httpbin/patch"); + } + + @Override + @Test + public void noResponseBodyForPatch() { + final TestInterface api = + newBuilder().target(TestInterface.class, "https://nghttp2.org/httpbin/"); + Assertions.assertThat(api.patch()) + .contains("https://nghttp2.org/httpbin/patch"); + } + + @Override + @Test + public void reasonPhraseIsOptional() throws IOException, InterruptedException { + server.enqueue(new MockResponse() + .addHeader("Reason-Phrase", "There is A reason") + .setStatus("HTTP/1.1 " + 200)); + + final AbstractClientTest.TestInterface api = newBuilder() + .target(AbstractClientTest.TestInterface.class, "http://localhost:" + server.getPort()); + + final Response response = api.post("foo"); + + assertThat(response.status()).isEqualTo(200); + assertThat(response.reason()).isEqualTo("There is A reason"); + } + + + @Override + @Test + public void testVeryLongResponseNullLength() { + // client is too smart to fall for a body that is 8 bytes long + } + + @Override + public Feign.Builder newBuilder() { + return Feign.builder().client(new Http2Client()); + } + +} diff --git a/java8/pom.xml b/java8/pom.xml new file mode 100644 index 000000000..eb3755da8 --- /dev/null +++ b/java8/pom.xml @@ -0,0 +1,40 @@ + + + + 4.0.0 + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + + + feign-java8 + Feign Java 8 + Feign Java 8 + + + ${project.basedir}/.. + + + + + feign-core + + + + diff --git a/jaxb/README.md b/jaxb/README.md new file mode 100644 index 000000000..35d57b884 --- /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/pom.xml b/jaxb/pom.xml new file mode 100644 index 000000000..4047e2359 --- /dev/null +++ b/jaxb/pom.xml @@ -0,0 +1,75 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + + feign-jaxb + Feign JAXB + Feign JAXB + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + ${project.groupId} + feign-core + test-jar + test + + + + + + java11 + + 11 + + + + + + javax.xml.bind + jaxb-api + 2.3.1 + + + org.glassfish.jaxb + jaxb-runtime + 2.4.0-b180830.0438 + 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 000000000..d6105eb28 --- /dev/null +++ b/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java @@ -0,0 +1,175 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.List; +import java.util.Map; +import java.util.Map.Entry; +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. Since JAXB contexts creation can be an expensive task, JAXB context can be preloaded on + * factory creation otherwise they will be created and cached dynamically when needed. + */ +public final class JAXBContextFactory { + + private final ConcurrentHashMap, JAXBContext> 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 { + return getContext(clazz).createUnmarshaller(); + } + + /** + * Creates a new {@link javax.xml.bind.Marshaller} that handles the supplied class. + */ + public Marshaller createMarshaller(Class clazz) throws JAXBException { + Marshaller marshaller = getContext(clazz).createMarshaller(); + setMarshallerProperties(marshaller); + return marshaller; + } + + private void setMarshallerProperties(Marshaller marshaller) throws PropertyException { + for (Entry en : properties.entrySet()) { + marshaller.setProperty(en.getKey(), en.getValue()); + } + } + + 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; + } + + /** + * Will preload factory's cache with JAXBContext for provided classes + * + * @param classes + * @throws JAXBException + */ + private void preloadContextCache(List> classes) throws JAXBException { + if (classes != null && !classes.isEmpty()) { + for (Class clazz : classes) { + getContext(clazz); + } + } + } + + /** + * Creates instances of {@link feign.jaxb.JAXBContextFactory}. + */ + public static class Builder { + + private final Map properties = new HashMap<>(10); + + /** + * 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; + } + + /** + * Sets the given property of any Marshaller created by this factory. + * + *

+ * Example :
+ *
+ * + * new JAXBContextFactory.Builder() + * .withProperty("com.sun.xml.internal.bind.xmlHeaders", "<!DOCTYPE Example SYSTEM \"example.dtd\">") + * .build(); + * + *

+ */ + public Builder withProperty(String key, Object value) { + properties.put(key, value); + return this; + } + + /** + * Creates a new {@link feign.jaxb.JAXBContextFactory} instance with a lazy loading cached + * context + */ + public JAXBContextFactory build() { + return new JAXBContextFactory(properties); + } + + /** + * Creates a new {@link feign.jaxb.JAXBContextFactory} instance. Pre-loads context cache with + * given classes + * + * @param classes + * @return ContextFactory with a pre-populated JAXBContext cache + * @throws JAXBException if provided classes can't be used for JAXBContext generation most + * likely due to missing JAXB annotations + */ + public JAXBContextFactory build(List> classes) throws JAXBException { + JAXBContextFactory factory = new JAXBContextFactory(properties); + factory.preloadContextCache(classes); + return factory; + } + } +} 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 000000000..158835f09 --- /dev/null +++ b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java @@ -0,0 +1,127 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.ParameterizedType; +import java.lang.reflect.Type; +import javax.xml.bind.JAXBException; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParserFactory; +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 || response.status() == 204) + return Util.emptyValueOf(type); + if (response.body() == null) + return null; + while (type instanceof ParameterizedType) { + ParameterizedType ptype = (ParameterizedType) type; + type = ptype.getRawType(); + } + 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); + + return jaxbContextFactory.createUnmarshaller((Class) type).unmarshal(new SAXSource( + saxParserFactory.newSAXParser().getXMLReader(), + new InputSource(response.body().asInputStream()))); + } catch (JAXBException | ParserConfigurationException | SAXException e) { + throw new DecodeException(response.status(), 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 000000000..77a59476f --- /dev/null +++ b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java @@ -0,0 +1,67 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..d4e6cfa7f --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java @@ -0,0 +1,279 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 static feign.Util.UTF_8; +import static feign.assertj.FeignAssertions.assertThat; +import static org.junit.Assert.assertEquals; +import feign.Request; +import feign.Request.HttpMethod; +import feign.RequestTemplate; +import feign.Response; +import feign.Util; +import feign.codec.Encoder; +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 org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +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); + + // RequestTemplate always expects a UNIX style newline. + assertThat(template).hasBody( + new StringBuilder().append("") + .append("\n") + .append("") + .append("\n") + .append(" Test") + .append("\n") + .append("") + .append("\n") + .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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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(feign.codec.DecodeException.class); + thrown.expectMessage( + "java.util.Map is an interface, and JAXB can't handle interfaces.\n" + + "\tthis problem is related to the following location:\n" + + "\t\tat java.util.Map"); + + class ParameterizedHolder { + + Map field; + } + Type parameterized = ParameterizedHolder.class.getDeclaredField("field").getGenericType(); + + Response response = Response.builder() + .status(200) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.>emptyMap()) + .body("", UTF_8) + .build(); + + new JAXBDecoder(new JAXBContextFactory.Builder().build()).decode(response, parameterized); + } + + @XmlRootElement + static class Box { + + @XmlElement + private T t; + + public void set(T t) { + this.t = t; + } + + } + + @Test + public void decodeAnnotatedParameterizedTypes() throws Exception { + JAXBContextFactory jaxbContextFactory = + new JAXBContextFactory.Builder().withMarshallerFormattedOutput(true).build(); + + Encoder encoder = new JAXBEncoder(jaxbContextFactory); + + Box boxStr = new Box<>(); + boxStr.set("hello"); + Box> boxBoxStr = new Box<>(); + boxBoxStr.set(boxStr); + RequestTemplate template = new RequestTemplate(); + encoder.encode(boxBoxStr, Box.class, template); + + Response response = Response.builder() + .status(200) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.>emptyMap()) + .body(template.body()) + .build(); + + new JAXBDecoder(new JAXBContextFactory.Builder().build()).decode(response, Box.class); + + } + + /** + * Enabled via {@link feign.Feign.Builder#decode404()} + */ + @Test + public void notFoundDecodesToEmpty() throws Exception { + Response response = Response.builder() + .status(404) + .reason("NOT FOUND") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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 000000000..00b77b556 --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java @@ -0,0 +1,96 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import javax.xml.bind.Marshaller; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +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)); + } + + @Test + public void testPreloadCache() throws Exception { + + List> classes = Arrays.asList(String.class, Integer.class); + JAXBContextFactory factory = + new JAXBContextFactory.Builder().build(classes); + + Field f = factory.getClass().getDeclaredField("jaxbContexts"); // NoSuchFieldException + f.setAccessible(true); + Map internalCache = (Map) f.get(factory); // IllegalAccessException + assertFalse(internalCache.isEmpty()); + assertTrue(internalCache.size() == classes.size()); + assertNotNull(internalCache.get(String.class)); + assertNotNull(internalCache.get(Integer.class)); + + } + +} 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 000000000..d7321e74e --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/examples/AWSSignatureVersion4.java @@ -0,0 +1,160 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.requestBody().asString(); + 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 000000000..e386928cf --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/examples/IAMExample.java @@ -0,0 +1,97 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.target(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 000000000..b6dabb29b --- /dev/null +++ b/jaxb/src/test/java/feign/jaxb/examples/package-info.java @@ -0,0 +1,16 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT 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 000000000..966ed66c5 --- /dev/null +++ b/jaxrs/README.md @@ -0,0 +1,35 @@ +# 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). + +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 all values into 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/pom.xml b/jaxrs/pom.xml new file mode 100644 index 000000000..68e40d3c6 --- /dev/null +++ b/jaxrs/pom.xml @@ -0,0 +1,75 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.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 + + + + + + + maven-jar-plugin + + + + test-jar + + + + + + + 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 000000000..2a6d33f1f --- /dev/null +++ b/jaxrs/src/main/java/feign/jaxrs/JAXRSContract.java @@ -0,0 +1,185 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 feign.Contract; +import feign.MethodMetadata; +import feign.Request; +import javax.ws.rs.*; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import static feign.Util.checkState; +import static feign.Util.emptyToNull; +import static feign.Util.removeValues; + +/** + * Please refer to the Feign + * JAX-RS README. + */ +public 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 && !path.value().isEmpty()) { + String pathValue = path.value(); + 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); + } + // jax-rs allows whitespace around the param name, as well as an optional regex. The contract + // should + // strip these out appropriately. + pathValue = pathValue.replaceAll("\\{\\s*(.+?)\\s*(:.+?)?\\}", "\\{$1\\}"); + data.template().uri(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(Request.HttpMethod.valueOf(http.value())); + } else if (annotationType == Path.class) { + String pathValue = emptyToNull(Path.class.cast(methodAnnotation).value()); + if (pathValue == null) { + return; + } + 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().uri(methodAnnotationValue, true); + } 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 = + removeValues(produces.value(), (mediaType) -> emptyToNull(mediaType) == null, String.class); + checkState(serverProduces.length > 0, "Produces.value() was empty on %s", name); + data.template().header(ACCEPT, Collections.emptyList()); // remove any previous produces + data.template().header(ACCEPT, serverProduces); + } + + private void handleConsumesAnnotation(MethodMetadata data, Consumes consumes, String name) { + String[] serverConsumes = + removeValues(consumes.value(), (mediaType) -> emptyToNull(mediaType) == null, String.class); + checkState(serverConsumes.length > 0, "Consumes.value() was empty on %s", name); + data.template().header(CONTENT_TYPE, Collections.emptyList()); // remove any previous consumes + data.template().header(CONTENT_TYPE, serverConsumes[0]); + } + + /** + * Allows derived contracts to specify unsupported jax-rs parameter annotations which should be + * ignored. Required for JAX-RS 2 compatibility. + */ + protected boolean isUnsupportedHttpParameterAnnotation(Annotation parameterAnnotation) { + return false; + } + + @Override + protected boolean processAnnotationsOnParameter(MethodMetadata data, + Annotation[] annotations, + int paramIndex) { + boolean isHttpParam = false; + for (Annotation parameterAnnotation : annotations) { + Class annotationType = parameterAnnotation.annotationType(); + // masc20180327. parameter with unsupported jax-rs annotations should not be passed as body + // params. + // this will prevent interfaces from becoming unusable entirely due to single (unsupported) + // endpoints. + // https://github.com/OpenFeign/feign/issues/669 + if (this.isUnsupportedHttpParameterAnnotation(parameterAnnotation)) { + isHttpParam = true; + } else 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); + String query = addTemplatedParam(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); + String header = addTemplatedParam(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. + private String addTemplatedParam(String name) { + return String.format("{%s}", name); + } +} 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 000000000..56940e64a --- /dev/null +++ b/jaxrs/src/test/java/feign/jaxrs/JAXRSContractTest.java @@ -0,0 +1,664 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +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 = createContract(); + + protected JAXRSContract createContract() { + return 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()) + .hasPath("/") + .hasQueries(); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "one").template()) + .hasPath("/") + .hasQueries( + entry("Action", asList("GetUser"))); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "two").template()) + .hasPath("/") + .hasQueries( + entry("Action", asList("GetUser")), + entry("Version", asList("2010-05-08"))); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "three").template()) + .hasPath("/") + .hasQueries( + entry("Action", asList("GetUser")), + entry("Version", asList("2010-05-08")), + entry("limit", asList("1"))); + + assertThat(parseAndValidateMetadata(WithQueryParamsInPath.class, "empty").template()) + .hasPath("/") + .hasQueries( + entry("flag", new ArrayList<>()), + entry("Action", asList("GetUser")), + entry("Version", asList("2010-05-08"))); + } + + @Test + public void producesAddsAcceptHeader() throws Exception { + MethodMetadata md = parseAndValidateMetadata(ProducesAndConsumes.class, "produces"); + + /* multiple @Produces annotations should be additive */ + assertThat(md.template()) + .hasHeaders( + entry("Content-Type", asList("application/json")), + entry("Accept", asList("application/xml"))); + } + + @Test + public void producesMultipleAddsAcceptHeader() throws Exception { + MethodMetadata md = parseAndValidateMetadata(ProducesAndConsumes.class, "producesMultiple"); + + assertThat(md.template()) + .hasHeaders( + entry("Content-Type", Collections.singletonList("application/json")), + entry("Accept", asList("application/xml", "text/plain"))); + } + + @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"); + + /* multiple @Consumes annotations are additive */ + assertThat(md.template()) + .hasHeaders( + entry("Content-Type", asList("application/xml")), + entry("Accept", asList("text/html"))); + } + + @Test + public void consumesMultipleAddsContentTypeHeader() throws Exception { + MethodMetadata md = parseAndValidateMetadata(ProducesAndConsumes.class, "consumesMultiple"); + + assertThat(md.template()) + .hasHeaders(entry("Content-Type", asList("application/xml")), + entry("Accept", Collections.singletonList("text/html"))); + } + + @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(JAXRSContractTest.class.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 { + assertThat(parseAndValidateMetadata(EmptyPathOnType.class, "base").template()) + .hasUrl("/"); + } + + @Test + public void emptyPathOnTypeSpecific() throws Exception { + assertThat(parseAndValidateMetadata(EmptyPathOnType.class, "get").template()) + .hasUrl("/specific"); + } + + @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 { + assertThat(parseAndValidateMetadata(PathOnType.class, "emptyPath").template()) + .hasUrl("/base"); + } + + @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 regexPathOnMethodOrType() 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}"); + + assertThat(parseAndValidateMetadata( + ComplexPathOnType.class, "pathParamWithMultipleRegex", String.class, String.class) + .template()) + .hasUrl("/{baseparam}/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"); + } + + + @Test + public void producesWithHeaderParamContainAllHeaders() throws Exception { + assertThat(parseAndValidateMetadata(MixedAnnotations.class, "getWithHeaders", + String.class, String.class, String.class) + .template()) + .hasHeaders(entry("Accept", Arrays.asList("{Accept}", "application/json"))) + .hasQueries( + entry("multiple", Arrays.asList("stuff", "{multiple}")), + entry("another", Collections.singletonList("{another}"))); + } + + 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({"application/xml", "text/plain"}) + Response producesMultiple(); + + @GET + @Produces({}) + Response producesNada(); + + @GET + @Produces({""}) + Response producesEmpty(); + + @POST + @Consumes("application/xml") + Response consumes(); + + @POST + @Consumes({"application/xml", "application/json"}) + Response consumesMultiple(); + + @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(); + + @GET + @Path("/specific") + Response get(); + } + + @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); + } + + @Path("/{baseparam: [0-9]+}") + interface ComplexPathOnType { + + @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)); + } + + interface MixedAnnotations { + + @GET + @Path("/api/stuff?multiple=stuff") + @Produces("application/json") + Response getWithHeaders(@HeaderParam("Accept") String accept, + @QueryParam("multiple") String multiple, + @QueryParam("another") String another); + } +} 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 000000000..ec45914a7 --- /dev/null +++ b/jaxrs/src/test/java/feign/jaxrs/examples/GitHubExample.java @@ -0,0 +1,53 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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/jaxrs2/README.md b/jaxrs2/README.md new file mode 100644 index 000000000..cbdd4be7f --- /dev/null +++ b/jaxrs2/README.md @@ -0,0 +1,37 @@ +# Feign JAXRS 2 +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 all values into 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/jaxrs2/pom.xml b/jaxrs2/pom.xml new file mode 100644 index 000000000..0933e36bb --- /dev/null +++ b/jaxrs2/pom.xml @@ -0,0 +1,106 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + + feign-jaxrs2 + Feign JAX-RS 2 + Feign JAX-RS 2 + + + + 1.8 + java18 + ${project.basedir}/.. + 1.8 + 1.8 + + + + + ${project.groupId} + feign-core + + + + ${project.groupId} + feign-jaxrs + + + javax.ws.rs + jsr311-api + + + + + + javax.ws.rs + javax.ws.rs-api + 2.1 + provided + + + + + ${project.groupId} + feign-gson + test + + + + ${project.groupId} + feign-core + test-jar + test + + + ${project.groupId} + feign-jaxrs + test-jar + test + + + + org.glassfish.jersey.core + jersey-client + 2.26 + test + + + org.glassfish.jersey.inject + jersey-hk2 + 2.26 + test + + + com.squareup.okhttp3 + mockwebserver + test + + + org.hamcrest + java-hamcrest + 2.0.0.0 + + + diff --git a/jaxrs2/src/main/java/feign/jaxrs2/JAXRS2Contract.java b/jaxrs2/src/main/java/feign/jaxrs2/JAXRS2Contract.java new file mode 100644 index 000000000..13d821277 --- /dev/null +++ b/jaxrs2/src/main/java/feign/jaxrs2/JAXRS2Contract.java @@ -0,0 +1,38 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.jaxrs2; + +import javax.ws.rs.container.Suspended; +import javax.ws.rs.core.Context; +import feign.jaxrs.JAXRSContract; +import java.lang.annotation.Annotation; + +/** + * Please refer to the Feign + * JAX-RS 2 README. + */ +public final class JAXRS2Contract extends JAXRSContract { + @Override + protected boolean isUnsupportedHttpParameterAnnotation(Annotation parameterAnnotation) { + Class annotationType = parameterAnnotation.annotationType(); + + // masc20180327. parameter with unsupported jax-rs annotations should not be passed as body + // params. + // this will prevent interfaces from becoming unusable entirely due to single (unsupported) + // endpoints. + // https://github.com/OpenFeign/feign/issues/669 + return (annotationType == Suspended.class || + annotationType == Context.class); + } +} diff --git a/jaxrs2/src/main/java/feign/jaxrs2/JAXRSClient.java b/jaxrs2/src/main/java/feign/jaxrs2/JAXRSClient.java new file mode 100644 index 000000000..3d53f100a --- /dev/null +++ b/jaxrs2/src/main/java/feign/jaxrs2/JAXRSClient.java @@ -0,0 +1,134 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.jaxrs2; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.Collection; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.Entity; +import javax.ws.rs.core.*; +import feign.Client; +import feign.Request.Options; + +/** + * This module directs Feign's http requests to javax.ws.rs.client.Client . Ex: + * + *
+ * GitHub github =
+ *     Feign.builder().client(new JaxRSClient()).target(GitHub.class, "https://api.github.com");
+ * 
+ */ +public class JAXRSClient implements Client { + + private final ClientBuilder clientBuilder; + + public JAXRSClient() { + this(ClientBuilder.newBuilder()); + } + + public JAXRSClient(ClientBuilder clientBuilder) { + this.clientBuilder = clientBuilder; + } + + @Override + public feign.Response execute(feign.Request request, Options options) throws IOException { + final Response response = clientBuilder + .connectTimeout(options.connectTimeoutMillis(), TimeUnit.MILLISECONDS) + .readTimeout(options.readTimeoutMillis(), TimeUnit.MILLISECONDS) + .build() + .target(request.url()) + .request() + .headers(toMultivaluedMap(request.headers())) + .method(request.httpMethod().name(), createRequestEntity(request)); + + return feign.Response.builder() + .request(request) + .body(response.readEntity(InputStream.class), + integerHeader(response, HttpHeaders.CONTENT_LENGTH)) + .headers(toMap(response.getStringHeaders())) + .status(response.getStatus()) + .reason(response.getStatusInfo().getReasonPhrase()) + .build(); + } + + private Entity createRequestEntity(feign.Request request) { + if (request.body() == null) { + return null; + } + + return Entity.entity( + request.body(), + new Variant(mediaType(request.headers()), locale(request.headers()), + encoding(request.charset()))); + } + + private Integer integerHeader(Response response, String header) { + final MultivaluedMap headers = response.getStringHeaders(); + if (!headers.containsKey(header)) { + return null; + } + + try { + return new Integer(headers.getFirst(header)); + } catch (final NumberFormatException e) { + // not a number or too big to fit Integer + return null; + } + } + + private String encoding(Charset charset) { + if (charset == null) + return null; + + return charset.name(); + } + + private String locale(Map> headers) { + if (!headers.containsKey(HttpHeaders.CONTENT_LANGUAGE)) + return null; + + return headers.get(HttpHeaders.CONTENT_LANGUAGE).iterator().next(); + } + + private MediaType mediaType(Map> headers) { + if (!headers.containsKey(HttpHeaders.CONTENT_TYPE)) + return null; + + return MediaType.valueOf(headers.get(HttpHeaders.CONTENT_TYPE).iterator().next()); + } + + private MultivaluedMap toMultivaluedMap(Map> headers) { + final MultivaluedHashMap mvHeaders = new MultivaluedHashMap<>(); + + headers.forEach((key, value1) -> value1 + .forEach(value -> mvHeaders.add(key, value))); + + return mvHeaders; + } + + private Map> toMap(MultivaluedMap headers) { + return headers.entrySet().stream() + .collect(Collectors.toMap( + Entry::getKey, + Entry::getValue)); + } + +} + diff --git a/jaxrs2/src/test/java/feign/jaxrs2/JAXRS2ContractTest.java b/jaxrs2/src/test/java/feign/jaxrs2/JAXRS2ContractTest.java new file mode 100644 index 000000000..a732619bc --- /dev/null +++ b/jaxrs2/src/test/java/feign/jaxrs2/JAXRS2ContractTest.java @@ -0,0 +1,30 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.jaxrs2; + +import feign.jaxrs.JAXRSContract; +import feign.jaxrs.JAXRSContractTest; + +/** + * Tests interfaces defined per {@link JAXRS2Contract} are interpreted into expected + * {@link feign .RequestTemplate template} instances. + */ +public class JAXRS2ContractTest extends JAXRSContractTest { + + @Override + protected JAXRSContract createContract() { + return new JAXRS2Contract(); + } + +} diff --git a/jaxrs2/src/test/java/feign/jaxrs2/JAXRSClientTest.java b/jaxrs2/src/test/java/feign/jaxrs2/JAXRSClientTest.java new file mode 100644 index 000000000..de5ce4dbf --- /dev/null +++ b/jaxrs2/src/test/java/feign/jaxrs2/JAXRSClientTest.java @@ -0,0 +1,136 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.jaxrs2; + +import static feign.Util.UTF_8; +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import feign.Feign; +import feign.Feign.Builder; +import feign.Headers; +import feign.RequestLine; +import feign.Response; +import feign.Util; +import feign.assertj.MockWebServerAssertions; +import feign.client.AbstractClientTest; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Collections; +import javax.ws.rs.ProcessingException; +import okhttp3.mockwebserver.MockResponse; +import org.assertj.core.data.MapEntry; +import org.junit.Assume; +import org.junit.Test; + +/** + * Tests client-specific behavior, such as ensuring Content-Length is sent when specified. + */ +public class JAXRSClientTest extends AbstractClientTest { + + @Override + public Builder newBuilder() { + return Feign.builder().client(new JAXRSClient()); + } + + @Override + public void testPatch() throws Exception { + try { + super.testPatch(); + } catch (final ProcessingException e) { + Assume.assumeNoException("JaxRS client do not support PATCH requests", e); + } + } + + @Override + public void noResponseBodyForPut() { + try { + super.noResponseBodyForPut(); + } catch (final IllegalStateException e) { + Assume.assumeNoException("JaxRS client do not support empty bodies on PUT", e); + } + } + + @Override + public void noResponseBodyForPatch() { + try { + super.noResponseBodyForPatch(); + } catch (final IllegalStateException e) { + Assume.assumeNoException("JaxRS client do not support PATCH requests", e); + } + } + + @Test + public void reasonPhraseIsOptional() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setStatus("HTTP/1.1 " + 200)); + + final TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + final Response response = api.post("foo"); + + assertThat(response.status()).isEqualTo(200); + // jaxrsclient is creating a reason when none is present + // assertThat(response.reason()).isNullOrEmpty(); + } + + @Test + public void parsesRequestAndResponse() throws IOException, InterruptedException { + server.enqueue(new MockResponse().setBody("foo").addHeader("Foo: Bar")); + + final TestInterface api = newBuilder() + .target(TestInterface.class, "http://localhost:" + server.getPort()); + + final 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()) + .hasSameContentAs(new ByteArrayInputStream("foo".getBytes(UTF_8))); + + /* queries with no values are omitted from the uri. See RFC 6750 */ + MockWebServerAssertions.assertThat(server.takeRequest()).hasMethod("POST") + .hasPath("/?foo=bar&foo=baz&qux") + .hasBody("foo"); + } + + @Test + public void testContentTypeWithoutCharset2() throws Exception { + server.enqueue(new MockResponse() + .setBody("AAAAAAAA")); + final JaxRSClientTestInterface api = newBuilder() + .target(JaxRSClientTestInterface.class, "http://localhost:" + server.getPort()); + + final Response response = api.getWithContentType(); + // Response length should not be null + assertEquals("AAAAAAAA", Util.toString(response.body().asReader())); + + MockWebServerAssertions.assertThat(server.takeRequest()) + .hasHeaders( + MapEntry.entry("Accept", Collections.singletonList("text/plain")), + MapEntry.entry("Content-Type", Collections.singletonList("text/plain"))) + .hasMethod("GET"); + } + + + public interface JaxRSClientTestInterface { + + @RequestLine("GET /") + @Headers({"Accept: text/plain", "Content-Type: text/plain"}) + Response getWithContentType(); + } +} diff --git a/mock/README.asciidoc b/mock/README.asciidoc new file mode 100644 index 000000000..819a1f8fe --- /dev/null +++ b/mock/README.asciidoc @@ -0,0 +1,47 @@ +# feign-mock + +An easy way to test https://github.com/OpenFeign/feign. Since feign stores most of the logic in annotations, this helps to check if the annotations are correct. + +The original article is available https://velo.github.io/2016/06/05/Testing-feign-clients.html[here] + +If mocking feign clients is easy, testing the logic written in annotations is not! + +To check if you are parsing the request/response properly, the only way is firing a real request. Well, that doesn't seem to be a good path to unit (or even integration) test remote services. Any IO change will affect test stability. + +With feign-mock you can use pre-loaded JSON strings or streams as content for your responses. It also allows you to verify mocked invocations and feign-mock will hit your annotations to make sure everything works. + +##### Example + +``` + private GitHub github; + private MockClient mockClient; + + @Before + public void setup() throws IOException { + mockClient = new MockClient() + .noContent(HttpMethod.PATCH, "/repos/velo/feign-mock/contributors"); + + github = Feign.builder() + .decoder(new GsonDecoder()) + .client(mockClient) + .target(new MockTarget<>(GitHub.class)); + } + + @After + public void tearDown() { + mockClient.verifyStatus(); + } + + @Test + public void missHttpMethod() { + List result = github.patchContributors("velo", "feign-mock"); + assertThat(result, nullValue()); + mockClient.verifyOne(HttpMethod.PATCH, "/repos/velo/feign-mock/contributors"); + } +``` + +This simple test returns no content and verifies that the URL was truly invoked. + +On the mocked client, you can include all URLs and methods you want to mock. + +For more comprehensive examples take a look at https://github.com/OpenFeign/feign/blob/master/mock/src/test/java/feign/mock/MockClientTest.java[MockClientTest]. diff --git a/mock/pom.xml b/mock/pom.xml new file mode 100644 index 000000000..7faff77d7 --- /dev/null +++ b/mock/pom.xml @@ -0,0 +1,61 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + + feign-mock + Feign Mock + Feign Mock + + + ${project.basedir}/.. + + 1.3 + + + + + ${project.groupId} + feign-core + + + + ${project.groupId} + feign-gson + test + + + org.hamcrest + hamcrest-core + ${hamcrest.version} + test + + + org.hamcrest + hamcrest-library + ${hamcrest.version} + test + + + + diff --git a/mock/src/main/java/feign/mock/HttpMethod.java b/mock/src/main/java/feign/mock/HttpMethod.java new file mode 100644 index 000000000..819474099 --- /dev/null +++ b/mock/src/main/java/feign/mock/HttpMethod.java @@ -0,0 +1,20 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.mock; + +public enum HttpMethod { + + GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, CONNECT, PATCH + +} diff --git a/mock/src/main/java/feign/mock/MockClient.java b/mock/src/main/java/feign/mock/MockClient.java new file mode 100644 index 000000000..ce976b11e --- /dev/null +++ b/mock/src/main/java/feign/mock/MockClient.java @@ -0,0 +1,282 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.mock; + +import static feign.Util.UTF_8; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import feign.Client; +import feign.Request; +import feign.Response; +import feign.Util; + +public class MockClient implements Client { + + class RequestResponse { + + private final RequestKey requestKey; + + private final Response.Builder responseBuilder; + + public RequestResponse(RequestKey requestKey, Response.Builder responseBuilder) { + this.requestKey = requestKey; + this.responseBuilder = responseBuilder; + } + + } + + private final List responses = new ArrayList(); + + private final Map> requests = new HashMap>(); + + private boolean sequential; + + private Iterator responseIterator; + + public MockClient() {} + + public MockClient(boolean sequential) { + this.sequential = sequential; + } + + @Override + public synchronized Response execute(Request request, Request.Options options) + throws IOException { + RequestKey requestKey = RequestKey.create(request); + Response.Builder responseBuilder; + if (sequential) { + responseBuilder = executeSequential(requestKey); + } else { + responseBuilder = executeAny(request, requestKey); + } + + return responseBuilder.request(request).build(); + } + + private Response.Builder executeSequential(RequestKey requestKey) { + Response.Builder responseBuilder; + if (responseIterator == null) { + responseIterator = responses.iterator(); + } + if (!responseIterator.hasNext()) { + throw new VerificationAssertionError("Received excessive request %s", requestKey); + } + + RequestResponse expectedRequestResponse = responseIterator.next(); + if (!expectedRequestResponse.requestKey.equalsExtended(requestKey)) { + throw new VerificationAssertionError("Expected: \n%s,\nbut was: \n%s", + expectedRequestResponse.requestKey, + requestKey); + } + + responseBuilder = expectedRequestResponse.responseBuilder; + return responseBuilder; + } + + private Response.Builder executeAny(Request request, RequestKey requestKey) { + Response.Builder responseBuilder; + if (requests.containsKey(requestKey)) { + requests.get(requestKey).add(request); + } else { + requests.put(requestKey, new ArrayList(Arrays.asList(request))); + } + + responseBuilder = getResponseBuilder(request, requestKey); + return responseBuilder; + } + + private Response.Builder getResponseBuilder(Request request, RequestKey requestKey) { + Response.Builder responseBuilder = null; + for (RequestResponse requestResponse : responses) { + if (requestResponse.requestKey.equalsExtended(requestKey)) { + responseBuilder = requestResponse.responseBuilder; + // Don't break here, last one should win to be compatible with + // previous + // releases of this library! + } + } + if (responseBuilder == null) { + responseBuilder = + Response.builder().status(HttpURLConnection.HTTP_NOT_FOUND).reason("Not mocker") + .headers(request.headers()); + } + return responseBuilder; + } + + public MockClient ok(HttpMethod method, String url, InputStream responseBody) throws IOException { + return ok(RequestKey.builder(method, url).build(), responseBody); + } + + public MockClient ok(HttpMethod method, String url, String responseBody) { + return ok(RequestKey.builder(method, url).build(), responseBody); + } + + public MockClient ok(HttpMethod method, String url, byte[] responseBody) { + return ok(RequestKey.builder(method, url).build(), responseBody); + } + + public MockClient ok(HttpMethod method, String url) { + return ok(RequestKey.builder(method, url).build()); + } + + public MockClient ok(RequestKey requestKey, InputStream responseBody) throws IOException { + return ok(requestKey, Util.toByteArray(responseBody)); + } + + public MockClient ok(RequestKey requestKey, String responseBody) { + return ok(requestKey, responseBody.getBytes(UTF_8)); + } + + public MockClient ok(RequestKey requestKey, byte[] responseBody) { + return add(requestKey, HttpURLConnection.HTTP_OK, responseBody); + } + + public MockClient ok(RequestKey requestKey) { + return ok(requestKey, (byte[]) null); + } + + public MockClient add(HttpMethod method, String url, int status, InputStream responseBody) + throws IOException { + return add(RequestKey.builder(method, url).build(), status, responseBody); + } + + public MockClient add(HttpMethod method, String url, int status, String responseBody) { + return add(RequestKey.builder(method, url).build(), status, responseBody); + } + + public MockClient add(HttpMethod method, String url, int status, byte[] responseBody) { + return add(RequestKey.builder(method, url).build(), status, responseBody); + } + + public MockClient add(HttpMethod method, String url, int status) { + return add(RequestKey.builder(method, url).build(), status); + } + + /** + * @param response + *
    + *
  • the status defaults to 0, not 200!
  • + *
  • the internal feign-code requires the headers to be set
  • + *
+ */ + public MockClient add(HttpMethod method, String url, Response.Builder response) { + return add(RequestKey.builder(method, url).build(), response); + } + + public MockClient add(RequestKey requestKey, int status, InputStream responseBody) + throws IOException { + return add(requestKey, status, Util.toByteArray(responseBody)); + } + + public MockClient add(RequestKey requestKey, int status, String responseBody) { + return add(requestKey, status, responseBody.getBytes(UTF_8)); + } + + public MockClient add(RequestKey requestKey, int status, byte[] responseBody) { + return add(requestKey, + Response.builder().status(status).reason("Mocked").headers(RequestHeaders.EMPTY) + .body(responseBody)); + } + + public MockClient add(RequestKey requestKey, int status) { + return add(requestKey, status, (byte[]) null); + } + + public MockClient add(RequestKey requestKey, Response.Builder response) { + responses.add(new RequestResponse(requestKey, response)); + return this; + } + + /** + * @deprecated use {@link #add(HttpMethod, String, feign.Response.Builder)} instead + */ + @Deprecated + public MockClient add(HttpMethod method, String url, Response response) { + return this.add(method, url, response.toBuilder()); + } + + public MockClient noContent(HttpMethod method, String url) { + return add(method, url, HttpURLConnection.HTTP_NO_CONTENT); + } + + public Request verifyOne(HttpMethod method, String url) { + return verifyTimes(method, url, 1).get(0); + } + + public List verifyTimes(final HttpMethod method, final String url, final int times) { + if (times < 0) { + throw new IllegalArgumentException("times must be a non negative number"); + } + + if (times == 0) { + verifyNever(method, url); + return Collections.emptyList(); + } + + RequestKey requestKey = RequestKey.builder(method, url).build(); + if (!requests.containsKey(requestKey)) { + throw new VerificationAssertionError("Wanted: '%s' but never invoked! Got: %s", requestKey, + requests.keySet()); + } + + List result = requests.get(requestKey); + if (result.size() != times) { + throw new VerificationAssertionError("Wanted: '%s' to be invoked: '%s' times but got: '%s'!", + requestKey, + times, result.size()); + } + + return result; + } + + public void verifyNever(HttpMethod method, String url) { + RequestKey requestKey = RequestKey.builder(method, url).build(); + if (requests.containsKey(requestKey)) { + throw new VerificationAssertionError("Do not wanted: '%s' but was invoked!", requestKey); + } + } + + /** + * To be called in an @After method: + * + *
+   * @After
+   * public void tearDown() {
+   *   mockClient.verifyStatus();
+   * }
+   * 
+ */ + public void verifyStatus() { + if (sequential) { + boolean unopenedIterator = responseIterator == null && !responses.isEmpty(); + if (unopenedIterator || responseIterator.hasNext()) { + throw new VerificationAssertionError("More executions were expected"); + } + } + } + + public void resetRequests() { + requests.clear(); + } + + +} diff --git a/mock/src/main/java/feign/mock/MockTarget.java b/mock/src/main/java/feign/mock/MockTarget.java new file mode 100644 index 000000000..43d8a0862 --- /dev/null +++ b/mock/src/main/java/feign/mock/MockTarget.java @@ -0,0 +1,49 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.mock; + +import feign.Request; +import feign.RequestTemplate; +import feign.Target; + +public class MockTarget implements Target { + + private final Class type; + + public MockTarget(Class type) { + this.type = type; + } + + @Override + public Class type() { + return type; + } + + @Override + public String name() { + return type.getSimpleName(); + } + + @Override + public String url() { + return ""; + } + + @Override + public Request apply(RequestTemplate input) { + input.target(url()); + return input.request(); + } + +} diff --git a/mock/src/main/java/feign/mock/RequestHeaders.java b/mock/src/main/java/feign/mock/RequestHeaders.java new file mode 100644 index 000000000..b65bf075a --- /dev/null +++ b/mock/src/main/java/feign/mock/RequestHeaders.java @@ -0,0 +1,121 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.mock; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class RequestHeaders { + + public static class Builder { + + private Map> headers = new HashMap>(); + + private Builder() {} + + public Builder add(String key, Collection values) { + if (!headers.containsKey(key)) { + headers.put(key, values); + } else { + Collection previousValues = headers.get(key); + previousValues.addAll(values); + headers.put(key, previousValues); + } + return this; + } + + public Builder add(String key, String value) { + if (!headers.containsKey(key)) { + headers.put(key, new ArrayList(Arrays.asList(value))); + } else { + final Collection values = headers.get(key); + values.add(value); + headers.put(key, values); + } + return this; + } + + public RequestHeaders build() { + return new RequestHeaders(this); + } + + } + + public static final Map> EMPTY = Collections.emptyMap(); + + public static Builder builder() { + return new Builder(); + } + + public static RequestHeaders of(Map> headers) { + return new RequestHeaders(headers); + } + + private Map> headers; + + private RequestHeaders(Builder builder) { + this.headers = builder.headers; + } + + private RequestHeaders(Map> headers) { + this.headers = headers; + } + + public int size() { + return headers.size(); + } + + public int sizeOf(String key) { + if (!headers.containsKey(key)) { + return 0; + } + return headers.get(key).size(); + } + + public Collection fetch(String key) { + return headers.get(key); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final RequestHeaders other = (RequestHeaders) obj; + return this.headers.equals(other.headers); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + for (Map.Entry> entry : headers.entrySet()) { + builder.append(entry).append(',').append(' '); + } + if (builder.length() > 0) { + return builder.substring(0, builder.length() - 2); + } + return "no"; + } + +} diff --git a/mock/src/main/java/feign/mock/RequestKey.java b/mock/src/main/java/feign/mock/RequestKey.java new file mode 100644 index 000000000..5d4a3f293 --- /dev/null +++ b/mock/src/main/java/feign/mock/RequestKey.java @@ -0,0 +1,188 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.mock; + +import static feign.Util.UTF_8; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import feign.Request; +import feign.Util; + +public class RequestKey { + + public static class Builder { + + private final HttpMethod method; + + private final String url; + + private RequestHeaders headers; + + private Charset charset; + + private byte[] body; + + private Builder(HttpMethod method, String url) { + this.method = method; + this.url = url; + } + + @Deprecated + public Builder headers(Map> headers) { + this.headers = RequestHeaders.of(headers); + return this; + } + + public Builder headers(RequestHeaders headers) { + this.headers = headers; + return this; + } + + public Builder charset(Charset charset) { + this.charset = charset; + return this; + } + + public Builder body(String body) { + return body(body.getBytes(UTF_8)); + } + + public Builder body(byte[] body) { + this.body = body; + return this; + } + + public RequestKey build() { + return new RequestKey(this); + } + + } + + public static Builder builder(HttpMethod method, String url) { + return new Builder(method, url); + } + + public static RequestKey create(Request request) { + return new RequestKey(request); + } + + private static String buildUrl(Request request) { + try { + return URLDecoder.decode(request.url(), Util.UTF_8.name()); + } catch (final UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + private final HttpMethod method; + + private final String url; + + private final RequestHeaders headers; + + private final Charset charset; + + private final byte[] body; + + private RequestKey(Builder builder) { + this.method = builder.method; + this.url = builder.url; + this.headers = builder.headers; + this.charset = builder.charset; + this.body = builder.body; + } + + private RequestKey(Request request) { + this.method = HttpMethod.valueOf(request.httpMethod().name()); + this.url = buildUrl(request); + this.headers = RequestHeaders.of(request.headers()); + this.charset = request.charset(); + this.body = request.body(); + } + + public HttpMethod getMethod() { + return method; + } + + public String getUrl() { + return url; + } + + public RequestHeaders getHeaders() { + return headers; + } + + public Charset getCharset() { + return charset; + } + + public byte[] getBody() { + return body; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((method == null) ? 0 : method.hashCode()); + result = prime * result + ((url == null) ? 0 : url.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final RequestKey other = (RequestKey) obj; + if (method != other.method) { + return false; + } + if (url == null) { + return other.url == null; + } else + return url.equals(other.url); + } + + public boolean equalsExtended(Object obj) { + if (equals(obj)) { + RequestKey other = (RequestKey) obj; + boolean headersEqual = + other.headers == null || headers == null || headers.equals(other.headers); + boolean charsetEqual = + other.charset == null || charset == null || charset.equals(other.charset); + boolean bodyEqual = other.body == null || body == null || Arrays.equals(other.body, body); + return headersEqual && charsetEqual && bodyEqual; + } + return false; + } + + @Override + public String toString() { + return String.format("Request [%s %s: %s headers and %s]", method, url, + headers == null ? "without" : "with " + headers, + charset == null ? "no charset" : "charset " + charset); + } + +} diff --git a/mock/src/main/java/feign/mock/VerificationAssertionError.java b/mock/src/main/java/feign/mock/VerificationAssertionError.java new file mode 100644 index 000000000..084313aae --- /dev/null +++ b/mock/src/main/java/feign/mock/VerificationAssertionError.java @@ -0,0 +1,24 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.mock; + +public class VerificationAssertionError extends AssertionError { + + private static final long serialVersionUID = -3302777023656958993L; + + public VerificationAssertionError(String message, Object... arguments) { + super(String.format(message, arguments)); + } + +} diff --git a/mock/src/test/java/feign/mock/MockClientSequentialTest.java b/mock/src/test/java/feign/mock/MockClientSequentialTest.java new file mode 100644 index 000000000..69b12d9b4 --- /dev/null +++ b/mock/src/test/java/feign/mock/MockClientSequentialTest.java @@ -0,0 +1,174 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.mock; + +import static feign.Util.toByteArray; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.fail; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.util.List; +import javax.net.ssl.HttpsURLConnection; +import org.junit.Before; +import org.junit.Test; +import feign.Body; +import feign.Feign; +import feign.FeignException; +import feign.Headers; +import feign.Param; +import feign.RequestLine; +import feign.Response; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.gson.GsonDecoder; + +public class MockClientSequentialTest { + + interface GitHub { + + @Headers({"Name: {owner}"}) + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("GET /repos/{owner}/{repo}/contributors?client_id={client_id}") + List contributors(@Param("client_id") String clientId, + @Param("owner") String owner, + @Param("repo") String repo); + + @RequestLine("PATCH /repos/{owner}/{repo}/contributors") + List patchContributors(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("POST /repos/{owner}/{repo}/contributors") + @Body("%7B\"login\":\"{login}\",\"type\":\"{type}\"%7D") + Contributor create(@Param("owner") String owner, + @Param("repo") String repo, + @Param("login") String login, + @Param("type") String type); + + } + + static class Contributor { + + String login; + + int contributions; + + } + + class AssertionDecoder implements Decoder { + + private final Decoder delegate; + + public AssertionDecoder(Decoder delegate) { + this.delegate = delegate; + } + + @Override + public Object decode(Response response, Type type) + throws IOException, DecodeException, FeignException { + assertThat(response.request(), notNullValue()); + + return delegate.decode(response, type); + } + + } + + private GitHub githubSequential; + private MockClient mockClientSequential; + + @Before + public void setup() throws IOException { + try (InputStream input = getClass().getResourceAsStream("/fixtures/contributors.json")) { + byte[] data = toByteArray(input); + RequestHeaders headers = RequestHeaders + .builder() + .add("Name", "netflix") + .build(); + mockClientSequential = new MockClient(true); + githubSequential = Feign.builder().decoder(new AssertionDecoder(new GsonDecoder())) + .client(mockClientSequential + .add(RequestKey + .builder(HttpMethod.GET, "/repos/netflix/feign/contributors") + .headers(headers).build(), HttpsURLConnection.HTTP_OK, data) + .add(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=55", + HttpsURLConnection.HTTP_NOT_FOUND) + .add(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=7 7", + HttpsURLConnection.HTTP_INTERNAL_ERROR, new ByteArrayInputStream(data)) + .add(HttpMethod.GET, "/repos/netflix/feign/contributors", + Response.builder().status(HttpsURLConnection.HTTP_OK) + .headers(RequestHeaders.EMPTY).body(data))) + .target(new MockTarget<>(GitHub.class)); + } + } + + @Test + public void sequentialRequests() throws Exception { + githubSequential.contributors("netflix", "feign"); + try { + githubSequential.contributors("55", "netflix", "feign"); + fail(); + } catch (FeignException e) { + assertThat(e.status(), equalTo(HttpsURLConnection.HTTP_NOT_FOUND)); + } + try { + githubSequential.contributors("7 7", "netflix", "feign"); + fail(); + } catch (FeignException e) { + assertThat(e.status(), equalTo(HttpsURLConnection.HTTP_INTERNAL_ERROR)); + } + githubSequential.contributors("netflix", "feign"); + + mockClientSequential.verifyStatus(); + } + + @Test + public void sequentialRequestsCalledTooLess() throws Exception { + githubSequential.contributors("netflix", "feign"); + try { + mockClientSequential.verifyStatus(); + fail(); + } catch (VerificationAssertionError e) { + assertThat(e.getMessage(), startsWith("More executions")); + } + } + + @Test + public void sequentialRequestsCalledTooMany() throws Exception { + sequentialRequests(); + + try { + githubSequential.contributors("netflix", "feign"); + fail(); + } catch (VerificationAssertionError e) { + assertThat(e.getMessage(), containsString("excessive")); + } + } + + @Test + public void sequentialRequestsInWrongOrder() throws Exception { + try { + githubSequential.contributors("7 7", "netflix", "feign"); + fail(); + } catch (VerificationAssertionError e) { + assertThat(e.getMessage(), startsWith("Expected: \nRequest [")); + } + } + +} diff --git a/mock/src/test/java/feign/mock/MockClientTest.java b/mock/src/test/java/feign/mock/MockClientTest.java new file mode 100644 index 000000000..c6a5c573f --- /dev/null +++ b/mock/src/test/java/feign/mock/MockClientTest.java @@ -0,0 +1,264 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.mock; + +import static feign.Util.toByteArray; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.notNullValue; +import static org.junit.Assert.fail; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Type; +import java.util.List; +import javax.net.ssl.HttpsURLConnection; +import org.hamcrest.Matchers; +import org.junit.Before; +import org.junit.Test; +import feign.Body; +import feign.Feign; +import feign.FeignException; +import feign.Param; +import feign.Request; +import feign.RequestLine; +import feign.Response; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.gson.GsonDecoder; + +public class MockClientTest { + + interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("GET /repos/{owner}/{repo}/contributors?client_id={client_id}") + List contributors(@Param("client_id") String clientId, + @Param("owner") String owner, + @Param("repo") String repo); + + @RequestLine("PATCH /repos/{owner}/{repo}/contributors") + List patchContributors(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("POST /repos/{owner}/{repo}/contributors") + @Body("%7B\"login\":\"{login}\",\"type\":\"{type}\"%7D") + Contributor create(@Param("owner") String owner, + @Param("repo") String repo, + @Param("login") String login, + @Param("type") String type); + + } + + static class Contributor { + + String login; + + int contributions; + + } + + class AssertionDecoder implements Decoder { + + private final Decoder delegate; + + public AssertionDecoder(Decoder delegate) { + this.delegate = delegate; + } + + @Override + public Object decode(Response response, Type type) + throws IOException, DecodeException, FeignException { + assertThat(response.request(), notNullValue()); + + return delegate.decode(response, type); + } + + } + + private GitHub github; + private MockClient mockClient; + + @Before + public void setup() throws IOException { + try (InputStream input = getClass().getResourceAsStream("/fixtures/contributors.json")) { + byte[] data = toByteArray(input); + mockClient = new MockClient(); + github = Feign.builder().decoder(new AssertionDecoder(new GsonDecoder())) + .client(mockClient.ok(HttpMethod.GET, "/repos/netflix/feign/contributors", data) + .ok(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=55") + .ok(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=7 7", + new ByteArrayInputStream(data)) + .ok(HttpMethod.POST, "/repos/netflix/feign/contributors", + "{\"login\":\"velo\",\"contributions\":0}") + .noContent(HttpMethod.PATCH, "/repos/velo/feign-mock/contributors") + .add(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=1234567890", + HttpsURLConnection.HTTP_NOT_FOUND) + .add(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=123456789", + HttpsURLConnection.HTTP_INTERNAL_ERROR, new ByteArrayInputStream(data)) + .add(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=123456789", + HttpsURLConnection.HTTP_INTERNAL_ERROR, "") + .add(HttpMethod.GET, "/repos/netflix/feign/contributors?client_id=123456789", + HttpsURLConnection.HTTP_INTERNAL_ERROR, data)) + .target(new MockTarget<>(GitHub.class)); + } + } + + @Test + public void hitMock() { + List contributors = github.contributors("netflix", "feign"); + assertThat(contributors, hasSize(30)); + mockClient.verifyStatus(); + } + + @Test + public void missMock() { + try { + github.contributors("velo", "feign-mock"); + fail(); + } catch (FeignException e) { + assertThat(e.getMessage(), Matchers.containsString("404")); + } + } + + @Test + public void missHttpMethod() { + try { + github.patchContributors("netflix", "feign"); + fail(); + } catch (FeignException e) { + assertThat(e.getMessage(), Matchers.containsString("404")); + } + } + + @Test + public void paramsEncoding() { + List contributors = github.contributors("7 7", "netflix", "feign"); + assertThat(contributors, hasSize(30)); + mockClient.verifyStatus(); + } + + @Test + public void verifyInvocation() { + Contributor contribution = + github.create("netflix", "feign", "velo_at_github", "preposterous hacker"); + // making sure it received a proper response + assertThat(contribution, notNullValue()); + assertThat(contribution.login, equalTo("velo")); + assertThat(contribution.contributions, equalTo(0)); + + List results = + mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 1); + assertThat(results, hasSize(1)); + + byte[] body = mockClient.verifyOne(HttpMethod.POST, "/repos/netflix/feign/contributors").body(); + assertThat(body, notNullValue()); + + String message = new String(body); + assertThat(message, containsString("velo_at_github")); + assertThat(message, containsString("preposterous hacker")); + + mockClient.verifyStatus(); + } + + @Test + public void verifyNone() { + github.create("netflix", "feign", "velo_at_github", "preposterous hacker"); + mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 1); + + try { + mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 0); + fail(); + } catch (VerificationAssertionError e) { + assertThat(e.getMessage(), containsString("Do not wanted")); + assertThat(e.getMessage(), containsString("POST")); + assertThat(e.getMessage(), containsString("/repos/netflix/feign/contributors")); + } + + try { + mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 3); + fail(); + } catch (VerificationAssertionError e) { + assertThat(e.getMessage(), containsString("Wanted")); + assertThat(e.getMessage(), containsString("POST")); + assertThat(e.getMessage(), containsString("/repos/netflix/feign/contributors")); + assertThat(e.getMessage(), containsString("'3'")); + assertThat(e.getMessage(), containsString("'1'")); + } + } + + @Test + public void verifyNotInvoked() { + mockClient.verifyNever(HttpMethod.POST, "/repos/netflix/feign/contributors"); + List results = + mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 0); + assertThat(results, hasSize(0)); + try { + mockClient.verifyOne(HttpMethod.POST, "/repos/netflix/feign/contributors"); + fail(); + } catch (VerificationAssertionError e) { + assertThat(e.getMessage(), containsString("Wanted")); + assertThat(e.getMessage(), containsString("POST")); + assertThat(e.getMessage(), containsString("/repos/netflix/feign/contributors")); + assertThat(e.getMessage(), containsString("never invoked")); + } + } + + @Test + public void verifyNegative() { + try { + mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", -1); + fail(); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage(), containsString("non negative")); + } + } + + @Test + public void verifyMultipleRequests() { + mockClient.verifyNever(HttpMethod.POST, "/repos/netflix/feign/contributors"); + + github.create("netflix", "feign", "velo_at_github", "preposterous hacker"); + Request result = mockClient.verifyOne(HttpMethod.POST, "/repos/netflix/feign/contributors"); + assertThat(result, notNullValue()); + + github.create("netflix", "feign", "velo_at_github", "preposterous hacker"); + List results = + mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 2); + assertThat(results, hasSize(2)); + + github.create("netflix", "feign", "velo_at_github", "preposterous hacker"); + results = mockClient.verifyTimes(HttpMethod.POST, "/repos/netflix/feign/contributors", 3); + assertThat(results, hasSize(3)); + + mockClient.verifyStatus(); + } + + @Test + public void resetRequests() { + mockClient.verifyNever(HttpMethod.POST, "/repos/netflix/feign/contributors"); + + github.create("netflix", "feign", "velo_at_github", "preposterous hacker"); + Request result = mockClient.verifyOne(HttpMethod.POST, "/repos/netflix/feign/contributors"); + assertThat(result, notNullValue()); + + mockClient.resetRequests(); + + mockClient.verifyNever(HttpMethod.POST, "/repos/netflix/feign/contributors"); + } + +} diff --git a/mock/src/test/java/feign/mock/MockTargetTest.java b/mock/src/test/java/feign/mock/MockTargetTest.java new file mode 100644 index 000000000..c8441ba32 --- /dev/null +++ b/mock/src/test/java/feign/mock/MockTargetTest.java @@ -0,0 +1,35 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.mock; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; +import org.junit.Before; +import org.junit.Test; + +public class MockTargetTest { + + private MockTarget target; + + @Before + public void setup() { + target = new MockTarget<>(MockTargetTest.class); + } + + @Test + public void test() { + assertThat(target.name(), equalTo("MockTargetTest")); + } + +} diff --git a/mock/src/test/java/feign/mock/RequestHeadersTest.java b/mock/src/test/java/feign/mock/RequestHeadersTest.java new file mode 100644 index 000000000..6333aa60c --- /dev/null +++ b/mock/src/test/java/feign/mock/RequestHeadersTest.java @@ -0,0 +1,88 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.mock; + +import static org.assertj.core.api.Assertions.assertThat; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class RequestHeadersTest { + + @Test + public void shouldCreateEmptyRequestHeaders() { + RequestHeaders headers = RequestHeaders + .builder() + .build(); + assertThat(headers.size()).isEqualTo(0); + } + + @Test + public void shouldReturnZeroSizeForUnknownKey() { + RequestHeaders headers = RequestHeaders + .builder() + .build(); + assertThat(headers.sizeOf("unknown")).isEqualTo(0); + } + + @Test + public void shouldCreateRequestHeadersFromSingleValue() { + RequestHeaders headers = RequestHeaders + .builder() + .add("header", "val") + .add("other header", "val2") + .build(); + + assertThat(headers.fetch("header")).contains("val"); + assertThat(headers.sizeOf("header")).isEqualTo(1); + assertThat(headers.fetch("other header")).contains("val2"); + assertThat(headers.sizeOf("other header")).isEqualTo(1); + } + + @Test + public void shouldCreateRequestHeadersFromSingleValueAndCollection() { + RequestHeaders headers = RequestHeaders + .builder() + .add("header", "val") + .add("other header", "val2") + .add("header", Arrays.asList("val3", "val4")) + .build(); + + assertThat(headers.fetch("header")).contains("val", "val3", "val4"); + assertThat(headers.sizeOf("header")).isEqualTo(3); + assertThat(headers.fetch("other header")).contains("val2"); + assertThat(headers.sizeOf("other header")).isEqualTo(1); + } + + @Test + public void shouldCreateRequestHeadersFromHeadersMap() { + Map> map = new HashMap>(); + map.put("header", Arrays.asList("val", "val2")); + RequestHeaders headers = RequestHeaders.of(map); + assertThat(headers.size()).isEqualTo(1); + } + + @Test + public void shouldPrintHeaders() { + RequestHeaders headers = RequestHeaders + .builder() + .add("header", "val") + .add("other header", "val2") + .add("header", Arrays.asList("val3", "val4")) + .build(); + assertThat(headers.toString()).isEqualTo("other header=[val2], header=[val, val3, val4]"); + } +} diff --git a/mock/src/test/java/feign/mock/RequestKeyTest.java b/mock/src/test/java/feign/mock/RequestKeyTest.java new file mode 100644 index 000000000..3d84759d2 --- /dev/null +++ b/mock/src/test/java/feign/mock/RequestKeyTest.java @@ -0,0 +1,165 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.mock; + +import static org.hamcrest.Matchers.both; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.assertThat; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import feign.Request; + +public class RequestKeyTest { + + private RequestKey requestKey; + + @Before + public void setUp() { + RequestHeaders headers = RequestHeaders + .builder() + .add("my-header", "val").build(); + requestKey = + RequestKey.builder(HttpMethod.GET, "a").headers(headers).charset(StandardCharsets.UTF_16) + .body("content").build(); + } + + @Test + public void builder() throws Exception { + assertThat(requestKey.getMethod(), equalTo(HttpMethod.GET)); + assertThat(requestKey.getUrl(), equalTo("a")); + assertThat(requestKey.getHeaders().size(), is(1)); + assertThat(requestKey.getHeaders().fetch("my-header"), + equalTo((Collection) Arrays.asList("val"))); + assertThat(requestKey.getCharset(), equalTo(StandardCharsets.UTF_16)); + } + + @Test + public void create() throws Exception { + Map> map = new HashMap>(); + map.put("my-header", Arrays.asList("val")); + Request request = + Request.create(Request.HttpMethod.GET, "a", map, "content".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_16); + requestKey = RequestKey.create(request); + + assertThat(requestKey.getMethod(), equalTo(HttpMethod.GET)); + assertThat(requestKey.getUrl(), equalTo("a")); + assertThat(requestKey.getHeaders().size(), is(1)); + assertThat(requestKey.getHeaders().fetch("my-header"), + equalTo((Collection) Arrays.asList("val"))); + assertThat(requestKey.getCharset(), equalTo(StandardCharsets.UTF_16)); + assertThat(requestKey.getBody(), equalTo("content".getBytes(StandardCharsets.UTF_8))); + } + + @Test + public void checkHashes() { + RequestKey requestKey1 = RequestKey.builder(HttpMethod.GET, "a").build(); + RequestKey requestKey2 = RequestKey.builder(HttpMethod.GET, "b").build(); + + assertThat(requestKey1.hashCode(), not(equalTo(requestKey2.hashCode()))); + assertThat(requestKey1, not(equalTo(requestKey2))); + } + + @Test + public void equalObject() { + assertThat(requestKey, not(equalTo(new Object()))); + } + + @Test + public void equalNull() { + assertThat(requestKey, not(equalTo(null))); + } + + @Test + public void equalPost() { + RequestKey requestKey1 = RequestKey.builder(HttpMethod.GET, "a").build(); + RequestKey requestKey2 = RequestKey.builder(HttpMethod.POST, "a").build(); + + assertThat(requestKey1.hashCode(), not(equalTo(requestKey2.hashCode()))); + assertThat(requestKey1, not(equalTo(requestKey2))); + } + + @Test + public void equalSelf() { + assertThat(requestKey.hashCode(), equalTo(requestKey.hashCode())); + assertThat(requestKey, equalTo(requestKey)); + } + + @Test + public void equalMinimum() { + RequestKey requestKey2 = RequestKey.builder(HttpMethod.GET, "a").build(); + + assertThat(requestKey.hashCode(), equalTo(requestKey2.hashCode())); + assertThat(requestKey, equalTo(requestKey2)); + } + + @Test + public void equalExtra() { + RequestHeaders headers = RequestHeaders + .builder() + .add("my-other-header", "other value").build(); + RequestKey requestKey2 = RequestKey.builder(HttpMethod.GET, "a").headers(headers) + .charset(StandardCharsets.ISO_8859_1).build(); + + assertThat(requestKey.hashCode(), equalTo(requestKey2.hashCode())); + assertThat(requestKey, equalTo(requestKey2)); + } + + @Test + public void equalsExtended() { + RequestKey requestKey2 = RequestKey.builder(HttpMethod.GET, "a").build(); + + assertThat(requestKey.hashCode(), equalTo(requestKey2.hashCode())); + assertThat(requestKey.equalsExtended(requestKey2), equalTo(true)); + } + + @Test + public void equalsExtendedExtra() { + RequestHeaders headers = RequestHeaders + .builder() + .add("my-other-header", "other value").build(); + RequestKey requestKey2 = RequestKey.builder(HttpMethod.GET, "a").headers(headers) + .charset(StandardCharsets.ISO_8859_1).build(); + + assertThat(requestKey.hashCode(), equalTo(requestKey2.hashCode())); + assertThat(requestKey.equalsExtended(requestKey2), equalTo(false)); + } + + @Test + public void testToString() throws Exception { + assertThat(requestKey.toString(), startsWith("Request [GET a: ")); + assertThat(requestKey.toString(), + both(containsString(" with my-header=[val] ")).and(containsString(" UTF-16]"))); + } + + @Test + public void testToStringSimple() throws Exception { + requestKey = RequestKey.builder(HttpMethod.GET, "a").build(); + + assertThat(requestKey.toString(), startsWith("Request [GET a: ")); + assertThat(requestKey.toString(), + both(containsString(" without ")).and(containsString(" no charset"))); + } + +} +// diff --git a/mock/src/test/resources/fixtures/contributors.json b/mock/src/test/resources/fixtures/contributors.json new file mode 100644 index 000000000..f677c796f --- /dev/null +++ b/mock/src/test/resources/fixtures/contributors.json @@ -0,0 +1,602 @@ +[ + { + "login": "adriancole", + "id": 64215, + "avatar_url": "https://avatars.githubusercontent.com/u/64215?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/adriancole", + "html_url": "https://github.com/adriancole", + "followers_url": "https://api.github.com/users/adriancole/followers", + "following_url": "https://api.github.com/users/adriancole/following{/other_user}", + "gists_url": "https://api.github.com/users/adriancole/gists{/gist_id}", + "starred_url": "https://api.github.com/users/adriancole/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/adriancole/subscriptions", + "organizations_url": "https://api.github.com/users/adriancole/orgs", + "repos_url": "https://api.github.com/users/adriancole/repos", + "events_url": "https://api.github.com/users/adriancole/events{/privacy}", + "received_events_url": "https://api.github.com/users/adriancole/received_events", + "type": "User", + "site_admin": false, + "contributions": 297 + }, + { + "login": "quidryan", + "id": 360255, + "avatar_url": "https://avatars.githubusercontent.com/u/360255?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/quidryan", + "html_url": "https://github.com/quidryan", + "followers_url": "https://api.github.com/users/quidryan/followers", + "following_url": "https://api.github.com/users/quidryan/following{/other_user}", + "gists_url": "https://api.github.com/users/quidryan/gists{/gist_id}", + "starred_url": "https://api.github.com/users/quidryan/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/quidryan/subscriptions", + "organizations_url": "https://api.github.com/users/quidryan/orgs", + "repos_url": "https://api.github.com/users/quidryan/repos", + "events_url": "https://api.github.com/users/quidryan/events{/privacy}", + "received_events_url": "https://api.github.com/users/quidryan/received_events", + "type": "User", + "site_admin": false, + "contributions": 43 + }, + { + "login": "rspieldenner", + "id": 782102, + "avatar_url": "https://avatars.githubusercontent.com/u/782102?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/rspieldenner", + "html_url": "https://github.com/rspieldenner", + "followers_url": "https://api.github.com/users/rspieldenner/followers", + "following_url": "https://api.github.com/users/rspieldenner/following{/other_user}", + "gists_url": "https://api.github.com/users/rspieldenner/gists{/gist_id}", + "starred_url": "https://api.github.com/users/rspieldenner/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/rspieldenner/subscriptions", + "organizations_url": "https://api.github.com/users/rspieldenner/orgs", + "repos_url": "https://api.github.com/users/rspieldenner/repos", + "events_url": "https://api.github.com/users/rspieldenner/events{/privacy}", + "received_events_url": "https://api.github.com/users/rspieldenner/received_events", + "type": "User", + "site_admin": false, + "contributions": 14 + }, + { + "login": "davidmc24", + "id": 447825, + "avatar_url": "https://avatars.githubusercontent.com/u/447825?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/davidmc24", + "html_url": "https://github.com/davidmc24", + "followers_url": "https://api.github.com/users/davidmc24/followers", + "following_url": "https://api.github.com/users/davidmc24/following{/other_user}", + "gists_url": "https://api.github.com/users/davidmc24/gists{/gist_id}", + "starred_url": "https://api.github.com/users/davidmc24/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/davidmc24/subscriptions", + "organizations_url": "https://api.github.com/users/davidmc24/orgs", + "repos_url": "https://api.github.com/users/davidmc24/repos", + "events_url": "https://api.github.com/users/davidmc24/events{/privacy}", + "received_events_url": "https://api.github.com/users/davidmc24/received_events", + "type": "User", + "site_admin": false, + "contributions": 12 + }, + { + "login": "ahus1", + "id": 3957921, + "avatar_url": "https://avatars.githubusercontent.com/u/3957921?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/ahus1", + "html_url": "https://github.com/ahus1", + "followers_url": "https://api.github.com/users/ahus1/followers", + "following_url": "https://api.github.com/users/ahus1/following{/other_user}", + "gists_url": "https://api.github.com/users/ahus1/gists{/gist_id}", + "starred_url": "https://api.github.com/users/ahus1/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/ahus1/subscriptions", + "organizations_url": "https://api.github.com/users/ahus1/orgs", + "repos_url": "https://api.github.com/users/ahus1/repos", + "events_url": "https://api.github.com/users/ahus1/events{/privacy}", + "received_events_url": "https://api.github.com/users/ahus1/received_events", + "type": "User", + "site_admin": false, + "contributions": 6 + }, + { + "login": "allenxwang", + "id": 1728105, + "avatar_url": "https://avatars.githubusercontent.com/u/1728105?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/allenxwang", + "html_url": "https://github.com/allenxwang", + "followers_url": "https://api.github.com/users/allenxwang/followers", + "following_url": "https://api.github.com/users/allenxwang/following{/other_user}", + "gists_url": "https://api.github.com/users/allenxwang/gists{/gist_id}", + "starred_url": "https://api.github.com/users/allenxwang/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/allenxwang/subscriptions", + "organizations_url": "https://api.github.com/users/allenxwang/orgs", + "repos_url": "https://api.github.com/users/allenxwang/repos", + "events_url": "https://api.github.com/users/allenxwang/events{/privacy}", + "received_events_url": "https://api.github.com/users/allenxwang/received_events", + "type": "User", + "site_admin": false, + "contributions": 5 + }, + { + "login": "nmiyake", + "id": 4267425, + "avatar_url": "https://avatars.githubusercontent.com/u/4267425?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/nmiyake", + "html_url": "https://github.com/nmiyake", + "followers_url": "https://api.github.com/users/nmiyake/followers", + "following_url": "https://api.github.com/users/nmiyake/following{/other_user}", + "gists_url": "https://api.github.com/users/nmiyake/gists{/gist_id}", + "starred_url": "https://api.github.com/users/nmiyake/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/nmiyake/subscriptions", + "organizations_url": "https://api.github.com/users/nmiyake/orgs", + "repos_url": "https://api.github.com/users/nmiyake/repos", + "events_url": "https://api.github.com/users/nmiyake/events{/privacy}", + "received_events_url": "https://api.github.com/users/nmiyake/received_events", + "type": "User", + "site_admin": false, + "contributions": 4 + }, + { + "login": "Drdoteam", + "id": 4572139, + "avatar_url": "https://avatars.githubusercontent.com/u/4572139?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/Drdoteam", + "html_url": "https://github.com/Drdoteam", + "followers_url": "https://api.github.com/users/Drdoteam/followers", + "following_url": "https://api.github.com/users/Drdoteam/following{/other_user}", + "gists_url": "https://api.github.com/users/Drdoteam/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Drdoteam/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Drdoteam/subscriptions", + "organizations_url": "https://api.github.com/users/Drdoteam/orgs", + "repos_url": "https://api.github.com/users/Drdoteam/repos", + "events_url": "https://api.github.com/users/Drdoteam/events{/privacy}", + "received_events_url": "https://api.github.com/users/Drdoteam/received_events", + "type": "User", + "site_admin": false, + "contributions": 4 + }, + { + "login": "spencergibb", + "id": 594085, + "avatar_url": "https://avatars.githubusercontent.com/u/594085?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/spencergibb", + "html_url": "https://github.com/spencergibb", + "followers_url": "https://api.github.com/users/spencergibb/followers", + "following_url": "https://api.github.com/users/spencergibb/following{/other_user}", + "gists_url": "https://api.github.com/users/spencergibb/gists{/gist_id}", + "starred_url": "https://api.github.com/users/spencergibb/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/spencergibb/subscriptions", + "organizations_url": "https://api.github.com/users/spencergibb/orgs", + "repos_url": "https://api.github.com/users/spencergibb/repos", + "events_url": "https://api.github.com/users/spencergibb/events{/privacy}", + "received_events_url": "https://api.github.com/users/spencergibb/received_events", + "type": "User", + "site_admin": false, + "contributions": 3 + }, + { + "login": "jacob-meacham", + "id": 1624811, + "avatar_url": "https://avatars.githubusercontent.com/u/1624811?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/jacob-meacham", + "html_url": "https://github.com/jacob-meacham", + "followers_url": "https://api.github.com/users/jacob-meacham/followers", + "following_url": "https://api.github.com/users/jacob-meacham/following{/other_user}", + "gists_url": "https://api.github.com/users/jacob-meacham/gists{/gist_id}", + "starred_url": "https://api.github.com/users/jacob-meacham/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/jacob-meacham/subscriptions", + "organizations_url": "https://api.github.com/users/jacob-meacham/orgs", + "repos_url": "https://api.github.com/users/jacob-meacham/repos", + "events_url": "https://api.github.com/users/jacob-meacham/events{/privacy}", + "received_events_url": "https://api.github.com/users/jacob-meacham/received_events", + "type": "User", + "site_admin": false, + "contributions": 3 + }, + { + "login": "pnepywoda", + "id": 13909400, + "avatar_url": "https://avatars.githubusercontent.com/u/13909400?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/pnepywoda", + "html_url": "https://github.com/pnepywoda", + "followers_url": "https://api.github.com/users/pnepywoda/followers", + "following_url": "https://api.github.com/users/pnepywoda/following{/other_user}", + "gists_url": "https://api.github.com/users/pnepywoda/gists{/gist_id}", + "starred_url": "https://api.github.com/users/pnepywoda/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/pnepywoda/subscriptions", + "organizations_url": "https://api.github.com/users/pnepywoda/orgs", + "repos_url": "https://api.github.com/users/pnepywoda/repos", + "events_url": "https://api.github.com/users/pnepywoda/events{/privacy}", + "received_events_url": "https://api.github.com/users/pnepywoda/received_events", + "type": "User", + "site_admin": false, + "contributions": 3 + }, + { + "login": "santhosh-tekuri", + "id": 1112271, + "avatar_url": "https://avatars.githubusercontent.com/u/1112271?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/santhosh-tekuri", + "html_url": "https://github.com/santhosh-tekuri", + "followers_url": "https://api.github.com/users/santhosh-tekuri/followers", + "following_url": "https://api.github.com/users/santhosh-tekuri/following{/other_user}", + "gists_url": "https://api.github.com/users/santhosh-tekuri/gists{/gist_id}", + "starred_url": "https://api.github.com/users/santhosh-tekuri/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/santhosh-tekuri/subscriptions", + "organizations_url": "https://api.github.com/users/santhosh-tekuri/orgs", + "repos_url": "https://api.github.com/users/santhosh-tekuri/repos", + "events_url": "https://api.github.com/users/santhosh-tekuri/events{/privacy}", + "received_events_url": "https://api.github.com/users/santhosh-tekuri/received_events", + "type": "User", + "site_admin": false, + "contributions": 3 + }, + { + "login": "bstick12", + "id": 1146861, + "avatar_url": "https://avatars.githubusercontent.com/u/1146861?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/bstick12", + "html_url": "https://github.com/bstick12", + "followers_url": "https://api.github.com/users/bstick12/followers", + "following_url": "https://api.github.com/users/bstick12/following{/other_user}", + "gists_url": "https://api.github.com/users/bstick12/gists{/gist_id}", + "starred_url": "https://api.github.com/users/bstick12/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/bstick12/subscriptions", + "organizations_url": "https://api.github.com/users/bstick12/orgs", + "repos_url": "https://api.github.com/users/bstick12/repos", + "events_url": "https://api.github.com/users/bstick12/events{/privacy}", + "received_events_url": "https://api.github.com/users/bstick12/received_events", + "type": "User", + "site_admin": false, + "contributions": 3 + }, + { + "login": "oillio", + "id": 205051, + "avatar_url": "https://avatars.githubusercontent.com/u/205051?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/oillio", + "html_url": "https://github.com/oillio", + "followers_url": "https://api.github.com/users/oillio/followers", + "following_url": "https://api.github.com/users/oillio/following{/other_user}", + "gists_url": "https://api.github.com/users/oillio/gists{/gist_id}", + "starred_url": "https://api.github.com/users/oillio/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/oillio/subscriptions", + "organizations_url": "https://api.github.com/users/oillio/orgs", + "repos_url": "https://api.github.com/users/oillio/repos", + "events_url": "https://api.github.com/users/oillio/events{/privacy}", + "received_events_url": "https://api.github.com/users/oillio/received_events", + "type": "User", + "site_admin": false, + "contributions": 2 + }, + { + "login": "stromnet", + "id": 668449, + "avatar_url": "https://avatars.githubusercontent.com/u/668449?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/stromnet", + "html_url": "https://github.com/stromnet", + "followers_url": "https://api.github.com/users/stromnet/followers", + "following_url": "https://api.github.com/users/stromnet/following{/other_user}", + "gists_url": "https://api.github.com/users/stromnet/gists{/gist_id}", + "starred_url": "https://api.github.com/users/stromnet/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/stromnet/subscriptions", + "organizations_url": "https://api.github.com/users/stromnet/orgs", + "repos_url": "https://api.github.com/users/stromnet/repos", + "events_url": "https://api.github.com/users/stromnet/events{/privacy}", + "received_events_url": "https://api.github.com/users/stromnet/received_events", + "type": "User", + "site_admin": false, + "contributions": 2 + }, + { + "login": "qualidafial", + "id": 38629, + "avatar_url": "https://avatars.githubusercontent.com/u/38629?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/qualidafial", + "html_url": "https://github.com/qualidafial", + "followers_url": "https://api.github.com/users/qualidafial/followers", + "following_url": "https://api.github.com/users/qualidafial/following{/other_user}", + "gists_url": "https://api.github.com/users/qualidafial/gists{/gist_id}", + "starred_url": "https://api.github.com/users/qualidafial/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/qualidafial/subscriptions", + "organizations_url": "https://api.github.com/users/qualidafial/orgs", + "repos_url": "https://api.github.com/users/qualidafial/repos", + "events_url": "https://api.github.com/users/qualidafial/events{/privacy}", + "received_events_url": "https://api.github.com/users/qualidafial/received_events", + "type": "User", + "site_admin": false, + "contributions": 2 + }, + { + "login": "amit-git", + "id": 2767034, + "avatar_url": "https://avatars.githubusercontent.com/u/2767034?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/amit-git", + "html_url": "https://github.com/amit-git", + "followers_url": "https://api.github.com/users/amit-git/followers", + "following_url": "https://api.github.com/users/amit-git/following{/other_user}", + "gists_url": "https://api.github.com/users/amit-git/gists{/gist_id}", + "starred_url": "https://api.github.com/users/amit-git/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/amit-git/subscriptions", + "organizations_url": "https://api.github.com/users/amit-git/orgs", + "repos_url": "https://api.github.com/users/amit-git/repos", + "events_url": "https://api.github.com/users/amit-git/events{/privacy}", + "received_events_url": "https://api.github.com/users/amit-git/received_events", + "type": "User", + "site_admin": false, + "contributions": 2 + }, + { + "login": "dstepanov", + "id": 666879, + "avatar_url": "https://avatars.githubusercontent.com/u/666879?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/dstepanov", + "html_url": "https://github.com/dstepanov", + "followers_url": "https://api.github.com/users/dstepanov/followers", + "following_url": "https://api.github.com/users/dstepanov/following{/other_user}", + "gists_url": "https://api.github.com/users/dstepanov/gists{/gist_id}", + "starred_url": "https://api.github.com/users/dstepanov/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/dstepanov/subscriptions", + "organizations_url": "https://api.github.com/users/dstepanov/orgs", + "repos_url": "https://api.github.com/users/dstepanov/repos", + "events_url": "https://api.github.com/users/dstepanov/events{/privacy}", + "received_events_url": "https://api.github.com/users/dstepanov/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "asukhyy", + "id": 891597, + "avatar_url": "https://avatars.githubusercontent.com/u/891597?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/asukhyy", + "html_url": "https://github.com/asukhyy", + "followers_url": "https://api.github.com/users/asukhyy/followers", + "following_url": "https://api.github.com/users/asukhyy/following{/other_user}", + "gists_url": "https://api.github.com/users/asukhyy/gists{/gist_id}", + "starred_url": "https://api.github.com/users/asukhyy/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/asukhyy/subscriptions", + "organizations_url": "https://api.github.com/users/asukhyy/orgs", + "repos_url": "https://api.github.com/users/asukhyy/repos", + "events_url": "https://api.github.com/users/asukhyy/events{/privacy}", + "received_events_url": "https://api.github.com/users/asukhyy/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "carlossg", + "id": 23651, + "avatar_url": "https://avatars.githubusercontent.com/u/23651?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/carlossg", + "html_url": "https://github.com/carlossg", + "followers_url": "https://api.github.com/users/carlossg/followers", + "following_url": "https://api.github.com/users/carlossg/following{/other_user}", + "gists_url": "https://api.github.com/users/carlossg/gists{/gist_id}", + "starred_url": "https://api.github.com/users/carlossg/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/carlossg/subscriptions", + "organizations_url": "https://api.github.com/users/carlossg/orgs", + "repos_url": "https://api.github.com/users/carlossg/repos", + "events_url": "https://api.github.com/users/carlossg/events{/privacy}", + "received_events_url": "https://api.github.com/users/carlossg/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "christopherlakey", + "id": 1859690, + "avatar_url": "https://avatars.githubusercontent.com/u/1859690?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/christopherlakey", + "html_url": "https://github.com/christopherlakey", + "followers_url": "https://api.github.com/users/christopherlakey/followers", + "following_url": "https://api.github.com/users/christopherlakey/following{/other_user}", + "gists_url": "https://api.github.com/users/christopherlakey/gists{/gist_id}", + "starred_url": "https://api.github.com/users/christopherlakey/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/christopherlakey/subscriptions", + "organizations_url": "https://api.github.com/users/christopherlakey/orgs", + "repos_url": "https://api.github.com/users/christopherlakey/repos", + "events_url": "https://api.github.com/users/christopherlakey/events{/privacy}", + "received_events_url": "https://api.github.com/users/christopherlakey/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "dsyer", + "id": 124075, + "avatar_url": "https://avatars.githubusercontent.com/u/124075?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/dsyer", + "html_url": "https://github.com/dsyer", + "followers_url": "https://api.github.com/users/dsyer/followers", + "following_url": "https://api.github.com/users/dsyer/following{/other_user}", + "gists_url": "https://api.github.com/users/dsyer/gists{/gist_id}", + "starred_url": "https://api.github.com/users/dsyer/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/dsyer/subscriptions", + "organizations_url": "https://api.github.com/users/dsyer/orgs", + "repos_url": "https://api.github.com/users/dsyer/repos", + "events_url": "https://api.github.com/users/dsyer/events{/privacy}", + "received_events_url": "https://api.github.com/users/dsyer/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "aspyker", + "id": 260750, + "avatar_url": "https://avatars.githubusercontent.com/u/260750?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/aspyker", + "html_url": "https://github.com/aspyker", + "followers_url": "https://api.github.com/users/aspyker/followers", + "following_url": "https://api.github.com/users/aspyker/following{/other_user}", + "gists_url": "https://api.github.com/users/aspyker/gists{/gist_id}", + "starred_url": "https://api.github.com/users/aspyker/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/aspyker/subscriptions", + "organizations_url": "https://api.github.com/users/aspyker/orgs", + "repos_url": "https://api.github.com/users/aspyker/repos", + "events_url": "https://api.github.com/users/aspyker/events{/privacy}", + "received_events_url": "https://api.github.com/users/aspyker/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "FrEaKmAn", + "id": 232901, + "avatar_url": "https://avatars.githubusercontent.com/u/232901?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/FrEaKmAn", + "html_url": "https://github.com/FrEaKmAn", + "followers_url": "https://api.github.com/users/FrEaKmAn/followers", + "following_url": "https://api.github.com/users/FrEaKmAn/following{/other_user}", + "gists_url": "https://api.github.com/users/FrEaKmAn/gists{/gist_id}", + "starred_url": "https://api.github.com/users/FrEaKmAn/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/FrEaKmAn/subscriptions", + "organizations_url": "https://api.github.com/users/FrEaKmAn/orgs", + "repos_url": "https://api.github.com/users/FrEaKmAn/repos", + "events_url": "https://api.github.com/users/FrEaKmAn/events{/privacy}", + "received_events_url": "https://api.github.com/users/FrEaKmAn/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "htynkn", + "id": 659135, + "avatar_url": "https://avatars.githubusercontent.com/u/659135?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/htynkn", + "html_url": "https://github.com/htynkn", + "followers_url": "https://api.github.com/users/htynkn/followers", + "following_url": "https://api.github.com/users/htynkn/following{/other_user}", + "gists_url": "https://api.github.com/users/htynkn/gists{/gist_id}", + "starred_url": "https://api.github.com/users/htynkn/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/htynkn/subscriptions", + "organizations_url": "https://api.github.com/users/htynkn/orgs", + "repos_url": "https://api.github.com/users/htynkn/repos", + "events_url": "https://api.github.com/users/htynkn/events{/privacy}", + "received_events_url": "https://api.github.com/users/htynkn/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "jebeaudet", + "id": 3722096, + "avatar_url": "https://avatars.githubusercontent.com/u/3722096?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/jebeaudet", + "html_url": "https://github.com/jebeaudet", + "followers_url": "https://api.github.com/users/jebeaudet/followers", + "following_url": "https://api.github.com/users/jebeaudet/following{/other_user}", + "gists_url": "https://api.github.com/users/jebeaudet/gists{/gist_id}", + "starred_url": "https://api.github.com/users/jebeaudet/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/jebeaudet/subscriptions", + "organizations_url": "https://api.github.com/users/jebeaudet/orgs", + "repos_url": "https://api.github.com/users/jebeaudet/repos", + "events_url": "https://api.github.com/users/jebeaudet/events{/privacy}", + "received_events_url": "https://api.github.com/users/jebeaudet/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "jmcampanini", + "id": 316848, + "avatar_url": "https://avatars.githubusercontent.com/u/316848?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/jmcampanini", + "html_url": "https://github.com/jmcampanini", + "followers_url": "https://api.github.com/users/jmcampanini/followers", + "following_url": "https://api.github.com/users/jmcampanini/following{/other_user}", + "gists_url": "https://api.github.com/users/jmcampanini/gists{/gist_id}", + "starred_url": "https://api.github.com/users/jmcampanini/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/jmcampanini/subscriptions", + "organizations_url": "https://api.github.com/users/jmcampanini/orgs", + "repos_url": "https://api.github.com/users/jmcampanini/repos", + "events_url": "https://api.github.com/users/jmcampanini/events{/privacy}", + "received_events_url": "https://api.github.com/users/jmcampanini/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "Randgalt", + "id": 264818, + "avatar_url": "https://avatars.githubusercontent.com/u/264818?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/Randgalt", + "html_url": "https://github.com/Randgalt", + "followers_url": "https://api.github.com/users/Randgalt/followers", + "following_url": "https://api.github.com/users/Randgalt/following{/other_user}", + "gists_url": "https://api.github.com/users/Randgalt/gists{/gist_id}", + "starred_url": "https://api.github.com/users/Randgalt/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/Randgalt/subscriptions", + "organizations_url": "https://api.github.com/users/Randgalt/orgs", + "repos_url": "https://api.github.com/users/Randgalt/repos", + "events_url": "https://api.github.com/users/Randgalt/events{/privacy}", + "received_events_url": "https://api.github.com/users/Randgalt/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "VanRoy", + "id": 1958756, + "avatar_url": "https://avatars.githubusercontent.com/u/1958756?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/VanRoy", + "html_url": "https://github.com/VanRoy", + "followers_url": "https://api.github.com/users/VanRoy/followers", + "following_url": "https://api.github.com/users/VanRoy/following{/other_user}", + "gists_url": "https://api.github.com/users/VanRoy/gists{/gist_id}", + "starred_url": "https://api.github.com/users/VanRoy/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/VanRoy/subscriptions", + "organizations_url": "https://api.github.com/users/VanRoy/orgs", + "repos_url": "https://api.github.com/users/VanRoy/repos", + "events_url": "https://api.github.com/users/VanRoy/events{/privacy}", + "received_events_url": "https://api.github.com/users/VanRoy/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + }, + { + "login": "mhurne", + "id": 677354, + "avatar_url": "https://avatars.githubusercontent.com/u/677354?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/mhurne", + "html_url": "https://github.com/mhurne", + "followers_url": "https://api.github.com/users/mhurne/followers", + "following_url": "https://api.github.com/users/mhurne/following{/other_user}", + "gists_url": "https://api.github.com/users/mhurne/gists{/gist_id}", + "starred_url": "https://api.github.com/users/mhurne/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/mhurne/subscriptions", + "organizations_url": "https://api.github.com/users/mhurne/orgs", + "repos_url": "https://api.github.com/users/mhurne/repos", + "events_url": "https://api.github.com/users/mhurne/events{/privacy}", + "received_events_url": "https://api.github.com/users/mhurne/received_events", + "type": "User", + "site_admin": false, + "contributions": 1 + } +] diff --git a/mvnw b/mvnw new file mode 100755 index 000000000..6ecc150ae --- /dev/null +++ b/mvnw @@ -0,0 +1,236 @@ +#!/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 + +# 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" + +# 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"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# 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 + +# avoid using MAVEN_CMD_LINE_ARGS below since that would loose parameter escaping in $@ +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 000000000..8e2b7459f --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,146 @@ +@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 + +# avoid using MAVEN_CMD_LINE_ARGS below since that would loose parameter escaping in %* +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +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 000000000..81f68373e --- /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/pom.xml b/okhttp/pom.xml new file mode 100644 index 000000000..761c2b7af --- /dev/null +++ b/okhttp/pom.xml @@ -0,0 +1,58 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.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 000000000..914a84679 --- /dev/null +++ b/okhttp/src/main/java/feign/okhttp/OkHttpClient.java @@ -0,0 +1,170 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.nio.charset.Charset; +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import feign.Client; +import feign.Request.HttpMethod; +import okhttp3.*; + +/** + * 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)) {
+        requestBuilder.addHeader(field, value);
+        if (field.equalsIgnoreCase("Content-Type")) {
+          mediaType = MediaType.parse(value);
+          if (input.charset() != null) {
+            mediaType.charset(input.charset());
+          }
+        }
+      }
+    }
+    // Some servers choke on the default accept string.
+    if (!hasAcceptHeader) {
+      requestBuilder.addHeader("Accept", "*/*");
+    }
+
+    byte[] inputBody = input.body();
+    boolean isMethodWithBody =
+        HttpMethod.POST == input.httpMethod() || HttpMethod.PUT == input.httpMethod()
+            || HttpMethod.PATCH == input.httpMethod();
+    if (isMethodWithBody) {
+      requestBuilder.removeHeader("Content-Type");
+      if (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.httpMethod().name(), body);
+    return requestBuilder.build();
+  }
+
+  private static feign.Response toFeignResponse(Response response, feign.Request request)
+      throws IOException {
+    return feign.Response.builder()
+        .status(response.code())
+        .reason(response.message())
+        .request(request)
+        .headers(toMap(response.headers()))
+        .body(toBody(response.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) {
+      if (input != null) {
+        input.close();
+      }
+      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 Reader asReader(Charset charset) throws IOException {
+        return asReader();
+      }
+    };
+  }
+
+  @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)
+          .followRedirects(options.isFollowRedirects())
+          .build();
+    } else {
+      requestScoped = delegate;
+    }
+    Request request = toOkHttpRequest(input);
+    Response response = requestScoped.newCall(request).execute();
+    return toFeignResponse(response, input).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 000000000..380c3b1f8
--- /dev/null
+++ b/okhttp/src/test/java/feign/okhttp/OkHttpClientTest.java
@@ -0,0 +1,106 @@
+/**
+ * Copyright 2012-2019 The Feign Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
+ * in compliance with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License
+ * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.Headers;
+import feign.RequestLine;
+import feign.Response;
+import feign.Request;
+import feign.Util;
+import feign.assertj.MockWebServerAssertions;
+import feign.client.AbstractClientTest;
+import feign.Feign;
+import java.util.Collections;
+import okhttp3.mockwebserver.MockResponse;
+import org.assertj.core.data.MapEntry;
+import org.junit.Test;
+import static org.junit.Assert.assertEquals;
+
+/** 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());
+  }
+
+
+  @Test
+  public void testContentTypeWithoutCharset() throws Exception {
+    server.enqueue(new MockResponse()
+        .setBody("AAAAAAAA"));
+    OkHttpClientTestInterface api = newBuilder()
+        .target(OkHttpClientTestInterface.class, "http://localhost:" + server.getPort());
+
+    Response response = api.getWithContentType();
+    // Response length should not be null
+    assertEquals("AAAAAAAA", Util.toString(response.body().asReader()));
+
+    MockWebServerAssertions.assertThat(server.takeRequest())
+        .hasHeaders(
+            MapEntry.entry("Accept", Collections.singletonList("text/plain")),
+            MapEntry.entry("Content-Type", Collections.singletonList("text/plain")))
+        .hasMethod("GET");
+  }
+
+
+  @Test
+  public void testNoFollowRedirect() throws Exception {
+    server.enqueue(
+        new MockResponse().setResponseCode(302).addHeader("Location", server.url("redirect")));
+
+    OkHttpClientTestInterface api = newBuilder()
+        .options(new Request.Options(1000, 1000, false))
+        .target(OkHttpClientTestInterface.class, "http://localhost:" + server.getPort());
+
+    Response response = api.get();
+    // Response length should not be null
+    assertEquals(302, response.status());
+    assertEquals(server.url("redirect").toString(),
+        response.headers().get("Location").iterator().next());
+
+  }
+
+
+  @Test
+  public void testFollowRedirect() throws Exception {
+    String expectedBody = "Hello";
+
+    server.enqueue(
+        new MockResponse().setResponseCode(302).addHeader("Location", server.url("redirect")));
+    server.enqueue(new MockResponse().setBody(expectedBody));
+
+    OkHttpClientTestInterface api = newBuilder()
+        .options(new Request.Options(1000, 1000, true))
+        .target(OkHttpClientTestInterface.class, "http://localhost:" + server.getPort());
+
+    Response response = api.get();
+    // Response length should not be null
+    assertEquals(200, response.status());
+    assertEquals(expectedBody, response.body().toString());
+
+  }
+
+
+  public interface OkHttpClientTestInterface {
+
+    @RequestLine("GET /")
+    @Headers({"Accept: text/plain", "Content-Type: text/plain"})
+    Response getWithContentType();
+
+    @RequestLine("GET /")
+    Response get();
+  }
+}
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 000000000..a6ec82be0
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,706 @@
+
+
+
+  4.0.0
+
+  io.github.openfeign
+  parent
+  10.2.1-SNAPSHOT
+  pom
+
+  Feign (Parent)
+  Feign makes writing java http clients easier
+
+  
+    core
+    gson
+    httpclient
+    hystrix
+    jackson
+    jackson-jaxb
+    jaxb
+    jaxrs
+    jaxrs2
+    okhttp
+    ribbon
+    sax
+    slf4j
+    soap
+    reactive
+    example-github
+    example-wikipedia
+    
+    java8
+    mock
+    benchmark
+  
+
+  
+    UTF-8
+    UTF-8
+
+    
+    -Duser.language=en
+
+    
+    1.8
+    java18
+
+    
+    1.8
+    1.8
+
+    ${project.basedir}
+
+    3.6.0
+    2.5
+    1.7.13
+    1.60
+
+    4.12
+    2.9.8
+    3.10.0
+
+    1.17
+    3.8.0
+    2.5.2
+    3.0.1
+    3.0.1
+    3.0
+    3.1.0
+    2.5.3
+    4.0.0
+    0.1.0
+    2.22.0
+    0.14.3
+    file://${project.basedir}/src/config/bom.xml
+  
+  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
+    
+    
+      velo
+      Marvin Herman Froeder
+      velo br at gmail dot com
+      about.me/velo
+    
+  
+
+  
+    
+      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-jaxrs
+        ${project.version}
+        test-jar
+      
+
+      
+        ${project.groupId}
+        feign-jaxrs2
+        ${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}
+      
+
+      
+        org.bouncycastle
+        bcprov-jdk15on
+        ${bouncy.version}
+      
+
+      
+        com.fasterxml.jackson.core
+        jackson-databind
+        ${jackson.version}
+      
+
+      
+        com.fasterxml.jackson.core
+        jackson-core
+        ${jackson.version}
+      
+
+      
+        com.fasterxml.jackson.core
+        jackson-annotations
+        ${jackson.version}
+      
+
+      
+        org.slf4j
+        slf4j-simple
+        ${slf4j.version}
+      
+
+      
+        org.slf4j
+        slf4j-nop
+        ${slf4j.version}
+      
+
+
+    
+  
+
+  
+    
+      junit
+      junit
+      test
+    
+
+    
+      org.assertj
+      assertj-core
+      test
+    
+  
+
+  
+    
+      
+        
+        
+          io.takari
+          maven
+          0.3.4
+        
+
+        
+          maven-compiler-plugin
+          ${maven-compiler-plugin.version}
+        
+
+        
+          maven-jar-plugin
+          ${maven-jar-plugin.version}
+          
+            
+              ${project.build.outputDirectory}/META-INF/MANIFEST.MF
+            
+          
+        
+
+        
+          org.apache.maven.plugins
+          maven-surefire-plugin
+          ${maven-surefire-plugin.version}
+          
+            true
+            false
+            ${jvm.options}
+          
+          
+            
+              
+              org.ow2.asm
+              asm
+              7.0-beta
+            
+          
+        
+      
+    
+
+    
+      
+        maven-compiler-plugin
+        true
+        
+          
+          
+            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
+            
+          
+        
+        
+          
+            org.ow2.asm
+            asm
+            7.0-beta
+          
+        
+      
+
+      
+      
+        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
+            
+          
+        
+      
+      
+        com.mycila
+        license-maven-plugin
+        ${license-maven-plugin.version}
+        
+          
+          
${main.basedir}/src/etc/header.txt
+ + .travis.yml + .editorconfig + .gitattributes + .gitignore + .mvn/** + mvnw* + etc/header.txt + **/.idea/** + **/target/** + LICENSE + **/*.md + bnd.bnd + src/test/resources/** + src/main/resources/** + + true +
+ + + compile + + check + + + + + + com.mycila + license-maven-plugin-git + ${license-maven-plugin.version} + + +
+ + com.marvinformatics.formatter + formatter-maven-plugin + 2.2.0 + + LF + ${main.basedir}/src/config/eclipse-java-style.xml + + + + verify + + format + + + + + + + com.github.ekryd.sortpom + sortpom-maven-plugin + 2.8.0 + + true + \n + src/config/pomSortOrder.xml + false + + + + format + verify + + sort + + + + + + io.sundr + sundr-maven-plugin + ${bom-generator.version} + false + + ${bom.template.file.path} + + + feign-bom + Feign (Bill Of Materials) + + + true + + + + + io.github.openfeign:* + + + *:feign-example-* + *:feign-benchmark + + + + + + + + + generate-bom + + + + + + io.sundr + sundr-codegen + ${bom-generator.version} + + + com.sun + tools + + + + + +
+
+ + + + windows + + + Windows + + + + file:///${project.basedir}/src/config/bom.xml + + + + java11 + + 11 + + + + java11 + + + + + + org.apache.maven.plugins + maven-release-plugin + + + true + + + + + + + + validateCodeFormat + + + + + com.marvinformatics.formatter + formatter-maven-plugin + 2.2.0 + + LF + ${main.basedir}/src/config/eclipse-java-style.xml + + + + validate-only + + validate + + initialize + + + + + + + + + release + + + + + org.apache.maven.plugins + maven-source-plugin + ${maven-source-plugin.version} + + + attach-sources + + jar + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + false + + + + attach-javadocs + + jar + + package + + + + + + + +
diff --git a/reactive/README.md b/reactive/README.md new file mode 100644 index 000000000..3879e2c89 --- /dev/null +++ b/reactive/README.md @@ -0,0 +1,113 @@ +Reactive Streams Wrapper +--- + +This module wraps Feign's http requests in a [Reactive Streams](https://reactive-streams.org) +Publisher, enabling the use of Reactive Stream `Publisher` return types. Supported Reactive Streams implementations are: + +* [Reactor](https://projectreactor.io/ (`Mono` and `Flux`) +* [ReactiveX (RxJava)](https://reactivex.io) (`Flowable` only) + +To use these wrappers, add the `feign-reactive-wrappers` module, and your desired `reactive-streams` +implementation to your classpath. Then configure Feign to use the reactive streams wrappers. + +```java +public interface GitHubReactor { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + Flux contributors(@Param("owner") String owner, @Param("repo") String repo); + + class Contributor { + String login; + + public Contributor(String login) { + this.login = login; + } + } +} + +public class ExampleReactor { + public static void main(String args[]) { + GitHubReactor gitHub = ReactorFeign.builder() + .target(GitHubReactor.class, "https://api.github.com"); + + List contributors = gitHub.contributors("OpenFeign", "feign") + .map(Contributor::new) + .collect(Collectors.toList()) + .block(); + } +} + +public interface GitHubReactiveX { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + Flowable contributors(@Param("owner") String owner, @Param("repo") String repo); + + class Contributor { + String login; + + public Contributor(String login) { + this.login = login; + } + } +} + +public class ExampleRxJava2 { + public static void main(String args[]) { + GitHubReactiveX gitHub = RxJavaFeign.builder() + .target(GitHub.class, "https://api.github.com"); + + List contributors = gitHub.contributors("OpenFeign", "feign") + .map(Contributor::new) + .collect(Collectors.toList()) + .block(); + } +} + +``` + +Considerations +--- + +These wrappers are not *reactive all the way down*, given that Feign generated requests are +synchronous. Requests still block, but execution is controlled by the `Publisher` and their +related `Scheduler`. While this may not be ideal in terms of a fully reactive application, providing these +wrappers provide an intermediate upgrade path for Feign. + +### Streaming + +Methods that return `java.util.streams` Types are not supported. Responses are read fully, +the wrapped in the appropriate reactive wrappers. + +### Iterable and Collections responses + +Due to the Synchronous nature of Feign requests, methods that return `Iterable` types must specify the collection +in the `Publisher`. For `Reactor` types, this limits the use of `Flux` as a response type. If you +want to use `Flux`, you will need to manually convert the `Mono` or `Iterable` response types into +`Flux` using the `fromIterable` method. + + +```java +public interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + Mono> contributors(@Param("owner") String owner, @Param("repo") String repo); + + class Contributor { + String login; + + public Contributor(String login) { + this.login = login; + } + } +} + +public class ExampleApplication { + public static void main(String[] args) { + GitHub gitHub = ReactorFeign.builder() + .target(GitHub.class, "https://api.github.com"); + + Mono> contributors = gitHub.contributors("OpenFeign", "feign"); + Flux contributorFlux = Flux.fromIterable(contributors.block()); + } +} +``` diff --git a/reactive/pom.xml b/reactive/pom.xml new file mode 100644 index 000000000..6c55f645d --- /dev/null +++ b/reactive/pom.xml @@ -0,0 +1,93 @@ + + + + 4.0.0 + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + feign-reactive-wrappers + + Feign Reactive Wrappers + Reactive Wrapper for Feign Clients + + + ${project.basedir}/.. + 3.1.8.RELEASE + 1.0.2 + 2.2.2 + 1.9.5 + + + + + io.github.openfeign + feign-core + ${project.version} + + + org.reactivestreams + reactive-streams + ${reactive.streams.version} + + + io.projectreactor + reactor-core + ${reactor.version} + provided + true + + + io.reactivex.rxjava2 + rxjava + ${reactivex.version} + provided + true + + + org.mockito + mockito-all + ${mockito.version} + test + + + io.github.openfeign + feign-jackson + ${project.version} + test + + + io.github.openfeign + feign-okhttp + ${project.version} + test + + + io.github.openfeign + feign-jaxrs + ${project.version} + test + + + com.squareup.okhttp3 + mockwebserver + test + + + + diff --git a/reactive/src/main/java/feign/reactive/ReactiveDelegatingContract.java b/reactive/src/main/java/feign/reactive/ReactiveDelegatingContract.java new file mode 100644 index 000000000..5ced302af --- /dev/null +++ b/reactive/src/main/java/feign/reactive/ReactiveDelegatingContract.java @@ -0,0 +1,81 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.reactive; + +import feign.Contract; +import feign.MethodMetadata; +import feign.Types; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; +import org.reactivestreams.Publisher; + +public class ReactiveDelegatingContract implements Contract { + + private final Contract delegate; + + ReactiveDelegatingContract(Contract delegate) { + this.delegate = delegate; + } + + @Override + public List parseAndValidatateMetadata(Class targetType) { + List methodsMetadata = this.delegate.parseAndValidatateMetadata(targetType); + + for (final MethodMetadata metadata : methodsMetadata) { + final Type type = metadata.returnType(); + if (!isReactive(type)) { + throw new IllegalArgumentException(String.format( + "Method %s of contract %s doesn't returns a org.reactivestreams.Publisher", + metadata.configKey(), targetType.getSimpleName())); + } + + /* + * we will need to change the return type of the method to match the return type contained + * within the Publisher + */ + Type[] actualTypes = ((ParameterizedType) type).getActualTypeArguments(); + if (actualTypes.length > 1) { + throw new IllegalStateException("Expected only one contained type."); + } else { + Class actual = Types.getRawType(actualTypes[0]); + if (Stream.class.isAssignableFrom(actual)) { + throw new IllegalArgumentException( + "Streams are not supported when using Reactive Wrappers"); + } + metadata.returnType(actualTypes[0]); + } + } + + return methodsMetadata; + } + + /** + * Ensure that the type provided implements a Reactive Streams Publisher. + * + * @param type to inspect. + * @return true if the type implements the Reactive Streams Publisher specification. + */ + private boolean isReactive(Type type) { + if (!ParameterizedType.class.isAssignableFrom(type.getClass())) { + return false; + } + ParameterizedType parameterizedType = (ParameterizedType) type; + Type raw = parameterizedType.getRawType(); + return Arrays.asList(((Class) raw).getInterfaces()) + .contains(Publisher.class); + } +} diff --git a/reactive/src/main/java/feign/reactive/ReactiveFeign.java b/reactive/src/main/java/feign/reactive/ReactiveFeign.java new file mode 100644 index 000000000..96535c0fc --- /dev/null +++ b/reactive/src/main/java/feign/reactive/ReactiveFeign.java @@ -0,0 +1,60 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.reactive; + +import feign.Contract; +import feign.Feign; +import feign.InvocationHandlerFactory; + +abstract class ReactiveFeign { + + + + public static class Builder extends Feign.Builder { + + private Contract contract = new Contract.Default(); + + /** + * Extend the current contract to support Reactive Stream return types. + * + * @param contract to extend. + * @return a Builder for chaining. + */ + @Override + public Builder contract(Contract contract) { + this.contract = contract; + return this; + } + + /** + * Build the Feign instance. + * + * @return a new Feign Instance. + */ + @Override + public Feign build() { + if (!(this.contract instanceof ReactiveDelegatingContract)) { + super.contract(new ReactiveDelegatingContract(this.contract)); + } else { + super.contract(this.contract); + } + return super.build(); + } + + @Override + public Feign.Builder doNotCloseAfterDecode() { + throw new UnsupportedOperationException("Streaming Decoding is not supported."); + } + } +} diff --git a/reactive/src/main/java/feign/reactive/ReactiveInvocationHandler.java b/reactive/src/main/java/feign/reactive/ReactiveInvocationHandler.java new file mode 100644 index 000000000..16cdb7a11 --- /dev/null +++ b/reactive/src/main/java/feign/reactive/ReactiveInvocationHandler.java @@ -0,0 +1,111 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.reactive; + +import feign.FeignException; +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Target; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.lang.reflect.Type; +import java.util.Map; +import java.util.concurrent.Callable; +import org.reactivestreams.Publisher; + +public abstract class ReactiveInvocationHandler implements InvocationHandler { + + private final Target target; + private final Map dispatch; + + public ReactiveInvocationHandler(Target target, + Map dispatch) { + this.target = target; + this.dispatch = dispatch; + } + + @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 this.invoke(method, this.dispatch.get(method), args); + } + + @Override + public int hashCode() { + return this.target.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (ReactiveInvocationHandler.class.isAssignableFrom(obj.getClass())) { + return this.target.equals(obj); + } + return false; + } + + @Override + public String toString() { + return "Target [" + this.target.toString() + "]"; + } + + /** + * Invoke the Method Handler. + * + * @param method on the Target to invoke. + * @param methodHandler to invoke + * @param arguments for the method + * @return a reactive {@link Publisher} for the invocation. + */ + protected abstract Publisher invoke(Method method, + MethodHandler methodHandler, + Object[] arguments); + + /** + * Invoke the Method Handler as a Callable. + * + * @param methodHandler to invoke + * @param arguments for the method + * @return a Callable wrapper for the invocation. + */ + Callable invokeMethod(MethodHandler methodHandler, Object[] arguments) { + return () -> { + try { + return methodHandler.invoke(arguments); + } catch (Throwable th) { + if (th instanceof FeignException) { + throw (FeignException) th; + } + throw new RuntimeException(th); + } + }; + } +} diff --git a/reactive/src/main/java/feign/reactive/ReactorFeign.java b/reactive/src/main/java/feign/reactive/ReactorFeign.java new file mode 100644 index 000000000..33278dfc2 --- /dev/null +++ b/reactive/src/main/java/feign/reactive/ReactorFeign.java @@ -0,0 +1,52 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.reactive; + +import feign.Feign; +import feign.reactive.ReactiveFeign.Builder; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.util.Map; +import feign.InvocationHandlerFactory; +import feign.Target; + +public class ReactorFeign extends ReactiveFeign { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends ReactiveFeign.Builder { + + @Override + public Feign build() { + super.invocationHandlerFactory(new ReactorInvocationHandlerFactory()); + return super.build(); + } + + @Override + public Feign.Builder invocationHandlerFactory( + InvocationHandlerFactory invocationHandlerFactory) { + throw new UnsupportedOperationException( + "Invocation Handler Factory overrides are not supported."); + } + } + + private static class ReactorInvocationHandlerFactory implements InvocationHandlerFactory { + @Override + public InvocationHandler create(Target target, Map dispatch) { + return new ReactorInvocationHandler(target, dispatch); + } + } +} diff --git a/reactive/src/main/java/feign/reactive/ReactorInvocationHandler.java b/reactive/src/main/java/feign/reactive/ReactorInvocationHandler.java new file mode 100644 index 000000000..42d13a019 --- /dev/null +++ b/reactive/src/main/java/feign/reactive/ReactorInvocationHandler.java @@ -0,0 +1,44 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.reactive; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Target; +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.Callable; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +public class ReactorInvocationHandler extends ReactiveInvocationHandler { + + ReactorInvocationHandler(Target target, + Map dispatch) { + super(target, dispatch); + } + + @Override + protected Publisher invoke(Method method, MethodHandler methodHandler, Object[] arguments) { + Callable invocation = this.invokeMethod(methodHandler, arguments); + if (Flux.class.isAssignableFrom(method.getReturnType())) { + return Flux.from(Mono.fromCallable(invocation)).subscribeOn(Schedulers.elastic()); + } else if (Mono.class.isAssignableFrom(method.getReturnType())) { + return Mono.fromCallable(invocation).subscribeOn(Schedulers.elastic()); + } + throw new IllegalArgumentException( + "Return type " + method.getReturnType().getName() + " is not supported"); + } +} diff --git a/reactive/src/main/java/feign/reactive/RxJavaFeign.java b/reactive/src/main/java/feign/reactive/RxJavaFeign.java new file mode 100644 index 000000000..89ea35640 --- /dev/null +++ b/reactive/src/main/java/feign/reactive/RxJavaFeign.java @@ -0,0 +1,53 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.reactive; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.util.Map; +import feign.Feign; +import feign.InvocationHandlerFactory; +import feign.Target; + +public class RxJavaFeign extends ReactiveFeign { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder extends ReactiveFeign.Builder { + + @Override + public Feign build() { + super.invocationHandlerFactory(new RxJavaInvocationHandlerFactory()); + return super.build(); + } + + @Override + public Feign.Builder invocationHandlerFactory( + InvocationHandlerFactory invocationHandlerFactory) { + throw new UnsupportedOperationException( + "Invocation Handler Factory overrides are not supported."); + } + + } + + private static class RxJavaInvocationHandlerFactory implements InvocationHandlerFactory { + @Override + public InvocationHandler create(Target target, Map dispatch) { + return new RxJavaInvocationHandler(target, dispatch); + } + } + +} diff --git a/reactive/src/main/java/feign/reactive/RxJavaInvocationHandler.java b/reactive/src/main/java/feign/reactive/RxJavaInvocationHandler.java new file mode 100644 index 000000000..4e694f901 --- /dev/null +++ b/reactive/src/main/java/feign/reactive/RxJavaInvocationHandler.java @@ -0,0 +1,36 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.reactive; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Target; +import io.reactivex.Flowable; +import io.reactivex.schedulers.Schedulers; +import java.lang.reflect.Method; +import java.util.Map; +import org.reactivestreams.Publisher; + +public class RxJavaInvocationHandler extends ReactiveInvocationHandler { + + RxJavaInvocationHandler(Target target, + Map dispatch) { + super(target, dispatch); + } + + @Override + protected Publisher invoke(Method method, MethodHandler methodHandler, Object[] arguments) { + return Flowable.fromCallable(this.invokeMethod(methodHandler, arguments)) + .observeOn(Schedulers.trampoline()); + } +} diff --git a/reactive/src/test/java/feign/reactive/ReactiveDelegatingContractTest.java b/reactive/src/test/java/feign/reactive/ReactiveDelegatingContractTest.java new file mode 100644 index 000000000..6ccc265d3 --- /dev/null +++ b/reactive/src/test/java/feign/reactive/ReactiveDelegatingContractTest.java @@ -0,0 +1,84 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.reactive; + +import feign.Contract; +import feign.Param; +import feign.RequestLine; +import feign.reactive.ReactiveDelegatingContract; +import io.reactivex.Flowable; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class ReactiveDelegatingContractTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void onlyReactiveReturnTypesSupported() { + this.thrown.expect(IllegalArgumentException.class); + Contract contract = new ReactiveDelegatingContract(new Contract.Default()); + contract.parseAndValidatateMetadata(TestSynchronousService.class); + } + + @Test + public void reactorTypes() { + Contract contract = new ReactiveDelegatingContract(new Contract.Default()); + contract.parseAndValidatateMetadata(TestReactorService.class); + } + + @Test + public void reactivexTypes() { + Contract contract = new ReactiveDelegatingContract(new Contract.Default()); + contract.parseAndValidatateMetadata(TestReactiveXService.class); + } + + @Test + public void streamsAreNotSupported() { + this.thrown.expect(IllegalArgumentException.class); + Contract contract = new ReactiveDelegatingContract(new Contract.Default()); + contract.parseAndValidatateMetadata(StreamsService.class); + } + + public interface TestSynchronousService { + @RequestLine("GET /version") + String version(); + } + + public interface TestReactiveXService { + @RequestLine("GET /version") + Flowable version(); + } + + + public interface TestReactorService { + @RequestLine("GET /version") + Mono version(); + + @RequestLine("GET /users/{username}") + Flux user(@Param("username") String username); + } + + public interface StreamsService { + + @RequestLine("GET /version") + Mono> version(); + } + +} diff --git a/reactive/src/test/java/feign/reactive/ReactiveFeignIntegrationTest.java b/reactive/src/test/java/feign/reactive/ReactiveFeignIntegrationTest.java new file mode 100644 index 000000000..e45dc73b9 --- /dev/null +++ b/reactive/src/test/java/feign/reactive/ReactiveFeignIntegrationTest.java @@ -0,0 +1,341 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.reactive; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import feign.Client; +import feign.InvocationHandlerFactory; +import feign.Logger; +import feign.Logger.Level; +import feign.Param; +import feign.QueryMap; +import feign.QueryMapEncoder; +import feign.Request; +import feign.Request.Options; +import feign.RequestInterceptor; +import feign.RequestLine; +import feign.RequestTemplate; +import feign.Response; +import feign.ResponseMapper; +import feign.RetryableException; +import feign.Retryer; +import feign.Target; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import feign.jaxrs.JAXRSContract; +import io.reactivex.Flowable; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.AdditionalAnswers; +import org.mockito.stubbing.Answer; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class ReactiveFeignIntegrationTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public final MockWebServer webServer = new MockWebServer(); + + private String getServerUrl() { + return "http://localhost:" + this.webServer.getPort(); + } + + @Test + public void testDefaultMethodsNotProxied() { + TestReactorService service = ReactorFeign.builder() + .target(TestReactorService.class, this.getServerUrl()); + assertThat(service).isEqualTo(service); + assertThat(service.toString()).isNotNull(); + assertThat(service.hashCode()).isNotZero(); + } + + @Test + public void testReactorTargetFull() throws Exception { + this.webServer.enqueue(new MockResponse().setBody("1.0")); + this.webServer.enqueue(new MockResponse().setBody("{ \"username\": \"test\" }")); + + TestReactorService service = ReactorFeign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .logger(new ConsoleLogger()) + .decode404() + .options(new Options()) + .logLevel(Level.FULL) + .target(TestReactorService.class, this.getServerUrl()); + assertThat(service).isNotNull(); + + String version = service.version() + .block(); + assertThat(version).isNotNull(); + assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/version"); + + + /* test encoding and decoding */ + User user = service.user("test") + .blockFirst(); + assertThat(user).isNotNull().hasFieldOrPropertyWithValue("username", "test"); + assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/users/test"); + + } + + @Test + public void testRxJavaTarget() throws Exception { + this.webServer.enqueue(new MockResponse().setBody("1.0")); + this.webServer.enqueue(new MockResponse().setBody("{ \"username\": \"test\" }")); + + TestReactiveXService service = RxJavaFeign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .logger(new ConsoleLogger()) + .logLevel(Level.FULL) + .target(TestReactiveXService.class, this.getServerUrl()); + assertThat(service).isNotNull(); + + String version = service.version() + .firstElement().blockingGet(); + assertThat(version).isNotNull(); + assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/version"); + + /* test encoding and decoding */ + User user = service.user("test") + .firstElement().blockingGet(); + assertThat(user).isNotNull().hasFieldOrPropertyWithValue("username", "test"); + assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/users/test"); + } + + @Test + public void invocationFactoryIsNotSupported() { + this.thrown.expect(UnsupportedOperationException.class); + ReactorFeign.builder() + .invocationHandlerFactory( + (target, dispatch) -> null) + .target(TestReactiveXService.class, "http://localhost"); + } + + @Test + public void doNotCloseUnsupported() { + this.thrown.expect(UnsupportedOperationException.class); + ReactorFeign.builder() + .doNotCloseAfterDecode() + .target(TestReactiveXService.class, "http://localhost"); + } + + @Test + public void testRequestInterceptor() { + this.webServer.enqueue(new MockResponse().setBody("1.0")); + + RequestInterceptor mockInterceptor = mock(RequestInterceptor.class); + TestReactorService service = ReactorFeign.builder() + .requestInterceptor(mockInterceptor) + .target(TestReactorService.class, this.getServerUrl()); + service.version().block(); + verify(mockInterceptor, times(1)).apply(any(RequestTemplate.class)); + } + + @Test + public void testRequestInterceptors() { + this.webServer.enqueue(new MockResponse().setBody("1.0")); + + RequestInterceptor mockInterceptor = mock(RequestInterceptor.class); + TestReactorService service = ReactorFeign.builder() + .requestInterceptors(Arrays.asList(mockInterceptor, mockInterceptor)) + .target(TestReactorService.class, this.getServerUrl()); + service.version().block(); + verify(mockInterceptor, times(2)).apply(any(RequestTemplate.class)); + } + + @Test + public void testResponseMappers() throws Exception { + this.webServer.enqueue(new MockResponse().setBody("1.0")); + + ResponseMapper responseMapper = mock(ResponseMapper.class); + Decoder decoder = mock(Decoder.class); + given(responseMapper.map(any(Response.class), any(Type.class))) + .willAnswer(AdditionalAnswers.returnsFirstArg()); + given(decoder.decode(any(Response.class), any(Type.class))).willReturn("1.0"); + + TestReactorService service = ReactorFeign.builder() + .mapAndDecode(responseMapper, decoder) + .target(TestReactorService.class, this.getServerUrl()); + service.version().block(); + verify(responseMapper, times(1)) + .map(any(Response.class), any(Type.class)); + verify(decoder, times(1)).decode(any(Response.class), any(Type.class)); + } + + @Test + public void testQueryMapEncoders() { + this.webServer.enqueue(new MockResponse().setBody("No Results Found")); + + QueryMapEncoder encoder = mock(QueryMapEncoder.class); + given(encoder.encode(any(Object.class))).willReturn(Collections.emptyMap()); + TestReactiveXService service = RxJavaFeign.builder() + .queryMapEncoder(encoder) + .target(TestReactiveXService.class, this.getServerUrl()); + String results = service.search(new SearchQuery()) + .blockingSingle(); + assertThat(results).isNotEmpty(); + verify(encoder, times(1)).encode(any(Object.class)); + } + + @SuppressWarnings({"ResultOfMethodCallIgnored", "ThrowableNotThrown"}) + @Test + public void testErrorDecoder() { + this.thrown.expect(RuntimeException.class); + this.webServer.enqueue(new MockResponse().setBody("Bad Request").setResponseCode(400)); + + ErrorDecoder errorDecoder = mock(ErrorDecoder.class); + given(errorDecoder.decode(anyString(), any(Response.class))) + .willReturn(new IllegalStateException("bad request")); + + TestReactiveXService service = RxJavaFeign.builder() + .errorDecoder(errorDecoder) + .target(TestReactiveXService.class, this.getServerUrl()); + service.search(new SearchQuery()) + .blockingSingle(); + verify(errorDecoder, times(1)).decode(anyString(), any(Response.class)); + } + + @Test + public void testRetryer() { + this.webServer.enqueue(new MockResponse().setBody("Not Available").setResponseCode(-1)); + this.webServer.enqueue(new MockResponse().setBody("1.0")); + + Retryer retryer = new Retryer.Default(); + Retryer spy = spy(retryer); + when(spy.clone()).thenReturn(spy); + TestReactorService service = ReactorFeign.builder() + .retryer(spy) + .target(TestReactorService.class, this.getServerUrl()); + service.version().log().block(); + verify(spy, times(1)).continueOrPropagate(any(RetryableException.class)); + } + + @Test + public void testClient() throws Exception { + Client client = mock(Client.class); + given(client.execute(any(Request.class), any(Options.class))) + .willAnswer((Answer) invocation -> Response.builder() + .status(200) + .headers(Collections.emptyMap()) + .body("1.0", Charset.defaultCharset()) + .request((Request) invocation.getArguments()[0]) + .build()); + + TestReactorService service = ReactorFeign.builder() + .client(client) + .target(TestReactorService.class, this.getServerUrl()); + service.version().block(); + verify(client, times(1)).execute(any(Request.class), any(Options.class)); + } + + @Test + public void testDifferentContract() throws Exception { + this.webServer.enqueue(new MockResponse().setBody("1.0")); + + TestJaxRSReactorService service = ReactorFeign.builder() + .contract(new JAXRSContract()) + .target(TestJaxRSReactorService.class, this.getServerUrl()); + String version = service.version().block(); + assertThat(version).isNotNull(); + assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/version"); + } + + + interface TestReactorService { + @RequestLine("GET /version") + Mono version(); + + @RequestLine("GET /users/{username}") + Flux user(@Param("username") String username); + } + + + interface TestReactiveXService { + @RequestLine("GET /version") + Flowable version(); + + @RequestLine("GET /users/{username}") + Flowable user(@Param("username") String username); + + @RequestLine("GET /users/search") + Flowable search(@QueryMap SearchQuery query); + } + + interface TestJaxRSReactorService { + + @Path("/version") + @GET + Mono version(); + } + + + @SuppressWarnings("unused") + static class User { + private String username; + + public User() { + super(); + } + + public String getUsername() { + return username; + } + } + + + @SuppressWarnings("unused") + static class SearchQuery { + SearchQuery() { + super(); + } + + public String query() { + return "query"; + } + } + + + public static class ConsoleLogger extends Logger { + @Override + protected void log(String configKey, String format, Object... args) { + System.out.println(String.format(methodTag(configKey) + format, args)); + } + } +} diff --git a/reactive/src/test/java/feign/reactive/ReactiveInvocationHandlerTest.java b/reactive/src/test/java/feign/reactive/ReactiveInvocationHandlerTest.java new file mode 100644 index 000000000..5c64976a5 --- /dev/null +++ b/reactive/src/test/java/feign/reactive/ReactiveInvocationHandlerTest.java @@ -0,0 +1,132 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.reactive; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import feign.FeignException; +import feign.InvocationHandlerFactory.MethodHandler; +import feign.RequestLine; +import feign.Target; +import feign.reactive.ReactorInvocationHandler; +import feign.reactive.RxJavaInvocationHandler; +import io.reactivex.Flowable; +import java.lang.reflect.Method; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import reactor.core.publisher.Mono; + +@RunWith(MockitoJUnitRunner.class) +public class ReactiveInvocationHandlerTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Mock + private Target target; + + @Mock + private MethodHandler methodHandler; + + private Method method; + + @SuppressWarnings("unchecked") + @Test + public void invokeOnSubscribeReactor() throws Throwable { + Method method = TestReactorService.class.getMethod("version"); + ReactorInvocationHandler handler = new ReactorInvocationHandler(this.target, + Collections.singletonMap(method, this.methodHandler)); + + Object result = handler.invoke(method, this.methodHandler, new Object[] {}); + assertThat(result).isInstanceOf(Mono.class); + verifyZeroInteractions(this.methodHandler); + + /* subscribe and execute the method */ + Mono mono = (Mono) result; + mono.log().block(); + verify(this.methodHandler, times(1)).invoke(any()); + } + + @SuppressWarnings("unchecked") + @Test + public void invokeFailureReactor() throws Throwable { + this.thrown.expect(RuntimeException.class); + given(this.methodHandler.invoke(any())).willThrow(new RuntimeException("Could Not Decode")); + given(this.method.getReturnType()).willReturn((Class) Class.forName(Mono.class.getName())); + ReactorInvocationHandler handler = new ReactorInvocationHandler(this.target, + Collections.singletonMap(this.method, this.methodHandler)); + + Object result = handler.invoke(this.method, this.methodHandler, new Object[] {}); + assertThat(result).isInstanceOf(Mono.class); + verifyZeroInteractions(this.methodHandler); + + /* subscribe and execute the method, should result in an error */ + Mono mono = (Mono) result; + mono.log().block(); + verify(this.methodHandler, times(1)).invoke(any()); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + @Test + public void invokeOnSubscribeRxJava() throws Throwable { + given(this.methodHandler.invoke(any())).willReturn("Result"); + RxJavaInvocationHandler handler = + new RxJavaInvocationHandler(this.target, + Collections.singletonMap(this.method, this.methodHandler)); + + Object result = handler.invoke(this.method, this.methodHandler, new Object[] {}); + assertThat(result).isInstanceOf(Flowable.class); + verifyZeroInteractions(this.methodHandler); + + /* subscribe and execute the method */ + Flowable flow = (Flowable) result; + flow.firstElement().blockingGet(); + verify(this.methodHandler, times(1)).invoke(any()); + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + @Test + public void invokeFailureRxJava() throws Throwable { + this.thrown.expect(RuntimeException.class); + given(this.methodHandler.invoke(any())).willThrow(new RuntimeException("Could Not Decode")); + RxJavaInvocationHandler handler = + new RxJavaInvocationHandler(this.target, + Collections.singletonMap(this.method, this.methodHandler)); + + Object result = handler.invoke(this.method, this.methodHandler, new Object[] {}); + assertThat(result).isInstanceOf(Flowable.class); + verifyZeroInteractions(this.methodHandler); + + /* subscribe and execute the method */ + Flowable flow = (Flowable) result; + flow.firstElement().blockingGet(); + verify(this.methodHandler, times(1)).invoke(any()); + } + + + public interface TestReactorService { + @RequestLine("GET /version") + Mono version(); + } + +} diff --git a/ribbon/README.md b/ribbon/README.md new file mode 100644 index 000000000..4de2eba3f --- /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/pom.xml b/ribbon/pom.xml new file mode 100644 index 000000000..874ae7a3f --- /dev/null +++ b/ribbon/pom.xml @@ -0,0 +1,66 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + + feign-ribbon + Feign Ribbon + Feign Ribbon + + + ${project.basedir}/.. + 2.3.0 + + + + + ${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 000000000..ebffbfc44 --- /dev/null +++ b/ribbon/src/main/java/feign/ribbon/LBClient.java @@ -0,0 +1,187 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.config.CommonClientConfigKey; +import com.netflix.client.config.IClientConfig; +import com.netflix.loadbalancer.ILoadBalancer; +import feign.Request.HttpMethod; +import java.io.IOException; +import java.net.URI; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import feign.Client; +import feign.Request; +import feign.Response; +import feign.Util; + +public final class LBClient extends + AbstractLoadBalancerAwareClient { + + private final int connectTimeout; + private final int readTimeout; + private final IClientConfig clientConfig; + private final Set retryableStatusCodes; + private final Boolean followRedirects; + + public static LBClient create(ILoadBalancer lb, IClientConfig clientConfig) { + return new LBClient(lb, clientConfig); + } + + static Set parseStatusCodes(String statusCodesString) { + if (statusCodesString == null || statusCodesString.isEmpty()) { + return Collections.emptySet(); + } + Set codes = new LinkedHashSet(); + for (String codeString : statusCodesString.split(",")) { + codes.add(Integer.parseInt(codeString)); + } + return codes; + } + + LBClient(ILoadBalancer lb, IClientConfig clientConfig) { + super(lb, clientConfig); + this.clientConfig = clientConfig; + connectTimeout = clientConfig.get(CommonClientConfigKey.ConnectTimeout); + readTimeout = clientConfig.get(CommonClientConfigKey.ReadTimeout); + retryableStatusCodes = parseStatusCodes(clientConfig.get(LBClientFactory.RetryableStatusCodes)); + followRedirects = clientConfig.get(CommonClientConfigKey.FollowRedirects); + } + + @Override + public RibbonResponse execute(RibbonRequest request, IClientConfig configOverride) + throws IOException, ClientException { + Request.Options options; + if (configOverride != null) { + options = + new Request.Options( + configOverride.get(CommonClientConfigKey.ConnectTimeout, connectTimeout), + (configOverride.get(CommonClientConfigKey.ReadTimeout, readTimeout)), + configOverride.get(CommonClientConfigKey.FollowRedirects, followRedirects)); + } else { + options = new Request.Options(connectTimeout, readTimeout); + } + Response response = request.client().execute(request.toRequest(), options); + if (retryableStatusCodes.contains(response.status())) { + response.close(); + throw new ClientException(ClientException.ErrorType.SERVER_THROTTLED); + } + 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().httpMethod() != HttpMethod.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() { + // add header "Content-Length" according to the request body + final byte[] body = request.body(); + final int bodyLength = body != null ? body.length : 0; + // create a new Map to avoid side effect, not to change the old headers + Map> headers = new LinkedHashMap>(); + headers.putAll(request.headers()); + headers.put(Util.CONTENT_LENGTH, Collections.singletonList(String.valueOf(bodyLength))); + return Request.create(request.httpMethod(), getUri().toASCIIString(), headers, body, + request.charset()); + } + + 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 000000000..0de910154 --- /dev/null +++ b/ribbon/src/main/java/feign/ribbon/LBClientFactory.java @@ -0,0 +1,55 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.ClientFactory; +import com.netflix.client.config.CommonClientConfigKey; +import com.netflix.client.config.DefaultClientConfigImpl; +import com.netflix.client.config.IClientConfig; +import com.netflix.client.config.IClientConfigKey; +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); + } + } + + IClientConfigKey RetryableStatusCodes = + new CommonClientConfigKey("RetryableStatusCodes") {}; + + final class DisableAutoRetriesByDefaultClientConfig extends DefaultClientConfigImpl { + @Override + public int getDefaultMaxAutoRetriesNextServer() { + return 0; + } + + @Override + public void loadDefaultValues() { + super.loadDefaultValues(); + putDefaultStringProperty(LBClientFactory.RetryableStatusCodes, ""); + } + } +} 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 000000000..185b80bbd --- /dev/null +++ b/ribbon/src/main/java/feign/ribbon/LoadBalancingTarget.java @@ -0,0 +1,140 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.target(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 000000000..f381703f4 --- /dev/null +++ b/ribbon/src/main/java/feign/ribbon/RibbonClient.java @@ -0,0 +1,148 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 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()); + setProperty(CommonClientConfigKey.FollowRedirects, options.isFollowRedirects()); + } + + @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 000000000..f5ba1581f --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/LBClientFactoryTest.java @@ -0,0 +1,29 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 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/LBClientTest.java b/ribbon/src/test/java/feign/ribbon/LBClientTest.java new file mode 100644 index 000000000..98c548d6c --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/LBClientTest.java @@ -0,0 +1,61 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 feign.Request.HttpMethod; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.Charset; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Test; +import feign.Request; +import feign.ribbon.LBClient.RibbonRequest; +import static org.assertj.core.api.Assertions.assertThat; + +public class LBClientTest { + + @Test + public void testParseCodes() { + assertThat(LBClient.parseStatusCodes("")).isEmpty(); + assertThat(LBClient.parseStatusCodes(null)).isEmpty(); + assertThat(LBClient.parseStatusCodes("504")).contains(504); + assertThat(LBClient.parseStatusCodes("503,504")).contains(503, 504); + } + + @Test + public void testRibbonRequest() throws URISyntaxException { + // test for RibbonRequest.toRequest() + // the url has a query whose value is an encoded json string + String urlWithEncodedJson = "http://test.feign.com/p?q=%7b%22a%22%3a1%7d"; + HttpMethod method = HttpMethod.GET; + URI uri = new URI(urlWithEncodedJson); + Map> headers = new LinkedHashMap>(); + // create a Request for recreating another Request by toRequest() + Request requestOrigin = + Request.create(method, uri.toASCIIString(), headers, null, Charset.forName("utf-8")); + RibbonRequest ribbonRequest = new RibbonRequest(null, requestOrigin, uri); + + // use toRequest() recreate a Request + Request requestRecreate = ribbonRequest.toRequest(); + + // test that requestOrigin and requestRecreate are same except the header 'Content-Length' + // ps, requestOrigin and requestRecreate won't be null + assertThat(requestOrigin.toString()) + .contains(String.format("%s %s HTTP/1.1\n", method, urlWithEncodedJson)); + assertThat(requestRecreate.toString()) + .contains(String.format("%s %s HTTP/1.1\nContent-Length: 0\n", method, urlWithEncodedJson)); + } +} 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 000000000..feefa8d2b --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/LoadBalancingTargetTest.java @@ -0,0 +1,100 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..063a38130 --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/PropagateFirstIOExceptionTest.java @@ -0,0 +1,61 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..dc28550dd --- /dev/null +++ b/ribbon/src/test/java/feign/ribbon/RibbonClientTest.java @@ -0,0 +1,393 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertFalse; +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 java.util.Collection; +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.Response; +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)); + + 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) { + + } + // TODO: why are these retrying? + assertThat(server1.getRequestCount()).isGreaterThanOrEqualTo(1); + // 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); + assertThat(server1.getRequestCount() + server2.getRequestCount()).isGreaterThanOrEqualTo(2); + // 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) { + + } + assertThat(server1.getRequestCount()).isGreaterThanOrEqualTo(1); + assertThat(server1.getRequestCount()).isGreaterThanOrEqualTo(1); + // 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"; + + /* values must be pct encoded, see RFC 6750 */ + String expectedQueryStringValue = "some%20string%20with%20space"; + 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 ribbonRetryOnStatusCodes() throws IOException, InterruptedException { + server1.enqueue(new MockResponse().setResponseCode(502)); + server2.enqueue(new MockResponse().setResponseCode(503)); + + getConfigInstance().setProperty(serverListKey(), + hostAndPort(server1.url("").url()) + "," + hostAndPort(server2.url("").url())); + getConfigInstance().setProperty(client() + ".ribbon.MaxAutoRetriesNextServer", 1); + getConfigInstance().setProperty(client() + ".ribbon.RetryableStatusCodes", "503,502"); + + TestInterface api = + Feign.builder().client(RibbonClient.create()).retryer(Retryer.NEVER_RETRY) + .target(TestInterface.class, "http://" + client()); + + try { + api.post(); + fail("No exception thrown"); + } catch (Exception ignored) { + + } + assertEquals(1, server1.getRequestCount()); + assertEquals(1, server2.getRequestCount()); + } + + + @Test + public void testFeignOptionsFollowRedirect() { + String expectedLocation = server2.url("").url().toString(); + server1 + .enqueue(new MockResponse().setResponseCode(302).setHeader("Location", expectedLocation)); + + getConfigInstance().setProperty(serverListKey(), hostAndPort(server1.url("").url())); + + Request.Options options = new Request.Options(1000, 1000, false); + TestInterface api = Feign.builder() + .options(options) + .client(RibbonClient.create()) + .retryer(Retryer.NEVER_RETRY) + .target(TestInterface.class, "http://" + client()); + + try { + Response response = api.get(); + assertEquals(302, response.status()); + Collection location = response.headers().get("Location"); + assertNotNull(location); + assertFalse(location.isEmpty()); + assertEquals(expectedLocation, location.iterator().next()); + } catch (Exception ignored) { + ignored.printStackTrace(); + fail("Shouldn't throw "); + } + + } + + @Test + public void testFeignOptionsNoFollowRedirect() { + // 302 will say go to server 2 + server1.enqueue(new MockResponse().setResponseCode(302).setHeader("Location", + server2.url("").url().toString())); + // server 2 will send back 200 with "Hello" as body + server2.enqueue(new MockResponse().setResponseCode(200).setBody("Hello")); + + getConfigInstance().setProperty(serverListKey(), + hostAndPort(server1.url("").url()) + "," + hostAndPort(server2.url("").url())); + + Request.Options options = new Request.Options(1000, 1000, true); + TestInterface api = Feign.builder() + .options(options) + .client(RibbonClient.create()) + .retryer(Retryer.NEVER_RETRY) + .target(TestInterface.class, "http://" + client()); + + try { + Response response = api.get(); + assertEquals(200, response.status()); + assertEquals("Hello", response.body().toString()); + } catch (Exception ignored) { + ignored.printStackTrace(); + fail("Shouldn't throw "); + } + + } + + @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())); + assertThat(config.get(CommonClientConfigKey.FollowRedirects), + equalTo(options.isFollowRedirects())); + assertEquals(3, 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); + + @RequestLine("GET /") + Response get(); + } +} diff --git a/sax/README.md b/sax/README.md new file mode 100644 index 000000000..1c901ed65 --- /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/pom.xml b/sax/pom.xml new file mode 100644 index 000000000..9e670f67a --- /dev/null +++ b/sax/pom.xml @@ -0,0 +1,47 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.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 000000000..02833e1a5 --- /dev/null +++ b/sax/src/main/java/feign/sax/SAXDecoder.java @@ -0,0 +1,172 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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(response.status(), 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 000000000..303f5368a --- /dev/null +++ b/sax/src/test/java/feign/sax/SAXDecoderTest.java @@ -0,0 +1,162 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.sax; + +import feign.Request; +import feign.Request.HttpMethod; +import feign.Util; +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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.>emptyMap()) + .body(statusFailed, UTF_8) + .build(); + } + + @Test + public void nullBodyDecodesToNull() throws Exception { + Response response = Response.builder() + .status(204) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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 000000000..1559d2791 --- /dev/null +++ b/sax/src/test/java/feign/sax/examples/AWSSignatureVersion4.java @@ -0,0 +1,161 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.requestBody().asString(); + 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 000000000..46ce8d5a8 --- /dev/null +++ b/sax/src/test/java/feign/sax/examples/IAMExample.java @@ -0,0 +1,92 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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.target(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 000000000..e2c21fd0a --- /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/pom.xml b/slf4j/pom.xml new file mode 100644 index 000000000..cc14d182a --- /dev/null +++ b/slf4j/pom.xml @@ -0,0 +1,61 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.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 000000000..e08704c58 --- /dev/null +++ b/slf4j/src/main/java/feign/slf4j/Slf4jLogger.java @@ -0,0 +1,75 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..bf3586e9a --- /dev/null +++ b/slf4j/src/test/java/feign/slf4j/RecordingSimpleLogger.java @@ -0,0 +1,83 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 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 000000000..56011e112 --- /dev/null +++ b/slf4j/src/test/java/feign/slf4j/Slf4jLoggerTest.java @@ -0,0 +1,112 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.slf4j; + +import feign.Request.HttpMethod; +import feign.Util; +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(HttpMethod.GET).target("http://api.example.com") + .resolve(Collections.emptyMap()).request(); + private static final Response RESPONSE = + Response.builder() + .status(200) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .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" + System.lineSeparator()); + + 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" + System.lineSeparator()); + + 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" + System.lineSeparator()); + + 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" + System.lineSeparator()); + + 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." + + System.lineSeparator() + + "DEBUG feign.Logger - [someMethod] ---> GET http://api.example.com HTTP/1.1" + + System.lineSeparator() + + "DEBUG feign.Logger - [someMethod] <--- HTTP/1.1 200 OK (273ms)" + + System.lineSeparator()); + + 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/soap/README.md b/soap/README.md new file mode 100644 index 000000000..c6e804b96 --- /dev/null +++ b/soap/README.md @@ -0,0 +1,50 @@ +SOAP Codec +=================== + +This module adds support for encoding and decoding SOAP Body objects via JAXB and SOAPMessage. It also provides SOAPFault decoding capabilities by wrapping them into the original `javax.xml.ws.soap.SOAPFaultException`, so that you'll only need to catch `SOAPFaultException` in order to handle SOAPFault. + +Add `SOAPEncoder` and/or `SOAPDecoder` to your `Feign.Builder` like so: + +```java +public interface MyApi { + + @RequestLine("POST /getObject") + @Headers({ + "SOAPAction: getObject", + "Content-Type: text/xml" + }) + MyJaxbObjectResponse getObject(MyJaxbObjectRequest request); + + } + + ... + + JAXBContextFactory jaxbFactory = new JAXBContextFactory.Builder() + .withMarshallerJAXBEncoding("UTF-8") + .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd") + .build(); + + api = Feign.builder() + .encoder(new SOAPEncoder(jaxbFactory)) + .decoder(new SOAPDecoder(jaxbFactory)) + .target(MyApi.class, "http://api"); + + ... + + try { + api.getObject(new MyJaxbObjectRequest()); + } catch (SOAPFaultException faultException) { + log.info(faultException.getFault().getFaultString()); + } + +``` + +Because a SOAP Fault can be returned as well with a 200 http code than a 4xx or 5xx HTTP error code (depending on the used API), you may also use `SOAPErrorDecoder` in your API configuration, in order to be able to catch `SOAPFaultException` in case of SOAP Fault. Add it, like below: + +```java +api = Feign.builder() + .encoder(new SOAPEncoder(jaxbFactory)) + .decoder(new SOAPDecoder(jaxbFactory)) + .errorDecoder(new SOAPErrorDecoder()) + .target(MyApi.class, "http://api"); +``` \ No newline at end of file diff --git a/soap/pom.xml b/soap/pom.xml new file mode 100644 index 000000000..a2d1d6313 --- /dev/null +++ b/soap/pom.xml @@ -0,0 +1,89 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 10.2.1-SNAPSHOT + + + feign-soap + Feign SOAP + Feign SOAP CoDec + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + ${project.groupId} + feign-jaxb + + + + ${project.groupId} + feign-core + test-jar + test + + + + + + + 11 + + + + + + javax.xml.bind + jaxb-api + 2.3.1 + + + org.glassfish.jaxb + jaxb-runtime + 2.4.0-b180830.0438 + test + + + javax.xml.ws + jaxws-api + 2.3.1 + + + com.sun.xml.messaging.saaj + saaj-impl + 1.5.0 + + + + + + diff --git a/soap/src/main/java/feign/soap/SOAPDecoder.java b/soap/src/main/java/feign/soap/SOAPDecoder.java new file mode 100644 index 000000000..29e789276 --- /dev/null +++ b/soap/src/main/java/feign/soap/SOAPDecoder.java @@ -0,0 +1,168 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.soap; + +import java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import javax.xml.bind.JAXBException; +import javax.xml.soap.MessageFactory; +import javax.xml.soap.SOAPConstants; +import javax.xml.soap.SOAPException; +import javax.xml.soap.SOAPMessage; +import javax.xml.ws.soap.SOAPFaultException; +import feign.Response; +import feign.Util; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.jaxb.JAXBContextFactory; + +/** + * Decodes SOAP responses using SOAPMessage and JAXB for the body part.
+ * + *

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

+ * + *

+ * A SOAP Fault can be returned with a 200 HTTP code. Hence, faults could be handled with no error + * on the HTTP layer. In this case, you'll certainly have to catch {@link SOAPFaultException} to get + * fault from your API client service. In the other case (Faults are returned with 4xx or 5xx HTTP + * error code), you may use {@link SOAPErrorDecoder} in your API configuration. + * + *

+ *
+ * public interface MyApi {
+ *
+ *    @RequestLine("POST /getObject")
+ *    @Headers({
+ *      "SOAPAction: getObject",
+ *      "Content-Type: text/xml"
+ *    })
+ *    MyJaxbObjectResponse getObject(MyJaxbObjectRequest request);
+ *
+ * }
+ *
+ * ...
+ *
+ * JAXBContextFactory jaxbFactory = new JAXBContextFactory.Builder()
+ *     .withMarshallerJAXBEncoding("UTF-8")
+ *     .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd")
+ *     .build();
+ *
+ * api = Feign.builder()
+ *     .decoder(new SOAPDecoder(jaxbFactory))
+ *     .target(MyApi.class, "http://api");
+ *
+ * ...
+ *
+ * try {
+ *    api.getObject(new MyJaxbObjectRequest());
+ * } catch (SOAPFaultException faultException) {
+ *    log.info(faultException.getFault().getFaultString());
+ * }
+ * 
+ * + *

+ * + * @see SOAPErrorDecoder + * @see SOAPFaultException + */ +public class SOAPDecoder implements Decoder { + + + private final JAXBContextFactory jaxbContextFactory; + private final String soapProtocol; + + public SOAPDecoder(JAXBContextFactory jaxbContextFactory) { + this.jaxbContextFactory = jaxbContextFactory; + this.soapProtocol = SOAPConstants.DEFAULT_SOAP_PROTOCOL; + } + + private SOAPDecoder(Builder builder) { + this.soapProtocol = builder.soapProtocol; + this.jaxbContextFactory = builder.jaxbContextFactory; + } + + @Override + public Object decode(Response response, Type type) throws IOException { + if (response.status() == 404) + return Util.emptyValueOf(type); + if (response.body() == null) + return null; + while (type instanceof ParameterizedType) { + ParameterizedType ptype = (ParameterizedType) type; + type = ptype.getRawType(); + } + if (!(type instanceof Class)) { + throw new UnsupportedOperationException( + "SOAP only supports decoding raw types. Found " + type); + } + + try { + SOAPMessage message = + MessageFactory.newInstance(soapProtocol).createMessage(null, + response.body().asInputStream()); + if (message.getSOAPBody() != null) { + if (message.getSOAPBody().hasFault()) { + throw new SOAPFaultException(message.getSOAPBody().getFault()); + } + + return jaxbContextFactory.createUnmarshaller((Class) type) + .unmarshal(message.getSOAPBody().extractContentAsDocument()); + } + } catch (SOAPException | JAXBException e) { + throw new DecodeException(response.status(), e.toString(), e); + } finally { + if (response.body() != null) { + response.body().close(); + } + } + return Util.emptyValueOf(type); + + } + + + public static class Builder { + String soapProtocol = SOAPConstants.DEFAULT_SOAP_PROTOCOL; + JAXBContextFactory jaxbContextFactory; + + public Builder withJAXBContextFactory(JAXBContextFactory jaxbContextFactory) { + this.jaxbContextFactory = jaxbContextFactory; + return this; + } + + /** + * The protocol used to create message factory. Default is "SOAP 1.1 Protocol". + * + * @param soapProtocol a string constant representing the MessageFactory protocol. + * @see SOAPConstants#SOAP_1_1_PROTOCOL + * @see SOAPConstants#SOAP_1_2_PROTOCOL + * @see SOAPConstants#DYNAMIC_SOAP_PROTOCOL + * @see MessageFactory#newInstance(String) + */ + public Builder withSOAPProtocol(String soapProtocol) { + this.soapProtocol = soapProtocol; + return this; + } + + public SOAPDecoder build() { + if (jaxbContextFactory == null) { + throw new IllegalStateException("JAXBContextFactory must be non-null"); + } + return new SOAPDecoder(this); + } + } + +} diff --git a/soap/src/main/java/feign/soap/SOAPEncoder.java b/soap/src/main/java/feign/soap/SOAPEncoder.java new file mode 100644 index 000000000..c52951d3e --- /dev/null +++ b/soap/src/main/java/feign/soap/SOAPEncoder.java @@ -0,0 +1,200 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.soap; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Marshaller; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.soap.MessageFactory; +import javax.xml.soap.SOAPConstants; +import javax.xml.soap.SOAPException; +import javax.xml.soap.SOAPMessage; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.TransformerFactoryConfigurationError; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import org.w3c.dom.Document; +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.jaxb.JAXBContextFactory; + + +/** + * Encodes requests using SOAPMessage and JAXB for the body part.
+ *

+ * Basic example with with Feign.Builder: + *

+ * + *
+ * 
+ * public interface MyApi {
+ * 
+ *    @RequestLine("POST /getObject")
+ *    @Headers({
+ *      "SOAPAction: getObject",
+ *      "Content-Type: text/xml"
+ *    })
+ *    MyJaxbObjectResponse getObject(MyJaxbObjectRequest request);
+ *    
+ * }
+ * 
+ * ...
+ * 
+ * JAXBContextFactory jaxbFactory = new JAXBContextFactory.Builder()
+ *     .withMarshallerJAXBEncoding("UTF-8")
+ *     .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd")
+ *     .build();
+ *
+ * api = Feign.builder()
+ *     .encoder(new SOAPEncoder(jaxbFactory))
+ *     .target(MyApi.class, "http://api");
+ *     
+ * ...
+ *
+ * try {
+ *    api.getObject(new MyJaxbObjectRequest());
+ * } catch (SOAPFaultException faultException) {
+ *    log.info(faultException.getFault().getFaultString());
+ * }
+ * 
+ * + *

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

+ */ +public class SOAPEncoder implements Encoder { + + private static final String DEFAULT_SOAP_PROTOCOL = SOAPConstants.SOAP_1_1_PROTOCOL; + + private final boolean writeXmlDeclaration; + private final boolean formattedOutput; + private final Charset charsetEncoding; + private final JAXBContextFactory jaxbContextFactory; + private final String soapProtocol; + + private SOAPEncoder(Builder builder) { + this.jaxbContextFactory = builder.jaxbContextFactory; + this.writeXmlDeclaration = builder.writeXmlDeclaration; + this.charsetEncoding = builder.charsetEncoding; + this.soapProtocol = builder.soapProtocol; + this.formattedOutput = builder.formattedOutput; + } + + public SOAPEncoder(JAXBContextFactory jaxbContextFactory) { + this.jaxbContextFactory = jaxbContextFactory; + this.writeXmlDeclaration = true; + this.formattedOutput = false; + this.charsetEncoding = StandardCharsets.UTF_8; + this.soapProtocol = DEFAULT_SOAP_PROTOCOL; + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + if (!(bodyType instanceof Class)) { + throw new UnsupportedOperationException( + "SOAP only supports encoding raw types. Found " + bodyType); + } + try { + Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + Marshaller marshaller = jaxbContextFactory.createMarshaller((Class) bodyType); + marshaller.marshal(object, document); + SOAPMessage soapMessage = MessageFactory.newInstance(soapProtocol).createMessage(); + soapMessage.setProperty(SOAPMessage.WRITE_XML_DECLARATION, + Boolean.toString(writeXmlDeclaration)); + soapMessage.setProperty(SOAPMessage.CHARACTER_SET_ENCODING, charsetEncoding.displayName()); + soapMessage.getSOAPBody().addDocument(document); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + if (formattedOutput) { + Transformer t = TransformerFactory.newInstance().newTransformer(); + t.setOutputProperty(OutputKeys.INDENT, "yes"); + t.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4"); + t.transform(new DOMSource(soapMessage.getSOAPPart()), new StreamResult(bos)); + } else { + soapMessage.writeTo(bos); + } + template.body(new String(bos.toByteArray())); + } catch (SOAPException | JAXBException | ParserConfigurationException | IOException + | TransformerFactoryConfigurationError | TransformerException e) { + throw new EncodeException(e.toString(), e); + } + } + + /** + * Creates instances of {@link SOAPEncoder}. + */ + public static class Builder { + + private JAXBContextFactory jaxbContextFactory; + public boolean formattedOutput = false; + private boolean writeXmlDeclaration = true; + private Charset charsetEncoding = StandardCharsets.UTF_8; + private String soapProtocol = DEFAULT_SOAP_PROTOCOL; + + /** The {@link JAXBContextFactory} for body part. */ + public Builder withJAXBContextFactory(JAXBContextFactory jaxbContextFactory) { + this.jaxbContextFactory = jaxbContextFactory; + return this; + } + + /** Output format indent if true. Default is false */ + public Builder withFormattedOutput(boolean formattedOutput) { + this.formattedOutput = formattedOutput; + return this; + } + + /** Write the xml declaration if true. Default is true */ + public Builder withWriteXmlDeclaration(boolean writeXmlDeclaration) { + this.writeXmlDeclaration = writeXmlDeclaration; + return this; + } + + /** Specify the charset encoding. Default is {@link Charset#defaultCharset()}. */ + public Builder withCharsetEncoding(Charset charsetEncoding) { + this.charsetEncoding = charsetEncoding; + return this; + } + + /** + * The protocol used to create message factory. Default is "SOAP 1.1 Protocol". + * + * @param soapProtocol a string constant representing the MessageFactory protocol. + * + * @see SOAPConstants#SOAP_1_1_PROTOCOL + * @see SOAPConstants#SOAP_1_2_PROTOCOL + * @see SOAPConstants#DYNAMIC_SOAP_PROTOCOL + * @see MessageFactory#newInstance(String) + */ + public Builder withSOAPProtocol(String soapProtocol) { + this.soapProtocol = soapProtocol; + return this; + } + + public SOAPEncoder build() { + if (jaxbContextFactory == null) { + throw new IllegalStateException("JAXBContextFactory must be non-null"); + } + return new SOAPEncoder(this); + } + } +} diff --git a/soap/src/main/java/feign/soap/SOAPErrorDecoder.java b/soap/src/main/java/feign/soap/SOAPErrorDecoder.java new file mode 100644 index 000000000..da05d1482 --- /dev/null +++ b/soap/src/main/java/feign/soap/SOAPErrorDecoder.java @@ -0,0 +1,80 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.soap; + +import java.io.IOException; +import javax.xml.soap.MessageFactory; +import javax.xml.soap.SOAPConstants; +import javax.xml.soap.SOAPException; +import javax.xml.soap.SOAPFault; +import javax.xml.soap.SOAPMessage; +import javax.xml.ws.soap.SOAPFaultException; +import feign.Response; +import feign.codec.ErrorDecoder; + +/** + * Wraps the returned {@link SOAPFault} if present into a {@link SOAPFaultException}. So you need to + * catch {@link SOAPFaultException} to retrieve the reason of the {@link SOAPFault}. + * + *

+ * If no faults is returned then the default {@link ErrorDecoder} is used to return exception and + * eventually retry the call. + *

+ * + */ +public class SOAPErrorDecoder implements ErrorDecoder { + + private final String soapProtocol; + + public SOAPErrorDecoder() { + this.soapProtocol = SOAPConstants.DEFAULT_SOAP_PROTOCOL; + } + + /** + * SOAPErrorDecoder constructor allowing you to specify the SOAP protocol. + * + * @param soapProtocol a string constant representing the MessageFactory protocol. + * + * @see SOAPConstants#SOAP_1_1_PROTOCOL + * @see SOAPConstants#SOAP_1_2_PROTOCOL + * @see SOAPConstants#DYNAMIC_SOAP_PROTOCOL + * @see MessageFactory#newInstance(String) + */ + public SOAPErrorDecoder(String soapProtocol) { + this.soapProtocol = soapProtocol; + } + + @Override + public Exception decode(String methodKey, Response response) { + if (response.body() == null || response.status() == 503) + return defaultErrorDecoder(methodKey, response); + + SOAPMessage message; + try { + message = MessageFactory.newInstance(soapProtocol).createMessage(null, + response.body().asInputStream()); + if (message.getSOAPBody() != null && message.getSOAPBody().hasFault()) { + return new SOAPFaultException(message.getSOAPBody().getFault()); + } + } catch (SOAPException | IOException e) { + // ignored + } + return defaultErrorDecoder(methodKey, response); + } + + private Exception defaultErrorDecoder(String methodKey, Response response) { + return new ErrorDecoder.Default().decode(methodKey, response); + } + +} diff --git a/soap/src/main/resources/services/javax.xml.soap.SAAJMetaFactory b/soap/src/main/resources/services/javax.xml.soap.SAAJMetaFactory new file mode 100644 index 000000000..a09cd2b13 --- /dev/null +++ b/soap/src/main/resources/services/javax.xml.soap.SAAJMetaFactory @@ -0,0 +1 @@ +com.sun.xml.messaging.saaj.soap.SAAJMetaFactoryImpl diff --git a/soap/src/test/java/feign/soap/SOAPCodecTest.java b/soap/src/test/java/feign/soap/SOAPCodecTest.java new file mode 100644 index 000000000..729dbdc74 --- /dev/null +++ b/soap/src/test/java/feign/soap/SOAPCodecTest.java @@ -0,0 +1,397 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.soap; + +import static feign.Util.UTF_8; +import static feign.assertj.FeignAssertions.assertThat; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +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 javax.xml.bind.annotation.XmlValue; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import feign.Request; +import feign.Request.HttpMethod; +import feign.RequestTemplate; +import feign.Response; +import feign.Util; +import feign.codec.Encoder; +import feign.jaxb.JAXBContextFactory; +import feign.jaxb.JAXBDecoder; + +public class SOAPCodecTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void encodesSoap() throws Exception { + Encoder encoder = new SOAPEncoder.Builder() + .withJAXBContextFactory(new JAXBContextFactory.Builder().build()) + .build(); + + GetPrice mock = new GetPrice(); + mock.item = new Item(); + mock.item.value = "Apples"; + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, GetPrice.class, template); + + String soapEnvelop = "" + + "" + + "" + + "" + + "" + + "Apples" + + "" + + "" + + ""; + assertThat(template).hasBody(soapEnvelop); + } + + @Test + public void doesntEncodeParameterizedTypes() throws Exception { + thrown.expect(UnsupportedOperationException.class); + thrown.expectMessage( + "SOAP only supports encoding raw types. Found java.util.Map"); + + class ParameterizedHolder { + + @SuppressWarnings("unused") + Map field; + } + Type parameterized = ParameterizedHolder.class.getDeclaredField("field").getGenericType(); + + RequestTemplate template = new RequestTemplate(); + new SOAPEncoder(new JAXBContextFactory.Builder().build()) + .encode(Collections.emptyMap(), parameterized, template); + } + + + @Test + public void encodesSoapWithCustomJAXBMarshallerEncoding() throws Exception { + JAXBContextFactory jaxbContextFactory = + new JAXBContextFactory.Builder().withMarshallerJAXBEncoding("UTF-16").build(); + + Encoder encoder = new SOAPEncoder.Builder() + // .withWriteXmlDeclaration(true) + .withJAXBContextFactory(jaxbContextFactory) + .withCharsetEncoding(Charset.forName("UTF-16")) + .build(); + + GetPrice mock = new GetPrice(); + mock.item = new Item(); + mock.item.value = "Apples"; + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, GetPrice.class, template); + + String soapEnvelop = "" + + "" + + "" + + "" + + "" + + "Apples" + + "" + + "" + + ""; + byte[] utf16Bytes = soapEnvelop.getBytes("UTF-16LE"); + assertThat(template).hasBody(utf16Bytes); + } + + + @Test + public void encodesSoapWithCustomJAXBSchemaLocation() throws Exception { + JAXBContextFactory jaxbContextFactory = + new JAXBContextFactory.Builder() + .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd") + .build(); + + Encoder encoder = new SOAPEncoder(jaxbContextFactory); + + GetPrice mock = new GetPrice(); + mock.item = new Item(); + mock.item.value = "Apples"; + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, GetPrice.class, template); + + assertThat(template).hasBody("" + + "" + + "" + + "" + + "" + + "Apples" + + "" + + "" + + ""); + } + + + @Test + public void encodesSoapWithCustomJAXBNoSchemaLocation() throws Exception { + JAXBContextFactory jaxbContextFactory = + new JAXBContextFactory.Builder() + .withMarshallerNoNamespaceSchemaLocation("http://apihost/schema.xsd") + .build(); + + Encoder encoder = new SOAPEncoder(jaxbContextFactory); + + GetPrice mock = new GetPrice(); + mock.item = new Item(); + mock.item.value = "Apples"; + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, GetPrice.class, template); + + assertThat(template).hasBody("" + + "" + + "" + + "" + + "" + + "Apples" + + "" + + "" + + ""); + } + + @Test + public void encodesSoapWithCustomJAXBFormattedOuput() throws Exception { + Encoder encoder = new SOAPEncoder.Builder().withFormattedOutput(true) + .withJAXBContextFactory(new JAXBContextFactory.Builder() + .build()) + .build(); + + GetPrice mock = new GetPrice(); + mock.item = new Item(); + mock.item.value = "Apples"; + + RequestTemplate template = new RequestTemplate(); + encoder.encode(mock, GetPrice.class, template); + + assertThat(template).hasBody( + "" + System.lineSeparator() + + "" + + System.lineSeparator() + + " " + System.lineSeparator() + + " " + System.lineSeparator() + + " " + System.lineSeparator() + + " Apples" + System.lineSeparator() + + " " + System.lineSeparator() + + " " + System.lineSeparator() + + "" + System.lineSeparator() + + ""); + } + + @Test + public void decodesSoap() throws Exception { + GetPrice mock = new GetPrice(); + mock.item = new Item(); + mock.item.value = "Apples"; + + String mockSoapEnvelop = "" + + "" + + "" + + "" + + "" + + "Apples" + + "" + + "" + + ""; + + Response response = Response.builder() + .status(200) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(mockSoapEnvelop, UTF_8) + .build(); + + SOAPDecoder decoder = new SOAPDecoder(new JAXBContextFactory.Builder().build()); + + assertEquals(mock, decoder.decode(response, GetPrice.class)); + } + + @Test + public void decodesSoap1_2Protocol() throws Exception { + GetPrice mock = new GetPrice(); + mock.item = new Item(); + mock.item.value = "Apples"; + + String mockSoapEnvelop = "" + + "" + + "" + + "" + + "" + + "Apples" + + "" + + "" + + ""; + + Response response = Response.builder() + .status(200) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(mockSoapEnvelop, UTF_8) + .build(); + + SOAPDecoder decoder = new SOAPDecoder(new JAXBContextFactory.Builder().build()); + + assertEquals(mock, decoder.decode(response, GetPrice.class)); + } + + + @Test + public void doesntDecodeParameterizedTypes() throws Exception { + thrown.expect(feign.codec.DecodeException.class); + thrown.expectMessage( + "java.util.Map is an interface, and JAXB can't handle interfaces.\n" + + "\tthis problem is related to the following location:\n" + + "\t\tat java.util.Map"); + + class ParameterizedHolder { + + @SuppressWarnings("unused") + Map field; + } + Type parameterized = ParameterizedHolder.class.getDeclaredField("field").getGenericType(); + + Response response = Response.builder() + .status(200) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.>emptyMap()) + .body("" + + "" + + "
" + + "" + + "" + + "Apples" + + "" + + "" + + "", UTF_8) + .build(); + + new SOAPDecoder(new JAXBContextFactory.Builder().build()).decode(response, parameterized); + } + + @XmlRootElement + static class Box { + + @XmlElement + private T t; + + public void set(T t) { + this.t = t; + } + + } + + @Test + public void decodeAnnotatedParameterizedTypes() throws Exception { + JAXBContextFactory jaxbContextFactory = + new JAXBContextFactory.Builder().withMarshallerFormattedOutput(true).build(); + + Encoder encoder = new SOAPEncoder(jaxbContextFactory); + + Box boxStr = new Box<>(); + boxStr.set("hello"); + Box> boxBoxStr = new Box<>(); + boxBoxStr.set(boxStr); + RequestTemplate template = new RequestTemplate(); + encoder.encode(boxBoxStr, Box.class, template); + + Response response = Response.builder() + .status(200) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.>emptyMap()) + .body(template.body()) + .build(); + + new SOAPDecoder(new JAXBContextFactory.Builder().build()).decode(response, Box.class); + + } + + /** + * Enabled via {@link feign.Feign.Builder#decode404()} + */ + @Test + public void notFoundDecodesToEmpty() throws Exception { + Response response = Response.builder() + .status(404) + .reason("NOT FOUND") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.>emptyMap()) + .build(); + assertThat((byte[]) new JAXBDecoder(new JAXBContextFactory.Builder().build()) + .decode(response, byte[].class)).isEmpty(); + } + + + @XmlRootElement(name = "GetPrice") + @XmlAccessorType(XmlAccessType.FIELD) + static class GetPrice { + + @XmlElement(name = "Item") + private Item item; + + @Override + public boolean equals(Object obj) { + if (obj instanceof GetPrice) { + GetPrice getPrice = (GetPrice) obj; + return item.value.equals(getPrice.item.value); + } + return false; + } + + @Override + public int hashCode() { + return item.value != null ? item.value.hashCode() : 0; + } + } + + @XmlRootElement(name = "Item") + @XmlAccessorType(XmlAccessType.FIELD) + static class Item { + + @XmlValue + private String value; + + @Override + public boolean equals(Object obj) { + if (obj instanceof Item) { + Item item = (Item) obj; + return value.equals(item.value); + } + return false; + } + + @Override + public int hashCode() { + return value != null ? value.hashCode() : 0; + } + } + +} diff --git a/soap/src/test/java/feign/soap/SOAPFaultDecoderTest.java b/soap/src/test/java/feign/soap/SOAPFaultDecoderTest.java new file mode 100644 index 000000000..3326bc808 --- /dev/null +++ b/soap/src/test/java/feign/soap/SOAPFaultDecoderTest.java @@ -0,0 +1,122 @@ +/** + * Copyright 2012-2019 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.soap; + +import static feign.Util.UTF_8; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import javax.xml.soap.SOAPConstants; +import javax.xml.ws.soap.SOAPFaultException; +import org.assertj.core.api.Assertions; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import feign.FeignException; +import feign.Request; +import feign.Request.HttpMethod; +import feign.Response; +import feign.Util; +import feign.jaxb.JAXBContextFactory; + +public class SOAPFaultDecoderTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void soapDecoderThrowsSOAPFaultException() throws IOException { + + thrown.expect(SOAPFaultException.class); + thrown.expectMessage("Processing error"); + + Response response = Response.builder() + .status(200) + .reason("OK") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(getResourceBytes("/samples/SOAP_1_2_FAULT.xml")) + .build(); + + new SOAPDecoder.Builder().withSOAPProtocol(SOAPConstants.SOAP_1_2_PROTOCOL) + .withJAXBContextFactory(new JAXBContextFactory.Builder().build()).build() + .decode(response, Object.class); + } + + @Test + public void errorDecoderReturnsSOAPFaultException() throws IOException { + Response response = Response.builder() + .status(400) + .reason("BAD REQUEST") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(getResourceBytes("/samples/SOAP_1_1_FAULT.xml")) + .build(); + + Exception error = + new SOAPErrorDecoder().decode("Service#foo()", response); + Assertions.assertThat(error).isInstanceOf(SOAPFaultException.class) + .hasMessage("Message was not SOAP 1.1 compliant"); + } + + @Test + public void errorDecoderReturnsFeignExceptionOn503Status() throws IOException { + Response response = Response.builder() + .status(503) + .reason("Service Unavailable") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body("Service Unavailable", UTF_8) + .build(); + + Exception error = + new SOAPErrorDecoder().decode("Service#foo()", response); + + Assertions.assertThat(error).isInstanceOf(FeignException.class) + .hasMessage("status 503 reading Service#foo()"); + } + + @Test + public void errorDecoderReturnsFeignExceptionOnEmptyFault() throws IOException { + Response response = Response.builder() + .status(500) + .reason("Internal Server Error") + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body("\n" + + "\n" + + " \n" + + " \n" + + "", UTF_8) + .build(); + + Exception error = + new SOAPErrorDecoder().decode("Service#foo()", response); + + Assertions.assertThat(error).isInstanceOf(FeignException.class) + .hasMessage("status 500 reading Service#foo()"); + } + + private static byte[] getResourceBytes(String resourcePath) throws IOException { + InputStream resourceAsStream = SOAPFaultDecoderTest.class.getResourceAsStream(resourcePath); + byte[] bytes = new byte[resourceAsStream.available()]; + new DataInputStream(resourceAsStream).readFully(bytes); + return bytes; + } + +} diff --git a/soap/src/test/resources/samples/SOAP_1_1_FAULT.xml b/soap/src/test/resources/samples/SOAP_1_1_FAULT.xml new file mode 100644 index 000000000..5f7fe979f --- /dev/null +++ b/soap/src/test/resources/samples/SOAP_1_1_FAULT.xml @@ -0,0 +1,13 @@ + + + + + + SOAP-ENV:Client + Message was not SOAP 1.1 compliant + + + \ No newline at end of file diff --git a/soap/src/test/resources/samples/SOAP_1_2_FAULT.xml b/soap/src/test/resources/samples/SOAP_1_2_FAULT.xml new file mode 100644 index 000000000..0b39989e4 --- /dev/null +++ b/soap/src/test/resources/samples/SOAP_1_2_FAULT.xml @@ -0,0 +1,23 @@ + + + + + + env:Sender + + rpc:BadArguments + + + + Processing error + + + + Name does not match card number + 999 + + + + + diff --git a/src/config/bom.xml b/src/config/bom.xml new file mode 100644 index 000000000..a79f6b9de --- /dev/null +++ b/src/config/bom.xml @@ -0,0 +1,90 @@ + + + + 4.0.0 + + ${model.groupId} + ${model.artifactId} + ${model.version} + ${model.name} + pom + Bill of material + #if ($model.url) + + ${model.url}#end + #if ($model.licenses && !$model.licenses.isEmpty()) + + #foreach($l in $model.licenses) + + + ${l.name} + ${l.url} + ${l.distribution} + #end + + + #end + +#if ($model.developers && !$model.developers.isEmpty()) + #foreach($d in $model.developers) + + + ${d.id} + ${d.name}#if($d.email) + + ${d.email}#end#if($d.url) + + ${d.url}#end#if($d.organization) + + ${d.organization}#end#if($d.organizationUrl) + + ${d.organizationUrl}#end + + #end + + +#end + + + #foreach($d in $model.dependencyManagement.dependencies) + + + ${d.groupId} + ${d.artifactId} + ${d.version}#if( $d.scope && $!d.scope != '' ) + + ${d.scope}#end#if( $d.type && $!d.type != '' && $!d.type != 'jar' && $!d.type != 'bundle') + + ${d.type}#end#if( $d.classifier && $!d.classifier != '' ) + + ${d.classifier}#end#if( $d.exclusions && $d.exclusions.size() > 0 ) + + #foreach( $e in $d.exclusions ) + + + ${e.groupId} + ${e.artifactId} + #end + + #end + + #end + + + + + diff --git a/src/config/eclipse-java-style.xml b/src/config/eclipse-java-style.xml new file mode 100644 index 000000000..46517a1cd --- /dev/null +++ b/src/config/eclipse-java-style.xml @@ -0,0 +1,332 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/config/pomSortOrder.xml b/src/config/pomSortOrder.xml new file mode 100644 index 000000000..11c0bca29 --- /dev/null +++ b/src/config/pomSortOrder.xml @@ -0,0 +1,562 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/etc/header.txt b/src/etc/header.txt new file mode 100644 index 000000000..f0f3921a2 --- /dev/null +++ b/src/etc/header.txt @@ -0,0 +1,11 @@ +Copyright ${license.git.copyrightYears} The Feign Authors + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License +is distributed on an "AS IS" BASIS, WITHOUT 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/travis/publish.sh b/travis/publish.sh new file mode 100755 index 000000000..db8b5c595 --- /dev/null +++ b/travis/publish.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# +# Copyright 2012-2019 The Feign Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. +# + +# 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 +} + +print_project_version() { + ./mvnw help:evaluate -N -Dexpression=project.version|sed -n '/^[0-9]/p' +} + +is_release_commit() { + project_version="$(print_project_version)" + 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 +} + +javadoc_to_gh_pages() { + version="$(print_project_version)" + rm -rf javadoc-builddir + builddir="javadoc-builddir/$version" + + # Collect javadoc for all modules + for jar in $(find . -name "*${version}-javadoc.jar"); do + module="$(echo "$jar" | sed "s~.*/\(.*\)-${version}-javadoc.jar~\1~")" + this_builddir="$builddir/$module" + if [ -d "$this_builddir" ]; then + # Skip modules we've already processed. + # We may find multiple instances of the same javadoc jar because of, for instance, + # integration tests copying jars around. + continue + fi + mkdir -p "$this_builddir" + unzip "$jar" -d "$this_builddir" + # Build a simple module-level index + echo "
  • ${module}
  • " >> "${builddir}/index.html" + done + + # Update gh-pages + git fetch origin gh-pages:gh-pages + git checkout gh-pages + rm -rf "$version" + mv "javadoc-builddir/$version" ./ + rm -rf "javadoc-builddir" + + # Update simple version-level index + if ! grep "$version" index.html 2>/dev/null; then + echo "
  • ${version}
  • " >> index.html + fi + + # Ensure links are ordered by versions, latest on top + sort -rV index.html > index.html.sorted + mv index.html.sorted index.html + + git add "$version" + git add index.html + git commit -m "Automatically updated javadocs for $version" + git push origin gh-pages +} + +#---------------------- +# MAIN +#---------------------- + +if ! is_pull_request && build_started_by_tag; then + check_travis_branch_equals_travis_tag + check_release_tag +fi + +# skip license on travis due to #1512 +./mvnw install -nsu -Dlicense.skip=true + +# formatter errors: +if [ -z $(git status --porcelain) ]; +then + echo "No changes detected, all good" +else + echo "The following files have formatting changes:" + git status --porcelain + echo "" + echo "Please run 'mvn clean install' locally to format files" + exit 1 +fi + +# 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 -pl -:feign-benchmark -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 + javadoc_to_gh_pages + 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 + # skip license on travis due to #1512 + ./mvnw --batch-mode -s ./.settings.xml -Prelease -nsu -DreleaseVersion="$(release_version)" -Darguments="-DskipTests -Dlicense.skip=true" release:prepare +fi