diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..d1c8c0ac5 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,140 @@ +# +# 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. +# + +# common executors +executors: + java: + docker: + - image: velo/toolchains-4-ci-builds:with-21 + +# common commands +commands: + resolve-dependencies: + description: 'Download and prepare all dependencies' + steps: + - run: + name: 'Resolving Dependencies' + command: | + ./mvnw -ntp dependency:resolve-plugins go-offline:resolve-dependencies -DskipTests=true -B + 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: | + ./mvnw -ntp -nsu -s .circleci/settings.xml -P release -pl -:feign-benchmark -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: + executor: + name: java + <<: *defaults + steps: + - checkout + - restore_cache: + keys: + - feign-dependencies-v2-{{ checksum "pom.xml" }} + - feign-dependencies-v2- + - resolve-dependencies + - save_cache: + paths: + - ~/.m2/repository + key: feign-dependencies-v2-{{ checksum "pom.xml" }} + - run: + name: 'Test' + command: | + ./mvnw -ntp -B verify + - verify-formatting + + deploy: + executor: + name: java + <<: *defaults + steps: + - checkout + - restore_cache: + keys: + - feign-dependencies-v2-{{ checksum "pom.xml" }} + - feign-dependencies-v2- + - resolve-dependencies + - configure-gpg + - nexus-deploy + +workflows: + version: 2 + build: + jobs: + - test: + name: 'pr-build' + filters: + <<: *all-branches + + snapshot: + jobs: + - test: + name: 'snapshot' + filters: + <<: *master-only + - deploy: + name: 'deploy snapshot' + requires: + - 'snapshot' + context: Sonatype + filters: + <<: *master-only + + release: + jobs: + - deploy: + name: '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/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..6741fdc93 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [velo] +patreon: velo132 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..8bffe2fee --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "maven" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 100 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..e442a7ff4 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,101 @@ +# ---------------------------------------------------------------------------- +# Copyright 2012-2014 The Feign Authors +# +# The Netty Project licenses this file to you under the Apache License, +# version 2.0 (the "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at: +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +name: "CodeQL" + +on: + push: + branches: ["master"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["master"] + schedule: + - cron: '0 13 * * 3' + +permissions: + contents: read + +jobs: + analyze: + permissions: + actions: read # for github/codeql-action/init to get workflow details + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/analyze to upload SARIF results + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # Override automatic language detection by changing the below list + # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] + language: ['java'] + # Learn more... + # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Cache .m2/repository + - name: Cache local Maven repository + uses: actions/cache@v3 + continue-on-error: true + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ matrix.language }} ${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven-${{ matrix.language }} + ${{ runner.os }}-maven- + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + # - name: Autobuild + # uses: github/codeql-action/autobuild@v2 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + - name: Setup Java JDK + uses: actions/setup-java@v4.0.0 + with: + distribution: 'temurin' + java-version: '21' + + - name: Compile project + run: ./mvnw -B -ntp clean package -Pquickbuild -Dtoolchain.skip=true + env: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/comment-pr.yml b/.github/workflows/comment-pr.yml new file mode 100644 index 000000000..4aac84804 --- /dev/null +++ b/.github/workflows/comment-pr.yml @@ -0,0 +1,56 @@ +# Description: This workflow is triggered when the `receive-pr` workflow completes to post suggestions on the PR. +# Since this pull request has write permissions on the target repo, we should **NOT** execute any untrusted code. +# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ +--- +name: comment-pr + +on: + workflow_run: + workflows: ["receive-pr"] + types: + - completed + +jobs: + post-suggestions: + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#running-a-workflow-based-on-the-conclusion-of-another-workflow + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + env: + # https://docs.github.com/en/actions/reference/authentication-in-a-workflow#permissions-for-the-github_token + ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{github.event.workflow_run.head_branch}} + repository: ${{github.event.workflow_run.head_repository.full_name}} + + # Download the patch + - uses: actions/download-artifact@v4 + with: + name: patch + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + - name: Apply patch + run: | + git apply git-diff.patch --allow-empty + rm git-diff.patch + + # Download the PR number + - uses: actions/download-artifact@v4 + with: + name: pr_number + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + - name: Read pr_number.txt + run: | + PR_NUMBER=$(cat pr_number.txt) + echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV + rm pr_number.txt + + # Post suggestions as a comment on the PR + - uses: googleapis/code-suggester@v4 + with: + command: review + pull_number: ${{ env.PR_NUMBER }} + git_dir: '.' diff --git a/.github/workflows/receive-pr.yml b/.github/workflows/receive-pr.yml new file mode 100644 index 000000000..b2e22e802 --- /dev/null +++ b/.github/workflows/receive-pr.yml @@ -0,0 +1,56 @@ +# Description: This workflow runs OpenRewrite recipes against opened pull request and upload the patch. +# Since this pull request receives untrusted code, we should **NOT** have any secrets in the environment. +# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ +--- +name: receive-pr + +on: + pull_request: + types: [opened, synchronize] + branches: + - master + +concurrency: + group: '${{ github.workflow }} @ ${{ github.ref }}' + cancel-in-progress: true + +jobs: + upload-patch: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{github.event.pull_request.head.ref}} + repository: ${{github.event.pull_request.head.repo.full_name}} + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + # Capture the PR number + # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#using-data-from-the-triggering-workflow + - name: Create pr_number.txt + run: echo "${{ github.event.number }}" > pr_number.txt + - uses: actions/upload-artifact@v4 + with: + name: pr_number + path: pr_number.txt + - name: Remove pr_number.txt + run: rm -f pr_number.txt + + # Execute recipes + - name: Apply OpenRewrite recipes + run: ./mvnw -Dtoolchain.skip=true -Dlicense.skip=true -DskipTests=true -P openrewrite clean install + secrets: + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} + + # Capture the diff + - name: Create patch + run: | + git diff | tee git-diff.patch + - uses: actions/upload-artifact@v4 + with: + name: patch + path: git-diff.patch diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..d19b71053 --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# 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 +.mvn/.develocity/develocity-workspace-id diff --git a/.mvn/develocity.xml b/.mvn/develocity.xml new file mode 100644 index 000000000..1fe2a983d --- /dev/null +++ b/.mvn/develocity.xml @@ -0,0 +1,43 @@ + + + + + https://develocity.commonhaus.dev + false + + feign + + #{isFalse(env['CI'])} + + authenticated + + + #{{'0.0.0.0'}} + + + + + false + + + false + #{isTrue(env['CI'])} + + + diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml new file mode 100644 index 000000000..2d55265b1 --- /dev/null +++ b/.mvn/extensions.xml @@ -0,0 +1,29 @@ + + + + + com.gradle + develocity-maven-extension + 1.22.1 + + + com.gradle + common-custom-user-data-maven-extension + 2.0.1 + + diff --git a/.mvn/jvm.config b/.mvn/jvm.config new file mode 100644 index 000000000..c79df8879 --- /dev/null +++ b/.mvn/jvm.config @@ -0,0 +1 @@ +-XX:CICompilerCount=1 -XX:TieredStopAtLevel=1 -Djava.security.egd=file:/dev/./urandom 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.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 000000000..fe378a151 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.1 +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..7f3c52d2f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,328 @@ +### Version 11.9 + +* `OkHttpClient` now implements `AsyncClient` + +### 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..a8c8d54d1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,1529 @@ -feign -===== +# Feign simplifies the process of writing Java HTTP clients + +[![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 + +--- + +# Usage + +The feign library is available from [Maven Central](https://central.sonatype.com/artifact/io.github.openfeign/feign-core). + +```xml + + io.github.openfeign + feign-core + ??feign.version?? + +``` + +### 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. + +We also have limited support for Level 3, Path Style Expressions, with the following restrictions: + +* Maps and Lists are expanded by default. +* Only Single variable templates are supported. + +*Examples:* + +``` +{;who} ;who=fred +{;half} ;half=50%25 +{;empty} ;empty +{;list} ;list=red;list=green;list=blue +{;map} ;semi=%3B;dot=.;comma=%2C +``` + +```java +public interface MatrixService { + + @RequestLine("GET /repos{;owners}") + List contributors(@Param("owners") List owners); + + class Contributor { + String login; + int contributions; + } +} +``` + +If `owners` in the above example is defined as `Matt, Jeff, Susan`, the uri will expand to `/repos;owners=Matt;owners=Jeff;owners=Susan` + +For more information see [RFC 6570, Section 3.2.7](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.7) + +#### 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! + +### Encoder/Decoder + +#### 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). + +#### Moshi +[Moshi](./moshi) includes an encoder and decoder you can use with a JSON API. +Add `MoshiEncoder` and/or `MoshiDecoder` to your `Feign.Builder` like so: + +```java +GitHub github = Feign.builder() + .encoder(new MoshiEncoder()) + .decoder(new MoshiDecoder()) + .target(GitHub.class, "https://api.github.com"); +``` + +#### Sax +[SaxDecoder](./sax) allows you to decode XML in a way that is compatible with normal JVM and also Android environments. + +Here's an example of how to configure Sax response parsing: +```java +public class Example { + public static void main(String[] args) { + Api api = Feign.builder() + .decoder(SAXDecoder.builder() + .registerContentHandler(UserIdHandler.class) + .build()) + .target(Api.class, "https://apihost"); + } +} +``` + +#### JAXB +[JAXB](./jaxb) includes an encoder and decoder you can use with an XML API. + +Add `JAXBEncoder` and/or `JAXBDecoder` to your `Feign.Builder` like so: + +```java +public class Example { + public static void main(String[] args) { + Api api = Feign.builder() + .encoder(new JAXBEncoder()) + .decoder(new JAXBDecoder()) + .target(Api.class, "https://apihost"); + } +} +``` + +#### 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, ...) + +#### Fastjson2 + +[fastjson2](./fastjson2) includes an encoder and decoder you can use with a JSON API. + +Add `Fastjson2Encoder` and/or `Fastjson2Decoder` to your `Feign.Builder` like so: + +```java +public class Example { + public static void main(String[] args) { + GitHub github = Feign.builder() + .encoder(new Fastjson2Encoder()) + .decoder(new Fastjson2Decoder()) + .target(GitHub.class, "https://api.github.com"); + } +} +``` + +### Contract + +#### 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"); + } +} +``` + +### Client + +#### 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"); +``` + +### Breaker + +#### 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"); + } +} +``` + +### Logger + +#### 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"); + } +} +``` + +If any methods in your interface return type `Stream`, you'll need to configure a `StreamDecoder`. + +Here's how to configure Stream decoder without delegate decoder: + +```java +public class Example { + public static void main(String[] args) { + GitHub github = Feign.builder() + .decoder(StreamDecoder.create((r, t) -> { + BufferedReader bufferedReader = new BufferedReader(r.body().asReader(UTF_8)); + return bufferedReader.lines().iterator(); + })) + .target(GitHub.class, "https://api.github.com"); + } +} +``` + +Here's how to configure Stream decoder with delegate decoder: + +```java + +public class Example { + public static void main(String[] args) { + GitHub github = Feign.builder() + .decoder(StreamDecoder.create((r, t) -> { + BufferedReader bufferedReader = new BufferedReader(r.body().asReader(UTF_8)); + return bufferedReader.lines().iterator(); + }, (r, t) -> "this is delegate decoder")) + .target(GitHub.class, "https://api.github.com"); + } +} +``` + +### Encoders +The simplest way to send a request body to a server is to define a `POST` method that has a `String` or `byte[]` parameter without any annotations on it. You will likely need to add a `Content-Type` header. + +```java +interface LoginClient { + @RequestLine("POST /") + @Headers("Content-Type: application/json") + void login(String content); +} + +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. You can annotate a specific field of `CustomPojo` with the `@Param` annotation to specify a different name to the query parameter. The following POJO will generate query params of "/find?name={name}&number={number}®ion_id={regionId}" (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; + @Param("region_id") + private final String regionId; + + public CustomPojo (String name, int number, String regionId) { + this.name = name; + this.number = number; + this.regionId = regionId; + } +} +``` + +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. + +The following example shows how to refresh token and retry with `ErrorDecoder` and `Retryer` when received a 401 response. + +```java +public class Example { + public static void main(String[] args) { + var github = Feign.builder() + .decoder(new GsonDecoder()) + .retryer(new MyRetryer(100, 3)) + .errorDecoder(new MyErrorDecoder()) + .target(Github.class, "https://api.github.com"); + + var contributors = github.contributors("foo", "bar", "invalid_token"); + for (var contributor : contributors) { + System.out.println(contributor.login + " " + contributor.contributions); + } + } + + static class MyErrorDecoder implements ErrorDecoder { + + private final ErrorDecoder defaultErrorDecoder = new Default(); + + @Override + public Exception decode(String methodKey, Response response) { + // wrapper 401 to RetryableException in order to retry + if (response.status() == 401) { + return new RetryableException(response.status(), response.reason(), response.request().httpMethod(), null, response.request()); + } + return defaultErrorDecoder.decode(methodKey, response); + } + } + + static class MyRetryer implements Retryer { + + private final long period; + private final int maxAttempts; + private int attempt = 1; + + public MyRetryer(long period, int maxAttempts) { + this.period = period; + this.maxAttempts = maxAttempts; + } + + @Override + public void continueOrPropagate(RetryableException e) { + if (++attempt > maxAttempts) { + throw e; + } + if (e.status() == 401) { + // remove Authorization first, otherwise Feign will add a new Authorization header + // cause github responses a 400 bad request + e.request().requestTemplate().removeHeader("Authorization"); + e.request().requestTemplate().header("Authorization", "Bearer " + getNewToken()); + try { + Thread.sleep(period); + } catch (InterruptedException ex) { + throw e; + } + } else { + throw e; + } + } + + // Access an external api to obtain new token + // In this example, we can simply return a fixed token to demonstrate how Retryer works + private String getNewToken() { + return "newToken"; + } + + @Override + public Retryer clone() { + return new MyRetryer(period, maxAttempts); + } +} +``` + +`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. + +#### Response Interceptor +If you need to treat what would otherwise be an error as a success and return a result rather than throw an exception then you may use a `ResponseInterceptor`. + +As an example Feign includes a simple `RedirectionInterceptor` that can be used to extract the location header from redirection responses. +```java +public interface Api { + // returns a 302 response + @RequestLine("GET /location") + String location(); +} + +public class MyApp { + public static void main(String[] args) { + // Configure the HTTP client to ignore redirection + Api api = Feign.builder() + .options(new Options(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, false)) + .responseInterceptor(new RedirectionInterceptor()) + .target(Api.class, "https://redirect.example.com"); + } +} +``` + +### 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. + +> **A Note on Metrics modules**: +> +> All the metric-integrations are built in separate modules and not available in the `feign-core` module. You will need to add them to your dependencies. + +#### 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.builder() + .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` + +## Maven’s Bill of Material (BOM) + +Keeping all feign libraries on the same version is essential to avoid incompatible binaries. When consuming external dependencies, can be tricky to make sure only one version is present. + +With that in mind, feign build generates a module called `feign-bom` that locks the versions for all `feign-*` modules. + +The Bill Of Material is a special POM file that groups dependency versions that are known to be valid and tested to work together. This will reduce the developers’ pain of having to test the compatibility of different versions and reduce the chances to have version mismatches. + + +[Here](https://repo1.maven.org/maven2/io/github/openfeign/feign-bom/11.9/feign-bom-11.9.pom) is one example of what feign BOM file looks like. + +#### Usage + +```xml + + +... + + + + + io.github.openfeign + feign-bom + ??feign.version?? + pom + import + + + + +``` +# Form Encoder + +[![build_status](https://travis-ci.org/OpenFeign/feign-form.svg?branch=master)](https://travis-ci.org/OpenFeign/feign-form) +[![maven_central](https://maven-badges.herokuapp.com/maven-central/io.github.openfeign.form/feign-form/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.github.openfeign.form/feign-form) +[![License](http://img.shields.io/:license-apache-brightgreen.svg)](http://www.apache.org/licenses/LICENSE-2.0.html) + +This module adds support for encoding **application/x-www-form-urlencoded** and **multipart/form-data** forms. + +## Add dependency + +Include the dependency to your app: + +**Maven**: + +```xml + + ... + + io.github.openfeign.form + feign-form + 4.0.0 + + ... + +``` + +**Gradle**: + +```groovy +compile 'io.github.openfeign.form:feign-form:4.0.0' +``` + +## Requirements + +The `feign-form` extension depend on `OpenFeign` and its *concrete* versions: + +- all `feign-form` releases before **3.5.0** works with `OpenFeign` **9.\*** versions; +- starting from `feign-form`'s version **3.5.0**, the module works with `OpenFeign` **10.1.0** versions and greater. + +> **IMPORTANT:** there is no backward compatibility and no any gurantee that the `feign-form`'s versions after **3.5.0** work with `OpenFeign` before **10.\***. `OpenFeign` was refactored in 10th release, so the best approach - use the freshest `OpenFeign` and `feign-form` versions. + +Notes: + +- [spring-cloud-openfeign](https://github.com/spring-cloud/spring-cloud-openfeign) uses `OpenFeign` **9.\*** till **v2.0.3.RELEASE** and uses **10.\*** after. Anyway, the dependency already has suitable `feign-form` version, see [dependency pom](https://github.com/spring-cloud/spring-cloud-openfeign/blob/master/spring-cloud-openfeign-dependencies/pom.xml#L19), so you don't need to specify it separately; + +- `spring-cloud-starter-feign` is a **deprecated** dependency and it always uses the `OpenFeign`'s **9.\*** versions. + +## Usage + +Add `FormEncoder` to your `Feign.Builder` like so: + +```java +SomeApi github = Feign.builder() + .encoder(new FormEncoder()) + .target(SomeApi.class, "http://api.some.org"); +``` + +Moreover, you can decorate the existing encoder, for example JsonEncoder like this: + +```java +SomeApi github = Feign.builder() + .encoder(new FormEncoder(new JacksonEncoder())) + .target(SomeApi.class, "http://api.some.org"); +``` + +And use them together: + +```java +interface SomeApi { + + @RequestLine("POST /json") + @Headers("Content-Type: application/json") + void json (Dto dto); + + @RequestLine("POST /form") + @Headers("Content-Type: application/x-www-form-urlencoded") + void from (@Param("field1") String field1, @Param("field2") String[] values); +} +``` + +You can specify two types of encoding forms by `Content-Type` header. + +### application/x-www-form-urlencoded + +```java +interface SomeApi { + + @RequestLine("POST /authorization") + @Headers("Content-Type: application/x-www-form-urlencoded") + void authorization (@Param("email") String email, @Param("password") String password); + + // Group all parameters within a POJO + @RequestLine("POST /user") + @Headers("Content-Type: application/x-www-form-urlencoded") + void addUser (User user); + + class User { + + Integer id; + + String name; + } +} +``` + +### multipart/form-data + +```java +interface SomeApi { + + // File parameter + @RequestLine("POST /send_photo") + @Headers("Content-Type: multipart/form-data") + void sendPhoto (@Param("is_public") Boolean isPublic, @Param("photo") File photo); + + // byte[] parameter + @RequestLine("POST /send_photo") + @Headers("Content-Type: multipart/form-data") + void sendPhoto (@Param("is_public") Boolean isPublic, @Param("photo") byte[] photo); + + // FormData parameter + @RequestLine("POST /send_photo") + @Headers("Content-Type: multipart/form-data") + void sendPhoto (@Param("is_public") Boolean isPublic, @Param("photo") FormData photo); + + // Group all parameters within a POJO + @RequestLine("POST /send_photo") + @Headers("Content-Type: multipart/form-data") + void sendPhoto (MyPojo pojo); + + class MyPojo { + + @FormProperty("is_public") + Boolean isPublic; + + File photo; + } +} +``` + +In the example above, the `sendPhoto` method uses the `photo` parameter using three different supported types. + +* `File` will use the File's extension to detect the `Content-Type`; +* `byte[]` will use `application/octet-stream` as `Content-Type`; +* `FormData` will use the `FormData`'s `Content-Type` and `fileName`; +* Client's custom POJO for grouping parameters (including types above). + +`FormData` is custom object that wraps a `byte[]` and defines a `Content-Type` and `fileName` like this: + +```java + FormData formData = new FormData("image/png", "filename.png", myDataAsByteArray); + someApi.sendPhoto(true, formData); +``` + +### Spring MultipartFile and Spring Cloud Netflix @FeignClient support + +You can also use Form Encoder with Spring `MultipartFile` and `@FeignClient`. + +Include the dependencies to your project's pom.xml file: + +```xml + + + io.github.openfeign.form + feign-form + 4.0.0 + + + io.github.openfeign.form + feign-form-spring + 4.0.0 + + +``` + +```java +@FeignClient( + name = "file-upload-service", + configuration = FileUploadServiceClient.MultipartSupportConfig.class +) +public interface FileUploadServiceClient extends IFileUploadServiceClient { + + public class MultipartSupportConfig { + + @Autowired + private ObjectFactory messageConverters; + + @Bean + public Encoder feignFormEncoder () { + return new SpringFormEncoder(new SpringEncoder(messageConverters)); + } + } +} +``` + +Or, if you don't need Spring's standard encoder: + +```java +@FeignClient( + name = "file-upload-service", + configuration = FileUploadServiceClient.MultipartSupportConfig.class +) +public interface FileUploadServiceClient extends IFileUploadServiceClient { + + public class MultipartSupportConfig { + + @Bean + public Encoder feignFormEncoder () { + return new SpringFormEncoder(); + } + } +} +``` + +Thanks to [tf-haotri-pham](https://github.com/tf-haotri-pham) for his feature, which makes use of Apache commons-fileupload library, which handles the parsing of the multipart response. The body data parts are held as byte arrays in memory. + +To use this feature, include SpringManyMultipartFilesReader in the list of message converters for the Decoder and have the Feign client return an array of MultipartFile: + +```java +@FeignClient( + name = "${feign.name}", + url = "${feign.url}" + configuration = DownloadClient.ClientConfiguration.class +) +public interface DownloadClient { + + @RequestMapping("/multipart/download/{fileId}") + MultipartFile[] download(@PathVariable("fileId") String fileId); + + class ClientConfiguration { + + @Autowired + private ObjectFactory messageConverters; + + @Bean + public Decoder feignDecoder () { + List> springConverters = + messageConverters.getObject().getConverters(); + + List> decoderConverters = + new ArrayList>(springConverters.size() + 1); + + decoderConverters.addAll(springConverters); + decoderConverters.add(new SpringManyMultipartFilesReader(4096)); + + HttpMessageConverters httpMessageConverters = new HttpMessageConverters(decoderConverters); + + return new SpringDecoder(new ObjectFactory() { + + @Override + public HttpMessageConverters getObject() { + return httpMessageConverters; + } + }); + } + } +} +``` 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..ca93ddb92 --- /dev/null +++ b/annotation-error-decoder/pom.xml @@ -0,0 +1,58 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 13.5-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 + + + org.junit.jupiter + junit-jupiter + 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..8823fb0be --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/AnnotationErrorDecoder.java @@ -0,0 +1,191 @@ +/* + * Copyright 2012-2023 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..51ffa9c44 --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/ErrorCodes.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 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..fd52bdbf1 --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/ErrorHandling.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2023 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..23ab70a3d --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/ExceptionGenerator.java @@ -0,0 +1,221 @@ +/* + * Copyright 2012-2023 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..9939492ba --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/FeignExceptionConstructor.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2023 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..a1770bb60 --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/MethodErrorHandler.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-2023 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..2619de244 --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/ResponseBody.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2023 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..4a5700f13 --- /dev/null +++ b/annotation-error-decoder/src/main/java/feign/error/ResponseHeaders.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2023 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..a9927f579 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AbstractAnnotationErrorDecoderTest.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2023 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.Feign.configKey; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import feign.Request; +import feign.Response; + +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..de3b4cf3b --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderAnnotationInheritanceTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2012-2023 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 java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +public class AnnotationErrorDecoderAnnotationInheritanceTest extends + AbstractAnnotationErrorDecoderTest { + @Override + public Class interfaceAtTest() { + return TestClientInterfaceWithWithMetaAnnotation.class; + } + + 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}, + }); + } // first data value (0) is default + + public String testType; + public int errorCode; + public String method; + public Class expectedExceptionClass; + + @MethodSource("data") + @ParameterizedTest( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + void test(String testType, + int errorCode, + String method, + Class expectedExceptionClass) + throws Exception { + initAnnotationErrorDecoderAnnotationInheritanceTest(testType, errorCode, method, + expectedExceptionClass); + AnnotationErrorDecoder decoder = + AnnotationErrorDecoder.builderFor(TestClientInterfaceWithWithMetaAnnotation.class).build(); + + assertThat(decoder.decode(feignConfigKey(method), testResponse(errorCode)).getClass()) + .isEqualTo(expectedExceptionClass); + } + + @ClassError + public static 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() {} + } + + public void initAnnotationErrorDecoderAnnotationInheritanceTest(String testType, + int errorCode, + String method, + Class expectedExceptionClass) { + this.testType = testType; + this.errorCode = errorCode; + this.method = method; + this.expectedExceptionClass = expectedExceptionClass; + } +} 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..85479b1a7 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderClassInheritanceTest.java @@ -0,0 +1,136 @@ +/* + * Copyright 2012-2023 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 java.util.Arrays; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.ClassLevelDefaultException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.ClassLevelNotFoundException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.Method1DefaultException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.Method1NotFoundException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.Method2NotFoundException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.Method3DefaultException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.ServeErrorException; +import feign.error.AnnotationErrorDecoderClassInheritanceTest.ParentInterfaceWithErrorHandling.UnauthenticatedOrUnauthorizedException; + +public class AnnotationErrorDecoderClassInheritanceTest extends + AbstractAnnotationErrorDecoderTest { + + @Override + public Class interfaceAtTest() { + return GrandChild.class; + } + + 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}, + }); + } // first data value (0) is default + + public String testType; + public int errorCode; + public String method; + public Class expectedExceptionClass; + + @MethodSource("data") + @ParameterizedTest( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + void test(String testType, + int errorCode, + String method, + Class expectedExceptionClass) + throws Exception { + initAnnotationErrorDecoderClassInheritanceTest(testType, errorCode, method, + expectedExceptionClass); + 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 { + } + + public void initAnnotationErrorDecoderClassInheritanceTest(String testType, + int errorCode, + String method, + Class expectedExceptionClass) { + this.testType = testType; + this.errorCode = errorCode; + this.method = method; + this.expectedExceptionClass = expectedExceptionClass; + } +} 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..5bf6a0111 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderExceptionConstructorsTest.java @@ -0,0 +1,580 @@ +/* + * Copyright 2012-2023 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 java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import feign.Request; +import feign.codec.Decoder; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DeclaredDefaultConstructorException; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DeclaredDefaultConstructorWithOtherConstructorsException; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefaultConstructorException; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithAnnotationForBody; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithAnnotationForBodyAndHeaders; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithAnnotationForBodyAndHeadersSecondOrder; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithAnnotationForHeaders; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithAnnotationForHeadersButNotForBody; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithAnnotationForNonSupportedBody; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithAnnotationForOptionalBody; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithNoAnnotationForBody; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithRequest; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithRequestAndAnnotationForResponseBody; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithRequestAndResponseBody; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithRequestAndResponseHeadersAndOptionalResponseBody; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DefinedConstructorWithRequestAndResponseHeadersAndResponseBody; +import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.ParametersException; +import feign.optionals.OptionalDecoder; + +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; + } + + 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} + }); + } // first data value (0) is default + + public String testName; + public int errorCode; + public Class expectedExceptionClass; + public Object expectedRequest; + public Object expectedBody; + public Map> expectedHeaders; + + @MethodSource("data") + @ParameterizedTest( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + void test(String testName, + int errorCode, + Class expectedExceptionClass, + Object expectedRequest, + Object expectedBody, + Map> expectedHeaders) + throws Exception { + initAnnotationErrorDecoderExceptionConstructorsTest(testName, errorCode, expectedExceptionClass, + expectedRequest, expectedBody, expectedHeaders); + 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); + } + + @MethodSource("data") + @ParameterizedTest( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + void ifExceptionIsNotInTheList(String testName, + int errorCode, + Class expectedExceptionClass, + Object expectedRequest, + Object expectedBody, + Map> expectedHeaders) + throws Exception { + initAnnotationErrorDecoderExceptionConstructorsTest(testName, errorCode, expectedExceptionClass, + expectedRequest, expectedBody, expectedHeaders); + 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; + } + } + } + + public void initAnnotationErrorDecoderExceptionConstructorsTest(String testName, + int errorCode, + Class expectedExceptionClass, + Object expectedRequest, + Object expectedBody, + Map> expectedHeaders) { + this.testName = testName; + this.errorCode = errorCode; + this.expectedExceptionClass = expectedExceptionClass; + this.expectedRequest = expectedRequest; + this.expectedBody = expectedBody; + this.expectedHeaders = expectedHeaders; + } +} 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..52c89af11 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderIllegalInterfacesTest.java @@ -0,0 +1,183 @@ +/* + * Copyright 2012-2023 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 static org.assertj.core.api.Assertions.fail; +import java.util.Arrays; +import java.util.Collection; +import java.util.Map; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import feign.Request; + +public class AnnotationErrorDecoderIllegalInterfacesTest { + + 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"} + }); + } // first data value (0) is default + + public Class testInterface; + public Class expectedExceptionClass; + public String messageStart; + + @MethodSource("data") + @ParameterizedTest( + name = "{index}: When building interface ({0}) should return exception type ({1}) with message ({2})") + void test(Class testInterface, + Class expectedExceptionClass, + String messageStart) + throws Exception { + initAnnotationErrorDecoderIllegalInterfacesTest(testInterface, expectedExceptionClass, + messageStart); + 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) {} + } + } + + public void initAnnotationErrorDecoderIllegalInterfacesTest(Class testInterface, + Class expectedExceptionClass, + String messageStart) { + this.testInterface = testInterface; + this.expectedExceptionClass = expectedExceptionClass; + this.messageStart = messageStart; + } + + + +} 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..11a3903e0 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderInheritanceClassLevelAnnotationTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2012-2023 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 java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +public class AnnotationErrorDecoderInheritanceClassLevelAnnotationTest extends + AbstractAnnotationErrorDecoderTest { + @Override + public Class interfaceAtTest() { + return SecondLevelInterface.class; + } + + 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}, + }); + } // first data value (0) is default + + public String testType; + public int errorCode; + public String method; + public Class expectedExceptionClass; + + @MethodSource("data") + @ParameterizedTest( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + void test(String testType, + int errorCode, + String method, + Class expectedExceptionClass) + throws Exception { + initAnnotationErrorDecoderInheritanceClassLevelAnnotationTest(testType, errorCode, method, + expectedExceptionClass); + 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() {} + } + + public void initAnnotationErrorDecoderInheritanceClassLevelAnnotationTest(String testType, + int errorCode, + String method, + Class expectedExceptionClass) { + this.testType = testType; + this.errorCode = errorCode; + this.method = method; + this.expectedExceptionClass = expectedExceptionClass; + } +} 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..abf8aa448 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderInheritanceMethodLevelAnnotationTest.java @@ -0,0 +1,149 @@ +/* + * Copyright 2012-2023 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 java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +public class AnnotationErrorDecoderInheritanceMethodLevelAnnotationTest extends + AbstractAnnotationErrorDecoderTest { + @Override + public Class interfaceAtTest() { + return SecondLevelInterface.class; + } + + 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}, + }); + } // first data value (0) is default + + public String testType; + public int errorCode; + public String method; + public Class expectedExceptionClass; + + @MethodSource("data") + @ParameterizedTest( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + void test(String testType, + int errorCode, + String method, + Class expectedExceptionClass) + throws Exception { + initAnnotationErrorDecoderInheritanceMethodLevelAnnotationTest(testType, errorCode, method, + expectedExceptionClass); + 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 { + @Override + @SecondLevelMethodErrorHandling + void topLevelMethod2(); + + @Override + @ErrorHandling( + codeSpecific = { + @ErrorCodes(codes = {404}, generate = MethodSecondLevelErrorHandlingException.class)}, + defaultException = MethodSecondLevelDefaultException.class) + void topLevelMethod3(); + + @Override + @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() {} + } + + public void initAnnotationErrorDecoderInheritanceMethodLevelAnnotationTest(String testType, + int errorCode, + String method, + Class expectedExceptionClass) { + this.testType = testType; + this.errorCode = errorCode; + this.method = method; + this.expectedExceptionClass = expectedExceptionClass; + } +} 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..5b7688279 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderInheritanceTest.java @@ -0,0 +1,139 @@ +/* + * Copyright 2012-2023 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 java.util.Arrays; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import feign.error.AnnotationErrorDecoderInheritanceTest.TopLevelInterface.ClassLevelDefaultException; +import feign.error.AnnotationErrorDecoderInheritanceTest.TopLevelInterface.ClassLevelNotFoundException; +import feign.error.AnnotationErrorDecoderInheritanceTest.TopLevelInterface.Method1DefaultException; +import feign.error.AnnotationErrorDecoderInheritanceTest.TopLevelInterface.Method1NotFoundException; +import feign.error.AnnotationErrorDecoderInheritanceTest.TopLevelInterface.Method2NotFoundException; +import feign.error.AnnotationErrorDecoderInheritanceTest.TopLevelInterface.Method3DefaultException; +import feign.error.AnnotationErrorDecoderInheritanceTest.TopLevelInterface.ServeErrorException; +import feign.error.AnnotationErrorDecoderInheritanceTest.TopLevelInterface.UnauthenticatedOrUnauthorizedException; + +public class AnnotationErrorDecoderInheritanceTest extends + AbstractAnnotationErrorDecoderTest { + + @Override + public Class interfaceAtTest() { + return TestClientInterfaceWithExceptionPriority.class; + } + + 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}, + }); + } // first data value (0) is default + + public String testType; + public int errorCode; + public String method; + public Class expectedExceptionClass; + + @MethodSource("data") + @ParameterizedTest( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + void test(String testType, + int errorCode, + String method, + Class expectedExceptionClass) + throws Exception { + initAnnotationErrorDecoderInheritanceTest(testType, errorCode, method, expectedExceptionClass); + 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(); + } + + public void initAnnotationErrorDecoderInheritanceTest(String testType, + int errorCode, + String method, + Class expectedExceptionClass) { + this.testType = testType; + this.errorCode = errorCode; + this.method = method; + this.expectedExceptionClass = expectedExceptionClass; + } +} 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..397910cd0 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderNoAnnotationTest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2023 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.jupiter.api.Test; +import feign.codec.ErrorDecoder; +import feign.error.AnnotationErrorDecoderNoAnnotationTest.TestClientInterfaceWithNoAnnotations; + +public class AnnotationErrorDecoderNoAnnotationTest + extends AbstractAnnotationErrorDecoderTest { + + @Override + public Class interfaceAtTest() { + return TestClientInterfaceWithNoAnnotations.class; + } + + @Test + void delegatesToDefaultErrorDecoder() throws Exception { + + ErrorDecoder defaultErrorDecoder = (methodKey, response) -> 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..cfda8e937 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderPriorityTest.java @@ -0,0 +1,132 @@ +/* + * Copyright 2012-2023 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 java.util.Arrays; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import feign.error.AnnotationErrorDecoderPriorityTest.TestClientInterfaceWithExceptionPriority.ClassLevelDefaultException; +import feign.error.AnnotationErrorDecoderPriorityTest.TestClientInterfaceWithExceptionPriority.ClassLevelNotFoundException; +import feign.error.AnnotationErrorDecoderPriorityTest.TestClientInterfaceWithExceptionPriority.Method1DefaultException; +import feign.error.AnnotationErrorDecoderPriorityTest.TestClientInterfaceWithExceptionPriority.Method1NotFoundException; +import feign.error.AnnotationErrorDecoderPriorityTest.TestClientInterfaceWithExceptionPriority.Method2NotFoundException; +import feign.error.AnnotationErrorDecoderPriorityTest.TestClientInterfaceWithExceptionPriority.Method3DefaultException; +import feign.error.AnnotationErrorDecoderPriorityTest.TestClientInterfaceWithExceptionPriority.ServeErrorException; +import feign.error.AnnotationErrorDecoderPriorityTest.TestClientInterfaceWithExceptionPriority.UnauthenticatedOrUnauthorizedException; + +public class AnnotationErrorDecoderPriorityTest extends + AbstractAnnotationErrorDecoderTest { + + @Override + public Class interfaceAtTest() { + return TestClientInterfaceWithExceptionPriority.class; + } + + 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}, + }); + } // first data value (0) is default + + public String testType; + public int errorCode; + public String method; + public Class expectedExceptionClass; + + @MethodSource("data") + @ParameterizedTest( + name = "{0}: When error code ({1}) on method ({2}) should return exception type ({3})") + void test(String testType, + int errorCode, + String method, + Class expectedExceptionClass) + throws Exception { + initAnnotationErrorDecoderPriorityTest(testType, errorCode, method, expectedExceptionClass); + 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 { + } + } + + public void initAnnotationErrorDecoderPriorityTest(String testType, + int errorCode, + String method, + Class expectedExceptionClass) { + this.testType = testType; + this.errorCode = errorCode; + this.method = method; + this.expectedExceptionClass = expectedExceptionClass; + } +} 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..af1e725f0 --- /dev/null +++ b/annotation-error-decoder/src/test/java/feign/error/TestPojo.java @@ -0,0 +1,19 @@ +/* + * Copyright 2012-2023 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..8ca20444d --- /dev/null +++ b/apt-test-generator/pom.xml @@ -0,0 +1,196 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 13.5-SNAPSHOT + + + io.github.openfeign.experimental + feign-apt-test-generator + Feign APT test generator + Feign code generation tool for mocked clients + + + ${project.basedir}/.. + + true + + + + + com.github.jknack + handlebars + 4.3.1 + + + + io.github.openfeign + feign-example-github + ${project.version} + + + + com.google.testing.compile + compile-testing + 0.21.0 + test + + + junit + junit + + + + + org.slf4j + slf4j-jdk14 + ${slf4j.version} + test + + + + com.google.guava + guava + ${guava.version} + + + com.google.auto.service + auto-service-annotations + 1.1.1 + provided + + + + + + + docker + true + + ${project.basedir}/docker + + + + ${basedir}/src/main/resources + + + src/main/java + + **/*.java + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + feign.aptgenerator.github.GitHubFactoryExample + + + false + + + + + + org.skife.maven + really-executable-jar-maven-plugin + 2.1.1 + + github + + + + package + + really-executable-jar + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + 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 + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + --add-opens jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.main=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..4ec725a52 --- /dev/null +++ b/apt-test-generator/src/main/java/feign/apttestgenerator/ArgumentDefinition.java @@ -0,0 +1,27 @@ +/* + * Copyright 2012-2023 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..3696795d0 --- /dev/null +++ b/apt-test-generator/src/main/java/feign/apttestgenerator/ClientDefinition.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-2023 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..98267436f --- /dev/null +++ b/apt-test-generator/src/main/java/feign/apttestgenerator/GenerateTestStubAPT.java @@ -0,0 +1,158 @@ +/* + * Copyright 2012-2023 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..5870f8f19 --- /dev/null +++ b/apt-test-generator/src/main/java/feign/apttestgenerator/MethodDefinition.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2023 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..3656447bb --- /dev/null +++ b/apt-test-generator/src/test/java/example/github/GitHubStub.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-2023 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..5874da451 --- /dev/null +++ b/apt-test-generator/src/test/java/feign/apttestgenerator/GenerateTestStubAPTTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-2023 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 java.io.File; +import org.junit.jupiter.api.Test; +import com.google.testing.compile.Compilation; +import com.google.testing.compile.JavaFileObjects; + +/** + * Test for {@link GenerateTestStubAPT} + */ +class GenerateTestStubAPTTest { + + private final File main = new File("../example-github/src/main/java/").getAbsoluteFile(); + + @Test + 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..4015239ac --- /dev/null +++ b/benchmark/pom.xml @@ -0,0 +1,176 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 13.5-SNAPSHOT + + + feign-benchmark + Feign Benchmark (JMH) + + + 1.37 + 0.5.3 + 1.3.8 + 4.1.113.Final + ${project.basedir}/.. + + true + + + + + + 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 + + + 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-api + ${slf4j.version} + + + org.slf4j + slf4j-nop + ${slf4j.version} + + + + + package + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + org.openjdk.jmh.Main + + + false + + + + + + org.skife.maven + really-executable-jar-maven-plugin + 2.1.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..562054ba8 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/DecoderIteratorsBenchmark.java @@ -0,0 +1,124 @@ +/* + * Copyright 2012-2023 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..7a5b54866 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/FeignTestInterface.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2023 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..48efe2f28 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/RealRequestBenchmarks.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-2023 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..fe3490aa3 --- /dev/null +++ b/benchmark/src/main/java/feign/benchmark/WhatShouldWeCacheBenchmarks.java @@ -0,0 +1,123 @@ +/* + * Copyright 2012-2023 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.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 = (request, options) -> { + 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..0abeadbbf --- /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..a17888622 --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,133 @@ + + + + 4.0.0 + + + io.github.openfeign + parent + 13.5-SNAPSHOT + + + feign-core + Feign Core + Feign Core + + + ${project.basedir}/.. + + + + + com.squareup.okhttp3 + mockwebserver + test + + + org.junit.jupiter + junit-jupiter + test + + + + com.google.code.gson + gson + test + + + + org.springframework + spring-context + 6.1.13 + test + + + + com.fasterxml.jackson.core + jackson-databind + test + + + + org.mockito + mockito-core + ${mockito.version} + test + + + + + + + maven-jar-plugin + + + + test-jar + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-banned-dependencies + + enforce + + + + + feign-core should never include any dependencies, this is a design choice to keep core light and extend functionality using modules + + *:*:*:*:*:* + + + *:*:*:*:test:* + + + + true + + + + + + + + + + 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..9c7ce282c --- /dev/null +++ b/core/src/main/java/feign/AlwaysEncodeBodyContract.java @@ -0,0 +1,31 @@ +/* + * Copyright 2012-2023 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..2cfe63d83 --- /dev/null +++ b/core/src/main/java/feign/AsyncClient.java @@ -0,0 +1,101 @@ +/* + * Copyright 2012-2023 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/AsyncContextSupplier.java b/core/src/main/java/feign/AsyncContextSupplier.java new file mode 100644 index 000000000..865244f0c --- /dev/null +++ b/core/src/main/java/feign/AsyncContextSupplier.java @@ -0,0 +1,19 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +public interface AsyncContextSupplier { + + C newContext(); +} diff --git a/core/src/main/java/feign/AsyncFeign.java b/core/src/main/java/feign/AsyncFeign.java new file mode 100644 index 000000000..8006b05ed --- /dev/null +++ b/core/src/main/java/feign/AsyncFeign.java @@ -0,0 +1,234 @@ +/* + * Copyright 2012-2024 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Logger.Level; +import feign.Request.Options; +import feign.Target.HardCodedTarget; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Supplier; + +/** + * 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 final class AsyncFeign { + public static AsyncBuilder builder() { + return new AsyncBuilder<>(); + } + + /** + * @deprecated use {@link #builder()} instead. + */ + @Deprecated() + public static AsyncBuilder asyncBuilder() { + return builder(); + } + + 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 extends BaseBuilder, AsyncFeign> { + + private AsyncContextSupplier defaultContextSupplier = () -> null; + private AsyncClient client = new AsyncClient.Default<>( + new Client.Default(null, null), LazyInitializedExecutorService.instance); + private MethodInfoResolver methodInfoResolver = MethodInfo::new; + + @Deprecated + public AsyncBuilder defaultContextSupplier(Supplier supplier) { + this.defaultContextSupplier = supplier::get; + return this; + } + + public AsyncBuilder client(AsyncClient client) { + this.client = client; + return this; + } + + public AsyncBuilder methodInfoResolver(MethodInfoResolver methodInfoResolver) { + this.methodInfoResolver = methodInfoResolver; + return this; + } + + @Override + public AsyncBuilder mapAndDecode(ResponseMapper mapper, Decoder decoder) { + return super.mapAndDecode(mapper, decoder); + } + + @Override + public AsyncBuilder decoder(Decoder decoder) { + return super.decoder(decoder); + } + + @Override + @Deprecated + public AsyncBuilder decode404() { + return super.decode404(); + } + + @Override + public AsyncBuilder dismiss404() { + return super.dismiss404(); + } + + @Override + public AsyncBuilder errorDecoder(ErrorDecoder errorDecoder) { + return super.errorDecoder(errorDecoder); + } + + @Override + public AsyncBuilder doNotCloseAfterDecode() { + return super.doNotCloseAfterDecode(); + } + + @Override + public AsyncBuilder decodeVoid() { + return super.decodeVoid(); + } + + public AsyncBuilder defaultContextSupplier(AsyncContextSupplier supplier) { + this.defaultContextSupplier = supplier; + 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); + } + + @Override + public AsyncBuilder logLevel(Level logLevel) { + return super.logLevel(logLevel); + } + + @Override + public AsyncBuilder contract(Contract contract) { + return super.contract(contract); + } + + @Override + public AsyncBuilder logger(Logger logger) { + return super.logger(logger); + } + + @Override + public AsyncBuilder encoder(Encoder encoder) { + return super.encoder(encoder); + } + + @Override + public AsyncBuilder queryMapEncoder(QueryMapEncoder queryMapEncoder) { + return super.queryMapEncoder(queryMapEncoder); + } + + @Override + public AsyncBuilder options(Options options) { + return super.options(options); + } + + @Override + public AsyncBuilder requestInterceptor(RequestInterceptor requestInterceptor) { + return super.requestInterceptor(requestInterceptor); + } + + @Override + public AsyncBuilder requestInterceptors(Iterable requestInterceptors) { + return super.requestInterceptors(requestInterceptors); + } + + @Override + public AsyncBuilder invocationHandlerFactory(InvocationHandlerFactory invocationHandlerFactory) { + return super.invocationHandlerFactory(invocationHandlerFactory); + } + + @Override + public AsyncFeign internalBuild() { + AsyncResponseHandler responseHandler = + (AsyncResponseHandler) Capability.enrich( + new AsyncResponseHandler( + logLevel, + logger, + decoder, + errorDecoder, + dismiss404, + closeAfterDecode, decodeVoid, responseInterceptorChain()), + AsyncResponseHandler.class, + capabilities); + + final MethodHandler.Factory methodHandlerFactory = + new AsynchronousMethodHandler.Factory<>( + client, retryer, requestInterceptors, + responseHandler, logger, logLevel, + propagationPolicy, methodInfoResolver, + new RequestTemplateFactoryResolver(encoder, queryMapEncoder), + options); + final ReflectiveFeign feign = + new ReflectiveFeign<>(contract, methodHandlerFactory, invocationHandlerFactory, + defaultContextSupplier); + return new AsyncFeign<>(feign); + } + } + + private final ReflectiveFeign feign; + + private AsyncFeign(ReflectiveFeign feign) { + this.feign = feign; + } + + public T newInstance(Target target) { + return feign.newInstance(target); + } + + public T newInstance(Target target, C context) { + return feign.newInstance(target, context); + } +} diff --git a/core/src/main/java/feign/AsyncResponseHandler.java b/core/src/main/java/feign/AsyncResponseHandler.java new file mode 100644 index 000000000..242c83aee --- /dev/null +++ b/core/src/main/java/feign/AsyncResponseHandler.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2023 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.Logger.Level; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; + +/** + * The response handler that is used to provide asynchronous support on top of standard response + * handling + */ +@Experimental +class AsyncResponseHandler { + private final ResponseHandler responseHandler; + + AsyncResponseHandler(Level logLevel, Logger logger, Decoder decoder, ErrorDecoder errorDecoder, + boolean dismiss404, boolean closeAfterDecode, boolean decodeVoid, + ResponseInterceptor.Chain executionChain) { + this.responseHandler = new ResponseHandler(logLevel, logger, decoder, errorDecoder, dismiss404, + closeAfterDecode, decodeVoid, executionChain); + } + + public CompletableFuture handleResponse(String configKey, + Response response, + Type returnType, + long elapsedTime) { + CompletableFuture resultFuture = new CompletableFuture<>(); + handleResponse(resultFuture, configKey, response, returnType, elapsedTime); + return resultFuture; + } + + /** + * @deprecated use {@link #handleResponse(String, Response, Type, long)} instead. + */ + @Deprecated() + void handleResponse(CompletableFuture resultFuture, + String configKey, + Response response, + Type returnType, + long elapsedTime) { + try { + resultFuture.complete( + this.responseHandler.handleResponse(configKey, response, returnType, elapsedTime)); + } catch (Exception e) { + resultFuture.completeExceptionally(e); + } + } +} diff --git a/core/src/main/java/feign/AsynchronousMethodHandler.java b/core/src/main/java/feign/AsynchronousMethodHandler.java new file mode 100644 index 000000000..56e7ebb63 --- /dev/null +++ b/core/src/main/java/feign/AsynchronousMethodHandler.java @@ -0,0 +1,269 @@ +/* + * Copyright 2012-2024 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Request.Options; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.stream.Stream; +import static feign.ExceptionPropagationPolicy.UNWRAP; +import static feign.FeignException.errorExecuting; +import static feign.Util.checkNotNull; + +final class AsynchronousMethodHandler implements MethodHandler { + + private final AsyncClient client; + private final C requestContext; + private final AsyncResponseHandler asyncResponseHandler; + private final MethodInfo methodInfo; + private final MethodHandlerConfiguration methodHandlerConfiguration; + + private AsynchronousMethodHandler(MethodHandlerConfiguration methodHandlerConfiguration, + AsyncClient client, AsyncResponseHandler asyncResponseHandler, C requestContext, + MethodInfo methodInfo) { + this.methodHandlerConfiguration = + checkNotNull(methodHandlerConfiguration, "methodHandlerConfiguration"); + this.client = checkNotNull(client, "client for %s", methodHandlerConfiguration.getTarget()); + this.requestContext = requestContext; + this.asyncResponseHandler = asyncResponseHandler; + this.methodInfo = methodInfo; + } + + @Override + public Object invoke(Object[] argv) throws Throwable { + RequestTemplate template = methodHandlerConfiguration.getBuildTemplateFromArgs().create(argv); + Options options = findOptions(argv); + Retryer retryer = this.methodHandlerConfiguration.getRetryer().clone(); + try { + if (methodInfo.isAsyncReturnType()) { + return executeAndDecode(template, options, retryer); + } else { + return executeAndDecode(template, options, retryer).join(); + } + } catch (CompletionException e) { + throw e.getCause(); + } + } + + private CompletableFuture executeAndDecode(RequestTemplate template, + Options options, + Retryer retryer) { + CancellableFuture resultFuture = new CancellableFuture<>(); + + executeAndDecode(template, options) + .whenComplete((response, throwable) -> { + if (throwable != null) { + if (!resultFuture.isDone() && shouldRetry(retryer, throwable, resultFuture)) { + if (methodHandlerConfiguration.getLogLevel() != Logger.Level.NONE) { + methodHandlerConfiguration.getLogger().logRetry( + methodHandlerConfiguration.getMetadata().configKey(), + methodHandlerConfiguration.getLogLevel()); + } + + resultFuture.setInner( + executeAndDecode(template, options, retryer)); + } + } else { + resultFuture.complete(response); + } + }); + + return resultFuture; + } + + private static class CancellableFuture extends CompletableFuture { + private CompletableFuture inner = null; + + public void setInner(CompletableFuture value) { + inner = value; + inner.whenComplete(pipeTo(this)); + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + final boolean result = super.cancel(mayInterruptIfRunning); + if (inner != null) { + inner.cancel(mayInterruptIfRunning); + } + return result; + } + + private static BiConsumer pipeTo(CompletableFuture completableFuture) { + return (value, throwable) -> { + if (completableFuture.isDone()) { + return; + } + + if (throwable != null) { + completableFuture.completeExceptionally(throwable); + } else { + completableFuture.complete(value); + } + }; + } + } + + private boolean shouldRetry(Retryer retryer, + Throwable throwable, + CompletableFuture resultFuture) { + if (throwable instanceof CompletionException) { + throwable = throwable.getCause(); + } + + if (!(throwable instanceof RetryableException)) { + resultFuture.completeExceptionally(throwable); + return false; + } + + RetryableException retryableException = (RetryableException) throwable; + try { + retryer.continueOrPropagate(retryableException); + return true; + } catch (RetryableException th) { + Throwable cause = th.getCause(); + if (methodHandlerConfiguration.getPropagationPolicy() == UNWRAP && cause != null) { + resultFuture.completeExceptionally(cause); + } else { + resultFuture.completeExceptionally(th); + } + return false; + } + } + + private CompletableFuture executeAndDecode(RequestTemplate template, Options options) { + Request request = targetRequest(template); + + if (methodHandlerConfiguration.getLogLevel() != Logger.Level.NONE) { + methodHandlerConfiguration.getLogger().logRequest( + methodHandlerConfiguration.getMetadata().configKey(), + methodHandlerConfiguration.getLogLevel(), request); + } + + long start = System.nanoTime(); + return client.execute(request, options, Optional.ofNullable(requestContext)) + .thenApply(response -> + // TODO: remove in Feign 12 + ensureRequestIsSet(response, template, request)) + .exceptionally(throwable -> { + CompletionException completionException = throwable instanceof CompletionException + ? (CompletionException) throwable + : new CompletionException(throwable); + if (completionException.getCause() instanceof IOException) { + IOException ioException = (IOException) completionException.getCause(); + if (methodHandlerConfiguration.getLogLevel() != Logger.Level.NONE) { + methodHandlerConfiguration.getLogger().logIOException( + methodHandlerConfiguration.getMetadata().configKey(), + methodHandlerConfiguration.getLogLevel(), ioException, + elapsedTime(start)); + } + + throw errorExecuting(request, ioException); + } else { + throw completionException; + } + }) + .thenCompose(response -> handleResponse(response, elapsedTime(start))); + } + + private static Response ensureRequestIsSet(Response response, + RequestTemplate template, + Request request) { + return response.toBuilder() + .request(request) + .requestTemplate(template) + .build(); + } + + private CompletableFuture handleResponse(Response response, long elapsedTime) { + return asyncResponseHandler.handleResponse( + methodHandlerConfiguration.getMetadata().configKey(), response, + methodInfo.underlyingReturnType(), elapsedTime); + } + + private long elapsedTime(long start) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + } + + private Request targetRequest(RequestTemplate template) { + for (RequestInterceptor interceptor : methodHandlerConfiguration.getRequestInterceptors()) { + interceptor.apply(template); + } + return methodHandlerConfiguration.getTarget().apply(template); + } + + private Options findOptions(Object[] argv) { + if (argv == null || argv.length == 0) { + return this.methodHandlerConfiguration.getOptions(); + } + return Stream.of(argv) + .filter(Options.class::isInstance) + .map(Options.class::cast) + .findFirst() + .orElse(this.methodHandlerConfiguration.getOptions()); + } + + static class Factory implements MethodHandler.Factory { + + private final AsyncClient client; + private final Retryer retryer; + private final List requestInterceptors; + private final AsyncResponseHandler responseHandler; + private final Logger logger; + private final Logger.Level logLevel; + private final ExceptionPropagationPolicy propagationPolicy; + private final MethodInfoResolver methodInfoResolver; + private final RequestTemplateFactoryResolver requestTemplateFactoryResolver; + private final Options options; + + Factory(AsyncClient client, Retryer retryer, List requestInterceptors, + AsyncResponseHandler responseHandler, + Logger logger, Logger.Level logLevel, + ExceptionPropagationPolicy propagationPolicy, + MethodInfoResolver methodInfoResolver, + RequestTemplateFactoryResolver requestTemplateFactoryResolver, + Options options) { + this.client = checkNotNull(client, "client"); + this.retryer = checkNotNull(retryer, "retryer"); + this.requestInterceptors = checkNotNull(requestInterceptors, "requestInterceptors"); + this.responseHandler = responseHandler; + this.logger = checkNotNull(logger, "logger"); + this.logLevel = checkNotNull(logLevel, "logLevel"); + this.propagationPolicy = propagationPolicy; + this.methodInfoResolver = methodInfoResolver; + this.requestTemplateFactoryResolver = + checkNotNull(requestTemplateFactoryResolver, "requestTemplateFactoryResolver"); + this.options = checkNotNull(options, "options"); + } + + @Override + public MethodHandler create(Target target, + MethodMetadata metadata, + C requestContext) { + final RequestTemplate.Factory buildTemplateFromArgs = + requestTemplateFactoryResolver.resolve(target, metadata); + + MethodHandlerConfiguration methodHandlerConfiguration = + new MethodHandlerConfiguration(metadata, target, retryer, requestInterceptors, logger, + logLevel, buildTemplateFromArgs, options, propagationPolicy); + return new AsynchronousMethodHandler(methodHandlerConfiguration, client, responseHandler, + requestContext, methodInfoResolver.resolve(target.type(), metadata.method())); + } + } +} diff --git a/core/src/main/java/feign/BaseBuilder.java b/core/src/main/java/feign/BaseBuilder.java new file mode 100644 index 000000000..8943ab67e --- /dev/null +++ b/core/src/main/java/feign/BaseBuilder.java @@ -0,0 +1,317 @@ +/* + * Copyright 2012-2023 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.ExceptionPropagationPolicy.NONE; +import feign.Feign.ResponseMappingDecoder; +import feign.Logger.NoOpLogger; +import feign.Request.Options; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; +import feign.querymap.FieldQueryMapEncoder; +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public abstract class BaseBuilder, T> implements Cloneable { + + private final B thisB; + + protected final List requestInterceptors = + new ArrayList<>(); + protected final List responseInterceptors = new ArrayList<>(); + protected Logger.Level logLevel = Logger.Level.NONE; + protected Contract contract = new Contract.Default(); + protected Retryer retryer = new Retryer.Default(); + protected Logger logger = new NoOpLogger(); + protected Encoder encoder = new Encoder.Default(); + protected Decoder decoder = new Decoder.Default(); + protected boolean closeAfterDecode = true; + protected boolean decodeVoid = false; + protected QueryMapEncoder queryMapEncoder = QueryMap.MapEncoder.FIELD.instance(); + protected ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + protected Options options = new Options(); + protected InvocationHandlerFactory invocationHandlerFactory = + new InvocationHandlerFactory.Default(); + protected boolean dismiss404; + protected ExceptionPropagationPolicy propagationPolicy = NONE; + protected List capabilities = new ArrayList<>(); + + + public BaseBuilder() { + super(); + thisB = (B) this; + } + + public B logLevel(Logger.Level logLevel) { + this.logLevel = logLevel; + return thisB; + } + + public B contract(Contract contract) { + this.contract = contract; + return thisB; + } + + public B retryer(Retryer retryer) { + this.retryer = retryer; + return thisB; + } + + public B logger(Logger logger) { + this.logger = logger; + return thisB; + } + + public B encoder(Encoder encoder) { + this.encoder = encoder; + return thisB; + } + + public B decoder(Decoder decoder) { + this.decoder = decoder; + return thisB; + } + + /** + * This flag indicates that the response should not be automatically closed upon completion of + * decoding the message. This should be set if you plan on processing the response into a + * lazy-evaluated construct, such as a {@link java.util.Iterator}. + * + *

