diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..4839c66e4 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,255 @@ +# +# Copyright 2012-2020 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. +# + +orbs: + android: circleci/android@1.0.3 + +# common executors +executors: + java: + parameters: + version: + description: 'jdk version to use' + default: '8' + type: string + docker: + - image: circleci/openjdk:<> + android: + parameters: + version: + description: 'jdk version to use' + default: '8' + type: string + docker: + - image: circleci/openjdk:<> +# common commands +commands: + resolve-dependencies: + description: 'Download and prepare all dependencies' + steps: + - run: + name: 'Resolving Dependencies' + command: | + mvn dependency:resolve-plugins go-offline:resolve-dependencies -DskipTests=true + verify-formatting: + steps: + - run: + name: 'Verify formatting' + command: | + scripts/no-git-changes.sh + configure-gpg: + steps: + - run: + name: 'Configure GPG keys' + command: | + echo -e "$GPG_KEY" | gpg --batch --no-tty --import --yes + nexus-deploy: + steps: + - run: + name: 'Deploy Core Modules Sonatype' + command: | + mvn -nsu -s .circleci/settings.xml -P release -pl -:feign-benchmark -DskipTests=true deploy + nexus-deploy-jdk11: + steps: + - run: + name: 'Build JDK 11 Release modules locally' + command: | + mvn -B -nsu -s .circleci/settings.xml -P java11 -pl :feign-java11 -am -DskipTests=true install + - run: + name: 'Deploy JDK 11 Modules to Sonatype' + command: | + mvn -B -nsu -s .circleci/settings.xml -P release,java11 -pl :feign-java11 -DskipTests=true deploy +# our job defaults +defaults: &defaults + working_directory: ~/feign + environment: + # Customize the JVM maximum heap limit + MAVEN_OPTS: -Xmx3200m + +# branch filters +master-only: &master-only + branches: + only: master + +tags-only: &tags-only + branches: + ignore: /.*/ + tags: + only: /.*/ + +all-branches: &all-branches + branches: + ignore: master + tags: + ignore: /.*/ + +version: 2.1 + +jobs: + test: + parameters: + jdk: + description: 'jdk version to use' + default: '8' + type: string + executor: + name: java + version: <> + <<: *defaults + steps: + - checkout + - restore_cache: + keys: + - feign-dependencies-{{ checksum "pom.xml" }} + - feign-dependencies- + - resolve-dependencies + - save_cache: + paths: + - ~/.m2 + key: feign-dependencies-{{ checksum "pom.xml" }} + - run: + name: 'Test' + command: | + mvn -o test + - verify-formatting + android-test: + # These next lines define the Android machine image executor: https://circleci.com/docs/2.0/executor-types/ + executor: + name: android/android-machine + + steps: + # Checkout the code as the first step. + - checkout + + # The next step will run the unit tests + - android/run-tests: + test-command: ./gradlew lint testDebug --continue + + deploy: + parameters: + jdk: + description: 'jdk version to use' + default: '8' + type: string + executor: + name: java + version: <> + <<: *defaults + steps: + - checkout + - restore_cache: + keys: + - feign-dependencies-{{ checksum "pom.xml" }} + - feign-dependencies- + - resolve-dependencies + - configure-gpg + - nexus-deploy + + deploy-jdk11: + parameters: + jdk: + description: 'jdk version to use' + default: '11' + type: string + executor: + name: java + version: <> + <<: *defaults + steps: + - checkout + - restore_cache: + keys: + - feign-dependencies-{{ checksum "pom.xml" }} + - feign-dependencies- + - resolve-dependencies + - configure-gpg + - nexus-deploy-jdk11 + +workflows: + version: 2 + build: + jobs: + - test: + jdk: '8' + name: 'jdk 8' + filters: + <<: *all-branches + - test: + jdk: '11' + name: 'jdk 11' + filters: + <<: *all-branches + - test: + jdk: '14-buster' + name: 'jdk 14' + filters: + <<: *all-branches + - test: + name: 'android test' + + + snapshot: + jobs: + - test: + jdk: '8' + name: 'jdk 8' + filters: + <<: *master-only + - test: + jdk: '11' + name: 'jdk 11' + filters: + <<: *master-only + - test: + jdk: '14-buster' + name: 'jdk 14' + filters: + <<: *master-only + - deploy: + jdk: '8' + name: 'deploy snapshot' + requires: + - 'jdk 8' + - 'jdk 11' + - 'jdk 14' + context: Sonatype + filters: + <<: *master-only + - deploy-jdk11: + jdk: '11' + name: 'deploy jdk11 snapshot modules' + requires: + - 'jdk 11' + - 'deploy snapshot' + context: Sonatype + filters: + <<: *master-only + + release: + jobs: + - deploy: + jdk: '8' + name: 'release to maven central' + context: Sonatype + filters: + <<: *tags-only + - deploy-jdk11: + jdk: '11' + name: 'release jdk11 artifacts to maven central' + requires: + - 'release to maven central' + context: Sonatype + filters: + <<: *tags-only + diff --git a/.circleci/settings.xml b/.circleci/settings.xml new file mode 100644 index 000000000..b3b4740ad --- /dev/null +++ b/.circleci/settings.xml @@ -0,0 +1,39 @@ + + + + + ossrh + ${env.SONATYPE_USER} + ${env.SONATYPE_PASSWORD} + + + + + ossrh + + true + + + ${env.GPG_PASSPHRASE} + + + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..b4a09015c --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# 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 + +# encrypted values +*.asc + +# maven versions +*.versionsBackup 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..33cade1b1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,324 @@ +### Version 10.9 + +* Configurable to disable streaming mode for Default client by verils (#1182) +* Overriding query parameter name by boggard (#1184) +* Internal feign metrics by velo: +* Dropwizard metrics 5 (#1181) +* Micrometer (#1188) + +### Version 10.8 + +* async feign variant supporting CompleteableFutures by motinis (#1174) +* deterministic iterations for Feign mocks by contextshuffling (#1165) +* Async client for apache http 5 by velo (#1179) + +### Version 10.7 + +* Fix for vunerabilities reported by snky (#1121) +* Makes iterator compatible with Java iterator expected behavior (#1117) +* Bump reactive dependencies (#1105) +* Deprecated `encoded` and add comment (#1108) + +### Version 10.6 +* Remove java8 module (#1086) +* Add composed Spring annotations support (#1090) +* Generate mocked clients for tests from feign interfaces (#1092) + +### Version 10.5 +* Add Apache Http 5 Client (#1065) +* Updating Apache HttpClient to 4.5.10 (#1080) (#1081) +* Spring4 contract (#1069) +* Declarative contracts (#1060) + +### Version 10.4 +* Adding support for JDK Proxy (#1045) +* Add Google HTTP Client support (#1057) + +### Version 10.3 +* Upgrade dependencies with security vunerabilities (#997 #1010 #1011 #1024 #1025 #1031 #1032) +* Parse Retry-After header responses that include decimal points (#980) +* Fine-grained HTTP error exceptions with client and server errors (#854) +* Adds support for per request timeout options (#970) +* Unwrap RetryableException and throw cause (#737) +* JacksonEncoder avoids intermediate String request body (#989) +* Respect decode404 flag and decode 404 response body (#1012) +* Maintain user-given order for header values (#1009) + +### 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..02d5ab155 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,1030 @@ -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) +[![CircleCI](https://circleci.com/gh/OpenFeign/feign/tree/master.svg?style=svg)](https://circleci.com/gh/OpenFeign/feign/tree/master) +[![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 + +## Feature overview + +This is a map with current key features provided by feign: + +![MindMap overview](http://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/OpenFeign/feign/master/src/docs/overview-mindmap.iuml) + +# Roadmap +## Feign 11 and beyond +Making _API_ clients easier + +Short Term - What we're working on now. ⏰ +--- +* Response Caching + * Support caching of api responses. Allow for users to define under what conditions a response is eligible for caching and what type of caching mechanism should be used. + * Support in-memory caching and external cache implementations (EhCache, Google, Spring, etc...) +* Complete URI Template expression support + * Support [level 1 through level 4](https://tools.ietf.org/html/rfc6570#section-1.2) URI template expressions. + * Use [URI Templates TCK](https://github.com/uri-templates/uritemplate-test) to verify compliance. +* `Logger` API refactor + * Refactor the `Logger` API to adhere closer to frameworks like SLF4J providing a common mental model for logging within Feign. This model will be used by Feign itself throughout and provide clearer direction on how the `Logger` will be used. +* `Retry` API refactor + * Refactor the `Retry` API to support user-supplied conditions and better control over back-off policies. **This may result in non-backward-compatible breaking changes** + +Medium Term - What's up next. ⏲ +--- +* Async execution support via `CompletableFuture` + * Allow for `Future` chaining and executor management for the request/response lifecycle. **Implementation will require non-backward-compatible breaking changes**. However this feature is required before Reactive execution can be considered. +* Reactive execution support via [Reactive Streams](https://www.reactive-streams.org/) + * For JDK 9+, consider a native implementation that uses `java.util.concurrent.Flow`. + * Support for [Project Reactor](https://projectreactor.io/) and [RxJava 2+](https://github.com/ReactiveX/RxJava) implementations on JDK 8. + +Long Term - The future ☁️ +--- +* Additional Circuit Breaker Support. + * Support additional Circuit Breaker implementations like [Resilience4J](https://resilience4j.readme.io/) and Spring Circuit Breaker + +--- + +### 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); + + @RequestLine("POST /repos/{owner}/{repo}/issues") + void createIssue(Issue issue, @Param("owner") String owner, @Param("repo") String repo); + +} + +public static class Contributor { + String login; + int contributions; +} + +public static class Issue { + String title; + String body; + List assignees; + int milestone; + List labels; +} + +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 provided as annotation value. If value is missing it will try to get the name from bytecode method parameter name (if the code was compiled with `-parameters` flag). | +| `@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`.| + + +> **Overriding the Request Line** +> +> If there is a need to target a request to a different host then the one supplied when the Feign client was created, or +> you want to supply a target host for each request, include a `java.net.URI` parameter and Feign will use that value +> as the request target. +> +> ```java +> @RequestLine("POST /repos/{owner}/{repo}/issues") +> void createIssue(URI host, Issue issue, @Param("owner") String owner, @Param("repo") String repo); +> ``` +> + +### 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 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 request setting, you can use `options(Request.Options options)` on `target()` to set connectTimeout, connectTimeoutUnit, readTimeout, readTimeoutUnit, followRedirects.
+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()) + .options(new Request.Options(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true)) + .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"); + } +} +``` + +For the lighter weight Jackson Jr, use `JacksonJrEncoder` and `JacksonJrDecoder` from +the [Jackson Jr Module](./jackson-jr). + +### 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](https://openjdk.java.net/jeps/321) 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()) + .logLevel(Level.FULL) + .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 +To customize headers for each request method on a Target, a RequestInterceptor can be used. RequestInterceptors can be +shared across Target instances and are expected to be thread-safe. RequestInterceptors are applied to all request +methods on a Target. + +If you need per method customization, a custom Target is required, as the a RequestInterceptor does not have access to +the current method metadata. + +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("GitHub.Logger").appendToFile("logs/http.log")) + .logLevel(Logger.Level.FULL) + .target(GitHub.class, "https://api.github.com"); + } +} +``` + +> **A Note on JavaLogger**: +> Avoid using of default ```JavaLogger()``` constructor - it was marked as deprecated and will be removed soon. + +The SLF4JLogger (see above) may also be of interest. + +To filter out sensitive information like authorization or tokens +override methods `shouldLogRequestHeader` or `shouldLogResponseHeader`. + +#### 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. + +### Metrics +By default, feign won't collect any metrics. + +But, it's possible to add metric collection capabilities to any feign client. + +Metric Capabilities provide a first-class Metrics API that users can tap into to gain insight into the request/response lifecycle. + +#### Dropwizard Metrics 4 + +``` +public class MyApp { + public static void main(String[] args) { + GitHub github = Feign.builder() + .addCapability(new Metrics4Capability()) + .target(GitHub.class, "https://api.github.com"); + + github.contributors("OpenFeign", "feign"); + // metrics will be available from this point onwards + } +} +``` + +#### Dropwizard Metrics 5 + +``` +public class MyApp { + public static void main(String[] args) { + GitHub github = Feign.builder() + .addCapability(new Metrics5Capability()) + .target(GitHub.class, "https://api.github.com"); + + github.contributors("OpenFeign", "feign"); + // metrics will be available from this point onwards + } +} +``` + +#### Micrometer + +``` +public class MyApp { + public static void main(String[] args) { + GitHub github = Feign.builder() + .addCapability(new MicrometerCapability()) + .target(GitHub.class, "https://api.github.com"); + + github.contributors("OpenFeign", "feign"); + // metrics will be available from this point onwards + } +} +``` + +#### 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"); + } +} +``` + + +### Async execution via `CompletableFuture` + +Feign 10.8 introduces a new builder `AsyncFeign` that allow methods to return `CompletableFuture` instances. + +```java +interface GitHub { + @RequestLine("GET /repos/{owner}/{repo}/contributors") + CompletableFuture> contributors(@Param("owner") String owner, @Param("repo") String repo); +} + +public class MyApp { + public static void main(String... args) { + GitHub github = AsyncFeign.asyncBuilder() + .decoder(new GsonDecoder()) + .target(GitHub.class, "https://api.github.com"); + + // Fetch and print a list of the contributors to this library. + CompletableFuture> contributors = github.contributors("OpenFeign", "feign"); + for (Contributor contributor : contributors.get(1, TimeUnit.SECONDS)) { + System.out.println(contributor.login + " (" + contributor.contributions + ")"); + } + } +} +``` + +Initial implementation include 2 async clients: +- `AsyncClient.Default` +- `AsyncApacheHttp5Client` diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 000000000..2714f5486 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,68 @@ +# 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** + + Prepare the next release by running the [release script](scripts/release.sh) from a clean checkout of the master branch. + This script will: + * Update all versions to the next release. + * Tag the release. + * Update all versions to the next development version. + +1. **Wait for CI** + + This part is controlled by the [CircleCI configuration](.circleci/config.yml), specifically the `deploy` job. Which + creates the release artifacts and deploys them to maven central. + +## Credentials + +Credentials of various kind are needed for the release process to work. If you notice something +failing due to unauthorized, you will need to modify the stored values in `Sonatype` [CircleCI Context](https://circleci.com/docs/2.0/contexts/) +for the OpenFeign organization. + +`SONATYPE_USER` - the username of the Sonatype account used to upload artifacts. +`SONATYPE_PASSWORD` - password for the Sonatype account. +`GPG_KEY` - the gpg key used to sign the artifacts. +`GPG_PASSPHRASE` - the passphrase for the gpg key + +### Troubleshooting invalid credentials + +If the `deploy` job fails due to invalid credentials, double check the `SONATYPE_USER` and `SONATYPE_PASSWORD` +variables first and correct them. + +### Troubleshooting GPG issues + +If the `deploy` job fails when signing artifacts, the GPG key may have expired or is incorrect. To update the +`GPG_KEY`, you must export a valid GPG key to ascii and replace all newline characters with `\n`. This will +allow CircleCi to inject the key into the environment in a way where it can be imported again. Use the following command +to generate the key file. + +```shell +gpg -a --export-secret-keys | cat -e | sed | sed 's/\$/\\n/g' > gpg_key.asc +``` + +Paste the contents of this file into the `GPG_KEY` variable in the context and try the job again. + +## 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/annotation-error-decoder/README.md b/annotation-error-decoder/README.md new file mode 100644 index 000000000..f99d5db77 --- /dev/null +++ b/annotation-error-decoder/README.md @@ -0,0 +1,359 @@ +Annotation Error Decoder +========================= + +This module allows to annotate Feign's interfaces with annotations to generate Exceptions based on error codes + +To use AnnotationErrorDecoder with Feign, add the Annotation Error Decoder module to your classpath. Then, configure +Feign to use the AnnotationErrorDecoder: + +```java +GitHub github = Feign.builder() + .errorDecoder( + AnnotationErrorDecoder.builderFor(GitHub.class).build() + ) + .target(GitHub.class, "https://api.github.com"); +``` + +## Leveraging the annotations and priority order +For annotation decoding to work, the class must be annotated with `@ErrorHandling` tags or meta-annotations. +The tags are valid in both the class level as well as method level. They will be treated from 'most specific' to +'least specific' in the following order: +* A code specific exception defined on the method +* A code specific exception defined on the class +* The default exception of the method +* The default exception of the class + +```java +@ErrorHandling(codeSpecific = + { + @ErrorCodes( codes = {401}, generate = UnAuthorizedException.class), + @ErrorCodes( codes = {403}, generate = ForbiddenException.class), + @ErrorCodes( codes = {404}, generate = UnknownItemException.class), + }, + defaultException = ClassLevelDefaultException.class +) +interface GitHub { + + @ErrorHandling(codeSpecific = + { + @ErrorCodes( codes = {404}, generate = NonExistentRepoException.class), + @ErrorCodes( codes = {502, 503, 504}, generate = RetryAfterCertainTimeException.class), + }, + defaultException = FailedToGetContributorsException.class + ) + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); +} +``` +In the above example, error responses to 'contributors' would hence be mapped as follows by status codes: + +| Code | Exception | Reason | +| ----------- | -------------------------------- | --------------------- | +| 401 | `UnAuthorizedException` | from Class definition | +| 403 | `ForbiddenException` | from Class definition | +| 404 | `NonExistenRepoException` | from Method definition, note that the class generic exception won't be thrown here | +| 502,503,504 | `RetryAfterCertainTimeException` | from method definition. Note that you can have multiple error codes generate the same type of exception | +| Any Other | `FailedToGetContributorsException` | from Method default | + +For a class level default exception to be thrown, the method must not have a `defaultException` defined, nor must the error code +be mapped at either the method or class level. + +If the return code cannot be mapped to any code and no default exceptions have been configured, then the decoder will +drop to a default decoder (by default, the standard one provided by feign). You can change the default drop-into decoder +as follows: + +```java +GitHub github = Feign.builder() + .errorDecoder( + AnnotationErrorDecoder.builderFor(GitHub.class) + .withDefaultDecoder(new MyOtherErrorDecoder()) + .build() + ) + .target(GitHub.class, "https://api.github.com"); +``` + + +## Complex Exceptions + +Any exception can be used if they have a default constructor: + +```java +class DefaultConstructorException extends Exception {} +``` + +However, if you want to have parameters (such as the feign.Request object or response body or response headers), you have to annotate its +constructor appropriately (the body annotation is optional, provided there aren't paramters which will clash) + +All the following examples are valid exceptions: +```java +class JustBody extends Exception { + + @FeignExceptionConstructor + public JustBody(String body) { + + } +} +class JustRequest extends Exception { + + @FeignExceptionConstructor + public JustRequest(Request request) { + + } +} +class RequestAndResponseBody extends Exception { + + @FeignExceptionConstructor + public RequestAndResponseBody(Request request, String body) { + + } +} +//Headers must be of type Map> +class BodyAndHeaders extends Exception { + + @FeignExceptionConstructor + public BodyAndHeaders(@ResponseBody String body, @ResponseHeaders Map> headers) { + + } +} +class RequestAndResponseBodyAndHeaders extends Exception { + + @FeignExceptionConstructor + public RequestAndResponseBodyAndHeaders(Request request, @ResponseBody String body, @ResponseHeaders Map> headers) { + + } +} +class JustHeaders extends Exception { + + @FeignExceptionConstructor + public JustHeaders(@ResponseHeaders Map> headers) { + + } +} +``` + +If you want to have the body decoded, you'll need to pass a decoder at construction time (just as for normal responses): + +```java +GitHub github = Feign.builder() + .errorDecoder( + AnnotationErrorDecoder.builderFor(GitHub.class) + .withResponseBodyDecoder(new JacksonDecoder()) + .build() + ) + .target(GitHub.class, "https://api.github.com"); +``` + +This will enable you to create exceptions where the body is a complex pojo: + +```java +class ComplexPojoException extends Exception { + + @FeignExceptionConstructor + public ComplexPojoException(GithubExceptionResponse body) { + if (body != null) { + // extract data + } else { + // fallback code + } + } +} +//The pojo can then be anything you'd like provided the decoder can manage it +class GithubExceptionResponse { + public String message; + public int githubCode; + public List urlsForHelp; +} +``` + +It's worth noting that at setup/startup time, the generators are checked with a null value of the body. +If you don't do the null-checker, you'll get an NPE and startup will fail. + + +## Inheriting from other interface definitions +You can create a client interface that inherits from a different one. However, there are some limitations that +you should be aware of (for most cases, these shouldn't be an issue): +* The inheritance is not natural java inheritance of annotations - as these don't work on interfaces +* Instead, the error looks at the class and if it finds the `@ErrorHandling` annotation, it uses that one. +* If not, it will look at *all* the interfaces the main interface `extends` - but it does so in the order the +java API gives it - so order is not guaranteed. +* If it finds the annotation in one of those parents, it uses that definition, without looking at any other +* That means that if more than one interface was extended which contained the `@ErrorHandling` annotation, we can't +really guarantee which one of the parents will be selected and you should really do handling at the child interface + * so far, the java API seems to return in order of definition after the `extends`, but it's a really bad practice + if you have to depend on that... so our suggestion: don't. + +That means that as long as you only ever extend from a base interface (where you may decide that all 404's are "NotFoundException", for example) +then you should be ok. But if you get complex in polymorphism, all bets are off - so don't go crazy! + +Example: +In the following code: +* The base `FeignClientBase` interface defines a default set of exceptions at class level +* the `GitHub1` and `GitHub2` interfaces will inherit the class-level error handling, which means that +any 401/403/404 will be handled correctly (provided the method doesn't specify a more specific exception) +* the `GitHub3` interface however, by defining its own error handling, will handle all 401's, but not the +403/404's since there's no merging/etc (not really in the plan to implement either...) +```java + +@ErrorHandling(codeSpecific = + { + @ErrorCodes( codes = {401}, generate = UnAuthorizedException.class), + @ErrorCodes( codes = {403}, generate = ForbiddenException.class), + @ErrorCodes( codes = {404}, generate = UnknownItemException.class), + }, + defaultException = ClassLevelDefaultException.class +) +interface FeignClientBase {} + +interface GitHub1 extends FeignClientBase { + + @ErrorHandling(codeSpecific = + { + @ErrorCodes( codes = {404}, generate = NonExistentRepoException.class), + @ErrorCodes( codes = {502, 503, 504}, generate = RetryAfterCertainTimeException.class), + }, + defaultException = FailedToGetContributorsException.class + ) + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); +} + +interface GitHub2 extends FeignClientBase { + + @ErrorHandling(codeSpecific = + { + @ErrorCodes( codes = {404}, generate = NonExistentRepoException.class), + @ErrorCodes( codes = {502, 503, 504}, generate = RetryAfterCertainTimeException.class), + }, + defaultException = FailedToGetContributorsException.class + ) + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); +} + +@ErrorHandling(codeSpecific = + { + @ErrorCodes( codes = {401}, generate = UnAuthorizedException.class) + }, + defaultException = ClassLevelDefaultException.class +) +interface GitHub3 extends FeignClientBase { + + @ErrorHandling(codeSpecific = + { + @ErrorCodes( codes = {404}, generate = NonExistentRepoException.class), + @ErrorCodes( codes = {502, 503, 504}, generate = RetryAfterCertainTimeException.class), + }, + defaultException = FailedToGetContributorsException.class + ) + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); +} +``` + +## Meta-annotations +When you want to share the same configuration of one `@ErrorHandling` annotation the `@ErrorHandling` annotation +can be moved to a meta-annotation. Then later on this meta-annotation can be used on a method or at class level to +reduce the amount duplicated code. A meta-annotation is a special annotation that contains the `@ErrorHandling` +annotation and possibly other annotations, e.g. Spring-Rest annotations. + +There are some limitations and rules to keep in mind when using meta-annotation: +- inheritance for meta-annotations when using interface inheritance is supported and is following the same rules as for + interface inheritance (see above) + - `@ErrorHandling` has **precedence** over any meta-annotation when placed together on a class or method + - a meta-annotation on a child interface (method or class) has **precedence** over the error handling defined in the + parent interface +- having a meta-annotation on a meta-annotation is not supported, only the annotations on a type are checked for a + `@ErrorHandling` +- when multiple meta-annotations with an `@ErrorHandling` annotation are present on a class or method the first one + which is returned by java API is used to figure out the error handling, the others are not considered, so it is + advisable to have only one meta-annotation on each method or class as the order is not guaranteed. +- **no merging** of configurations is supported, e.g. multiple meta-annotations on the same type, meta-annotation with + `@ErrorHandling` on the same type + +Example: + +Let's assume multiple methods need to handle the response-code `404` in the same way but differently what is +specified in the `@ErrorHandling` annotation on the class-level. In that case, to avoid also duplicate annotation definitions +on the affected methods a meta-annotation can reduce the amount of code to be written to handle this `404` differently. + +In the following code the status-code `404` is handled on a class level which throws an `UnknownItemException` for all +methods inside this interface. For the methods `contributors` and `languages` a different exceptions needs to be thrown, +in this case it is a `NoDataFoundException`. The `teams`method will still use the exception defined by the class-level +error handling annotation. To simplify the code a meta-annotation can be created and be used in the interface to keep +the interface small and readable. + +```java +@ErrorHandling( + codeSpecific = { + @ErrorCodes(codes = {404}, generate = NoDataFoundException.class), + }, + defaultException = GithubRemoteException.class) +@Retention(RetentionPolicy.RUNTIME) +@interface NoDataErrorHandling { +} +``` + +Having this meta-annotation in place it can be used to transform the interface into a much smaller one, keeping the same +behavior. +- `contributers` will throw a `NoDataFoundException` for status code `404` as defined on method level and a + `GithubRemoteException` for all other status codes +- `languages` will throw a `NoDataFoundException` for status code `404` as defined on method level and a + `GithubRemoteException` for all other status codes +- `teams` will throw a `UnknownItemException` for status code `404` as defined on class level and a + `ClassLevelDefaultException` for all other status codes + +Before: +```java +@ErrorHandling(codeSpecific = + { + @ErrorCodes( codes = {404}, generate = UnknownItemException.class) + }, + defaultException = ClassLevelDefaultException.class +) +interface GitHub { + @ErrorHandling(codeSpecific = + { + @ErrorCodes( codes = {404}, generate = NoDataFoundException.class) + }, + defaultException = GithubRemoteException.class + ) + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + + @ErrorHandling(codeSpecific = + { + @ErrorCodes( codes = {404}, generate = NoDataFoundException.class) + }, + defaultException = GithubRemoteException.class + ) + @RequestLine("GET /repos/{owner}/{repo}/languages") + Map languages(@Param("owner") String owner, @Param("repo") String repo); + + @ErrorHandling + @RequestLine("GET /repos/{owner}/{repo}/team") + List languages(@Param("owner") String owner, @Param("repo") String repo); +} +``` + +After: +```java +@ErrorHandling(codeSpecific = + { + @ErrorCodes( codes = {404}, generate = UnknownItemException.class) + }, + defaultException = ClassLevelDefaultException.class +) +interface GitHub { + @NoDataErrorHandling + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + + @NoDataErrorHandling + @RequestLine("GET /repos/{owner}/{repo}/languages") + Map languages(@Param("owner") String owner, @Param("repo") String repo); + + @ErrorHandling + @RequestLine("GET /repos/{owner}/{repo}/team") + List languages(@Param("owner") String owner, @Param("repo") String repo); +} +``` diff --git a/annotation-error-decoder/pom.xml b/annotation-error-decoder/pom.xml new file mode 100644 index 000000000..ebaad9397 --- /dev/null +++ b/annotation-error-decoder/pom.xml @@ -0,0 +1,53 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 11.9-SNAPSHOT + + + feign-annotation-error-decoder + Feign Annotation Error Decoder + Feign Annotation Error Decoder + + + ${project.basedir}/.. + + + + + ${project.groupId} + feign-core + + + + ${project.groupId} + feign-core + test-jar + test + + + + com.squareup.okhttp3 + mockwebserver + test + + + diff --git a/annotation-error-decoder/src/main/java/feign/error/AnnotationErrorDecoder.java b/annotation-error-decoder/src/main/java/feign/error/AnnotationErrorDecoder.java new file mode 100644 index 000000000..41c1ce9aa --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/AnnotationErrorDecoder.java @@ -0,0 +1,191 @@ +/* + * Copyright 2012-2022 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.error; + +import feign.Response; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import static feign.Feign.configKey; + +public class AnnotationErrorDecoder implements ErrorDecoder { + + private final Map errorHandlerMap; + private final ErrorDecoder defaultDecoder; + + + AnnotationErrorDecoder(Map errorHandlerMap, + ErrorDecoder defaultDecoder) { + this.errorHandlerMap = errorHandlerMap; + this.defaultDecoder = defaultDecoder; + } + + @Override + public Exception decode(String methodKey, Response response) { + if (errorHandlerMap.containsKey(methodKey)) { + return errorHandlerMap.get(methodKey).decode(response); + } + return defaultDecoder.decode(methodKey, response); + } + + + public static AnnotationErrorDecoder.Builder builderFor(Class apiType) { + return new Builder(apiType); + } + + public static class Builder { + private final Class apiType; + private ErrorDecoder defaultDecoder = new ErrorDecoder.Default(); + private Decoder responseBodyDecoder = new Decoder.Default(); + + + public Builder(Class apiType) { + this.apiType = apiType; + } + + public Builder withDefaultDecoder(ErrorDecoder defaultDecoder) { + this.defaultDecoder = defaultDecoder; + return this; + } + + public Builder withResponseBodyDecoder(Decoder responseBodyDecoder) { + this.responseBodyDecoder = responseBodyDecoder; + return this; + } + + public AnnotationErrorDecoder build() { + Map errorHandlerMap = generateErrorHandlerMapFromApi(apiType); + return new AnnotationErrorDecoder(errorHandlerMap, defaultDecoder); + } + + Map generateErrorHandlerMapFromApi(Class apiType) { + + ExceptionGenerator classLevelDefault = new ExceptionGenerator.Builder() + .withResponseBodyDecoder(responseBodyDecoder) + .withExceptionType(ErrorHandling.NO_DEFAULT.class) + .build(); + Map classLevelStatusCodeDefinitions = + new HashMap(); + + Optional classLevelErrorHandling = + readErrorHandlingIncludingInherited(apiType); + if (classLevelErrorHandling.isPresent()) { + ErrorHandlingDefinition classErrorHandlingDefinition = + readAnnotation(classLevelErrorHandling.get(), responseBodyDecoder); + classLevelDefault = classErrorHandlingDefinition.defaultThrow; + classLevelStatusCodeDefinitions = classErrorHandlingDefinition.statusCodesMap; + } + + Map methodErrorHandlerMap = + new HashMap(); + for (Method method : apiType.getMethods()) { + ErrorHandling methodLevelAnnotation = getErrorHandlingAnnotation(method); + if (methodLevelAnnotation != null) { + ErrorHandlingDefinition methodErrorHandling = + readAnnotation(methodLevelAnnotation, responseBodyDecoder); + ExceptionGenerator methodDefault = methodErrorHandling.defaultThrow; + if (methodDefault.getExceptionType().equals(ErrorHandling.NO_DEFAULT.class)) { + methodDefault = classLevelDefault; + } + + MethodErrorHandler methodErrorHandler = + new MethodErrorHandler(methodErrorHandling.statusCodesMap, + classLevelStatusCodeDefinitions, methodDefault); + + methodErrorHandlerMap.put(configKey(apiType, method), methodErrorHandler); + } + } + + return methodErrorHandlerMap; + } + + Optional readErrorHandlingIncludingInherited(Class apiType) { + ErrorHandling apiTypeAnnotation = getErrorHandlingAnnotation(apiType); + if (apiTypeAnnotation != null) { + return Optional.of(apiTypeAnnotation); + } + for (Class parentInterface : apiType.getInterfaces()) { + Optional errorHandling = + readErrorHandlingIncludingInherited(parentInterface); + if (errorHandling.isPresent()) { + return errorHandling; + } + } + // Finally, if there's a superclass that isn't Object check if the superclass has anything + if (!apiType.isInterface() && !apiType.getSuperclass().equals(Object.class)) { + return readErrorHandlingIncludingInherited(apiType.getSuperclass()); + } + return Optional.empty(); + } + + private static ErrorHandling getErrorHandlingAnnotation(AnnotatedElement element) { + ErrorHandling annotation = element.getAnnotation(ErrorHandling.class); + if (annotation == null) { + for (Annotation metaAnnotation : element.getAnnotations()) { + annotation = metaAnnotation.annotationType().getAnnotation(ErrorHandling.class); + if (annotation != null) { + break; + } + } + } + return annotation; + } + + static ErrorHandlingDefinition readAnnotation(ErrorHandling errorHandling, + Decoder responseBodyDecoder) { + ExceptionGenerator defaultException = new ExceptionGenerator.Builder() + .withResponseBodyDecoder(responseBodyDecoder) + .withExceptionType(errorHandling.defaultException()) + .build(); + Map statusCodesDefinition = + new HashMap(); + + for (ErrorCodes statusCodeDefinition : errorHandling.codeSpecific()) { + for (int statusCode : statusCodeDefinition.codes()) { + if (statusCodesDefinition.containsKey(statusCode)) { + throw new IllegalStateException( + "Status Code [" + statusCode + "] " + + "has already been declared to throw [" + + statusCodesDefinition.get(statusCode).getExceptionType().getName() + "] " + + "and [" + statusCodeDefinition.generate() + "] - dupe definition"); + } + statusCodesDefinition.put(statusCode, + new ExceptionGenerator.Builder() + .withResponseBodyDecoder(responseBodyDecoder) + .withExceptionType(statusCodeDefinition.generate()) + .build()); + } + } + + return new ErrorHandlingDefinition(defaultException, statusCodesDefinition); + } + + private static class ErrorHandlingDefinition { + private final ExceptionGenerator defaultThrow; + private final Map statusCodesMap; + + + private ErrorHandlingDefinition(ExceptionGenerator defaultThrow, + Map statusCodesMap) { + this.defaultThrow = defaultThrow; + this.statusCodesMap = statusCodesMap; + } + } + } +} diff --git a/annotation-error-decoder/src/main/java/feign/error/ErrorCodes.java b/annotation-error-decoder/src/main/java/feign/error/ErrorCodes.java new file mode 100644 index 000000000..7810feb1d --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/ErrorCodes.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2022 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.error; + +public @interface ErrorCodes { + int[] codes(); + + Class generate(); +} diff --git a/annotation-error-decoder/src/main/java/feign/error/ErrorHandling.java b/annotation-error-decoder/src/main/java/feign/error/ErrorHandling.java new file mode 100644 index 000000000..ffb13e065 --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/ErrorHandling.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2022 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.error; + +import feign.Response; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface ErrorHandling { + ErrorCodes[] codeSpecific() default {}; + + Class defaultException() default NO_DEFAULT.class; + + final class NO_DEFAULT extends Exception { + @FeignExceptionConstructor + public NO_DEFAULT(@ResponseBody Response response) { + super("Endpoint responded with " + response.status() + ", reason: " + response.reason()); + } + } +} diff --git a/annotation-error-decoder/src/main/java/feign/error/ExceptionGenerator.java b/annotation-error-decoder/src/main/java/feign/error/ExceptionGenerator.java new file mode 100644 index 000000000..433c9d78f --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/ExceptionGenerator.java @@ -0,0 +1,221 @@ +/* + * Copyright 2012-2022 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.error; + +import feign.Request; +import feign.Response; +import feign.Types; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import static feign.Util.checkState; + +class ExceptionGenerator { + + private static final Response TEST_RESPONSE; + + static { + Map> testHeaders = new HashMap>(); + testHeaders.put("TestHeader", Arrays.asList("header-value")); + + TEST_RESPONSE = Response.builder() + .status(500) + .body((Response.Body) null) + .headers(testHeaders) + .request(Request.create(Request.HttpMethod.GET, "http://test", testHeaders, + Request.Body.empty(), null)) + .build(); + } + + private final Integer bodyIndex; + private final Integer requestIndex; + private final Integer headerMapIndex; + private final Integer numOfParams; + private final Type bodyType; + private final Class exceptionType; + private final Decoder bodyDecoder; + + ExceptionGenerator(Integer bodyIndex, Integer requestIndex, Integer headerMapIndex, + Integer numOfParams, Type bodyType, + Class exceptionType, Decoder bodyDecoder) { + this.bodyIndex = bodyIndex; + this.requestIndex = requestIndex; + this.headerMapIndex = headerMapIndex; + this.numOfParams = numOfParams; + this.bodyType = bodyType; + this.exceptionType = exceptionType; + this.bodyDecoder = bodyDecoder; + } + + + Exception createException(Response response) throws InvocationTargetException, + NoSuchMethodException, InstantiationException, IllegalAccessException { + + Class[] paramClasses = new Class[numOfParams]; + Object[] paramValues = new Object[numOfParams]; + if (bodyIndex >= 0) { + paramClasses[bodyIndex] = Types.getRawType(bodyType); + paramValues[bodyIndex] = resolveBody(response); + } + if (requestIndex >= 0) { + paramClasses[requestIndex] = Request.class; + paramValues[requestIndex] = response.request(); + } + if (headerMapIndex >= 0) { + paramValues[headerMapIndex] = response.headers(); + paramClasses[headerMapIndex] = Map.class; + } + return exceptionType.getConstructor(paramClasses) + .newInstance(paramValues); + + } + + Class getExceptionType() { + return exceptionType; + } + + private Object resolveBody(Response response) { + if (bodyType instanceof Class && ((Class) bodyType).isInstance(response)) { + return response; + } + try { + return bodyDecoder.decode(response, bodyType); + } catch (IOException e) { + // How do we log this? + return null; + } catch (DecodeException e) { + // How do we log this? + return null; + } + } + + static class Builder { + private Class exceptionType; + private Decoder responseBodyDecoder; + + public Builder withExceptionType(Class exceptionType) { + this.exceptionType = exceptionType; + return this; + } + + public Builder withResponseBodyDecoder(Decoder bodyDecoder) { + this.responseBodyDecoder = bodyDecoder; + return this; + } + + public ExceptionGenerator build() { + Constructor constructor = getConstructor(exceptionType); + Type[] parameterTypes = constructor.getGenericParameterTypes(); + Annotation[][] parametersAnnotations = constructor.getParameterAnnotations(); + + Integer bodyIndex = -1; + Integer requestIndex = -1; + Integer headerMapIndex = -1; + Integer numOfParams = parameterTypes.length; + Type bodyType = null; + + for (int i = 0; i < parameterTypes.length; i++) { + Annotation[] paramAnnotations = parametersAnnotations[i]; + boolean foundAnnotation = false; + for (Annotation annotation : paramAnnotations) { + if (annotation.annotationType().equals(ResponseHeaders.class)) { + checkState(headerMapIndex == -1, + "Cannot have two parameters tagged with @ResponseHeaders"); + checkState(Types.getRawType(parameterTypes[i]).equals(Map.class), + "Response Header map must be of type Map, but was %s", parameterTypes[i]); + headerMapIndex = i; + foundAnnotation = true; + break; + } + } + if (!foundAnnotation) { + if (parameterTypes[i].equals(Request.class)) { + checkState(requestIndex == -1, + "Cannot have two parameters either without annotations or with object of type feign.Request"); + requestIndex = i; + } else { + checkState(bodyIndex == -1, + "Cannot have two parameters either without annotations or with @ResponseBody annotation"); + bodyIndex = i; + bodyType = parameterTypes[i]; + } + } + } + + ExceptionGenerator generator = new ExceptionGenerator( + bodyIndex, + requestIndex, + headerMapIndex, + numOfParams, + bodyType, + exceptionType, + responseBodyDecoder); + + validateGeneratorCanBeUsedToGenerateExceptions(generator); + return generator; + } + + private void validateGeneratorCanBeUsedToGenerateExceptions(ExceptionGenerator generator) { + try { + generator.createException(TEST_RESPONSE); + } catch (Exception e) { + throw new IllegalStateException( + "Cannot generate exception - check constructor parameter types (are headers Map> or is something causing an exception on construction?)", + e); + } + } + + private Constructor getConstructor(Class exceptionClass) { + Constructor preferredConstructor = null; + for (Constructor constructor : exceptionClass.getConstructors()) { + + FeignExceptionConstructor exceptionConstructor = + constructor.getAnnotation(FeignExceptionConstructor.class); + if (exceptionConstructor == null) { + continue; + } + Class[] parameterTypes = constructor.getParameterTypes(); + if (parameterTypes.length == 0) { + continue; + } + if (preferredConstructor == null) { + preferredConstructor = (Constructor) constructor; + } else { + throw new IllegalStateException( + "Too many constructors marked with @FeignExceptionConstructor"); + } + } + + if (preferredConstructor == null) { + try { + return exceptionClass.getConstructor(); + } catch (NoSuchMethodException e) { + throw new IllegalStateException( + "Cannot find any suitable constructor in class [" + exceptionClass.getName() + + "] - did you forget to mark one with @FeignExceptionConstructor or at least have a public default constructor?", + e); + } + } + return preferredConstructor; + } + } +} diff --git a/annotation-error-decoder/src/main/java/feign/error/FeignExceptionConstructor.java b/annotation-error-decoder/src/main/java/feign/error/FeignExceptionConstructor.java new file mode 100644 index 000000000..7877ae142 --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/FeignExceptionConstructor.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2022 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.error; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.CONSTRUCTOR}) +public @interface FeignExceptionConstructor { +} diff --git a/annotation-error-decoder/src/main/java/feign/error/MethodErrorHandler.java b/annotation-error-decoder/src/main/java/feign/error/MethodErrorHandler.java new file mode 100644 index 000000000..ca44b438d --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/MethodErrorHandler.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-2022 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.error; + +import feign.Response; +import java.lang.reflect.InvocationTargetException; +import java.util.Map; + +class MethodErrorHandler { + + private final Map methodLevelExceptionsByCode; + private final Map classLevelExceptionsByCode; + private final ExceptionGenerator defaultException; + + MethodErrorHandler(Map methodLevelExceptionsByCode, + Map classLevelExceptionsByCode, + ExceptionGenerator defaultException) { + this.methodLevelExceptionsByCode = methodLevelExceptionsByCode; + this.classLevelExceptionsByCode = classLevelExceptionsByCode; + this.defaultException = defaultException; + } + + + public Exception decode(Response response) { + ExceptionGenerator constructorDefinition = getConstructorDefinition(response); + return createException(constructorDefinition, response); + } + + private ExceptionGenerator getConstructorDefinition(Response response) { + if (methodLevelExceptionsByCode.containsKey(response.status())) { + return methodLevelExceptionsByCode.get(response.status()); + } + if (classLevelExceptionsByCode.containsKey(response.status())) { + return classLevelExceptionsByCode.get(response.status()); + } + return defaultException; + } + + protected Exception createException(ExceptionGenerator constructorDefinition, Response response) { + try { + return constructorDefinition.createException(response); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Cannot access constructor", e); + } catch (InstantiationException e) { + throw new IllegalStateException("Cannot instantiate exception with constructor", e); + } catch (InvocationTargetException e) { + throw new IllegalStateException("Cannot invoke constructor", e); + } catch (NoSuchMethodException e) { + throw new IllegalStateException("Constructor does not exist", e); + } + } +} diff --git a/annotation-error-decoder/src/main/java/feign/error/ResponseBody.java b/annotation-error-decoder/src/main/java/feign/error/ResponseBody.java new file mode 100644 index 000000000..da9bb4c13 --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/ResponseBody.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2022 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.error; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PARAMETER}) +public @interface ResponseBody { +} diff --git a/annotation-error-decoder/src/main/java/feign/error/ResponseHeaders.java b/annotation-error-decoder/src/main/java/feign/error/ResponseHeaders.java new file mode 100644 index 000000000..b5119838e --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/ResponseHeaders.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2022 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.error; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.PARAMETER}) +public @interface ResponseHeaders { +} diff --git a/annotation-error-decoder/src/test/java/feign/error/AbstractAnnotationErrorDecoderTest.java b/annotation-error-decoder/src/test/java/feign/error/AbstractAnnotationErrorDecoderTest.java new file mode 100644 index 000000000..5a930932b --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AbstractAnnotationErrorDecoderTest.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2022 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.error; + +import feign.Request; +import feign.Response; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import static feign.Feign.configKey; + +public abstract class AbstractAnnotationErrorDecoderTest { + + + + public abstract Class interfaceAtTest(); + + String feignConfigKey(String methodName) throws NoSuchMethodException { + return configKey(interfaceAtTest(), interfaceAtTest().getMethod(methodName)); + } + + Response testResponse(int status) { + return testResponse(status, "default Response body"); + } + + Response testResponse(int status, String body) { + return testResponse(status, body, new HashMap>()); + } + + Response testResponse(int status, String body, Map> headers) { + return Response.builder() + .status(status) + .body(body, StandardCharsets.UTF_8) + .headers(headers) + .request(Request.create(Request.HttpMethod.GET, "http://test", headers, + Request.Body.empty(), null)) + .build(); + } +} diff --git a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderAnnotationInheritanceTest.java b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderAnnotationInheritanceTest.java new file mode 100644 index 000000000..b699874e7 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderAnnotationInheritanceTest.java @@ -0,0 +1,105 @@ +/* + * Copyright 2012-2022 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.error; + +import static org.assertj.core.api.Assertions.assertThat; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; + +@RunWith(Parameterized.class) +public class AnnotationErrorDecoderAnnotationInheritanceTest extends + AbstractAnnotationErrorDecoderTest { + @Override + public Class interfaceAtTest() { + return TestClientInterfaceWithWithMetaAnnotation.class; + } + + @Parameters( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + public static Iterable data() { + return Arrays.asList(new Object[][] { + {"Test Code Specific At Method", 402, "method1Test", MethodLevelDefaultException.class}, + {"Test Code Specific At Method", 403, "method1Test", MethodLevelNotFoundException.class}, + {"Test Code Specific At Method", 404, "method1Test", MethodLevelNotFoundException.class}, + {"Test Code Specific At Method", 402, "method2Test", ClassLevelDefaultException.class}, + {"Test Code Specific At Method", 403, "method2Test", MethodLevelNotFoundException.class}, + {"Test Code Specific At Method", 404, "method2Test", ClassLevelNotFoundException.class}, + }); + } + + @Parameter // first data value (0) is default + public String testType; + + @Parameter(1) + public int errorCode; + + @Parameter(2) + public String method; + + @Parameter(3) + public Class expectedExceptionClass; + + @Test + public void test() throws Exception { + AnnotationErrorDecoder decoder = + AnnotationErrorDecoder.builderFor(TestClientInterfaceWithWithMetaAnnotation.class).build(); + + assertThat(decoder.decode(feignConfigKey(method), testResponse(errorCode)).getClass()) + .isEqualTo(expectedExceptionClass); + } + + @ClassError + interface TestClientInterfaceWithWithMetaAnnotation { + @MethodError + void method1Test(); + + @ErrorHandling( + codeSpecific = {@ErrorCodes(codes = {403}, generate = MethodLevelNotFoundException.class)}) + void method2Test(); + } + + @ErrorHandling( + codeSpecific = {@ErrorCodes(codes = {404}, generate = ClassLevelNotFoundException.class),}, + defaultException = ClassLevelDefaultException.class) + @Retention(RetentionPolicy.RUNTIME) + @interface ClassError { + } + + @ErrorHandling( + codeSpecific = { + @ErrorCodes(codes = {404, 403}, generate = MethodLevelNotFoundException.class),}, + defaultException = MethodLevelDefaultException.class) + @Retention(RetentionPolicy.RUNTIME) + @interface MethodError { + } + + static class ClassLevelDefaultException extends Exception { + public ClassLevelDefaultException() {} + } + static class ClassLevelNotFoundException extends Exception { + public ClassLevelNotFoundException() {} + } + static class MethodLevelDefaultException extends Exception { + public MethodLevelDefaultException() {} + } + static class MethodLevelNotFoundException extends Exception { + public MethodLevelNotFoundException() {} + } +} diff --git a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderClassInheritanceTest.java b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderClassInheritanceTest.java new file mode 100644 index 000000000..a58a4de8e --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderClassInheritanceTest.java @@ -0,0 +1,131 @@ +/* + * Copyright 2012-2022 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.error; + +import static org.assertj.core.api.Assertions.assertThat; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.Method1NotFoundException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.UnauthenticatedOrUnauthorizedException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.Method2NotFoundException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.ServeErrorException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.ClassLevelNotFoundException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.Method1DefaultException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.Method3DefaultException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.ClassLevelDefaultException; +import java.util.Arrays; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +@RunWith(Parameterized.class) +public class AnnotationErrorDecoderClassInheritanceTest extends + AbstractAnnotationErrorDecoderTest { + + @Override + public Class interfaceAtTest() { + return GrandChild.class; + } + + @Parameters( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + public static Iterable data() { + return Arrays.asList(new Object[][] { + {"Test Code Specific At Method", 404, "method1Test", Method1NotFoundException.class}, + {"Test Code Specific At Method", 401, "method1Test", + UnauthenticatedOrUnauthorizedException.class}, + {"Test Code Specific At Method", 404, "method2Test", Method2NotFoundException.class}, + {"Test Code Specific At Method", 500, "method2Test", ServeErrorException.class}, + {"Test Code Specific At Method", 503, "method2Test", ServeErrorException.class}, + {"Test Code Specific At Class", 403, "method1Test", + UnauthenticatedOrUnauthorizedException.class}, + {"Test Code Specific At Class", 403, "method2Test", + UnauthenticatedOrUnauthorizedException.class}, + {"Test Code Specific At Class", 404, "method3Test", ClassLevelNotFoundException.class}, + {"Test Code Specific At Class", 403, "method3Test", + UnauthenticatedOrUnauthorizedException.class}, + {"Test Default At Method", 504, "method1Test", Method1DefaultException.class}, + {"Test Default At Method", 504, "method3Test", Method3DefaultException.class}, + {"Test Default At Class", 504, "method2Test", ClassLevelDefaultException.class}, + }); + } + + @Parameter // first data value (0) is default + public String testType; + + @Parameter(1) + public int errorCode; + + @Parameter(2) + public String method; + + @Parameter(3) + public Class expectedExceptionClass; + + @Test + public void test() throws Exception { + AnnotationErrorDecoder decoder = + AnnotationErrorDecoder.builderFor(GrandChild.class).build(); + + assertThat(decoder.decode(feignConfigKey(method), testResponse(errorCode)).getClass()) + .isEqualTo(expectedExceptionClass); + } + + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = ClassLevelNotFoundException.class), + @ErrorCodes(codes = {403}, generate = UnauthenticatedOrUnauthorizedException.class) + }, + defaultException = ClassLevelDefaultException.class) + interface ParentInterfaceWithErrorHandling { + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = Method1NotFoundException.class), + @ErrorCodes(codes = {401}, generate = UnauthenticatedOrUnauthorizedException.class) + }, + defaultException = Method1DefaultException.class) + void method1Test(); + + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = Method2NotFoundException.class), + @ErrorCodes(codes = {500, 503}, generate = ServeErrorException.class) + }) + void method2Test(); + + @ErrorHandling( + defaultException = Method3DefaultException.class) + void method3Test(); + + class ClassLevelDefaultException extends Exception { + } + class Method1DefaultException extends Exception { + } + class Method3DefaultException extends Exception { + } + class Method1NotFoundException extends Exception { + } + class Method2NotFoundException extends Exception { + } + class ClassLevelNotFoundException extends Exception { + } + class UnauthenticatedOrUnauthorizedException extends Exception { + } + class ServeErrorException extends Exception { + } + } + + abstract class Child implements ParentInterfaceWithErrorHandling { + } + + abstract class GrandChild extends Child { + } +} diff --git a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderExceptionConstructorsTest.java b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderExceptionConstructorsTest.java new file mode 100644 index 000000000..3d4fd7005 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderExceptionConstructorsTest.java @@ -0,0 +1,542 @@ +/* + * Copyright 2012-2022 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.error; + +import feign.Request; +import feign.codec.Decoder; +import feign.optionals.OptionalDecoder; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.junit.runners.Parameterized.Parameter; +import java.util.*; +import static org.assertj.core.api.Assertions.assertThat; +import static feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors; +import static feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.*; + +@RunWith(Parameterized.class) +public class AnnotationErrorDecoderExceptionConstructorsTest extends + AbstractAnnotationErrorDecoderTest { + + + private static final String NO_BODY = "NO BODY"; + private static final Object NULL_BODY = null; + private static final String NON_NULL_BODY = "A GIVEN BODY"; + private static final feign.Request REQUEST = feign.Request.create(feign.Request.HttpMethod.GET, + "http://test", Collections.emptyMap(), Request.Body.empty(), null); + private static final feign.Request NO_REQUEST = null; + private static final Map> NON_NULL_HEADERS = + new HashMap>(); + private static final Map> NO_HEADERS = null; + + + @Override + public Class interfaceAtTest() { + return TestClientInterfaceWithDifferentExceptionConstructors.class; + } + + @Parameters( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + public static Iterable data() { + return Arrays.asList(new Object[][] { + {"Test Default Constructor", 500, DefaultConstructorException.class, NO_REQUEST, NO_BODY, + NO_HEADERS}, + {"test Default Constructor", 501, DeclaredDefaultConstructorException.class, NO_REQUEST, + NO_BODY, + NO_HEADERS}, + {"test Default Constructor", 502, + DeclaredDefaultConstructorWithOtherConstructorsException.class, NO_REQUEST, NO_BODY, + NO_HEADERS}, + {"test Declared Constructor", 503, DefinedConstructorWithNoAnnotationForBody.class, + NO_REQUEST, + NON_NULL_BODY, NO_HEADERS}, + {"test Declared Constructor", 504, DefinedConstructorWithAnnotationForBody.class, + NO_REQUEST, NON_NULL_BODY, NO_HEADERS}, + {"test Declared Constructor", 505, DefinedConstructorWithAnnotationForBodyAndHeaders.class, + NO_REQUEST, NON_NULL_BODY, NON_NULL_HEADERS}, + {"test Declared Constructor", 506, + DefinedConstructorWithAnnotationForBodyAndHeadersSecondOrder.class, NO_REQUEST, + NON_NULL_BODY, + NON_NULL_HEADERS}, + {"test Declared Constructor", 507, DefinedConstructorWithAnnotationForHeaders.class, + NO_REQUEST, NO_BODY, NON_NULL_HEADERS}, + {"test Declared Constructor", 508, + DefinedConstructorWithAnnotationForHeadersButNotForBody.class, NO_REQUEST, + NON_NULL_BODY, + NON_NULL_HEADERS}, + {"test Declared Constructor", 509, + DefinedConstructorWithAnnotationForNonSupportedBody.class, NO_REQUEST, NULL_BODY, + NO_HEADERS}, + {"test Declared Constructor", 510, + DefinedConstructorWithAnnotationForOptionalBody.class, NO_REQUEST, + Optional.of(NON_NULL_BODY), + NO_HEADERS}, + {"test Declared Constructor", 511, + DefinedConstructorWithRequest.class, REQUEST, NO_BODY, + NO_HEADERS}, + {"test Declared Constructor", 512, + DefinedConstructorWithRequestAndResponseBody.class, REQUEST, NON_NULL_BODY, + NO_HEADERS}, + {"test Declared Constructor", 513, + DefinedConstructorWithRequestAndAnnotationForResponseBody.class, REQUEST, NON_NULL_BODY, + NO_HEADERS}, + {"test Declared Constructor", 514, + DefinedConstructorWithRequestAndResponseHeadersAndResponseBody.class, REQUEST, + NON_NULL_BODY, + NON_NULL_HEADERS}, + {"test Declared Constructor", 515, + DefinedConstructorWithRequestAndResponseHeadersAndOptionalResponseBody.class, REQUEST, + Optional.of(NON_NULL_BODY), + NON_NULL_HEADERS} + }); + } + + @Parameter // first data value (0) is default + public String testName; + + @Parameter(1) + public int errorCode; + + @Parameter(2) + public Class expectedExceptionClass; + + @Parameter(3) + public Object expectedRequest; + + @Parameter(4) + public Object expectedBody; + + @Parameter(5) + public Map> expectedHeaders; + + @Test + public void test() throws Exception { + AnnotationErrorDecoder decoder = AnnotationErrorDecoder + .builderFor(TestClientInterfaceWithDifferentExceptionConstructors.class) + .withResponseBodyDecoder(new OptionalDecoder(new Decoder.Default())) + .build(); + + Exception genericException = decoder.decode(feignConfigKey("method1Test"), + testResponse(errorCode, NON_NULL_BODY, NON_NULL_HEADERS)); + + assertThat(genericException).isInstanceOf(expectedExceptionClass); + + ParametersException exception = (ParametersException) genericException; + assertThat(exception.body()).isEqualTo(expectedBody); + assertThat(exception.headers()).isEqualTo(expectedHeaders); + } + + @Test + public void testIfExceptionIsNotInTheList() throws Exception { + AnnotationErrorDecoder decoder = AnnotationErrorDecoder + .builderFor(TestClientInterfaceWithDifferentExceptionConstructors.class) + .withResponseBodyDecoder(new OptionalDecoder(new Decoder.Default())) + .build(); + + Exception genericException = decoder.decode(feignConfigKey("method1Test"), + testResponse(-1, NON_NULL_BODY, NON_NULL_HEADERS)); + + assertThat(genericException) + .isInstanceOf(ErrorHandling.NO_DEFAULT.class) + .hasMessage("Endpoint responded with -1, reason: null"); + } + + interface TestClientInterfaceWithDifferentExceptionConstructors { + + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {500}, generate = DefaultConstructorException.class), + @ErrorCodes(codes = {501}, generate = DeclaredDefaultConstructorException.class), + @ErrorCodes(codes = {502}, + generate = DeclaredDefaultConstructorWithOtherConstructorsException.class), + @ErrorCodes(codes = {503}, generate = DefinedConstructorWithNoAnnotationForBody.class), + @ErrorCodes(codes = {504}, generate = DefinedConstructorWithAnnotationForBody.class), + @ErrorCodes(codes = {505}, + generate = DefinedConstructorWithAnnotationForBodyAndHeaders.class), + @ErrorCodes(codes = {506}, + generate = DefinedConstructorWithAnnotationForBodyAndHeadersSecondOrder.class), + @ErrorCodes(codes = {507}, generate = DefinedConstructorWithAnnotationForHeaders.class), + @ErrorCodes(codes = {508}, + generate = DefinedConstructorWithAnnotationForHeadersButNotForBody.class), + @ErrorCodes(codes = {509}, + generate = DefinedConstructorWithAnnotationForNonSupportedBody.class), + @ErrorCodes(codes = {510}, + generate = DefinedConstructorWithAnnotationForOptionalBody.class), + @ErrorCodes(codes = {511}, + generate = DefinedConstructorWithRequest.class), + @ErrorCodes(codes = {512}, + generate = DefinedConstructorWithRequestAndResponseBody.class), + @ErrorCodes(codes = {513}, + generate = DefinedConstructorWithRequestAndAnnotationForResponseBody.class), + @ErrorCodes(codes = {514}, + generate = DefinedConstructorWithRequestAndResponseHeadersAndResponseBody.class), + @ErrorCodes(codes = {515}, + generate = DefinedConstructorWithRequestAndResponseHeadersAndOptionalResponseBody.class) + }) + void method1Test(); + + class ParametersException extends Exception { + public Object body() { + return NO_BODY; + } + + public feign.Request request() { + return null; + } + + public Map> headers() { + return null; + } + } + class DefaultConstructorException extends ParametersException { + } + + class DeclaredDefaultConstructorException extends ParametersException { + public DeclaredDefaultConstructorException() {} + } + + class DeclaredDefaultConstructorWithOtherConstructorsException extends ParametersException { + public DeclaredDefaultConstructorWithOtherConstructorsException() {} + + public DeclaredDefaultConstructorWithOtherConstructorsException(TestPojo testPojo) { + throw new UnsupportedOperationException("Should not be called"); + } + + public DeclaredDefaultConstructorWithOtherConstructorsException(Throwable cause) { + throw new UnsupportedOperationException("Should not be called"); + } + + public DeclaredDefaultConstructorWithOtherConstructorsException(String message, + Throwable cause) { + throw new UnsupportedOperationException("Should not be called"); + } + } + + class DefinedConstructorWithNoAnnotationForBody extends ParametersException { + String body; + + public DefinedConstructorWithNoAnnotationForBody() { + throw new UnsupportedOperationException("Should not be called"); + } + + @FeignExceptionConstructor + public DefinedConstructorWithNoAnnotationForBody(String body) { + this.body = body; + } + + public DefinedConstructorWithNoAnnotationForBody(TestPojo testPojo) { + throw new UnsupportedOperationException("Should not be called"); + } + + @Override + public Object body() { + return body; + } + + } + + class DefinedConstructorWithRequest extends ParametersException { + feign.Request request; + + public DefinedConstructorWithRequest() { + throw new UnsupportedOperationException("Should not be called"); + } + + @FeignExceptionConstructor + public DefinedConstructorWithRequest(feign.Request request) { + this.request = request; + } + + public DefinedConstructorWithRequest(TestPojo testPojo) { + throw new UnsupportedOperationException("Should not be called"); + } + + @Override + public feign.Request request() { + return request; + } + + } + + class DefinedConstructorWithRequestAndResponseBody extends ParametersException { + feign.Request request; + String body; + + public DefinedConstructorWithRequestAndResponseBody() { + throw new UnsupportedOperationException("Should not be called"); + } + + @FeignExceptionConstructor + public DefinedConstructorWithRequestAndResponseBody(feign.Request request, String body) { + this.request = request; + this.body = body; + } + + public DefinedConstructorWithRequestAndResponseBody(TestPojo testPojo) { + throw new UnsupportedOperationException("Should not be called"); + } + + @Override + public feign.Request request() { + return request; + } + + @Override + public String body() { + return body; + } + } + + class DefinedConstructorWithRequestAndAnnotationForResponseBody extends ParametersException { + feign.Request request; + String body; + + public DefinedConstructorWithRequestAndAnnotationForResponseBody() { + throw new UnsupportedOperationException("Should not be called"); + } + + @FeignExceptionConstructor + public DefinedConstructorWithRequestAndAnnotationForResponseBody(feign.Request request, + @ResponseBody String body) { + this.request = request; + this.body = body; + } + + public DefinedConstructorWithRequestAndAnnotationForResponseBody(TestPojo testPojo) { + throw new UnsupportedOperationException("Should not be called"); + } + + @Override + public feign.Request request() { + return request; + } + + @Override + public String body() { + return body; + } + } + + class DefinedConstructorWithRequestAndResponseHeadersAndResponseBody + extends ParametersException { + feign.Request request; + String body; + Map headers; + + public DefinedConstructorWithRequestAndResponseHeadersAndResponseBody() { + throw new UnsupportedOperationException("Should not be called"); + } + + @FeignExceptionConstructor + public DefinedConstructorWithRequestAndResponseHeadersAndResponseBody(feign.Request request, + @ResponseHeaders Map headers, + @ResponseBody String body) { + this.request = request; + this.body = body; + this.headers = headers; + } + + public DefinedConstructorWithRequestAndResponseHeadersAndResponseBody(TestPojo testPojo) { + throw new UnsupportedOperationException("Should not be called"); + } + + @Override + public feign.Request request() { + return request; + } + + @Override + public Map headers() { + return headers; + } + + @Override + public String body() { + return body; + } + } + + class DefinedConstructorWithRequestAndResponseHeadersAndOptionalResponseBody + extends ParametersException { + feign.Request request; + Optional body; + Map headers; + + public DefinedConstructorWithRequestAndResponseHeadersAndOptionalResponseBody() { + throw new UnsupportedOperationException("Should not be called"); + } + + @FeignExceptionConstructor + public DefinedConstructorWithRequestAndResponseHeadersAndOptionalResponseBody( + feign.Request request, + @ResponseHeaders Map headers, + @ResponseBody Optional body) { + this.request = request; + this.body = body; + this.headers = headers; + } + + public DefinedConstructorWithRequestAndResponseHeadersAndOptionalResponseBody( + TestPojo testPojo) { + throw new UnsupportedOperationException("Should not be called"); + } + + @Override + public feign.Request request() { + return request; + } + + @Override + public Map headers() { + return headers; + } + + @Override + public Object body() { + return body; + } + } + + class DefinedConstructorWithAnnotationForBody extends ParametersException { + String body; + + public DefinedConstructorWithAnnotationForBody() { + throw new UnsupportedOperationException("Should not be called"); + } + + @FeignExceptionConstructor + public DefinedConstructorWithAnnotationForBody(@ResponseBody String body) { + this.body = body; + } + + @Override + public Object body() { + return body; + } + } + + class DefinedConstructorWithAnnotationForOptionalBody extends ParametersException { + Optional body; + + public DefinedConstructorWithAnnotationForOptionalBody() { + throw new UnsupportedOperationException("Should not be called"); + } + + @FeignExceptionConstructor + public DefinedConstructorWithAnnotationForOptionalBody(@ResponseBody Optional body) { + this.body = body; + } + + @Override + public Object body() { + return body; + } + } + + class DefinedConstructorWithAnnotationForNonSupportedBody extends ParametersException { + TestPojo body; + + @FeignExceptionConstructor + public DefinedConstructorWithAnnotationForNonSupportedBody(@ResponseBody TestPojo body) { + this.body = body; + } + + @Override + public Object body() { + return body; + } + } + + class DefinedConstructorWithAnnotationForBodyAndHeaders extends ParametersException { + String body; + Map> headers; + + @FeignExceptionConstructor + public DefinedConstructorWithAnnotationForBodyAndHeaders(@ResponseBody String body, + @ResponseHeaders Map> headers) { + this.body = body; + this.headers = headers; + } + + @Override + public Object body() { + return body; + } + + @Override + public Map> headers() { + return headers; + } + } + + class DefinedConstructorWithAnnotationForBodyAndHeadersSecondOrder extends ParametersException { + String body; + Map> headers; + + @FeignExceptionConstructor + public DefinedConstructorWithAnnotationForBodyAndHeadersSecondOrder( + @ResponseHeaders Map> headers, @ResponseBody String body) { + this.body = body; + this.headers = headers; + } + + @Override + public Object body() { + return body; + } + + @Override + public Map> headers() { + return headers; + } + } + + class DefinedConstructorWithAnnotationForHeaders extends ParametersException { + Map> headers; + + @FeignExceptionConstructor + public DefinedConstructorWithAnnotationForHeaders( + @ResponseHeaders Map> headers) { + this.headers = headers; + } + + @Override + public Map> headers() { + return headers; + } + } + + class DefinedConstructorWithAnnotationForHeadersButNotForBody extends ParametersException { + String body; + Map> headers; + + @FeignExceptionConstructor + public DefinedConstructorWithAnnotationForHeadersButNotForBody( + @ResponseHeaders Map> headers, String body) { + this.body = body; + this.headers = headers; + } + + @Override + public Object body() { + return body; + } + + @Override + public Map> headers() { + return headers; + } + } + } +} diff --git a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderIllegalInterfacesTest.java b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderIllegalInterfacesTest.java new file mode 100644 index 000000000..a4cfa4369 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderIllegalInterfacesTest.java @@ -0,0 +1,180 @@ +/* + * Copyright 2012-2022 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.error; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.Request; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.junit.runners.Parameterized.Parameter; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +@RunWith(Parameterized.class) +public class AnnotationErrorDecoderIllegalInterfacesTest { + + @Parameters( + name = "{index}: When building interface ({0}) should return exception type ({1}) with message ({2})") + public static Iterable data() { + return Arrays.asList(new Object[][] { + {IllegalTestClientInterfaceWithTooManyMappingsToSingleCode.class, + IllegalStateException.class, "Status Code [404] has already been declared"}, + {IllegalTestClientInterfaceWithExceptionWithTooManyConstructors.class, + IllegalStateException.class, "Too many constructors"}, + {IllegalTestClientInterfaceWithExceptionWithTooManyBodyParams.class, + IllegalStateException.class, "Cannot have two parameters either without"}, + {IllegalTestClientInterfaceWithExceptionWithTooManyHeaderParams.class, + IllegalStateException.class, "Cannot have two parameters tagged"}, + {IllegalTestClientInterfaceWithExceptionWithBadHeaderParams.class, + IllegalStateException.class, "Cannot generate exception - check constructor"}, + {IllegalTestClientInterfaceWithExceptionWithBadHeaderObjectParam.class, + IllegalStateException.class, "Response Header map must be of type Map"}, + {IllegalTestClientInterfaceWithExceptionWithTooManyRequestParams.class, + IllegalStateException.class, "Cannot have two parameters either without"} + }); + } + + @Parameter // first data value (0) is default + public Class testInterface; + + @Parameter(1) + public Class expectedExceptionClass; + + @Parameter(2) + public String messageStart; + + @Test + public void test() throws Exception { + try { + AnnotationErrorDecoder.builderFor(testInterface).build(); + fail("Should have thrown exception"); + } catch (Exception e) { + assertThat(e.getClass()).isEqualTo(expectedExceptionClass); + assertThat(e.getMessage()).startsWith(messageStart); + } + } + + interface IllegalTestClientInterfaceWithTooManyMappingsToSingleCode { + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = Exception.class), + @ErrorCodes(codes = {404}, generate = Exception.class) + }) + void method1Test(); + } + + interface IllegalTestClientInterfaceWithExceptionWithTooManyConstructors { + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = BadException.class) + }) + void method1Test(); + + class BadException extends Exception { + + @FeignExceptionConstructor + public BadException(String body) { + + } + + @FeignExceptionConstructor + public BadException(String body, @ResponseHeaders Map> headers) { + + } + } + } + + interface IllegalTestClientInterfaceWithExceptionWithTooManyBodyParams { + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = BadException.class) + }) + void method1Test(); + + class BadException extends Exception { + + @FeignExceptionConstructor + public BadException(String body, @ResponseBody String otherBody) { + + } + } + } + + interface IllegalTestClientInterfaceWithExceptionWithTooManyRequestParams { + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = BadException.class) + }) + void method1Test(); + + class BadException extends Exception { + + @FeignExceptionConstructor + public BadException(Request request1, Request request2) { + + } + } + } + + interface IllegalTestClientInterfaceWithExceptionWithTooManyHeaderParams { + + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = BadException.class) + }) + void method1Test(); + + class BadException extends Exception { + + @FeignExceptionConstructor + public BadException(@ResponseHeaders Map> headers1, + @ResponseHeaders Map> headers2) { + + } + } + } + + interface IllegalTestClientInterfaceWithExceptionWithBadHeaderParams { + + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = BadException.class) + }) + void method1Test(); + + class BadException extends Exception { + + @FeignExceptionConstructor + public BadException(@ResponseHeaders Map headers1) { + headers1.get(3); + } + } + } + + interface IllegalTestClientInterfaceWithExceptionWithBadHeaderObjectParam { + + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = BadException.class) + }) + void method1Test(); + + class BadException extends Exception { + + @FeignExceptionConstructor + public BadException(@ResponseHeaders TestPojo headers1) {} + } + } + + + +} diff --git a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderInheritanceClassLevelAnnotationTest.java b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderInheritanceClassLevelAnnotationTest.java new file mode 100644 index 000000000..437dd2e32 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderInheritanceClassLevelAnnotationTest.java @@ -0,0 +1,119 @@ +/* + * Copyright 2012-2022 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.error; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(Parameterized.class) +public class AnnotationErrorDecoderInheritanceClassLevelAnnotationTest extends + AbstractAnnotationErrorDecoderTest { + @Override + public Class interfaceAtTest() { + return SecondLevelInterface.class; + } + + @Parameters( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + public static Iterable data() { + return Arrays.asList(new Object[][] { + {"Test Code Specific At Method", 403, "topLevelMethod", + SecondLevelClassDefaultException.class}, + {"Test Code Specific At Method", 403, "secondLevelMethod", + SecondLevelMethodDefaultException.class}, + {"Test Code Specific At Method", 404, "topLevelMethod", + SecondLevelClassAnnotationException.class}, + {"Test Code Specific At Method", 404, "secondLevelMethod", + SecondLevelMethodErrorHandlingException.class}, + }); + } + + @Parameter // first data value (0) is default + public String testType; + + @Parameter(1) + public int errorCode; + + @Parameter(2) + public String method; + + @Parameter(3) + public Class expectedExceptionClass; + + @Test + public void test() throws Exception { + AnnotationErrorDecoder decoder = + AnnotationErrorDecoder.builderFor(SecondLevelInterface.class).build(); + + assertThat(decoder.decode(feignConfigKey(method), testResponse(errorCode)).getClass()) + .isEqualTo(expectedExceptionClass); + } + + @TopLevelClassError + interface TopLevelInterface { + @ErrorHandling + void topLevelMethod(); + } + + @SecondLevelClassError + interface SecondLevelInterface extends TopLevelInterface { + @ErrorHandling( + codeSpecific = { + @ErrorCodes(codes = {404}, generate = SecondLevelMethodErrorHandlingException.class)}, + defaultException = SecondLevelMethodDefaultException.class) + void secondLevelMethod(); + } + + @ErrorHandling( + codeSpecific = { + @ErrorCodes(codes = {403, 404}, generate = TopLevelClassAnnotationException.class),}, + defaultException = TopLevelClassDefaultException.class) + @Retention(RetentionPolicy.RUNTIME) + @interface TopLevelClassError { + } + + @ErrorHandling( + codeSpecific = { + @ErrorCodes(codes = {404}, generate = SecondLevelClassAnnotationException.class),}, + defaultException = SecondLevelClassDefaultException.class) + @Retention(RetentionPolicy.RUNTIME) + @interface SecondLevelClassError { + } + + static class TopLevelClassDefaultException extends Exception { + public TopLevelClassDefaultException() {} + } + static class TopLevelClassAnnotationException extends Exception { + public TopLevelClassAnnotationException() {} + } + static class SecondLevelClassDefaultException extends Exception { + public SecondLevelClassDefaultException() {} + } + static class SecondLevelMethodDefaultException extends Exception { + public SecondLevelMethodDefaultException() {} + } + static class SecondLevelClassAnnotationException extends Exception { + public SecondLevelClassAnnotationException() {} + } + static class SecondLevelMethodErrorHandlingException extends Exception { + public SecondLevelMethodErrorHandlingException() {} + } +} diff --git a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderInheritanceMethodLevelAnnotationTest.java b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderInheritanceMethodLevelAnnotationTest.java new file mode 100644 index 000000000..da6c79e87 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderInheritanceMethodLevelAnnotationTest.java @@ -0,0 +1,141 @@ +/* + * Copyright 2012-2022 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.error; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(Parameterized.class) +public class AnnotationErrorDecoderInheritanceMethodLevelAnnotationTest extends + AbstractAnnotationErrorDecoderTest { + @Override + public Class interfaceAtTest() { + return SecondLevelInterface.class; + } + + @Parameters( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + public static Iterable data() { + return Arrays.asList(new Object[][] { + {"Test Code Specific At Method", 403, "topLevelMethod1", + MethodTopLevelDefaultException.class}, + {"Test Code Specific At Method", 404, "topLevelMethod1", + MethodTopLevelAnnotationException.class}, + {"Test Code Specific At Method", 403, "topLevelMethod2", + MethodSecondLevelDefaultException.class}, + {"Test Code Specific At Method", 404, "topLevelMethod2", + MethodSecondLevelAnnotationException.class}, + {"Test Code Specific At Method", 403, "topLevelMethod3", + MethodSecondLevelDefaultException.class}, + {"Test Code Specific At Method", 404, "topLevelMethod3", + MethodSecondLevelErrorHandlingException.class}, + {"Test Code Specific At Method", 403, "topLevelMethod4", + MethodSecondLevelDefaultException.class}, + {"Test Code Specific At Method", 404, "topLevelMethod4", + MethodSecondLevelAnnotationException.class}, + }); + } + + @Parameter // first data value (0) is default + public String testType; + + @Parameter(1) + public int errorCode; + + @Parameter(2) + public String method; + + @Parameter(3) + public Class expectedExceptionClass; + + @Test + public void test() throws Exception { + AnnotationErrorDecoder decoder = + AnnotationErrorDecoder.builderFor(SecondLevelInterface.class).build(); + + assertThat(decoder.decode(feignConfigKey(method), testResponse(errorCode)).getClass()) + .isEqualTo(expectedExceptionClass); + } + + interface TopLevelInterface { + @TopLevelMethodErrorHandling + void topLevelMethod1(); + + @TopLevelMethodErrorHandling + void topLevelMethod2(); + + @TopLevelMethodErrorHandling + void topLevelMethod3(); + + @ErrorHandling(codeSpecific = @ErrorCodes(codes = {404}, + generate = TopLevelMethodErrorHandlingException.class)) + void topLevelMethod4(); + } + + interface SecondLevelInterface extends TopLevelInterface { + @SecondLevelMethodErrorHandling + void topLevelMethod2(); + + @ErrorHandling( + codeSpecific = { + @ErrorCodes(codes = {404}, generate = MethodSecondLevelErrorHandlingException.class)}, + defaultException = MethodSecondLevelDefaultException.class) + void topLevelMethod3(); + + @SecondLevelMethodErrorHandling + void topLevelMethod4(); + } + + @ErrorHandling( + codeSpecific = { + @ErrorCodes(codes = {404}, generate = MethodTopLevelAnnotationException.class),}, + defaultException = MethodTopLevelDefaultException.class) + @Retention(RetentionPolicy.RUNTIME) + @interface TopLevelMethodErrorHandling { + } + + @ErrorHandling( + codeSpecific = { + @ErrorCodes(codes = {404}, generate = MethodSecondLevelAnnotationException.class),}, + defaultException = MethodSecondLevelDefaultException.class) + @Retention(RetentionPolicy.RUNTIME) + @interface SecondLevelMethodErrorHandling { + } + + static class MethodTopLevelDefaultException extends Exception { + public MethodTopLevelDefaultException() {} + } + static class TopLevelMethodErrorHandlingException extends Exception { + public TopLevelMethodErrorHandlingException() {} + } + static class MethodTopLevelAnnotationException extends Exception { + public MethodTopLevelAnnotationException() {} + } + static class MethodSecondLevelDefaultException extends Exception { + public MethodSecondLevelDefaultException() {} + } + static class MethodSecondLevelErrorHandlingException extends Exception { + public MethodSecondLevelErrorHandlingException() {} + } + static class MethodSecondLevelAnnotationException extends Exception { + public MethodSecondLevelAnnotationException() {} + } +} diff --git a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderInheritanceTest.java b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderInheritanceTest.java new file mode 100644 index 000000000..4228b70f5 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderInheritanceTest.java @@ -0,0 +1,135 @@ +/* + * Copyright 2012-2022 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.error; + +import static feign.error.AnnotationErrorDecoderInheritanceTest.TestClientInterfaceWithExceptionPriority.ClassLevelDefaultException; +import static feign.error.AnnotationErrorDecoderInheritanceTest.TestClientInterfaceWithExceptionPriority.ClassLevelNotFoundException; +import static feign.error.AnnotationErrorDecoderInheritanceTest.TestClientInterfaceWithExceptionPriority.Method1DefaultException; +import static feign.error.AnnotationErrorDecoderInheritanceTest.TestClientInterfaceWithExceptionPriority.Method1NotFoundException; +import static feign.error.AnnotationErrorDecoderInheritanceTest.TestClientInterfaceWithExceptionPriority.Method2NotFoundException; +import static feign.error.AnnotationErrorDecoderInheritanceTest.TestClientInterfaceWithExceptionPriority.Method3DefaultException; +import static feign.error.AnnotationErrorDecoderInheritanceTest.TestClientInterfaceWithExceptionPriority.ServeErrorException; +import static feign.error.AnnotationErrorDecoderInheritanceTest.TestClientInterfaceWithExceptionPriority.UnauthenticatedOrUnauthorizedException; +import static org.assertj.core.api.Assertions.assertThat; +import java.util.Arrays; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameter; +import org.junit.runners.Parameterized.Parameters; + +@RunWith(Parameterized.class) +public class AnnotationErrorDecoderInheritanceTest extends + AbstractAnnotationErrorDecoderTest { + + @Override + public Class interfaceAtTest() { + return TestClientInterfaceWithExceptionPriority.class; + } + + @Parameters( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + public static Iterable data() { + return Arrays.asList(new Object[][] { + {"Test Code Specific At Method", 404, "method1Test", Method1NotFoundException.class}, + {"Test Code Specific At Method", 401, "method1Test", + UnauthenticatedOrUnauthorizedException.class}, + {"Test Code Specific At Method", 404, "method2Test", Method2NotFoundException.class}, + {"Test Code Specific At Method", 500, "method2Test", ServeErrorException.class}, + {"Test Code Specific At Method", 503, "method2Test", ServeErrorException.class}, + {"Test Code Specific At Class", 403, "method1Test", + UnauthenticatedOrUnauthorizedException.class}, + {"Test Code Specific At Class", 403, "method2Test", + UnauthenticatedOrUnauthorizedException.class}, + {"Test Code Specific At Class", 404, "method3Test", ClassLevelNotFoundException.class}, + {"Test Code Specific At Class", 403, "method3Test", + UnauthenticatedOrUnauthorizedException.class}, + {"Test Default At Method", 504, "method1Test", Method1DefaultException.class}, + {"Test Default At Method", 504, "method3Test", Method3DefaultException.class}, + {"Test Default At Class", 504, "method2Test", ClassLevelDefaultException.class}, + }); + } + + @Parameter // first data value (0) is default + public String testType; + + @Parameter(1) + public int errorCode; + + @Parameter(2) + public String method; + + @Parameter(3) + public Class expectedExceptionClass; + + @Test + public void test() throws Exception { + AnnotationErrorDecoder decoder = + AnnotationErrorDecoder.builderFor(TestClientInterfaceWithExceptionPriority.class).build(); + + assertThat(decoder.decode(feignConfigKey(method), testResponse(errorCode)).getClass()) + .isEqualTo(expectedExceptionClass); + } + + + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = ClassLevelNotFoundException.class), + @ErrorCodes(codes = {403}, generate = UnauthenticatedOrUnauthorizedException.class) + }, + defaultException = ClassLevelDefaultException.class) + interface TopLevelInterface { + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = Method1NotFoundException.class), + @ErrorCodes(codes = {401}, generate = UnauthenticatedOrUnauthorizedException.class) + }, + defaultException = Method1DefaultException.class) + void method1Test(); + + class ClassLevelDefaultException extends Exception { + } + class Method1DefaultException extends Exception { + } + class Method3DefaultException extends Exception { + } + class Method1NotFoundException extends Exception { + } + class Method2NotFoundException extends Exception { + } + class ClassLevelNotFoundException extends Exception { + } + class UnauthenticatedOrUnauthorizedException extends Exception { + } + class ServeErrorException extends Exception { + } + + } + + interface SecondTopLevelInterface { + } + interface SecondLevelInterface extends SecondTopLevelInterface, TopLevelInterface { + } + + interface TestClientInterfaceWithExceptionPriority extends SecondLevelInterface { + + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = Method2NotFoundException.class), + @ErrorCodes(codes = {500, 503}, generate = ServeErrorException.class) + }) + void method2Test(); + + @ErrorHandling( + defaultException = Method3DefaultException.class) + void method3Test(); + } +} diff --git a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderNoAnnotationTest.java b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderNoAnnotationTest.java new file mode 100644 index 000000000..ed788ba65 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderNoAnnotationTest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2022 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.error; + +import feign.Response; +import feign.codec.ErrorDecoder; +import org.junit.Test; +import static feign.error.AnnotationErrorDecoderNoAnnotationTest.*; +import static org.assertj.core.api.Assertions.assertThat; + +public class AnnotationErrorDecoderNoAnnotationTest + extends AbstractAnnotationErrorDecoderTest { + + @Override + public Class interfaceAtTest() { + return TestClientInterfaceWithNoAnnotations.class; + } + + @Test + public void delegatesToDefaultErrorDecoder() throws Exception { + + ErrorDecoder defaultErrorDecoder = new ErrorDecoder() { + @Override + public Exception decode(String methodKey, Response response) { + return new DefaultErrorDecoderException(); + } + }; + + AnnotationErrorDecoder decoder = + AnnotationErrorDecoder.builderFor(TestClientInterfaceWithNoAnnotations.class) + .withDefaultDecoder(defaultErrorDecoder) + .build(); + + assertThat(decoder.decode(feignConfigKey("method1Test"), testResponse(502)).getClass()) + .isEqualTo(DefaultErrorDecoderException.class); + } + + interface TestClientInterfaceWithNoAnnotations { + void method1Test(); + } + + static class DefaultErrorDecoderException extends Exception { + } + +} diff --git a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderPriorityTest.java b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderPriorityTest.java new file mode 100644 index 000000000..41ab6e076 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderPriorityTest.java @@ -0,0 +1,121 @@ +/* + * Copyright 2012-2022 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.error; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.junit.runners.Parameterized.Parameter; +import java.util.Arrays; +import static org.assertj.core.api.Assertions.assertThat; +import static feign.error.AnnotationErrorDecoderPriorityTest.TestClientInterfaceWithExceptionPriority.*; + +@RunWith(Parameterized.class) +public class AnnotationErrorDecoderPriorityTest extends + AbstractAnnotationErrorDecoderTest { + + @Override + public Class interfaceAtTest() { + return TestClientInterfaceWithExceptionPriority.class; + } + + @Parameters( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + public static Iterable data() { + return Arrays.asList(new Object[][] { + {"Test Code Specific At Method", 404, "method1Test", Method1NotFoundException.class}, + {"Test Code Specific At Method", 401, "method1Test", + UnauthenticatedOrUnauthorizedException.class}, + {"Test Code Specific At Method", 404, "method2Test", Method2NotFoundException.class}, + {"Test Code Specific At Method", 500, "method2Test", ServeErrorException.class}, + {"Test Code Specific At Method", 503, "method2Test", ServeErrorException.class}, + {"Test Code Specific At Class", 403, "method1Test", + UnauthenticatedOrUnauthorizedException.class}, + {"Test Code Specific At Class", 403, "method2Test", + UnauthenticatedOrUnauthorizedException.class}, + {"Test Code Specific At Class", 404, "method3Test", ClassLevelNotFoundException.class}, + {"Test Code Specific At Class", 403, "method3Test", + UnauthenticatedOrUnauthorizedException.class}, + {"Test Default At Method", 504, "method1Test", Method1DefaultException.class}, + {"Test Default At Method", 504, "method3Test", Method3DefaultException.class}, + {"Test Default At Class", 504, "method2Test", ClassLevelDefaultException.class}, + }); + } + + @Parameter // first data value (0) is default + public String testType; + + @Parameter(1) + public int errorCode; + + @Parameter(2) + public String method; + + @Parameter(3) + public Class expectedExceptionClass; + + @Test + public void test() throws Exception { + AnnotationErrorDecoder decoder = + AnnotationErrorDecoder.builderFor(TestClientInterfaceWithExceptionPriority.class).build(); + + assertThat(decoder.decode(feignConfigKey(method), testResponse(errorCode)).getClass()) + .isEqualTo(expectedExceptionClass); + } + + + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = ClassLevelNotFoundException.class), + @ErrorCodes(codes = {403}, generate = UnauthenticatedOrUnauthorizedException.class) + }, + defaultException = ClassLevelDefaultException.class) + interface TestClientInterfaceWithExceptionPriority { + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = Method1NotFoundException.class), + @ErrorCodes(codes = {401}, generate = UnauthenticatedOrUnauthorizedException.class) + }, + defaultException = Method1DefaultException.class) + void method1Test(); + + @ErrorHandling(codeSpecific = { + @ErrorCodes(codes = {404}, generate = Method2NotFoundException.class), + @ErrorCodes(codes = {500, 503}, generate = ServeErrorException.class) + }) + void method2Test(); + + @ErrorHandling( + defaultException = Method3DefaultException.class) + void method3Test(); + + + + class ClassLevelDefaultException extends Exception { + } + class Method1DefaultException extends Exception { + } + class Method3DefaultException extends Exception { + } + class Method1NotFoundException extends Exception { + } + class Method2NotFoundException extends Exception { + } + class ClassLevelNotFoundException extends Exception { + } + class UnauthenticatedOrUnauthorizedException extends Exception { + } + class ServeErrorException extends Exception { + } + } +} diff --git a/annotation-error-decoder/src/test/java/feign/error/TestPojo.java b/annotation-error-decoder/src/test/java/feign/error/TestPojo.java new file mode 100644 index 000000000..9db75fc82 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/TestPojo.java @@ -0,0 +1,19 @@ +/* + * Copyright 2012-2022 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.error; + +public class TestPojo { + public String aString; + public int anInt; +} diff --git a/apt-test-generator/README.md b/apt-test-generator/README.md new file mode 100644 index 000000000..35d998452 --- /dev/null +++ b/apt-test-generator/README.md @@ -0,0 +1,66 @@ +# Feign APT test generator +This module generates mock clients for tests based on feign interfaces + +## Usage + +Just need to add this module to dependency list and Java [Annotation Processing Tool](https://docs.oracle.com/javase/7/docs/technotes/guides/apt/GettingStarted.html) will automatically pick up the jar and generate test clients. + +There are 2 main alternatives to include this to a project: + +1. Just add to classpath and java compiler should automaticaly detect and run code generation. On maven this is done like this: + +```xml + + io.github.openfeign.experimental + feign-apt-test-generator + ${feign.version} + test + +``` + +1. Use a purpose build tool that allow to pick output location and don't mix dependencies onto classpath + +```xml + + com.mysema.maven + apt-maven-plugin + 1.1.3 + + + + process + + + target/generated-test-sources/feign + feign.apttestgenerator.GenerateTestStubAPT + + + + + + io.github.openfeign.experimental + feign-apt-test-generator + ${feign.version} + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.0.0 + + + feign-stubs-source + generate-test-sources + + add-test-source + + + + target/generated-test-sources/feign + + + + + +``` diff --git a/apt-test-generator/pom.xml b/apt-test-generator/pom.xml new file mode 100644 index 000000000..fda29c640 --- /dev/null +++ b/apt-test-generator/pom.xml @@ -0,0 +1,195 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 11.9-SNAPSHOT + + + io.github.openfeign.experimental + feign-apt-test-generator + Feign APT test generator + Feign code generation tool for mocked clients + + + ${project.basedir}/.. + + + + + com.github.jknack + handlebars + 4.3.0 + + + + io.github.openfeign + feign-example-github + ${project.version} + + + + com.google.testing.compile + compile-testing + 0.19 + test + + + org.slf4j + slf4j-jdk14 + 1.7.25 + test + + + + com.google.guava + guava + ${guava.version} + + + com.google.auto.service + auto-service + 1.0-rc5 + provided + + + + + + + docker + true + + ${project.basedir}/docker + + + + ${basedir}/src/main/resources + + + src/main/java + + **/*.java + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.4.3 + + + package + + shade + + + + + feign.aptgenerator.github.GitHubFactoryExample + + + false + + + + + + org.skife.maven + really-executable-jar-maven-plugin + 1.5.0 + + github + + + + package + + really-executable-jar + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-surefire-plugin.version} + + + + integration-test + verify + + + + + + + + com.spotify + docker-maven-plugin + ${docker-maven-plugin.version} + + + ${project.build.directory}/classes/docker/ + + + true + + docker-hub + https://index.docker.io/v1/ + feign-apt-generator/test + + + / + ${project.build.directory} + ${project.artifactId}-${project.version}.jar + + + + + + + post-integration-test + + build + + + + + + + + + + active-on-jdk-16 + + [16,) + + + + 3.0.0-M5 + --add-opens jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED + + + + diff --git a/apt-test-generator/src/main/java/feign/apttestgenerator/ArgumentDefinition.java b/apt-test-generator/src/main/java/feign/apttestgenerator/ArgumentDefinition.java new file mode 100644 index 000000000..2a025983a --- /dev/null +++ b/apt-test-generator/src/main/java/feign/apttestgenerator/ArgumentDefinition.java @@ -0,0 +1,27 @@ +/* + * Copyright 2012-2022 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.apttestgenerator; + +public class ArgumentDefinition { + + public final String name; + public final String type; + + public ArgumentDefinition(String name, String type) { + super(); + this.name = name; + this.type = type; + } + +} diff --git a/apt-test-generator/src/main/java/feign/apttestgenerator/ClientDefinition.java b/apt-test-generator/src/main/java/feign/apttestgenerator/ClientDefinition.java new file mode 100644 index 000000000..79d0b3ee1 --- /dev/null +++ b/apt-test-generator/src/main/java/feign/apttestgenerator/ClientDefinition.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-2022 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.apttestgenerator; + +public class ClientDefinition { + + public final String jpackage; + public final String className; + public final String fullQualifiedName; + + public ClientDefinition(String jpackage, String className, String fullQualifiedName) { + super(); + this.jpackage = jpackage; + this.className = className; + this.fullQualifiedName = fullQualifiedName; + } + +} diff --git a/apt-test-generator/src/main/java/feign/apttestgenerator/GenerateTestStubAPT.java b/apt-test-generator/src/main/java/feign/apttestgenerator/GenerateTestStubAPT.java new file mode 100644 index 000000000..a9f558e95 --- /dev/null +++ b/apt-test-generator/src/main/java/feign/apttestgenerator/GenerateTestStubAPT.java @@ -0,0 +1,158 @@ +/* + * Copyright 2012-2022 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.apttestgenerator; + +import com.github.jknack.handlebars.*; +import com.github.jknack.handlebars.context.FieldValueResolver; +import com.github.jknack.handlebars.context.JavaBeanValueResolver; +import com.github.jknack.handlebars.context.MapValueResolver; +import com.github.jknack.handlebars.io.URLTemplateSource; +import com.google.auto.service.AutoService; +import com.google.common.collect.ImmutableList; +import java.io.IOError; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.*; +import java.util.stream.Collectors; +import javax.annotation.processing.*; +import javax.lang.model.element.*; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.type.WildcardType; +import javax.tools.Diagnostic.Kind; +import javax.tools.JavaFileObject; + +@SupportedAnnotationTypes({ + "feign.RequestLine" +}) +@AutoService(Processor.class) +public class GenerateTestStubAPT extends AbstractProcessor { + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + System.out.println(annotations); + System.out.println(roundEnv); + + final Map> clientsToGenerate = annotations.stream() + .map(roundEnv::getElementsAnnotatedWith) + .flatMap(Set::stream) + .map(ExecutableElement.class::cast) + .collect(Collectors.toMap( + annotatedMethod -> TypeElement.class.cast(annotatedMethod.getEnclosingElement()), + ImmutableList::of, + (list1, list2) -> ImmutableList.builder() + .addAll(list1) + .addAll(list2) + .build())); + + System.out.println("Count: " + clientsToGenerate.size()); + System.out.println("clientsToGenerate: " + clientsToGenerate); + + final Handlebars handlebars = new Handlebars(); + + final URLTemplateSource source = + new URLTemplateSource("stub.mustache", getClass().getResource("/stub.mustache")); + Template template; + try { + template = handlebars.with(EscapingStrategy.JS).compile(source); + } catch (final IOException e) { + throw new IOError(e); + } + + + clientsToGenerate.forEach((type, executables) -> { + try { + final String jPackage = readPackage(type); + final String className = type.getSimpleName().toString(); + final JavaFileObject builderFile = processingEnv.getFiler() + .createSourceFile(jPackage + "." + className + "Stub"); + + final ClientDefinition client = new ClientDefinition( + jPackage, + className, + type.toString()); + + final List methods = executables.stream() + .map(method -> { + final String methodName = method.getSimpleName().toString(); + + final List args = method.getParameters() + .stream() + .map(var -> new ArgumentDefinition(var.getSimpleName().toString(), + var.asType().toString())) + .collect(Collectors.toList()); + return new MethodDefinition( + methodName, + method.getReturnType().toString(), + method.getReturnType().getKind() == TypeKind.VOID, + args); + }) + .collect(Collectors.toList()); + + final Context context = Context.newBuilder(template) + .combine("client", client) + .combine("methods", methods) + .resolver(JavaBeanValueResolver.INSTANCE, MapValueResolver.INSTANCE, + FieldValueResolver.INSTANCE) + .build(); + final String stubSource = template.apply(context); + System.out.println(stubSource); + + builderFile.openWriter().append(stubSource).close(); + } catch (final Exception e) { + e.printStackTrace(); + processingEnv.getMessager().printMessage(Kind.ERROR, + "Unable to generate factory for " + type); + } + }); + + return true; + } + + + + private Type toJavaType(TypeMirror type) { + outType(type.getClass()); + if (type instanceof WildcardType) { + + } + return Object.class; + } + + private void outType(Class class1) { + if (Object.class.equals(class1) || class1 == null) { + return; + } + System.out.println(class1); + outType(class1.getSuperclass()); + Arrays.stream(class1.getInterfaces()).forEach(this::outType); + } + + + + private String readPackage(Element type) { + if (type.getKind() == ElementKind.PACKAGE) { + return type.toString(); + } + + if (type.getKind() == ElementKind.CLASS + || type.getKind() == ElementKind.INTERFACE) { + return readPackage(type.getEnclosingElement()); + } + + return null; + } + +} + diff --git a/apt-test-generator/src/main/java/feign/apttestgenerator/MethodDefinition.java b/apt-test-generator/src/main/java/feign/apttestgenerator/MethodDefinition.java new file mode 100644 index 000000000..f4cf73afb --- /dev/null +++ b/apt-test-generator/src/main/java/feign/apttestgenerator/MethodDefinition.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2022 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.apttestgenerator; + +import com.google.common.base.CaseFormat; +import com.google.common.base.Converter; +import java.util.List; + +public class MethodDefinition { + + private static final Converter TO_UPPER_CASE = + CaseFormat.LOWER_CAMEL.converterTo(CaseFormat.UPPER_CAMEL); + private final String name; + private final String uname; + private final String returnType; + private final boolean isVoid; + private final List args; + + public MethodDefinition(String name, String returnType, boolean isVoid, + List args) { + super(); + this.name = name; + this.uname = TO_UPPER_CASE.convert(name); + this.returnType = returnType; + this.isVoid = isVoid; + this.args = args; + } + +} diff --git a/apt-test-generator/src/main/resources/stub.mustache b/apt-test-generator/src/main/resources/stub.mustache new file mode 100644 index 000000000..015fbbad7 --- /dev/null +++ b/apt-test-generator/src/main/resources/stub.mustache @@ -0,0 +1,62 @@ +package {{client.jpackage}}; + +import java.util.concurrent.atomic.AtomicInteger; +import feign.Experimental; + +public class {{client.className}}Stub + implements {{client.fullQualifiedName}} { + + @Experimental + public class {{client.className}}Invokations { + +{{#each methods as |method|}} + + private final AtomicInteger {{method.name}} = new AtomicInteger(0); + + public int {{method.name}}() { + return {{method.name}}.get(); + } + +{{/each}} + + } + + @Experimental + public class {{client.className}}Anwsers { + +{{#each methods as |method|}} + {{#unless method.isVoid}} + private {{method.returnType}} {{method.name}}Default; + {{/unless}} +{{/each}} + + } + + public {{client.className}}Invokations invokations; + public {{client.className}}Anwsers answers; + + public {{client.className}}Stub() { + this.invokations = new {{client.className}}Invokations(); + this.answers = new {{client.className}}Anwsers(); + } + +{{#each methods as |method|}} + {{#unless method.isVoid}} + @Experimental + public {{client.className}}Stub with{{method.uname}}({{method.returnType}} {{method.name}}) { + answers.{{method.name}}Default = {{method.name}}; + return this; + } + {{/unless}} + + @Override + public {{method.returnType}} {{method.name}}({{#each method.args as |arg|}}{{arg.type}} {{arg.name}}{{#unless @last}},{{/unless}}{{/each}}) { + invokations.{{method.name}}.incrementAndGet(); +{{#unless method.isVoid}} + return answers.{{method.name}}Default; +{{/unless}} + } + +{{/each}} + +} diff --git a/apt-test-generator/src/test/java/example/github/GitHubStub.java b/apt-test-generator/src/test/java/example/github/GitHubStub.java new file mode 100644 index 000000000..a464d0f10 --- /dev/null +++ b/apt-test-generator/src/test/java/example/github/GitHubStub.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-2022 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 example.github; + +import java.util.concurrent.atomic.AtomicInteger; +import feign.Experimental; + +public class GitHubStub + implements example.github.GitHubExample.GitHub { + + @Experimental + public class GitHubInvokations { + + private final AtomicInteger repos = new AtomicInteger(0); + + public int repos() { + return repos.get(); + } + + private final AtomicInteger contributors = new AtomicInteger(0); + + public int contributors() { + return contributors.get(); + } + + private final AtomicInteger createIssue = new AtomicInteger(0); + + public int createIssue() { + return createIssue.get(); + } + + } + + @Experimental + public class GitHubAnwsers { + + private java.util.List reposDefault; + + private java.util.List contributorsDefault; + + } + + public GitHubInvokations invokations; + public GitHubAnwsers answers; + + public GitHubStub() { + this.invokations = new GitHubInvokations(); + this.answers = new GitHubAnwsers(); + } + + @Experimental + public GitHubStub withRepos(java.util.List repos) { + answers.reposDefault = repos; + return this; + } + + @Override + public java.util.List repos(java.lang.String owner) { + invokations.repos.incrementAndGet(); + + return answers.reposDefault; + } + + @Experimental + public GitHubStub withContributors(java.util.List contributors) { + answers.contributorsDefault = contributors; + return this; + } + + + @Override + public java.util.List contributors(java.lang.String owner, + java.lang.String repo) { + invokations.contributors.incrementAndGet(); + + return answers.contributorsDefault; + } + + @Override + public void createIssue(example.github.GitHubExample.GitHub.Issue issue, + java.lang.String owner, + java.lang.String repo) { + invokations.createIssue.incrementAndGet(); + + } + +} diff --git a/apt-test-generator/src/test/java/feign/apttestgenerator/GenerateTestStubAPTTest.java b/apt-test-generator/src/test/java/feign/apttestgenerator/GenerateTestStubAPTTest.java new file mode 100644 index 000000000..138a6d08e --- /dev/null +++ b/apt-test-generator/src/test/java/feign/apttestgenerator/GenerateTestStubAPTTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-2022 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.apttestgenerator; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static com.google.testing.compile.Compiler.javac; +import com.google.testing.compile.Compilation; +import com.google.testing.compile.JavaFileObjects; +import org.junit.Test; +import java.io.File; + +/** + * Test for {@link GenerateTestStubAPT} + */ +public class GenerateTestStubAPTTest { + + private final File main = new File("../example-github/src/main/java/").getAbsoluteFile(); + + @Test + public void test() throws Exception { + final Compilation compilation = + javac() + .withProcessors(new GenerateTestStubAPT()) + .compile(JavaFileObjects.forResource( + new File(main, "example/github/GitHubExample.java") + .toURI() + .toURL())); + assertThat(compilation).succeeded(); + assertThat(compilation) + .generatedSourceFile("example.github.GitHubStub") + .hasSourceEquivalentTo(JavaFileObjects.forResource( + new File("src/test/java/example/github/GitHubStub.java") + .toURI() + .toURL())); + } + +} 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..31f8acdfa --- /dev/null +++ b/benchmark/pom.xml @@ -0,0 +1,174 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 11.9-SNAPSHOT + + + feign-benchmark + Feign Benchmark (JMH) + + + 1.34 + 0.5.3 + 1.3.8 + 4.1.74.Final + + ${project.basedir}/.. + + + + + + io.netty + netty-bom + ${netty.version} + pom + import + + + + + + + ${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 + ${bouncy.version} + + + io.netty + netty-handler + ${netty.version} + + + io.netty + netty-codec-http + ${netty.version} + + + io.reactivex + rxnetty-http + ${rx.netty.version} + + + io.reactivex + rxnetty-spectator-http + ${rx.netty.version} + + + io.reactivex + rxnetty-common + ${rx.netty.version} + + + io.reactivex + rxnetty-tcp + ${rx.netty.version} + + + io.netty + netty-buffer + compile + + + io.reactivex + rxjava + ${rx.java.version} + + + 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..36cf0c665 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/DecoderIteratorsBenchmark.java @@ -0,0 +1,124 @@ +/* + * Copyright 2012-2022 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(); + } + } + + @SuppressWarnings("deprecation") + @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.parseInt(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..812976331 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/FeignTestInterface.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2022 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..498831ac1 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/RealRequestBenchmarks.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-2022 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.server.HttpServer; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import io.netty.buffer.ByteBuf; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +@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..9c658f3a1 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/WhatShouldWeCacheBenchmarks.java @@ -0,0 +1,126 @@ +/* + * Copyright 2012-2022 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().parseAndValidateMetadata(FeignTestInterface.class); + + public List parseAndValidateMetadata(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.parseAndValidateMetadata(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..e62c89f57 --- /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..ae62cef38 --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,108 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 11.9-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 + 5.2.14.RELEASE + test + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + test + + + + org.hamcrest + hamcrest + test + + + + + + + maven-jar-plugin + + + + test-jar + + + + + + + + + + active-on-jdk-11 + + 11 + + + + + org.apache.maven.plugins + maven-surefire-plugin + + --illegal-access=deny + + + + + + + diff --git a/core/src/main/java/feign/AlwaysEncodeBodyContract.java b/core/src/main/java/feign/AlwaysEncodeBodyContract.java new file mode 100644 index 000000000..5f742a2c3 --- /dev/null +++ b/core/src/main/java/feign/AlwaysEncodeBodyContract.java @@ -0,0 +1,31 @@ +/* + * Copyright 2012-2022 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; + +/** + * {@link DeclarativeContract} extension that allows user provided custom encoders to define the + * request message payload using only the request template and the method parameters, not requiring + * a specific and unique body object. + * + * This type of contract is useful when an application needs a Feign client whose request payload is + * defined entirely by a custom Feign encoder regardless of how many parameters are declared at the + * client method. In this case, even with no presence of body parameter the provided encoder will + * have to know how to define the request payload (for example, based on the method name, method + * return type, and other metadata provided by custom annotations, all available via the provided + * {@link RequestTemplate} object). + * + * @author fabiocarvalho777@gmail.com + */ +public abstract class AlwaysEncodeBodyContract extends DeclarativeContract { +} diff --git a/core/src/main/java/feign/AsyncClient.java b/core/src/main/java/feign/AsyncClient.java new file mode 100644 index 000000000..59c0d05ff --- /dev/null +++ b/core/src/main/java/feign/AsyncClient.java @@ -0,0 +1,101 @@ +/* + * Copyright 2012-2022 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.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import feign.Request.Options; + +/** + * Submits HTTP {@link Request requests} asynchronously, with an optional context. + */ +@Experimental +public interface AsyncClient { + + /** + * Executes the request asynchronously. Calling {@link CompletableFuture#cancel(boolean)} on the + * result may cause the execution to be cancelled / aborted, but this is not guaranteed. + * + * @param request safe to replay + * @param options options to apply to this request + * @param requestContext - the optional context, for example for storing session cookies. The + * client should update this appropriately based on the received response before completing + * the result. + * @return a {@link CompletableFuture} to be completed with the response, or completed + * exceptionally otherwise, for example with an {@link java.io.IOException} on a network + * error connecting to {@link Request#url()}. + */ + CompletableFuture execute(Request request, Options options, Optional requestContext); + + class Default implements AsyncClient { + + private final Client client; + private final ExecutorService executorService; + + public Default(Client client, ExecutorService executorService) { + this.client = client; + this.executorService = executorService; + } + + @Override + public CompletableFuture execute(Request request, + Options options, + Optional requestContext) { + final CompletableFuture result = new CompletableFuture<>(); + final Future future = executorService.submit(() -> { + try { + result.complete(client.execute(request, options)); + } catch (final Exception e) { + result.completeExceptionally(e); + } + }); + result.whenComplete((response, throwable) -> { + if (result.isCancelled()) { + future.cancel(true); + } + }); + return result; + } + } + + /** + * A synchronous implementation of {@link AsyncClient} + * + * @param - unused context; synchronous clients handle context internally + */ + class Pseudo implements AsyncClient { + + private final Client client; + + public Pseudo(Client client) { + this.client = client; + } + + @Override + public CompletableFuture execute(Request request, + Options options, + Optional requestContext) { + final CompletableFuture result = new CompletableFuture<>(); + try { + result.complete(client.execute(request, options)); + } catch (final Exception e) { + result.completeExceptionally(e); + } + + return result; + } + } +} diff --git a/core/src/main/java/feign/AsyncFeign.java b/core/src/main/java/feign/AsyncFeign.java new file mode 100644 index 000000000..5235f5884 --- /dev/null +++ b/core/src/main/java/feign/AsyncFeign.java @@ -0,0 +1,359 @@ +/* + * Copyright 2012-2022 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.Type; +import java.util.Optional; +import java.util.concurrent.*; +import java.util.function.Supplier; +import feign.Logger.NoOpLogger; +import feign.Request.Options; +import feign.Target.HardCodedTarget; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; + +/** + * Enhances {@link Feign} to provide support for asynchronous clients. Context (for example for + * session cookies or tokens) is explicit, as calls for the same session may be done across several + * threads.
+ *
+ * {@link Retryer} is not supported in this model, as that is a blocking API. + * {@link ExceptionPropagationPolicy} is made redundant as {@link RetryableException} is never + * thrown.
+ * Alternative approaches to retrying can be handled through {@link AsyncClient clients}.
+ *
+ * Target interface methods must return {@link CompletableFuture} with a non-wildcard type. As the + * completion is done by the {@link AsyncClient}, it is important that any subsequent processing on + * the thread be short - generally, this should involve notifying some other thread of the work to + * be done (for example, creating and submitting a task to an {@link ExecutorService}). + * + */ +@Experimental +public abstract class AsyncFeign extends Feign { + + public static AsyncBuilder asyncBuilder() { + return new AsyncBuilder<>(); + } + + private static class LazyInitializedExecutorService { + + private static final ExecutorService instance = Executors.newCachedThreadPool(r -> { + final Thread result = new Thread(r); + result.setDaemon(true); + return result; + }); + } + + public static class AsyncBuilder { + + private final Builder builder; + private Supplier defaultContextSupplier = () -> null; + private AsyncClient client; + + private Logger.Level logLevel = Logger.Level.NONE; + private Logger logger = new NoOpLogger(); + + private Decoder decoder = new Decoder.Default(); + private ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + private boolean dismiss404; + private boolean closeAfterDecode = true; + + public AsyncBuilder() { + super(); + this.builder = Feign.builder(); + } + + public AsyncBuilder defaultContextSupplier(Supplier supplier) { + this.defaultContextSupplier = supplier; + return this; + } + + public AsyncBuilder client(AsyncClient client) { + this.client = client; + return this; + } + + /** + * @see Builder#mapAndDecode(ResponseMapper, Decoder) + */ + public AsyncBuilder mapAndDecode(ResponseMapper mapper, Decoder decoder) { + this.decoder = (response, type) -> decoder.decode(mapper.map(response, type), type); + return this; + } + + /** + * @see Builder#decoder(Decoder) + */ + public AsyncBuilder decoder(Decoder decoder) { + this.decoder = decoder; + return this; + } + + /** + * @see Builder#decode404() + * @deprecated + */ + public AsyncBuilder decode404() { + this.dismiss404 = true; + return this; + } + + /** + * @see Builder#dismiss404() + */ + public AsyncBuilder dismiss404() { + this.dismiss404 = true; + return this; + } + + /** + * @see Builder#errorDecoder(ErrorDecoder) + */ + public AsyncBuilder errorDecoder(ErrorDecoder errorDecoder) { + this.errorDecoder = errorDecoder; + return this; + } + + public AsyncBuilder doNotCloseAfterDecode() { + this.closeAfterDecode = false; + return this; + } + + public T target(Class apiType, String url) { + return target(new HardCodedTarget<>(apiType, url)); + } + + public T target(Class apiType, String url, C context) { + return target(new HardCodedTarget<>(apiType, url), context); + } + + public T target(Target target) { + return build().newInstance(target); + } + + public T target(Target target, C context) { + return build().newInstance(target, context); + } + + private AsyncBuilder lazyInits() { + if (client == null) { + client = new AsyncClient.Default<>(new Client.Default(null, null), + LazyInitializedExecutorService.instance); + } + + return this; + } + + public AsyncFeign build() { + return new ReflectiveAsyncFeign<>(lazyInits()); + } + + // start of builder delgates + + /** + * @see Builder#logLevel(Logger.Level) + */ + public AsyncBuilder logLevel(Logger.Level logLevel) { + builder.logLevel(logLevel); + this.logLevel = logLevel; + return this; + } + + /** + * @see Builder#contract(Contract) + */ + public AsyncBuilder contract(Contract contract) { + builder.contract(contract); + return this; + } + + /** + * @see Builder#logLevel(Logger.Level) + */ + public AsyncBuilder logger(Logger logger) { + builder.logger(logger); + this.logger = logger; + return this; + } + + /** + * @see Builder#encoder(Encoder) + */ + public AsyncBuilder encoder(Encoder encoder) { + builder.encoder(encoder); + return this; + } + + /** + * @see Builder#queryMapEncoder(QueryMapEncoder) + */ + public AsyncBuilder queryMapEncoder(QueryMapEncoder queryMapEncoder) { + builder.queryMapEncoder(queryMapEncoder); + return this; + } + + + /** + * @see Builder#options(Options) + */ + public AsyncBuilder options(Options options) { + builder.options(options); + return this; + } + + /** + * @see Builder#requestInterceptor(RequestInterceptor) + */ + public AsyncBuilder requestInterceptor(RequestInterceptor requestInterceptor) { + builder.requestInterceptor(requestInterceptor); + return this; + } + + /** + * @see Builder#requestInterceptors(Iterable) + */ + public AsyncBuilder requestInterceptors(Iterable requestInterceptors) { + builder.requestInterceptors(requestInterceptors); + return this; + } + + /** + * @see Builder#invocationHandlerFactory(InvocationHandlerFactory) + */ + public AsyncBuilder invocationHandlerFactory(InvocationHandlerFactory invocationHandlerFactory) { + builder.invocationHandlerFactory(invocationHandlerFactory); + return this; + } + } + + private final ThreadLocal> activeContext; + + private final Feign feign; + + private final Supplier defaultContextSupplier; + private final AsyncClient client; + + private final Logger.Level logLevel; + private final Logger logger; + + private final AsyncResponseHandler responseHandler; + + protected AsyncFeign(AsyncBuilder asyncBuilder) { + this.activeContext = new ThreadLocal<>(); + + this.defaultContextSupplier = asyncBuilder.defaultContextSupplier; + this.client = asyncBuilder.client; + + this.logLevel = asyncBuilder.logLevel; + this.logger = asyncBuilder.logger; + + this.responseHandler = new AsyncResponseHandler( + asyncBuilder.logLevel, + asyncBuilder.logger, + asyncBuilder.decoder, + asyncBuilder.errorDecoder, + asyncBuilder.dismiss404, + asyncBuilder.closeAfterDecode); + + asyncBuilder.builder.client(this::stageExecution); + asyncBuilder.builder.decoder(this::stageDecode); + asyncBuilder.builder.forceDecoding(); // force all handling through stageDecode + + this.feign = asyncBuilder.builder.build(); + } + + private Response stageExecution(Request request, Options options) { + final Response result = Response.builder() + .status(200) + .request(request) + .build(); + + final AsyncInvocation invocationContext = activeContext.get(); + + invocationContext.setResponseFuture( + client.execute(request, options, Optional.ofNullable(invocationContext.context()))); + + + return result; + } + + // from SynchronousMethodHandler + long elapsedTime(long start) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + } + + + private Object stageDecode(Response response, Type type) { + final AsyncInvocation invocationContext = activeContext.get(); + + final CompletableFuture result = new CompletableFuture<>(); + + invocationContext.responseFuture().whenComplete((r, t) -> { + final long elapsedTime = elapsedTime(invocationContext.startNanos()); + + if (t != null) { + if (logLevel != Logger.Level.NONE && t instanceof IOException) { + final IOException e = (IOException) t; + logger.logIOException(invocationContext.configKey(), logLevel, e, elapsedTime); + } + result.completeExceptionally(t); + } else { + responseHandler.handleResponse(result, invocationContext.configKey(), r, + invocationContext.underlyingType(), elapsedTime); + } + }); + + result.whenComplete((r, t) -> { + if (result.isCancelled()) { + invocationContext.responseFuture().cancel(true); + } + }); + + if (invocationContext.isAsyncReturnType()) { + return result; + } + try { + return result.join(); + } catch (final CompletionException e) { + final Response r = invocationContext.responseFuture().join(); + Throwable cause = e.getCause(); + if (cause == null) { + cause = e; + } + throw new AsyncJoinException(r.status(), cause.getMessage(), r.request(), cause); + } + } + + + protected void setInvocationContext(AsyncInvocation invocationContext) { + activeContext.set(invocationContext); + } + + protected void clearInvocationContext() { + activeContext.remove(); + } + + @Override + public T newInstance(Target target) { + return newInstance(target, defaultContextSupplier.get()); + } + + public T newInstance(Target target, C context) { + return wrap(target.type(), feign.newInstance(target), context); + } + + protected abstract T wrap(Class type, T instance, C context); +} diff --git a/core/src/main/java/feign/AsyncInvocation.java b/core/src/main/java/feign/AsyncInvocation.java new file mode 100644 index 000000000..c8c63da17 --- /dev/null +++ b/core/src/main/java/feign/AsyncInvocation.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-2022 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; +import java.util.concurrent.CompletableFuture; + +/** + * A specific invocation of an APU + */ +@Experimental +class AsyncInvocation { + + private final C context; + private final MethodInfo methodInfo; + private final long startNanos; + private CompletableFuture responseFuture; + + AsyncInvocation(C context, MethodInfo methodInfo) { + super(); + this.context = context; + this.methodInfo = methodInfo; + this.startNanos = System.nanoTime(); + } + + C context() { + return context; + } + + String configKey() { + return methodInfo.configKey(); + } + + long startNanos() { + return startNanos; + } + + Type underlyingType() { + return methodInfo.underlyingReturnType(); + } + + boolean isAsyncReturnType() { + return methodInfo.isAsyncReturnType(); + } + + void setResponseFuture(CompletableFuture responseFuture) { + this.responseFuture = responseFuture; + } + + CompletableFuture responseFuture() { + return responseFuture; + } +} diff --git a/core/src/main/java/feign/AsyncJoinException.java b/core/src/main/java/feign/AsyncJoinException.java new file mode 100644 index 000000000..5d7e83b6e --- /dev/null +++ b/core/src/main/java/feign/AsyncJoinException.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2022 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 java.util.concurrent.CompletableFuture; + +/** + * Thrown to encapsulate an underlying cause when using {@link CompletableFuture#join()} to convert + * an asynchronous call to a synchronous one. + */ +@Experimental +public class AsyncJoinException extends FeignException { + + private static final long serialVersionUID = 1L; + + /** + * @param message possibly null reason for the failure. + * @param cause the cause of the error. + */ + public AsyncJoinException(int status, String message, Request request, Throwable cause) { + super(status, message, request, checkNotNull(cause, "cause")); + } +} diff --git a/core/src/main/java/feign/AsyncResponseHandler.java b/core/src/main/java/feign/AsyncResponseHandler.java new file mode 100644 index 000000000..90675e11b --- /dev/null +++ b/core/src/main/java/feign/AsyncResponseHandler.java @@ -0,0 +1,122 @@ +/* + * Copyright 2012-2022 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.FeignException.errorReading; +import static feign.Util.ensureClosed; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import feign.Logger.Level; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; + +/** + * The response handler that is used to provide asynchronous support on top of standard response + * handling + */ +@Experimental +class AsyncResponseHandler { + + private static final long MAX_RESPONSE_BUFFER_SIZE = 8192L; + + private final Level logLevel; + private final Logger logger; + + private final Decoder decoder; + private final ErrorDecoder errorDecoder; + private final boolean dismiss404; + private final boolean closeAfterDecode; + + AsyncResponseHandler(Level logLevel, Logger logger, Decoder decoder, ErrorDecoder errorDecoder, + boolean dismiss404, boolean closeAfterDecode) { + super(); + this.logLevel = logLevel; + this.logger = logger; + this.decoder = decoder; + this.errorDecoder = errorDecoder; + this.dismiss404 = dismiss404; + this.closeAfterDecode = closeAfterDecode; + } + + boolean isVoidType(Type returnType) { + return Void.class == returnType || void.class == returnType; + } + + void handleResponse(CompletableFuture resultFuture, + String configKey, + Response response, + Type returnType, + long elapsedTime) { + // copied fairly liberally from SynchronousMethodHandler + boolean shouldClose = true; + + try { + if (logLevel != Level.NONE) { + response = logger.logAndRebufferResponse(configKey, logLevel, response, + elapsedTime); + } + if (Response.class == returnType) { + if (response.body() == null) { + resultFuture.complete(response); + } else if (response.body().length() == null + || response.body().length() > MAX_RESPONSE_BUFFER_SIZE) { + shouldClose = false; + resultFuture.complete(response); + } else { + // Ensure the response body is disconnected + final byte[] bodyData = Util.toByteArray(response.body().asInputStream()); + resultFuture.complete(response.toBuilder().body(bodyData).build()); + } + } else if (response.status() >= 200 && response.status() < 300) { + if (isVoidType(returnType)) { + resultFuture.complete(null); + } else { + final Object result = decode(response, returnType); + shouldClose = closeAfterDecode; + resultFuture.complete(result); + } + } else if (dismiss404 && response.status() == 404 && !isVoidType(returnType)) { + final Object result = decode(response, returnType); + shouldClose = closeAfterDecode; + resultFuture.complete(result); + } else { + resultFuture.completeExceptionally(errorDecoder.decode(configKey, response)); + } + } catch (final IOException e) { + if (logLevel != Level.NONE) { + logger.logIOException(configKey, logLevel, e, elapsedTime); + } + resultFuture.completeExceptionally(errorReading(response.request(), response, e)); + } catch (final Exception e) { + resultFuture.completeExceptionally(e); + } finally { + if (shouldClose) { + ensureClosed(response.body()); + } + } + + } + + Object decode(Response response, Type type) throws IOException { + try { + return decoder.decode(response, type); + } catch (final FeignException e) { + throw e; + } catch (final RuntimeException e) { + throw new DecodeException(response.status(), e.getMessage(), response.request(), e); + } + } +} diff --git a/core/src/main/java/feign/Body.java b/core/src/main/java/feign/Body.java new file mode 100644 index 000000000..e7336fbb1 --- /dev/null +++ b/core/src/main/java/feign/Body.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2022 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/Capability.java b/core/src/main/java/feign/Capability.java new file mode 100644 index 000000000..4e00895db --- /dev/null +++ b/core/src/main/java/feign/Capability.java @@ -0,0 +1,115 @@ +/* + * Copyright 2012-2022 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.InvocationTargetException; +import java.util.Arrays; +import java.util.List; +import feign.Logger.Level; +import feign.Request.Options; +import feign.codec.Decoder; +import feign.codec.Encoder; + +/** + * Capabilities expose core feign artifacts to implementations so parts of core can be customized + * around the time the client being built. + * + * For instance, capabilities take the {@link Client}, make changes to it and feed the modified + * version back to feign. + * + * @see Metrics5Capability + */ +public interface Capability { + + + static E enrich(E componentToEnrich, List capabilities) { + return capabilities.stream() + // invoke each individual capability and feed the result to the next one. + // This is equivalent to: + // Capability cap1 = ...; + // Capability cap2 = ...; + // Capability cap2 = ...; + // Contract contract = ...; + // Contract contract1 = cap1.enrich(contract); + // Contract contract2 = cap2.enrich(contract1); + // Contract contract3 = cap3.enrich(contract2); + // or in a more compact version + // Contract enrichedContract = cap3.enrich(cap2.enrich(cap1.enrich(contract))); + .reduce( + componentToEnrich, + (component, capability) -> invoke(component, capability), + (component, enrichedComponent) -> enrichedComponent); + } + + static E invoke(E target, Capability capability) { + return Arrays.stream(capability.getClass().getMethods()) + .filter(method -> method.getName().equals("enrich")) + .filter(method -> method.getReturnType().isInstance(target)) + .findFirst() + .map(method -> { + try { + return (E) method.invoke(capability, target); + } catch (IllegalAccessException | IllegalArgumentException + | InvocationTargetException e) { + throw new RuntimeException("Unable to enrich " + target, e); + } + }) + .orElse(target); + } + + default Client enrich(Client client) { + return client; + } + + default Retryer enrich(Retryer retryer) { + return retryer; + } + + default RequestInterceptor enrich(RequestInterceptor requestInterceptor) { + return requestInterceptor; + } + + default Logger enrich(Logger logger) { + return logger; + } + + default Level enrich(Level level) { + return level; + } + + default Contract enrich(Contract contract) { + return contract; + } + + default Options enrich(Options options) { + return options; + } + + default Encoder enrich(Encoder encoder) { + return encoder; + } + + default Decoder enrich(Decoder decoder) { + return decoder; + } + + default InvocationHandlerFactory enrich(InvocationHandlerFactory invocationHandlerFactory) { + return invocationHandlerFactory; + } + + default QueryMapEncoder enrich(QueryMapEncoder queryMapEncoder) { + return queryMapEncoder; + } + +} diff --git a/core/src/main/java/feign/Client.java b/core/src/main/java/feign/Client.java new file mode 100644 index 000000000..885fac861 --- /dev/null +++ b/core/src/main/java/feign/Client.java @@ -0,0 +1,283 @@ +/* + * Copyright 2012-2022 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.CONTENT_ENCODING; +import static feign.Util.CONTENT_LENGTH; +import static feign.Util.ENCODING_DEFLATE; +import static feign.Util.ENCODING_GZIP; +import static feign.Util.checkArgument; +import static feign.Util.checkNotNull; +import static feign.Util.isNotBlank; +import static java.lang.String.CASE_INSENSITIVE_ORDER; +import static java.lang.String.format; +import feign.Request.Options; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.Proxy; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; +import java.util.zip.InflaterInputStream; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; + +/** + * 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; + + class Default implements Client { + + private final SSLSocketFactory sslContextFactory; + private final HostnameVerifier hostnameVerifier; + + /** + * Disable the request body internal buffering for {@code HttpURLConnection}. + * + * @see HttpURLConnection#setFixedLengthStreamingMode(int) + * @see HttpURLConnection#setFixedLengthStreamingMode(long) + * @see HttpURLConnection#setChunkedStreamingMode(int) + */ + private final boolean disableRequestBuffering; + + /** + * Create a new client, which disable request buffering by default. + * + * @param sslContextFactory SSLSocketFactory for secure https URL connections. + * @param hostnameVerifier the host name verifier. + */ + public Default(SSLSocketFactory sslContextFactory, HostnameVerifier hostnameVerifier) { + this.sslContextFactory = sslContextFactory; + this.hostnameVerifier = hostnameVerifier; + this.disableRequestBuffering = true; + } + + /** + * Create a new client. + * + * @param sslContextFactory SSLSocketFactory for secure https URL connections. + * @param hostnameVerifier the host name verifier. + * @param disableRequestBuffering Disable the request body internal buffering for + * {@code HttpURLConnection}. + */ + public Default(SSLSocketFactory sslContextFactory, HostnameVerifier hostnameVerifier, + boolean disableRequestBuffering) { + super(); + this.sslContextFactory = sslContextFactory; + this.hostnameVerifier = hostnameVerifier; + this.disableRequestBuffering = disableRequestBuffering; + } + + @Override + public Response execute(Request request, Options options) throws IOException { + HttpURLConnection connection = convertAndSend(request, options); + return convertResponse(connection, request); + } + + 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 TreeMap<>(CASE_INSENSITIVE_ORDER); + 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 { + if (this.isGzip(headers.get(CONTENT_ENCODING))) { + stream = new GZIPInputStream(connection.getInputStream()); + } else if (this.isDeflate(headers.get(CONTENT_ENCODING))) { + stream = new InflaterInputStream(connection.getInputStream()); + } else { + stream = connection.getInputStream(); + } + } + return Response.builder() + .status(status) + .reason(reason) + .headers(headers) + .request(request) + .body(stream, length) + .build(); + } + + public HttpURLConnection getConnection(final URL url) throws IOException { + return (HttpURLConnection) url.openConnection(); + } + + HttpURLConnection convertAndSend(Request request, Options options) throws IOException { + final URL url = new URL(request.url()); + final HttpURLConnection connection = this.getConnection(url); + 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 = this.isGzip(contentEncodingValues); + boolean deflateEncodedRequest = this.isDeflate(contentEncodingValues); + + 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 (disableRequestBuffering) { + 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; + } + + private boolean isGzip(Collection contentEncodingValues) { + return contentEncodingValues != null + && !contentEncodingValues.isEmpty() + && contentEncodingValues.contains(ENCODING_GZIP); + } + + private boolean isDeflate(Collection contentEncodingValues) { + return contentEncodingValues != null + && !contentEncodingValues.isEmpty() + && contentEncodingValues.contains(ENCODING_DEFLATE); + } + } + + /** + * Client that supports a {@link java.net.Proxy}. + */ + class Proxied extends Default { + + public static final String PROXY_AUTHORIZATION = "Proxy-Authorization"; + private final Proxy proxy; + private String credentials; + + public Proxied(SSLSocketFactory sslContextFactory, HostnameVerifier hostnameVerifier, + Proxy proxy) { + super(sslContextFactory, hostnameVerifier); + checkNotNull(proxy, "a proxy is required."); + this.proxy = proxy; + } + + public Proxied(SSLSocketFactory sslContextFactory, HostnameVerifier hostnameVerifier, + Proxy proxy, String proxyUser, String proxyPassword) { + this(sslContextFactory, hostnameVerifier, proxy); + checkArgument(isNotBlank(proxyUser), "proxy user is required."); + checkArgument(isNotBlank(proxyPassword), "proxy password is required."); + this.credentials = basic(proxyUser, proxyPassword); + } + + @Override + public HttpURLConnection getConnection(URL url) throws IOException { + HttpURLConnection connection = (HttpURLConnection) url.openConnection(this.proxy); + if (isNotBlank(this.credentials)) { + connection.addRequestProperty(PROXY_AUTHORIZATION, this.credentials); + } + return connection; + } + + public String getCredentials() { + return this.credentials; + } + + private String basic(String username, String password) { + String token = username + ":" + password; + byte[] bytes = token.getBytes(StandardCharsets.ISO_8859_1); + String encoded = Base64.getEncoder().encodeToString(bytes); + return "Basic " + encoded; + } + } +} diff --git a/core/src/main/java/feign/CollectionFormat.java b/core/src/main/java/feign/CollectionFormat.java new file mode 100644 index 000000000..a7e376ef7 --- /dev/null +++ b/core/src/main/java/feign/CollectionFormat.java @@ -0,0 +1,96 @@ +/* + * Copyright 2012-2022 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.encode(field, charset)); + if (value != null) { + builder.append('='); + builder.append(value); + } + } else { + // delimited with a separator character + if (builder.length() == 0) { + builder.append(UriUtils.encode(field, charset)); + } + if (value == null) { + continue; + } + builder.append(valueCount++ == 0 ? "=" : UriUtils.encode(separator, charset)); + builder.append(value); + } + } + 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..93f44e77d --- /dev/null +++ b/core/src/main/java/feign/Contract.java @@ -0,0 +1,336 @@ +/* + * Copyright 2012-2022 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.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Parameter; +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; +import static feign.Util.checkState; +import static feign.Util.emptyToNull; + +/** + * Defines what annotations and values are valid on interfaces. + */ +public interface Contract { + + /** + * Called to parse the methods in the class that are linked to HTTP requests. + * + * @param targetType {@link feign.Target#type() type} of the Feign interface. + */ + List parseAndValidateMetadata(Class targetType); + + abstract class BaseContract implements Contract { + + /** + * @param targetType {@link feign.Target#type() type} of the Feign interface. + * @see #parseAndValidateMetadata(Class) + */ + @Override + public List parseAndValidateMetadata(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()); + final Map result = new LinkedHashMap(); + for (final Method method : targetType.getMethods()) { + if (method.getDeclaringClass() == Object.class || + (method.getModifiers() & Modifier.STATIC) != 0 || + Util.isDefault(method)) { + continue; + } + final MethodMetadata metadata = parseAndValidateMetadata(targetType, method); + if (result.containsKey(metadata.configKey())) { + MethodMetadata existingMetadata = result.get(metadata.configKey()); + Type existingReturnType = existingMetadata.returnType(); + Type overridingReturnType = metadata.returnType(); + Type resolvedType = Types.resolveReturnType(existingReturnType, overridingReturnType); + if (resolvedType.equals(overridingReturnType)) { + result.put(metadata.configKey(), metadata); + } + continue; + } + result.put(metadata.configKey(), metadata); + } + return new ArrayList<>(result.values()); + } + + /** + * @deprecated use {@link #parseAndValidateMetadata(Class, Method)} instead. + */ + @Deprecated + public MethodMetadata parseAndValidateMetadata(Method method) { + return parseAndValidateMetadata(method.getDeclaringClass(), method); + } + + /** + * Called indirectly by {@link #parseAndValidateMetadata(Class)}. + */ + protected MethodMetadata parseAndValidateMetadata(Class targetType, Method method) { + final MethodMetadata data = new MethodMetadata(); + data.targetType(targetType); + data.method(method); + data.returnType( + Types.resolve(targetType, targetType, method.getGenericReturnType())); + data.configKey(Feign.configKey(targetType, method)); + if (AlwaysEncodeBodyContract.class.isAssignableFrom(this.getClass())) { + data.alwaysEncodeBody(true); + } + + if (targetType.getInterfaces().length == 1) { + processAnnotationOnClass(data, targetType.getInterfaces()[0]); + } + processAnnotationOnClass(data, targetType); + + + for (final Annotation methodAnnotation : method.getAnnotations()) { + processAnnotationOnMethod(data, methodAnnotation, method); + } + if (data.isIgnored()) { + return data; + } + checkState(data.template().method() != null, + "Method %s not annotated with HTTP method type (ex. GET, POST)%s", + data.configKey(), data.warnings()); + final Class[] parameterTypes = method.getParameterTypes(); + final Type[] genericParameterTypes = method.getGenericParameterTypes(); + + final Annotation[][] parameterAnnotations = method.getParameterAnnotations(); + final 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 (isHttpAnnotation) { + data.ignoreParamater(i); + } + + if (parameterTypes[i] == URI.class) { + data.urlIndex(i); + } else if (!isHttpAnnotation && parameterTypes[i] != Request.Options.class) { + if (data.isAlreadyProcessed(i)) { + checkState(data.formParams().isEmpty() || data.bodyIndex() == null, + "Body parameters cannot be used with form parameters.%s", data.warnings()); + } else if (!data.alwaysEncodeBody()) { + checkState(data.formParams().isEmpty(), + "Body parameters cannot be used with form parameters.%s", data.warnings()); + checkState(data.bodyIndex() == null, + "Method has too many Body parameters: %s%s", method, data.warnings()); + data.bodyIndex(i); + data.bodyType( + Types.resolve(targetType, targetType, genericParameterTypes[i])); + } + } + } + + if (data.headerMapIndex() != null) { + // check header map parameter for map type + if (Map.class.isAssignableFrom(parameterTypes[data.headerMapIndex()])) { + checkMapKeys("HeaderMap", 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())) { + final 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 + final Type[] interfaces = ((Class) genericType).getGenericInterfaces(); + for (final Type extended : interfaces) { + if (ParameterizedType.class.isAssignableFrom(extended.getClass())) { + // use the first extended interface we find. + final 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) { + final Collection names = + data.indexToName().containsKey(i) ? data.indexToName().get(i) : new ArrayList(); + names.add(name); + data.indexToName().put(i, names); + } + } + + class Default extends DeclarativeContract { + + static final Pattern REQUEST_LINE_PATTERN = Pattern.compile("^([A-Z]+)[ ]*(.*)$"); + + public Default() { + super.registerClassAnnotation(Headers.class, (header, data) -> { + final String[] headersOnType = header.value(); + checkState(headersOnType.length > 0, "Headers annotation was empty on type %s.", + data.configKey()); + final Map> headers = toMap(headersOnType); + headers.putAll(data.template().headers()); + data.template().headers(null); // to clear + data.template().headers(headers); + }); + super.registerMethodAnnotation(RequestLine.class, (ann, data) -> { + final String requestLine = ann.value(); + checkState(emptyToNull(requestLine) != null, + "RequestLine annotation was empty on method %s.", data.configKey()); + + final 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", + data.configKey())); + } else { + data.template().method(HttpMethod.valueOf(requestLineMatcher.group(1))); + data.template().uri(requestLineMatcher.group(2)); + } + data.template().decodeSlash(ann.decodeSlash()); + data.template() + .collectionFormat(ann.collectionFormat()); + }); + super.registerMethodAnnotation(Body.class, (ann, data) -> { + final String body = ann.value(); + checkState(emptyToNull(body) != null, "Body annotation was empty on method %s.", + data.configKey()); + if (body.indexOf('{') == -1) { + data.template().body(body); + } else { + data.template().bodyTemplate(body); + } + }); + super.registerMethodAnnotation(Headers.class, (header, data) -> { + final String[] headersOnMethod = header.value(); + checkState(headersOnMethod.length > 0, "Headers annotation was empty on method %s.", + data.configKey()); + data.template().headers(toMap(headersOnMethod)); + }); + super.registerParameterAnnotation(Param.class, (paramAnnotation, data, paramIndex) -> { + final String annotationName = paramAnnotation.value(); + final Parameter parameter = data.method().getParameters()[paramIndex]; + final String name; + if (emptyToNull(annotationName) == null && parameter.isNamePresent()) { + name = parameter.getName(); + } else { + name = annotationName; + } + checkState(emptyToNull(name) != null, "Param annotation was empty on param %s.", + paramIndex); + nameParam(data, name, paramIndex); + final Class expander = paramAnnotation.expander(); + if (expander != Param.ToStringExpander.class) { + data.indexToExpanderClass().put(paramIndex, expander); + } + if (!data.template().hasRequestVariable(name)) { + data.formParams().add(name); + } + }); + super.registerParameterAnnotation(QueryMap.class, (queryMap, data, paramIndex) -> { + checkState(data.queryMapIndex() == null, + "QueryMap annotation was present on multiple parameters."); + data.queryMapIndex(paramIndex); + data.queryMapEncoded(queryMap.encoded()); + }); + super.registerParameterAnnotation(HeaderMap.class, (queryMap, data, paramIndex) -> { + checkState(data.headerMapIndex() == null, + "HeaderMap annotation was present on multiple parameters."); + data.headerMapIndex(paramIndex); + }); + } + + private static Map> toMap(String[] input) { + final Map> result = + new LinkedHashMap>(input.length); + for (final String header : input) { + final int colon = header.indexOf(':'); + final 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/DeclarativeContract.java b/core/src/main/java/feign/DeclarativeContract.java new file mode 100644 index 000000000..f105ea0bb --- /dev/null +++ b/core/src/main/java/feign/DeclarativeContract.java @@ -0,0 +1,263 @@ +/* + * Copyright 2012-2022 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.Contract.BaseContract; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.*; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * {@link Contract} base implementation that works by declaring witch annotations should be + * processed and how each annotation modifies {@link MethodMetadata} + */ +public abstract class DeclarativeContract extends BaseContract { + + private final List classAnnotationProcessors = new ArrayList<>(); + private final List methodAnnotationProcessors = new ArrayList<>(); + private final Map, DeclarativeContract.ParameterAnnotationProcessor> parameterAnnotationProcessors = + new HashMap<>(); + + @Override + public final List parseAndValidateMetadata(Class targetType) { + // any implementations must register processors + return super.parseAndValidateMetadata(targetType); + } + + /** + * 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 targetType the class to process + */ + @Override + protected final void processAnnotationOnClass(MethodMetadata data, Class targetType) { + final List processors = Arrays.stream(targetType.getAnnotations()) + .flatMap(annotation -> classAnnotationProcessors.stream() + .filter(processor -> processor.test(annotation))) + .collect(Collectors.toList()); + + if (!processors.isEmpty()) { + Arrays.stream(targetType.getAnnotations()) + .forEach(annotation -> processors.stream() + .filter(processor -> processor.test(annotation)) + .forEach(processor -> processor.process(annotation, data))); + } else { + if (targetType.getAnnotations().length == 0) { + data.addWarning(String.format( + "Class %s has no annotations, it may affect contract %s", + targetType.getSimpleName(), + getClass().getSimpleName())); + } else { + data.addWarning(String.format( + "Class %s has annotations %s that are not used by contract %s", + targetType.getSimpleName(), + Arrays.stream(targetType.getAnnotations()) + .map(annotation -> annotation.annotationType() + .getSimpleName()) + .collect(Collectors.toList()), + getClass().getSimpleName())); + } + } + } + + /** + * @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. + */ + @Override + protected final void processAnnotationOnMethod(MethodMetadata data, + Annotation annotation, + Method method) { + List processors = methodAnnotationProcessors.stream() + .filter(processor -> processor.test(annotation)) + .collect(Collectors.toList()); + + if (!processors.isEmpty()) { + processors.forEach(processor -> processor.process(annotation, data)); + } else { + data.addWarning(String.format( + "Method %s has an annotation %s that is not used by contract %s", + method.getName(), + annotation.annotationType() + .getSimpleName(), + getClass().getSimpleName())); + } + } + + + /** + * @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. + */ + @Override + protected final boolean processAnnotationsOnParameter(MethodMetadata data, + Annotation[] annotations, + int paramIndex) { + List matchingAnnotations = Arrays.stream(annotations) + .filter( + annotation -> parameterAnnotationProcessors.containsKey(annotation.annotationType())) + .collect(Collectors.toList()); + + if (!matchingAnnotations.isEmpty()) { + matchingAnnotations.forEach(annotation -> parameterAnnotationProcessors + .getOrDefault(annotation.annotationType(), ParameterAnnotationProcessor.DO_NOTHING) + .process(annotation, data, paramIndex)); + + } else { + final Parameter parameter = data.method().getParameters()[paramIndex]; + String parameterName = parameter.isNamePresent() + ? parameter.getName() + : parameter.getType().getSimpleName(); + if (annotations.length == 0) { + data.addWarning(String.format( + "Parameter %s has no annotations, it may affect contract %s", + parameterName, + getClass().getSimpleName())); + } else { + data.addWarning(String.format( + "Parameter %s has annotations %s that are not used by contract %s", + parameterName, + Arrays.stream(annotations) + .map(annotation -> annotation.annotationType() + .getSimpleName()) + .collect(Collectors.toList()), + getClass().getSimpleName())); + } + } + return false; + } + + /** + * Called while class annotations are being processed + * + * @param annotationType to be processed + * @param processor function that defines the annotations modifies {@link MethodMetadata} + */ + protected void registerClassAnnotation(Class annotationType, + DeclarativeContract.AnnotationProcessor processor) { + registerClassAnnotation( + annotation -> annotation.annotationType().equals(annotationType), + processor); + } + + /** + * Called while class annotations are being processed + * + * @param predicate to check if the annotation should be processed or not + * @param processor function that defines the annotations modifies {@link MethodMetadata} + */ + protected void registerClassAnnotation(Predicate predicate, + DeclarativeContract.AnnotationProcessor processor) { + this.classAnnotationProcessors.add(new GuardedAnnotationProcessor(predicate, processor)); + } + + /** + * Called while method annotations are being processed + * + * @param annotationType to be processed + * @param processor function that defines the annotations modifies {@link MethodMetadata} + */ + protected void registerMethodAnnotation(Class annotationType, + DeclarativeContract.AnnotationProcessor processor) { + registerMethodAnnotation( + annotation -> annotation.annotationType().equals(annotationType), + processor); + } + + /** + * Called while method annotations are being processed + * + * @param predicate to check if the annotation should be processed or not + * @param processor function t