+ * Feign standard decoders do not have built in support for this flag. If you are using this flag, + * you MUST also use a custom Decoder, and be sure to close all resources appropriately somewhere + * in the Decoder (you can use {@link Util#ensureClosed} for convenience). + * + * @since 9.6 + * + */ + public B doNotCloseAfterDecode() { + this.closeAfterDecode = false; + return thisB; + } + + public B decodeVoid() { + this.decodeVoid = true; + return thisB; + } + + public B queryMapEncoder(QueryMapEncoder queryMapEncoder) { + this.queryMapEncoder = queryMapEncoder; + return thisB; + } + + /** + * Allows to map the response before passing it to the decoder. + */ + public B mapAndDecode(ResponseMapper mapper, Decoder decoder) { + this.decoder = new ResponseMappingDecoder(mapper, decoder); + return thisB; + } + + /** + * This flag indicates that the {@link #decoder(Decoder) decoder} should process responses with + * 404 status, specifically returning null or empty instead of throwing {@link FeignException}. + * + *

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

+ * This flag only works with 404, as opposed to all or arbitrary status codes. This was an + * explicit decision: 404 -> empty is safe, common and doesn't complicate redirection, retry or + * fallback policy. If your server returns a different status for not-found, correct via a custom + * {@link #client(Client) client}. + * + * @since 11.9 + */ + public B dismiss404() { + this.dismiss404 = true; + return thisB; + } + + + /** + * This flag indicates that the {@link #decoder(Decoder) decoder} should process responses with + * 404 status, specifically returning null or empty instead of throwing {@link FeignException}. + * + *

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

+ * This flag only works with 404, as opposed to all or arbitrary status codes. This was an + * explicit decision: 404 -> empty is safe, common and doesn't complicate redirection, retry or + * fallback policy. If your server returns a different status for not-found, correct via a custom + * {@link #client(Client) client}. + * + * @since 8.12 + * @deprecated use {@link #dismiss404()} instead. + */ + @Deprecated + public B decode404() { + this.dismiss404 = true; + return thisB; + } + + + public B errorDecoder(ErrorDecoder errorDecoder) { + this.errorDecoder = errorDecoder; + return thisB; + } + + public B options(Options options) { + this.options = options; + return thisB; + } + + /** + * Adds a single request interceptor to the builder. + */ + public B requestInterceptor(RequestInterceptor requestInterceptor) { + this.requestInterceptors.add(requestInterceptor); + return thisB; + } + + /** + * Sets the full set of request interceptors for the builder, overwriting any previous + * interceptors. + */ + public B requestInterceptors(Iterable requestInterceptors) { + this.requestInterceptors.clear(); + for (RequestInterceptor requestInterceptor : requestInterceptors) { + this.requestInterceptors.add(requestInterceptor); + } + return thisB; + } + + /** + * Sets the full set of request interceptors for the builder, overwriting any previous + * interceptors. + */ + public B responseInterceptors(Iterable responseInterceptors) { + this.responseInterceptors.clear(); + for (ResponseInterceptor responseInterceptor : responseInterceptors) { + this.responseInterceptors.add(responseInterceptor); + } + return thisB; + } + + /** + * Adds a single response interceptor to the builder. + */ + public B responseInterceptor(ResponseInterceptor responseInterceptor) { + this.responseInterceptors.add(responseInterceptor); + return thisB; + } + + + /** + * Allows you to override how reflective dispatch works inside of Feign. + */ + public B invocationHandlerFactory(InvocationHandlerFactory invocationHandlerFactory) { + this.invocationHandlerFactory = invocationHandlerFactory; + return thisB; + } + + public B exceptionPropagationPolicy(ExceptionPropagationPolicy propagationPolicy) { + this.propagationPolicy = propagationPolicy; + return thisB; + } + + public B addCapability(Capability capability) { + this.capabilities.add(capability); + return thisB; + } + + @SuppressWarnings("unchecked") + B enrich() { + if (capabilities.isEmpty()) { + return thisB; + } + + try { + B clone = (B) thisB.clone(); + + getFieldsToEnrich().forEach(field -> { + field.setAccessible(true); + try { + final Object originalValue = field.get(clone); + final Object enriched; + if (originalValue instanceof List) { + Type ownerType = + ((ParameterizedType) field.getGenericType()).getActualTypeArguments()[0]; + enriched = ((List) originalValue).stream() + .map(value -> Capability.enrich(value, (Class) ownerType, capabilities)) + .collect(Collectors.toList()); + } else { + enriched = Capability.enrich(originalValue, field.getType(), capabilities); + } + field.set(clone, enriched); + } catch (IllegalArgumentException | IllegalAccessException e) { + throw new RuntimeException("Unable to enrich field " + field, e); + } finally { + field.setAccessible(false); + } + }); + + return clone; + } catch (CloneNotSupportedException e) { + throw new AssertionError(e); + } + } + + List getFieldsToEnrich() { + return Util.allFields(getClass()) + .stream() + // exclude anything generated by compiler + .filter(field -> !field.isSynthetic()) + // and capabilities itself + .filter(field -> !Objects.equals(field.getName(), "capabilities")) + // and thisB helper field + .filter(field -> !Objects.equals(field.getName(), "thisB")) + // skip primitive types + .filter(field -> !field.getType().isPrimitive()) + // skip enumerations + .filter(field -> !field.getType().isEnum()) + .collect(Collectors.toList()); + } + + public final T build() { + return enrich().internalBuild(); + } + + protected abstract T internalBuild(); + + protected ResponseInterceptor.Chain responseInterceptorChain() { + ResponseInterceptor.Chain endOfChain = + ResponseInterceptor.Chain.DEFAULT; + ResponseInterceptor.Chain executionChain = this.responseInterceptors.stream() + .reduce(ResponseInterceptor::andThen) + .map(interceptor -> interceptor.apply(endOfChain)) + .orElse(endOfChain); + + return (ResponseInterceptor.Chain) Capability.enrich(executionChain, + ResponseInterceptor.Chain.class, capabilities); + } + + +} diff --git a/core/src/main/java/feign/Body.java b/core/src/main/java/feign/Body.java new file mode 100644 index 000000000..2099bc19e --- /dev/null +++ b/core/src/main/java/feign/Body.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2023 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..63fae0708 --- /dev/null +++ b/core/src/main/java/feign/Capability.java @@ -0,0 +1,144 @@ +/* + * Copyright 2012-2023 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.Logger.Level; +import feign.Request.Options; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; +import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; +import java.util.List; + +/** + * 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 Object enrich(Object componentToEnrich, + Class capabilityToEnrich, + 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, + (target, capability) -> invoke(target, capability, capabilityToEnrich), + (component, enrichedComponent) -> enrichedComponent); + } + + static Object invoke(Object target, Capability capability, Class capabilityToEnrich) { + return Arrays.stream(capability.getClass().getMethods()) + .filter(method -> method.getName().equals("enrich")) + .filter(method -> method.getReturnType().isAssignableFrom(capabilityToEnrich)) + .findFirst() + .map(method -> { + try { + return 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 AsyncClient enrich(AsyncClient client) { + return client; + } + + default Retryer enrich(Retryer retryer) { + return retryer; + } + + default RequestInterceptor enrich(RequestInterceptor requestInterceptor) { + return requestInterceptor; + } + + default ResponseInterceptor enrich(ResponseInterceptor responseInterceptor) { + return responseInterceptor; + } + + default ResponseInterceptor.Chain enrich(ResponseInterceptor.Chain chain) { + return chain; + } + + 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 ErrorDecoder enrich(ErrorDecoder decoder) { + return decoder; + } + + default InvocationHandlerFactory enrich(InvocationHandlerFactory invocationHandlerFactory) { + return invocationHandlerFactory; + } + + default QueryMapEncoder enrich(QueryMapEncoder queryMapEncoder) { + return queryMapEncoder; + } + + default AsyncResponseHandler enrich(AsyncResponseHandler asyncResponseHandler) { + return asyncResponseHandler; + } + + default AsyncContextSupplier enrich(AsyncContextSupplier asyncContextSupplier) { + return asyncContextSupplier; + } + + default MethodInfoResolver enrich(MethodInfoResolver methodInfoResolver) { + return methodInfoResolver; + } +} diff --git a/core/src/main/java/feign/Client.java b/core/src/main/java/feign/Client.java new file mode 100644 index 000000000..93a17dd90 --- /dev/null +++ b/core/src/main/java/feign/Client.java @@ -0,0 +1,299 @@ +/* + * Copyright 2012-2024 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.ACCEPT_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 { + stream = connection.getInputStream(); + } + if (stream != null && this.isGzip(headers.get(CONTENT_ENCODING))) { + stream = new GZIPInputStream(stream); + } else if (stream != null && this.isDeflate(headers.get(CONTENT_ENCODING))) { + stream = new InflaterInputStream(stream); + } + 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); + } + } + // Avoid add "Accept-encoding" twice or more when "compression" option is enabled + else if (field.equals(ACCEPT_ENCODING)) { + connection.addRequestProperty(field, String.join(", ", request.headers().get(field))); + break; + } else { + connection.addRequestProperty(field, value); + } + } + } + // Some servers choke on the default accept string. + if (!hasAcceptHeader) { + connection.addRequestProperty("Accept", "*/*"); + } + + boolean hasEmptyBody = false; + byte[] body = request.body(); + if (body == null && request.httpMethod().isWithBody()) { + body = new byte[0]; + hasEmptyBody = true; + } + + if (body != null) { + /* + * Ignore disableRequestBuffering flag if the empty body was set, to ensure that internal + * retry logic applies to such requests. + */ + if (disableRequestBuffering && !hasEmptyBody) { + 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(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..da3999d73 --- /dev/null +++ b/core/src/main/java/feign/CollectionFormat.java @@ -0,0 +1,96 @@ +/* + * Copyright 2012-2023 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..2461587da --- /dev/null +++ b/core/src/main/java/feign/Contract.java @@ -0,0 +1,341 @@ +/* + * Copyright 2012-2023 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) || method.isAnnotationPresent(FeignIgnore.class)) { + 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 ("kotlin.coroutines.Continuation".equals(parameterTypes[i].getName())) { + data.ignoreParamater(i); + } + + if (parameterTypes[i] == URI.class) { + data.urlIndex(i); + } else if (!isHttpAnnotation + && !Request.Options.class.isAssignableFrom(parameterTypes[i])) { + 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.queryMapEncoder(queryMap.mapEncoder().instance()); + }); + 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..36f650f7d --- /dev/null +++ b/core/src/main/java/feign/DeclarativeContract.java @@ -0,0 +1,263 @@ +/* + * Copyright 2012-2023 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 that defines the annotations modifies {@link MethodMetadata} + */ + protected void registerMethodAnnotation(Predicate predicate, + DeclarativeContract.AnnotationProcessor processor) { + this.methodAnnotationProcessors.add(new GuardedAnnotationProcessor(predicate, processor)); + } + + /** + * Called while method parameter annotations are being processed + * + * @param annotation to be processed + * @param processor function that defines the annotations modifies {@link MethodMetadata} + */ + protected void registerParameterAnnotation(Class annotation, + DeclarativeContract.ParameterAnnotationProcessor processor) { + this.parameterAnnotationProcessors.put((Class) annotation, + (DeclarativeContract.ParameterAnnotationProcessor) processor); + } + + @FunctionalInterface + public interface AnnotationProcessor { + + /** + * @param annotation present on the current element. + * @param metadata collected so far relating to the current java method. + */ + void process(E annotation, MethodMetadata metadata); + } + + @FunctionalInterface + public interface ParameterAnnotationProcessor { + + DeclarativeContract.ParameterAnnotationProcessor DO_NOTHING = (ann, data, i) -> { + }; + + /** + * @param annotation present on the current parameter annotation. + * @param metadata metadata collected so far relating to the current java method. + * @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. + */ + void process(E annotation, MethodMetadata metadata, int paramIndex); + } + + private class GuardedAnnotationProcessor + implements Predicate, DeclarativeContract.AnnotationProcessor { + + private final Predicate predicate; + private final DeclarativeContract.AnnotationProcessor processor; + + @SuppressWarnings({"rawtypes", "unchecked"}) + private GuardedAnnotationProcessor(Predicate predicate, + DeclarativeContract.AnnotationProcessor processor) { + this.predicate = predicate; + this.processor = processor; + } + + @Override + public void process(Annotation annotation, MethodMetadata metadata) { + processor.process(annotation, metadata); + } + + @Override + public boolean test(Annotation t) { + return predicate.test(t); + } + + } + +} diff --git a/core/src/main/java/feign/DefaultMethodHandler.java b/core/src/main/java/feign/DefaultMethodHandler.java new file mode 100644 index 000000000..19a5f8688 --- /dev/null +++ b/core/src/main/java/feign/DefaultMethodHandler.java @@ -0,0 +1,143 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.InvocationHandlerFactory.MethodHandler; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodHandles.Lookup; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Handles default methods by directly invoking the default method code on the interface. The bindTo + * method must be called on the result before invoke is called. + */ +final class DefaultMethodHandler implements MethodHandler { + // Uses Java 7 MethodHandle based reflection. As default methods will only exist when + // run on a Java 8 JVM this will not affect use on legacy JVMs. + // When Feign upgrades to Java 7, remove the @IgnoreJRERequirement annotation. + private final MethodHandle unboundHandle; + + // handle is effectively final after bindTo has been called... + private MethodHandle handle; + + public DefaultMethodHandler(Method defaultMethod) { + Class declaringClass = defaultMethod.getDeclaringClass(); + + try { + Lookup lookup = readLookup(declaringClass); + this.unboundHandle = lookup.unreflectSpecial(defaultMethod, declaringClass); + } catch (NoSuchFieldException | IllegalAccessException | IllegalArgumentException + | InvocationTargetException ex) { + throw new IllegalStateException(ex); + } + + } + + private Lookup readLookup(Class declaringClass) + throws IllegalAccessException, InvocationTargetException, NoSuchFieldException { + try { + return safeReadLookup(declaringClass); + } catch (NoSuchMethodException e) { + try { + return androidLookup(declaringClass); + } catch (InstantiationException | NoSuchMethodException instantiationException) { + return legacyReadLookup(); + } + } + } + + public Lookup androidLookup(Class declaringClass) throws InstantiationException, + InvocationTargetException, NoSuchMethodException, IllegalAccessException { + Lookup lookup; + try { + // Android 9+ double reflection + Class classReference = Class.class; + Class[] classType = new Class[] {Class.class}; + Method reflectedGetDeclaredConstructor = classReference.getDeclaredMethod( + "getDeclaredConstructor", + Class[].class); + reflectedGetDeclaredConstructor.setAccessible(true); + Constructor someHiddenMethod = + (Constructor) reflectedGetDeclaredConstructor.invoke(Lookup.class, (Object) classType); + lookup = (Lookup) someHiddenMethod.newInstance(declaringClass); + } catch (IllegalAccessException ex0) { + // Android < 9 reflection + Constructor lookupConstructor = Lookup.class.getDeclaredConstructor(Class.class); + lookupConstructor.setAccessible(true); + lookup = lookupConstructor.newInstance(declaringClass); + } + return (lookup); + } + + + /** + * equivalent to: + * + *
+   * return MethodHandles.privateLookupIn(declaringClass, MethodHandles.lookup());
+   * 
+ * + * @param declaringClass + * @return + * @throws IllegalAccessException + * @throws InvocationTargetException + * @throws NoSuchMethodException + */ + private Lookup safeReadLookup(Class declaringClass) + throws IllegalAccessException, InvocationTargetException, NoSuchMethodException { + Lookup lookup = MethodHandles.lookup(); + + Object privateLookupIn = + MethodHandles.class.getMethod("privateLookupIn", Class.class, Lookup.class) + .invoke(null, declaringClass, lookup); + return (Lookup) privateLookupIn; + } + + private Lookup legacyReadLookup() throws NoSuchFieldException, IllegalAccessException { + Field field = Lookup.class.getDeclaredField("IMPL_LOOKUP"); + field.setAccessible(true); + Lookup lookup = (Lookup) field.get(null); + return lookup; + } + + /** + * Bind this handler to a proxy object. After bound, DefaultMethodHandler#invoke will act as if it + * was called on the proxy object. Must be called once and only once for a given instance of + * DefaultMethodHandler + */ + public void bindTo(Object proxy) { + if (handle != null) { + throw new IllegalStateException( + "Attempted to rebind a default method handler that was already bound"); + } + handle = unboundHandle.bindTo(proxy); + } + + /** + * Invoke this method. DefaultMethodHandler#bindTo must be called before the first time invoke is + * called. + */ + @Override + public Object invoke(Object[] argv) throws Throwable { + if (handle == null) { + throw new IllegalStateException( + "Default method handler invoked before proxy has been bound."); + } + return handle.invokeWithArguments(argv); + } +} diff --git a/core/src/main/java/feign/ExceptionPropagationPolicy.java b/core/src/main/java/feign/ExceptionPropagationPolicy.java new file mode 100644 index 000000000..fb6d6c06b --- /dev/null +++ b/core/src/main/java/feign/ExceptionPropagationPolicy.java @@ -0,0 +1,18 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +public enum ExceptionPropagationPolicy { + NONE, UNWRAP +} diff --git a/core/src/main/java/feign/Experimental.java b/core/src/main/java/feign/Experimental.java new file mode 100644 index 000000000..66d569d7f --- /dev/null +++ b/core/src/main/java/feign/Experimental.java @@ -0,0 +1,44 @@ +/* + * Copyright 2012-2023 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.*; +import java.lang.annotation.Target; + +/** + * Indicates that a public API (public class, method or field) is subject to incompatible changes, + * or even removal, in a future release. An API bearing this annotation is exempt from any + * compatibility guarantees made by its containing library. Note that the presence of this + * annotation implies nothing about the quality or performance of the API in question, only the fact + * that it is not "API-frozen." + * + *

+ * It is generally safe for applications to depend on beta APIs, at the cost of some extra + * work during upgrades. However it is generally inadvisable for libraries (which get + * included on users' CLASSPATHs, outside the library developers' control) to do so. + * + * "Inspired" on guava @Beta + */ +@Retention(RetentionPolicy.CLASS) +@Target({ + ElementType.ANNOTATION_TYPE, + ElementType.CONSTRUCTOR, + ElementType.FIELD, + ElementType.METHOD, + ElementType.TYPE +}) +@Documented +public @interface Experimental { + +} diff --git a/core/src/main/java/feign/Feign.java b/core/src/main/java/feign/Feign.java new file mode 100644 index 000000000..b107af9f8 --- /dev/null +++ b/core/src/main/java/feign/Feign.java @@ -0,0 +1,234 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Request.Options; +import feign.Target.HardCodedTarget; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; +import java.io.IOException; +import java.lang.reflect.Method; +import java.lang.reflect.Type; + +/** + * Feign's purpose is to ease development against http apis that feign restfulness.
+ * In implementation, Feign is a {@link Feign#newInstance factory} for generating {@link Target + * targeted} http apis. + */ +public abstract class Feign { + + public static Builder builder() { + return new Builder(); + } + + /** + * Configuration keys are formatted as unresolved see + * tags. This method exposes that format, in case you need to create the same value as + * {@link MethodMetadata#configKey()} for correlation purposes. + * + *

+ * Here are some sample encodings: + * + *

+   * 
    + *
  • {@code + * Route53 + * }: would match a class {@code + * route53.Route53 + * }
  • + *
  • {@code Route53#list()}: would match a method {@code route53.Route53#list()}
  • + *
  • {@code Route53#listAt(Marker)}: would match a method {@code + * route53.Route53#listAt(Marker)}
  • + *
  • {@code Route53#listByNameAndType(String, String)}: would match a method {@code + * route53.Route53#listAt(String, String)}
  • + *
+ *
+ * + * Note that there is no whitespace expected in a key! + * + * @param targetType {@link feign.Target#type() type} of the Feign interface. + * @param method invoked method, present on {@code type} or its super. + * @see MethodMetadata#configKey() + */ + public static String configKey(Class targetType, Method method) { + StringBuilder builder = new StringBuilder(); + builder.append(targetType.getSimpleName()); + builder.append('#').append(method.getName()).append('('); + for (Type param : method.getGenericParameterTypes()) { + param = Types.resolve(targetType, targetType, param); + builder.append(Types.getRawType(param).getSimpleName()).append(','); + } + if (method.getParameterTypes().length > 0) { + builder.deleteCharAt(builder.length() - 1); + } + return builder.append(')').toString(); + } + + /** + * @deprecated use {@link #configKey(Class, Method)} instead. + */ + @Deprecated + public static String configKey(Method method) { + return configKey(method.getDeclaringClass(), method); + } + + /** + * Returns a new instance of an HTTP API, defined by annotations in the {@link Feign Contract}, + * for the specified {@code target}. You should cache this result. + */ + public abstract T newInstance(Target target); + + public static class Builder extends BaseBuilder { + + private Client client = new Client.Default(null, null); + + @Override + public Builder logLevel(Logger.Level logLevel) { + return super.logLevel(logLevel); + } + + @Override + public Builder contract(Contract contract) { + return super.contract(contract); + } + + public Builder client(Client client) { + this.client = client; + + return this; + } + + @Override + public Builder retryer(Retryer retryer) { + return super.retryer(retryer); + } + + @Override + public Builder logger(Logger logger) { + return super.logger(logger); + } + + @Override + public Builder encoder(Encoder encoder) { + return super.encoder(encoder); + } + + @Override + public Builder decoder(Decoder decoder) { + return super.decoder(decoder); + } + + @Override + public Builder queryMapEncoder(QueryMapEncoder queryMapEncoder) { + return super.queryMapEncoder(queryMapEncoder); + } + + @Override + public Builder mapAndDecode(ResponseMapper mapper, Decoder decoder) { + return super.mapAndDecode(mapper, decoder); + } + + @Deprecated + @Override + public Builder decode404() { + return super.decode404(); + } + + @Override + public Builder errorDecoder(ErrorDecoder errorDecoder) { + return super.errorDecoder(errorDecoder); + } + + @Override + public Builder options(Options options) { + return super.options(options); + } + + @Override + public Builder requestInterceptor(RequestInterceptor requestInterceptor) { + return super.requestInterceptor(requestInterceptor); + } + + @Override + public Builder requestInterceptors(Iterable requestInterceptors) { + return super.requestInterceptors(requestInterceptors); + } + + @Override + public Builder invocationHandlerFactory(InvocationHandlerFactory invocationHandlerFactory) { + return super.invocationHandlerFactory(invocationHandlerFactory); + } + + @Override + public Builder doNotCloseAfterDecode() { + return super.doNotCloseAfterDecode(); + } + + @Override + public Builder decodeVoid() { + return super.decodeVoid(); + } + + @Override + public Builder exceptionPropagationPolicy(ExceptionPropagationPolicy propagationPolicy) { + return super.exceptionPropagationPolicy(propagationPolicy); + } + + @Override + public Builder addCapability(Capability capability) { + return super.addCapability(capability); + } + + public T target(Class apiType, String url) { + return target(new HardCodedTarget<>(apiType, url)); + } + + public T target(Target target) { + return build().newInstance(target); + } + + @Override + public Feign internalBuild() { + final ResponseHandler responseHandler = + new ResponseHandler(logLevel, logger, decoder, errorDecoder, + dismiss404, closeAfterDecode, decodeVoid, responseInterceptorChain()); + MethodHandler.Factory methodHandlerFactory = + new SynchronousMethodHandler.Factory(client, retryer, requestInterceptors, + responseHandler, logger, logLevel, propagationPolicy, + new RequestTemplateFactoryResolver(encoder, queryMapEncoder), + options); + return new ReflectiveFeign<>(contract, methodHandlerFactory, invocationHandlerFactory, + () -> null); + } + } + + public static class ResponseMappingDecoder implements Decoder { + + private final ResponseMapper mapper; + private final Decoder delegate; + + public ResponseMappingDecoder(ResponseMapper mapper, Decoder decoder) { + this.mapper = mapper; + this.delegate = decoder; + } + + @Override + public Object decode(Response response, Type type) throws IOException { + return delegate.decode(mapper.map(response, type), type); + } + } +} diff --git a/core/src/main/java/feign/FeignException.java b/core/src/main/java/feign/FeignException.java new file mode 100644 index 000000000..327095918 --- /dev/null +++ b/core/src/main/java/feign/FeignException.java @@ -0,0 +1,541 @@ +/* + * Copyright 2012-2024 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import static feign.Util.*; +import static java.lang.String.format; +import static java.util.regex.Pattern.CASE_INSENSITIVE; + +/** + * Origin exception type for all Http Apis. + */ +public class FeignException extends RuntimeException { + + private static final String EXCEPTION_MESSAGE_TEMPLATE_NULL_REQUEST = + "request should not be null"; + private static final long serialVersionUID = 0; + private final int status; + private byte[] responseBody; + private Map> responseHeaders; + private final Request request; + + protected FeignException(int status, String message, Throwable cause) { + super(message, cause); + this.status = status; + this.request = null; + } + + protected FeignException(int status, String message, Throwable cause, byte[] responseBody, + Map> responseHeaders) { + super(message, cause); + this.status = status; + this.responseBody = responseBody; + this.responseHeaders = caseInsensitiveCopyOf(responseHeaders); + this.request = null; + } + + protected FeignException(int status, String message) { + super(message); + this.status = status; + this.request = null; + } + + protected FeignException(int status, String message, byte[] responseBody, + Map> responseHeaders) { + super(message); + this.status = status; + this.responseBody = responseBody; + this.responseHeaders = caseInsensitiveCopyOf(responseHeaders); + this.request = null; + } + + protected FeignException(int status, String message, Request request, Throwable cause) { + super(message, cause); + this.status = status; + this.request = checkRequestNotNull(request); + } + + protected FeignException(int status, String message, Request request, Throwable cause, + byte[] responseBody, Map> responseHeaders) { + super(message, cause); + this.status = status; + this.responseBody = responseBody; + this.responseHeaders = caseInsensitiveCopyOf(responseHeaders); + this.request = checkRequestNotNull(request); + } + + protected FeignException(int status, String message, Request request) { + super(message); + this.status = status; + this.request = checkRequestNotNull(request); + } + + protected FeignException(int status, String message, Request request, byte[] responseBody, + Map> responseHeaders) { + super(message); + this.status = status; + this.responseBody = responseBody; + this.responseHeaders = caseInsensitiveCopyOf(responseHeaders); + this.request = checkRequestNotNull(request); + } + + private Request checkRequestNotNull(Request request) { + return checkNotNull(request, EXCEPTION_MESSAGE_TEMPLATE_NULL_REQUEST); + } + + public int status() { + return this.status; + } + + /** + * The Response Body, if present. + * + * @return the body of the response. + * @deprecated use {@link #responseBody()} instead. + */ + @Deprecated + public byte[] content() { + return this.responseBody; + } + + /** + * The Response body. + * + * @return an Optional wrapping the response body. + */ + public Optional responseBody() { + if (this.responseBody == null) { + return Optional.empty(); + } + return Optional.of(ByteBuffer.wrap(this.responseBody)); + } + + public Map> responseHeaders() { + if (this.responseHeaders == null) { + return Collections.emptyMap(); + } + return responseHeaders; + } + + public Request request() { + return this.request; + } + + public boolean hasRequest() { + return (this.request != null); + } + + public String contentUTF8() { + if (responseBody != null) { + return new String(responseBody, UTF_8); + } else { + return ""; + } + } + + static FeignException errorReading(Request request, Response response, IOException cause) { + return new FeignException( + response.status(), + format("%s reading %s %s", cause.getMessage(), request.httpMethod(), request.url()), + request, + cause, + request.body(), + request.headers()); + } + + public static FeignException errorStatus(String methodKey, Response response) { + return errorStatus(methodKey, response, null, null); + } + + public static FeignException errorStatus(String methodKey, + Response response, + Integer maxBodyBytesLength, + Integer maxBodyCharsLength) { + + byte[] body = {}; + try { + if (response.body() != null) { + body = Util.toByteArray(response.body().asInputStream()); + } + } catch (IOException ignored) { // NOPMD + } + + String message = new FeignExceptionMessageBuilder() + .withResponse(response) + .withMethodKey(methodKey) + .withMaxBodyBytesLength(maxBodyBytesLength) + .withMaxBodyCharsLength(maxBodyCharsLength) + .withBody(body).build(); + + return errorStatus(response.status(), message, response.request(), body, response.headers()); + } + + private static FeignException errorStatus(int status, + String message, + Request request, + byte[] body, + Map> headers) { + if (isClientError(status)) { + return clientErrorStatus(status, message, request, body, headers); + } + if (isServerError(status)) { + return serverErrorStatus(status, message, request, body, headers); + } + return new FeignException(status, message, request, body, headers); + } + + private static boolean isClientError(int status) { + return status >= 400 && status < 500; + } + + private static FeignClientException clientErrorStatus(int status, + String message, + Request request, + byte[] body, + Map> headers) { + switch (status) { + case 400: + return new BadRequest(message, request, body, headers); + case 401: + return new Unauthorized(message, request, body, headers); + case 403: + return new Forbidden(message, request, body, headers); + case 404: + return new NotFound(message, request, body, headers); + case 405: + return new MethodNotAllowed(message, request, body, headers); + case 406: + return new NotAcceptable(message, request, body, headers); + case 409: + return new Conflict(message, request, body, headers); + case 410: + return new Gone(message, request, body, headers); + case 415: + return new UnsupportedMediaType(message, request, body, headers); + case 429: + return new TooManyRequests(message, request, body, headers); + case 422: + return new UnprocessableEntity(message, request, body, headers); + default: + return new FeignClientException(status, message, request, body, headers); + } + } + + private static boolean isServerError(int status) { + return status >= 500 && status <= 599; + } + + private static FeignServerException serverErrorStatus(int status, + String message, + Request request, + byte[] body, + Map> headers) { + switch (status) { + case 500: + return new InternalServerError(message, request, body, headers); + case 501: + return new NotImplemented(message, request, body, headers); + case 502: + return new BadGateway(message, request, body, headers); + case 503: + return new ServiceUnavailable(message, request, body, headers); + case 504: + return new GatewayTimeout(message, request, body, headers); + default: + return new FeignServerException(status, message, request, body, headers); + } + } + + static FeignException errorExecuting(Request request, IOException cause) { + final Long nonRetryable = null; + return new RetryableException( + -1, + format("%s executing %s %s", cause.getMessage(), request.httpMethod(), request.url()), + request.httpMethod(), + cause, + nonRetryable, request); + } + + public static class FeignClientException extends FeignException { + public FeignClientException(int status, String message, Request request, byte[] body, + Map> headers) { + super(status, message, request, body, headers); + } + } + + + public static class BadRequest extends FeignClientException { + public BadRequest(String message, Request request, byte[] body, + Map> headers) { + super(400, message, request, body, headers); + } + } + + + public static class Unauthorized extends FeignClientException { + public Unauthorized(String message, Request request, byte[] body, + Map> headers) { + super(401, message, request, body, headers); + } + } + + + public static class Forbidden extends FeignClientException { + public Forbidden(String message, Request request, byte[] body, + Map> headers) { + super(403, message, request, body, headers); + } + } + + + public static class NotFound extends FeignClientException { + public NotFound(String message, Request request, byte[] body, + Map> headers) { + super(404, message, request, body, headers); + } + } + + + public static class MethodNotAllowed extends FeignClientException { + public MethodNotAllowed(String message, Request request, byte[] body, + Map> headers) { + super(405, message, request, body, headers); + } + } + + + public static class NotAcceptable extends FeignClientException { + public NotAcceptable(String message, Request request, byte[] body, + Map> headers) { + super(406, message, request, body, headers); + } + } + + + public static class Conflict extends FeignClientException { + public Conflict(String message, Request request, byte[] body, + Map> headers) { + super(409, message, request, body, headers); + } + } + + + public static class Gone extends FeignClientException { + public Gone(String message, Request request, byte[] body, + Map> headers) { + super(410, message, request, body, headers); + } + } + + + public static class UnsupportedMediaType extends FeignClientException { + public UnsupportedMediaType(String message, Request request, byte[] body, + Map> headers) { + super(415, message, request, body, headers); + } + } + + + public static class TooManyRequests extends FeignClientException { + public TooManyRequests(String message, Request request, byte[] body, + Map> headers) { + super(429, message, request, body, headers); + } + } + + + public static class UnprocessableEntity extends FeignClientException { + public UnprocessableEntity(String message, Request request, byte[] body, + Map> headers) { + super(422, message, request, body, headers); + } + } + + + public static class FeignServerException extends FeignException { + public FeignServerException(int status, String message, Request request, byte[] body, + Map> headers) { + super(status, message, request, body, headers); + } + } + + + public static class InternalServerError extends FeignServerException { + public InternalServerError(String message, Request request, byte[] body, + Map> headers) { + super(500, message, request, body, headers); + } + } + + + public static class NotImplemented extends FeignServerException { + public NotImplemented(String message, Request request, byte[] body, + Map> headers) { + super(501, message, request, body, headers); + } + } + + + public static class BadGateway extends FeignServerException { + public BadGateway(String message, Request request, byte[] body, + Map> headers) { + super(502, message, request, body, headers); + } + } + + + public static class ServiceUnavailable extends FeignServerException { + public ServiceUnavailable(String message, Request request, byte[] body, + Map> headers) { + super(503, message, request, body, headers); + } + } + + + public static class GatewayTimeout extends FeignServerException { + public GatewayTimeout(String message, Request request, byte[] body, + Map> headers) { + super(504, message, request, body, headers); + } + } + + + private static class FeignExceptionMessageBuilder { + + private static final int MAX_BODY_BYTES_LENGTH = 400; + private static final int MAX_BODY_CHARS_LENGTH = 200; + + private Response response; + + private byte[] body; + private String methodKey; + private Integer maxBodyBytesLength; + private Integer maxBodyCharsLength; + + public FeignExceptionMessageBuilder withResponse(Response response) { + this.response = response; + return this; + } + + public FeignExceptionMessageBuilder withBody(byte[] body) { + this.body = body; + return this; + } + + public FeignExceptionMessageBuilder withMethodKey(String methodKey) { + this.methodKey = methodKey; + return this; + } + + public FeignExceptionMessageBuilder withMaxBodyBytesLength(Integer length) { + this.maxBodyBytesLength = length; + return this; + } + + public FeignExceptionMessageBuilder withMaxBodyCharsLength(Integer length) { + this.maxBodyCharsLength = length; + return this; + } + + public String build() { + StringBuilder result = new StringBuilder(); + + if (maxBodyBytesLength == null) { + maxBodyBytesLength = MAX_BODY_BYTES_LENGTH; + } + if (maxBodyCharsLength == null) { + maxBodyCharsLength = MAX_BODY_CHARS_LENGTH; + } + if (response.reason() != null) { + result.append(format("[%d %s]", response.status(), response.reason())); + } else { + result.append(format("[%d]", response.status())); + } + result.append(format(" during [%s] to [%s] [%s]", response.request().httpMethod(), + response.request().url(), methodKey)); + + result.append(format(": [%s]", getBodyAsString(body, response.headers()))); + + return result.toString(); + } + + private String getBodyAsString(byte[] body, Map> headers) { + Charset charset = getResponseCharset(headers); + if (charset == null) { + charset = Util.UTF_8; + } + return getResponseBody(body, charset); + } + + private String getResponseBody(byte[] body, Charset charset) { + if (body.length < maxBodyBytesLength) { + return new String(body, charset); + } + return getResponseBodyPreview(body, charset); + } + + private String getResponseBodyPreview(byte[] body, Charset charset) { + try { + Reader reader = new InputStreamReader(new ByteArrayInputStream(body), charset); + CharBuffer result = CharBuffer.allocate(maxBodyCharsLength); + + reader.read(result); + reader.close(); + ((Buffer) result).flip(); + return result + "... (" + body.length + " bytes)"; + } catch (IOException e) { + return e + ", failed to parse response"; + } + } + + private static Charset getResponseCharset(Map> headers) { + + Collection strings = headers.get("content-type"); + if (strings == null || strings.isEmpty()) { + return null; + } + + Pattern pattern = Pattern.compile(".*charset=\"?([^\\s|^;|^\"]+).*", CASE_INSENSITIVE); + Matcher matcher = pattern.matcher(strings.iterator().next()); + if (!matcher.lookingAt()) { + return null; + } + + String group = matcher.group(1); + try { + if (!Charset.isSupported(group)) { + return null; + } + } catch (IllegalCharsetNameException ex) { + return null; + } + return Charset.forName(group); + } + } +} diff --git a/core/src/main/java/feign/FeignIgnore.java b/core/src/main/java/feign/FeignIgnore.java new file mode 100644 index 000000000..f65195f18 --- /dev/null +++ b/core/src/main/java/feign/FeignIgnore.java @@ -0,0 +1,26 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.lang.annotation.Retention; +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Indicates that method will be ignored + */ +@Retention(RUNTIME) +@java.lang.annotation.Target({METHOD}) +public @interface FeignIgnore { +} diff --git a/core/src/main/java/feign/HeaderMap.java b/core/src/main/java/feign/HeaderMap.java new file mode 100644 index 000000000..9767e5b06 --- /dev/null +++ b/core/src/main/java/feign/HeaderMap.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.lang.annotation.Retention; +import java.util.List; +import java.util.Map; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * A template parameter that can be applied to a Map that contains header entries, where the keys + * are Strings that are the header field names and the values are the header field values. The + * headers specified by the map will be applied to the request after all other processing, and will + * take precedence over any previously specified header parameters.
+ * This parameter is useful in cases where different header fields and values need to be set on an + * API method on a per-request basis in a thread-safe manner and independently of Feign client + * construction. A concrete example of a case like this are custom metadata header fields (e.g. as + * "x-amz-meta-*" or "x-goog-meta-*") where the header field names are dynamic and the range of keys + * cannot be determined a priori. The {@link Headers} annotation does not allow this because the + * header fields that it defines are static (it is not possible to add or remove fields on a + * per-request basis), and doing this using a custom {@link Target} or {@link RequestInterceptor} + * can be cumbersome (it requires more code for per-method customization, it is difficult to + * implement in a thread-safe manner and it requires customization when the Feign client for the API + * is built).
+ * + *
+ * ...
+ * @RequestLine("GET /servers/{serverId}")
+ * void get(@Param("serverId") String serverId, @HeaderMap Map);
+ * ...
+ * 
+ * + * The annotated parameter must be an instance of {@link Map}, and the keys must be Strings. The + * header field value of a key will be the value of its toString method, except in the following + * cases:
+ *
+ *
    + *
  • if the value is null, the value will remain null (rather than converting to the String + * "null") + *
  • if the value is an {@link Iterable}, it is converted to a {@link List} of String objects + * where each value in the list is either null if the original value was null or the value's + * toString representation otherwise. + *
+ *
+ * Once this conversion is applied, the query keys and resulting String values follow the same + * contract as if they were set using {@link RequestTemplate#header(String, String...)}. + */ +@Retention(RUNTIME) +@java.lang.annotation.Target(PARAMETER) +public @interface HeaderMap { +} diff --git a/core/src/main/java/feign/Headers.java b/core/src/main/java/feign/Headers.java new file mode 100644 index 000000000..788036b4b --- /dev/null +++ b/core/src/main/java/feign/Headers.java @@ -0,0 +1,78 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Expands headers supplied in the {@code value}. Variables to the the right of the colon are + * expanded.
+ * + *
+ * @Headers("Content-Type: application/xml")
+ * interface SoapApi {
+ * ...   
+ * @RequestLine("GET /")
+ * @Headers("Cache-Control: max-age=640000")
+ * ...
+ *
+ * @RequestLine("POST /")
+ * @Headers({
+ *   "X-Foo: Bar",
+ *   "X-Ping: {token}"
+ * }) void post(@Param("token") String token);
+ * ...
+ * 
+ * + *
+ * Notes: + *
    + *
  • If you'd like curly braces literally in the header, urlencode them first.
  • + *
  • Headers do not overwrite each other. All headers with the same name will be included in the + * request.
  • + *
+ *
+ * Relationship to JAXRS
+ *
+ * The following two forms are identical.
+ *
+ * Feign: + * + *
+ * @RequestLine("POST /")
+ * @Headers({
+ *   "X-Ping: {token}"
+ * }) void post(@Named("token") String token);
+ * ...
+ * 
+ * + *
+ * JAX-RS: + * + *
+ * @POST @Path("/")
+ * void post(@HeaderParam("X-Ping") String token);
+ * ...
+ * 
+ */ +@Target({METHOD, TYPE}) +@Retention(RUNTIME) +public @interface Headers { + + String[] value(); +} diff --git a/core/src/main/java/feign/InvocationContext.java b/core/src/main/java/feign/InvocationContext.java new file mode 100755 index 000000000..3c57666b0 --- /dev/null +++ b/core/src/main/java/feign/InvocationContext.java @@ -0,0 +1,137 @@ +/* + * Copyright 2012-2023 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 feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; +import java.io.IOException; +import java.lang.reflect.Type; + +public class InvocationContext { + private static final long MAX_RESPONSE_BUFFER_SIZE = 8192L; + private final String configKey; + private final Decoder decoder; + private final ErrorDecoder errorDecoder; + private final boolean dismiss404; + private final boolean closeAfterDecode; + private final boolean decodeVoid; + private final Response response; + private final Type returnType; + + InvocationContext(String configKey, Decoder decoder, ErrorDecoder errorDecoder, + boolean dismiss404, boolean closeAfterDecode, boolean decodeVoid, Response response, + Type returnType) { + this.configKey = configKey; + this.decoder = decoder; + this.errorDecoder = errorDecoder; + this.dismiss404 = dismiss404; + this.closeAfterDecode = closeAfterDecode; + this.decodeVoid = decodeVoid; + this.response = response; + this.returnType = returnType; + } + + public Decoder decoder() { + return decoder; + } + + public Type returnType() { + return returnType; + } + + public Response response() { + return response; + } + + public Object proceed() throws Exception { + if (returnType == Response.class) { + return disconnectResponseBodyIfNeeded(response); + } + + try { + final boolean shouldDecodeResponseBody = + (response.status() >= 200 && response.status() < 300) + || (response.status() == 404 && dismiss404 + && !isVoidType(returnType)); + + if (!shouldDecodeResponseBody) { + throw decodeError(configKey, response); + } + + if (isVoidType(returnType) && !decodeVoid) { + ensureClosed(response.body()); + return null; + } + + Class rawType = Types.getRawType(returnType); + if (TypedResponse.class.isAssignableFrom(rawType)) { + Type bodyType = Types.resolveLastTypeParameter(returnType, TypedResponse.class); + return TypedResponse.builder(response) + .body(decode(response, bodyType)) + .build(); + } + + return decode(response, returnType); + } finally { + if (closeAfterDecode) { + ensureClosed(response.body()); + } + } + } + + private static Response disconnectResponseBodyIfNeeded(Response response) throws IOException { + final boolean shouldDisconnectResponseBody = response.body() != null + && response.body().length() != null + && response.body().length() <= MAX_RESPONSE_BUFFER_SIZE; + if (!shouldDisconnectResponseBody) { + return response; + } + + try { + final byte[] bodyData = Util.toByteArray(response.body().asInputStream()); + return response.toBuilder().body(bodyData).build(); + } finally { + ensureClosed(response.body()); + } + } + + private Object decode(Response response, Type returnType) { + try { + return decoder.decode(response, returnType); + } catch (final FeignException e) { + throw e; + } catch (final RuntimeException e) { + throw new DecodeException(response.status(), e.getMessage(), response.request(), e); + } catch (IOException e) { + throw errorReading(response.request(), response, e); + } + } + + private Exception decodeError(String methodKey, Response response) { + try { + return errorDecoder.decode(methodKey, response); + } finally { + ensureClosed(response.body()); + } + } + + private boolean isVoidType(Type returnType) { + return returnType == Void.class + || returnType == void.class + || returnType.getTypeName().equals("kotlin.Unit"); + } +} diff --git a/core/src/main/java/feign/InvocationHandlerFactory.java b/core/src/main/java/feign/InvocationHandlerFactory.java new file mode 100644 index 000000000..1a4eee97a --- /dev/null +++ b/core/src/main/java/feign/InvocationHandlerFactory.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2023 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.codec.Decoder; +import feign.codec.ErrorDecoder; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.util.Map; + +/** + * Controls reflective method dispatch. + */ +public interface InvocationHandlerFactory { + + InvocationHandler create(Target target, Map dispatch); + + /** + * Like {@link InvocationHandler#invoke(Object, java.lang.reflect.Method, Object[])}, except for a + * single method. + */ + interface MethodHandler { + + Object invoke(Object[] argv) throws Throwable; + + interface Factory { + MethodHandler create(Target target, + MethodMetadata md, + C requestContext); + } + } + + static final class Default implements InvocationHandlerFactory { + + @Override + public InvocationHandler create(Target target, Map dispatch) { + return new ReflectiveFeign.FeignInvocationHandler(target, dispatch); + } + } +} diff --git a/core/src/main/java/feign/Logger.java b/core/src/main/java/feign/Logger.java new file mode 100644 index 000000000..6e190530a --- /dev/null +++ b/core/src/main/java/feign/Logger.java @@ -0,0 +1,307 @@ +/* + * Copyright 2012-2024 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import static feign.Util.UTF_8; +import static feign.Util.decodeOrDefault; +import static feign.Util.ensureClosed; +import static feign.Util.valuesOrEmpty; +import static java.util.Objects.nonNull; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.logging.FileHandler; +import java.util.logging.LogRecord; +import java.util.logging.SimpleFormatter; + +/** + * Simple logging abstraction for debug messages. Adapted from {@code retrofit.RestAdapter.Log}. + */ +public abstract class Logger { + + protected static String methodTag(String configKey) { + return '[' + configKey.substring(0, configKey.indexOf('(')) + "] "; + } + + /** + * Override to log requests and responses using your own implementation. Messages will be http + * request and response text. + * + * @param configKey value of {@link Feign#configKey(Class, java.lang.reflect.Method)} + * @param format {@link java.util.Formatter format string} + * @param args arguments applied to {@code format} + */ + protected abstract void log(String configKey, String format, Object... args); + + /** + * Override to filter out request headers. + * + * @param header header name + * @return true to log a request header + */ + protected boolean shouldLogRequestHeader(String header) { + return true; + } + + /** + * Override to filter out response headers. + * + * @param header header name + * @return true to log a response header + */ + protected boolean shouldLogResponseHeader(String header) { + return true; + } + + protected void logRequest(String configKey, Level logLevel, Request request) { + String protocolVersion = resolveProtocolVersion(request.protocolVersion()); + log(configKey, "---> %s %s %s", request.httpMethod().name(), request.url(), + protocolVersion); + if (logLevel.ordinal() >= Level.HEADERS.ordinal()) { + + for (String field : request.headers().keySet()) { + if (shouldLogRequestHeader(field)) { + for (String value : valuesOrEmpty(request.headers(), field)) { + log(configKey, "%s: %s", field, value); + } + } + } + + int bodyLength = 0; + if (request.body() != null) { + bodyLength = request.length(); + if (logLevel.ordinal() >= Level.FULL.ordinal()) { + String bodyText = + request.charset() != null + ? new String(request.body(), request.charset()) + : null; + log(configKey, ""); // CRLF + log(configKey, "%s", bodyText != null ? bodyText : "Binary data"); + } + } + log(configKey, "---> END HTTP (%s-byte body)", bodyLength); + } + } + + protected void logRetry(String configKey, Level logLevel) { + log(configKey, "---> RETRYING"); + } + + protected Response logAndRebufferResponse(String configKey, + Level logLevel, + Response response, + long elapsedTime) + throws IOException { + String protocolVersion = resolveProtocolVersion(response.protocolVersion()); + String reason = + response.reason() != null && logLevel.compareTo(Level.NONE) > 0 ? " " + response.reason() + : ""; + int status = response.status(); + log(configKey, "<--- %s %s%s (%sms)", protocolVersion, status, reason, elapsedTime); + if (logLevel.ordinal() >= Level.HEADERS.ordinal()) { + + for (String field : response.headers().keySet()) { + if (shouldLogResponseHeader(field)) { + for (String value : valuesOrEmpty(response.headers(), field)) { + log(configKey, "%s: %s", field, value); + } + } + } + + int bodyLength = 0; + if (response.body() != null && !(status == 204 || status == 205)) { + // HTTP 204 No Content "...response MUST NOT include a message-body" + // HTTP 205 Reset Content "...response MUST NOT include an entity" + if (logLevel.ordinal() >= Level.FULL.ordinal()) { + log(configKey, ""); // CRLF + } + byte[] bodyData = Util.toByteArray(response.body().asInputStream()); + ensureClosed(response.body()); + bodyLength = bodyData.length; + if (logLevel.ordinal() >= Level.FULL.ordinal() && bodyLength > 0) { + log(configKey, "%s", decodeOrDefault(bodyData, UTF_8, "Binary data")); + } + log(configKey, "<--- END HTTP (%s-byte body)", bodyLength); + return response.toBuilder().body(bodyData).build(); + } else { + log(configKey, "<--- END HTTP (%s-byte body)", bodyLength); + } + } + return response; + } + + protected IOException logIOException(String configKey, + Level logLevel, + IOException ioe, + long elapsedTime) { + log(configKey, "<--- ERROR %s: %s (%sms)", ioe.getClass().getSimpleName(), ioe.getMessage(), + elapsedTime); + if (logLevel.ordinal() >= Level.FULL.ordinal()) { + StringWriter sw = new StringWriter(); + ioe.printStackTrace(new PrintWriter(sw)); + log(configKey, "%s", sw.toString()); + log(configKey, "<--- END ERROR"); + } + return ioe; + } + + protected static String resolveProtocolVersion(Request.ProtocolVersion protocolVersion) { + if (nonNull(protocolVersion)) { + return protocolVersion.toString(); + } + return "UNKNOWN"; + } + + /** + * Controls the level of logging. + */ + public enum Level { + /** + * No logging. + */ + NONE, + /** + * Log only the request method and URL and the response status code and execution time. + */ + BASIC, + /** + * Log the basic information along with request and response headers. + */ + HEADERS, + /** + * Log the headers, body, and metadata for both requests and responses. + */ + FULL + } + + /** + * Logs to System.err. + */ + public static class ErrorLogger extends Logger { + @Override + protected void log(String configKey, String format, Object... args) { + System.err.printf(methodTag(configKey) + format + "%n", args); + } + } + + /** + * Logs to the category {@link Logger} at {@link java.util.logging.Level#FINE}, if loggable. + */ + public static class JavaLogger extends Logger { + + final java.util.logging.Logger logger; + + /** + * @deprecated Use {@link #JavaLogger(String)} or {@link #JavaLogger(Class)} instead. + * + * This constructor can be used to create just one logger. Example = + * {@code Logger.JavaLogger().appendToFile("logs/first.log")} + * + * If you create multiple loggers for multiple clients and provide different files + * to write log - you'll have unexpected behavior - all clients will write same log + * to each file. + * + * That's why this constructor will be removed in future. + */ + @Deprecated + public JavaLogger() { + logger = java.util.logging.Logger.getLogger(Logger.class.getName()); + } + + /** + * Constructor for JavaLogger class + * + * @param loggerName a name for the logger. This should be a dot-separated name and should + * normally be based on the package name or class name of the subsystem, such as java.net + * or javax.swing + */ + public JavaLogger(String loggerName) { + logger = java.util.logging.Logger.getLogger(loggerName); + } + + /** + * Constructor for JavaLogger class + * + * @param clazz the returned logger will be named after clazz + */ + public JavaLogger(Class clazz) { + logger = java.util.logging.Logger.getLogger(clazz.getName()); + } + + @Override + protected void logRequest(String configKey, Level logLevel, Request request) { + if (logger.isLoggable(java.util.logging.Level.FINE)) { + super.logRequest(configKey, logLevel, request); + } + } + + @Override + protected Response logAndRebufferResponse(String configKey, + Level logLevel, + Response response, + long elapsedTime) + throws IOException { + if (logger.isLoggable(java.util.logging.Level.FINE)) { + return super.logAndRebufferResponse(configKey, logLevel, response, elapsedTime); + } + return response; + } + + @Override + protected void log(String configKey, String format, Object... args) { + if (logger.isLoggable(java.util.logging.Level.FINE)) { + logger.fine(String.format(methodTag(configKey) + format, args)); + } + } + + /** + * Helper that configures java.util.logging to sanely log messages at FINE level without + * additional formatting. + */ + public JavaLogger appendToFile(String logfile) { + logger.setLevel(java.util.logging.Level.FINE); + try { + FileHandler handler = new FileHandler(logfile, true); + handler.setFormatter(new SimpleFormatter() { + @Override + public String format(LogRecord record) { + return String.format("%s%n", record.getMessage()); // NOPMD + } + }); + logger.addHandler(handler); + } catch (IOException e) { + throw new IllegalStateException("Could not add file handler.", e); + } + return this; + } + } + + public static class NoOpLogger extends Logger { + + @Override + protected void logRequest(String configKey, Level logLevel, Request request) {} + + @Override + protected Response logAndRebufferResponse(String configKey, + Level logLevel, + Response response, + long elapsedTime) + throws IOException { + return response; + } + + @Override + protected void log(String configKey, String format, Object... args) {} + } +} diff --git a/core/src/main/java/feign/MethodHandlerConfiguration.java b/core/src/main/java/feign/MethodHandlerConfiguration.java new file mode 100644 index 000000000..0524235e0 --- /dev/null +++ b/core/src/main/java/feign/MethodHandlerConfiguration.java @@ -0,0 +1,93 @@ +/* + * Copyright 2012-2024 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.List; +import static feign.Util.checkNotNull; + +public class MethodHandlerConfiguration { + + private final MethodMetadata metadata; + + private final Target target; + + private final Retryer retryer; + + private final List requestInterceptors; + + private final Logger logger; + + private final Logger.Level logLevel; + + private final RequestTemplate.Factory buildTemplateFromArgs; + + private final Request.Options options; + + private final ExceptionPropagationPolicy propagationPolicy; + + public MethodMetadata getMetadata() { + return metadata; + } + + public Target getTarget() { + return target; + } + + public Retryer getRetryer() { + return retryer; + } + + public List getRequestInterceptors() { + return requestInterceptors; + } + + public Logger getLogger() { + return logger; + } + + public Logger.Level getLogLevel() { + return logLevel; + } + + public RequestTemplate.Factory getBuildTemplateFromArgs() { + return buildTemplateFromArgs; + } + + public Request.Options getOptions() { + return options; + } + + public ExceptionPropagationPolicy getPropagationPolicy() { + return propagationPolicy; + } + + + public MethodHandlerConfiguration(MethodMetadata metadata, Target target, + Retryer retryer, List requestInterceptors, + Logger logger, + Logger.Level logLevel, RequestTemplate.Factory buildTemplateFromArgs, + Request.Options options, ExceptionPropagationPolicy propagationPolicy) { + this.target = checkNotNull(target, "target"); + this.retryer = checkNotNull(retryer, "retryer for %s", target); + this.requestInterceptors = + checkNotNull(requestInterceptors, "requestInterceptors for %s", target); + this.logger = checkNotNull(logger, "logger for %s", target); + this.logLevel = checkNotNull(logLevel, "logLevel for %s", target); + this.metadata = checkNotNull(metadata, "metadata for %s", target); + this.buildTemplateFromArgs = checkNotNull(buildTemplateFromArgs, "metadata for %s", target); + this.options = checkNotNull(options, "options for %s", target); + this.propagationPolicy = propagationPolicy; + + } +} diff --git a/core/src/main/java/feign/MethodInfo.java b/core/src/main/java/feign/MethodInfo.java new file mode 100644 index 000000000..6af710f60 --- /dev/null +++ b/core/src/main/java/feign/MethodInfo.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2023 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.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; + +@Experimental +public class MethodInfo { + private final Type underlyingReturnType; + private final boolean asyncReturnType; + + protected MethodInfo(Type underlyingReturnType, boolean asyncReturnType) { + this.underlyingReturnType = underlyingReturnType; + this.asyncReturnType = asyncReturnType; + } + + MethodInfo(Class targetType, Method method) { + final Type type = Types.resolve(targetType, targetType, method.getGenericReturnType()); + + if (type instanceof ParameterizedType + && Types.getRawType(type).isAssignableFrom(CompletableFuture.class)) { + this.asyncReturnType = true; + this.underlyingReturnType = ((ParameterizedType) type).getActualTypeArguments()[0]; + } else { + this.asyncReturnType = false; + this.underlyingReturnType = type; + } + } + + Type underlyingReturnType() { + return underlyingReturnType; + } + + boolean isAsyncReturnType() { + return asyncReturnType; + } +} diff --git a/core/src/main/java/feign/MethodInfoResolver.java b/core/src/main/java/feign/MethodInfoResolver.java new file mode 100644 index 000000000..9ed90f887 --- /dev/null +++ b/core/src/main/java/feign/MethodInfoResolver.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2023 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.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; + +@Experimental +public interface MethodInfoResolver { + public MethodInfo resolve(Class targetType, Method method); +} diff --git a/core/src/main/java/feign/MethodMetadata.java b/core/src/main/java/feign/MethodMetadata.java new file mode 100644 index 000000000..ef002e695 --- /dev/null +++ b/core/src/main/java/feign/MethodMetadata.java @@ -0,0 +1,266 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.io.Serializable; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.*; +import java.util.stream.Collectors; +import feign.Param.Expander; + +public final class MethodMetadata implements Serializable { + + private static final long serialVersionUID = 1L; + private String configKey; + private transient Type returnType; + private Integer urlIndex; + private Integer bodyIndex; + private Integer headerMapIndex; + private Integer queryMapIndex; + private QueryMapEncoder queryMapEncoder; + private boolean alwaysEncodeBody; + private transient Type bodyType; + private final RequestTemplate template = new RequestTemplate(); + private final List formParams = new ArrayList(); + private final Map> indexToName = + new LinkedHashMap>(); + private final Map> indexToExpanderClass = + new LinkedHashMap>(); + private final Map indexToEncoded = new LinkedHashMap(); + private transient Map indexToExpander; + private BitSet parameterToIgnore = new BitSet(); + private boolean ignored; + private transient Class targetType; + private transient Method method; + private transient final List warnings = new ArrayList<>(); + + MethodMetadata() { + template.methodMetadata(this); + } + + /** + * Used as a reference to this method. For example, {@link Logger#log(String, String, Object...) + * logging} or {@link ReflectiveFeign reflective dispatch}. + * + * @see Feign#configKey(Class, java.lang.reflect.Method) + */ + public String configKey() { + return configKey; + } + + public MethodMetadata configKey(String configKey) { + this.configKey = configKey; + return this; + } + + public Type returnType() { + return returnType; + } + + public MethodMetadata returnType(Type returnType) { + this.returnType = returnType; + return this; + } + + public Integer urlIndex() { + return urlIndex; + } + + public MethodMetadata urlIndex(Integer urlIndex) { + this.urlIndex = urlIndex; + return this; + } + + public Integer bodyIndex() { + return bodyIndex; + } + + public MethodMetadata bodyIndex(Integer bodyIndex) { + this.bodyIndex = bodyIndex; + return this; + } + + public Integer headerMapIndex() { + return headerMapIndex; + } + + public MethodMetadata headerMapIndex(Integer headerMapIndex) { + this.headerMapIndex = headerMapIndex; + return this; + } + + public Integer queryMapIndex() { + return queryMapIndex; + } + + public MethodMetadata queryMapIndex(Integer queryMapIndex) { + this.queryMapIndex = queryMapIndex; + return this; + } + + public QueryMapEncoder queryMapEncoder() { + return queryMapEncoder; + } + + public MethodMetadata queryMapEncoder(QueryMapEncoder queryMapEncoder) { + this.queryMapEncoder = queryMapEncoder; + return this; + } + + @Experimental + public boolean alwaysEncodeBody() { + return alwaysEncodeBody; + } + + @Experimental + MethodMetadata alwaysEncodeBody(boolean alwaysEncodeBody) { + this.alwaysEncodeBody = alwaysEncodeBody; + return this; + } + + /** + * Type corresponding to {@link #bodyIndex()}. + */ + public Type bodyType() { + return bodyType; + } + + public MethodMetadata bodyType(Type bodyType) { + this.bodyType = bodyType; + return this; + } + + public RequestTemplate template() { + return template; + } + + public List formParams() { + return formParams; + } + + public Map> indexToName() { + return indexToName; + } + + public Map indexToEncoded() { + return indexToEncoded; + } + + /** + * If {@link #indexToExpander} is null, classes here will be instantiated by newInstance. + */ + public Map> indexToExpanderClass() { + return indexToExpanderClass; + } + + /** + * After {@link #indexToExpanderClass} is populated, this is set by contracts that support runtime + * injection. + */ + public MethodMetadata indexToExpander(Map indexToExpander) { + this.indexToExpander = indexToExpander; + return this; + } + + /** + * When not null, this value will be used instead of {@link #indexToExpander()}. + */ + public Map indexToExpander() { + return indexToExpander; + } + + /** + * @param i individual parameter that should be ignored + * @return this instance + */ + public MethodMetadata ignoreParamater(int i) { + this.parameterToIgnore.set(i); + return this; + } + + public BitSet parameterToIgnore() { + return parameterToIgnore; + } + + public MethodMetadata parameterToIgnore(BitSet parameterToIgnore) { + this.parameterToIgnore = parameterToIgnore; + return this; + } + + /** + * @param i individual parameter to check if should be ignored + * @return true when field should not be processed by feign + */ + public boolean shouldIgnoreParamater(int i) { + return parameterToIgnore.get(i); + } + + /** + * @param index + * @return true if the parameter {@code index} was already consumed by a any + * {@link MethodMetadata} holder + */ + public boolean isAlreadyProcessed(Integer index) { + return index.equals(urlIndex) + || index.equals(bodyIndex) + || index.equals(headerMapIndex) + || index.equals(queryMapIndex) + || indexToName.containsKey(index) + || indexToExpanderClass.containsKey(index) + || indexToEncoded.containsKey(index) + || (indexToExpander != null && indexToExpander.containsKey(index)) + || parameterToIgnore.get(index); + } + + public void ignoreMethod() { + this.ignored = true; + } + + public boolean isIgnored() { + return ignored; + } + + @Experimental + public MethodMetadata targetType(Class targetType) { + this.targetType = targetType; + return this; + } + + @Experimental + public Class targetType() { + return targetType; + } + + @Experimental + public MethodMetadata method(Method method) { + this.method = method; + return this; + } + + @Experimental + public Method method() { + return method; + } + + public void addWarning(String warning) { + warnings.add(warning); + } + + public String warnings() { + return warnings.stream() + .collect(Collectors.joining("\n- ", "\nWarnings:\n- ", "")); + } + +} diff --git a/core/src/main/java/feign/Param.java b/core/src/main/java/feign/Param.java new file mode 100644 index 000000000..879157cec --- /dev/null +++ b/core/src/main/java/feign/Param.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.lang.annotation.Retention; +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * A named template parameter applied to {@link Headers}, {@linkplain RequestLine}, + * {@linkplain Body}, POJO fields or beans properties when it expanding + */ +@Retention(RUNTIME) +@java.lang.annotation.Target({PARAMETER, FIELD, METHOD}) +public @interface Param { + + /** + * The name of the template parameter. + */ + String value() default ""; + + /** + * How to expand the value of this parameter, if {@link ToStringExpander} isn't adequate. + */ + Class expander() default ToStringExpander.class; + + /** + * {@code encoded} has been maintained for backward compatibility and should be deprecated. We no + * longer need it as values that are already pct-encoded should be identified during expansion and + * passed through without any changes + * + * @see QueryMap#encoded + * @deprecated + */ + boolean encoded() default false; + + interface Expander { + + /** + * Expands the value into a string. Does not accept or return null. + */ + String expand(Object value); + } + + final class ToStringExpander implements Expander { + + @Override + public String expand(Object value) { + return value.toString(); + } + } +} diff --git a/core/src/main/java/feign/QueryMap.java b/core/src/main/java/feign/QueryMap.java new file mode 100644 index 000000000..91b127eee --- /dev/null +++ b/core/src/main/java/feign/QueryMap.java @@ -0,0 +1,97 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.lang.annotation.Retention; +import java.util.List; +import java.util.Map; +import feign.querymap.BeanQueryMapEncoder; +import feign.querymap.FieldQueryMapEncoder; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * A template parameter that can be applied to a Map that contains query parameters, where the keys + * are Strings that are the parameter names and the values are the parameter values. The queries + * specified by the map will be applied to the request after all other processing, and will take + * precedence over any previously specified query parameters. It is not necessary to reference the + * parameter map as a variable.
+ *
+ * + *
+ * ...
+ * @RequestLine("POST /servers")
+ * void servers(@QueryMap Map);
+ * ...
+ *
+ * @RequestLine("GET /servers/{serverId}?count={count}")
+ * void get(@Param("serverId") String serverId, @Param("count") int count, @QueryMap Map);
+ * ...
+ * 
+ * + * The annotated parameter must be an instance of {@link Map}, and the keys must be Strings. The + * query value of a key will be the value of its toString method, except in the following cases: + *
+ *
+ *
    + *
  • if the value is null, the value will remain null (rather than converting to the String + * "null") + *
  • if the value is an {@link Iterable}, it is converted to a {@link List} of String objects + * where each value in the list is either null if the original value was null or the value's + * toString representation otherwise. + *
+ *
+ * Once this conversion is applied, the query keys and resulting String values follow the same + * contract as if they were set using {@link RequestTemplate#query(String, String...)}. + */ +@Retention(RUNTIME) +@java.lang.annotation.Target(PARAMETER) +public @interface QueryMap { + + /** + * Specifies whether parameter names and values are already encoded. + *

+ * Deprecation: there are two options + *

    + *
  • if name or value are already encoded we do nothing;
  • + *
  • if name or value are not encoded we encode them.
  • + *
+ * + * @see Param#encoded + * @deprecated + */ + boolean encoded() default false; + + /** + * Specifies the QueryMapEncoder implementation to use to transform DTO into query map. + * + * @return the enum containing the instance of QueryMapEncoder + */ + MapEncoder mapEncoder() default MapEncoder.DEFAULT; + + public enum MapEncoder { + // the latter DEFAULT will use BaseBuilder instance + BEAN(new BeanQueryMapEncoder()), FIELD(new FieldQueryMapEncoder()), DEFAULT(null); + + private QueryMapEncoder mapEncoder; + + private MapEncoder(QueryMapEncoder mapEncoder) { + this.mapEncoder = mapEncoder; + } + + public QueryMapEncoder instance() { + return mapEncoder; + } + } +} diff --git a/core/src/main/java/feign/QueryMapEncoder.java b/core/src/main/java/feign/QueryMapEncoder.java new file mode 100644 index 000000000..8c6ce217e --- /dev/null +++ b/core/src/main/java/feign/QueryMapEncoder.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.querymap.FieldQueryMapEncoder; +import feign.querymap.BeanQueryMapEncoder; +import java.util.Map; + +/** + * A QueryMapEncoder encodes Objects into maps of query parameter names to values. + * + * @see FieldQueryMapEncoder + * @see BeanQueryMapEncoder + * + */ +public interface QueryMapEncoder { + + /** + * Encodes the given object into a query map. + * + * @param object the object to encode + * @return the map represented by the object + */ + Map encode(Object object); + + /** + * @deprecated use {@link BeanQueryMapEncoder} instead. default encoder uses reflection to inspect + * provided objects Fields to expand the objects values into a query string. If you + * prefer that the query string be built using getter and setter methods, as defined + * in the Java Beans API, please use the {@link BeanQueryMapEncoder} + */ + class Default extends FieldQueryMapEncoder { + } +} diff --git a/core/src/main/java/feign/RedirectionInterceptor.java b/core/src/main/java/feign/RedirectionInterceptor.java new file mode 100755 index 000000000..2451abe1d --- /dev/null +++ b/core/src/main/java/feign/RedirectionInterceptor.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-2023 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.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collection; + +/** + * An implementation of {@link ResponseInterceptor} the returns the value of the location header + * when appropriate. + */ +public class RedirectionInterceptor implements ResponseInterceptor { + @Override + public Object intercept(InvocationContext invocationContext, Chain chain) throws Exception { + Response response = invocationContext.response(); + int status = response.status(); + Object returnValue = null; + if (300 <= status && status < 400 && response.headers().containsKey("Location")) { + Type returnType = rawType(invocationContext.returnType()); + Collection locations = response.headers().get("Location"); + if (Collection.class.equals(returnType)) { + returnValue = locations; + } else if (String.class.equals(returnType)) { + if (locations.isEmpty()) { + returnValue = ""; + } else { + returnValue = locations.stream().findFirst().orElse(""); + } + } + } + if (returnValue == null) { + return chain.next(invocationContext); + } else { + response.close(); + return returnValue; + } + } + + private Type rawType(Type type) { + return type instanceof ParameterizedType ? ((ParameterizedType) type).getRawType() : type; + } +} diff --git a/core/src/main/java/feign/ReflectiveFeign.java b/core/src/main/java/feign/ReflectiveFeign.java new file mode 100644 index 000000000..6e3f8c479 --- /dev/null +++ b/core/src/main/java/feign/ReflectiveFeign.java @@ -0,0 +1,209 @@ +/* + * Copyright 2012-2023 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.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Proxy; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import feign.InvocationHandlerFactory.MethodHandler; + +public class ReflectiveFeign extends Feign { + + private final ParseHandlersByName targetToHandlersByName; + private final InvocationHandlerFactory factory; + private final AsyncContextSupplier defaultContextSupplier; + + ReflectiveFeign( + Contract contract, + MethodHandler.Factory methodHandlerFactory, + InvocationHandlerFactory invocationHandlerFactory, + AsyncContextSupplier defaultContextSupplier) { + this.targetToHandlersByName = new ParseHandlersByName(contract, methodHandlerFactory); + this.factory = invocationHandlerFactory; + this.defaultContextSupplier = defaultContextSupplier; + } + + /** + * creates an api binding to the {@code target}. As this invokes reflection, care should be taken + * to cache the result. + */ + public T newInstance(Target target) { + return newInstance(target, defaultContextSupplier.newContext()); + } + + @SuppressWarnings("unchecked") + public T newInstance(Target target, C requestContext) { + TargetSpecificationVerifier.verify(target); + + Map methodToHandler = + targetToHandlersByName.apply(target, requestContext); + InvocationHandler handler = factory.create(target, methodToHandler); + T proxy = (T) Proxy.newProxyInstance(target.type().getClassLoader(), + new Class[] {target.type()}, handler); + + for (MethodHandler methodHandler : methodToHandler.values()) { + if (methodHandler instanceof DefaultMethodHandler) { + ((DefaultMethodHandler) methodHandler).bindTo(proxy); + } + } + + return proxy; + } + + static class FeignInvocationHandler implements InvocationHandler { + + private final Target target; + private final Map dispatch; + + FeignInvocationHandler(Target target, Map dispatch) { + this.target = checkNotNull(target, "target"); + this.dispatch = checkNotNull(dispatch, "dispatch for %s", target); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if ("equals".equals(method.getName())) { + try { + Object otherHandler = + args.length > 0 && args[0] != null ? Proxy.getInvocationHandler(args[0]) : null; + return equals(otherHandler); + } catch (IllegalArgumentException e) { + return false; + } + } else if ("hashCode".equals(method.getName())) { + return hashCode(); + } else if ("toString".equals(method.getName())) { + return toString(); + } else if (!dispatch.containsKey(method)) { + throw new UnsupportedOperationException( + String.format("Method \"%s\" should not be called", method.getName())); + } + + return dispatch.get(method).invoke(args); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof FeignInvocationHandler) { + FeignInvocationHandler other = (FeignInvocationHandler) obj; + return target.equals(other.target); + } + return false; + } + + @Override + public int hashCode() { + return target.hashCode(); + } + + @Override + public String toString() { + return target.toString(); + } + } + + private static final class ParseHandlersByName { + + private final Contract contract; + private final MethodHandler.Factory factory; + + ParseHandlersByName( + Contract contract, + MethodHandler.Factory factory) { + this.contract = contract; + this.factory = factory; + } + + public Map apply(Target target, C requestContext) { + final Map result = new LinkedHashMap<>(); + + final List metadataList = contract.parseAndValidateMetadata(target.type()); + for (MethodMetadata md : metadataList) { + final Method method = md.method(); + if (method.getDeclaringClass() == Object.class) { + continue; + } + + final MethodHandler handler = createMethodHandler(target, md, requestContext); + result.put(method, handler); + } + + for (Method method : target.type().getMethods()) { + if (Util.isDefault(method)) { + final MethodHandler handler = new DefaultMethodHandler(method); + result.put(method, handler); + } + } + + return result; + } + + private MethodHandler createMethodHandler(final Target target, + final MethodMetadata md, + final C requestContext) { + if (md.isIgnored()) { + return args -> { + throw new IllegalStateException(md.configKey() + " is not a method handled by feign"); + }; + } + + return factory.create(target, md, requestContext); + } + } + + private static class TargetSpecificationVerifier { + public static void verify(Target target) { + Class type = target.type(); + if (!type.isInterface()) { + throw new IllegalArgumentException("Type must be an interface: " + type); + } + + for (final Method m : type.getMethods()) { + final Class retType = m.getReturnType(); + + if (!CompletableFuture.class.isAssignableFrom(retType)) { + continue; // synchronous case + } + + if (retType != CompletableFuture.class) { + throw new IllegalArgumentException("Method return type is not CompleteableFuture: " + + getFullMethodName(type, retType, m)); + } + + final Type genRetType = m.getGenericReturnType(); + + if (!(genRetType instanceof ParameterizedType)) { + throw new IllegalArgumentException("Method return type is not parameterized: " + + getFullMethodName(type, genRetType, m)); + } + + if (((ParameterizedType) genRetType).getActualTypeArguments()[0] instanceof WildcardType) { + throw new IllegalArgumentException( + "Wildcards are not supported for return-type parameters: " + + getFullMethodName(type, genRetType, m)); + } + } + } + + private static String getFullMethodName(Class type, Type retType, Method m) { + return retType.getTypeName() + " " + type.toGenericString() + "." + m.getName(); + } + } +} diff --git a/core/src/main/java/feign/Request.java b/core/src/main/java/feign/Request.java new file mode 100644 index 000000000..95d881d9e --- /dev/null +++ b/core/src/main/java/feign/Request.java @@ -0,0 +1,569 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import static feign.Util.checkNotNull; +import static feign.Util.getThreadIdentifier; +import static feign.Util.valuesOrEmpty; +import java.io.Serializable; +import java.net.HttpURLConnection; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * An immutable request to an http server. + */ +public final class Request implements Serializable { + + public enum HttpMethod { + GET, HEAD, POST(true), PUT(true), DELETE, CONNECT, OPTIONS, TRACE, PATCH(true); + + private final boolean withBody; + + HttpMethod() { + this(false); + } + + HttpMethod(boolean withBody) { + this.withBody = withBody; + } + + public boolean isWithBody() { + return this.withBody; + } + } + + public enum ProtocolVersion { + + HTTP_1_0("HTTP/1.0"), HTTP_1_1("HTTP/1.1"), HTTP_2("HTTP/2.0"), MOCK; + + final String protocolVersion; + + ProtocolVersion() { + protocolVersion = name(); + } + + ProtocolVersion(String protocolVersion) { + this.protocolVersion = protocolVersion; + } + + @Override + public String toString() { + return protocolVersion; + } + + } + + /** + * No parameters can be null except {@code body} and {@code charset}. All parameters must be + * effectively immutable, via safe copies, not mutating or otherwise. + * + * @deprecated {@link #create(HttpMethod, String, Map, byte[], Charset)} + */ + @Deprecated + public static Request create(String method, + String url, + Map> headers, + byte[] body, + Charset charset) { + checkNotNull(method, "httpMethod of %s", method); + final HttpMethod httpMethod = HttpMethod.valueOf(method.toUpperCase()); + return create(httpMethod, url, headers, body, charset, null); + } + + /** + * Builds a Request. All parameters must be effectively immutable, via safe copies. + * + * @param httpMethod for the request. + * @param url for the request. + * @param headers to include. + * @param body of the request, can be {@literal null} + * @param charset of the request, can be {@literal null} + * @return a Request + */ + @Deprecated + public static Request create(HttpMethod httpMethod, + String url, + Map> headers, + byte[] body, + Charset charset) { + return create(httpMethod, url, headers, Body.create(body, charset), null); + } + + /** + * Builds a Request. All parameters must be effectively immutable, via safe copies. + * + * @param httpMethod for the request. + * @param url for the request. + * @param headers to include. + * @param body of the request, can be {@literal null} + * @param charset of the request, can be {@literal null} + * @return a Request + */ + public static Request create(HttpMethod httpMethod, + String url, + Map> headers, + byte[] body, + Charset charset, + RequestTemplate requestTemplate) { + return create(httpMethod, url, headers, Body.create(body, charset), requestTemplate); + } + + /** + * Builds a Request. All parameters must be effectively immutable, via safe copies. + * + * @param httpMethod for the request. + * @param url for the request. + * @param headers to include. + * @param body of the request, can be {@literal null} + * @return a Request + */ + public static Request create(HttpMethod httpMethod, + String url, + Map> headers, + Body body, + RequestTemplate requestTemplate) { + return new Request(httpMethod, url, headers, body, requestTemplate); + } + + private final HttpMethod httpMethod; + private final String url; + private final Map> headers; + private final Body body; + private final RequestTemplate requestTemplate; + private final ProtocolVersion protocolVersion; + + /** + * Creates a new Request. + * + * @param method of the request. + * @param url for the request. + * @param headers for the request. + * @param body for the request, optional. + * @param requestTemplate used to build the request. + */ + Request(HttpMethod method, + String url, + Map> headers, + Body body, + RequestTemplate requestTemplate) { + this.httpMethod = checkNotNull(method, "httpMethod of %s", method.name()); + this.url = checkNotNull(url, "url"); + this.headers = checkNotNull(headers, "headers of %s %s", method, url); + this.body = body; + this.requestTemplate = requestTemplate; + protocolVersion = ProtocolVersion.HTTP_1_1; + } + + /** + * Http Method for this request. + * + * @return the HttpMethod string + * @deprecated @see {@link #httpMethod()} + */ + @Deprecated + public String method() { + return httpMethod.name(); + } + + /** + * Http Method for the request. + * + * @return the HttpMethod. + */ + public HttpMethod httpMethod() { + return this.httpMethod; + } + + + /** + * URL for the request. + * + * @return URL as a String. + */ + public String url() { + return url; + } + + /** + * Request Headers. + * + * @return the request headers. + */ + public Map> headers() { + return Collections.unmodifiableMap(headers); + } + + /** + * Add new entries to request Headers. It overrides existing entries + * + * @param key + * @param value + */ + public void header(String key, String value) { + header(key, Arrays.asList(value)); + } + + /** + * Add new entries to request Headers. It overrides existing entries + * + * @param key + * @param values + */ + public void header(String key, Collection values) { + headers.put(key, values); + } + + /** + * Charset of the request. + * + * @return the current character set for the request, may be {@literal null} for binary data. + */ + public Charset charset() { + return body.encoding; + } + + /** + * If present, this is the replayable body to send to the server. In some cases, this may be + * interpretable as text. + * + * @see #charset() + */ + public byte[] body() { + return body.data; + } + + public boolean isBinary() { + return body.isBinary(); + } + + /** + * Request Length. + * + * @return size of the request body. + */ + public int length() { + return this.body.length(); + } + + /** + * Request HTTP protocol version + * + * @return HTTP protocol version + */ + public ProtocolVersion protocolVersion() { + return protocolVersion; + } + + /** + * Request as an HTTP/1.1 request. + * + * @return the request. + */ + @Override + public String toString() { + final StringBuilder builder = new StringBuilder(); + builder.append(httpMethod).append(' ').append(url).append(' ').append(protocolVersion) + .append('\n'); + for (final String field : headers.keySet()) { + for (final String value : valuesOrEmpty(headers, field)) { + builder.append(field).append(": ").append(value).append('\n'); + } + } + if (body != null) { + builder.append('\n').append(body.asString()); + } + return builder.toString(); + } + + /** + * Controls the per-request settings currently required to be implemented by all {@link Client + * clients} + */ + public static class Options { + + private final long connectTimeout; + private final TimeUnit connectTimeoutUnit; + private final long readTimeout; + private final TimeUnit readTimeoutUnit; + private final boolean followRedirects; + private final Map> threadToMethodOptions; + + /** + * Get an Options by methodName + * + * @param methodName it's your FeignInterface method name. + * @return method Options + */ + @Experimental + public Options getMethodOptions(String methodName) { + Map methodOptions = + threadToMethodOptions.getOrDefault(getThreadIdentifier(), new HashMap<>()); + return methodOptions.getOrDefault(methodName, this); + } + + /** + * Set methodOptions by methodKey and options + * + * @param methodName it's your FeignInterface method name. + * @param options it's the Options for this method. + */ + @Experimental + public void setMethodOptions(String methodName, Options options) { + String threadIdentifier = getThreadIdentifier(); + Map methodOptions = + threadToMethodOptions.getOrDefault(threadIdentifier, new HashMap<>()); + threadToMethodOptions.put(threadIdentifier, methodOptions); + methodOptions.put(methodName, options); + } + + /** + * Creates a new Options instance. + * + * @param connectTimeoutMillis connection timeout in milliseconds. + * @param readTimeoutMillis read timeout in milliseconds. + * @param followRedirects if the request should follow 3xx redirections. + * + * @deprecated please use {@link #Options(long, TimeUnit, long, TimeUnit, boolean)} + */ + @Deprecated + public Options(int connectTimeoutMillis, int readTimeoutMillis, boolean followRedirects) { + this(connectTimeoutMillis, TimeUnit.MILLISECONDS, + readTimeoutMillis, TimeUnit.MILLISECONDS, + followRedirects); + } + + /** + * Creates a new Options Instance. + * + * @param connectTimeout value. + * @param connectTimeoutUnit with the TimeUnit for the timeout value. + * @param readTimeout value. + * @param readTimeoutUnit with the TimeUnit for the timeout value. + * @param followRedirects if the request should follow 3xx redirections. + */ + public Options(long connectTimeout, TimeUnit connectTimeoutUnit, + long readTimeout, TimeUnit readTimeoutUnit, + boolean followRedirects) { + super(); + this.connectTimeout = connectTimeout; + this.connectTimeoutUnit = connectTimeoutUnit; + this.readTimeout = readTimeout; + this.readTimeoutUnit = readTimeoutUnit; + this.followRedirects = followRedirects; + this.threadToMethodOptions = new ConcurrentHashMap<>(); + } + + /** + * Creates a new Options instance that follows redirects by default. + * + * @param connectTimeoutMillis connection timeout in milliseconds. + * @param readTimeoutMillis read timeout in milliseconds. + * + * @deprecated please use {@link #Options(long, TimeUnit, long, TimeUnit, boolean)} + */ + @Deprecated + public Options(int connectTimeoutMillis, int readTimeoutMillis) { + this(connectTimeoutMillis, readTimeoutMillis, true); + } + + /** + * Creates a new Options Instance. + * + * @param connectTimeout value. + * @param readTimeout value. + * @param followRedirects if the request should follow 3xx redirections. + */ + public Options(Duration connectTimeout, Duration readTimeout, boolean followRedirects) { + this(connectTimeout.toMillis(), TimeUnit.MILLISECONDS, readTimeout.toMillis(), + TimeUnit.MILLISECONDS, followRedirects); + } + + /** + * Creates the new Options instance using the following defaults: + *
    + *
  • Connect Timeout: 10 seconds
  • + *
  • Read Timeout: 60 seconds
  • + *
  • Follow all 3xx redirects
  • + *
+ */ + public Options() { + this(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true); + } + + /** + * Defaults to 10 seconds. {@code 0} implies no timeout. + * + * @see java.net.HttpURLConnection#getConnectTimeout() + */ + public int connectTimeoutMillis() { + return (int) connectTimeoutUnit.toMillis(connectTimeout); + } + + /** + * Defaults to 60 seconds. {@code 0} implies no timeout. + * + * @see java.net.HttpURLConnection#getReadTimeout() + */ + public int readTimeoutMillis() { + return (int) readTimeoutUnit.toMillis(readTimeout); + } + + + /** + * Defaults to true. {@code false} tells the client to not follow the redirections. + * + * @see HttpURLConnection#getFollowRedirects() + */ + public boolean isFollowRedirects() { + return followRedirects; + } + + /** + * Connect Timeout Value. + * + * @return current timeout value. + */ + public long connectTimeout() { + return connectTimeout; + } + + /** + * TimeUnit for the Connection Timeout value. + * + * @return TimeUnit + */ + public TimeUnit connectTimeoutUnit() { + return connectTimeoutUnit; + } + + /** + * Read Timeout value. + * + * @return current read timeout value. + */ + public long readTimeout() { + return readTimeout; + } + + /** + * TimeUnit for the Read Timeout value. + * + * @return TimeUnit + */ + public TimeUnit readTimeoutUnit() { + return readTimeoutUnit; + } + + } + + @Experimental + public RequestTemplate requestTemplate() { + return this.requestTemplate; + } + + /** + * Request Body + *

+ * Considered experimental, will most likely be made internal going forward. + *

+ */ + @Experimental + public static class Body implements Serializable { + + private transient Charset encoding; + + private byte[] data; + + private Body() { + super(); + } + + private Body(byte[] data) { + this.data = data; + } + + private Body(byte[] data, Charset encoding) { + this.data = data; + this.encoding = encoding; + } + + public Optional getEncoding() { + return Optional.ofNullable(this.encoding); + } + + public int length() { + /* calculate the content length based on the data provided */ + return data != null ? data.length : 0; + } + + public byte[] asBytes() { + return data; + } + + public String asString() { + return !isBinary() + ? new String(data, encoding) + : "Binary data"; + } + + public boolean isBinary() { + return encoding == null || data == null; + } + + public static Body create(String data) { + return new Body(data.getBytes()); + } + + public static Body create(String data, Charset charset) { + return new Body(data.getBytes(charset), charset); + } + + public static Body create(byte[] data) { + return new Body(data); + } + + public static Body create(byte[] data, Charset charset) { + return new Body(data, charset); + } + + /** + * Creates a new Request Body with charset encoded data. + * + * @param data to be encoded. + * @param charset to encode the data with. if {@literal null}, then data will be considered + * binary and will not be encoded. + * + * @return a new Request.Body instance with the encoded data. + * @deprecated please use {@link Request.Body#create(byte[], Charset)} + */ + @Deprecated + public static Body encoded(byte[] data, Charset charset) { + return create(data, charset); + } + + public static Body empty() { + return new Body(); + } + + } +} diff --git a/core/src/main/java/feign/RequestInterceptor.java b/core/src/main/java/feign/RequestInterceptor.java new file mode 100644 index 000000000..e4bafa304 --- /dev/null +++ b/core/src/main/java/feign/RequestInterceptor.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +/** + * Zero or more {@code RequestInterceptors} may be configured for purposes such as adding headers to + * all requests. No guarantees are given with regards to the order that interceptors are applied. + * Once interceptors are applied, {@link Target#apply(RequestTemplate)} is called to create the + * immutable http request sent via {@link Client#execute(Request, feign.Request.Options)}.
+ *
+ * For example:
+ * + *
+ * public void apply(RequestTemplate input) {
+ *   input.header("X-Auth", currentToken);
+ * }
+ * 
+ * + *
+ *
+ * Configuration
+ *
+ * {@code RequestInterceptors} are configured via {@link Feign.Builder#requestInterceptors}.
+ *
+ * Implementation notes
+ *
+ * Do not add parameters, such as {@code /path/{foo}/bar } in your implementation of + * {@link #apply(RequestTemplate)}.
+ * Interceptors are applied after the template's parameters are + * {@link RequestTemplate#resolve(java.util.Map) resolved}. This is to ensure that you can implement + * signatures are interceptors.
+ *
+ *
+ * Relationship to Retrofit 1.x
+ *
+ * This class is similar to {@code RequestInterceptor.intercept()}, except that the implementation + * can read, remove, or otherwise mutate any part of the request template. + */ +public interface RequestInterceptor { + + /** + * Called for every request. Add data using methods on the supplied {@link RequestTemplate}. + */ + void apply(RequestTemplate template); +} diff --git a/core/src/main/java/feign/RequestLine.java b/core/src/main/java/feign/RequestLine.java new file mode 100644 index 000000000..f56fdaa49 --- /dev/null +++ b/core/src/main/java/feign/RequestLine.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.lang.annotation.Retention; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Expands the uri template supplied in the {@code value}, permitting path and query variables, or + * just the http method. Templates should conform to + * RFC 6570. Support is limited to Simple String + * expansion and Reserved Expansion (Level 1 and Level 2) expressions. + */ +@java.lang.annotation.Target(METHOD) +@Retention(RUNTIME) +public @interface RequestLine { + + String value(); + + boolean decodeSlash() default true; + + CollectionFormat collectionFormat() default CollectionFormat.EXPLODED; +} diff --git a/core/src/main/java/feign/RequestTemplate.java b/core/src/main/java/feign/RequestTemplate.java new file mode 100644 index 000000000..f5c5022a9 --- /dev/null +++ b/core/src/main/java/feign/RequestTemplate.java @@ -0,0 +1,1102 @@ +/* + * Copyright 2012-2024 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_LENGTH; +import static feign.Util.checkNotNull; +import feign.Request.HttpMethod; +import feign.template.BodyTemplate; +import feign.template.HeaderTemplate; +import feign.template.QueryTemplate; +import feign.template.UriTemplate; +import feign.template.UriUtils; +import java.io.Serializable; +import java.net.URI; +import java.nio.charset.Charset; +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Request Builder for an HTTP Target. + *

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

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

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

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

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

+ *

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

+ * + * @param pos in the uri to place the value. + * @param value to insert. + * @return a RequestTemplate for chaining. + * @deprecated see {@link RequestTemplate#target(String)} + */ + @SuppressWarnings("unused") + @Deprecated + public RequestTemplate insert(int pos, CharSequence value) { + return target(value.toString()); + } + + /** + * Set the Uri for the request, replacing the existing uri if set. + * + * @param uri to use, must be a relative uri. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate uri(String uri) { + return this.uri(uri, false); + } + + /** + * Set the uri for the request. + * + * @param uri to use, must be a relative uri. + * @param append if the uri should be appended, if the uri is already set. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate uri(String uri, boolean append) { + /* validate and ensure that the url is always a relative one */ + if (UriUtils.isAbsolute(uri)) { + throw new IllegalArgumentException("url values must be not be absolute."); + } + + if (uri == null) { + uri = "/"; + } else if ((!uri.isEmpty() && !uri.startsWith("/") && !uri.startsWith("{") + && !uri.startsWith("?") && !uri.startsWith(";"))) { + /* if the start of the url is a literal, it must begin with a slash. */ + uri = "/" + uri; + } + + int fragmentIndex = uri.indexOf('#'); + if (fragmentIndex > -1) { + fragment = uri.substring(fragmentIndex); + uri = uri.substring(0, fragmentIndex); + } + + /* + * templates may provide query parameters. since we want to manage those explicity, we will need + * to extract those out, leaving the uriTemplate with only the path to deal with. + */ + Matcher queryMatcher = QUERY_STRING_PATTERN.matcher(uri); + if (queryMatcher.find()) { + String queryString = uri.substring(queryMatcher.start() + 1); + + /* parse the query string */ + this.extractQueryTemplates(queryString, append); + + /* reduce the uri to the path */ + uri = uri.substring(0, queryMatcher.start()); + } + + /* replace the uri template */ + if (append && this.uriTemplate != null) { + this.uriTemplate = UriTemplate.append(this.uriTemplate, uri); + } else { + this.uriTemplate = UriTemplate.create(uri, !this.decodeSlash, this.charset); + } + return this; + } + + /** + * Set the target host for this request. + * + * @param target host for this request. Must be an absolute target. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate target(String target) { + /* target can be empty */ + if (Util.isBlank(target)) { + return this; + } + + /* verify that the target contains the scheme, host and port */ + if (!UriUtils.isAbsolute(target)) { + throw new IllegalArgumentException("target values must be absolute."); + } + if (target.endsWith("/")) { + target = target.substring(0, target.length() - 1); + } + try { + /* parse the target */ + URI targetUri = URI.create(target); + + if (Util.isNotBlank(targetUri.getRawQuery())) { + /* + * target has a query string, we need to make sure that they are recorded as queries + */ + this.extractQueryTemplates(targetUri.getRawQuery(), true); + } + + /* strip the query string */ + this.target = + targetUri.getScheme() + "://" + targetUri.getRawAuthority() + targetUri.getRawPath(); + if (targetUri.getFragment() != null) { + this.fragment = "#" + targetUri.getFragment(); + } + } catch (IllegalArgumentException iae) { + /* the uri provided is not a valid one, we can't continue */ + throw new IllegalArgumentException("Target is not a valid URI.", iae); + } + return this; + } + + /** + * The URL for the request. If the template has not been resolved, the url will represent a uri + * template. + * + * @return the url + */ + public String url() { + + /* build the fully qualified url with all query parameters */ + StringBuilder url = new StringBuilder(this.path()); + if (!this.queries.isEmpty()) { + url.append(this.queryLine()); + } + if (fragment != null) { + url.append(fragment); + } + + return url.toString(); + } + + /** + * The Uri Path. + * + * @return the uri path. + */ + public String path() { + /* build the fully qualified url with all query parameters */ + StringBuilder path = new StringBuilder(); + if (this.target != null) { + path.append(this.target); + } + if (this.uriTemplate != null) { + path.append(this.uriTemplate.toString()); + } + if (path.length() == 0) { + /* no path indicates the root uri */ + path.append("/"); + } + return path.toString(); + + } + + /** + * List all of the template variable expressions for this template. + * + * @return a list of template variable names + */ + public List variables() { + /* combine the variables from the uri, query, header, and body templates */ + List variables = new ArrayList<>(this.uriTemplate.getVariables()); + + /* queries */ + for (QueryTemplate queryTemplate : this.queries.values()) { + variables.addAll(queryTemplate.getVariables()); + } + + /* headers */ + for (HeaderTemplate headerTemplate : this.headers.values()) { + variables.addAll(headerTemplate.getVariables()); + } + + /* body */ + if (this.bodyTemplate != null) { + variables.addAll(this.bodyTemplate.getVariables()); + } + + return variables; + } + + /** + * @see RequestTemplate#query(String, Iterable) + */ + public RequestTemplate query(String name, String... values) { + if (values == null) { + return query(name, Collections.emptyList()); + } + return query(name, Arrays.asList(values)); + } + + + /** + * Specify a Query String parameter, with the specified values. Values can be literals or template + * expressions. + * + * @param name of the parameter. + * @param values for this parameter. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate query(String name, Iterable values) { + return appendQuery(name, values, this.collectionFormat); + } + + /** + * Specify a Query String parameter, with the specified values. Values can be literals or template + * expressions. + * + * @param name of the parameter. + * @param values for this parameter. + * @param collectionFormat to use when resolving collection based expressions. + * @return a Request Template for chaining. + */ + public RequestTemplate query(String name, + Iterable values, + CollectionFormat collectionFormat) { + return appendQuery(name, values, collectionFormat); + } + + /** + * Appends the query name and values. + * + * @param name of the parameter. + * @param values for the parameter, may be expressions. + * @param collectionFormat to use when resolving collection based query variables. + * @return a RequestTemplate for chaining. + */ + private RequestTemplate appendQuery(String name, + Iterable values, + CollectionFormat collectionFormat) { + if (!values.iterator().hasNext()) { + /* empty value, clear the existing values */ + this.queries.remove(name); + return this; + } + + /* create a new query template out of the information here */ + this.queries.compute(name, (key, queryTemplate) -> { + if (queryTemplate == null) { + return QueryTemplate.create(name, values, this.charset, collectionFormat, this.decodeSlash); + } else { + return QueryTemplate.append(queryTemplate, values, collectionFormat, this.decodeSlash); + } + }); + return this; + } + + /** + * Sets the Query Parameters. + * + * @param queries to use for this request. + * @return a RequestTemplate for chaining. + */ + @SuppressWarnings("unused") + public RequestTemplate queries(Map> queries) { + if (queries == null || queries.isEmpty()) { + this.queries.clear(); + } else { + queries.forEach(this::query); + } + return this; + } + + + /** + * Return an immutable Map of all Query Parameters and their values. + * + * @return registered Query Parameters. + */ + public Map> queries() { + Map> queryMap = new LinkedHashMap<>(); + this.queries.forEach((key, queryTemplate) -> { + List values = new ArrayList<>(queryTemplate.getValues()); + + /* add the expanded collection, but lock it */ + queryMap.put(key, Collections.unmodifiableList(values)); + }); + + return Collections.unmodifiableMap(queryMap); + } + + /** + * @see RequestTemplate#header(String, Iterable) + */ + public RequestTemplate header(String name, String... values) { + if (values == null) { + return appendHeader(name, Collections.emptyList()); + } + + return header(name, Arrays.asList(values)); + } + + /** + * Specify a Header, with the specified values. Values can be literals or template expressions. + * + * @param name of the header. + * @param values for this header. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate header(String name, Iterable values) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required."); + } + if (values == null) { + values = Collections.emptyList(); + } + + return appendHeader(name, values); + } + + /** + * @see RequestTemplate#headerLiteral(String, Iterable) + */ + public RequestTemplate headerLiteral(String name, String... values) { + if (values == null) { + return headerLiteral(name, Collections.emptyList()); + } + + return headerLiteral(name, Arrays.asList(values)); + } + + /** + * Specify a Header, with the specified values. Values are treated as literals. Template + * expressions are not resolved. + * + * @param name of the header. + * @param values for this header. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate headerLiteral(String name, Iterable values) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required."); + } + if (values == null) { + values = Collections.emptyList(); + } + + return appendHeader(name, values, true); + } + + /** + * Clear on reader from {@link RequestTemplate} + * + * @param name of the header. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate removeHeader(String name) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required."); + } + this.headers.remove(name); + return this; + } + + /** + * Append the Header. Will create a new Header if it doesn't already exist. Treats all values as + * potentially expressions. + * + * @param name of the header + * @param values for the header, may be expressions. + * @return a RequestTemplate for chaining. + */ + private RequestTemplate appendHeader(String name, Iterable values) { + return this.appendHeader(name, values, false); + } + + /** + * Append the Header. Will create a new Header if it doesn't already exist. + * + * @param name of the header + * @param values for the header, may be expressions. + * @param literal indicator, to treat the values as literals and not expressions + * @return a RequestTemplate for chaining. + */ + private RequestTemplate appendHeader(String name, Iterable values, boolean literal) { + if (!values.iterator().hasNext()) { + /* empty value, clear the existing values */ + this.headers.remove(name); + return this; + } + if (name.equals("Content-Type")) { + // a client can only produce content of one single type, so always override Content-Type and + // only add a single type + this.headers.remove(name); + this.headers.put(name, + HeaderTemplate.create(name, Collections.singletonList(values.iterator().next()))); + return this; + } + this.headers.compute(name, (headerName, headerTemplate) -> { + if (headerTemplate == null) { + if (literal) { + return HeaderTemplate.literal(headerName, values); + } else { + return HeaderTemplate.create(headerName, values); + } + } else if (literal) { + return HeaderTemplate.appendLiteral(headerTemplate, values); + } else { + return HeaderTemplate.append(headerTemplate, values); + } + }); + return this; + } + + /** + * Headers for this Request. + * + * @param headers to use. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate headers(Map> headers) { + if (headers != null && !headers.isEmpty()) { + headers.forEach(this::header); + } else { + this.headers.clear(); + } + return this; + } + + /** + * Returns an copy of the Headers for this request. + * + * @return the currently applied headers. + */ + public Map> headers() { + Map> headerMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + this.headers.forEach((key, headerTemplate) -> { + List values = new ArrayList<>(headerTemplate.getValues()); + + /* add the expanded collection, but only if it has values */ + if (!values.isEmpty()) { + headerMap.put(key, values); + } + }); + return headerMap; + } + + /** + * Sets the Body and Charset for this request. + * + * @param data to send, can be null. + * @param charset of the encoded data. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate body(byte[] data, Charset charset) { + this.body(Request.Body.create(data, charset)); + return this; + } + + /** + * Set the Body for this request. Charset is assumed to be UTF_8. Data must be encoded. + * + * @param bodyText to send. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate body(String bodyText) { + this.body(Request.Body.create(bodyText.getBytes(this.charset), this.charset)); + return this; + } + + /** + * Set the Body for this request. + * + * @param body to send. + * @return a RequestTemplate for chaining. + * @deprecated use {@link #body(byte[], Charset)} instead. + */ + @Deprecated + public RequestTemplate body(Request.Body body) { + this.body = body; + + /* body template must be cleared to prevent double processing */ + this.bodyTemplate = null; + + header(CONTENT_LENGTH, Collections.emptyList()); + if (body.length() > 0) { + header(CONTENT_LENGTH, String.valueOf(body.length())); + } + + return this; + } + + /** + * Charset of the Request Body, if known. + * + * @return the currently applied Charset. + */ + public Charset requestCharset() { + if (this.body != null) { + return this.body.getEncoding() + .orElse(this.charset); + } + return this.charset; + } + + /** + * The Request Body. + * + * @return the request body. + */ + public byte[] body() { + return body.asBytes(); + } + + /** + * The Request.Body internal object. + * + * @return the internal Request.Body. + * @deprecated this abstraction is leaky and will be removed in later releases. + */ + @Deprecated + public Request.Body requestBody() { + return this.body; + } + + + /** + * Specify the Body Template to use. Can contain literals and expressions. + * + * @param bodyTemplate to use. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate bodyTemplate(String bodyTemplate) { + this.bodyTemplate = BodyTemplate.create(bodyTemplate, this.charset); + return this; + } + + /** + * Specify the Body Template to use. Can contain literals and expressions. + * + * @param bodyTemplate to use. + * @return a RequestTemplate for chaining. + */ + public RequestTemplate bodyTemplate(String bodyTemplate, Charset charset) { + this.bodyTemplate = BodyTemplate.create(bodyTemplate, charset); + this.charset = charset; + return this; + } + + /** + * Body Template to resolve. + * + * @return the unresolved body template. + */ + public String bodyTemplate() { + if (this.bodyTemplate != null) { + return this.bodyTemplate.toString(); + } + return null; + } + + @Override + public String toString() { + return request().toString(); + } + + /** + * Return if the variable exists on the uri, query, or headers, in this template. + * + * @param variable to look for. + * @return true if the variable exists, false otherwise. + */ + public boolean hasRequestVariable(String variable) { + return this.getRequestVariables().contains(variable); + } + + /** + * Retrieve all uri, header, and query template variables. + * + * @return a List of all the variable names. + */ + public Collection getRequestVariables() { + final Collection variables = new LinkedHashSet<>(this.uriTemplate.getVariables()); + this.queries.values().forEach(queryTemplate -> variables.addAll(queryTemplate.getVariables())); + this.headers.values() + .forEach(headerTemplate -> variables.addAll(headerTemplate.getVariables())); + return variables; + } + + /** + * If this template has been resolved. + * + * @return true if the template has been resolved, false otherwise. + */ + @SuppressWarnings("unused") + public boolean resolved() { + return this.resolved; + } + + /** + * The Query String for the template. Expressions are not resolved. + * + * @return the Query String. + */ + public String queryLine() { + StringBuilder queryString = new StringBuilder(); + + if (!this.queries.isEmpty()) { + Iterator iterator = this.queries.values().iterator(); + while (iterator.hasNext()) { + QueryTemplate queryTemplate = iterator.next(); + String query = queryTemplate.toString(); + if (query != null && !query.isEmpty()) { + queryString.append(query); + if (iterator.hasNext()) { + queryString.append("&"); + } + } + } + } + /* remove any trailing ampersands */ + String result = queryString.toString(); + if (result.endsWith("&")) { + result = result.substring(0, result.length() - 1); + } + + if (!result.isEmpty()) { + result = "?" + result; + } + + return result; + } + + private void extractQueryTemplates(String queryString, boolean append) { + /* split the query string up into name value pairs */ + Map> queryParameters = + Arrays.stream(queryString.split("&")) + .map(this::splitQueryParameter) + .collect(Collectors.groupingBy( + SimpleImmutableEntry::getKey, + LinkedHashMap::new, + Collectors.mapping(Entry::getValue, Collectors.toList()))); + + /* add them to this template */ + if (!append) { + /* clear the queries and use the new ones */ + this.queries.clear(); + } + queryParameters.forEach(this::query); + } + + private SimpleImmutableEntry splitQueryParameter(String pair) { + int eq = pair.indexOf("="); + final String name = (eq > 0) ? pair.substring(0, eq) : pair; + final String value = (eq > 0 && eq < pair.length()) ? pair.substring(eq + 1) : null; + return new SimpleImmutableEntry<>(name, value); + } + + @Experimental + public RequestTemplate methodMetadata(MethodMetadata methodMetadata) { + this.methodMetadata = methodMetadata; + return this; + } + + @Experimental + public RequestTemplate feignTarget(Target feignTarget) { + this.feignTarget = feignTarget; + return this; + } + + @Experimental + public MethodMetadata methodMetadata() { + return methodMetadata; + } + + @Experimental + public Target feignTarget() { + return feignTarget; + } + + /** + * Factory for creating RequestTemplate. + */ + interface Factory { + + /** + * create a request template using args passed to a method invocation. + */ + RequestTemplate create(Object[] argv); + } + +} diff --git a/core/src/main/java/feign/RequestTemplateFactoryResolver.java b/core/src/main/java/feign/RequestTemplateFactoryResolver.java new file mode 100644 index 000000000..6dd69de89 --- /dev/null +++ b/core/src/main/java/feign/RequestTemplateFactoryResolver.java @@ -0,0 +1,283 @@ +/* + * Copyright 2012-2023 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.checkArgument; +import static feign.Util.checkNotNull; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.template.UriUtils; + +final class RequestTemplateFactoryResolver { + private final Encoder encoder; + private final QueryMapEncoder queryMapEncoder; + + RequestTemplateFactoryResolver( + Encoder encoder, + QueryMapEncoder queryMapEncoder) { + this.encoder = checkNotNull(encoder, "encoder"); + this.queryMapEncoder = checkNotNull(queryMapEncoder, "queryMapEncoder"); + } + + public RequestTemplate.Factory resolve(Target target, MethodMetadata md) { + if (!md.formParams().isEmpty() && md.template().bodyTemplate() == null) { + return new BuildFormEncodedTemplateFromArgs(md, encoder, queryMapEncoder, target); + } else if (md.bodyIndex() != null || md.alwaysEncodeBody()) { + return new BuildEncodedTemplateFromArgs(md, encoder, queryMapEncoder, target); + } else { + return new BuildTemplateByResolvingArgs(md, queryMapEncoder, target); + } + } + + private static class BuildTemplateByResolvingArgs implements RequestTemplate.Factory { + + private final QueryMapEncoder queryMapEncoder; + + protected final MethodMetadata metadata; + protected final Target target; + private final Map indexToExpander = + new LinkedHashMap(); + + private BuildTemplateByResolvingArgs(MethodMetadata metadata, QueryMapEncoder queryMapEncoder, + Target target) { + this.metadata = metadata; + this.target = target; + this.queryMapEncoder = queryMapEncoder; + if (metadata.indexToExpander() != null) { + indexToExpander.putAll(metadata.indexToExpander()); + return; + } + if (metadata.indexToExpanderClass().isEmpty()) { + return; + } + for (Map.Entry> indexToExpanderClass : metadata + .indexToExpanderClass().entrySet()) { + try { + indexToExpander + .put(indexToExpanderClass.getKey(), indexToExpanderClass.getValue().newInstance()); + } catch (InstantiationException e) { + throw new IllegalStateException(e); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } + } + } + + @Override + public RequestTemplate create(Object[] argv) { + RequestTemplate mutable = RequestTemplate.from(metadata.template()); + mutable.feignTarget(target); + if (metadata.urlIndex() != null) { + int urlIndex = metadata.urlIndex(); + checkArgument(argv[urlIndex] != null, "URI parameter %s was null", urlIndex); + mutable.target(String.valueOf(argv[urlIndex])); + } + Map varBuilder = new LinkedHashMap(); + for (Map.Entry> entry : metadata.indexToName().entrySet()) { + int i = entry.getKey(); + Object value = argv[entry.getKey()]; + if (value != null) { // Null values are skipped. + if (indexToExpander.containsKey(i)) { + value = expandElements(indexToExpander.get(i), value); + } + for (String name : entry.getValue()) { + varBuilder.put(name, value); + } + } + } + + RequestTemplate template = resolve(argv, mutable, varBuilder); + if (metadata.queryMapIndex() != null) { + // add query map parameters after initial resolve so that they take + // precedence over any predefined values + Object value = argv[metadata.queryMapIndex()]; + Map queryMap = toQueryMap(value, metadata.queryMapEncoder()); + template = addQueryMapQueryParameters(queryMap, template); + } + + if (metadata.headerMapIndex() != null) { + // add header map parameters for a resolution of the user pojo object + Object value = argv[metadata.headerMapIndex()]; + Map headerMap = toQueryMap(value, metadata.queryMapEncoder()); + template = addHeaderMapHeaders(headerMap, template); + } + + return template; + } + + private Map toQueryMap(Object value, QueryMapEncoder queryMapEncoder) { + if (value instanceof Map) { + return (Map) value; + } + try { + // encode with @QueryMap annotation if exists otherwise with the one from this resolver + return queryMapEncoder != null ? queryMapEncoder.encode(value) + : this.queryMapEncoder.encode(value); + } catch (EncodeException e) { + throw new IllegalStateException(e); + } + } + + private Object expandElements(Param.Expander expander, Object value) { + if (value instanceof Iterable) { + return expandIterable(expander, (Iterable) value); + } + return expander.expand(value); + } + + private List expandIterable(Param.Expander expander, Iterable value) { + List values = new ArrayList(); + for (Object element : value) { + if (element != null) { + values.add(expander.expand(element)); + } + } + return values; + } + + @SuppressWarnings("unchecked") + private RequestTemplate addHeaderMapHeaders(Map headerMap, + RequestTemplate mutable) { + for (Map.Entry currEntry : headerMap.entrySet()) { + Collection values = new ArrayList(); + + Object currValue = currEntry.getValue(); + if (currValue instanceof Iterable) { + Iterator iter = ((Iterable) currValue).iterator(); + while (iter.hasNext()) { + Object nextObject = iter.next(); + values.add(nextObject == null ? null : nextObject.toString()); + } + } else { + values.add(currValue == null ? null : currValue.toString()); + } + + mutable.header(currEntry.getKey(), values); + } + return mutable; + } + + @SuppressWarnings("unchecked") + private RequestTemplate addQueryMapQueryParameters(Map queryMap, + RequestTemplate mutable) { + for (Map.Entry currEntry : queryMap.entrySet()) { + Collection values = new ArrayList(); + + Object currValue = currEntry.getValue(); + if (currValue instanceof Iterable) { + Iterator iter = ((Iterable) currValue).iterator(); + while (iter.hasNext()) { + Object nextObject = iter.next(); + values.add(nextObject == null ? null : UriUtils.encode(nextObject.toString())); + } + } else if (currValue instanceof Object[]) { + for (Object value : (Object[]) currValue) { + values.add(value == null ? null : UriUtils.encode(value.toString())); + } + } else { + if (currValue != null) { + values.add(UriUtils.encode(currValue.toString())); + } + } + + if (values.size() > 0) { + mutable.query(UriUtils.encode(currEntry.getKey()), values); + } + } + return mutable; + } + + protected RequestTemplate resolve(Object[] argv, + RequestTemplate mutable, + Map variables) { + return mutable.resolve(variables); + } + } + + private static class BuildFormEncodedTemplateFromArgs extends BuildTemplateByResolvingArgs { + + private final Encoder encoder; + + private BuildFormEncodedTemplateFromArgs(MethodMetadata metadata, Encoder encoder, + QueryMapEncoder queryMapEncoder, Target target) { + super(metadata, queryMapEncoder, target); + this.encoder = encoder; + } + + @Override + protected RequestTemplate resolve(Object[] argv, + RequestTemplate mutable, + Map variables) { + Map formVariables = new LinkedHashMap(); + for (Map.Entry entry : variables.entrySet()) { + if (metadata.formParams().contains(entry.getKey())) { + formVariables.put(entry.getKey(), entry.getValue()); + } + } + try { + encoder.encode(formVariables, Encoder.MAP_STRING_WILDCARD, mutable); + } catch (EncodeException e) { + throw e; + } catch (RuntimeException e) { + throw new EncodeException(e.getMessage(), e); + } + return super.resolve(argv, mutable, variables); + } + } + + private static class BuildEncodedTemplateFromArgs extends BuildTemplateByResolvingArgs { + + private final Encoder encoder; + + private BuildEncodedTemplateFromArgs(MethodMetadata metadata, Encoder encoder, + QueryMapEncoder queryMapEncoder, Target target) { + super(metadata, queryMapEncoder, target); + this.encoder = encoder; + } + + @Override + protected RequestTemplate resolve(Object[] argv, + RequestTemplate mutable, + Map variables) { + + boolean alwaysEncodeBody = mutable.methodMetadata().alwaysEncodeBody(); + + Object body = null; + if (!alwaysEncodeBody) { + body = argv[metadata.bodyIndex()]; + checkArgument(body != null, "Body parameter %s was null", metadata.bodyIndex()); + } + + try { + if (alwaysEncodeBody) { + body = argv == null ? new Object[0] : argv; + encoder.encode(body, Object[].class, mutable); + } else { + encoder.encode(body, metadata.bodyType(), mutable); + } + } catch (EncodeException e) { + throw e; + } catch (RuntimeException e) { + throw new EncodeException(e.getMessage(), e); + } + return super.resolve(argv, mutable, variables); + } + } +} diff --git a/core/src/main/java/feign/Response.java b/core/src/main/java/feign/Response.java new file mode 100644 index 000000000..72a6d1acc --- /dev/null +++ b/core/src/main/java/feign/Response.java @@ -0,0 +1,392 @@ +/* + * Copyright 2012-2024 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.*; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.*; +import feign.Request.ProtocolVersion; +import static feign.Util.*; + +/** + * An immutable response to an http invocation which only returns string content. + */ +public final class Response implements Closeable { + + private final int status; + private final String reason; + private final Map> headers; + private final Body body; + private final Request request; + private final ProtocolVersion protocolVersion; + + private Response(Builder builder) { + checkState(builder.request != null, "original request is required"); + this.status = builder.status; + this.request = builder.request; + this.reason = builder.reason; // nullable + this.headers = caseInsensitiveCopyOf(builder.headers); + this.body = builder.body; // nullable + this.protocolVersion = builder.protocolVersion; + } + + public Builder toBuilder() { + return new Builder(this); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private static final ProtocolVersion DEFAULT_PROTOCOL_VERSION = ProtocolVersion.HTTP_1_1; + int status; + String reason; + Map> headers; + Body body; + Request request; + private RequestTemplate requestTemplate; + private ProtocolVersion protocolVersion = DEFAULT_PROTOCOL_VERSION; + + Builder() {} + + Builder(Response source) { + this.status = source.status; + this.reason = source.reason; + this.headers = source.headers; + this.body = source.body; + this.request = source.request; + this.protocolVersion = source.protocolVersion; + } + + /** @see Response#status */ + public Builder status(int status) { + this.status = status; + return this; + } + + /** @see Response#reason */ + public Builder reason(String reason) { + this.reason = reason; + return this; + } + + /** @see Response#headers */ + public Builder headers(Map> headers) { + this.headers = headers; + return this; + } + + /** @see Response#body */ + public Builder body(Body body) { + this.body = body; + return this; + } + + /** @see Response#body */ + public Builder body(InputStream inputStream, Integer length) { + this.body = InputStreamBody.orNull(inputStream, length); + return this; + } + + /** @see Response#body */ + public Builder body(byte[] data) { + this.body = ByteArrayBody.orNull(data); + return this; + } + + /** @see Response#body */ + public Builder body(String text, Charset charset) { + this.body = ByteArrayBody.orNull(text, charset); + return this; + } + + /** + * @see Response#request + */ + public Builder request(Request request) { + checkNotNull(request, "request is required"); + this.request = request; + return this; + } + + /** + * HTTP protocol version + */ + public Builder protocolVersion(ProtocolVersion protocolVersion) { + this.protocolVersion = (protocolVersion != null) ? protocolVersion : DEFAULT_PROTOCOL_VERSION; + return this; + } + + /** + * The Request Template used for the original request. + * + * @param requestTemplate used. + * @return builder reference. + */ + @Experimental + public Builder requestTemplate(RequestTemplate requestTemplate) { + this.requestTemplate = requestTemplate; + return this; + } + + public Response build() { + return new Response(this); + } + } + + /** + * status code. ex {@code 200} + * + * See rfc2616 + */ + public int status() { + return status; + } + + /** + * Nullable and not set when using http/2 See + * ... See + * ... + */ + public String reason() { + return reason; + } + + /** + * Returns a case-insensitive mapping of header names to their values. + */ + public Map> headers() { + return headers; + } + + /** + * if present, the response had a body + */ + public Body body() { + return body; + } + + /** + * the request that generated this response + */ + public Request request() { + return request; + } + + /** + * the HTTP protocol version + * + * @return HTTP protocol version or empty if a client does not provide it + */ + public ProtocolVersion protocolVersion() { + return protocolVersion; + } + + /** + * Returns a charset object based on the requests content type. Defaults to UTF-8 See + * rfc7231 - + * Accept-Charset See + * rfc7231 - Media + * Type + */ + public Charset charset() { + + Collection contentTypeHeaders = headers().get("Content-Type"); + + if (contentTypeHeaders != null) { + for (String contentTypeHeader : contentTypeHeaders) { + String[] contentTypeParmeters = contentTypeHeader.split(";"); + if (contentTypeParmeters.length > 1) { + String[] charsetParts = contentTypeParmeters[1].split("="); + if (charsetParts.length == 2 && "charset".equalsIgnoreCase(charsetParts[0].trim())) { + String charsetString = charsetParts[1].replaceAll("\"", ""); + return Charset.forName(charsetString); + } + } + } + } + + return Util.UTF_8; + } + + @Override + public String toString() { + StringBuilder builder = + new StringBuilder(protocolVersion.toString()).append(" ").append(status); + if (reason != null) + builder.append(' ').append(reason); + builder.append('\n'); + for (String field : headers.keySet()) { + for (String value : valuesOrEmpty(headers, field)) { + builder.append(field).append(": ").append(value).append('\n'); + } + } + if (body != null) + builder.append('\n').append(body); + return builder.toString(); + } + + @Override + public void close() { + Util.ensureClosed(body); + } + + public interface Body extends Closeable { + + /** + * length in bytes, if known. Null if unknown or greater than {@link Integer#MAX_VALUE}. + * + *
+ *
+ *
+ * Note
+ * This is an integer as most implementations cannot do bodies greater than 2GB. + */ + Integer length(); + + /** + * True if {@link #asInputStream()} and {@link #asReader()} can be called more than once. + */ + boolean isRepeatable(); + + /** + * It is the responsibility of the caller to close the stream. + */ + InputStream asInputStream() throws IOException; + + /** + * It is the responsibility of the caller to close the stream. + * + * @deprecated favor {@link Body#asReader(Charset)} + */ + @Deprecated + default Reader asReader() throws IOException { + return asReader(StandardCharsets.UTF_8); + } + + /** + * It is the responsibility of the caller to close the stream. + */ + Reader asReader(Charset charset) throws IOException; + } + + private static final class InputStreamBody implements Response.Body { + + private final InputStream inputStream; + private final Integer length; + + private InputStreamBody(InputStream inputStream, Integer length) { + this.inputStream = inputStream; + this.length = length; + } + + private static Body orNull(InputStream inputStream, Integer length) { + if (inputStream == null) { + return null; + } + return new InputStreamBody(inputStream, length); + } + + @Override + public Integer length() { + return length; + } + + @Override + public boolean isRepeatable() { + return false; + } + + @Override + public InputStream asInputStream() { + return inputStream; + } + + @SuppressWarnings("deprecation") + @Override + public Reader asReader() { + return new InputStreamReader(inputStream, UTF_8); + } + + @Override + public Reader asReader(Charset charset) { + checkNotNull(charset, "charset should not be null"); + return new InputStreamReader(inputStream, charset); + } + + @Override + public void close() throws IOException { + inputStream.close(); + } + + } + + private static final class ByteArrayBody implements Response.Body { + + private final byte[] data; + + public ByteArrayBody(byte[] data) { + this.data = data; + } + + private static Body orNull(byte[] data) { + if (data == null) { + return null; + } + return new ByteArrayBody(data); + } + + private static Body orNull(String text, Charset charset) { + if (text == null) { + return null; + } + checkNotNull(charset, "charset"); + return new ByteArrayBody(text.getBytes(charset)); + } + + @Override + public Integer length() { + return data.length; + } + + @Override + public boolean isRepeatable() { + return true; + } + + @Override + public InputStream asInputStream() { + return new ByteArrayInputStream(data); + } + + @SuppressWarnings("deprecation") + @Override + public Reader asReader() { + return new InputStreamReader(asInputStream(), UTF_8); + } + + @Override + public Reader asReader(Charset charset) { + checkNotNull(charset, "charset should not be null"); + return new InputStreamReader(asInputStream(), charset); + } + + @Override + public void close() {} + + } + +} diff --git a/core/src/main/java/feign/ResponseHandler.java b/core/src/main/java/feign/ResponseHandler.java new file mode 100755 index 000000000..b9e1d5876 --- /dev/null +++ b/core/src/main/java/feign/ResponseHandler.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-2023 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 feign.Logger.Level; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; +import java.io.IOException; +import java.lang.reflect.Type; + +/** + * The response handler that is used to provide synchronous support on top of standard response + * handling + */ +public class ResponseHandler { + + private final Level logLevel; + private final Logger logger; + + private final Decoder decoder; + private final ErrorDecoder errorDecoder; + private final boolean dismiss404; + private final boolean closeAfterDecode; + + private final boolean decodeVoid; + + private final ResponseInterceptor.Chain executionChain; + + public ResponseHandler(Level logLevel, Logger logger, Decoder decoder, ErrorDecoder errorDecoder, + boolean dismiss404, boolean closeAfterDecode, boolean decodeVoid, + ResponseInterceptor.Chain executionChain) { + super(); + this.logLevel = logLevel; + this.logger = logger; + this.decoder = decoder; + this.errorDecoder = errorDecoder; + this.dismiss404 = dismiss404; + this.closeAfterDecode = closeAfterDecode; + this.decodeVoid = decodeVoid; + this.executionChain = executionChain; + } + + public Object handleResponse(String configKey, + Response response, + Type returnType, + long elapsedTime) + throws Exception { + try { + response = logAndRebufferResponseIfNeeded(configKey, response, elapsedTime); + return executionChain.next( + new InvocationContext(configKey, decoder, errorDecoder, dismiss404, closeAfterDecode, + decodeVoid, response, returnType)); + } catch (final IOException e) { + if (logLevel != Level.NONE) { + logger.logIOException(configKey, logLevel, e, elapsedTime); + } + throw errorReading(response.request(), response, e); + } catch (Exception e) { + ensureClosed(response.body()); + throw e; + } + } + + private Response logAndRebufferResponseIfNeeded(String configKey, + Response response, + long elapsedTime) + throws IOException { + if (logLevel == Level.NONE) { + return response; + } + + return logger.logAndRebufferResponse(configKey, logLevel, response, elapsedTime); + } +} diff --git a/core/src/main/java/feign/ResponseInterceptor.java b/core/src/main/java/feign/ResponseInterceptor.java new file mode 100755 index 000000000..cc9377d75 --- /dev/null +++ b/core/src/main/java/feign/ResponseInterceptor.java @@ -0,0 +1,71 @@ +/* + * Copyright 2012-2023 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; + +/** + * {@code ResponseInterceptor}s may be configured for purposes such as verifying or modifying + * headers of response, verifying the business status of decoded object, or processing responses to + * unsuccessful requests. Once the interceptors are applied, + * {@link ResponseInterceptor#intercept(InvocationContext, Chain)} is called, then the response is + * decoded. + */ +public interface ResponseInterceptor { + + /** + * Called by {@link ResponseHandler} after refreshing the response and wrapped around the whole + * decode process, must either manually invoke {@link Chain#next(InvocationContext)} or manually + * create a new response object + * + * @param invocationContext information surrounding the response being decoded + * @return decoded response + */ + Object intercept(InvocationContext invocationContext, Chain chain) throws Exception; + + /** + * Return a new {@link ResponseInterceptor} that invokes the current interceptor first and then + * the one that is passed in. + * + * @param nextInterceptor the interceptor to delegate to after the current + * @return a new interceptor that chains the two + */ + default ResponseInterceptor andThen(ResponseInterceptor nextInterceptor) { + return (ic, chain) -> intercept(ic, + nextContext -> nextInterceptor.intercept(nextContext, chain)); + } + + /** + * Contract for delegation to the rest of the chain. + */ + interface Chain { + Chain DEFAULT = InvocationContext::proceed; + + /** + * Delegate to the rest of the chain to execute the request. + * + * @param context the request to execute the {@link Chain} . + * @return the response + */ + Object next(InvocationContext context) throws Exception; + } + + /** + * Apply this interceptor to the given {@code Chain} resulting in an intercepted chain. + * + * @param chain the chain to add interception around + * @return a new chain instance + */ + default Chain apply(Chain chain) { + return request -> intercept(request, chain); + } +} diff --git a/core/src/main/java/feign/ResponseMapper.java b/core/src/main/java/feign/ResponseMapper.java new file mode 100644 index 000000000..f9ecfc522 --- /dev/null +++ b/core/src/main/java/feign/ResponseMapper.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.lang.reflect.Type; + +/** + * Map function to apply to the response before decoding it. + * + *
+ * {@code
+ * new ResponseMapper() {
+ *   @Override
+ *   public Response map(Response response, Type type) {
+ *     try {
+ *       return response
+ *           .toBuilder()
+ *           .body(Util.toString(response.body().asReader()).toUpperCase().getBytes())
+ *           .build();
+ *     } catch (IOException e) {
+ *       throw new RuntimeException(e);
+ *     }
+ *   }
+ * };
+ * }
+ * 
+ */ +public interface ResponseMapper { + + Response map(Response response, Type type); +} diff --git a/core/src/main/java/feign/RetryableException.java b/core/src/main/java/feign/RetryableException.java new file mode 100644 index 000000000..d7c6a5b7a --- /dev/null +++ b/core/src/main/java/feign/RetryableException.java @@ -0,0 +1,100 @@ +/* + * Copyright 2012-2024 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.Request.HttpMethod; +import java.util.Collection; +import java.util.Date; +import java.util.Map; + +/** + * This exception is raised when the {@link Response} is deemed to be retryable, typically via an + * {@link feign.codec.ErrorDecoder} when the {@link Response#status() status} is 503. + */ +public class RetryableException extends FeignException { + + private static final long serialVersionUID = 2L; + + private final Long retryAfter; + private final HttpMethod httpMethod; + + /** + * @param retryAfter usually corresponds to the {@link feign.Util#RETRY_AFTER} header. If you + * don't want to retry, set null. + */ + public RetryableException(int status, String message, HttpMethod httpMethod, Throwable cause, + Long retryAfter, Request request) { + super(status, message, request, cause); + this.httpMethod = httpMethod; + this.retryAfter = retryAfter; + } + + @Deprecated + public RetryableException(int status, String message, HttpMethod httpMethod, Throwable cause, + Date retryAfter, Request request) { + super(status, message, request, cause); + this.httpMethod = httpMethod; + this.retryAfter = retryAfter != null ? retryAfter.getTime() : null; + } + + /** + * @param retryAfter usually corresponds to the {@link feign.Util#RETRY_AFTER} header. If you + * don't want to retry, set null. + */ + public RetryableException(int status, String message, HttpMethod httpMethod, Long retryAfter, + Request request) { + super(status, message, request); + this.httpMethod = httpMethod; + this.retryAfter = retryAfter; + } + + @Deprecated + public RetryableException(int status, String message, HttpMethod httpMethod, Date retryAfter, + Request request) { + super(status, message, request); + this.httpMethod = httpMethod; + this.retryAfter = retryAfter != null ? retryAfter.getTime() : null; + } + + /** + * @param retryAfter usually corresponds to the {@link feign.Util#RETRY_AFTER} header. + */ + public RetryableException(int status, String message, HttpMethod httpMethod, Long retryAfter, + Request request, byte[] responseBody, + Map> responseHeaders) { + super(status, message, request, responseBody, responseHeaders); + this.httpMethod = httpMethod; + this.retryAfter = retryAfter; + } + + @Deprecated + public RetryableException(int status, String message, HttpMethod httpMethod, Date retryAfter, + Request request, byte[] responseBody, Map> responseHeaders) { + super(status, message, request, responseBody, responseHeaders); + this.httpMethod = httpMethod; + this.retryAfter = retryAfter != null ? retryAfter.getTime() : null; + } + + /** + * Sometimes corresponds to the {@link feign.Util#RETRY_AFTER} header present in {@code 503} + * status. Other times parsed from an application-specific response. Null if unknown. + */ + public Long retryAfter() { + return retryAfter; + } + + public HttpMethod method() { + return this.httpMethod; + } +} diff --git a/core/src/main/java/feign/Retryer.java b/core/src/main/java/feign/Retryer.java new file mode 100644 index 000000000..43dac3573 --- /dev/null +++ b/core/src/main/java/feign/Retryer.java @@ -0,0 +1,114 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import static java.util.concurrent.TimeUnit.SECONDS; + +/** + * Cloned for each invocation to {@link Client#execute(Request, feign.Request.Options)}. + * Implementations may keep state to determine if retry operations should continue or not. + */ +public interface Retryer extends Cloneable { + + /** + * if retry is permitted, return (possibly after sleeping). Otherwise, propagate the exception. + */ + void continueOrPropagate(RetryableException e); + + Retryer clone(); + + class Default implements Retryer { + + private final int maxAttempts; + private final long period; + private final long maxPeriod; + int attempt; + long sleptForMillis; + + public Default() { + this(100, SECONDS.toMillis(1), 5); + } + + public Default(long period, long maxPeriod, int maxAttempts) { + this.period = period; + this.maxPeriod = maxPeriod; + this.maxAttempts = maxAttempts; + this.attempt = 1; + } + + // visible for testing; + protected long currentTimeMillis() { + return System.currentTimeMillis(); + } + + public void continueOrPropagate(RetryableException e) { + if (attempt++ >= maxAttempts) { + throw e; + } + + long interval; + if (e.retryAfter() != null) { + interval = e.retryAfter() - currentTimeMillis(); + if (interval > maxPeriod) { + interval = maxPeriod; + } + if (interval < 0) { + return; + } + } else { + interval = nextMaxInterval(); + } + try { + Thread.sleep(interval); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + throw e; + } + sleptForMillis += interval; + } + + /** + * Calculates the time interval to a retry attempt.
+ * The interval increases exponentially with each attempt, at a rate of nextInterval *= 1.5 + * (where 1.5 is the backoff factor), to the maximum interval. + * + * @return time in milliseconds from now until the next attempt. + */ + long nextMaxInterval() { + long interval = (long) (period * Math.pow(1.5, attempt - 1)); + return Math.min(interval, maxPeriod); + } + + @Override + public Retryer clone() { + return new Default(period, maxPeriod, maxAttempts); + } + } + + /** + * Implementation that never retries request. It propagates the RetryableException. + */ + Retryer NEVER_RETRY = new Retryer() { + + @Override + public void continueOrPropagate(RetryableException e) { + throw e; + } + + @Override + public Retryer clone() { + return this; + } + }; +} diff --git a/core/src/main/java/feign/SynchronousMethodHandler.java b/core/src/main/java/feign/SynchronousMethodHandler.java new file mode 100644 index 000000000..f113fb3ea --- /dev/null +++ b/core/src/main/java/feign/SynchronousMethodHandler.java @@ -0,0 +1,169 @@ +/* + * Copyright 2012-2024 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.ExceptionPropagationPolicy.UNWRAP; +import static feign.FeignException.errorExecuting; +import static feign.Util.checkNotNull; +import feign.InvocationHandlerFactory.MethodHandler; +import feign.Request.Options; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +final class SynchronousMethodHandler implements MethodHandler { + + private final Client client; + private final ResponseHandler responseHandler; + private final MethodHandlerConfiguration methodHandlerConfiguration; + + private SynchronousMethodHandler(MethodHandlerConfiguration methodHandlerConfiguration, + Client client, ResponseHandler responseHandler) { + + this.methodHandlerConfiguration = + checkNotNull(methodHandlerConfiguration, "methodHandlerConfiguration"); + this.client = checkNotNull(client, "client for %s", methodHandlerConfiguration.getTarget()); + this.responseHandler = responseHandler; + } + + @Override + public Object invoke(Object[] argv) throws Throwable { + RequestTemplate template = methodHandlerConfiguration.getBuildTemplateFromArgs().create(argv); + Options options = findOptions(argv); + Retryer retryer = this.methodHandlerConfiguration.getRetryer().clone(); + while (true) { + try { + return executeAndDecode(template, options); + } catch (RetryableException e) { + try { + retryer.continueOrPropagate(e); + } catch (RetryableException th) { + Throwable cause = th.getCause(); + if (methodHandlerConfiguration.getPropagationPolicy() == UNWRAP && cause != null) { + throw cause; + } else { + throw th; + } + } + if (methodHandlerConfiguration.getLogLevel() != Logger.Level.NONE) { + methodHandlerConfiguration.getLogger().logRetry( + methodHandlerConfiguration.getMetadata().configKey(), + methodHandlerConfiguration.getLogLevel()); + } + continue; + } + } + } + + Object executeAndDecode(RequestTemplate template, Options options) throws Throwable { + Request request = targetRequest(template); + + if (methodHandlerConfiguration.getLogLevel() != Logger.Level.NONE) { + methodHandlerConfiguration.getLogger().logRequest( + methodHandlerConfiguration.getMetadata().configKey(), + methodHandlerConfiguration.getLogLevel(), request); + } + + Response response; + long start = System.nanoTime(); + try { + response = client.execute(request, options); + // ensure the request is set. TODO: remove in Feign 12 + response = response.toBuilder() + .request(request) + .requestTemplate(template) + .build(); + } catch (IOException e) { + if (methodHandlerConfiguration.getLogLevel() != Logger.Level.NONE) { + methodHandlerConfiguration.getLogger().logIOException( + methodHandlerConfiguration.getMetadata().configKey(), + methodHandlerConfiguration.getLogLevel(), e, elapsedTime(start)); + } + throw errorExecuting(request, e); + } + + long elapsedTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + return responseHandler.handleResponse( + methodHandlerConfiguration.getMetadata().configKey(), response, + methodHandlerConfiguration.getMetadata().returnType(), elapsedTime); + } + + long elapsedTime(long start) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + } + + Request targetRequest(RequestTemplate template) { + for (RequestInterceptor interceptor : methodHandlerConfiguration.getRequestInterceptors()) { + interceptor.apply(template); + } + return methodHandlerConfiguration.getTarget().apply(template); + } + + Options findOptions(Object[] argv) { + if (argv == null || argv.length == 0) { + return this.methodHandlerConfiguration.getOptions() + .getMethodOptions(methodHandlerConfiguration.getMetadata().method().getName()); + } + return Stream.of(argv) + .filter(Options.class::isInstance) + .map(Options.class::cast) + .findFirst() + .orElse(this.methodHandlerConfiguration.getOptions() + .getMethodOptions(methodHandlerConfiguration.getMetadata().method().getName())); + } + + static class Factory implements MethodHandler.Factory { + + private final Client client; + private final Retryer retryer; + private final List requestInterceptors; + private final ResponseHandler responseHandler; + private final Logger logger; + private final Logger.Level logLevel; + private final ExceptionPropagationPolicy propagationPolicy; + private final RequestTemplateFactoryResolver requestTemplateFactoryResolver; + private final Options options; + + Factory(Client client, Retryer retryer, List requestInterceptors, + ResponseHandler responseHandler, + Logger logger, Logger.Level logLevel, + ExceptionPropagationPolicy propagationPolicy, + RequestTemplateFactoryResolver requestTemplateFactoryResolver, + Options options) { + this.client = checkNotNull(client, "client"); + this.retryer = checkNotNull(retryer, "retryer"); + this.requestInterceptors = checkNotNull(requestInterceptors, "requestInterceptors"); + this.responseHandler = checkNotNull(responseHandler, "responseHandler"); + this.logger = checkNotNull(logger, "logger"); + this.logLevel = checkNotNull(logLevel, "logLevel"); + this.propagationPolicy = propagationPolicy; + this.requestTemplateFactoryResolver = + checkNotNull(requestTemplateFactoryResolver, "requestTemplateFactoryResolver"); + this.options = checkNotNull(options, "options"); + } + + @Override + public MethodHandler create(Target target, + MethodMetadata md, + Object requestContext) { + final RequestTemplate.Factory buildTemplateFromArgs = + requestTemplateFactoryResolver.resolve(target, md); + MethodHandlerConfiguration methodHandlerConfiguration = + new MethodHandlerConfiguration(md, target, retryer, requestInterceptors, logger, logLevel, + buildTemplateFromArgs, options, propagationPolicy); + return new SynchronousMethodHandler(methodHandlerConfiguration, client, responseHandler); + } + } +} diff --git a/core/src/main/java/feign/Target.java b/core/src/main/java/feign/Target.java new file mode 100644 index 000000000..3f711a77d --- /dev/null +++ b/core/src/main/java/feign/Target.java @@ -0,0 +1,204 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import static feign.Util.checkNotNull; +import static feign.Util.emptyToNull; + +/** + *
+ *
+ * relationship to JAXRS 2.0
+ *
+ * Similar to {@code + * javax.ws.rs.client.WebTarget}, as it produces requests. However, {@link RequestTemplate} is a + * closer match to {@code WebTarget}. + * + * @param type of the interface this target applies to. + */ +public interface Target { + + /* The type of the interface this target applies to. ex. {@code Route53}. */ + Class type(); + + /* configuration key associated with this target. For example, {@code route53}. */ + String name(); + + /* base HTTP URL of the target. For example, {@code https://api/v2}. */ + String url(); + + /** + * Targets a template to this target, adding the {@link #url() base url} and any target-specific + * headers or query parameters.
+ *
+ * For example:
+ * + *
+   * public Request apply(RequestTemplate input) {
+   *   input.insert(0, url());
+   *   input.replaceHeader("X-Auth", currentToken);
+   *   return input.asRequest();
+   * }
+   * 
+ * + *
+ *
+ *
+ * relationship to JAXRS 2.0
+ *
+ * This call is similar to {@code + * javax.ws.rs.client.WebTarget.request()}, except that we expect transient, but necessary + * decoration to be applied on invocation. + */ + public Request apply(RequestTemplate input); + + public static class HardCodedTarget implements Target { + + private final Class type; + private final String name; + private final String url; + + public HardCodedTarget(Class type, String url) { + this(type, url, url); + } + + public HardCodedTarget(Class type, String name, String url) { + this.type = checkNotNull(type, "type"); + this.name = checkNotNull(emptyToNull(name), "name"); + this.url = checkNotNull(emptyToNull(url), "url"); + } + + @Override + public Class type() { + return type; + } + + @Override + public String name() { + return name; + } + + @Override + public String url() { + return url; + } + + /* no authentication or other special activity. just insert the url. */ + @Override + public Request apply(RequestTemplate input) { + if (input.url().indexOf("http") != 0) { + input.target(url()); + } + return input.request(); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof HardCodedTarget) { + HardCodedTarget other = (HardCodedTarget) obj; + return type.equals(other.type) + && name.equals(other.name) + && url.equals(other.url); + } + return false; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + type.hashCode(); + result = 31 * result + name.hashCode(); + result = 31 * result + url.hashCode(); + return result; + } + + @Override + public String toString() { + if (name.equals(url)) { + return "HardCodedTarget(type=" + type.getSimpleName() + ", url=" + url + ")"; + } + return "HardCodedTarget(type=" + type.getSimpleName() + ", name=" + name + ", url=" + url + + ")"; + } + } + + public static final class EmptyTarget implements Target { + + private final Class type; + private final String name; + + EmptyTarget(Class type, String name) { + this.type = checkNotNull(type, "type"); + this.name = checkNotNull(emptyToNull(name), "name"); + } + + public static EmptyTarget create(Class type) { + return new EmptyTarget(type, "empty:" + type.getSimpleName()); + } + + public static EmptyTarget create(Class type, String name) { + return new EmptyTarget(type, name); + } + + @Override + public Class type() { + return type; + } + + @Override + public String name() { + return name; + } + + @Override + public String url() { + throw new UnsupportedOperationException("Empty targets don't have URLs"); + } + + @Override + public Request apply(RequestTemplate input) { + if (input.url().indexOf("http") != 0) { + throw new UnsupportedOperationException( + "Request with non-absolute URL not supported with empty target"); + } + return input.request(); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof EmptyTarget) { + EmptyTarget other = (EmptyTarget) obj; + return type.equals(other.type) + && name.equals(other.name); + } + return false; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + type.hashCode(); + result = 31 * result + name.hashCode(); + return result; + } + + @Override + public String toString() { + if (name.equals("empty:" + type.getSimpleName())) { + return "EmptyTarget(type=" + type.getSimpleName() + ")"; + } + return "EmptyTarget(type=" + type.getSimpleName() + ", name=" + name + ")"; + } + } +} diff --git a/core/src/main/java/feign/TypedResponse.java b/core/src/main/java/feign/TypedResponse.java new file mode 100644 index 000000000..750455ee5 --- /dev/null +++ b/core/src/main/java/feign/TypedResponse.java @@ -0,0 +1,177 @@ +/* + * Copyright 2012-2023 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.ProtocolVersion; +import java.util.Collection; +import java.util.Map; +import static feign.Util.*; + +public final class TypedResponse { + + private final int status; + private final String reason; + private final Map> headers; + private final T body; + private final Request request; + private final ProtocolVersion protocolVersion; + + private TypedResponse(Builder builder) { + checkState(builder.request != null, "original request is required"); + this.status = builder.status; + this.request = builder.request; + this.reason = builder.reason; // nullable + this.headers = caseInsensitiveCopyOf(builder.headers); + this.body = builder.body; + this.protocolVersion = builder.protocolVersion; + } + + public static Builder builder() { + return new Builder(); + } + + public static Builder builder(Response source) { + return new Builder(source); + } + + public static final class Builder { + int status; + String reason; + Map> headers; + T body; + Request request; + private ProtocolVersion protocolVersion = ProtocolVersion.HTTP_1_1; + + Builder() {} + + Builder(Response source) { + this.status = source.status(); + this.reason = source.reason(); + this.headers = source.headers(); + this.request = source.request(); + this.protocolVersion = source.protocolVersion(); + } + + /** @see TypedResponse#status */ + public Builder status(int status) { + this.status = status; + return this; + } + + /** @see TypedResponse#reason */ + public Builder reason(String reason) { + this.reason = reason; + return this; + } + + /** @see TypedResponse#headers */ + public Builder headers(Map> headers) { + this.headers = headers; + return this; + } + + /** @see TypedResponse#body */ + public Builder body(T body) { + this.body = body; + return this; + } + + /** + * @see TypedResponse#request + */ + public Builder request(Request request) { + checkNotNull(request, "request is required"); + this.request = request; + return this; + } + + /** + * HTTP protocol version + */ + public Builder protocolVersion(ProtocolVersion protocolVersion) { + this.protocolVersion = protocolVersion; + return this; + } + + public TypedResponse build() { + return new TypedResponse(this); + } + } + + /** + * status code. ex {@code 200} + * + * See rfc2616 + */ + public int status() { + return status; + } + + /** + * Nullable and not set when using http/2 + * + * See https://github.com/http2/http2-spec/issues/202 + */ + public String reason() { + return reason; + } + + /** + * Returns a case-insensitive mapping of header names to their values. + */ + public Map> headers() { + return headers; + } + + /** + * if present, the response had a body + */ + public T body() { + return body; + } + + /** + * the request that generated this response + */ + public Request request() { + return request; + } + + /** + * the HTTP protocol version + * + * @return HTTP protocol version or empty if a client does not provide it + */ + public ProtocolVersion protocolVersion() { + return protocolVersion; + } + + @Override + public String toString() { + StringBuilder builder = + new StringBuilder(protocolVersion.toString()).append(" ").append(status); + if (reason != null) + builder.append(' ').append(reason); + builder.append('\n'); + for (String field : headers.keySet()) { + for (String value : valuesOrEmpty(headers, field)) { + builder.append(field).append(": ").append(value).append('\n'); + } + } + if (body != null) + builder.append('\n').append(body); + return builder.toString(); + } + +} diff --git a/core/src/main/java/feign/Types.java b/core/src/main/java/feign/Types.java new file mode 100644 index 000000000..91d3d34c9 --- /dev/null +++ b/core/src/main/java/feign/Types.java @@ -0,0 +1,528 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.lang.reflect.Array; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.GenericDeclaration; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.util.Arrays; +import java.util.NoSuchElementException; +import static feign.Util.checkState; + +/** + * Static methods for working with types. + * + * @author Bob Lee + * @author Jesse Wilson + */ +public final class Types { + + private static final Type[] EMPTY_TYPE_ARRAY = new Type[0]; + + private Types() { + // No instances. + } + + public static Class getRawType(Type type) { + if (type instanceof Class) { + // Type is a normal class. + return (Class) type; + + } else if (type instanceof ParameterizedType) { + ParameterizedType parameterizedType = (ParameterizedType) type; + + // I'm not exactly sure why getRawType() returns Type instead of Class. Neal isn't either but + // suspects some pathological case related to nested classes exists. + Type rawType = parameterizedType.getRawType(); + if (!(rawType instanceof Class)) { + throw new IllegalArgumentException(); + } + return (Class) rawType; + + } else if (type instanceof GenericArrayType) { + Type componentType = ((GenericArrayType) type).getGenericComponentType(); + return Array.newInstance(getRawType(componentType), 0).getClass(); + + } else if (type instanceof TypeVariable) { + // We could use the variable's bounds, but that won't work if there are multiple. Having a raw + // type that's more general than necessary is okay. + return Object.class; + + } else if (type instanceof WildcardType) { + return getRawType(((WildcardType) type).getUpperBounds()[0]); + + } else { + String className = type == null ? "null" : type.getClass().getName(); + throw new IllegalArgumentException("Expected a Class, ParameterizedType, or " + + "GenericArrayType, but <" + type + "> is of type " + + className); + } + } + + /** + * Returns true if {@code a} and {@code b} are equal. + */ + static boolean equals(Type a, Type b) { + if (a == b) { + return true; // Also handles (a == null && b == null). + + } else if (a instanceof Class) { + return a.equals(b); // Class already specifies equals(). + + } else if (a instanceof ParameterizedType) { + if (!(b instanceof ParameterizedType)) { + return false; + } + ParameterizedType pa = (ParameterizedType) a; + ParameterizedType pb = (ParameterizedType) b; + return equal(pa.getOwnerType(), pb.getOwnerType()) + && pa.getRawType().equals(pb.getRawType()) + && Arrays.equals(pa.getActualTypeArguments(), pb.getActualTypeArguments()); + + } else if (a instanceof GenericArrayType) { + if (!(b instanceof GenericArrayType)) { + return false; + } + GenericArrayType ga = (GenericArrayType) a; + GenericArrayType gb = (GenericArrayType) b; + return equals(ga.getGenericComponentType(), gb.getGenericComponentType()); + + } else if (a instanceof WildcardType) { + if (!(b instanceof WildcardType)) { + return false; + } + WildcardType wa = (WildcardType) a; + WildcardType wb = (WildcardType) b; + return Arrays.equals(wa.getUpperBounds(), wb.getUpperBounds()) + && Arrays.equals(wa.getLowerBounds(), wb.getLowerBounds()); + + } else if (a instanceof TypeVariable) { + if (!(b instanceof TypeVariable)) { + return false; + } + TypeVariable va = (TypeVariable) a; + TypeVariable vb = (TypeVariable) b; + return va.getGenericDeclaration() == vb.getGenericDeclaration() + && va.getName().equals(vb.getName()); + + } else { + return false; // This isn't a type we support! + } + } + + /** + * Returns the generic supertype for {@code supertype}. For example, given a class {@code + * IntegerSet}, the result for when supertype is {@code Set.class} is {@code Set} and the + * result when the supertype is {@code Collection.class} is {@code Collection}. + */ + static Type getGenericSupertype(Type context, Class rawType, Class toResolve) { + if (toResolve == rawType) { + return context; + } + + // We skip searching through interfaces if unknown is an interface. + if (toResolve.isInterface()) { + Class[] interfaces = rawType.getInterfaces(); + for (int i = 0, length = interfaces.length; i < length; i++) { + if (interfaces[i] == toResolve) { + return rawType.getGenericInterfaces()[i]; + } else if (toResolve.isAssignableFrom(interfaces[i])) { + return getGenericSupertype(rawType.getGenericInterfaces()[i], interfaces[i], toResolve); + } + } + } + + // Check our supertypes. + if (!rawType.isInterface()) { + while (rawType != Object.class) { + Class rawSupertype = rawType.getSuperclass(); + if (rawSupertype == toResolve) { + return rawType.getGenericSuperclass(); + } else if (toResolve.isAssignableFrom(rawSupertype)) { + return getGenericSupertype(rawType.getGenericSuperclass(), rawSupertype, toResolve); + } + rawType = rawSupertype; + } + } + + // We can't resolve this further. + return toResolve; + } + + private static int indexOf(Object[] array, Object toFind) { + for (int i = 0; i < array.length; i++) { + if (toFind.equals(array[i])) { + return i; + } + } + throw new NoSuchElementException(); + } + + private static boolean equal(Object a, Object b) { + return a == b || (a != null && a.equals(b)); + } + + private static int hashCodeOrZero(Object o) { + return o != null ? o.hashCode() : 0; + } + + static String typeToString(Type type) { + return type instanceof Class ? ((Class) type).getName() : type.toString(); + } + + /** + * Returns the generic form of {@code supertype}. For example, if this is {@code + * ArrayList}, this returns {@code Iterable} given the input {@code + * Iterable.class}. + * + * @param supertype a superclass of, or interface implemented by, this. + */ + static Type getSupertype(Type context, Class contextRawType, Class supertype) { + if (!supertype.isAssignableFrom(contextRawType)) { + throw new IllegalArgumentException(); + } + return resolve(context, contextRawType, + getGenericSupertype(context, contextRawType, supertype)); + } + + public static Type resolve(Type context, Class contextRawType, Type toResolve) { + // This implementation is made a little more complicated in an attempt to avoid object-creation. + while (true) { + if (toResolve instanceof TypeVariable) { + TypeVariable typeVariable = (TypeVariable) toResolve; + toResolve = resolveTypeVariable(context, contextRawType, typeVariable); + if (toResolve == typeVariable) { + return toResolve; + } + + } else if (toResolve instanceof Class && ((Class) toResolve).isArray()) { + Class original = (Class) toResolve; + Type componentType = original.getComponentType(); + Type newComponentType = resolve(context, contextRawType, componentType); + return componentType == newComponentType ? original + : new GenericArrayTypeImpl( + newComponentType); + + } else if (toResolve instanceof GenericArrayType) { + GenericArrayType original = (GenericArrayType) toResolve; + Type componentType = original.getGenericComponentType(); + Type newComponentType = resolve(context, contextRawType, componentType); + return componentType == newComponentType ? original + : new GenericArrayTypeImpl( + newComponentType); + + } else if (toResolve instanceof ParameterizedType) { + ParameterizedType original = (ParameterizedType) toResolve; + Type ownerType = original.getOwnerType(); + Type newOwnerType = resolve(context, contextRawType, ownerType); + boolean changed = newOwnerType != ownerType; + + Type[] args = original.getActualTypeArguments(); + for (int t = 0, length = args.length; t < length; t++) { + Type resolvedTypeArgument = resolve(context, contextRawType, args[t]); + if (resolvedTypeArgument != args[t]) { + if (!changed) { + args = args.clone(); + changed = true; + } + args[t] = resolvedTypeArgument; + } + } + + return changed + ? new ParameterizedTypeImpl(newOwnerType, original.getRawType(), args) + : original; + + } else if (toResolve instanceof WildcardType) { + WildcardType original = (WildcardType) toResolve; + Type[] originalLowerBound = original.getLowerBounds(); + Type[] originalUpperBound = original.getUpperBounds(); + + if (originalLowerBound.length == 1) { + Type lowerBound = resolve(context, contextRawType, originalLowerBound[0]); + if (lowerBound != originalLowerBound[0]) { + return new WildcardTypeImpl(new Type[] {Object.class}, new Type[] {lowerBound}); + } + } else if (originalUpperBound.length == 1) { + Type upperBound = resolve(context, contextRawType, originalUpperBound[0]); + if (upperBound != originalUpperBound[0]) { + return new WildcardTypeImpl(new Type[] {upperBound}, EMPTY_TYPE_ARRAY); + } + } + return original; + + } else { + return toResolve; + } + } + } + + private static Type resolveTypeVariable( + Type context, + Class contextRawType, + TypeVariable unknown) { + Class declaredByRaw = declaringClassOf(unknown); + + // We can't reduce this further. + if (declaredByRaw == null) { + return unknown; + } + + Type declaredBy = getGenericSupertype(context, contextRawType, declaredByRaw); + if (declaredBy instanceof ParameterizedType) { + int index = indexOf(declaredByRaw.getTypeParameters(), unknown); + return ((ParameterizedType) declaredBy).getActualTypeArguments()[index]; + } + + return unknown; + } + + /** + * Returns the declaring class of {@code typeVariable}, or {@code null} if it was not declared by + * a class. + */ + private static Class declaringClassOf(TypeVariable typeVariable) { + GenericDeclaration genericDeclaration = typeVariable.getGenericDeclaration(); + return genericDeclaration instanceof Class ? (Class) genericDeclaration : null; + } + + private static void checkNotPrimitive(Type type) { + if (type instanceof Class && ((Class) type).isPrimitive()) { + throw new IllegalArgumentException(); + } + } + + public static Type resolveReturnType(Type baseType, Type overridingType) { + if (baseType instanceof Class && overridingType instanceof Class && + ((Class) baseType).isAssignableFrom((Class) overridingType)) { + // NOTE: javac generates multiple same methods for multiple inherited generic interfaces + return overridingType; + } + if (baseType instanceof Class && overridingType instanceof ParameterizedType) { + // NOTE: javac will generate multiple methods with different return types + // base interface declares generic method, override declares parameterized generic method + return overridingType; + } + if (baseType instanceof Class && overridingType instanceof TypeVariable) { + // NOTE: javac will generate multiple methods with different return types + // base interface declares non generic method, override declares generic method + return overridingType; + } + return baseType; + } + + /** + * Resolves the last type parameter of the parameterized {@code supertype}, based on the {@code + * genericContext}, into its upper bounds. + *

+ * Implementation copied from {@code + * retrofit.RestMethodInfo}. + * + * @param genericContext Ex. {@link java.lang.reflect.Field#getGenericType()} + * @param supertype Ex. {@code Decoder.class} + * @return in the example above, the type parameter of {@code Decoder}. + * @throws IllegalStateException if {@code supertype} cannot be resolved into a parameterized type + * using {@code context}. + */ + public static Type resolveLastTypeParameter(Type genericContext, Class supertype) + throws IllegalStateException { + Type resolvedSuperType = + Types.getSupertype(genericContext, Types.getRawType(genericContext), supertype); + checkState(resolvedSuperType instanceof ParameterizedType, + "could not resolve %s into a parameterized type %s", + genericContext, supertype); + Type[] types = ParameterizedType.class.cast(resolvedSuperType).getActualTypeArguments(); + for (int i = 0; i < types.length; i++) { + Type type = types[i]; + if (type instanceof WildcardType) { + types[i] = ((WildcardType) type).getUpperBounds()[0]; + } + } + return types[types.length - 1]; + } + + public static ParameterizedType parameterize(Class rawClass, Type... typeArguments) { + return new ParameterizedTypeImpl(rawClass.getEnclosingClass(), rawClass, typeArguments); + } + + static final class ParameterizedTypeImpl implements ParameterizedType { + + private final Type ownerType; + private final Type rawType; + private final Type[] typeArguments; + + ParameterizedTypeImpl(Type ownerType, Type rawType, Type... typeArguments) { + // Require an owner type if the raw type needs it. + if (rawType instanceof Class + && (ownerType == null) != (((Class) rawType).getEnclosingClass() == null)) { + throw new IllegalArgumentException(); + } + + this.ownerType = ownerType; + this.rawType = rawType; + this.typeArguments = typeArguments.clone(); + + for (Type typeArgument : this.typeArguments) { + if (typeArgument == null) { + throw new NullPointerException(); + } + checkNotPrimitive(typeArgument); + } + } + + @Override + public Type[] getActualTypeArguments() { + return typeArguments.clone(); + } + + @Override + public Type getRawType() { + return rawType; + } + + @Override + public Type getOwnerType() { + return ownerType; + } + + @Override + public boolean equals(Object other) { + return other instanceof ParameterizedType && Types.equals(this, (ParameterizedType) other); + } + + @Override + public int hashCode() { + return Arrays.hashCode(typeArguments) ^ rawType.hashCode() ^ hashCodeOrZero(ownerType); + } + + @Override + public String toString() { + StringBuilder result = new StringBuilder(30 * (typeArguments.length + 1)); + result.append(typeToString(rawType)); + if (typeArguments.length == 0) { + return result.toString(); + } + result.append("<").append(typeToString(typeArguments[0])); + for (int i = 1; i < typeArguments.length; i++) { + result.append(", ").append(typeToString(typeArguments[i])); + } + return result.append(">").toString(); + } + } + + private static final class GenericArrayTypeImpl implements GenericArrayType { + + private final Type componentType; + + GenericArrayTypeImpl(Type componentType) { + this.componentType = componentType; + } + + @Override + public Type getGenericComponentType() { + return componentType; + } + + @Override + public boolean equals(Object o) { + return o instanceof GenericArrayType + && Types.equals(this, (GenericArrayType) o); + } + + @Override + public int hashCode() { + return componentType.hashCode(); + } + + @Override + public String toString() { + return typeToString(componentType) + "[]"; + } + } + + /** + * The WildcardType interface supports multiple upper bounds and multiple lower bounds. We only + * support what the Java 6 language needs - at most one bound. If a lower bound is set, the upper + * bound must be Object.class. + */ + static final class WildcardTypeImpl implements WildcardType { + + private final Type upperBound; + private final Type lowerBound; + + WildcardTypeImpl(Type[] upperBounds, Type[] lowerBounds) { + if (lowerBounds.length > 1) { + throw new IllegalArgumentException(); + } + if (upperBounds.length != 1) { + throw new IllegalArgumentException(); + } + + if (lowerBounds.length == 1) { + if (lowerBounds[0] == null) { + throw new NullPointerException(); + } + checkNotPrimitive(lowerBounds[0]); + if (upperBounds[0] != Object.class) { + throw new IllegalArgumentException(); + } + this.lowerBound = lowerBounds[0]; + this.upperBound = Object.class; + } else { + if (upperBounds[0] == null) { + throw new NullPointerException(); + } + checkNotPrimitive(upperBounds[0]); + this.lowerBound = null; + this.upperBound = upperBounds[0]; + } + } + + @Override + public Type[] getUpperBounds() { + return new Type[] {upperBound}; + } + + @Override + public Type[] getLowerBounds() { + return lowerBound != null ? new Type[] {lowerBound} : EMPTY_TYPE_ARRAY; + } + + @Override + public boolean equals(Object other) { + return other instanceof WildcardType && Types.equals(this, (WildcardType) other); + } + + @Override + public int hashCode() { + // This equals Arrays.hashCode(getLowerBounds()) ^ Arrays.hashCode(getUpperBounds()). + return (lowerBound != null ? 31 + lowerBound.hashCode() : 1) ^ (31 + upperBound.hashCode()); + } + + @Override + public String toString() { + if (lowerBound != null) { + return "? super " + typeToString(lowerBound); + } + if (upperBound == Object.class) { + return "?"; + } + return "? extends " + typeToString(upperBound); + } + } +} diff --git a/core/src/main/java/feign/Util.java b/core/src/main/java/feign/Util.java new file mode 100644 index 000000000..96885236d --- /dev/null +++ b/core/src/main/java/feign/Util.java @@ -0,0 +1,408 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import static java.lang.String.format; +import static java.util.Objects.nonNull; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Stream; + +/** + * Utilities, typically copied in from guava, so as to avoid dependency conflicts. + */ +public class Util { + + /** + * The HTTP Content-Length header field name. + */ + public static final String CONTENT_LENGTH = "Content-Length"; + /** + * The HTTP Content-Encoding header field name. + */ + public static final String CONTENT_ENCODING = "Content-Encoding"; + /** + * The HTTP Accept-Encoding header field name. + */ + public static final String ACCEPT_ENCODING = "Accept-Encoding"; + /** + * The HTTP Retry-After header field name. + */ + public static final String RETRY_AFTER = "Retry-After"; + /** + * Value for the Content-Encoding header that indicates that GZIP encoding is in use. + */ + public static final String ENCODING_GZIP = "gzip"; + /** + * Value for the Content-Encoding header that indicates that DEFLATE encoding is in use. + */ + public static final String ENCODING_DEFLATE = "deflate"; + /** + * UTF-8: eight-bit UCS Transformation Format. + */ + public static final Charset UTF_8 = Charset.forName("UTF-8"); + + // com.google.common.base.Charsets + /** + * ISO-8859-1: ISO Latin Alphabet Number 1 (ISO-LATIN-1). + */ + public static final Charset ISO_8859_1 = Charset.forName("ISO-8859-1"); + private static final int BUF_SIZE = 0x800; // 2K chars (4K bytes) + + + /** + * Type literal for {@code Map}. + */ + public static final Type MAP_STRING_WILDCARD = + new Types.ParameterizedTypeImpl(null, Map.class, String.class, + new Types.WildcardTypeImpl(new Type[] {Object.class}, new Type[0])); + + private Util() { // no instances + } + + /** + * Copy of {@code com.google.common.base.Preconditions#checkArgument}. + */ + public static void checkArgument(boolean expression, + String errorMessageTemplate, + Object... errorMessageArgs) { + if (!expression) { + throw new IllegalArgumentException( + format(errorMessageTemplate, errorMessageArgs)); + } + } + + /** + * Copy of {@code com.google.common.base.Preconditions#checkNotNull}. + */ + public static T checkNotNull(T reference, + String errorMessageTemplate, + Object... errorMessageArgs) { + if (reference == null) { + // If either of these parameters is null, the right thing happens anyway + throw new NullPointerException( + format(errorMessageTemplate, errorMessageArgs)); + } + return reference; + } + + /** + * Copy of {@code com.google.common.base.Preconditions#checkState}. + */ + public static void checkState(boolean expression, + String errorMessageTemplate, + Object... errorMessageArgs) { + if (!expression) { + throw new IllegalStateException( + format(errorMessageTemplate, errorMessageArgs)); + } + } + + /** + * Identifies a method as a default instance method. + */ + public static boolean isDefault(Method method) { + // Default methods are public non-abstract, non-synthetic, and non-static instance methods + // declared in an interface. + // method.isDefault() is not sufficient for our usage as it does not check + // for synthetic methods. As a result, it picks up overridden methods as well as actual default + // methods. + final int SYNTHETIC = 0x00001000; + return ((method.getModifiers() + & (Modifier.ABSTRACT | Modifier.PUBLIC | Modifier.STATIC | SYNTHETIC)) == Modifier.PUBLIC) + && method.getDeclaringClass().isInterface(); + } + + /** + * Adapted from {@code com.google.common.base.Strings#emptyToNull}. + */ + public static String emptyToNull(String string) { + return string == null || string.isEmpty() ? null : string; + } + + /** + * Removes values from the array that meet the criteria for removal via the supplied + * {@link Predicate} value + */ + @SuppressWarnings("unchecked") + public static T[] removeValues(T[] values, Predicate shouldRemove, Class type) { + Collection collection = new ArrayList<>(values.length); + for (T value : values) { + if (shouldRemove.negate().test(value)) { + collection.add(value); + } + } + T[] array = (T[]) Array.newInstance(type, collection.size()); + return collection.toArray(array); + } + + /** + * Adapted from {@code com.google.common.base.Strings#emptyToNull}. + */ + @SuppressWarnings("unchecked") + public static T[] toArray(Iterable iterable, Class type) { + Collection collection; + if (iterable instanceof Collection) { + collection = (Collection) iterable; + } else { + collection = new ArrayList<>(); + for (T element : iterable) { + collection.add(element); + } + } + T[] array = (T[]) Array.newInstance(type, collection.size()); + return collection.toArray(array); + } + + /** + * Returns an unmodifiable collection which may be empty, but is never null. + */ + public static Collection valuesOrEmpty(Map> map, String key) { + Collection values = map.get(key); + return values != null ? values : Collections.emptyList(); + } + + public static void ensureClosed(Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (IOException ignored) { // NOPMD + } + } + } + + /** + * Moved to {@code feign.Types.resolveLastTypeParameter} + */ + @Deprecated + public static Type resolveLastTypeParameter(Type genericContext, Class supertype) + throws IllegalStateException { + return Types.resolveLastTypeParameter(genericContext, supertype); + } + + /** + * This returns well known empty values for well-known java types. This returns null for types not + * in the following list. + * + *

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

+ * When {@link Feign.Builder#dismiss404() decoding HTTP 404 status}, you'll need to teach decoders + * a default empty value for a type. This method cheaply supports typical types by only looking at + * the raw type (vs type hierarchy). Decorate for sophistication. + */ + public static Object emptyValueOf(Type type) { + return EMPTIES.getOrDefault(Types.getRawType(type), () -> null).get(); + } + + private static final Map, Supplier> EMPTIES; + static { + final Map, Supplier> empties = new LinkedHashMap<>(); + empties.put(boolean.class, () -> false); + empties.put(Boolean.class, () -> false); + empties.put(byte[].class, () -> new byte[0]); + empties.put(Collection.class, Collections::emptyList); + empties.put(Iterator.class, Collections::emptyIterator); + empties.put(List.class, Collections::emptyList); + empties.put(Map.class, Collections::emptyMap); + empties.put(Set.class, Collections::emptySet); + empties.put(Optional.class, Optional::empty); + empties.put(Stream.class, Stream::empty); + EMPTIES = Collections.unmodifiableMap(empties); + } + + /** + * Adapted from {@code com.google.common.io.CharStreams.toString()}. + */ + public static String toString(Reader reader) throws IOException { + if (reader == null) { + return null; + } + try { + StringBuilder to = new StringBuilder(); + CharBuffer charBuf = CharBuffer.allocate(BUF_SIZE); + // must cast to super class Buffer otherwise break when running with java 11 + Buffer buf = charBuf; + while (reader.read(charBuf) != -1) { + buf.flip(); + to.append(charBuf); + buf.clear(); + } + return to.toString(); + } finally { + ensureClosed(reader); + } + } + + /** + * Adapted from {@code com.google.common.io.ByteStreams.toByteArray()}. + */ + public static byte[] toByteArray(InputStream in) throws IOException { + checkNotNull(in, "in"); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + copy(in, out); + return out.toByteArray(); + } finally { + ensureClosed(in); + } + } + + /** + * Adapted from {@code com.google.common.io.ByteStreams.copy()}. + */ + private static long copy(InputStream from, OutputStream to) + throws IOException { + checkNotNull(from, "from"); + checkNotNull(to, "to"); + byte[] buf = new byte[BUF_SIZE]; + long total = 0; + while (true) { + int r = from.read(buf); + if (r == -1) { + break; + } + to.write(buf, 0, r); + total += r; + } + return total; + } + + public static String decodeOrDefault(byte[] data, Charset charset, String defaultValue) { + if (data == null) { + return defaultValue; + } + checkNotNull(charset, "charset"); + try { + return charset.newDecoder().decode(ByteBuffer.wrap(data)).toString(); + } catch (CharacterCodingException ex) { + return defaultValue; + } + } + + /** + * If the provided String is not null or empty. + * + * @param value to evaluate. + * @return true of the value is not null and not empty. + */ + public static boolean isNotBlank(String value) { + return value != null && !value.isEmpty(); + } + + /** + * If the provided String is null or empty. + * + * @param value to evaluate. + * @return true if the value is null or empty. + */ + public static boolean isBlank(String value) { + return value == null || value.isEmpty(); + } + + /** + * Copy entire map of string collection. + * + * The copy is unmodifiable map of unmodifiable collections. + * + * @param map string collection map + * @return copy of the map or an empty map if the map is null. + */ + public static Map> caseInsensitiveCopyOf(Map> map) { + if (map == null) { + return Collections.emptyMap(); + } + + Map> result = + new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + + for (Map.Entry> entry : map.entrySet()) { + String key = entry.getKey(); + if (!result.containsKey(key)) { + result.put(key.toLowerCase(Locale.ROOT), new LinkedList<>()); + } + result.get(key).addAll(entry.getValue()); + } + result.replaceAll((key, value) -> Collections.unmodifiableCollection(value)); + + return Collections.unmodifiableMap(result); + } + + public static > T enumForName(Class enumClass, Object object) { + String name = (nonNull(object)) ? object.toString() : null; + for (T enumItem : enumClass.getEnumConstants()) { + if (enumItem.name().equalsIgnoreCase(name) || enumItem.toString().equalsIgnoreCase(name)) { + return enumItem; + } + } + return null; + } + + public static List allFields(Class clazz) { + if (Objects.equals(clazz, Object.class)) { + return Collections.emptyList(); + } + + List fields = new ArrayList<>(); + fields.addAll(Arrays.asList(clazz.getDeclaredFields())); + fields.addAll(allFields(clazz.getSuperclass())); + return fields; + } + + public static String getThreadIdentifier() { + Thread currentThread = Thread.currentThread(); + return currentThread.getThreadGroup() + "_" + currentThread.getName() + "_" + + currentThread.getId(); + } +} diff --git a/core/src/main/java/feign/auth/Base64.java b/core/src/main/java/feign/auth/Base64.java new file mode 100644 index 000000000..4e067e811 --- /dev/null +++ b/core/src/main/java/feign/auth/Base64.java @@ -0,0 +1,158 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.auth; + +import java.io.UnsupportedEncodingException; + +/** + * copied from okhttp + * + * @author Alexander Y. Kleymenov + */ +final class Base64 { + + public static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + private static final byte[] MAP = new byte[] { + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', + 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', + 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', + '5', '6', '7', '8', '9', '+', '/' + }; + + private Base64() {} + + public static byte[] decode(byte[] in) { + return decode(in, in.length); + } + + public static byte[] decode(byte[] in, int len) { + // approximate output length + int length = len / 4 * 3; + // return an empty array on empty or short input without padding + if (length == 0) { + return EMPTY_BYTE_ARRAY; + } + // temporary array + byte[] out = new byte[length]; + // number of padding characters ('=') + int pad = 0; + byte chr; + // compute the number of the padding characters + // and adjust the length of the input + for (;; len--) { + chr = in[len - 1]; + // skip the neutral characters + if ((chr == '\n') || (chr == '\r') || (chr == ' ') || (chr == '\t')) { + continue; + } + if (chr == '=') { + pad++; + } else { + break; + } + } + // index in the output array + int outIndex = 0; + // index in the input array + int inIndex = 0; + // holds the value of the input character + int bits = 0; + // holds the value of the input quantum + int quantum = 0; + for (int i = 0; i < len; i++) { + chr = in[i]; + // skip the neutral characters + if ((chr == '\n') || (chr == '\r') || (chr == ' ') || (chr == '\t')) { + continue; + } + if ((chr >= 'A') && (chr <= 'Z')) { + // char ASCII value + // A 65 0 + // Z 90 25 (ASCII - 65) + bits = chr - 65; + } else if ((chr >= 'a') && (chr <= 'z')) { + // char ASCII value + // a 97 26 + // z 122 51 (ASCII - 71) + bits = chr - 71; + } else if ((chr >= '0') && (chr <= '9')) { + // char ASCII value + // 0 48 52 + // 9 57 61 (ASCII + 4) + bits = chr + 4; + } else if (chr == '+') { + bits = 62; + } else if (chr == '/') { + bits = 63; + } else { + return null; + } + // append the value to the quantum + quantum = (quantum << 6) | (byte) bits; + if (inIndex % 4 == 3) { + // 4 characters were read, so make the output: + out[outIndex++] = (byte) (quantum >> 16); + out[outIndex++] = (byte) (quantum >> 8); + out[outIndex++] = (byte) quantum; + } + inIndex++; + } + if (pad > 0) { + // adjust the quantum value according to the padding + quantum = quantum << (6 * pad); + // make output + out[outIndex++] = (byte) (quantum >> 16); + if (pad == 1) { + out[outIndex++] = (byte) (quantum >> 8); + } + } + // create the resulting array + byte[] result = new byte[outIndex]; + System.arraycopy(out, 0, result, 0, outIndex); + return result; + } + + public static String encode(byte[] in) { + int length = (in.length + 2) * 4 / 3; + byte[] out = new byte[length]; + int index = 0, end = in.length - in.length % 3; + for (int i = 0; i < end; i += 3) { + out[index++] = MAP[(in[i] & 0xff) >> 2]; + out[index++] = MAP[((in[i] & 0x03) << 4) | ((in[i + 1] & 0xff) >> 4)]; + out[index++] = MAP[((in[i + 1] & 0x0f) << 2) | ((in[i + 2] & 0xff) >> 6)]; + out[index++] = MAP[(in[i + 2] & 0x3f)]; + } + switch (in.length % 3) { + case 1: + out[index++] = MAP[(in[end] & 0xff) >> 2]; + out[index++] = MAP[(in[end] & 0x03) << 4]; + out[index++] = '='; + out[index++] = '='; + break; + case 2: + out[index++] = MAP[(in[end] & 0xff) >> 2]; + out[index++] = MAP[((in[end] & 0x03) << 4) | ((in[end + 1] & 0xff) >> 4)]; + out[index++] = MAP[((in[end + 1] & 0x0f) << 2)]; + out[index++] = '='; + break; + } + try { + return new String(out, 0, index, "US-ASCII"); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } +} + diff --git a/core/src/main/java/feign/auth/BasicAuthRequestInterceptor.java b/core/src/main/java/feign/auth/BasicAuthRequestInterceptor.java new file mode 100644 index 000000000..1d4396a59 --- /dev/null +++ b/core/src/main/java/feign/auth/BasicAuthRequestInterceptor.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.auth; + +import java.nio.charset.Charset; +import feign.RequestInterceptor; +import feign.RequestTemplate; +import static feign.Util.ISO_8859_1; +import static feign.Util.checkNotNull; + +/** + * An interceptor that adds the request header needed to use HTTP basic authentication. + */ +public class BasicAuthRequestInterceptor implements RequestInterceptor { + + private final String headerValue; + + /** + * Creates an interceptor that authenticates all requests with the specified username and password + * encoded using ISO-8859-1. + * + * @param username the username to use for authentication + * @param password the password to use for authentication + */ + public BasicAuthRequestInterceptor(String username, String password) { + this(username, password, ISO_8859_1); + } + + /** + * Creates an interceptor that authenticates all requests with the specified username and password + * encoded using the specified charset. + * + * @param username the username to use for authentication + * @param password the password to use for authentication + * @param charset the charset to use when encoding the credentials + */ + public BasicAuthRequestInterceptor(String username, String password, Charset charset) { + checkNotNull(username, "username"); + checkNotNull(password, "password"); + this.headerValue = "Basic " + base64Encode((username + ":" + password).getBytes(charset)); + } + + /* + * This uses a Sun internal method; if we ever encounter a case where this method is not + * available, the appropriate response would be to pull the necessary portions of Guava's + * BaseEncoding class into Util. + */ + private static String base64Encode(byte[] bytes) { + return Base64.encode(bytes); + } + + @Override + public void apply(RequestTemplate template) { + template.header("Authorization", headerValue); + } +} + diff --git a/core/src/main/java/feign/codec/DecodeException.java b/core/src/main/java/feign/codec/DecodeException.java new file mode 100644 index 000000000..1f08cb4a0 --- /dev/null +++ b/core/src/main/java/feign/codec/DecodeException.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.codec; + +import feign.FeignException; +import feign.Request; +import static feign.Util.checkNotNull; + +/** + * Similar to {@code javax.websocket.DecodeException}, raised when a problem occurs decoding a + * message. Note that {@code DecodeException} is not an {@code IOException}, nor does it have one + * set as its cause. + */ +public class DecodeException extends FeignException { + + private static final long serialVersionUID = 1L; + + /** + * @param message the reason for the failure. + */ + public DecodeException(int status, String message, Request request) { + super(status, checkNotNull(message, "message"), request); + } + + /** + * @param message possibly null reason for the failure. + * @param cause the cause of the error. + */ + public DecodeException(int status, String message, Request request, Throwable cause) { + super(status, message, request, checkNotNull(cause, "cause")); + } +} diff --git a/core/src/main/java/feign/codec/Decoder.java b/core/src/main/java/feign/codec/Decoder.java new file mode 100644 index 000000000..7024f54ac --- /dev/null +++ b/core/src/main/java/feign/codec/Decoder.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.codec; + +import java.io.IOException; +import java.lang.reflect.Type; +import feign.Feign; +import feign.FeignException; +import feign.Response; +import feign.Util; + +/** + * Decodes an HTTP response into a single object of the given {@code type}. Invoked when + * {@link Response#status()} is in the 2xx range and the return type is neither {@code void} nor + * {@code + * Response}. + *

+ *

+ * Example Implementation:
+ *

+ * + *

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

Implementation Note

The {@code type} parameter will correspond to the + * {@link java.lang.reflect.Method#getGenericReturnType() generic return type} of an + * {@link feign.Target#type() interface} processed by {@link feign.Feign#newInstance(feign.Target)}. + * When writing your implementation of Decoder, ensure you also test parameterized types such as + * {@code + * List}.
+ *

Note on exception propagation

Exceptions thrown by {@link Decoder}s get wrapped in a + * {@link DecodeException} unless they are a subclass of {@link FeignException} already, and unless + * the client was configured with {@link Feign.Builder#dismiss404()}. + */ +public interface Decoder { + + /** + * Decodes an http response into an object corresponding to its + * {@link java.lang.reflect.Method#getGenericReturnType() generic return type}. If you need to + * wrap exceptions, please do so via {@link DecodeException}. + * + * @param response the response to decode + * @param type {@link java.lang.reflect.Method#getGenericReturnType() generic return type} of the + * method corresponding to this {@code response}. + * @return instance of {@code type} + * @throws IOException will be propagated safely to the caller. + * @throws DecodeException when decoding failed due to a checked exception besides IOException. + * @throws FeignException when decoding succeeds, but conveys the operation failed. + */ + Object decode(Response response, Type type) throws IOException, DecodeException, FeignException; + + /** Default implementation of {@code Decoder}. */ + public class Default extends StringDecoder { + + @Override + public Object decode(Response response, Type type) throws IOException { + if (response.status() == 404 || response.status() == 204) + return Util.emptyValueOf(type); + if (response.body() == null) + return null; + if (byte[].class.equals(type)) { + return Util.toByteArray(response.body().asInputStream()); + } + return super.decode(response, type); + } + } +} diff --git a/core/src/main/java/feign/codec/EncodeException.java b/core/src/main/java/feign/codec/EncodeException.java new file mode 100644 index 000000000..75ad4b15b --- /dev/null +++ b/core/src/main/java/feign/codec/EncodeException.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.codec; + +import static feign.Util.checkNotNull; +import feign.FeignException; + +/** + * Similar to {@code javax.websocket.EncodeException}, raised when a problem occurs encoding a + * message. Note that {@code EncodeException} is not an {@code IOException}, nor does it have one + * set as its cause. + */ +public class EncodeException extends FeignException { + + private static final long serialVersionUID = 1L; + + /** + * @param message the reason for the failure. + */ + public EncodeException(String message) { + super(-1, checkNotNull(message, "message")); + } + + /** + * @param message possibly null reason for the failure. + * @param cause the cause of the error. + */ + public EncodeException(String message, Throwable cause) { + super(-1, message, checkNotNull(cause, "cause")); + } +} diff --git a/core/src/main/java/feign/codec/Encoder.java b/core/src/main/java/feign/codec/Encoder.java new file mode 100644 index 000000000..b30b44d57 --- /dev/null +++ b/core/src/main/java/feign/codec/Encoder.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.codec; + +import java.lang.reflect.Type; +import feign.RequestTemplate; +import feign.Util; +import static java.lang.String.format; + +/** + * Encodes an object into an HTTP request body. Like {@code javax.websocket.Encoder}. {@code + * Encoder} is used when a method parameter has no {@code @Param} annotation. For example:
+ *

+ * + *

+ * @POST
+ * @Path("/")
+ * void create(User user);
+ * 
+ * + * Example implementation:
+ *

+ * + *

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

+ *

Form encoding

+ *

+ * If any parameters are found in {@link feign.MethodMetadata#formParams()}, they will be collected + * and passed to the Encoder as a map. + * + *

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

+ * @RequestLine("POST /")
+ * Session login(@Param("username") String username, @Param("password") String password);
+ * 
+ */ +public interface Encoder { + /** Type literal for {@code Map}, indicating the object to encode is a form. */ + Type MAP_STRING_WILDCARD = Util.MAP_STRING_WILDCARD; + + /** + * Converts objects to an appropriate representation in the template. + * + * @param object what to encode as the request body. + * @param bodyType the type the object should be encoded as. {@link #MAP_STRING_WILDCARD} + * indicates form encoding. + * @param template the request template to populate. + * @throws EncodeException when encoding failed due to a checked exception. + */ + void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException; + + /** + * Default implementation of {@code Encoder}. + */ + class Default implements Encoder { + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + if (bodyType == String.class) { + template.body(object.toString()); + } else if (bodyType == byte[].class) { + template.body((byte[]) object, null); + } else if (object != null) { + throw new EncodeException( + format("%s is not a type supported by this encoder.", object.getClass())); + } + } + } +} diff --git a/core/src/main/java/feign/codec/ErrorDecoder.java b/core/src/main/java/feign/codec/ErrorDecoder.java new file mode 100644 index 000000000..b1a39354c --- /dev/null +++ b/core/src/main/java/feign/codec/ErrorDecoder.java @@ -0,0 +1,168 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.codec; + +import static feign.FeignException.errorStatus; +import static feign.Util.RETRY_AFTER; +import static feign.Util.checkNotNull; +import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME; +import static java.util.concurrent.TimeUnit.SECONDS; +import feign.FeignException; +import feign.Response; +import feign.RetryableException; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Collection; +import java.util.Map; + +/** + * Allows you to massage an exception into a application-specific one. Converting out to a throttle + * exception are examples of this in use. + * + *

+ * Ex: + * + *

+ * class IllegalArgumentExceptionOn404Decoder implements ErrorDecoder {
+ *
+ *   @Override
+ *   public Exception decode(String methodKey, Response response) {
+ *     if (response.status() == 400)
+ *       throw new IllegalArgumentException("bad zone name");
+ *     return new ErrorDecoder.Default().decode(methodKey, response);
+ *   }
+ *
+ * }
+ * 
+ * + *

+ * Error handling + * + *

+ * Responses where {@link Response#status()} is not in the 2xx range are classified as errors, + * addressed by the {@link ErrorDecoder}. That said, certain RPC apis return errors defined in the + * {@link Response#body()} even on a 200 status. For example, in the DynECT api, a job still running + * condition is returned with a 200 status, encoded in json. When scenarios like this occur, you + * should raise an application-specific exception (which may be {@link feign.RetryableException + * retryable}). + * + *

+ * Not Found Semantics + *

+ * It is commonly the case that 404 (Not Found) status has semantic value in HTTP apis. While the + * default behavior is to raise exeception, users can alternatively enable 404 processing via + * {@link feign.Feign.Builder#dismiss404()}. + */ +public interface ErrorDecoder { + + /** + * Implement this method in order to decode an HTTP {@link Response} when + * {@link Response#status()} is not in the 2xx range. Please raise application-specific exceptions + * where possible. If your exception is retryable, wrap or subclass {@link RetryableException} + * + * @param methodKey {@link feign.Feign#configKey} of the java method that invoked the request. ex. + * {@code IAM#getUser()} + * @param response HTTP response where {@link Response#status() status} is greater than or equal + * to {@code 300}. + * @return Exception IOException, if there was a network error reading the response or an + * application-specific exception decoded by the implementation. If the throwable is + * retryable, it should be wrapped, or a subtype of {@link RetryableException} + */ + public Exception decode(String methodKey, Response response); + + public class Default implements ErrorDecoder { + + private final RetryAfterDecoder retryAfterDecoder = new RetryAfterDecoder(); + private Integer maxBodyBytesLength; + private Integer maxBodyCharsLength; + + public Default() { + this.maxBodyBytesLength = null; + this.maxBodyCharsLength = null; + } + + public Default(Integer maxBodyBytesLength, Integer maxBodyCharsLength) { + this.maxBodyBytesLength = maxBodyBytesLength; + this.maxBodyCharsLength = maxBodyCharsLength; + } + + @Override + public Exception decode(String methodKey, Response response) { + FeignException exception = errorStatus(methodKey, response, maxBodyBytesLength, + maxBodyCharsLength); + Long retryAfter = retryAfterDecoder.apply(firstOrNull(response.headers(), RETRY_AFTER)); + if (retryAfter != null) { + return new RetryableException( + response.status(), + exception.getMessage(), + response.request().httpMethod(), + exception, + retryAfter, + response.request()); + } + return exception; + } + + private T firstOrNull(Map> map, String key) { + if (map.containsKey(key) && !map.get(key).isEmpty()) { + return map.get(key).iterator().next(); + } + return null; + } + } + + /** + * Decodes a {@link feign.Util#RETRY_AFTER} header into an epoch millisecond, if possible.
+ * See Retry-After format + */ + static class RetryAfterDecoder { + + private final DateTimeFormatter dateTimeFormatter; + + RetryAfterDecoder() { + this(RFC_1123_DATE_TIME); + } + + RetryAfterDecoder(DateTimeFormatter dateTimeFormatter) { + this.dateTimeFormatter = checkNotNull(dateTimeFormatter, "dateTimeFormatter"); + } + + protected long currentTimeMillis() { + return System.currentTimeMillis(); + } + + /** + * returns an epoch millisecond that corresponds to the first time a request can be retried. + * + * @param retryAfter String in + * Retry-After format + */ + public Long apply(String retryAfter) { + if (retryAfter == null) { + return null; + } + if (retryAfter.matches("^[0-9]+\\.?0*$")) { + retryAfter = retryAfter.replaceAll("\\.0*$", ""); + long deltaMillis = SECONDS.toMillis(Long.parseLong(retryAfter)); + return currentTimeMillis() + deltaMillis; + } + try { + return ZonedDateTime.parse(retryAfter, dateTimeFormatter).toInstant().toEpochMilli(); + } catch (NullPointerException | DateTimeParseException ignored) { + return null; + } + } + } +} diff --git a/core/src/main/java/feign/codec/StringDecoder.java b/core/src/main/java/feign/codec/StringDecoder.java new file mode 100644 index 000000000..d7393f847 --- /dev/null +++ b/core/src/main/java/feign/codec/StringDecoder.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.codec; + +import java.io.IOException; +import java.lang.reflect.Type; +import feign.Response; +import feign.Util; +import static java.lang.String.format; + +public class StringDecoder implements Decoder { + + @Override + public Object decode(Response response, Type type) throws IOException { + Response.Body body = response.body(); + if (response.status() == 404 || response.status() == 204 || body == null) { + return null; + } + if (String.class.equals(type)) { + return Util.toString(body.asReader(Util.UTF_8)); + } + throw new DecodeException(response.status(), + format("%s is not a type supported by this decoder.", type), response.request()); + } +} diff --git a/core/src/main/java/feign/optionals/OptionalDecoder.java b/core/src/main/java/feign/optionals/OptionalDecoder.java new file mode 100644 index 000000000..bf15aa6d6 --- /dev/null +++ b/core/src/main/java/feign/optionals/OptionalDecoder.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.optionals; + +import feign.Response; +import feign.Util; +import feign.codec.Decoder; +import java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Objects; +import java.util.Optional; + +public final class OptionalDecoder implements Decoder { + final Decoder delegate; + + public OptionalDecoder(Decoder delegate) { + Objects.requireNonNull(delegate, "Decoder must not be null. "); + this.delegate = delegate; + } + + @Override + public Object decode(Response response, Type type) throws IOException { + if (!isOptional(type)) { + return delegate.decode(response, type); + } + if (response.status() == 404 || response.status() == 204) { + return Optional.empty(); + } + Type enclosedType = Util.resolveLastTypeParameter(type, Optional.class); + return Optional.ofNullable(delegate.decode(response, enclosedType)); + } + + static boolean isOptional(Type type) { + if (!(type instanceof ParameterizedType)) { + return false; + } + ParameterizedType parameterizedType = (ParameterizedType) type; + return parameterizedType.getRawType().equals(Optional.class); + } +} diff --git a/core/src/main/java/feign/querymap/BeanQueryMapEncoder.java b/core/src/main/java/feign/querymap/BeanQueryMapEncoder.java new file mode 100644 index 000000000..a6a378f3b --- /dev/null +++ b/core/src/main/java/feign/querymap/BeanQueryMapEncoder.java @@ -0,0 +1,93 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.querymap; + +import feign.Param; +import feign.QueryMapEncoder; +import feign.codec.EncodeException; +import java.beans.IntrospectionException; +import java.beans.Introspector; +import java.beans.PropertyDescriptor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.*; + +/** + * the query map will be generated using java beans accessible getter property as query parameter + * names. + * + * eg: "/uri?name={name}&number={number}" + * + * order of included query parameters not guaranteed, and as usual, if any value is null, it will be + * left out + */ +public class BeanQueryMapEncoder implements QueryMapEncoder { + private final Map, ObjectParamMetadata> classToMetadata = + new HashMap, ObjectParamMetadata>(); + + @Override + public Map encode(Object object) throws EncodeException { + if (object == null) { + return Collections.emptyMap(); + } + try { + ObjectParamMetadata metadata = getMetadata(object.getClass()); + Map propertyNameToValue = new HashMap(); + for (PropertyDescriptor pd : metadata.objectProperties) { + Method method = pd.getReadMethod(); + Object value = method.invoke(object); + if (value != null && value != object) { + Param alias = method.getAnnotation(Param.class); + String name = alias != null ? alias.value() : pd.getName(); + propertyNameToValue.put(name, value); + } + } + return propertyNameToValue; + } catch (IllegalAccessException | IntrospectionException | InvocationTargetException e) { + throw new EncodeException("Failure encoding object into query map", e); + } + } + + private ObjectParamMetadata getMetadata(Class objectType) throws IntrospectionException { + ObjectParamMetadata metadata = classToMetadata.get(objectType); + if (metadata == null) { + metadata = ObjectParamMetadata.parseObjectType(objectType); + classToMetadata.put(objectType, metadata); + } + return metadata; + } + + private static class ObjectParamMetadata { + + private final List objectProperties; + + private ObjectParamMetadata(List objectProperties) { + this.objectProperties = Collections.unmodifiableList(objectProperties); + } + + private static ObjectParamMetadata parseObjectType(Class type) + throws IntrospectionException { + List properties = new ArrayList(); + + for (PropertyDescriptor pd : Introspector.getBeanInfo(type).getPropertyDescriptors()) { + boolean isGetterMethod = pd.getReadMethod() != null && !"class".equals(pd.getName()); + if (isGetterMethod) { + properties.add(pd); + } + } + + return new ObjectParamMetadata(properties); + } + } +} diff --git a/core/src/main/java/feign/querymap/FieldQueryMapEncoder.java b/core/src/main/java/feign/querymap/FieldQueryMapEncoder.java new file mode 100644 index 000000000..6554df6fe --- /dev/null +++ b/core/src/main/java/feign/querymap/FieldQueryMapEncoder.java @@ -0,0 +1,108 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.querymap; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import feign.Param; +import feign.QueryMapEncoder; +import feign.codec.EncodeException; + +/** + * the query map will be generated using member variable names as query parameter names. + * + * eg: "/uri?name={name}&number={number}" + * + * order of included query parameters not guaranteed, and as usual, if any value is null, it will be + * left out + */ +public class FieldQueryMapEncoder implements QueryMapEncoder { + + private final Map, ObjectParamMetadata> classToMetadata = + new ConcurrentHashMap<>(); + + @Override + public Map encode(Object object) throws EncodeException { + if (object == null) { + return Collections.emptyMap(); + } + ObjectParamMetadata metadata = + classToMetadata.computeIfAbsent(object.getClass(), ObjectParamMetadata::parseObjectType); + + return metadata.objectFields.stream() + .map(field -> this.FieldValuePair(object, field)) + .filter(fieldObjectPair -> fieldObjectPair.right.isPresent()) + .collect(Collectors.toMap(this::fieldName, + fieldObjectPair -> fieldObjectPair.right.get())); + + } + + private String fieldName(Pair> pair) { + Param alias = pair.left.getAnnotation(Param.class); + return alias != null ? alias.value() : pair.left.getName(); + } + + private Pair> FieldValuePair(Object object, Field field) { + try { + return Pair.pair(field, Optional.ofNullable(field.get(object))); + } catch (IllegalAccessException e) { + throw new EncodeException("Failure encoding object into query map", e); + } + } + + private static class ObjectParamMetadata { + + private final List objectFields; + + private ObjectParamMetadata(List objectFields) { + this.objectFields = Collections.unmodifiableList(objectFields); + } + + private static ObjectParamMetadata parseObjectType(Class type) { + List allFields = new ArrayList(); + + for (Class currentClass = type; currentClass != null; currentClass = + currentClass.getSuperclass()) { + Collections.addAll(allFields, currentClass.getDeclaredFields()); + } + + return new ObjectParamMetadata(allFields.stream() + .filter(field -> !field.isSynthetic()) + .peek(field -> field.setAccessible(true)) + .collect(Collectors.toList())); + } + } + + private static class Pair { + private Pair(T left, U right) { + this.right = right; + this.left = left; + } + + public final T left; + public final U right; + + public static Pair pair(T left, U right) { + return new Pair<>(left, right); + } + + } + +} diff --git a/core/src/main/java/feign/stream/StreamDecoder.java b/core/src/main/java/feign/stream/StreamDecoder.java new file mode 100644 index 000000000..ab2b95afb --- /dev/null +++ b/core/src/main/java/feign/stream/StreamDecoder.java @@ -0,0 +1,128 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.stream; + +import feign.FeignException; +import feign.Response; +import feign.codec.Decoder; +import java.io.Closeable; +import java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Iterator; +import java.util.Optional; +import java.util.Spliterators; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import static feign.Util.ensureClosed; + +/** + * Iterator based decoder that support streaming. + *

+ *

+ * Example:
+ * + *

+ * 
+ * Feign.builder()
+ *   .decoder(StreamDecoder.create(JacksonIteratorDecoder.create()))
+ *   .doNotCloseAfterDecode() // Required for streaming
+ *   .target(GitHub.class, "https://api.github.com");
+ * or
+ * Feign.builder()
+ *   .decoder(StreamDecoder.create(JacksonIteratorDecoder.create(), (r, t) -> "hello world")))
+ *   .doNotCloseAfterDecode() // Required for streaming
+ *   .target(GitHub.class, "https://api.github.com");
+ * interface GitHub {
+ *  {@literal @}RequestLine("GET /repos/{owner}/{repo}/contributors")
+ *   Stream contributors(@Param("owner") String owner, @Param("repo") String repo);
+ * }
+ * 
+ */ +public final class StreamDecoder implements Decoder { + + private final Decoder iteratorDecoder; + private final Optional delegateDecoder; + + StreamDecoder(Decoder iteratorDecoder, Decoder delegateDecoder) { + this.iteratorDecoder = iteratorDecoder; + this.delegateDecoder = Optional.ofNullable(delegateDecoder); + } + + @Override + public Object decode(Response response, Type type) + throws IOException, FeignException { + if (!isStream(type)) { + if (!delegateDecoder.isPresent()) { + throw new IllegalArgumentException("StreamDecoder supports types other than stream. " + + "When type is not stream, the delegate decoder needs to be setting."); + } else { + return delegateDecoder.get().decode(response, type); + } + } + ParameterizedType streamType = (ParameterizedType) type; + Iterator iterator = + (Iterator) iteratorDecoder.decode(response, new IteratorParameterizedType(streamType)); + + return StreamSupport.stream( + Spliterators.spliteratorUnknownSize(iterator, 0), false) + .onClose(() -> { + if (iterator instanceof Closeable) { + ensureClosed((Closeable) iterator); + } else { + ensureClosed(response); + } + }); + } + + public static boolean isStream(Type type) { + if (!(type instanceof ParameterizedType)) { + return false; + } + ParameterizedType parameterizedType = (ParameterizedType) type; + return parameterizedType.getRawType().equals(Stream.class); + } + + public static StreamDecoder create(Decoder iteratorDecoder) { + return new StreamDecoder(iteratorDecoder, null); + } + + public static StreamDecoder create(Decoder iteratorDecoder, Decoder delegateDecoder) { + return new StreamDecoder(iteratorDecoder, delegateDecoder); + } + + static final class IteratorParameterizedType implements ParameterizedType { + + private final ParameterizedType streamType; + + IteratorParameterizedType(ParameterizedType streamType) { + this.streamType = streamType; + } + + @Override + public Type[] getActualTypeArguments() { + return streamType.getActualTypeArguments(); + } + + @Override + public Type getRawType() { + return Iterator.class; + } + + @Override + public Type getOwnerType() { + return null; + } + } +} diff --git a/core/src/main/java/feign/template/BodyTemplate.java b/core/src/main/java/feign/template/BodyTemplate.java new file mode 100644 index 000000000..95ff37ec0 --- /dev/null +++ b/core/src/main/java/feign/template/BodyTemplate.java @@ -0,0 +1,72 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +import feign.Util; +import java.nio.charset.Charset; +import java.util.Map; + +/** + * Template for @{@link feign.Body} annotated Templates. Unresolved expressions are preserved as + * literals and literals are not URI encoded. + */ +public final class BodyTemplate extends Template { + + private static final String JSON_TOKEN_START = "{"; + private static final String JSON_TOKEN_END = "}"; + private static final String JSON_TOKEN_START_ENCODED = "%7B"; + private static final String JSON_TOKEN_END_ENCODED = "%7D"; + private boolean json = false; + + /** + * Create a new Body Template. + * + * @param template to parse. + * @return a Body Template instance. + */ + public static BodyTemplate create(String template) { + return new BodyTemplate(template, Util.UTF_8); + } + + /** + * Create a new Body Template. + * + * @param template to parse. + * @param charset to use when encoding the template. + * @return a Body Template instance. + */ + public static BodyTemplate create(String template, Charset charset) { + return new BodyTemplate(template, charset); + } + + private BodyTemplate(String value, Charset charset) { + super(value, ExpansionOptions.ALLOW_UNRESOLVED, EncodingOptions.NOT_REQUIRED, false, charset); + if (value.startsWith(JSON_TOKEN_START_ENCODED) && value.endsWith(JSON_TOKEN_END_ENCODED)) { + this.json = true; + } + } + + @Override + public String expand(Map variables) { + String expanded = super.expand(variables); + if (this.json) { + /* restore all start and end tokens */ + expanded = expanded.replaceAll(JSON_TOKEN_START_ENCODED, JSON_TOKEN_START); + expanded = expanded.replaceAll(JSON_TOKEN_END_ENCODED, JSON_TOKEN_END); + } + return expanded; + } + + +} diff --git a/core/src/main/java/feign/template/Expression.java b/core/src/main/java/feign/template/Expression.java new file mode 100644 index 000000000..8f1aa8631 --- /dev/null +++ b/core/src/main/java/feign/template/Expression.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * URI Template Expression. + */ +abstract class Expression implements TemplateChunk { + + private String name; + private Pattern pattern; + + /** + * Create a new Expression. + * + * @param name of the variable + * @param pattern the resolved variable must adhere to, optional. + */ + Expression(String name, String pattern) { + this.name = name; + Optional.ofNullable(pattern).ifPresent(s -> this.pattern = Pattern.compile(s)); + } + + abstract String expand(Object variable, boolean encode); + + public String getName() { + return this.name; + } + + Pattern getPattern() { + return pattern; + } + + /** + * Checks if the provided value matches the variable pattern, if one is defined. Always true if no + * pattern is defined. + * + * @param value to check. + * @return true if it matches. + */ + boolean matches(String value) { + if (pattern == null) { + return true; + } + return pattern.matcher(value).matches(); + } + + @Override + public String getValue() { + if (this.pattern != null) { + return "{" + this.name + ":" + this.pattern + "}"; + } + return "{" + this.name + "}"; + } + + @Override + public String toString() { + return this.getValue(); + } +} diff --git a/core/src/main/java/feign/template/Expressions.java b/core/src/main/java/feign/template/Expressions.java new file mode 100644 index 000000000..0a9979c2b --- /dev/null +++ b/core/src/main/java/feign/template/Expressions.java @@ -0,0 +1,272 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +import feign.Param.Expander; +import feign.Util; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class Expressions { + + private static final int MAX_EXPRESSION_LENGTH = 10000; + + private static final String PATH_STYLE_OPERATOR = ";"; + /** + * Literals may be present and preceded the expression. + * + * The expression part must start with a '{' and end with a '}'. The contents of the expression + * may start with an RFC Operator or the operators reserved by the rfc: Level 2 Operators: '+' and + * '#' Level 3 Operators: '.' and '/' and ';' and '?' and '&' Reserved Operators: '=' and ',' and + * '!' and '@' and '|' + * + * The RFC specifies that '{' or '}' or '(' or ')' or'$' is are illegal characters. Feign does not + * honor this portion of the RFC Expressions allow '$' characters for Collection expansions, and + * all other characters are legal as a regular expression may be passed as a Value Modifier in + * Feign + * + * This is not a complete implementation of the rfc + * + * RFC 6570 Expressions + */ + static final Pattern EXPRESSION_PATTERN = + Pattern.compile("^(\\{([+#./;?&=,!@|]?)(.+)\\})$"); + + // Partially From: + // https://stackoverflow.com/questions/29494608/regex-for-uri-templates-rfc-6570-wanted -- I + // suspect much of the codebase could be refactored around the example regex there + /** + * A pattern for matching possible variable names. + * + * This pattern accepts characters allowed in RFC 6570 Section 2.3 It also allows the characters + * feign has allowed in the past "[]-$" + * + * The RFC specifies that a variable name followed by a ':' should be a max-length specification. + * Feign deviates from the rfc in that the ':' value modifier is used to mark a regular + * expression. + * + */ + private static final Pattern VARIABLE_LIST_PATTERN = Pattern.compile( + "(([\\w-\\[\\]$]|%[0-9A-Fa-f]{2})(\\.?([\\w-\\[\\]$]|%[0-9A-Fa-f]{2}))*(:.*|\\*)?)(,(([\\w-\\[\\]$]|%[0-9A-Fa-f]{2})(\\.?([\\w-\\[\\]$]|%[0-9A-Fa-f]{2}))*(:.*|\\*)?))*"); + + public static Expression create(final String value) { + + /* remove the start and end braces */ + final String expression = stripBraces(value); + if (expression == null || expression.isEmpty()) { + throw new IllegalArgumentException("an expression is required."); + } + + /* Check if the expression is too long */ + if (expression.length() > MAX_EXPRESSION_LENGTH) { + throw new IllegalArgumentException( + "expression is too long. Max length: " + MAX_EXPRESSION_LENGTH); + } + + /* create a new regular expression matcher for the expression */ + String variableName = null; + String variablePattern = null; + String operator = null; + Matcher matcher = EXPRESSION_PATTERN.matcher(value); + if (matcher.matches()) { + /* grab the operator */ + operator = matcher.group(2).trim(); + + /* we have a valid variable expression, extract the name from the first group */ + variableName = matcher.group(3).trim(); + if (variableName.contains(":")) { + /* split on the colon and ensure the size of parts array must be 2 */ + String[] parts = variableName.split(":", 2); + variableName = parts[0]; + variablePattern = parts[1]; + } + + /* look for nested expressions */ + if (variableName.contains("{")) { + /* nested, literal */ + return null; + } + } + + /* check for an operator */ + if (PATH_STYLE_OPERATOR.equalsIgnoreCase(operator)) { + return new PathStyleExpression(variableName, variablePattern); + } + + /* default to simple */ + return SimpleExpression.isSimpleExpression(value) + ? new SimpleExpression(variableName, variablePattern) + : null; // Return null if it can't be validated as a Simple Expression -- Probably a Literal + } + + private static String stripBraces(String expression) { + if (expression == null) { + return null; + } + if (expression.startsWith("{") && expression.endsWith("}")) { + return expression.substring(1, expression.length() - 1); + } + return expression; + } + + /** + * Expression that adheres to Simple String Expansion as outlined in ) variable)); + } else if (Map.class.isAssignableFrom(variable.getClass())) { + expanded.append(this.expandMap((Map) variable)); + } else if (Optional.class.isAssignableFrom(variable.getClass())) { + Optional optional = (Optional) variable; + if (optional.isPresent()) { + expanded.append(this.expand(optional.get(), encode)); + } else { + if (!this.nameRequired) { + return null; + } + expanded.append(this.encode(this.getName())) + .append("="); + } + } else { + if (this.nameRequired) { + expanded.append(this.encode(this.getName())) + .append("="); + } + expanded.append((encode) ? encode(variable) : variable); + } + + /* return the string value of the variable */ + String result = expanded.toString(); + if (!this.matches(result)) { + throw new IllegalArgumentException("Value " + expanded + + " does not match the expression pattern: " + this.getPattern()); + } + return result; + } + + protected String expandIterable(Iterable values) { + StringBuilder result = new StringBuilder(); + for (Object value : values) { + if (value == null) { + /* skip */ + continue; + } + + /* expand the value */ + String expanded = this.encode(value); + if (expanded.isEmpty()) { + /* always append the separator */ + result.append(this.separator); + } else { + if (result.length() != 0) { + if (!result.toString().equalsIgnoreCase(this.separator)) { + result.append(this.separator); + } + } + if (this.nameRequired) { + result.append(this.encode(this.getName())) + .append("="); + } + result.append(expanded); + } + } + + /* return the expanded value */ + return result.toString(); + } + + protected String expandMap(Map values) { + StringBuilder result = new StringBuilder(); + + for (Entry entry : values.entrySet()) { + StringBuilder expanded = new StringBuilder(); + String name = this.encode(entry.getKey()); + String value = this.encode(entry.getValue().toString()); + + expanded.append(name) + .append("="); + if (!value.isEmpty()) { + expanded.append(value); + } + + if (result.length() != 0) { + result.append(this.separator); + } + + result.append(expanded); + } + return result.toString(); + } + + protected static boolean isSimpleExpression(String expressionCandidate) { + final Matcher matcher = EXPRESSION_PATTERN.matcher(expressionCandidate); + return matcher.matches() + && matcher.group(2).isEmpty() // Simple Expressions do not support any special operators + && VARIABLE_LIST_PATTERN.matcher(matcher.group(3)).matches(); + } + } + + public static class PathStyleExpression extends SimpleExpression implements Expander { + + public PathStyleExpression(String name, String pattern) { + super(name, pattern, ";", true); + } + + @Override + protected String expand(Object variable, boolean encode) { + return this.separator + super.expand(variable, encode); + } + + @Override + public String expand(Object value) { + return this.expand(value, true); + } + + @Override + public String getValue() { + if (this.getPattern() != null) { + return "{" + this.separator + this.getName() + ":" + this.getName() + "}"; + } + return "{" + this.separator + this.getName() + "}"; + } + } +} diff --git a/core/src/main/java/feign/template/HeaderTemplate.java b/core/src/main/java/feign/template/HeaderTemplate.java new file mode 100644 index 000000000..d6c797f67 --- /dev/null +++ b/core/src/main/java/feign/template/HeaderTemplate.java @@ -0,0 +1,183 @@ +/* + * Copyright 2012-2023 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign.template; + +import feign.Util; +import feign.template.Template.EncodingOptions; +import feign.template.Template.ExpansionOptions; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Template for HTTP Headers. Variables that are unresolved are ignored and Literals are not + * encoded. + */ +public final class HeaderTemplate { + + private final String name; + private final List