diff --git a/.circleci/config.yml b/.circleci/config.yml index 22ac02c0f..b6743fa91 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -29,7 +29,7 @@ commands: - run: name: 'Check if cache was restored' command: | - if [ -d "$HOME/.sdkman/candidates/java/21.0.2-tem" ] && [ -d ~/.m2/repository/io/github/openfeign ]; then + if [ -d "$HOME/.sdkman/candidates/java/25.0.2-tem" ] && [ -d ~/.m2/repository/io/github/openfeign ]; then echo "Complete cache hit detected - SDKMAN and Maven dependencies available." circleci step halt elif [ -d "$HOME/.sdkman/candidates/java/21.0.2-tem" ]; then @@ -47,16 +47,16 @@ commands: fi source "$HOME/.sdkman/bin/sdkman-init.sh" - run: - name: 'Install JDKs (8, 11, 17, 21)' + name: 'Install JDKs (8, 11, 17, 21, 25)' command: | source "$HOME/.sdkman/bin/sdkman-init.sh" - jdk_versions=("8.0.382-tem" "11.0.22-tem" "17.0.10-tem" "21.0.2-tem") + jdk_versions=("8.0.382-tem" "11.0.22-tem" "17.0.10-tem" "21.0.2-tem" "25.0.2-tem") for jdk_version in "${jdk_versions[@]}"; do if [ ! -d "$HOME/.sdkman/candidates/java/$jdk_version" ]; then echo "n" | sdk install java "$jdk_version" || true fi done - sdk default java 21.0.2-tem + sdk default java 25.0.2-tem - run: name: 'Configure Maven Toolchain' command: | @@ -92,14 +92,55 @@ commands: - run: name: 'Configure GPG keys' command: | - echo -e "$GPG_KEY" | gpg --batch --no-tty --import --yes + printf '%s' "$GPG_KEY" | base64 -d | gpg --batch --yes --import nexus-deploy: steps: - run: name: 'Deploy Core Modules Sonatype' + no_output_timeout: 30m command: | source "$HOME/.sdkman/bin/sdkman-init.sh" - ./mvnw -ntp -nsu -s .circleci/settings.xml -P release -pl -:feign-benchmark -DskipTests=true deploy + ./mvnw -ntp -nsu --serial -s .circleci/settings.xml -P release -pl -:feign-benchmark,-:feign-vertx4-test,-:feign-vertx5-test,-:feign-example-github,-:feign-example-github-with-coroutine,-:feign-example-wikipedia,-:feign-example-wikipedia-with-springboot -DskipTests=true deploy + - run: + name: 'Publish BOM to Maven Central' + when: always + command: | + BOM_DIR=target/classes/feign-bom + BOM_POM=$BOM_DIR/pom.xml + if [ ! -f "$BOM_POM" ]; then + echo "BOM pom not found, skipping" + exit 0 + fi + BOM_VERSION=$(grep '' "$BOM_POM" | head -1 | sed 's/.*\(.*\)<\/version>.*/\1/') + if [[ "$BOM_VERSION" == *SNAPSHOT* ]]; then + echo "Skipping BOM publish for SNAPSHOT version $BOM_VERSION" + exit 0 + fi + DEP_COUNT=$(grep -c 'feign-' "$BOM_POM" || true) + if [ "$DEP_COUNT" -eq 0 ]; then + echo "ERROR: BOM has no feign dependencies, refusing to publish an empty BOM" + exit 1 + fi + echo "BOM contains $DEP_COUNT feign dependencies" + STAGING_DIR=/tmp/bom-staging/io/github/openfeign/feign-bom/$BOM_VERSION + mkdir -p "$STAGING_DIR" + cp "$BOM_POM" "$STAGING_DIR/feign-bom-${BOM_VERSION}.pom" + gpg --armor --detach-sign "$STAGING_DIR/feign-bom-${BOM_VERSION}.pom" + md5sum "$STAGING_DIR/feign-bom-${BOM_VERSION}.pom" | awk '{print $1}' > "$STAGING_DIR/feign-bom-${BOM_VERSION}.pom.md5" + sha1sum "$STAGING_DIR/feign-bom-${BOM_VERSION}.pom" | awk '{print $1}' > "$STAGING_DIR/feign-bom-${BOM_VERSION}.pom.sha1" + sha256sum "$STAGING_DIR/feign-bom-${BOM_VERSION}.pom" | awk '{print $1}' > "$STAGING_DIR/feign-bom-${BOM_VERSION}.pom.sha256" + sha512sum "$STAGING_DIR/feign-bom-${BOM_VERSION}.pom" | awk '{print $1}' > "$STAGING_DIR/feign-bom-${BOM_VERSION}.pom.sha512" + (cd /tmp/bom-staging && zip -r /tmp/feign-bom-bundle.zip .) + TOKEN=$(echo -n "${CENTRAL_TOKEN_USERNAME}:${CENTRAL_TOKEN_PASSWORD}" | base64) + HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/bom-upload-response.txt \ + -X POST "https://central.sonatype.com/api/v1/publisher/upload?name=feign-bom-${BOM_VERSION}&publishingType=AUTOMATIC" \ + -H "Authorization: Bearer $TOKEN" \ + -F "bundle=@/tmp/feign-bom-bundle.zip") + echo "BOM upload response: $(cat /tmp/bom-upload-response.txt) (HTTP $HTTP_CODE)" + if [ "$HTTP_CODE" != "201" ]; then + echo "BOM upload failed" + exit 1 + fi # our job defaults defaults: &defaults @@ -147,7 +188,7 @@ jobs: name: 'Test' command: | source "$HOME/.sdkman/bin/sdkman-init.sh" - ./mvnw -ntp -B verify -T1C + ./mvnw -ntp -B verify - verify-formatting deploy: diff --git a/.circleci/toolchains.xml b/.circleci/toolchains.xml index d6ef9d38d..48764b609 100644 --- a/.circleci/toolchains.xml +++ b/.circleci/toolchains.xml @@ -50,4 +50,13 @@ /home/circleci/.sdkman/candidates/java/21.0.2-tem + + jdk + + 25 + + + /home/circleci/.sdkman/candidates/java/25.0.2-tem + + \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8bccb05c2..fa14f951f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,3 +10,92 @@ updates: schedule: interval: "daily" open-pull-requests-limit: 100 + ignore: + # Jersey uses the same property name (jersey.version) in jaxrs2/3/4 at different majors. + # Per-module entries below handle jersey updates independently. + - dependency-name: "org.glassfish.jersey.core:jersey-client" + - dependency-name: "org.glassfish.jersey.inject:jersey-hk2" + # Vertx uses the same property name (vertx.version) at 4.x and 5.x. + # Per-module entries below handle vertx updates independently. + - dependency-name: "io.vertx:*" + # JAXB impl at 2.x (jaxb, soap) and 4.x (jaxb-jakarta, soap-jakarta) + - dependency-name: "com.sun.xml.bind:jaxb-impl" + update-types: ["version-update:semver-major"] + # SAAJ impl at 1.x (soap) and 3.x (soap-jakarta) + - dependency-name: "com.sun.xml.messaging.saaj:saaj-impl" + update-types: ["version-update:semver-major"] + + # Jersey 2.x for jaxrs2 + - package-ecosystem: "maven" + directory: "/jaxrs2" + schedule: + interval: "daily" + allow: + - dependency-name: "org.glassfish.jersey.core:jersey-client" + - dependency-name: "org.glassfish.jersey.inject:jersey-hk2" + ignore: + - dependency-name: "org.glassfish.jersey.core:jersey-client" + update-types: ["version-update:semver-major"] + - dependency-name: "org.glassfish.jersey.inject:jersey-hk2" + update-types: ["version-update:semver-major"] + + # Jersey 3.x for jaxrs3 + - package-ecosystem: "maven" + directory: "/jaxrs3" + schedule: + interval: "daily" + allow: + - dependency-name: "org.glassfish.jersey.core:jersey-client" + - dependency-name: "org.glassfish.jersey.inject:jersey-hk2" + ignore: + - dependency-name: "org.glassfish.jersey.core:jersey-client" + update-types: ["version-update:semver-major"] + - dependency-name: "org.glassfish.jersey.inject:jersey-hk2" + update-types: ["version-update:semver-major"] + + # Jersey 4.x for jaxrs4 + - package-ecosystem: "maven" + directory: "/jaxrs4" + schedule: + interval: "daily" + allow: + - dependency-name: "org.glassfish.jersey.core:jersey-client" + - dependency-name: "org.glassfish.jersey.inject:jersey-hk2" + ignore: + - dependency-name: "org.glassfish.jersey.core:jersey-client" + update-types: ["version-update:semver-major"] + - dependency-name: "org.glassfish.jersey.inject:jersey-hk2" + update-types: ["version-update:semver-major"] + + # Vertx 5.x for feign-vertx (main library) + - package-ecosystem: "maven" + directory: "/vertx/feign-vertx" + schedule: + interval: "daily" + allow: + - dependency-name: "io.vertx:*" + ignore: + - dependency-name: "io.vertx:*" + update-types: ["version-update:semver-major"] + + # Vertx 4.x for feign-vertx4-test + - package-ecosystem: "maven" + directory: "/vertx/feign-vertx4-test" + schedule: + interval: "daily" + allow: + - dependency-name: "io.vertx:*" + ignore: + - dependency-name: "io.vertx:*" + update-types: ["version-update:semver-major"] + + # Vertx 5.x for feign-vertx5-test + - package-ecosystem: "maven" + directory: "/vertx/feign-vertx5-test" + schedule: + interval: "daily" + allow: + - dependency-name: "io.vertx:*" + ignore: + - dependency-name: "io.vertx:*" + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/auto-merge-dependabot.yml b/.github/workflows/auto-merge-dependabot.yml new file mode 100644 index 000000000..cc3a140b9 --- /dev/null +++ b/.github/workflows/auto-merge-dependabot.yml @@ -0,0 +1,31 @@ +name: Dependabot auto-merge +on: + pull_request: + branches: + - master + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'OpenFeign/feign' + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v2.2.0 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-approve Dependabot PRs + uses: hmarr/auto-approve-action@v3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge for Dependabot PRs + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.gitignore b/.gitignore index d19b71053..53adc1616 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ Thumbs.db /target **/test-output **/target +**/m2e-target **/bin build */build @@ -69,4 +70,9 @@ atlassian-ide-plugin.xml # maven versions *.versionsBackup + +# maven-release-plugin +release.properties +pom.xml.releaseBackup .mvn/.develocity/develocity-workspace-id +.sdkmanrc diff --git a/.mvn/develocity.xml b/.mvn/develocity.xml index 4f9fdf42d..7c4c992d8 100644 --- a/.mvn/develocity.xml +++ b/.mvn/develocity.xml @@ -33,7 +33,7 @@ - true + #{isTrue(env['CI'])} false diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index 085ff38f3..7040f3233 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -19,16 +19,11 @@ com.gradle develocity-maven-extension - 2.1 + 2.3.4 com.gradle common-custom-user-data-maven-extension - 2.0.3 - - - com.marvinformatics.jacoco - easy-jacoco-maven-plugin - 0.1.4 + 2.1.0 diff --git a/.mvn/jvm.config b/.mvn/jvm.config index cfbd68a5f..32db52fb9 100644 --- a/.mvn/jvm.config +++ b/.mvn/jvm.config @@ -1,3 +1,4 @@ +--sun-misc-unsafe-memory-access=allow --add-opens jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..016f88219 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,161 @@ +# AGENTS.md + +This file provides guidance to AI coding assistants when working with code in this repository. + +## Build Commands + +### Essential Build Commands +- `mvn clean install` - Default build command (installs all modules) +- `mvn clean install -Pdev` - **ALWAYS use this profile for development** - enables code formatting and other dev tools +- `mvn clean install -Pquickbuild` - Skip tests and validation for faster builds +- `mvn test` - Run all tests +- `mvn test -Dtest=ClassName` - Run specific test class + +### Module-specific Commands +- `mvn clean install -pl core` - Build only the core module +- `mvn clean install -pl core,gson` - Build specific modules +- `mvn clean test -pl core -Dtest=FeignTest` - Run specific test in specific module + +### Code Quality +- Code is automatically formatted using Google Java Format via git hooks +- License headers are enforced via maven-license-plugin +- Use `mvn validate` to check formatting and license compliance + +## Project Architecture + +### Core Architecture +Feign is a declarative HTTP client library with a modular design: + +**Core Module (`core/`)**: Contains the main Feign API and implementation +- `Feign.java` - Main factory class for creating HTTP clients +- `Client.java` - HTTP client abstraction (default implementation + pluggable alternatives) +- `Contract.java` - Annotation processing interface (Default, JAX-RS, Spring contracts) +- `Encoder/Decoder.java` - Request/response serialization interfaces +- `Target.java` - Represents the remote HTTP service to invoke +- `RequestTemplate.java` - Template for building HTTP requests with parameter substitution +- `MethodMetadata.java` - Metadata about interface methods and their annotations + +**Integration Modules**: Each module provides integration with specific libraries: +- `gson/`, `jackson/`, `fastjson2/` - JSON serialization +- `okhttp/`, `httpclient/`, `hc5/`, `java11/` - HTTP client implementations +- `jaxrs/`, `jaxrs2/`, `jaxrs3/` - JAX-RS annotation support +- `spring/` - Spring MVC annotation support +- `hystrix/` - Circuit breaker integration +- `micrometer/`, `dropwizard-metrics4/5/` - Metrics integration + +### Key Design Patterns +- **Builder Pattern**: `Feign.builder()` for configuring clients +- **Factory Pattern**: `Feign.newInstance(Target)` creates proxy instances +- **Strategy Pattern**: Pluggable `Client`, `Encoder`, `Decoder`, `Contract` implementations +- **Template Method**: `RequestTemplate` for building HTTP requests with parameter substitution +- **Proxy Pattern**: Dynamic proxies created for interface-based clients + +### Multi-module Maven Structure +- Parent POM manages dependencies and common configuration +- Each integration is a separate Maven module +- Modules can be built independently: `mvn clean install -pl module-name` +- Example modules depend on `feign-core` and their respective 3rd party libraries + +### Testing Strategy +- `feign-core` contains `AbstractClientTest` base class for testing HTTP clients +- Each module has its own test suite +- Integration tests use MockWebServer for HTTP mocking +- Tests are run with JUnit 5 and AssertJ assertions + +## Development Notes + +### Code Style +- Google Java Format is enforced via git hooks +- Code is formatted automatically on commit +- Package-private visibility is preferred over public when possible +- 3rd party dependencies are minimized in core module + +### Module Dependencies +- Core module: Minimal dependencies (only what's needed for HTTP client abstraction) +- Integration modules: Add specific 3rd party libraries (Jackson, OkHttp, etc.) +- BOM (Bill of Materials) manages version consistency across modules + +### Java Version Support +- Source/target: Java 8 (for `src/main`) +- Tests: Java 21 (for `src/test`) +- Maintains backwards compatibility with Java 8 in main codebase + +### Updating Java Version for a Module +When a module's dependencies require a newer Java version (e.g., due to dependency upgrades), you need to override the Java version in that module's `pom.xml`: + +1. Add a `` section to the module's `pom.xml` (or update existing one) +2. Set `` to the required version (11, 17, 21, etc.) + +Example: +```xml + + 17 + +``` + +**Common scenarios requiring Java version updates:** +- Dropwizard Metrics 5.x requires Java 17 +- Handlebars 4.5.0+ requires Java 17 +- Jakarta EE modules typically require Java 11+ + +**Examples of modules with custom Java versions:** +- `spring/` - Java 17 (for Spring 6.x) +- `jaxrs4/` - Java 17 (for Jakarta EE 9+) +- `dropwizard-metrics5/` - Java 17 (for Metrics 5.x) +- `apt-test-generator/` - Java 17 (for Handlebars 4.5.0+) +- `soap-jakarta/`, `jaxb-jakarta/` - Java 11 (for Jakarta namespace) + +### Dependabot Configuration + +Some modules define the same Maven property name (e.g., `jersey.version`, `vertx.version`) at different major versions. Dependabot treats these as a single property across the reactor and tries to set them all to the same value, which breaks modules locked to a specific major. + +**Current split-property modules:** +- `jersey.version`: 2.x (jaxrs2), 3.x (jaxrs3), 4.x (jaxrs4) +- `vertx.version`: 4.x (feign-vertx4-test), 5.x (feign-vertx, feign-vertx5-test) + +**How it works in `.github/dependabot.yml`:** +1. The root `/` entry **ignores** the conflicting dependencies entirely (jersey, vertx) +2. Each module gets its own entry with `allow` (only the conflicting dependency) and `ignore` (block major version bumps) +3. Other dependencies that use **different property names** per major (e.g., `jaxb-impl-2.version` vs `jaxb-impl-4.version`) only need `update-types: ["version-update:semver-major"]` on the root entry + +**When adding a new module that reuses a version property at a different major:** +1. Add the dependency to the root entry's `ignore` list (fully ignored, not just major) +2. Add a per-directory entry for the new module with `allow` for the specific dependency and `ignore` for `version-update:semver-major` +3. Verify existing modules with the same property also have their own per-directory entries + +## Releasing + +The release script is at `scripts/release.sh`. It handles version updates, tagging, and pushing. + +### Usage +- `./scripts/release.sh` — auto-detect release version from pom (strips `-SNAPSHOT`), auto-compute next snapshot +- `./scripts/release.sh ` — release a specific version, auto-compute next snapshot +- `./scripts/release.sh ` — release a specific version with explicit next snapshot + +### Examples +```bash +# Standard release (pom is at 13.10-SNAPSHOT, releases 13.10, next becomes 13.11-SNAPSHOT) +./scripts/release.sh + +# Patch release with custom next snapshot +./scripts/release.sh 13.9.1 13.10-SNAPSHOT +``` + +### What the script does +1. Sets pom versions to the release version (removes `-SNAPSHOT`) +2. Formats license headers and commits locally (no push) +3. Creates and pushes a git tag for the release version +4. Sets pom versions to the next snapshot and commits/pushes + +### Patch releases +When doing a patch release (e.g., 13.9.1 while pom is at 13.10-SNAPSHOT), pass both arguments so the next snapshot returns to the current development version: +```bash +./scripts/release.sh 13.9.1 13.10-SNAPSHOT +``` + +## Documentation Requirements + +- New modules must include a `README.md` with usage examples following the style of existing module READMEs (e.g., `jackson/README.md`, `graphql/README.md`) +- New public functionality (annotations, contracts, encoders, decoders) must be documented in the module's `README.md` +- README should include: Maven dependency coordinates, `Feign.builder()` configuration examples, and advanced usage if applicable +- Update this file's Integration Modules list when adding a new module diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8eab9bf87..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,81 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Build Commands - -### Essential Build Commands -- `mvn clean install` - Default build command (installs all modules) -- `mvn clean install -Pdev` - **ALWAYS use this profile for development** - enables code formatting and other dev tools -- `mvn clean install -Pquickbuild` - Skip tests and validation for faster builds -- `mvn test` - Run all tests -- `mvn test -Dtest=ClassName` - Run specific test class - -### Module-specific Commands -- `mvn clean install -pl core` - Build only the core module -- `mvn clean install -pl core,gson` - Build specific modules -- `mvn clean test -pl core -Dtest=FeignTest` - Run specific test in specific module - -### Code Quality -- Code is automatically formatted using Google Java Format via git hooks -- License headers are enforced via maven-license-plugin -- Use `mvn validate` to check formatting and license compliance - -## Project Architecture - -### Core Architecture -Feign is a declarative HTTP client library with a modular design: - -**Core Module (`core/`)**: Contains the main Feign API and implementation -- `Feign.java` - Main factory class for creating HTTP clients -- `Client.java` - HTTP client abstraction (default implementation + pluggable alternatives) -- `Contract.java` - Annotation processing interface (Default, JAX-RS, Spring contracts) -- `Encoder/Decoder.java` - Request/response serialization interfaces -- `Target.java` - Represents the remote HTTP service to invoke -- `RequestTemplate.java` - Template for building HTTP requests with parameter substitution -- `MethodMetadata.java` - Metadata about interface methods and their annotations - -**Integration Modules**: Each module provides integration with specific libraries: -- `gson/`, `jackson/`, `fastjson2/` - JSON serialization -- `okhttp/`, `httpclient/`, `hc5/`, `java11/` - HTTP client implementations -- `jaxrs/`, `jaxrs2/`, `jaxrs3/` - JAX-RS annotation support -- `spring/` - Spring MVC annotation support -- `hystrix/` - Circuit breaker integration -- `micrometer/`, `dropwizard-metrics4/5/` - Metrics integration - -### Key Design Patterns -- **Builder Pattern**: `Feign.builder()` for configuring clients -- **Factory Pattern**: `Feign.newInstance(Target)` creates proxy instances -- **Strategy Pattern**: Pluggable `Client`, `Encoder`, `Decoder`, `Contract` implementations -- **Template Method**: `RequestTemplate` for building HTTP requests with parameter substitution -- **Proxy Pattern**: Dynamic proxies created for interface-based clients - -### Multi-module Maven Structure -- Parent POM manages dependencies and common configuration -- Each integration is a separate Maven module -- Modules can be built independently: `mvn clean install -pl module-name` -- Example modules depend on `feign-core` and their respective 3rd party libraries - -### Testing Strategy -- `feign-core` contains `AbstractClientTest` base class for testing HTTP clients -- Each module has its own test suite -- Integration tests use MockWebServer for HTTP mocking -- Tests are run with JUnit 5 and AssertJ assertions - -## Development Notes - -### Code Style -- Google Java Format is enforced via git hooks -- Code is formatted automatically on commit -- Package-private visibility is preferred over public when possible -- 3rd party dependencies are minimized in core module - -### Module Dependencies -- Core module: Minimal dependencies (only what's needed for HTTP client abstraction) -- Integration modules: Add specific 3rd party libraries (Jackson, OkHttp, etc.) -- BOM (Bill of Materials) manages version consistency across modules - -### Java Version Support -- Source/target: Java 8 (for `src/main`) -- Tests: Java 21 (for `src/test`) -- Maintains backwards compatibility with Java 8 in main codebase \ No newline at end of file diff --git a/README.md b/README.md index 9f4115cbc..6e31ab89f 100644 --- a/README.md +++ b/README.md @@ -374,44 +374,54 @@ Feign intends to work well with other Open Source tools. Modules are welcome to ### Encoder/Decoder #### Gson -[Gson](./gson) includes an encoder and decoder you can use with a JSON API. +[Gson](./gson) includes a codec you can use with a JSON API. -Add `GsonEncoder` and/or `GsonDecoder` to your `Feign.Builder` like so: +```java +GitHub github = Feign.builder() + .codec(new GsonCodec()) + .target(GitHub.class, "https://api.github.com"); +``` + +You can also configure the encoder and decoder separately: ```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"); - } -} +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. +[Jackson](./jackson) includes a codec you can use with a JSON API. -Add `JacksonEncoder` and/or `JacksonDecoder` to your `Feign.Builder` like so: +```java +GitHub github = Feign.builder() + .codec(new JacksonCodec()) + .target(GitHub.class, "https://api.github.com"); +``` + +You can also configure the encoder and decoder separately: ```java -public class Example { - public static void main(String[] args) { - GitHub github = Feign.builder() +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: +[Moshi](./moshi) includes a codec you can use with a JSON API. + +```java +GitHub github = Feign.builder() + .codec(new MoshiCodec()) + .target(GitHub.class, "https://api.github.com"); +``` + +You can also configure the encoder and decoder separately: ```java GitHub github = Feign.builder() @@ -425,70 +435,72 @@ GitHub github = Feign.builder() 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"); - } -} +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. +[JAXB](./jaxb) includes a codec you can use with an XML API. -Add `JAXBEncoder` and/or `JAXBDecoder` to your `Feign.Builder` like so: +```java +Api api = Feign.builder() + .codec(new JAXBCodec(jaxbFactory)) + .target(Api.class, "https://apihost"); +``` + +You can also configure the encoder and decoder separately: ```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"); - } -} +Api api = Feign.builder() + .encoder(new JAXBEncoder(jaxbFactory)) + .decoder(new JAXBDecoder(jaxbFactory)) + .target(Api.class, "https://apihost"); ``` #### SOAP -[SOAP](./soap) includes an encoder and decoder you can use with an XML API. - +[SOAP](./soap) includes a codec 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 +Api api = Feign.builder() + .codec(new SOAPCodec(jaxbFactory)) + .errorDecoder(new SOAPErrorDecoder()) + .target(MyApi.class, "http://api"); +``` + +You can also configure the encoder and decoder separately: ```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"); - } -} +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](./fastjson2) includes an encoder and decoder you can use with a JSON API. +[fastjson2](./fastjson2) includes a codec you can use with a JSON API. -Add `Fastjson2Encoder` and/or `Fastjson2Decoder` to your `Feign.Builder` like so: +```java +GitHub github = Feign.builder() + .codec(new Fastjson2Codec()) + .target(GitHub.class, "https://api.github.com"); +``` + +You can also configure the encoder and decoder separately: ```java -public class Example { - public static void main(String[] args) { - GitHub github = Feign.builder() +GitHub github = Feign.builder() .encoder(new Fastjson2Encoder()) .decoder(new Fastjson2Decoder()) .target(GitHub.class, "https://api.github.com"); - } -} ``` ### Contract diff --git a/annotation-error-decoder/pom.xml b/annotation-error-decoder/pom.xml index 760bc3d5b..c014de040 100644 --- a/annotation-error-decoder/pom.xml +++ b/annotation-error-decoder/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-annotation-error-decoder diff --git a/annotation-error-decoder/src/main/java/feign/error/AnnotationErrorDecoder.java b/annotation-error-decoder/src/main/java/feign/error/AnnotationErrorDecoder.java index 35e5969ef..20f2af10f 100644 --- a/annotation-error-decoder/src/main/java/feign/error/AnnotationErrorDecoder.java +++ b/annotation-error-decoder/src/main/java/feign/error/AnnotationErrorDecoder.java @@ -19,6 +19,8 @@ import feign.Response; import feign.codec.Decoder; +import feign.codec.DefaultDecoder; +import feign.codec.DefaultErrorDecoder; import feign.codec.ErrorDecoder; import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; @@ -52,8 +54,8 @@ public static AnnotationErrorDecoder.Builder builderFor(Class apiType) { public static class Builder { private final Class apiType; - private ErrorDecoder defaultDecoder = new ErrorDecoder.Default(); - private Decoder responseBodyDecoder = new Decoder.Default(); + private ErrorDecoder defaultDecoder = new DefaultErrorDecoder(); + private Decoder responseBodyDecoder = new DefaultDecoder(); public Builder(Class apiType) { this.apiType = apiType; diff --git a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderExceptionConstructorsTest.java b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderExceptionConstructorsTest.java index 94ec29ca0..9728a46f5 100644 --- a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderExceptionConstructorsTest.java +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderExceptionConstructorsTest.java @@ -18,7 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; import feign.Request; -import feign.codec.Decoder; +import feign.codec.DefaultDecoder; import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors; import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DeclaredDefaultConstructorException; import feign.error.AnnotationErrorDecoderExceptionConstructorsTest.TestClientInterfaceWithDifferentExceptionConstructors.DeclaredDefaultConstructorWithOtherConstructorsException; @@ -232,7 +232,7 @@ void test( AnnotationErrorDecoder decoder = AnnotationErrorDecoder.builderFor( TestClientInterfaceWithDifferentExceptionConstructors.class) - .withResponseBodyDecoder(new OptionalDecoder(new Decoder.Default())) + .withResponseBodyDecoder(new OptionalDecoder(new DefaultDecoder())) .build(); Exception genericException = @@ -268,7 +268,7 @@ void ifExceptionIsNotInTheList( AnnotationErrorDecoder decoder = AnnotationErrorDecoder.builderFor( TestClientInterfaceWithDifferentExceptionConstructors.class) - .withResponseBodyDecoder(new OptionalDecoder(new Decoder.Default())) + .withResponseBodyDecoder(new OptionalDecoder(new DefaultDecoder())) .build(); Exception genericException = diff --git a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderNoAnnotationTest.java b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderNoAnnotationTest.java index 78e506298..57cb8c06f 100644 --- a/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderNoAnnotationTest.java +++ b/annotation-error-decoder/src/test/java/feign/error/AnnotationErrorDecoderNoAnnotationTest.java @@ -32,7 +32,7 @@ public Class interfaceAtTest() { @Test void delegatesToDefaultErrorDecoder() throws Exception { - ErrorDecoder defaultErrorDecoder = (methodKey, response) -> new DefaultErrorDecoderException(); + ErrorDecoder defaultErrorDecoder = (_, _) -> new DefaultErrorDecoderException(); AnnotationErrorDecoder decoder = AnnotationErrorDecoder.builderFor(TestClientInterfaceWithNoAnnotations.class) diff --git a/apt-test-generator/pom.xml b/apt-test-generator/pom.xml index c3f2cf884..aaeb1b7a0 100644 --- a/apt-test-generator/pom.xml +++ b/apt-test-generator/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT io.github.openfeign.experimental @@ -31,26 +31,35 @@ Feign code generation tool for mocked clients + 17 true + true com.github.jknack handlebars - 4.3.1 + ${handlebars.version} io.github.openfeign - feign-example-github + feign-core ${project.version} + test + + + io.github.openfeign + feign-gson + ${project.version} + test com.google.testing.compile compile-testing - 0.21.0 + ${compile-testing.version} test @@ -74,69 +83,13 @@ com.google.auto.service auto-service-annotations - 1.1.1 + ${auto-service-annotations.version} provided - - - docker - true - - ${project.basedir}/docker - - - - ${basedir}/src/main/resources - - - src/main/java - - **/*.java - - - - - - org.apache.maven.plugins - maven-shade-plugin - 3.6.0 - - - - shade - - package - - - - feign.aptgenerator.github.GitHubFactoryExample - - - false - - - - - - org.skife.maven - really-executable-jar-maven-plugin - 2.1.1 - - github - - - - - really-executable-jar - - package - - - org.apache.maven.plugins maven-failsafe-plugin @@ -150,45 +103,11 @@ - - - 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 - - - - - - - - build - - post-integration-test - - - - 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 + --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 --add-opens jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED diff --git a/apt-test-generator/src/test/java/feign/apttestgenerator/GenerateTestStubAPTTest.java b/apt-test-generator/src/test/java/feign/apttestgenerator/GenerateTestStubAPTTest.java index c31990e5e..8a635fa99 100644 --- a/apt-test-generator/src/test/java/feign/apttestgenerator/GenerateTestStubAPTTest.java +++ b/apt-test-generator/src/test/java/feign/apttestgenerator/GenerateTestStubAPTTest.java @@ -26,7 +26,7 @@ /** Test for {@link GenerateTestStubAPT} */ class GenerateTestStubAPTTest { - private final File main = new File("../example-github/src/main/java/").getAbsoluteFile(); + private final File resources = new File("src/test/resources/").getAbsoluteFile(); @Test void test() throws Exception { @@ -35,12 +35,12 @@ void test() throws Exception { .withProcessors(new GenerateTestStubAPT()) .compile( JavaFileObjects.forResource( - new File(main, "example/github/GitHubExample.java").toURI().toURL())); + new File(resources, "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())); + new File(resources, "example/github/GitHubStub.java").toURI().toURL())); } } diff --git a/apt-test-generator/src/test/resources/example/github/GitHubExample.java b/apt-test-generator/src/test/resources/example/github/GitHubExample.java new file mode 100644 index 000000000..ecb7368b0 --- /dev/null +++ b/apt-test-generator/src/test/resources/example/github/GitHubExample.java @@ -0,0 +1,149 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 feign.*; +import feign.codec.Decoder; +import feign.codec.DefaultErrorDecoder; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; +import feign.gson.GsonDecoder; +import feign.gson.GsonEncoder; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** Inspired by {@code com.example.retrofit.GitHubClient} */ +public class GitHubExample { + + public interface GitHub { + + public class Repository { + String name; + } + + public class Contributor { + String login; + } + + public class Issue { + + Issue() {} + + String title; + String body; + List assignees; + int milestone; + List labels; + } + + @RequestLine("GET /users/{username}/repos?sort=full_name") + List repos(@Param("username") String owner); + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + + @RequestLine("POST /repos/{owner}/{repo}/issues") + void createIssue(Issue issue, @Param("owner") String owner, @Param("repo") String repo); + + /** Lists all contributors for all repos owned by a user. */ + default List contributors(String owner) { + return repos(owner).stream() + .flatMap(repo -> contributors(owner, repo.name).stream()) + .map(c -> c.login) + .distinct() + .collect(Collectors.toList()); + } + + static GitHub connect() { + final Decoder decoder = new GsonDecoder(); + final Encoder encoder = new GsonEncoder(); + return Feign.builder() + .encoder(encoder) + .decoder(decoder) + .errorDecoder(new GitHubErrorDecoder(decoder)) + .logger(new Logger.ErrorLogger()) + .logLevel(Logger.Level.BASIC) + .requestInterceptor( + template -> { + template.header( + // not available when building PRs... + // https://docs.travis-ci.com/user/environment-variables/#defining-encrypted-variables-in-travisyml + "Authorization", "token 383f1c1b474d8f05a21e7964976ab0d403fee071"); + }) + .options(new Request.Options(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true)) + .target(GitHub.class, "https://api.github.com"); + } + } + + static class GitHubClientError extends RuntimeException { + private String message; // parsed from json + + @Override + public String getMessage() { + return message; + } + } + + public static void main(String... args) { + final GitHub github = GitHub.connect(); + + System.out.println("Let's fetch and print a list of the contributors to this org."); + final List contributors = github.contributors("openfeign"); + for (final String contributor : contributors) { + System.out.println(contributor); + } + + System.out.println("Now, let's cause an error."); + try { + github.contributors("openfeign", "some-unknown-project"); + } catch (final GitHubClientError e) { + System.out.println(e.getMessage()); + } + + System.out.println("Now, try to create an issue - which will also cause an error."); + try { + final GitHub.Issue issue = new GitHub.Issue(); + issue.title = "The title"; + issue.body = "Some Text"; + github.createIssue(issue, "OpenFeign", "SomeRepo"); + } catch (final GitHubClientError e) { + System.out.println(e.getMessage()); + } + } + + static class GitHubErrorDecoder implements ErrorDecoder { + + final Decoder decoder; + final ErrorDecoder defaultDecoder = new DefaultErrorDecoder(); + + GitHubErrorDecoder(Decoder decoder) { + this.decoder = decoder; + } + + @Override + public Exception decode(String methodKey, Response response) { + try { + // must replace status by 200 other GSONDecoder returns null + response = response.toBuilder().status(200).build(); + return (Exception) decoder.decode(response, GitHubClientError.class); + } catch (final IOException fallbackToDefault) { + return defaultDecoder.decode(methodKey, response); + } + } + } +} diff --git a/apt-test-generator/src/test/java/example/github/GitHubStub.java b/apt-test-generator/src/test/resources/example/github/GitHubStub.java similarity index 100% rename from apt-test-generator/src/test/java/example/github/GitHubStub.java rename to apt-test-generator/src/test/resources/example/github/GitHubStub.java diff --git a/benchmark/pom.xml b/benchmark/pom.xml index 928754f30..9cf0959ce 100644 --- a/benchmark/pom.xml +++ b/benchmark/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-benchmark @@ -32,7 +32,7 @@ 1.37 0.5.3 1.3.8 - 4.2.3.Final + 4.2.10.Final true @@ -137,7 +137,7 @@ org.apache.maven.plugins maven-shade-plugin - 3.6.0 + ${maven-shade-plugin.version} @@ -158,7 +158,7 @@ org.skife.maven really-executable-jar-maven-plugin - 2.1.1 + ${really-executable-jar-maven-plugin.version} benchmark diff --git a/benchmark/src/main/java/feign/benchmark/RealRequestBenchmarks.java b/benchmark/src/main/java/feign/benchmark/RealRequestBenchmarks.java index c53427e08..052570f1b 100644 --- a/benchmark/src/main/java/feign/benchmark/RealRequestBenchmarks.java +++ b/benchmark/src/main/java/feign/benchmark/RealRequestBenchmarks.java @@ -15,11 +15,11 @@ */ package feign.benchmark; +import feign.DefaultRetryer; import feign.Feign; import feign.Logger; import feign.Logger.Level; import feign.Response; -import feign.Retryer; import io.netty.buffer.ByteBuf; import io.reactivex.netty.protocol.http.server.HttpServer; import java.io.IOException; @@ -63,7 +63,7 @@ public void setup() { .client(new feign.okhttp.OkHttpClient(client)) .logLevel(Level.NONE) .logger(new Logger.ErrorLogger()) - .retryer(new Retryer.Default()) + .retryer(new DefaultRetryer()) .target(FeignTestInterface.class, "http://localhost:" + SERVER_PORT); queryRequest = new Request.Builder() diff --git a/benchmark/src/main/java/feign/benchmark/WhatShouldWeCacheBenchmarks.java b/benchmark/src/main/java/feign/benchmark/WhatShouldWeCacheBenchmarks.java index 3ff35a2a0..cd6002c1e 100644 --- a/benchmark/src/main/java/feign/benchmark/WhatShouldWeCacheBenchmarks.java +++ b/benchmark/src/main/java/feign/benchmark/WhatShouldWeCacheBenchmarks.java @@ -17,6 +17,7 @@ import feign.Client; import feign.Contract; +import feign.DefaultContract; import feign.Feign; import feign.MethodMetadata; import feign.Response; @@ -53,11 +54,11 @@ public class WhatShouldWeCacheBenchmarks { @Setup public void setup() { - feignContract = new Contract.Default(); + feignContract = new DefaultContract(); cachedContact = new Contract() { private final List cached = - new Default().parseAndValidateMetadata(FeignTestInterface.class); + new DefaultContract().parseAndValidateMetadata(FeignTestInterface.class); public List parseAndValidateMetadata(Class declaring) { return cached; diff --git a/core/pom.xml b/core/pom.xml index 415b421f9..fb9bf5129 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-core @@ -50,7 +50,7 @@ org.springframework spring-context - 6.2.9 + ${spring-context.version} test diff --git a/core/src/main/java/feign/AsyncClient.java b/core/src/main/java/feign/AsyncClient.java index fba30003a..d724f1389 100644 --- a/core/src/main/java/feign/AsyncClient.java +++ b/core/src/main/java/feign/AsyncClient.java @@ -19,7 +19,6 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; /** Submits HTTP {@link Request requests} asynchronously, with an optional context. */ @Experimental @@ -40,36 +39,14 @@ public interface AsyncClient { */ CompletableFuture execute(Request request, Options options, Optional requestContext); - class Default implements AsyncClient { - - private final Client client; - private final ExecutorService executorService; + /** + * @deprecated use {@link DefaultAsyncClient} instead. + */ + @Deprecated + class Default extends DefaultAsyncClient { 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; + super(client, executorService); } } diff --git a/core/src/main/java/feign/AsyncFeign.java b/core/src/main/java/feign/AsyncFeign.java index 955740333..67948dd46 100644 --- a/core/src/main/java/feign/AsyncFeign.java +++ b/core/src/main/java/feign/AsyncFeign.java @@ -70,8 +70,8 @@ public static class AsyncBuilder extends BaseBuilder, AsyncFe private AsyncContextSupplier defaultContextSupplier = () -> null; private AsyncClient client = - new AsyncClient.Default<>( - new Client.Default(null, null), LazyInitializedExecutorService.instance); + new DefaultAsyncClient<>( + new DefaultClient(null, null), LazyInitializedExecutorService.instance); private MethodInfoResolver methodInfoResolver = MethodInfo::new; @Deprecated diff --git a/core/src/main/java/feign/BaseBuilder.java b/core/src/main/java/feign/BaseBuilder.java index d2549e0a7..354f01fab 100644 --- a/core/src/main/java/feign/BaseBuilder.java +++ b/core/src/main/java/feign/BaseBuilder.java @@ -20,13 +20,18 @@ import feign.Feign.ResponseMappingDecoder; import feign.Logger.NoOpLogger; import feign.Request.Options; +import feign.codec.Codec; import feign.codec.Decoder; +import feign.codec.DefaultDecoder; +import feign.codec.DefaultEncoder; +import feign.codec.DefaultErrorDecoder; import feign.codec.Encoder; import feign.codec.ErrorDecoder; import java.lang.reflect.Field; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -35,21 +40,21 @@ public abstract class BaseBuilder, T> implements Clo private final B thisB; - protected final List requestInterceptors = new ArrayList<>(); - protected final List responseInterceptors = new ArrayList<>(); + protected List requestInterceptors = new ArrayList<>(); + protected List responseInterceptors = new ArrayList<>(); protected Logger.Level logLevel = Logger.Level.NONE; - protected Contract contract = new Contract.Default(); - protected Retryer retryer = new Retryer.Default(); + protected Contract contract = new DefaultContract(); + protected Retryer retryer = new DefaultRetryer(); protected Logger logger = new NoOpLogger(); - protected Encoder encoder = new Encoder.Default(); - protected Decoder decoder = new Decoder.Default(); + protected Encoder encoder = new DefaultEncoder(); + protected Decoder decoder = new DefaultDecoder(); protected boolean closeAfterDecode = true; protected boolean decodeVoid = false; protected QueryMapEncoder queryMapEncoder = QueryMap.MapEncoder.FIELD.instance(); - protected ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + protected ErrorDecoder errorDecoder = new DefaultErrorDecoder(); protected Options options = new Options(); protected InvocationHandlerFactory invocationHandlerFactory = - new InvocationHandlerFactory.Default(); + new DefaultInvocationHandlerFactory(); protected boolean dismiss404; protected ExceptionPropagationPolicy propagationPolicy = NONE; protected List capabilities = new ArrayList<>(); @@ -89,6 +94,12 @@ public B decoder(Decoder decoder) { return thisB; } + public B codec(Codec codec) { + this.encoder = codec.encoder(); + this.decoder = codec.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 @@ -262,6 +273,38 @@ B enrich() { } }); + // enrich each request interceptor, then enrich the list as a whole + RequestInterceptor[] requestArray = + clone.requestInterceptors.toArray(new RequestInterceptor[0]); + for (int i = 0; i < requestArray.length; i++) { + requestArray[i] = + (RequestInterceptor) + Capability.enrich(requestArray[i], RequestInterceptor.class, capabilities); + } + RequestInterceptors requestInterceptors = + (RequestInterceptors) + Capability.enrich( + new RequestInterceptors(Arrays.asList(requestArray)), + RequestInterceptors.class, + capabilities); + clone.requestInterceptors = requestInterceptors.interceptors(); + + // enrich each response interceptor, then enrich the list as a whole + ResponseInterceptor[] responseArray = + clone.responseInterceptors.toArray(new ResponseInterceptor[0]); + for (int i = 0; i < responseArray.length; i++) { + responseArray[i] = + (ResponseInterceptor) + Capability.enrich(responseArray[i], ResponseInterceptor.class, capabilities); + } + ResponseInterceptors responseInterceptors = + (ResponseInterceptors) + Capability.enrich( + new ResponseInterceptors(Arrays.asList(responseArray)), + ResponseInterceptors.class, + capabilities); + clone.responseInterceptors = responseInterceptors.interceptors(); + return clone; } catch (CloneNotSupportedException e) { throw new AssertionError(e); @@ -276,6 +319,9 @@ List getFieldsToEnrich() { .filter(field -> !Objects.equals(field.getName(), "capabilities")) // and thisB helper field .filter(field -> !Objects.equals(field.getName(), "thisB")) + // interceptor lists are enriched per-element then as a whole via custom types + .filter(field -> !Objects.equals(field.getName(), "requestInterceptors")) + .filter(field -> !Objects.equals(field.getName(), "responseInterceptors")) // skip primitive types .filter(field -> !field.getType().isPrimitive()) // skip enumerations diff --git a/core/src/main/java/feign/Capability.java b/core/src/main/java/feign/Capability.java index 7fb7e5d13..554c2a100 100644 --- a/core/src/main/java/feign/Capability.java +++ b/core/src/main/java/feign/Capability.java @@ -144,4 +144,12 @@ default AsyncContextSupplier enrich(AsyncContextSupplier asyncContextS default MethodInfoResolver enrich(MethodInfoResolver methodInfoResolver) { return methodInfoResolver; } + + default RequestInterceptors enrich(RequestInterceptors requestInterceptors) { + return requestInterceptors; + } + + default ResponseInterceptors enrich(ResponseInterceptors responseInterceptors) { + return responseInterceptors; + } } diff --git a/core/src/main/java/feign/Client.java b/core/src/main/java/feign/Client.java index 3c3d048c2..acaf75bb5 100644 --- a/core/src/main/java/feign/Client.java +++ b/core/src/main/java/feign/Client.java @@ -15,36 +15,18 @@ */ package feign; -import static feign.Util.ACCEPT_ENCODING; -import static feign.Util.CONTENT_ENCODING; -import static feign.Util.CONTENT_LENGTH; -import static feign.Util.ENCODING_DEFLATE; -import static feign.Util.ENCODING_GZIP; import static feign.Util.checkArgument; import static feign.Util.checkNotNull; import static feign.Util.isNotBlank; -import static java.lang.String.CASE_INSENSITIVE_ORDER; -import static java.lang.String.format; import feign.Request.Options; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.Proxy; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.Base64; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.TreeMap; -import java.util.zip.DeflaterOutputStream; -import java.util.zip.GZIPInputStream; -import java.util.zip.GZIPOutputStream; -import java.util.zip.InflaterInputStream; import javax.net.ssl.HostnameVerifier; -import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLSocketFactory; /** Submits HTTP {@link Request requests}. Implementations are expected to be thread-safe. */ @@ -60,201 +42,21 @@ public interface Client { */ 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; + /** + * @deprecated use {@link DefaultClient} instead. + */ + @Deprecated + class Default extends DefaultClient { - /** - * 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; + super(sslContextFactory, hostnameVerifier); } - /** - * 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", "*/*"); - } - - byte[] body = request.body(); - - if (body != null) { - /* - * Ignore disableRequestBuffering flag if the empty body was set, to ensure that internal - * retry logic applies to such requests. - */ - if (disableRequestBuffering) { - if (contentLength != null) { - connection.setFixedLengthStreamingMode(contentLength); - } else { - connection.setChunkedStreamingMode(8196); - } - } - connection.setDoOutput(true); - OutputStream out = connection.getOutputStream(); - if (gzipEncodedRequest) { - out = new GZIPOutputStream(out); - } else if (deflateEncodedRequest) { - out = new DeflaterOutputStream(out); - } - try { - out.write(body); - } finally { - try { - out.close(); - } catch (IOException suppressed) { // NOPMD - } - } - } - - if (body == null && request.httpMethod().isWithBody()) { - // To use this Header, set 'sun.net.http.allowRestrictedHeaders' property true. - connection.addRequestProperty("Content-Length", "0"); - } - - 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); + super(sslContextFactory, hostnameVerifier, disableRequestBuffering); } } diff --git a/core/src/main/java/feign/Contract.java b/core/src/main/java/feign/Contract.java index e014121a3..516272f45 100644 --- a/core/src/main/java/feign/Contract.java +++ b/core/src/main/java/feign/Contract.java @@ -16,13 +16,10 @@ package feign; import static feign.Util.checkState; -import static feign.Util.emptyToNull; -import feign.Request.HttpMethod; import java.lang.annotation.Annotation; import java.lang.reflect.Method; import java.lang.reflect.Modifier; -import java.lang.reflect.Parameter; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.net.URI; @@ -31,8 +28,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** Defines what annotations and values are valid on interfaces. */ public interface Contract { @@ -251,128 +246,9 @@ protected void nameParam(MethodMetadata data, String name, int i) { } } - 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.\nHint: %s", - paramIndex, - "Prefer using @Param(value=\"name\"), or compile your code with the -parameters flag.\n" - + "If the value is missing, Feign attempts to retrieve the parameter name from bytecode, " - + "which only works if the class was compiled with the -parameters flag."); - 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; - } - } + /** + * @deprecated use {@link DefaultContract} instead. + */ + @Deprecated + class Default extends DefaultContract {} } diff --git a/core/src/main/java/feign/DefaultAsyncClient.java b/core/src/main/java/feign/DefaultAsyncClient.java new file mode 100644 index 000000000..4df0d2291 --- /dev/null +++ b/core/src/main/java/feign/DefaultAsyncClient.java @@ -0,0 +1,56 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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.Options; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; + +@Experimental +public class DefaultAsyncClient implements AsyncClient { + + private final Client client; + private final ExecutorService executorService; + + public DefaultAsyncClient(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; + } +} diff --git a/core/src/main/java/feign/DefaultClient.java b/core/src/main/java/feign/DefaultClient.java new file mode 100644 index 000000000..d8e02fb4a --- /dev/null +++ b/core/src/main/java/feign/DefaultClient.java @@ -0,0 +1,240 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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.ACCEPT_ENCODING; +import static feign.Util.CONTENT_ENCODING; +import static feign.Util.CONTENT_LENGTH; +import static feign.Util.ENCODING_DEFLATE; +import static feign.Util.ENCODING_GZIP; +import static 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.URL; +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; + +public class DefaultClient 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 DefaultClient(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 DefaultClient( + 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", "*/*"); + } + + byte[] body = request.body(); + + if (body != null) { + /* + * Ignore disableRequestBuffering flag if the empty body was set, to ensure that internal + * retry logic applies to such requests. + */ + if (disableRequestBuffering) { + if (contentLength != null) { + connection.setFixedLengthStreamingMode(contentLength); + } else { + connection.setChunkedStreamingMode(8196); + } + } + connection.setDoOutput(true); + OutputStream out = connection.getOutputStream(); + if (gzipEncodedRequest) { + out = new GZIPOutputStream(out); + } else if (deflateEncodedRequest) { + out = new DeflaterOutputStream(out); + } + try { + out.write(body); + } finally { + try { + out.close(); + } catch (IOException suppressed) { // NOPMD + } + } + } + + if (body == null && request.httpMethod().isWithBody()) { + // To use this Header, set 'sun.net.http.allowRestrictedHeaders' property true. + connection.addRequestProperty("Content-Length", "0"); + } + + 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); + } +} diff --git a/core/src/main/java/feign/DefaultContract.java b/core/src/main/java/feign/DefaultContract.java new file mode 100644 index 000000000..79edadde8 --- /dev/null +++ b/core/src/main/java/feign/DefaultContract.java @@ -0,0 +1,153 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign; + +import static feign.Util.checkState; +import static feign.Util.emptyToNull; + +import feign.Request.HttpMethod; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class DefaultContract extends DeclarativeContract { + + static final Pattern REQUEST_LINE_PATTERN = Pattern.compile("^([A-Z]+)[ ]*(.*)$"); + + public DefaultContract() { + 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.\nHint: %s", + paramIndex, + "Prefer using @Param(value=\"name\"), or compile your code with the -parameters flag.\n" + + "If the value is missing, Feign attempts to retrieve the parameter name from bytecode, " + + "which only works if the class was compiled with the -parameters flag."); + 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/DefaultInvocationHandlerFactory.java b/core/src/main/java/feign/DefaultInvocationHandlerFactory.java new file mode 100644 index 000000000..b6b6d32ef --- /dev/null +++ b/core/src/main/java/feign/DefaultInvocationHandlerFactory.java @@ -0,0 +1,28 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.util.Map; + +public class DefaultInvocationHandlerFactory implements InvocationHandlerFactory { + + @Override + public InvocationHandler create(Target target, Map dispatch) { + return new ReflectiveFeign.FeignInvocationHandler(target, dispatch); + } +} diff --git a/core/src/main/java/feign/DefaultQueryMapEncoder.java b/core/src/main/java/feign/DefaultQueryMapEncoder.java new file mode 100644 index 000000000..f1df747f9 --- /dev/null +++ b/core/src/main/java/feign/DefaultQueryMapEncoder.java @@ -0,0 +1,24 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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; + +/** + * @deprecated use {@link feign.querymap.BeanQueryMapEncoder} instead. + */ +@Deprecated +public class DefaultQueryMapEncoder extends FieldQueryMapEncoder {} diff --git a/core/src/main/java/feign/DefaultRetryer.java b/core/src/main/java/feign/DefaultRetryer.java new file mode 100644 index 000000000..9248d8c42 --- /dev/null +++ b/core/src/main/java/feign/DefaultRetryer.java @@ -0,0 +1,86 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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; + +public class DefaultRetryer implements Retryer { + + private final int maxAttempts; + private final long period; + private final long maxPeriod; + int attempt; + long sleptForMillis; + + public DefaultRetryer() { + this(100, SECONDS.toMillis(1), 5); + } + + public DefaultRetryer(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 DefaultRetryer(period, maxPeriod, maxAttempts); + } +} diff --git a/core/src/main/java/feign/Feign.java b/core/src/main/java/feign/Feign.java index 950b0c4e8..c9095fb1d 100644 --- a/core/src/main/java/feign/Feign.java +++ b/core/src/main/java/feign/Feign.java @@ -95,7 +95,7 @@ public static String configKey(Method method) { public static class Builder extends BaseBuilder { - private Client client = new Client.Default(null, null); + private Client client = new DefaultClient(null, null); @Override public Builder logLevel(Logger.Level logLevel) { diff --git a/core/src/main/java/feign/InvocationHandlerFactory.java b/core/src/main/java/feign/InvocationHandlerFactory.java index f091e91a2..64cd47f8e 100644 --- a/core/src/main/java/feign/InvocationHandlerFactory.java +++ b/core/src/main/java/feign/InvocationHandlerFactory.java @@ -37,11 +37,9 @@ interface Factory { } } - static final class Default implements InvocationHandlerFactory { - - @Override - public InvocationHandler create(Target target, Map dispatch) { - return new ReflectiveFeign.FeignInvocationHandler(target, dispatch); - } - } + /** + * @deprecated use {@link DefaultInvocationHandlerFactory} instead. + */ + @Deprecated + static final class Default extends DefaultInvocationHandlerFactory {} } diff --git a/core/src/main/java/feign/QueryMapEncoder.java b/core/src/main/java/feign/QueryMapEncoder.java index 9543136f9..8ba510f03 100644 --- a/core/src/main/java/feign/QueryMapEncoder.java +++ b/core/src/main/java/feign/QueryMapEncoder.java @@ -36,10 +36,8 @@ public interface QueryMapEncoder { 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} + * @deprecated use {@link DefaultQueryMapEncoder} instead. */ - class Default extends FieldQueryMapEncoder {} + @Deprecated + class Default extends DefaultQueryMapEncoder {} } diff --git a/core/src/main/java/feign/RequestInterceptors.java b/core/src/main/java/feign/RequestInterceptors.java new file mode 100644 index 000000000..3376a7f52 --- /dev/null +++ b/core/src/main/java/feign/RequestInterceptors.java @@ -0,0 +1,33 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class RequestInterceptors { + + private final List interceptors; + + public RequestInterceptors(List interceptors) { + this.interceptors = new ArrayList<>(interceptors); + } + + public List interceptors() { + return Collections.unmodifiableList(interceptors); + } +} diff --git a/core/src/main/java/feign/RequestLine.java b/core/src/main/java/feign/RequestLine.java index b9e8ceffd..626c61875 100644 --- a/core/src/main/java/feign/RequestLine.java +++ b/core/src/main/java/feign/RequestLine.java @@ -30,9 +30,65 @@ @Retention(RUNTIME) public @interface RequestLine { + /** + * The HTTP request line, including the method and an optional URI template. + * + *

The string must begin with a valid {@linkplain feign.Request.HttpMethod HTTP method name} + * (e.g. {@linkplain feign.Request.HttpMethod#GET GET}, {@linkplain feign.Request.HttpMethod#POST + * POST}, {@linkplain feign.Request.HttpMethod#PUT PUT}), followed by a space and a URI template. + * If only the HTTP method is specified (e.g. {@code "DELETE"}), the request will use the base URL + * defined for the client. + * + *

Example: + * + *

{@code @RequestLine("GET /repos/{owner}/{repo}")
+   * Repo getRepo(@Param("owner") String owner, @Param("repo") String repo);
+   * }
+ * + * @return the HTTP method and optional URI template for the request. + * @see feign.template.UriTemplate + */ String value(); + /** + * Controls whether percent-encoded forward slashes ({@code %2F}) in expanded path variables are + * decoded back to {@code '/'} before sending the request. + * + *

When {@code true} (the default), any {@code %2F} sequences produced during URI template + * expansion will be replaced with literal slashes, meaning that path variables containing slashes + * will be interpreted as multiple path segments. + * + *

When {@code false}, percent-encoded slashes ({@code %2F}) are preserved in the final URL. + * This is useful when a path variable intentionally includes a slash as part of its value (for + * example, an encoded identifier such as {@code "foo%2Fbar"}). + * + *

Example: + * + *

{@code @RequestLine(value = "GET /projects/{id}", decodeSlash = false)
+   * Project getProject(@Param("id") String encodedId);
+   * }
+ * + * @return {@code true} if encoded slashes should be decoded (default behavior); {@code false} to + * preserve {@code %2F} sequences in the URL. + */ boolean decodeSlash() default true; + /** + * Specifies how collections (e.g. {@link java.util.List List} or arrays) are serialized when + * expanded into the URI template. + * + *

Determines whether values are represented as exploded parameters (repeated keys) or as a + * single comma-separated value, depending on the chosen {@link feign.CollectionFormat}. + * + *

Example: + * + *

    + *
  • {@linkplain CollectionFormat#EXPLODED EXPLODED}: {@code /items?id=1&id=2&id=3} + *
  • {@linkplain CollectionFormat#CSV CSV}: {@code /items?id=1,2,3} + *
+ * + * @return the collection serialization format to use when expanding templates. + * @see CollectionFormat + */ CollectionFormat collectionFormat() default CollectionFormat.EXPLODED; } diff --git a/core/src/main/java/feign/ResponseInterceptors.java b/core/src/main/java/feign/ResponseInterceptors.java new file mode 100644 index 000000000..52c943da3 --- /dev/null +++ b/core/src/main/java/feign/ResponseInterceptors.java @@ -0,0 +1,33 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class ResponseInterceptors { + + private final List interceptors; + + public ResponseInterceptors(List interceptors) { + this.interceptors = new ArrayList<>(interceptors); + } + + public List interceptors() { + return Collections.unmodifiableList(interceptors); + } +} diff --git a/core/src/main/java/feign/RetryableException.java b/core/src/main/java/feign/RetryableException.java index 2e210fef6..6c6998631 100644 --- a/core/src/main/java/feign/RetryableException.java +++ b/core/src/main/java/feign/RetryableException.java @@ -26,10 +26,11 @@ */ public class RetryableException extends FeignException { - private static final long serialVersionUID = 2L; + private static final long serialVersionUID = 3L; private final Long retryAfter; private final HttpMethod httpMethod; + private final String methodKey; /** * Represents a non-retryable exception when Retry-After information is explicitly not provided. @@ -46,6 +47,7 @@ public RetryableException(int status, String message, HttpMethod httpMethod, Req super(status, message, request); this.httpMethod = httpMethod; this.retryAfter = null; + this.methodKey = null; } /** @@ -65,6 +67,7 @@ public RetryableException( super(status, message, request, cause); this.httpMethod = httpMethod; this.retryAfter = null; + this.methodKey = null; } /** @@ -95,6 +98,32 @@ public RetryableException( super(status, message, request, cause); this.httpMethod = httpMethod; this.retryAfter = retryAfter; + this.methodKey = null; + } + + /** + * Represents a retryable exception with methodKey for identifying the method being retried. + * + * @param status the HTTP status code + * @param message the exception message + * @param httpMethod the HTTP method (GET, POST, etc.) + * @param cause the underlying cause of the exception + * @param retryAfter the retry delay in milliseconds + * @param request the original HTTP request + * @param methodKey the method key identifying the Feign method + */ + public RetryableException( + int status, + String message, + HttpMethod httpMethod, + Throwable cause, + Long retryAfter, + Request request, + String methodKey) { + super(status, message, request, cause); + this.httpMethod = httpMethod; + this.retryAfter = retryAfter; + this.methodKey = methodKey; } /** @@ -119,6 +148,7 @@ public RetryableException( super(status, message, request, cause); this.httpMethod = httpMethod; this.retryAfter = retryAfter != null ? retryAfter.getTime() : null; + this.methodKey = null; } /** @@ -139,6 +169,7 @@ public RetryableException( super(status, message, request); this.httpMethod = httpMethod; this.retryAfter = retryAfter; + this.methodKey = null; } /** @@ -156,6 +187,7 @@ public RetryableException( super(status, message, request); this.httpMethod = httpMethod; this.retryAfter = retryAfter != null ? retryAfter.getTime() : null; + this.methodKey = null; } /** @@ -183,6 +215,7 @@ public RetryableException( super(status, message, request, responseBody, responseHeaders); this.httpMethod = httpMethod; this.retryAfter = retryAfter; + this.methodKey = null; } /** @@ -209,6 +242,7 @@ public RetryableException( super(status, message, request, responseBody, responseHeaders); this.httpMethod = httpMethod; this.retryAfter = retryAfter != null ? retryAfter.getTime() : null; + this.methodKey = null; } /** @@ -222,4 +256,14 @@ public Long retryAfter() { public HttpMethod method() { return this.httpMethod; } + + /** + * Returns the method key identifying the Feign method that was being invoked. This corresponds to + * the methodKey parameter in {@link feign.codec.ErrorDecoder#decode}. + * + * @return the method key, or null if not set + */ + public String methodKey() { + return this.methodKey; + } } diff --git a/core/src/main/java/feign/Retryer.java b/core/src/main/java/feign/Retryer.java index 0f7045333..e4023f377 100644 --- a/core/src/main/java/feign/Retryer.java +++ b/core/src/main/java/feign/Retryer.java @@ -15,8 +15,6 @@ */ 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. @@ -30,71 +28,18 @@ public interface Retryer extends Cloneable { Retryer clone(); - class Default implements Retryer { - - private final int maxAttempts; - private final long period; - private final long maxPeriod; - int attempt; - long sleptForMillis; + /** + * @deprecated use {@link DefaultRetryer} instead. + */ + @Deprecated + class Default extends DefaultRetryer { public Default() { - this(100, SECONDS.toMillis(1), 5); + super(); } 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); + super(period, maxPeriod, maxAttempts); } } diff --git a/core/src/main/java/feign/Util.java b/core/src/main/java/feign/Util.java index 99b56683d..91cb7e5a1 100644 --- a/core/src/main/java/feign/Util.java +++ b/core/src/main/java/feign/Util.java @@ -34,6 +34,7 @@ import java.nio.CharBuffer; import java.nio.charset.CharacterCodingException; import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -74,11 +75,11 @@ public class Util { public static final String ENCODING_DEFLATE = "deflate"; /** UTF-8: eight-bit UCS Transformation Format. */ - public static final Charset UTF_8 = Charset.forName("UTF-8"); + public static final Charset UTF_8 = StandardCharsets.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"); + public static final Charset ISO_8859_1 = StandardCharsets.ISO_8859_1; private static final int BUF_SIZE = 0x800; // 2K chars (4K bytes) diff --git a/core/src/main/java/feign/codec/Codec.java b/core/src/main/java/feign/codec/Codec.java new file mode 100644 index 000000000..4e2a64c4b --- /dev/null +++ b/core/src/main/java/feign/codec/Codec.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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.Experimental; + +@Experimental +public interface Codec { + + Encoder encoder(); + + Decoder decoder(); +} diff --git a/core/src/main/java/feign/codec/Decoder.java b/core/src/main/java/feign/codec/Decoder.java index defe925ab..4ae473b9b 100644 --- a/core/src/main/java/feign/codec/Decoder.java +++ b/core/src/main/java/feign/codec/Decoder.java @@ -18,7 +18,6 @@ import feign.Feign; import feign.FeignException; import feign.Response; -import feign.Util; import java.io.IOException; import java.lang.reflect.Type; @@ -85,17 +84,9 @@ public interface Decoder { */ 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); - } - } + /** + * @deprecated use {@link DefaultDecoder} instead. + */ + @Deprecated + public class Default extends DefaultDecoder {} } diff --git a/core/src/main/java/feign/codec/DefaultDecoder.java b/core/src/main/java/feign/codec/DefaultDecoder.java new file mode 100644 index 000000000..c6ada1025 --- /dev/null +++ b/core/src/main/java/feign/codec/DefaultDecoder.java @@ -0,0 +1,34 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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.Response; +import feign.Util; +import java.io.IOException; +import java.lang.reflect.Type; + +public class DefaultDecoder 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/DefaultEncoder.java b/core/src/main/java/feign/codec/DefaultEncoder.java new file mode 100644 index 000000000..39a0f8dab --- /dev/null +++ b/core/src/main/java/feign/codec/DefaultEncoder.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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 java.lang.String.format; + +import feign.RequestTemplate; +import java.lang.reflect.Type; + +public class DefaultEncoder 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/DefaultErrorDecoder.java b/core/src/main/java/feign/codec/DefaultErrorDecoder.java new file mode 100644 index 000000000..fc7172cd4 --- /dev/null +++ b/core/src/main/java/feign/codec/DefaultErrorDecoder.java @@ -0,0 +1,68 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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 feign.FeignException; +import feign.Response; +import feign.RetryableException; +import java.util.Collection; +import java.util.Map; + +public class DefaultErrorDecoder implements ErrorDecoder { + + private final ErrorDecoder.RetryAfterDecoder retryAfterDecoder = + new ErrorDecoder.RetryAfterDecoder(); + private Integer maxBodyBytesLength; + private Integer maxBodyCharsLength; + + public DefaultErrorDecoder() { + this.maxBodyBytesLength = null; + this.maxBodyCharsLength = null; + } + + public DefaultErrorDecoder(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(), + methodKey); + } + 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; + } +} diff --git a/core/src/main/java/feign/codec/Encoder.java b/core/src/main/java/feign/codec/Encoder.java index 79a7f2003..143d33b22 100644 --- a/core/src/main/java/feign/codec/Encoder.java +++ b/core/src/main/java/feign/codec/Encoder.java @@ -15,8 +15,6 @@ */ package feign.codec; -import static java.lang.String.format; - import feign.RequestTemplate; import feign.Util; import java.lang.reflect.Type; @@ -83,19 +81,9 @@ public interface Encoder { */ 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())); - } - } - } + /** + * @deprecated use {@link DefaultEncoder} instead. + */ + @Deprecated + class Default extends DefaultEncoder {} } diff --git a/core/src/main/java/feign/codec/ErrorDecoder.java b/core/src/main/java/feign/codec/ErrorDecoder.java index 29f07a0b2..a2a55dc54 100644 --- a/core/src/main/java/feign/codec/ErrorDecoder.java +++ b/core/src/main/java/feign/codec/ErrorDecoder.java @@ -15,20 +15,14 @@ */ 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 @@ -81,44 +75,18 @@ public interface ErrorDecoder { */ public Exception decode(String methodKey, Response response); - public class Default implements ErrorDecoder { - - private final RetryAfterDecoder retryAfterDecoder = new RetryAfterDecoder(); - private Integer maxBodyBytesLength; - private Integer maxBodyCharsLength; + /** + * @deprecated use {@link DefaultErrorDecoder} instead. + */ + @Deprecated + public class Default extends DefaultErrorDecoder { public Default() { - this.maxBodyBytesLength = null; - this.maxBodyCharsLength = null; + super(); } 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; + super(maxBodyBytesLength, maxBodyCharsLength); } } diff --git a/core/src/main/java/feign/codec/JsonCodec.java b/core/src/main/java/feign/codec/JsonCodec.java new file mode 100644 index 000000000..4ef6a8853 --- /dev/null +++ b/core/src/main/java/feign/codec/JsonCodec.java @@ -0,0 +1,28 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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.Experimental; + +@Experimental +public interface JsonCodec extends Codec { + + @Override + JsonEncoder encoder(); + + @Override + JsonDecoder decoder(); +} diff --git a/core/src/main/java/feign/codec/JsonDecoder.java b/core/src/main/java/feign/codec/JsonDecoder.java new file mode 100644 index 000000000..5a51af550 --- /dev/null +++ b/core/src/main/java/feign/codec/JsonDecoder.java @@ -0,0 +1,26 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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.Experimental; +import java.io.IOException; +import java.lang.reflect.Type; + +@Experimental +public interface JsonDecoder extends Decoder { + + Object convert(Object object, Type type) throws IOException; +} diff --git a/core/src/main/java/feign/codec/JsonEncoder.java b/core/src/main/java/feign/codec/JsonEncoder.java new file mode 100644 index 000000000..47d800e74 --- /dev/null +++ b/core/src/main/java/feign/codec/JsonEncoder.java @@ -0,0 +1,21 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR 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.Experimental; + +@Experimental +public interface JsonEncoder extends Encoder {} diff --git a/core/src/test/java/feign/AlwaysEncodeBodyContractTest.java b/core/src/test/java/feign/AlwaysEncodeBodyContractTest.java index de0580ddd..7a626d6ef 100644 --- a/core/src/test/java/feign/AlwaysEncodeBodyContractTest.java +++ b/core/src/test/java/feign/AlwaysEncodeBodyContractTest.java @@ -38,7 +38,7 @@ class AlwaysEncodeBodyContractTest { private static class SampleContract extends AlwaysEncodeBodyContract { SampleContract() { AnnotationProcessor annotationProcessor = - (annotation, metadata) -> metadata.template().method(Request.HttpMethod.POST); + (_, metadata) -> metadata.template().method(Request.HttpMethod.POST); super.registerMethodAnnotation(SampleMethodAnnotation.class, annotationProcessor); } } diff --git a/core/src/test/java/feign/AsyncFeignTest.java b/core/src/test/java/feign/AsyncFeignTest.java index d1ac3c877..efd965632 100644 --- a/core/src/test/java/feign/AsyncFeignTest.java +++ b/core/src/test/java/feign/AsyncFeignTest.java @@ -32,6 +32,9 @@ import feign.Target.HardCodedTarget; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.DefaultDecoder; +import feign.codec.DefaultEncoder; +import feign.codec.DefaultErrorDecoder; import feign.codec.EncodeException; import feign.codec.Encoder; import feign.codec.ErrorDecoder; @@ -145,7 +148,7 @@ void bodyTypeCorrespondsWithParameterType() throws Exception { TestInterfaceAsync api = new TestInterfaceAsyncBuilder() .encoder( - new Encoder.Default() { + new DefaultEncoder() { @Override public void encode(Object object, Type bodyType, RequestTemplate template) { encodedType.set(bodyType); @@ -508,7 +511,7 @@ void overrideTypeSpecificDecoder() throws Throwable { TestInterfaceAsync api = new TestInterfaceAsyncBuilder() - .decoder((response, type) -> "fail") + .decoder((_, _) -> "fail") .target("http://localhost:" + server.getPort()); assertThat(unwrap(api.post())).isEqualTo("fail"); @@ -551,7 +554,7 @@ void doesntRetryAfterResponseIsSent() throws Throwable { TestInterfaceAsync api = new TestInterfaceAsyncBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target("http://localhost:" + server.getPort()); @@ -569,7 +572,7 @@ void throwsFeignExceptionIncludingBody() throws Throwable { TestInterfaceAsync api = AsyncFeign.builder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort()); @@ -594,7 +597,7 @@ void throwsFeignExceptionWithoutBody() { TestInterfaceAsync api = AsyncFeign.builder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort()); @@ -621,7 +624,7 @@ void ensureRetryerClonesItself() throws Throwable { AsyncFeign.builder() .retryer(retryer) .errorDecoder( - (methodKey, response) -> + (_, response) -> new RetryableException( response.status(), "play it again sam!", @@ -645,9 +648,9 @@ void throwsOriginalExceptionAfterFailedRetries() throws Throwable { TestInterfaceAsync api = AsyncFeign.builder() .exceptionPropagationPolicy(UNWRAP) - .retryer(new Retryer.Default(1, 1, 2)) + .retryer(new DefaultRetryer(1, 1, 2)) .errorDecoder( - (methodKey, response) -> + (_, response) -> new RetryableException( response.status(), "play it again sam!", @@ -671,9 +674,9 @@ void throwsRetryableExceptionIfNoUnderlyingCause() throws Throwable { TestInterfaceAsync api = AsyncFeign.builder() .exceptionPropagationPolicy(UNWRAP) - .retryer(new Retryer.Default(1, 1, 2)) + .retryer(new DefaultRetryer(1, 1, 2)) .errorDecoder( - (methodKey, response) -> + (_, response) -> new RetryableException( response.status(), message, @@ -705,7 +708,7 @@ void whenReturnTypeIsResponseNoErrorHandling() throws Throwable { // fake client as Client.Default follows redirects. TestInterfaceAsync api = AsyncFeign.builder() - .client(new AsyncClient.Default<>((request, options) -> response, execs)) + .client(new DefaultAsyncClient<>((_, _) -> response, execs)) .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort()); assertThat(unwrap(api.response()).headers()) @@ -730,7 +733,7 @@ public void cancelRetry(final int expectedTryCount) throws Throwable { final int RUNNING_TIME_MILLIS = 100; final ExecutorService execs = Executors.newSingleThreadExecutor(); final AsyncClient clientMock = - (request, options, requestContext) -> + (_, _, _) -> CompletableFuture.supplyAsync( () -> { final int tryCount = actualTryCount.addAndGet(1); @@ -755,7 +758,7 @@ public void cancelRetry(final int expectedTryCount) throws Throwable { final TestInterfaceAsync sut = AsyncFeign.builder() .client(clientMock) - .retryer(new Retryer.Default(0, Long.MAX_VALUE, expectedTryCount * 2)) + .retryer(new DefaultRetryer(0, Long.MAX_VALUE, expectedTryCount * 2)) .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort()); // Act @@ -796,7 +799,7 @@ void okIfDecodeRootCauseHasNoMessage() throws Throwable { TestInterfaceAsync api = new TestInterfaceAsyncBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new RuntimeException(); }) .target("http://localhost:" + server.getPort()); @@ -812,7 +815,7 @@ void decodingExceptionGetWrappedInDismiss404Mode() throws Throwable { new TestInterfaceAsyncBuilder() .dismiss404() .decoder( - (response, type) -> { + (response, _) -> { assertEquals(404, response.status()); throw new NoSuchElementException(); }) @@ -848,7 +851,7 @@ void okIfEncodeRootCauseHasNoMessage() throws Throwable { TestInterfaceAsync api = new TestInterfaceAsyncBuilder() .encoder( - (object, bodyType, template) -> { + (_, _, _) -> { throw new RuntimeException(); }) .target("http://localhost:" + server.getPort()); @@ -951,7 +954,7 @@ void responseMapperIsAppliedBeforeDelegate() throws IOException { } private ResponseMapper upperCaseResponseMapper() { - return (response, type) -> { + return (response, _) -> { try { return response.toBuilder() .body(Util.toString(response.body().asReader()).toUpperCase().getBytes()) @@ -1194,7 +1197,7 @@ public void apply(RequestTemplate template) { } } - static class IllegalArgumentExceptionOn400 extends ErrorDecoder.Default { + static class IllegalArgumentExceptionOn400 extends DefaultErrorDecoder { @Override public Exception decode(String methodKey, Response response) { @@ -1205,7 +1208,7 @@ public Exception decode(String methodKey, Response response) { } } - static class IllegalArgumentExceptionOn404 extends ErrorDecoder.Default { + static class IllegalArgumentExceptionOn404 extends DefaultErrorDecoder { @Override public Exception decode(String methodKey, Response response) { @@ -1220,9 +1223,9 @@ static final class TestInterfaceAsyncBuilder { private final AsyncFeign.AsyncBuilder delegate = AsyncFeign.builder() - .decoder(new Decoder.Default()) + .decoder(new DefaultDecoder()) .encoder( - (object, bodyType, template) -> { + (object, _, template) -> { if (object instanceof Map) { template.body(new Gson().toJson(object)); } else { diff --git a/core/src/test/java/feign/BaseApiTest.java b/core/src/test/java/feign/BaseApiTest.java index 11789f05b..be2cee7dd 100644 --- a/core/src/test/java/feign/BaseApiTest.java +++ b/core/src/test/java/feign/BaseApiTest.java @@ -65,7 +65,7 @@ void resolvesParameterizedResult() throws InterruptedException { Feign.builder() .decoder( - (response, type) -> { + (_, type) -> { assertThat(type).isEqualTo(new TypeToken>() {}.getType()); return null; }) @@ -83,10 +83,10 @@ void resolvesBodyParameter() throws InterruptedException { Feign.builder() .encoder( - (object, bodyType, template) -> + (_, bodyType, _) -> assertThat(bodyType).isEqualTo(new TypeToken>() {}.getType())) .decoder( - (response, type) -> { + (_, type) -> { assertThat(type).isEqualTo(new TypeToken>() {}.getType()); return null; }) diff --git a/core/src/test/java/feign/BaseBuilderTest.java b/core/src/test/java/feign/BaseBuilderTest.java index 297f0e3bd..30abf2d48 100644 --- a/core/src/test/java/feign/BaseBuilderTest.java +++ b/core/src/test/java/feign/BaseBuilderTest.java @@ -30,10 +30,8 @@ class BaseBuilderTest { void checkEnrichTouchesAllAsyncBuilderFields() throws IllegalArgumentException, IllegalAccessException { test( - AsyncFeign.builder() - .requestInterceptor(template -> {}) - .responseInterceptor((ic, c) -> c.next(ic)), - 14); + AsyncFeign.builder().requestInterceptor(_ -> {}).responseInterceptor((ic, c) -> c.next(ic)), + 12); } private void test(BaseBuilder builder, int expectedFieldsCount) @@ -62,9 +60,6 @@ private void test(BaseBuilder builder, int expectedFieldsCount) void checkEnrichTouchesAllBuilderFields() throws IllegalArgumentException, IllegalAccessException { test( - Feign.builder() - .requestInterceptor(template -> {}) - .responseInterceptor((ic, c) -> c.next(ic)), - 12); + Feign.builder().requestInterceptor(_ -> {}).responseInterceptor((ic, c) -> c.next(ic)), 10); } } diff --git a/core/src/test/java/feign/CapabilityTest.java b/core/src/test/java/feign/CapabilityTest.java index bc6890249..3acfe89cb 100644 --- a/core/src/test/java/feign/CapabilityTest.java +++ b/core/src/test/java/feign/CapabilityTest.java @@ -27,7 +27,7 @@ class CapabilityTest { private class AClient implements Client { public AClient(Client client) { - if (!(client instanceof Client.Default)) { + if (!(client instanceof DefaultClient)) { throw new RuntimeException( "Test is chaining invokations, expected Default Client instace here"); } @@ -58,7 +58,7 @@ void enrichClient() { Client enriched = (Client) Capability.enrich( - new Client.Default(null, null), + new DefaultClient(null, null), Client.class, Arrays.asList( new Capability() { diff --git a/core/src/test/java/feign/ClientTest.java b/core/src/test/java/feign/ClientTest.java index 8f6a072b7..cdfe54d29 100644 --- a/core/src/test/java/feign/ClientTest.java +++ b/core/src/test/java/feign/ClientTest.java @@ -68,7 +68,7 @@ void testConvertAndSendWithAcceptEncoding() throws IOException { Request request = Request.create( Request.HttpMethod.GET, "http://example.com", headers, body, requestTemplate); - Client.Default defaultClient = new Client.Default(null, null); + DefaultClient defaultClient = new DefaultClient(null, null); HttpURLConnection urlConnection = defaultClient.convertAndSend(request, options); Map> requestProperties = urlConnection.getRequestProperties(); // Test Avoid add "Accept-encoding" twice or more when "compression" option is enabled @@ -89,7 +89,7 @@ void testConvertAndSendWithContentLength() throws IOException { Request request = Request.create( Request.HttpMethod.GET, "http://example.com", headers, body, requestTemplate); - Client.Default defaultClient = new Client.Default(null, null); + DefaultClient defaultClient = new DefaultClient(null, null); HttpURLConnection urlConnection = defaultClient.convertAndSend(request, options); Map> requestProperties = urlConnection.getRequestProperties(); String requestProperty = urlConnection.getRequestProperty(Util.CONTENT_LENGTH); diff --git a/core/src/test/java/feign/ContractWithRuntimeInjectionTest.java b/core/src/test/java/feign/ContractWithRuntimeInjectionTest.java index 6d03fd16f..12be78518 100644 --- a/core/src/test/java/feign/ContractWithRuntimeInjectionTest.java +++ b/core/src/test/java/feign/ContractWithRuntimeInjectionTest.java @@ -96,7 +96,7 @@ static class ContractWithRuntimeInjection implements Contract { */ @Override public List parseAndValidateMetadata(Class targetType) { - List result = new Contract.Default().parseAndValidateMetadata(targetType); + List result = new DefaultContract().parseAndValidateMetadata(targetType); for (MethodMetadata md : result) { Map indexToExpander = new LinkedHashMap<>(); for (Map.Entry> entry : diff --git a/core/src/test/java/feign/DefaultContractInheritanceTest.java b/core/src/test/java/feign/DefaultContractInheritanceTest.java index f8eb63b36..77788f037 100644 --- a/core/src/test/java/feign/DefaultContractInheritanceTest.java +++ b/core/src/test/java/feign/DefaultContractInheritanceTest.java @@ -30,7 +30,7 @@ */ class DefaultContractInheritanceTest { - Contract.Default contract = new Contract.Default(); + DefaultContract contract = new DefaultContract(); @Headers("Foo: Bar") interface SimpleParameterizedBaseApi { diff --git a/core/src/test/java/feign/DefaultContractTest.java b/core/src/test/java/feign/DefaultContractTest.java index e704ca57a..6b26d1ed0 100644 --- a/core/src/test/java/feign/DefaultContractTest.java +++ b/core/src/test/java/feign/DefaultContractTest.java @@ -48,7 +48,7 @@ */ class DefaultContractTest { - Contract.Default contract = new Contract.Default(); + DefaultContract contract = new DefaultContract(); @Test void httpMethods() throws Exception { @@ -863,7 +863,7 @@ void errorMessageOnMixedContracts() { assertThrows( IllegalStateException.class, () -> contract.parseAndValidateMetadata(MixedAnnotations.class)); - assertThat(exception.getMessage()).contains("are not used by contract Default"); + assertThat(exception.getMessage()).contains("are not used by contract DefaultContract"); } interface MixedAnnotations { diff --git a/core/src/test/java/feign/FeignBuilderTest.java b/core/src/test/java/feign/FeignBuilderTest.java index 18393f6fe..2db2fd9df 100644 --- a/core/src/test/java/feign/FeignBuilderTest.java +++ b/core/src/test/java/feign/FeignBuilderTest.java @@ -210,7 +210,7 @@ void overrideEncoder() throws Exception { server.enqueue(new MockResponse().setBody("response data")); String url = "http://localhost:" + server.getPort(); - Encoder encoder = (object, bodyType, template) -> template.body(object.toString()); + Encoder encoder = (object, _, template) -> template.body(object.toString()); TestInterface api = Feign.builder().encoder(encoder).target(TestInterface.class, url); api.encodedPost(Arrays.asList("This", "is", "my", "request")); @@ -223,7 +223,7 @@ void overrideDecoder() { server.enqueue(new MockResponse().setBody("success!")); String url = "http://localhost:" + server.getPort(); - Decoder decoder = (response, type) -> "fail"; + Decoder decoder = (_, _) -> "fail"; TestInterface api = Feign.builder().decoder(decoder).target(TestInterface.class, url); assertThat(api.decodedPost()).isEqualTo("fail"); @@ -237,7 +237,7 @@ void overrideQueryMapEncoder() throws Exception { String url = "http://localhost:" + server.getPort(); QueryMapEncoder customMapEncoder = - ignored -> { + _ -> { Map queryMap = new HashMap<>(); queryMap.put("key1", "value1"); queryMap.put("key2", "value2"); @@ -347,7 +347,7 @@ void doNotCloseAfterDecode() { String url = "http://localhost:" + server.getPort(); Decoder decoder = - (response, type) -> + (response, _) -> new Iterator<>() { private boolean called = false; @@ -391,7 +391,7 @@ void doNotCloseAfterDecodeDecoderFailure() { String url = "http://localhost:" + server.getPort(); Decoder angryDecoder = - (response, type) -> { + (_, _) -> { throw new IOException("Failed to decode the response"); }; @@ -400,7 +400,7 @@ void doNotCloseAfterDecodeDecoderFailure() { Feign.builder() .client( new Client() { - Client client = new Client.Default(null, null); + Client client = new DefaultClient(null, null); @Override public Response execute(Request request, Request.Options options) @@ -454,7 +454,7 @@ public void close() throws IOException { try { api.decodedLazyPost(); fail("Expected an exception"); - } catch (FeignException expected) { + } catch (FeignException _) { } assertThat(closed.get()).as("Responses must be closed when the decoder fails").isTrue(); } diff --git a/core/src/test/java/feign/FeignTest.java b/core/src/test/java/feign/FeignTest.java index 15d744d21..748bfe1ad 100755 --- a/core/src/test/java/feign/FeignTest.java +++ b/core/src/test/java/feign/FeignTest.java @@ -39,6 +39,9 @@ import feign.Target.HardCodedTarget; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.DefaultDecoder; +import feign.codec.DefaultEncoder; +import feign.codec.DefaultErrorDecoder; import feign.codec.EncodeException; import feign.codec.Encoder; import feign.codec.ErrorDecoder; @@ -166,7 +169,7 @@ void bodyTypeCorrespondsWithParameterType() throws Exception { TestInterface api = new TestInterfaceBuilder() .encoder( - new Encoder.Default() { + new DefaultEncoder() { @Override public void encode(Object object, Type bodyType, RequestTemplate template) { encodedType.set(bodyType); @@ -534,7 +537,7 @@ void overrideTypeSpecificDecoder() throws Exception { TestInterface api = new TestInterfaceBuilder() - .decoder((response, type) -> "fail") + .decoder((_, _) -> "fail") .target("http://localhost:" + server.getPort()); assertThat("fail").isEqualTo(api.post()); @@ -577,7 +580,7 @@ void doesntRetryAfterResponseIsSent() throws Exception { TestInterface api = new TestInterfaceBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target("http://localhost:" + server.getPort()); @@ -593,7 +596,7 @@ void throwsFeignExceptionIncludingBody() { TestInterface api = Feign.builder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target(TestInterface.class, "http://localhost:" + server.getPort()); @@ -614,7 +617,7 @@ void throwsFeignExceptionWithoutBody() { TestInterface api = Feign.builder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target(TestInterface.class, "http://localhost:" + server.getPort()); @@ -641,7 +644,7 @@ void ensureRetryerClonesItself() throws Exception { Feign.builder() .retryer(retryer) .errorDecoder( - (methodKey, response) -> + (_, response) -> new RetryableException( response.status(), "play it again sam!", @@ -665,9 +668,9 @@ void throwsOriginalExceptionAfterFailedRetries() throws Exception { TestInterface api = Feign.builder() .exceptionPropagationPolicy(UNWRAP) - .retryer(new Retryer.Default(1, 1, 2)) + .retryer(new DefaultRetryer(1, 1, 2)) .errorDecoder( - (methodKey, response) -> + (_, response) -> new RetryableException( response.status(), "play it again sam!", @@ -690,9 +693,9 @@ void throwsRetryableExceptionIfNoUnderlyingCause() throws Exception { TestInterface api = Feign.builder() .exceptionPropagationPolicy(UNWRAP) - .retryer(new Retryer.Default(1, 1, 2)) + .retryer(new DefaultRetryer(1, 1, 2)) .errorDecoder( - (methodKey, response) -> + (_, response) -> new RetryableException( response.status(), message, @@ -721,7 +724,7 @@ void whenReturnTypeIsResponseNoErrorHandling() { // fake client as Client.Default follows redirects. TestInterface api = Feign.builder() - .client((request, options) -> response) + .client((_, _) -> response) .target(TestInterface.class, "http://localhost:" + server.getPort()); assertThat(api.response().headers()) @@ -758,7 +761,7 @@ void okIfDecodeRootCauseHasNoMessage() throws Exception { TestInterface api = new TestInterfaceBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new RuntimeException(); }) .target("http://localhost:" + server.getPort()); @@ -774,7 +777,7 @@ void decodingExceptionGetWrappedInDismiss404Mode() throws Exception { new TestInterfaceBuilder() .dismiss404() .decoder( - (response, type) -> { + (response, _) -> { assertEquals(404, response.status()); throw new NoSuchElementException(); }) @@ -806,7 +809,7 @@ void okIfEncodeRootCauseHasNoMessage() throws Exception { TestInterface api = new TestInterfaceBuilder() .encoder( - (object, bodyType, template) -> { + (_, _, _) -> { throw new RuntimeException(); }) .target("http://localhost:" + server.getPort()); @@ -889,7 +892,7 @@ void responseMapperIsAppliedBeforeDelegate() throws IOException { } private ResponseMapper upperCaseResponseMapper() { - return (response, type) -> { + return (response, _) -> { try { return response.toBuilder() .body(Util.toString(response.body().asReader(UTF_8)).toUpperCase().getBytes()) @@ -1398,7 +1401,7 @@ public void apply(RequestTemplate template) { } } - static class IllegalArgumentExceptionOn400 extends ErrorDecoder.Default { + static class IllegalArgumentExceptionOn400 extends DefaultErrorDecoder { @Override public Exception decode(String methodKey, Response response) { @@ -1409,7 +1412,7 @@ public Exception decode(String methodKey, Response response) { } } - static class IllegalArgumentExceptionOn404 extends ErrorDecoder.Default { + static class IllegalArgumentExceptionOn404 extends DefaultErrorDecoder { @Override public Exception decode(String methodKey, Response response) { @@ -1424,9 +1427,9 @@ static final class TestInterfaceBuilder { private final Feign.Builder delegate = new Feign.Builder() - .decoder(new Decoder.Default()) + .decoder(new DefaultDecoder()) .encoder( - (object, bodyType, template) -> { + (object, _, template) -> { if (object instanceof Map) { template.body(new Gson().toJson(object)); } else { diff --git a/core/src/test/java/feign/FeignUnderAsyncTest.java b/core/src/test/java/feign/FeignUnderAsyncTest.java index 94992ad5b..f18fd5d74 100644 --- a/core/src/test/java/feign/FeignUnderAsyncTest.java +++ b/core/src/test/java/feign/FeignUnderAsyncTest.java @@ -30,6 +30,9 @@ import feign.Target.HardCodedTarget; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.DefaultDecoder; +import feign.codec.DefaultEncoder; +import feign.codec.DefaultErrorDecoder; import feign.codec.EncodeException; import feign.codec.Encoder; import feign.codec.ErrorDecoder; @@ -128,7 +131,7 @@ void bodyTypeCorrespondsWithParameterType() throws Exception { TestInterface api = new TestInterfaceBuilder() .encoder( - new Encoder.Default() { + new DefaultEncoder() { @Override public void encode(Object object, Type bodyType, RequestTemplate template) { encodedType.set(bodyType); @@ -451,7 +454,7 @@ void overrideTypeSpecificDecoder() throws Exception { TestInterface api = new TestInterfaceBuilder() - .decoder((response, type) -> "fail") + .decoder((_, _) -> "fail") .target("http://localhost:" + server.getPort()); assertThat("fail").isEqualTo(api.post()); @@ -464,7 +467,7 @@ void doesntRetryAfterResponseIsSent() throws Exception { TestInterface api = new TestInterfaceBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target("http://localhost:" + server.getPort()); @@ -480,7 +483,7 @@ void throwsFeignExceptionIncludingBody() { TestInterface api = AsyncFeign.builder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target(TestInterface.class, "http://localhost:" + server.getPort()); @@ -501,7 +504,7 @@ void throwsFeignExceptionWithoutBody() { TestInterface api = AsyncFeign.builder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target(TestInterface.class, "http://localhost:" + server.getPort()); @@ -533,7 +536,7 @@ void whenReturnTypeIsResponseNoErrorHandling() { // fake client as Client.Default follows redirects. TestInterface api = AsyncFeign.builder() - .client(new AsyncClient.Default<>((request, options) -> response, execs)) + .client(new DefaultAsyncClient<>((_, _) -> response, execs)) .target(TestInterface.class, "http://localhost:" + server.getPort()); assertThat(api.response().headers()) @@ -571,7 +574,7 @@ void okIfDecodeRootCauseHasNoMessage() throws Exception { TestInterface api = new TestInterfaceBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new RuntimeException(); }) .target("http://localhost:" + server.getPort()); @@ -587,7 +590,7 @@ void decodingExceptionGetWrappedInDismiss404Mode() throws Exception { new TestInterfaceBuilder() .dismiss404() .decoder( - (response, type) -> { + (response, _) -> { assertEquals(404, response.status()); throw new NoSuchElementException(); }) @@ -619,7 +622,7 @@ void okIfEncodeRootCauseHasNoMessage() throws Exception { TestInterface api = new TestInterfaceBuilder() .encoder( - (object, bodyType, template) -> { + (_, _, _) -> { throw new RuntimeException(); }) .target("http://localhost:" + server.getPort()); @@ -704,7 +707,7 @@ void responseMapperIsAppliedBeforeDelegate() throws IOException { } private ResponseMapper upperCaseResponseMapper() { - return (response, type) -> { + return (response, _) -> { try { return response.toBuilder() .body(Util.toString(response.body().asReader(UTF_8)).toUpperCase().getBytes()) @@ -931,7 +934,7 @@ public void apply(RequestTemplate template) { } } - static class IllegalArgumentExceptionOn400 extends ErrorDecoder.Default { + static class IllegalArgumentExceptionOn400 extends DefaultErrorDecoder { @Override public Exception decode(String methodKey, Response response) { @@ -942,7 +945,7 @@ public Exception decode(String methodKey, Response response) { } } - static class IllegalArgumentExceptionOn404 extends ErrorDecoder.Default { + static class IllegalArgumentExceptionOn404 extends DefaultErrorDecoder { @Override public Exception decode(String methodKey, Response response) { @@ -957,9 +960,9 @@ static final class TestInterfaceBuilder { private final AsyncFeign.AsyncBuilder delegate = AsyncFeign.builder() - .decoder(new Decoder.Default()) + .decoder(new DefaultDecoder()) .encoder( - (object, bodyType, template) -> { + (object, _, template) -> { if (object instanceof Map) { template.body(new Gson().toJson(object)); } else { diff --git a/core/src/test/java/feign/MethodMetadataPresenceTest.java b/core/src/test/java/feign/MethodMetadataPresenceTest.java index 653fbd29b..3be2c6054 100644 --- a/core/src/test/java/feign/MethodMetadataPresenceTest.java +++ b/core/src/test/java/feign/MethodMetadataPresenceTest.java @@ -20,8 +20,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import feign.FeignBuilderTest.TestInterface; -import feign.codec.Decoder; -import feign.codec.Encoder; +import feign.codec.DefaultDecoder; +import feign.codec.DefaultEncoder; import java.io.IOException; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -44,7 +44,7 @@ void client() throws Exception { assertNotNull(request.requestTemplate()); assertNotNull(request.requestTemplate().methodMetadata()); assertNotNull(request.requestTemplate().feignTarget()); - return new Client.Default(null, null).execute(request, options); + return new DefaultClient(null, null).execute(request, options); }) .target(TestInterface.class, url); @@ -66,7 +66,7 @@ void encoder() throws Exception { assertNotNull(template); assertNotNull(template.methodMetadata()); assertNotNull(template.feignTarget()); - new Encoder.Default().encode(object, bodyType, template); + new DefaultEncoder().encode(object, bodyType, template); }) .target(TestInterface.class, url); @@ -89,7 +89,7 @@ void decoder() throws Exception { assertNotNull(template); assertNotNull(template.methodMetadata()); assertNotNull(template.feignTarget()); - return new Decoder.Default().decode(response, type); + return new DefaultDecoder().decode(response, type); }) .target(TestInterface.class, url); diff --git a/core/src/test/java/feign/RetryableExceptionTest.java b/core/src/test/java/feign/RetryableExceptionTest.java index 6e60e6482..0bd9a7312 100644 --- a/core/src/test/java/feign/RetryableExceptionTest.java +++ b/core/src/test/java/feign/RetryableExceptionTest.java @@ -49,4 +49,45 @@ void createRetryableExceptionWithResponseAndResponseHeader() { assertThat(retryableException.responseHeaders()).containsKey("TEST_HEADER"); assertThat(retryableException.responseHeaders().get("TEST_HEADER")).contains("TEST_CONTENT"); } + + @Test + void createRetryableExceptionWithMethodKey() { + // given + Long retryAfter = 5000L; + String methodKey = "TestClient#testMethod()"; + Request request = + Request.create(Request.HttpMethod.GET, "/", Collections.emptyMap(), null, Util.UTF_8); + Throwable cause = new RuntimeException("test cause"); + + // when + RetryableException retryableException = + new RetryableException( + 503, + "Service Unavailable", + Request.HttpMethod.GET, + cause, + retryAfter, + request, + methodKey); + + // then + assertThat(retryableException).isNotNull(); + assertThat(retryableException.methodKey()).isEqualTo(methodKey); + assertThat(retryableException.retryAfter()).isEqualTo(retryAfter); + assertThat(retryableException.method()).isEqualTo(Request.HttpMethod.GET); + } + + @Test + void methodKeyIsNullWhenNotProvided() { + // given + Request request = + Request.create(Request.HttpMethod.GET, "/", Collections.emptyMap(), null, Util.UTF_8); + + // when + RetryableException retryableException = + new RetryableException(503, "Service Unavailable", Request.HttpMethod.GET, request); + + // then + assertThat(retryableException.methodKey()).isNull(); + } } diff --git a/core/src/test/java/feign/RetryerTest.java b/core/src/test/java/feign/RetryerTest.java index 2b1d60481..74ed8e832 100644 --- a/core/src/test/java/feign/RetryerTest.java +++ b/core/src/test/java/feign/RetryerTest.java @@ -19,7 +19,6 @@ import static org.assertj.core.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.assertThrows; -import feign.Retryer.Default; import java.util.Collections; import org.junit.jupiter.api.Test; @@ -33,7 +32,7 @@ class RetryerTest { void only5TriesAllowedAndExponentialBackoff() { final Long nonRetryable = null; RetryableException e = new RetryableException(-1, null, null, nonRetryable, REQUEST); - Default retryer = new Retryer.Default(); + DefaultRetryer retryer = new DefaultRetryer(); assertThat(retryer.attempt).isEqualTo(1); assertThat(retryer.sleptForMillis).isEqualTo(0); @@ -57,8 +56,8 @@ void only5TriesAllowedAndExponentialBackoff() { @Test void considersRetryAfterButNotMoreThanMaxPeriod() { - Default retryer = - new Retryer.Default() { + DefaultRetryer retryer = + new DefaultRetryer() { @Override protected long currentTimeMillis() { return 0; @@ -81,7 +80,7 @@ void neverRetryAlwaysPropagates() { @Test void defaultRetryerFailsOnInterruptedException() { - Default retryer = new Retryer.Default(); + DefaultRetryer retryer = new DefaultRetryer(); Thread.currentThread().interrupt(); RetryableException expected = diff --git a/core/src/test/java/feign/client/DefaultClientTest.java b/core/src/test/java/feign/client/DefaultClientTest.java index 6aa821c72..c61b1b0a4 100644 --- a/core/src/test/java/feign/client/DefaultClientTest.java +++ b/core/src/test/java/feign/client/DefaultClientTest.java @@ -21,6 +21,7 @@ import feign.Client; import feign.Client.Proxied; +import feign.DefaultClient; import feign.Feign; import feign.Feign.Builder; import feign.RetryableException; @@ -37,11 +38,11 @@ public class DefaultClientTest extends AbstractClientTest { protected Client disableHostnameVerification = - new Client.Default(TrustingSSLSocketFactory.get(), (s, sslSession) -> true); + new DefaultClient(TrustingSSLSocketFactory.get(), (_, _) -> true); @Override public Builder newBuilder() { - return Feign.builder().client(new Client.Default(TrustingSSLSocketFactory.get(), null, false)); + return Feign.builder().client(new DefaultClient(TrustingSSLSocketFactory.get(), null, false)); } @Test diff --git a/core/src/test/java/feign/client/TrustingSSLSocketFactory.java b/core/src/test/java/feign/client/TrustingSSLSocketFactory.java index 7f74c7861..0222ff121 100644 --- a/core/src/test/java/feign/client/TrustingSSLSocketFactory.java +++ b/core/src/test/java/feign/client/TrustingSSLSocketFactory.java @@ -42,7 +42,7 @@ public final class TrustingSSLSocketFactory extends SSLSocketFactory private static final Map sslSocketFactories = new LinkedHashMap<>(); private static final char[] KEYSTORE_PASSWORD = "password".toCharArray(); - private static final String[] ENABLED_CIPHER_SUITES = {"TLS_RSA_WITH_AES_256_CBC_SHA"}; + private static final String[] ENABLED_CIPHER_SUITES = {"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA"}; private final SSLSocketFactory delegate; private final String serverAlias; private final PrivateKey privateKey; @@ -50,7 +50,7 @@ public final class TrustingSSLSocketFactory extends SSLSocketFactory private TrustingSSLSocketFactory(String serverAlias) { try { - SSLContext sc = SSLContext.getInstance("SSL"); + SSLContext sc = SSLContext.getInstance("TLS"); sc.init(new KeyManager[] {this}, new TrustManager[] {this}, new SecureRandom()); this.delegate = sc.getSocketFactory(); } catch (Exception e) { diff --git a/core/src/test/java/feign/codec/DefaultDecoderTest.java b/core/src/test/java/feign/codec/DefaultDecoderTest.java index f22828ec8..23ba1d4d0 100644 --- a/core/src/test/java/feign/codec/DefaultDecoderTest.java +++ b/core/src/test/java/feign/codec/DefaultDecoderTest.java @@ -35,7 +35,7 @@ @SuppressWarnings("deprecation") class DefaultDecoderTest { - private final Decoder decoder = new Decoder.Default(); + private final Decoder decoder = new DefaultDecoder(); @Test void decodesToString() throws Exception { diff --git a/core/src/test/java/feign/codec/DefaultEncoderTest.java b/core/src/test/java/feign/codec/DefaultEncoderTest.java index 6808ef4a6..9aecbb905 100644 --- a/core/src/test/java/feign/codec/DefaultEncoderTest.java +++ b/core/src/test/java/feign/codec/DefaultEncoderTest.java @@ -26,7 +26,7 @@ class DefaultEncoderTest { - private final Encoder encoder = new Encoder.Default(); + private final Encoder encoder = new DefaultEncoder(); @Test void encodesStrings() throws Exception { diff --git a/core/src/test/java/feign/codec/DefaultErrorDecoderHttpErrorTest.java b/core/src/test/java/feign/codec/DefaultErrorDecoderHttpErrorTest.java index 934abb083..c9dae156b 100644 --- a/core/src/test/java/feign/codec/DefaultErrorDecoderHttpErrorTest.java +++ b/core/src/test/java/feign/codec/DefaultErrorDecoderHttpErrorTest.java @@ -126,7 +126,7 @@ public static Object[][] errorCodes() { public Class expectedExceptionClass; public String expectedMessage; - private ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + private ErrorDecoder errorDecoder = new DefaultErrorDecoder(); private Map> headers = new LinkedHashMap<>(); diff --git a/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java b/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java index d074f2e0b..1967e7763 100644 --- a/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java +++ b/core/src/test/java/feign/codec/DefaultErrorDecoderTest.java @@ -36,7 +36,7 @@ @SuppressWarnings("deprecation") class DefaultErrorDecoderTest { - private ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + private ErrorDecoder errorDecoder = new DefaultErrorDecoder(); private Map> headers = new LinkedHashMap<>(); @@ -154,7 +154,7 @@ void lengthOfBodyExceptionTest() { Exception defaultException = errorDecoder.decode("Service#foo()", response); assertThat(defaultException.getMessage().length()).isLessThan(response.body().length()); - ErrorDecoder customizedErrorDecoder = new ErrorDecoder.Default(4000, 2000); + ErrorDecoder customizedErrorDecoder = new DefaultErrorDecoder(4000, 2000); Exception customizedException = customizedErrorDecoder.decode("Service#foo()", response); assertThat(customizedException.getMessage().length()) .isGreaterThanOrEqualTo(response.body().length()); diff --git a/core/src/test/java/feign/examples/GitHubExample.java b/core/src/test/java/feign/examples/GitHubExample.java index ac204e92a..248876a5b 100644 --- a/core/src/test/java/feign/examples/GitHubExample.java +++ b/core/src/test/java/feign/examples/GitHubExample.java @@ -42,10 +42,10 @@ public static void main(String... args) { .logLevel(Logger.Level.BASIC) .target(GitHub.class, "https://api.github.com"); - System.out.println("Let's fetch and print a list of the contributors to this library."); + IO.println("Let's fetch and print a list of the contributors to this library."); List contributors = github.contributors("netflix", "feign"); for (Contributor contributor : contributors) { - System.out.println(contributor.login + " (" + contributor.contributions + ")"); + IO.println(contributor.login + " (" + contributor.contributions + ")"); } } diff --git a/core/src/test/java/feign/optionals/OptionalDecoderTests.java b/core/src/test/java/feign/optionals/OptionalDecoderTests.java index f0abc88de..d2e015cb2 100644 --- a/core/src/test/java/feign/optionals/OptionalDecoderTests.java +++ b/core/src/test/java/feign/optionals/OptionalDecoderTests.java @@ -19,7 +19,7 @@ import feign.Feign; import feign.RequestLine; -import feign.codec.Decoder; +import feign.codec.DefaultDecoder; import java.io.IOException; import java.util.Optional; import okhttp3.mockwebserver.MockResponse; @@ -45,7 +45,7 @@ void simple404OptionalTest() throws IOException, InterruptedException { final OptionalInterface api = Feign.builder() .dismiss404() - .decoder(new OptionalDecoder(new Decoder.Default())) + .decoder(new OptionalDecoder(new DefaultDecoder())) .target(OptionalInterface.class, server.url("/").toString()); assertThat(api.getAsOptional().isPresent()).isFalse(); @@ -59,7 +59,7 @@ void simple204OptionalTest() throws IOException, InterruptedException { final OptionalInterface api = Feign.builder() - .decoder(new OptionalDecoder(new Decoder.Default())) + .decoder(new OptionalDecoder(new DefaultDecoder())) .target(OptionalInterface.class, server.url("/").toString()); assertThat(api.getAsOptional().isPresent()).isFalse(); @@ -72,7 +72,7 @@ void test200WithOptionalString() throws IOException, InterruptedException { final OptionalInterface api = Feign.builder() - .decoder(new OptionalDecoder(new Decoder.Default())) + .decoder(new OptionalDecoder(new DefaultDecoder())) .target(OptionalInterface.class, server.url("/").toString()); Optional response = api.getAsOptional(); @@ -88,7 +88,7 @@ void test200WhenResponseBodyIsNull() throws IOException, InterruptedException { final OptionalInterface api = Feign.builder() - .decoder(new OptionalDecoder(((response, type) -> null))) + .decoder(new OptionalDecoder(((_, _) -> null))) .target(OptionalInterface.class, server.url("/").toString()); assertThat(api.getAsOptional().isPresent()).isFalse(); @@ -101,7 +101,7 @@ void test200WhenDecodingNoOptional() throws IOException, InterruptedException { final OptionalInterface api = Feign.builder() - .decoder(new OptionalDecoder(new Decoder.Default())) + .decoder(new OptionalDecoder(new DefaultDecoder())) .target(OptionalInterface.class, server.url("/").toString()); assertThat(api.get()).isEqualTo("foo"); diff --git a/core/src/test/java/feign/stream/StreamDecoderTest.java b/core/src/test/java/feign/stream/StreamDecoderTest.java index 281943485..d514dcc6b 100644 --- a/core/src/test/java/feign/stream/StreamDecoderTest.java +++ b/core/src/test/java/feign/stream/StreamDecoderTest.java @@ -79,7 +79,7 @@ void simpleStreamTest() { Feign.builder() .decoder( StreamDecoder.create( - (response, type) -> + (response, _) -> new BufferedReader(response.body().asReader(UTF_8)).lines().iterator())) .doNotCloseAfterDecode() .target(StreamInterface.class, server.url("/").toString()); @@ -98,7 +98,7 @@ void simpleDefaultStreamTest() { Feign.builder() .decoder( StreamDecoder.create( - (r, t) -> { + (r, _) -> { BufferedReader bufferedReader = new BufferedReader(r.body().asReader(UTF_8)); return bufferedReader.lines().iterator(); })) @@ -119,11 +119,11 @@ void simpleDeleteDecoderTest() { Feign.builder() .decoder( StreamDecoder.create( - (r, t) -> { + (r, _) -> { BufferedReader bufferedReader = new BufferedReader(r.body().asReader(UTF_8)); return bufferedReader.lines().iterator(); }, - (r, t) -> "str")) + (_, _) -> "str")) .doNotCloseAfterDecode() .target(StreamInterface.class, server.url("/").toString()); @@ -144,7 +144,7 @@ void shouldCloseIteratorWhenStreamClosed() throws IOException { .build(); TestCloseableIterator it = new TestCloseableIterator(); - StreamDecoder decoder = StreamDecoder.create((r, t) -> it); + StreamDecoder decoder = StreamDecoder.create((_, _) -> it); try (Stream stream = (Stream) decoder.decode(response, new TypeReference>() {}.getType())) { diff --git a/dropwizard-metrics4/pom.xml b/dropwizard-metrics4/pom.xml index 86442a513..2113f2702 100644 --- a/dropwizard-metrics4/pom.xml +++ b/dropwizard-metrics4/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-dropwizard-metrics4 Feign Dropwizard Metrics4 @@ -40,7 +40,7 @@ io.dropwizard.metrics metrics-core - 4.2.33 + ${metrics-core-4.version} ${project.groupId} diff --git a/dropwizard-metrics5/pom.xml b/dropwizard-metrics5/pom.xml index e825314f8..c59139b5f 100644 --- a/dropwizard-metrics5/pom.xml +++ b/dropwizard-metrics5/pom.xml @@ -21,12 +21,16 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-dropwizard-metrics5 Feign Dropwizard Metrics5 Feign Dropwizard Metrics 5 + + 17 + + ${project.groupId} @@ -40,7 +44,7 @@ io.dropwizard.metrics5 metrics-core - 5.0.0-rc21 + ${metrics-core-5.version} ${project.groupId} diff --git a/example-github-with-coroutine/pom.xml b/example-github-with-coroutine/pom.xml index 6d8762e89..03acb10f8 100644 --- a/example-github-with-coroutine/pom.xml +++ b/example-github-with-coroutine/pom.xml @@ -22,13 +22,17 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-example-github-with-coroutine jar GitHub Example With Coroutine + + true + + io.github.openfeign @@ -45,7 +49,7 @@ org.apache.commons commons-exec - 1.5.0 + ${commons-exec.version} test @@ -56,7 +60,7 @@ org.apache.maven.plugins maven-shade-plugin - 3.6.0 + ${maven-shade-plugin.version} @@ -77,7 +81,7 @@ org.skife.maven really-executable-jar-maven-plugin - 2.1.1 + ${really-executable-jar-maven-plugin.version} github diff --git a/example-github-with-coroutine/src/main/java/example/github/GitHubExample.kt b/example-github-with-coroutine/src/main/java/example/github/GitHubExample.kt index 6af850993..548f710da 100644 --- a/example-github-with-coroutine/src/main/java/example/github/GitHubExample.kt +++ b/example-github-with-coroutine/src/main/java/example/github/GitHubExample.kt @@ -24,6 +24,7 @@ import feign.Response import feign.codec.Decoder import feign.codec.Encoder import feign.codec.ErrorDecoder +import feign.codec.DefaultErrorDecoder import feign.gson.GsonEncoder import feign.kotlin.CoroutineFeign import java.io.IOException @@ -122,7 +123,7 @@ internal class GitHubClientError() : RuntimeException() { internal class GitHubErrorDecoder( private val decoder: Decoder ) : ErrorDecoder { - private val defaultDecoder: ErrorDecoder = ErrorDecoder.Default() + private val defaultDecoder: ErrorDecoder = DefaultErrorDecoder() override fun decode(methodKey: String, response: Response): Exception { return try { // must replace status by 200 other GSONDecoder returns null diff --git a/example-github/pom.xml b/example-github/pom.xml index bda16b480..e20290b05 100644 --- a/example-github/pom.xml +++ b/example-github/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-example-github @@ -41,7 +41,7 @@ org.apache.commons commons-exec - 1.5.0 + ${commons-exec.version} test @@ -52,7 +52,7 @@ org.apache.maven.plugins maven-shade-plugin - 3.6.0 + ${maven-shade-plugin.version} @@ -73,7 +73,7 @@ org.skife.maven really-executable-jar-maven-plugin - 2.1.1 + ${really-executable-jar-maven-plugin.version} github diff --git a/example-github/src/main/java/example/github/GitHubExample.java b/example-github/src/main/java/example/github/GitHubExample.java index 41351df5f..ecb7368b0 100644 --- a/example-github/src/main/java/example/github/GitHubExample.java +++ b/example-github/src/main/java/example/github/GitHubExample.java @@ -17,6 +17,7 @@ import feign.*; import feign.codec.Decoder; +import feign.codec.DefaultErrorDecoder; import feign.codec.Encoder; import feign.codec.ErrorDecoder; import feign.gson.GsonDecoder; @@ -128,7 +129,7 @@ public static void main(String... args) { static class GitHubErrorDecoder implements ErrorDecoder { final Decoder decoder; - final ErrorDecoder defaultDecoder = new ErrorDecoder.Default(); + final ErrorDecoder defaultDecoder = new DefaultErrorDecoder(); GitHubErrorDecoder(Decoder decoder) { this.decoder = decoder; diff --git a/example-wikipedia-with-springboot/pom.xml b/example-wikipedia-with-springboot/pom.xml index 514e98654..1cca9e592 100644 --- a/example-wikipedia-with-springboot/pom.xml +++ b/example-wikipedia-with-springboot/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-example-wikipedia-with-springboot @@ -39,7 +39,7 @@ org.springframework.cloud spring-cloud-dependencies - 2025.0.0 + ${spring-cloud-dependencies.version} pom import @@ -63,10 +63,16 @@ org.springframework.cloud spring-cloud-starter-openfeign + + + io.github.openfeign + feign-form-spring + org.apache.commons commons-exec - 1.5.0 + ${commons-exec.version} test @@ -84,6 +90,7 @@ org.springframework.boot spring-boot-maven-plugin + ${springboot.version} example.wikipedia.WikipediaApplication ZIP @@ -100,7 +107,7 @@ org.skife.maven really-executable-jar-maven-plugin - 2.1.1 + ${really-executable-jar-maven-plugin.version} wikipedia diff --git a/example-wikipedia-with-springboot/src/main/java/example/wikipedia/WikipediaClientConfiguration.java b/example-wikipedia-with-springboot/src/main/java/example/wikipedia/WikipediaClientConfiguration.java index 15ae58a68..7afdb3bc2 100644 --- a/example-wikipedia-with-springboot/src/main/java/example/wikipedia/WikipediaClientConfiguration.java +++ b/example-wikipedia-with-springboot/src/main/java/example/wikipedia/WikipediaClientConfiguration.java @@ -21,6 +21,7 @@ import com.google.gson.stream.JsonReader; import example.wikipedia.WikipediaClient.Page; import example.wikipedia.WikipediaClient.Response; +import feign.RequestInterceptor; import feign.codec.Decoder; import feign.gson.GsonDecoder; import java.io.IOException; @@ -62,4 +63,11 @@ public Decoder decoder() { return new GsonDecoder(gson); } + + @Bean + public RequestInterceptor userAgentInterceptor() { + return template -> + template.header( + "User-Agent", "Feign Wikipedia Example (https://github.com/openfeign/feign)"); + } } diff --git a/example-wikipedia/pom.xml b/example-wikipedia/pom.xml index 6e12e2fa9..c342fe4cb 100644 --- a/example-wikipedia/pom.xml +++ b/example-wikipedia/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT io.github.openfeign @@ -42,7 +42,7 @@ org.apache.commons commons-exec - 1.5.0 + ${commons-exec.version} test @@ -53,7 +53,7 @@ org.apache.maven.plugins maven-shade-plugin - 3.6.0 + ${maven-shade-plugin.version} @@ -74,7 +74,7 @@ org.skife.maven really-executable-jar-maven-plugin - 2.1.1 + ${really-executable-jar-maven-plugin.version} wikipedia diff --git a/example-wikipedia/src/main/java/example/wikipedia/WikipediaExample.java b/example-wikipedia/src/main/java/example/wikipedia/WikipediaExample.java index f14175656..f2cb6ac1b 100644 --- a/example-wikipedia/src/main/java/example/wikipedia/WikipediaExample.java +++ b/example-wikipedia/src/main/java/example/wikipedia/WikipediaExample.java @@ -65,6 +65,11 @@ public static void main(String... args) throws InterruptedException { .logger(new Logger.ErrorLogger()) .logLevel(Logger.Level.BASIC) .options(new Request.Options(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true)) + .requestInterceptor( + template -> + template.header( + "User-Agent", + "Feign Wikipedia Example (https://github.com/openfeign/feign)")) .target(Wikipedia.class, "https://en.wikipedia.org"); System.out.println("Let's search for PTAL!"); diff --git a/fastjson2/README.md b/fastjson2/README.md index 8d283ea30..714995dba 100644 --- a/fastjson2/README.md +++ b/fastjson2/README.md @@ -3,7 +3,15 @@ Fastjson2 Codec This module adds support for encoding and decoding JSON via Fastjson2. -Add `Fastjson2Encoder` and/or `Fastjson2Decoder` to your `Feign.Builder` like so: +Add `Fastjson2Codec` to your `Feign.Builder` like so: + +```java +GitHub github = Feign.builder() + .codec(new Fastjson2Codec()) + .target(GitHub.class, "https://api.github.com"); +``` + +You can also configure the encoder and decoder separately: ```java GitHub github = Feign.builder() @@ -12,11 +20,12 @@ GitHub github = Feign.builder() .target(GitHub.class, "https://api.github.com"); ``` -If you want to customize, provide it to the `Fastjson2Encoder` and `Fastjson2Decoder`: +If you want to customize, provide features to the `Fastjson2Codec`: ```java GitHub github = Feign.builder() - .encoder(new Fastjson2Encoder(new JSONWriter.Feature[]{JSONWriter.Feature.WriteNonStringValueAsString}) - .decoder(new Fastjson2Decoder(new JSONReader.Feature[]{JSONReader.Feature.EmptyStringAsNull})) + .codec(new Fastjson2Codec( + new JSONWriter.Feature[]{JSONWriter.Feature.WriteNonStringValueAsString}, + new JSONReader.Feature[]{JSONReader.Feature.EmptyStringAsNull})) .target(GitHub.class, "https://api.github.com"); ``` diff --git a/fastjson2/pom.xml b/fastjson2/pom.xml index 794e9a717..a50a0a0d8 100644 --- a/fastjson2/pom.xml +++ b/fastjson2/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-fastjson2 diff --git a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Codec.java b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Codec.java new file mode 100644 index 000000000..9f1ec55a4 --- /dev/null +++ b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Codec.java @@ -0,0 +1,51 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.fastjson2; + +import com.alibaba.fastjson2.JSONReader; +import com.alibaba.fastjson2.JSONWriter; +import feign.Experimental; +import feign.codec.Codec; +import feign.codec.JsonCodec; +import feign.codec.JsonDecoder; +import feign.codec.JsonEncoder; + +@Experimental +public class Fastjson2Codec implements Codec, JsonCodec { + + private final Fastjson2Encoder encoder; + private final Fastjson2Decoder decoder; + + public Fastjson2Codec() { + this.encoder = new Fastjson2Encoder(); + this.decoder = new Fastjson2Decoder(); + } + + public Fastjson2Codec(JSONWriter.Feature[] writerFeatures, JSONReader.Feature[] readerFeatures) { + this.encoder = new Fastjson2Encoder(writerFeatures); + this.decoder = new Fastjson2Decoder(readerFeatures); + } + + @Override + public JsonEncoder encoder() { + return encoder; + } + + @Override + public JsonDecoder decoder() { + return decoder; + } +} diff --git a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java index fe8fa0733..80b1ada8d 100644 --- a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java +++ b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java @@ -19,11 +19,13 @@ import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONException; +import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONReader; import feign.FeignException; import feign.Response; import feign.Util; import feign.codec.Decoder; +import feign.codec.JsonDecoder; import java.io.IOException; import java.io.Reader; import java.lang.reflect.Type; @@ -31,7 +33,7 @@ /** * @author changjin wei(魏昌进) */ -public class Fastjson2Decoder implements Decoder { +public class Fastjson2Decoder implements Decoder, JsonDecoder { private final JSONReader.Feature[] features; @@ -59,4 +61,12 @@ public Object decode(Response response, Type type) throws IOException, FeignExce ensureClosed(reader); } } + + @Override + public Object convert(Object object, Type type) { + if (object instanceof JSONObject) { + return ((JSONObject) object).to(type); + } + return JSON.parseObject(JSON.toJSONString(object), type); + } } diff --git a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java index 5e622c72f..06a98e8dd 100644 --- a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java +++ b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java @@ -21,12 +21,13 @@ import feign.Util; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.JsonEncoder; import java.lang.reflect.Type; /** * @author changjin wei(魏昌进) */ -public class Fastjson2Encoder implements Encoder { +public class Fastjson2Encoder implements Encoder, JsonEncoder { private final JSONWriter.Feature[] features; diff --git a/fastjson2/src/test/java/feign/fastjson2/examples/GitHubExample.java b/fastjson2/src/test/java/feign/fastjson2/examples/GitHubExample.java index a74707b99..6721bf91d 100644 --- a/fastjson2/src/test/java/feign/fastjson2/examples/GitHubExample.java +++ b/fastjson2/src/test/java/feign/fastjson2/examples/GitHubExample.java @@ -30,10 +30,10 @@ public static void main(String... args) { .decoder(new Fastjson2Decoder()) .target(GitHub.class, "https://api.github.com"); - System.out.println("Let's fetch and print a list of the contributors to this library."); + IO.println("Let's fetch and print a list of the contributors to this library."); List contributors = github.contributors("netflix", "feign"); for (Contributor contributor : contributors) { - System.out.println(contributor.login + " (" + contributor.contributions + ")"); + IO.println(contributor.login + " (" + contributor.contributions + ")"); } } diff --git a/form-spring/pom.xml b/form-spring/pom.xml index 9b54b60ab..e00910eba 100644 --- a/form-spring/pom.xml +++ b/form-spring/pom.xml @@ -23,7 +23,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-form-spring @@ -39,12 +39,12 @@ org.apache.commons commons-text - 1.14.0 + ${commons-text.version} org.projectlombok lombok - 1.18.38 + ${lombok.version} provided @@ -58,14 +58,14 @@ org.springframework spring-web - 6.1.14 + ${spring-web.version} compile commons-fileupload commons-fileupload - 1.6.0 + ${commons-fileupload.version} compile @@ -78,7 +78,7 @@ org.springframework.cloud spring-cloud-starter-openfeign - 4.1.4 + ${spring-cloud-starter-openfeign.version} test @@ -125,13 +125,13 @@ org.mockito mockito-core - 5.18.0 + ${mockito.version} test org.mockito mockito-junit-jupiter - 5.18.0 + ${mockito.version} test @@ -147,6 +147,13 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + alphabetical + + diff --git a/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java b/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java index 58f350f13..67c9bd217 100644 --- a/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java +++ b/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java @@ -19,6 +19,7 @@ import static java.util.Collections.singletonMap; import feign.RequestTemplate; +import feign.codec.DefaultEncoder; import feign.codec.EncodeException; import feign.codec.Encoder; import feign.form.FormEncoder; @@ -38,7 +39,7 @@ public class SpringFormEncoder extends FormEncoder { /** Constructor with the default Feign's encoder as a delegate. */ public SpringFormEncoder() { - this(new Encoder.Default()); + this(new DefaultEncoder()); } /** diff --git a/form-spring/src/test/java/feign/form/feign/spring/Client.java b/form-spring/src/test/java/feign/form/feign/spring/Client.java index 14371fe89..dbd2040a9 100644 --- a/form-spring/src/test/java/feign/form/feign/spring/Client.java +++ b/form-spring/src/test/java/feign/form/feign/spring/Client.java @@ -25,10 +25,9 @@ import feign.form.spring.SpringFormEncoder; import java.util.List; import java.util.Map; -import org.springframework.beans.factory.ObjectFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.cloud.openfeign.support.FeignHttpMessageConverters; import org.springframework.cloud.openfeign.support.SpringEncoder; import org.springframework.context.annotation.Bean; import org.springframework.web.bind.annotation.PathVariable; @@ -91,10 +90,8 @@ String upload4( class ClientConfiguration { - @Autowired private ObjectFactory messageConverters; - @Bean - Encoder feignEncoder() { + Encoder feignEncoder(ObjectProvider messageConverters) { return new SpringFormEncoder(new SpringEncoder(messageConverters)); } diff --git a/form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java b/form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java index b1b3a6194..4645d950c 100644 --- a/form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java +++ b/form-spring/src/test/java/feign/form/feign/spring/DownloadClient.java @@ -18,22 +18,19 @@ import feign.Logger; import feign.codec.Decoder; import feign.form.spring.converter.SpringManyMultipartFilesReader; -import java.util.ArrayList; -import lombok.val; -import org.springframework.beans.factory.ObjectFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.cloud.openfeign.support.FeignHttpMessageConverters; +import org.springframework.cloud.openfeign.support.HttpMessageConverterCustomizer; import org.springframework.cloud.openfeign.support.SpringDecoder; import org.springframework.context.annotation.Bean; -import org.springframework.http.converter.HttpMessageConverter; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.multipart.MultipartFile; @FeignClient( name = "multipart-download-support-service", - url = "http://localhost:8081", + url = "http://localhost:8080", configuration = DownloadClient.ClientConfiguration.class) interface DownloadClient { @@ -42,26 +39,14 @@ interface DownloadClient { class ClientConfiguration { - @Autowired private ObjectFactory messageConverters; - @Bean - Decoder feignDecoder() { - val springConverters = messageConverters.getObject().getConverters(); - val decoderConverters = new ArrayList>(springConverters.size() + 1); - - decoderConverters.addAll(springConverters); - decoderConverters.add(new SpringManyMultipartFilesReader(4096)); - - val httpMessageConverters = new HttpMessageConverters(decoderConverters); - - return new SpringDecoder( - new ObjectFactory() { + HttpMessageConverterCustomizer multipartConverterCustomizer() { + return converters -> converters.add(new SpringManyMultipartFilesReader(4096)); + } - @Override - public HttpMessageConverters getObject() { - return httpMessageConverters; - } - }); + @Bean + Decoder feignDecoder(ObjectProvider messageConverters) { + return new SpringDecoder(messageConverters); } @Bean diff --git a/form-spring/src/test/java/feign/form/feign/spring/Server.java b/form-spring/src/test/java/feign/form/feign/spring/Server.java index ad51ef901..ed2e0a711 100644 --- a/form-spring/src/test/java/feign/form/feign/spring/Server.java +++ b/form-spring/src/test/java/feign/form/feign/spring/Server.java @@ -23,7 +23,6 @@ import java.io.IOException; import java.util.Map; -import lombok.val; import org.apache.commons.text.StringEscapeUtils; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.openfeign.EnableFeignClients; @@ -110,18 +109,18 @@ public ResponseEntity upload6( @GetMapping(path = "/multipart/download/{fileId}", produces = MULTIPART_FORM_DATA_VALUE) public MultiValueMap download(@PathVariable("fileId") String fileId) { - val multiParts = new LinkedMultiValueMap(); + var multiParts = new LinkedMultiValueMap(); - val infoString = "The text for file ID " + fileId + ". Testing unicode €"; - val infoPartheader = new HttpHeaders(); + var infoString = "The text for file ID " + fileId + ". Testing unicode €"; + var infoPartheader = new HttpHeaders(); infoPartheader.setContentType(new MediaType("text", "plain", UTF_8)); - val infoPart = new HttpEntity(infoString, infoPartheader); + var infoPart = new HttpEntity(infoString, infoPartheader); - val file = new ClassPathResource("testfile.txt"); - val filePartheader = new HttpHeaders(); + var file = new ClassPathResource("testfile.txt"); + var filePartheader = new HttpHeaders(); filePartheader.setContentType(APPLICATION_OCTET_STREAM); - val filePart = new HttpEntity(file, filePartheader); + var filePart = new HttpEntity(file, filePartheader); multiParts.add("info", infoPart); multiParts.add("file", filePart); diff --git a/form-spring/src/test/java/feign/form/feign/spring/SpringFormEncoderTest.java b/form-spring/src/test/java/feign/form/feign/spring/SpringFormEncoderTest.java index f87e18660..309397c0e 100644 --- a/form-spring/src/test/java/feign/form/feign/spring/SpringFormEncoderTest.java +++ b/form-spring/src/test/java/feign/form/feign/spring/SpringFormEncoderTest.java @@ -23,11 +23,12 @@ import feign.Response; import java.util.HashMap; import java.util.List; -import lombok.val; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; import org.springframework.web.multipart.MultipartFile; @SpringBootTest( @@ -38,15 +39,16 @@ "feign.hystrix.enabled=false", "logging.level.feign.form.feign.spring.Client=DEBUG" }) +@DirtiesContext(classMode = ClassMode.AFTER_CLASS) class SpringFormEncoderTest { @Autowired private Client client; @Test void upload1Test() throws Exception { - val folder = "test_folder"; - val file = new MockMultipartFile("file", "test".getBytes(UTF_8)); - val message = "message test"; + var folder = "test_folder"; + var file = new MockMultipartFile("file", "test".getBytes(UTF_8)); + var message = "message test"; assertThat(client.upload1(folder, file, message)) .isEqualTo(new String(file.getBytes()) + ':' + message + ':' + folder); @@ -54,9 +56,9 @@ void upload1Test() throws Exception { @Test void upload2Test() throws Exception { - val folder = "test_folder"; - val file = new MockMultipartFile("file", "test".getBytes(UTF_8)); - val message = "message test"; + var folder = "test_folder"; + var file = new MockMultipartFile("file", "test".getBytes(UTF_8)); + var message = "message test"; assertThat(client.upload2(file, folder, message)) .isEqualTo(new String(file.getBytes()) + ':' + message + ':' + folder); @@ -64,11 +66,11 @@ void upload2Test() throws Exception { @Test void uploadFileNameAndContentTypeTest() throws Exception { - val folder = "test_folder"; - val file = + var folder = "test_folder"; + var file = new MockMultipartFile( "file", "hello.dat", "application/octet-stream", "test".getBytes(UTF_8)); - val message = "message test"; + var message = "message test"; assertThat(client.upload3(file, folder, message)) .isEqualTo(file.getOriginalFilename() + ':' + file.getContentType() + ':' + folder); @@ -76,28 +78,28 @@ void uploadFileNameAndContentTypeTest() throws Exception { @Test void upload4Test() throws Exception { - val map = new HashMap(); + var map = new HashMap(); map.put("one", 1); map.put("two", 2); - val userName = "popa"; - val id = "42"; + var userName = "popa"; + var id = "42"; assertThat(client.upload4(id, map, userName)).isEqualTo(userName + ':' + id + ':' + map.size()); } @Test void upload5Test() throws Exception { - val file = new MockMultipartFile("popa.txt", "Hello world".getBytes(UTF_8)); - val dto = new Dto("field 1 value", 42, file); + var file = new MockMultipartFile("popa.txt", "Hello world".getBytes(UTF_8)); + var dto = new Dto("field 1 value", 42, file); assertThat(client.upload5(dto)).isNotNull().extracting(Response::status).isEqualTo(200); } @Test void upload6ArrayTest() throws Exception { - val file1 = new MockMultipartFile("popa1", "popa1", null, "Hello".getBytes(UTF_8)); - val file2 = new MockMultipartFile("popa2", "popa2", null, " world".getBytes(UTF_8)); + var file1 = new MockMultipartFile("popa1", "popa1", null, "Hello".getBytes(UTF_8)); + var file2 = new MockMultipartFile("popa2", "popa2", null, " world".getBytes(UTF_8)); assertThat(client.upload6Array(new MultipartFile[] {file1, file2})).isEqualTo("Hello world"); } diff --git a/form-spring/src/test/java/feign/form/feign/spring/SpringMultipartDecoderTest.java b/form-spring/src/test/java/feign/form/feign/spring/SpringMultipartDecoderTest.java index 129d62230..2412b552d 100644 --- a/form-spring/src/test/java/feign/form/feign/spring/SpringMultipartDecoderTest.java +++ b/form-spring/src/test/java/feign/form/feign/spring/SpringMultipartDecoderTest.java @@ -23,12 +23,15 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; import org.springframework.web.multipart.MultipartFile; @SpringBootTest( webEnvironment = DEFINED_PORT, classes = Server.class, - properties = {"server.port=8081", "feign.hystrix.enabled=false"}) + properties = {"server.port=8080", "feign.hystrix.enabled=false"}) +@DirtiesContext(classMode = ClassMode.AFTER_CLASS) class SpringMultipartDecoderTest { @Autowired private DownloadClient downloadClient; diff --git a/form-spring/src/test/java/feign/form/feign/spring/converter/SpringManyMultipartFilesReaderTest.java b/form-spring/src/test/java/feign/form/feign/spring/converter/SpringManyMultipartFilesReaderTest.java index 9fcded4c2..d7f4d1b42 100644 --- a/form-spring/src/test/java/feign/form/feign/spring/converter/SpringManyMultipartFilesReaderTest.java +++ b/form-spring/src/test/java/feign/form/feign/spring/converter/SpringManyMultipartFilesReaderTest.java @@ -15,7 +15,6 @@ */ package feign.form.feign.spring.converter; -import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.http.HttpHeaders.CONTENT_TYPE; import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; @@ -24,7 +23,6 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; -import lombok.val; import org.apache.commons.io.IOUtils; import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; @@ -38,8 +36,8 @@ class SpringManyMultipartFilesReaderTest { @Test void readMultipartFormDataTest() throws IOException { - val multipartFilesReader = new SpringManyMultipartFilesReader(4096); - val multipartFiles = + var multipartFilesReader = new SpringManyMultipartFilesReader(4096); + var multipartFiles = multipartFilesReader.read(MultipartFile[].class, new ValidMultipartMessage()); assertThat(multipartFiles.length).isEqualTo(2); @@ -58,7 +56,7 @@ static class ValidMultipartMessage implements HttpInputMessage { @Override public InputStream getBody() throws IOException { - val multipartBody = + var multipartBody = "--" + DUMMY_MULTIPART_BOUNDARY + "\r\n" @@ -84,10 +82,9 @@ public InputStream getBody() throws IOException { @Override public HttpHeaders getHeaders() { - val httpHeaders = new HttpHeaders(); - httpHeaders.put( - CONTENT_TYPE, - singletonList(MULTIPART_FORM_DATA_VALUE + "; boundary=" + DUMMY_MULTIPART_BOUNDARY)); + var httpHeaders = new HttpHeaders(); + httpHeaders.set( + CONTENT_TYPE, MULTIPART_FORM_DATA_VALUE + "; boundary=" + DUMMY_MULTIPART_BOUNDARY); return httpHeaders; } } diff --git a/form/pom.xml b/form/pom.xml index beb5b4894..0d827c976 100644 --- a/form/pom.xml +++ b/form/pom.xml @@ -23,7 +23,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-form @@ -33,7 +33,7 @@ org.projectlombok lombok - 1.18.38 + ${lombok.version} provided @@ -50,7 +50,7 @@ org.apache.commons commons-text - 1.14.0 + ${commons-text.version} test @@ -69,13 +69,13 @@ io.undertow undertow-core - 2.3.18.Final + ${undertow-core.version} test io.appulse utils-java - 1.18.0 + ${utils-java.version} test @@ -99,13 +99,13 @@ org.mockito mockito-core - 5.18.0 + ${mockito.version} test org.mockito mockito-junit-jupiter - 5.18.0 + ${mockito.version} test diff --git a/form/src/main/java/feign/form/FormEncoder.java b/form/src/main/java/feign/form/FormEncoder.java index d9959964b..e01e24976 100644 --- a/form/src/main/java/feign/form/FormEncoder.java +++ b/form/src/main/java/feign/form/FormEncoder.java @@ -22,6 +22,7 @@ import static lombok.AccessLevel.PRIVATE; import feign.RequestTemplate; +import feign.codec.DefaultEncoder; import feign.codec.EncodeException; import feign.codec.Encoder; import java.lang.reflect.Type; @@ -56,7 +57,7 @@ public class FormEncoder implements Encoder { /** Constructor with the default Feign's encoder as a delegate. */ public FormEncoder() { - this(new Encoder.Default()); + this(new DefaultEncoder()); } /** diff --git a/form/src/test/java/feign/form/BasicClientTest.java b/form/src/test/java/feign/form/BasicClientTest.java index 646ec2bb1..e4958d21a 100644 --- a/form/src/test/java/feign/form/BasicClientTest.java +++ b/form/src/test/java/feign/form/BasicClientTest.java @@ -30,7 +30,6 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.Map; -import lombok.val; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -45,7 +44,7 @@ class BasicClientTest { @BeforeAll static void configureClient() { - val logFile = logDir.resolve("log.txt").toString(); + var logFile = logDir.resolve("log.txt").toString(); API = Feign.builder() @@ -67,7 +66,7 @@ void testFormException() { @Test void testUpload() throws Exception { - val path = + var path = Path.of(Thread.currentThread().getContextClassLoader().getResource("file.txt").toURI()); assertThat(path).exists(); @@ -76,7 +75,7 @@ void testUpload() throws Exception { @Test void testUploadWithParam() throws Exception { - val path = + var path = Path.of(Thread.currentThread().getContextClassLoader().getResource("file.txt").toURI()); assertThat(path).exists(); @@ -85,7 +84,7 @@ void testUploadWithParam() throws Exception { @Test void testJson() { - val dto = new Dto("Artem", 11); + var dto = new Dto("Artem", 11); assertThat(API.json(dto)).isEqualTo("ok"); } @@ -100,11 +99,11 @@ void testQueryMap() { @Test void testMultipleFilesArray() throws Exception { - val path1 = + var path1 = Path.of(Thread.currentThread().getContextClassLoader().getResource("file.txt").toURI()); assertThat(path1).exists(); - val path2 = + var path2 = Path.of( Thread.currentThread().getContextClassLoader().getResource("another_file.txt").toURI()); assertThat(path2).exists(); @@ -116,11 +115,11 @@ void testMultipleFilesArray() throws Exception { @Test void testMultipleFilesList() throws Exception { - val path1 = + var path1 = Path.of(Thread.currentThread().getContextClassLoader().getResource("file.txt").toURI()); assertThat(path1).exists(); - val path2 = + var path2 = Path.of( Thread.currentThread().getContextClassLoader().getResource("another_file.txt").toURI()); assertThat(path2).exists(); @@ -132,9 +131,9 @@ void testMultipleFilesList() throws Exception { @Test void testUploadWithDto() throws Exception { - val dto = new Dto("Artem", 11); + var dto = new Dto("Artem", 11); - val path = + var path = Path.of(Thread.currentThread().getContextClassLoader().getResource("file.txt").toURI()); assertThat(path).exists(); @@ -146,7 +145,7 @@ void testUploadWithDto() throws Exception { @Test void testUnknownTypeFile() throws Exception { - val path = + var path = Path.of(Thread.currentThread().getContextClassLoader().getResource("file.abc").toURI()); assertThat(path).exists(); @@ -155,22 +154,22 @@ void testUnknownTypeFile() throws Exception { @Test void testFormData() throws Exception { - val formData = new FormData("application/custom-type", "popa.txt", "Allo".getBytes("UTF-8")); + var formData = new FormData("application/custom-type", "popa.txt", "Allo".getBytes("UTF-8")); assertThat(API.uploadFormData(formData)).isEqualTo("popa.txt:application/custom-type"); } @Test void testSubmitRepeatableQueryParam() throws Exception { - val names = new String[] {"Milada", "Thais"}; - val stringResponse = API.submitRepeatableQueryParam(names); + var names = new String[] {"Milada", "Thais"}; + var stringResponse = API.submitRepeatableQueryParam(names); assertThat(stringResponse).isEqualTo("Milada and Thais"); } @Test void testSubmitRepeatableFormParam() throws Exception { - val names = Arrays.asList("Milada", "Thais"); - val stringResponse = API.submitRepeatableFormParam(names); + var names = Arrays.asList("Milada", "Thais"); + var stringResponse = API.submitRepeatableFormParam(names); assertThat(stringResponse).isEqualTo("Milada and Thais"); } } diff --git a/form/src/test/java/feign/form/ByteArrayClientTest.java b/form/src/test/java/feign/form/ByteArrayClientTest.java index d83ecdaac..917daea09 100644 --- a/form/src/test/java/feign/form/ByteArrayClientTest.java +++ b/form/src/test/java/feign/form/ByteArrayClientTest.java @@ -27,7 +27,6 @@ import feign.Response; import feign.jackson.JacksonEncoder; import java.nio.file.Path; -import lombok.val; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -42,8 +41,8 @@ class ByteArrayClientTest { @BeforeAll static void configureClient() { - val encoder = new FormEncoder(new JacksonEncoder()); - val logFile = logDir.resolve("log-byte.txt").toString(); + var encoder = new FormEncoder(new JacksonEncoder()); + var logFile = logDir.resolve("log-byte.txt").toString(); API = Feign.builder() diff --git a/form/src/test/java/feign/form/CustomClientTest.java b/form/src/test/java/feign/form/CustomClientTest.java index 8c1e6e48e..7fd85b6e5 100644 --- a/form/src/test/java/feign/form/CustomClientTest.java +++ b/form/src/test/java/feign/form/CustomClientTest.java @@ -30,7 +30,6 @@ import feign.form.multipart.Output; import feign.jackson.JacksonEncoder; import java.nio.file.Path; -import lombok.val; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -45,11 +44,11 @@ class CustomClientTest { @BeforeAll static void configureClient() { - val encoder = new FormEncoder(new JacksonEncoder()); - val processor = (MultipartFormContentProcessor) encoder.getContentProcessor(MULTIPART); + var encoder = new FormEncoder(new JacksonEncoder()); + var processor = (MultipartFormContentProcessor) encoder.getContentProcessor(MULTIPART); processor.addFirstWriter(new CustomByteArrayWriter()); - val logFile = logDir.resolve("log.txt").toString(); + var logFile = logDir.resolve("log.txt").toString(); API = Feign.builder() @@ -70,7 +69,7 @@ private static final class CustomByteArrayWriter extends ByteArrayWriter { protected void write(Output output, String key, Object value) throws EncodeException { writeFileMetadata(output, key, "popa.txt", null); - val bytes = (byte[]) value; + var bytes = (byte[]) value; output.write(bytes); } } diff --git a/form/src/test/java/feign/form/FormPropertyTest.java b/form/src/test/java/feign/form/FormPropertyTest.java index bc07aa803..745092882 100644 --- a/form/src/test/java/feign/form/FormPropertyTest.java +++ b/form/src/test/java/feign/form/FormPropertyTest.java @@ -25,7 +25,6 @@ import feign.RequestLine; import feign.jackson.JacksonEncoder; import java.nio.file.Path; -import lombok.val; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -40,7 +39,7 @@ class FormPropertyTest { @BeforeAll static void configureClient() { - val logFile = logDir.resolve("log.txt").toString(); + var logFile = logDir.resolve("log.txt").toString(); API = Feign.builder() @@ -52,7 +51,7 @@ static void configureClient() { @Test void test() { - val dto = new FormDto("Amigo", 23); + var dto = new FormDto("Amigo", 23); assertThat(API.postData(dto)).isEqualTo("Amigo=23"); } diff --git a/form/src/test/java/feign/form/Server.java b/form/src/test/java/feign/form/Server.java index a78ffb988..e9aeb3655 100644 --- a/form/src/test/java/feign/form/Server.java +++ b/form/src/test/java/feign/form/Server.java @@ -28,7 +28,6 @@ import java.io.IOException; import java.util.Collection; import java.util.List; -import lombok.val; import org.apache.commons.text.StringEscapeUtils; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.http.HttpStatus; @@ -52,7 +51,7 @@ public class Server { @PostMapping("/form") public ResponseEntity form( @RequestParam("key1") String key1, @RequestParam("key2") String key2) { - val status = !key1.equals(key2) ? BAD_REQUEST : OK; + var status = !key1.equals(key2) ? BAD_REQUEST : OK; return ResponseEntity.status(status).body(null); } @@ -121,27 +120,27 @@ public ResponseEntity json(@RequestBody Dto dto) { @PostMapping("/query_map") public ResponseEntity queryMap(@RequestParam("filter") List filters) { - val status = filters != null && !filters.isEmpty() ? OK : I_AM_A_TEAPOT; + var status = filters != null && !filters.isEmpty() ? OK : I_AM_A_TEAPOT; return ResponseEntity.status(status).body(filters.size()); } @PostMapping(path = "/wild-card-map", consumes = APPLICATION_FORM_URLENCODED_VALUE) public ResponseEntity wildCardMap( @RequestParam("key1") String key1, @RequestParam("key2") String key2) { - val status = key1.equals(key2) ? OK : I_AM_A_TEAPOT; + var status = key1.equals(key2) ? OK : I_AM_A_TEAPOT; return ResponseEntity.status(status).body(null); } @PostMapping(path = "/upload/with_dto", consumes = MULTIPART_FORM_DATA_VALUE) public ResponseEntity uploadWithDto(Dto dto, @RequestPart("file") MultipartFile file) throws IOException { - val status = dto != null && dto.getName().equals("Artem") ? OK : I_AM_A_TEAPOT; + var status = dto != null && dto.getName().equals("Artem") ? OK : I_AM_A_TEAPOT; return ResponseEntity.status(status).body(file.getSize()); } @PostMapping(path = "/upload/byte_array", consumes = MULTIPART_FORM_DATA_VALUE) public ResponseEntity uploadByteArray(@RequestPart("file") MultipartFile file) { - val status = file != null ? OK : I_AM_A_TEAPOT; + var status = file != null ? OK : I_AM_A_TEAPOT; String safeFilename = HtmlUtils.htmlEscape(file.getOriginalFilename()); return ResponseEntity.status(status).body(safeFilename); } @@ -153,7 +152,7 @@ public ResponseEntity uploadByteArray(@RequestPart("file") MultipartFile // have the filename part it's // available in the parameter (getParameter()) public ResponseEntity uploadByteArrayParameter(MultipartHttpServletRequest request) { - val status = + var status = request.getFile("file") == null && request.getParameter("file") != null ? OK : I_AM_A_TEAPOT; @@ -162,13 +161,13 @@ public ResponseEntity uploadByteArrayParameter(MultipartHttpServletReque @PostMapping(path = "/upload/unknown_type", consumes = MULTIPART_FORM_DATA_VALUE) public ResponseEntity uploadUnknownType(@RequestPart("file") MultipartFile file) { - val status = file != null ? OK : I_AM_A_TEAPOT; + var status = file != null ? OK : I_AM_A_TEAPOT; return ResponseEntity.status(status).body(StringEscapeUtils.escapeHtml4(file.getContentType())); } @PostMapping(path = "/upload/form_data", consumes = MULTIPART_FORM_DATA_VALUE) public ResponseEntity uploadFormData(@RequestPart("file") MultipartFile file) { - val status = file != null ? OK : I_AM_A_TEAPOT; + var status = file != null ? OK : I_AM_A_TEAPOT; String sanitizedFilename = StringEscapeUtils.escapeHtml4(file.getOriginalFilename()); String sanitizedContentType = StringEscapeUtils.escapeHtml4(file.getContentType()); return ResponseEntity.status(status).body(sanitizedFilename + ':' + sanitizedContentType); @@ -176,11 +175,11 @@ public ResponseEntity uploadFormData(@RequestPart("file") MultipartFile @PostMapping(path = "/submit/url", consumes = APPLICATION_FORM_URLENCODED_VALUE) public ResponseEntity submitRepeatableQueryParam(@RequestParam("names") String[] names) { - val response = new StringBuilder(); + var response = new StringBuilder(); if (names != null && names.length == 2) { response.append(names[0]).append(" and ").append(names[1]); } - val status = response.length() > 0 ? OK : I_AM_A_TEAPOT; + var status = response.length() > 0 ? OK : I_AM_A_TEAPOT; return ResponseEntity.status(status).body(response.toString()); } @@ -188,12 +187,12 @@ public ResponseEntity submitRepeatableQueryParam(@RequestParam("names") @PostMapping(path = "/submit/form", consumes = MULTIPART_FORM_DATA_VALUE) public ResponseEntity submitRepeatableFormParam( @RequestParam("names") Collection names) { - val response = new StringBuilder(); + var response = new StringBuilder(); if (names != null && names.size() == 2) { - val iterator = names.iterator(); + var iterator = names.iterator(); response.append(iterator.next()).append(" and ").append(iterator.next()); } - val status = response.length() > 0 ? OK : I_AM_A_TEAPOT; + var status = response.length() > 0 ? OK : I_AM_A_TEAPOT; return ResponseEntity.status(status).body(response.toString()); } @@ -201,11 +200,11 @@ public ResponseEntity submitRepeatableFormParam( @PostMapping(path = "/form-data", consumes = APPLICATION_FORM_URLENCODED_VALUE) public ResponseEntity submitPostData( @RequestParam("f_name") String firstName, @RequestParam("age") Integer age) { - val response = new StringBuilder(); + var response = new StringBuilder(); if (firstName != null && age != null) { response.append(firstName).append("=").append(age); } - val status = response.length() > 0 ? OK : I_AM_A_TEAPOT; + var status = response.length() > 0 ? OK : I_AM_A_TEAPOT; return ResponseEntity.status(status).body(response.toString()); } diff --git a/form/src/test/java/feign/form/WildCardMapTest.java b/form/src/test/java/feign/form/WildCardMapTest.java index 9c8df4c08..3dca096c8 100644 --- a/form/src/test/java/feign/form/WildCardMapTest.java +++ b/form/src/test/java/feign/form/WildCardMapTest.java @@ -27,7 +27,6 @@ import java.nio.file.Path; import java.util.HashMap; import java.util.Map; -import lombok.val; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -42,7 +41,7 @@ class WildCardMapTest { @BeforeAll static void configureClient() { - val logFile = logDir.resolve("log.txt").toString(); + var logFile = logDir.resolve("log.txt").toString(); api = Feign.builder() diff --git a/form/src/test/java/feign/form/issues/Issue63Test.java b/form/src/test/java/feign/form/issues/Issue63Test.java index 7a6655eed..e0a6dafd0 100644 --- a/form/src/test/java/feign/form/issues/Issue63Test.java +++ b/form/src/test/java/feign/form/issues/Issue63Test.java @@ -26,7 +26,6 @@ import io.undertow.server.HttpServerExchange; import java.util.HashMap; import java.util.Map; -import lombok.val; import org.junit.jupiter.api.Test; // https://github.com/OpenFeign/feign-form/issues/63 @@ -34,13 +33,13 @@ class Issue63Test { @Test void test() { - try (val server = UndertowServer.builder().callback(this::handleRequest).start()) { - val client = + try (var server = UndertowServer.builder().callback(this::handleRequest).start()) { + var client = Feign.builder() .encoder(new FormEncoder(new JacksonEncoder())) .target(Client.class, server.getConnectUrl()); - val data = new HashMap(); + var data = new HashMap(); data.put("from", "+987654321"); data.put("to", "+123456789"); data.put("body", "hello world"); diff --git a/form/src/test/java/feign/form/utils/UndertowServer.java b/form/src/test/java/feign/form/utils/UndertowServer.java index a6903b7a8..48704a1ab 100644 --- a/form/src/test/java/feign/form/utils/UndertowServer.java +++ b/form/src/test/java/feign/form/utils/UndertowServer.java @@ -25,7 +25,6 @@ import java.net.InetSocketAddress; import lombok.Builder; import lombok.RequiredArgsConstructor; -import lombok.val; public final class UndertowServer implements AutoCloseable { @@ -33,7 +32,7 @@ public final class UndertowServer implements AutoCloseable { @Builder(buildMethodName = "start") private UndertowServer(FullBytesCallback callback) { - val port = + var port = SocketUtils.findFreePort() .orElseThrow(() -> new IllegalStateException("no available port to start server")); @@ -52,9 +51,9 @@ private UndertowServer(FullBytesCallback callback) { * @return listining server's url. */ public String getConnectUrl() { - val listenerInfo = undertow.getListenerInfo().iterator().next(); + var listenerInfo = undertow.getListenerInfo().iterator().next(); - val address = (InetSocketAddress) listenerInfo.getAddress(); + var address = (InetSocketAddress) listenerInfo.getAddress(); return String.format( "%s://%s:%d", listenerInfo.getProtcol(), address.getHostString(), address.getPort()); diff --git a/googlehttpclient/pom.xml b/googlehttpclient/pom.xml index 928c42233..ee57d9cc8 100644 --- a/googlehttpclient/pom.xml +++ b/googlehttpclient/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-googlehttpclient diff --git a/graphql-apt/README.md b/graphql-apt/README.md new file mode 100644 index 000000000..cc6935233 --- /dev/null +++ b/graphql-apt/README.md @@ -0,0 +1,99 @@ +Feign GraphQL APT +=================== + +Annotation processor for `feign-graphql` that generates Java records from GraphQL schemas at compile time. + +Given a `@GraphqlSchema`-annotated interface, this processor: + +- Parses the referenced `.graphql` schema file +- Validates all `@GraphqlQuery` strings against the schema +- Generates Java records for result types, input types, and enums +- Maps custom scalars to Java types via `@Scalar` annotations + +See the [feign-graphql README](../graphql/README.md) for usage examples. + +## Generated Output + +### Result types with inner records + +Nested result types are generated as inner records scoped to each query result. This ensures each query gets exactly the fields it selects, even when different queries target the same GraphQL type. + +For a query like: + +```graphql +{ + starship(id: "1") { + id name + location { planet sector } + specs { lengthMeters classification } + } +} +``` + +The processor generates a single file with inner records: + +```java +public record StarshipResult(String id, String name, Location location, Specs specs) { + + public record Location(String planet, String sector) {} + + public record Specs(Integer lengthMeters, String classification) {} + +} +``` + +Two different queries can select different fields from the same GraphQL type without conflict: + +```java +// Query 1: selects location { planet } +public record CharByPlanet(String id, Location location) { + public record Location(String planet) {} +} + +// Query 2: selects location { sector region } +public record CharByRegion(String id, Location location) { + public record Location(String sector, String region) {} +} +``` + +### Conflicting return type error + +If two queries use the same return type name but select different fields, the processor reports compilation errors on both methods showing which fields each selects: + +``` +error: Conflicting return type 'CharResult': method selects [id, email] but method 'query1()' already selects [id, name] + CharResult query2(); + ^ +error: Conflicting return type 'CharResult': method selects [id, name] but method 'query2()' selects [id, email] + CharResult query1(); + ^ +``` + +### Input types and enums + +Input types and enums are generated as top-level files since they represent the full schema type: + +```java +public record CreateCharacterInput(String name, String email, Episode appearsIn) {} +``` + +```java +public enum Episode { + NEWHOPE, + EMPIRE, + JEDI +} +``` + +## Maven Configuration + +Add as a `provided` dependency so it runs during compilation but is not included at runtime: + +```xml + + io.github.openfeign.experimental + feign-graphql-apt + ${feign.version} + provided + +``` diff --git a/graphql-apt/pom.xml b/graphql-apt/pom.xml new file mode 100644 index 000000000..e7cb3e694 --- /dev/null +++ b/graphql-apt/pom.xml @@ -0,0 +1,89 @@ + + + + 4.0.0 + + + io.github.openfeign + feign-parent + 13.12-SNAPSHOT + + + io.github.openfeign.experimental + feign-graphql-apt + Feign GraphQL APT + Feign annotation processor for GraphQL schema-based type generation + + + 17 + true + + + + + io.github.openfeign + feign-graphql + ${project.version} + + + + com.graphql-java + graphql-java + ${graphql-java.version} + + + + com.squareup + javapoet + ${javapoet.version} + + + + com.google.auto.service + auto-service + ${auto-service-annotations.version} + provided + + + + com.google.testing.compile + compile-testing + ${compile-testing.version} + test + + + junit + junit + + + + + + + + + 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 --add-opens jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED + + + + + diff --git a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java new file mode 100644 index 000000000..575d89a00 --- /dev/null +++ b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java @@ -0,0 +1,563 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql.apt; + +import com.google.auto.service.AutoService; +import com.squareup.javapoet.TypeName; +import feign.graphql.GraphqlField; +import feign.graphql.GraphqlQuery; +import feign.graphql.GraphqlSchema; +import feign.graphql.Scalar; +import feign.graphql.Toggle; +import graphql.language.Document; +import graphql.language.Field; +import graphql.language.ObjectTypeDefinition; +import graphql.language.OperationDefinition; +import graphql.language.SelectionSet; +import graphql.language.VariableDefinition; +import graphql.parser.Parser; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.SchemaParser; +import graphql.schema.idl.TypeDefinitionRegistry; +import graphql.schema.idl.UnExecutableSchemaGenerator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Supplier; +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.Filer; +import javax.annotation.processing.Messager; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.Processor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.annotation.processing.SupportedSourceVersion; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.PackageElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.MirroredTypeException; +import javax.lang.model.type.MirroredTypesException; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.tools.Diagnostic; + +@AutoService(Processor.class) +@SupportedAnnotationTypes("feign.graphql.GraphqlSchema") +@SupportedSourceVersion(SourceVersion.RELEASE_17) +public class GraphqlSchemaProcessor extends AbstractProcessor { + + private Filer filer; + private Messager messager; + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + this.filer = processingEnv.getFiler(); + this.messager = processingEnv.getMessager(); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + for (var element : roundEnv.getElementsAnnotatedWith(GraphqlSchema.class)) { + if (!(element instanceof TypeElement)) { + continue; + } + processInterface((TypeElement) element); + } + return true; + } + + private void processInterface(TypeElement typeElement) { + var schemaAnnotation = typeElement.getAnnotation(GraphqlSchema.class); + var schemaPath = schemaAnnotation.value(); + + var loader = new SchemaLoader(filer, messager); + var schemaContent = loader.load(schemaPath, typeElement); + if (schemaContent == null) { + return; + } + + var schemaParser = new SchemaParser(); + TypeDefinitionRegistry registry; + try { + registry = schemaParser.parse(schemaContent); + } catch (Exception e) { + messager.printMessage( + Diagnostic.Kind.ERROR, "Failed to parse GraphQL schema: " + e.getMessage(), typeElement); + return; + } + + var customScalars = collectScalarMappings(typeElement); + + if (!validateCustomScalars(registry, customScalars, typeElement)) { + return; + } + + var graphqlSchema = UnExecutableSchemaGenerator.makeUnExecutableSchema(registry); + + var generateTypes = schemaAnnotation.generateTypes(); + + var targetPackage = getPackageName(typeElement); + var typeMapper = new GraphqlTypeMapper(targetPackage, customScalars); + var validator = new QueryValidator(messager); + var generator = new TypeGenerator(filer, messager, registry, typeMapper, targetPackage); + + var classFieldAnnotations = extractFieldAnnotations(typeElement); + var classConfig = resolveClassConfig(schemaAnnotation, classFieldAnnotations); + + for (var enclosed : typeElement.getEnclosedElements()) { + if (!(enclosed instanceof ExecutableElement method)) { + continue; + } + var queryAnnotation = method.getAnnotation(GraphqlQuery.class); + if (queryAnnotation == null) { + continue; + } + + var methodConfig = resolveMethodConfig(method, queryAnnotation, classConfig); + generator.setAnnotationConfig(methodConfig); + + processMethod( + method, + queryAnnotation, + graphqlSchema, + registry, + generator, + validator, + generateTypes, + targetPackage); + } + } + + private Map collectScalarMappings(TypeElement typeElement) { + var scalars = new HashMap(); + collectScalarsFromType(typeElement, scalars); + collectScalarsFromParents(typeElement, scalars); + return scalars; + } + + private void collectScalarsFromType(TypeElement typeElement, Map scalars) { + for (var enclosed : typeElement.getEnclosedElements()) { + if (!(enclosed instanceof ExecutableElement method)) { + continue; + } + var scalarAnnotation = method.getAnnotation(Scalar.class); + if (scalarAnnotation == null) { + continue; + } + var scalarName = scalarAnnotation.value(); + var javaType = TypeName.get(method.getReturnType()); + scalars.put(scalarName, javaType); + } + } + + private void collectScalarsFromParents(TypeElement typeElement, Map scalars) { + for (var iface : typeElement.getInterfaces()) { + if (iface instanceof DeclaredType type) { + var element = type.asElement(); + if (element instanceof TypeElement parentType) { + collectScalarsFromType(parentType, scalars); + collectScalarsFromParents(parentType, scalars); + } + } + } + } + + private static final Set GRAPHQL_BUILT_IN_SCALARS = + Set.of("String", "Int", "Float", "Boolean", "ID"); + + private boolean validateCustomScalars( + TypeDefinitionRegistry registry, + Map customScalars, + TypeElement typeElement) { + var schemaScalars = registry.scalars(); + var valid = true; + for (var scalarName : schemaScalars.keySet()) { + if (GRAPHQL_BUILT_IN_SCALARS.contains(scalarName)) { + continue; + } + if (!customScalars.containsKey(scalarName)) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Custom scalar '" + + scalarName + + "' is used in the schema but no @Scalar(\"" + + scalarName + + "\") method is defined. " + + "Add a @Scalar(\"" + + scalarName + + "\") default method to this interface or a parent interface.", + typeElement); + valid = false; + } + } + return valid; + } + + private void processMethod( + ExecutableElement method, + GraphqlQuery queryAnnotation, + GraphQLSchema graphqlSchema, + TypeDefinitionRegistry registry, + TypeGenerator generator, + QueryValidator validator, + boolean generateTypes, + String targetPackage) { + + var queryString = queryAnnotation.value(); + Document document; + try { + document = Parser.parse(queryString); + } catch (Exception e) { + messager.printMessage( + Diagnostic.Kind.ERROR, "Failed to parse GraphQL query: " + e.getMessage(), method); + return; + } + + if (!validator.validate(graphqlSchema, document, method) || !generateTypes) { + return; + } + + var operation = findOperation(document); + if (operation == null) { + messager.printMessage( + Diagnostic.Kind.ERROR, "No operation definition found in GraphQL query", method); + return; + } + + validator.validateVariableBindings(operation, method); + + var returnTypeName = getSimpleTypeName(method.getReturnType()); + if (returnTypeName != null && !isExistingExternalType(method.getReturnType(), targetPackage)) { + var rootType = getRootType(operation, registry); + if (rootType != null) { + var rootField = findRootField(operation.getSelectionSet()); + if (rootField != null && rootField.getSelectionSet() != null) { + var rootFieldDef = GraphqlTypeMapper.findFieldDefinition(rootType, rootField.getName()); + if (rootFieldDef != null) { + var fieldTypeName = GraphqlTypeMapper.unwrapTypeName(rootFieldDef.getType()); + var fieldObjectType = + registry.getType(fieldTypeName, ObjectTypeDefinition.class).orElse(null); + if (fieldObjectType != null) { + generator.generateResultType( + returnTypeName, rootField.getSelectionSet(), fieldObjectType, method); + } + } + } + } + } + + var params = method.getParameters(); + var variableDefs = operation.getVariableDefinitions(); + + for (var param : params) { + var paramTypeName = getSimpleTypeName(param.asType()); + if (paramTypeName == null || isJavaBuiltIn(paramTypeName)) { + continue; + } + + if (isExistingExternalType(param.asType(), targetPackage)) { + continue; + } + + var graphqlInputTypeName = findGraphqlInputType(paramTypeName, variableDefs); + if (graphqlInputTypeName != null) { + generator.generateInputType(paramTypeName, graphqlInputTypeName, method); + } + } + } + + private OperationDefinition findOperation(Document document) { + for (var def : document.getDefinitions()) { + if (def instanceof OperationDefinition definition) { + return definition; + } + } + return null; + } + + private Field findRootField(SelectionSet selectionSet) { + if (selectionSet == null) { + return null; + } + for (var selection : selectionSet.getSelections()) { + if (selection instanceof Field field) { + return field; + } + } + return null; + } + + private ObjectTypeDefinition getRootType( + OperationDefinition operation, TypeDefinitionRegistry registry) { + var operationName = + switch (operation.getOperation()) { + case MUTATION -> "mutation"; + case SUBSCRIPTION -> "subscription"; + default -> "query"; + }; + var fallback = Character.toUpperCase(operationName.charAt(0)) + operationName.substring(1); + var rootTypeName = + registry + .schemaDefinition() + .flatMap( + sd -> + sd.getOperationTypeDefinitions().stream() + .filter(otd -> otd.getName().equals(operationName)) + .findFirst()) + .map(otd -> otd.getTypeName().getName()) + .orElse(fallback); + return registry.getType(rootTypeName, ObjectTypeDefinition.class).orElse(null); + } + + private String findGraphqlInputType( + String javaParamTypeName, List variableDefs) { + for (var varDef : variableDefs) { + var graphqlTypeName = GraphqlTypeMapper.unwrapTypeName(varDef.getType()); + if (graphqlTypeName.equals(javaParamTypeName)) { + return graphqlTypeName; + } + } + return javaParamTypeName; + } + + private static final Set JAVA_BUILT_INS = + Set.of( + "String", + "Integer", + "Long", + "Double", + "Float", + "Boolean", + "Object", + "Byte", + "Short", + "Character", + "BigDecimal", + "BigInteger"); + + private boolean isJavaBuiltIn(String typeName) { + return JAVA_BUILT_INS.contains(typeName); + } + + private String getSimpleTypeName(TypeMirror typeMirror) { + if (typeMirror instanceof DeclaredType declaredType) { + var typeElement = declaredType.asElement(); + var simpleName = typeElement.getSimpleName().toString(); + + if ("List".equals(simpleName)) { + var typeArgs = declaredType.getTypeArguments(); + if (!typeArgs.isEmpty()) { + return getSimpleTypeName(typeArgs.get(0)); + } + } + + return simpleName; + } + return null; + } + + private boolean isExistingExternalType(TypeMirror typeMirror, String targetPackage) { + var unwrapped = unwrapListTypeMirror(typeMirror); + if (unwrapped.getKind() == TypeKind.ERROR) { + return false; + } + if (unwrapped instanceof DeclaredType type) { + var element = type.asElement(); + if (element instanceof TypeElement typeElement) { + var qualifiedName = typeElement.getQualifiedName().toString(); + var lastDot = qualifiedName.lastIndexOf('.'); + var typePkg = lastDot > 0 ? qualifiedName.substring(0, lastDot) : ""; + return !typePkg.equals(targetPackage); + } + } + return false; + } + + private TypeMirror unwrapListTypeMirror(TypeMirror typeMirror) { + if (typeMirror instanceof DeclaredType declaredType) { + var simpleName = declaredType.asElement().getSimpleName().toString(); + if ("List".equals(simpleName)) { + var typeArgs = declaredType.getTypeArguments(); + if (!typeArgs.isEmpty()) { + return typeArgs.get(0); + } + } + } + return typeMirror; + } + + private TypeAnnotationConfig resolveClassConfig( + GraphqlSchema annotation, + Map classFieldAnnotations) { + var fqns = extractClassFqns(annotation::typeAnnotations); + var rawAnnotations = annotation.rawTypeAnnotations(); + var usesFqns = extractClassFqns(annotation::uses); + var nonNullFqns = extractClassFqns(annotation::nonNullTypeAnnotations); + var nonNullRaw = annotation.nonNullRawTypeAnnotations(); + var config = + TypeAnnotationConfig.resolve( + fqns, + rawAnnotations, + annotation.useOptional(), + annotation.useAliasForFieldNames(), + classFieldAnnotations, + nonNullFqns, + nonNullRaw); + if (usesFqns.isEmpty()) { + return config; + } + var mergedImports = new TreeSet<>(config.imports()); + for (var fqn : usesFqns) { + if (!fqn.startsWith("java.lang.")) { + mergedImports.add(fqn); + } + } + return new TypeAnnotationConfig( + mergedImports, + config.annotations(), + config.useOptional(), + config.useAliasForFieldNames(), + config.fieldAnnotations(), + config.nonNullAnnotations()); + } + + private static List extractClassFqns(Supplier[]> accessor) { + try { + var classes = accessor.get(); + return java.util.Arrays.stream(classes).map(Class::getCanonicalName).toList(); + } catch (MirroredTypesException e) { + return e.getTypeMirrors().stream().map(TypeMirror::toString).toList(); + } + } + + private TypeAnnotationConfig resolveMethodConfig( + ExecutableElement method, GraphqlQuery annotation, TypeAnnotationConfig classConfig) { + var methodFqns = extractClassFqns(annotation::typeAnnotations); + var methodRaw = annotation.rawTypeAnnotations(); + var methodOptionalToggle = annotation.useOptional(); + var methodAliasToggle = annotation.useAliasForFieldNames(); + + var useOptional = + methodOptionalToggle == Toggle.INHERIT + ? classConfig.useOptional() + : methodOptionalToggle == Toggle.TRUE; + var useAliasForFieldNames = + methodAliasToggle == Toggle.INHERIT + ? classConfig.useAliasForFieldNames() + : methodAliasToggle == Toggle.TRUE; + + var methodFieldAnnotations = extractFieldAnnotations(method); + var fieldAnnotations = + TypeAnnotationConfig.FieldAnnotations.merge( + classConfig.fieldAnnotations(), methodFieldAnnotations); + + var methodNonNullFqns = extractClassFqns(annotation::nonNullTypeAnnotations); + var methodNonNullRaw = annotation.nonNullRawTypeAnnotations(); + boolean hasMethodNonNull = !methodNonNullFqns.isEmpty() || methodNonNullRaw.length > 0; + var resolvedNonNull = hasMethodNonNull ? null : classConfig.nonNullAnnotations(); + + boolean hasMethodAnnotations = !methodFqns.isEmpty() || methodRaw.length > 0; + if (!hasMethodAnnotations && !hasMethodNonNull) { + if (useOptional == classConfig.useOptional() + && useAliasForFieldNames == classConfig.useAliasForFieldNames() + && fieldAnnotations.equals(classConfig.fieldAnnotations())) { + return classConfig; + } + var mergedImports = new TreeSet<>(classConfig.imports()); + for (var fa : fieldAnnotations.values()) { + mergedImports.addAll(fa.imports()); + } + return new TypeAnnotationConfig( + mergedImports, + classConfig.annotations(), + useOptional, + useAliasForFieldNames, + fieldAnnotations, + classConfig.nonNullAnnotations()); + } + + var nonNullFqns = hasMethodNonNull ? methodNonNullFqns : List.of(); + var nonNullRaw = hasMethodNonNull ? methodNonNullRaw : new String[0]; + var config = + TypeAnnotationConfig.resolve( + methodFqns, + methodRaw, + useOptional, + useAliasForFieldNames, + fieldAnnotations, + nonNullFqns, + nonNullRaw); + + if (resolvedNonNull != null && !resolvedNonNull.isEmpty()) { + var mergedImports = new TreeSet<>(config.imports()); + mergedImports.addAll(classConfig.imports()); + return new TypeAnnotationConfig( + mergedImports, + config.annotations(), + useOptional, + useAliasForFieldNames, + fieldAnnotations, + resolvedNonNull); + } + + return config; + } + + private Map extractFieldAnnotations( + Element method) { + var fieldAnnotations = new HashMap(); + var graphqlFields = method.getAnnotationsByType(GraphqlField.class); + for (var gf : graphqlFields) { + var fqns = extractClassFqns(gf::typeAnnotations); + var typeOverride = extractFieldTypeOverride(gf); + var resolved = + TypeAnnotationConfig.FieldAnnotations.resolve( + fqns, gf.rawTypeAnnotations(), typeOverride); + fieldAnnotations.put(gf.name(), resolved); + } + return fieldAnnotations; + } + + private String extractFieldTypeOverride(GraphqlField annotation) { + try { + var cls = annotation.type(); + if (cls == Void.class) { + return null; + } + return cls.getCanonicalName(); + } catch (MirroredTypeException e) { + var fqn = e.getTypeMirror().toString(); + return "java.lang.Void".equals(fqn) ? null : fqn; + } + } + + private String getPackageName(TypeElement typeElement) { + var enclosing = typeElement.getEnclosingElement(); + while (enclosing != null && !(enclosing instanceof PackageElement)) { + enclosing = enclosing.getEnclosingElement(); + } + if (enclosing instanceof PackageElement element) { + return element.getQualifiedName().toString(); + } + return ""; + } +} diff --git a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlTypeMapper.java b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlTypeMapper.java new file mode 100644 index 000000000..240b17983 --- /dev/null +++ b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlTypeMapper.java @@ -0,0 +1,113 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql.apt; + +import com.squareup.javapoet.ClassName; +import com.squareup.javapoet.ParameterizedTypeName; +import com.squareup.javapoet.TypeName; +import graphql.language.FieldDefinition; +import graphql.language.ListType; +import graphql.language.NonNullType; +import graphql.language.ObjectTypeDefinition; +import graphql.language.Type; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class GraphqlTypeMapper { + + private static final Map BUILT_IN_SCALARS = + Map.of( + "String", ClassName.get(String.class), + "Int", ClassName.get(Integer.class), + "Float", ClassName.get(Double.class), + "Boolean", ClassName.get(Boolean.class), + "ID", ClassName.get(String.class)); + + private final String targetPackage; + private final Map customScalars; + + public GraphqlTypeMapper(String targetPackage, Map customScalars) { + this.targetPackage = targetPackage; + this.customScalars = new HashMap<>(customScalars); + } + + public TypeName map(Type type) { + return map(type, false); + } + + public TypeName map(Type type, boolean useOptional) { + boolean nullable = !(type instanceof NonNullType); + var mapped = mapInner(type); + if (useOptional && nullable) { + return ParameterizedTypeName.get(ClassName.get(Optional.class), mapped); + } + return mapped; + } + + private TypeName mapInner(Type type) { + if (type instanceof NonNullType nullType) { + return mapInner(nullType.getType()); + } + if (type instanceof ListType listType) { + var elementType = mapInner(listType.getType()); + return ParameterizedTypeName.get(ClassName.get(List.class), elementType); + } + if (type instanceof graphql.language.TypeName name) { + return mapScalarOrNamed(name.getName()); + } + return ClassName.get(String.class); + } + + private TypeName mapScalarOrNamed(String name) { + var builtIn = BUILT_IN_SCALARS.get(name); + if (builtIn != null) { + return builtIn; + } + var custom = customScalars.get(name); + if (custom != null) { + return custom; + } + return ClassName.get(targetPackage, name); + } + + public boolean isScalar(String name) { + return BUILT_IN_SCALARS.containsKey(name) || customScalars.containsKey(name); + } + + static String unwrapTypeName(Type type) { + if (type instanceof NonNullType nullType) { + return unwrapTypeName(nullType.getType()); + } + if (type instanceof ListType listType) { + return unwrapTypeName(listType.getType()); + } + if (type instanceof graphql.language.TypeName name) { + return name.getName(); + } + return "String"; + } + + static FieldDefinition findFieldDefinition(ObjectTypeDefinition typeDef, String fieldName) { + for (var fd : typeDef.getFieldDefinitions()) { + if (fd.getName().equals(fieldName)) { + return fd; + } + } + return null; + } +} diff --git a/graphql-apt/src/main/java/feign/graphql/apt/QueryValidator.java b/graphql-apt/src/main/java/feign/graphql/apt/QueryValidator.java new file mode 100644 index 000000000..4fefe5eab --- /dev/null +++ b/graphql-apt/src/main/java/feign/graphql/apt/QueryValidator.java @@ -0,0 +1,104 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql.apt; + +import feign.Param; +import graphql.GraphQLError; +import graphql.language.Document; +import graphql.language.ListType; +import graphql.language.NonNullType; +import graphql.language.OperationDefinition; +import graphql.language.Type; +import graphql.language.VariableDefinition; +import graphql.schema.GraphQLSchema; +import graphql.validation.Validator; +import java.util.HashSet; +import java.util.Locale; +import javax.annotation.processing.Messager; +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.tools.Diagnostic; + +public class QueryValidator { + + private final Messager messager; + + public QueryValidator(Messager messager) { + this.messager = messager; + } + + public boolean validate(GraphQLSchema schema, Document document, Element methodElement) { + var validator = new Validator(); + var errors = validator.validateDocument(schema, document, Locale.ENGLISH); + + if (errors.isEmpty()) { + return true; + } + + for (GraphQLError error : errors) { + var locations = error.getLocations(); + if (locations != null && !locations.isEmpty()) { + var loc = locations.get(0); + messager.printMessage( + Diagnostic.Kind.ERROR, + "GraphQL validation error at line %d, column %d: %s" + .formatted(loc.getLine(), loc.getColumn(), error.getMessage()), + methodElement); + } else { + messager.printMessage( + Diagnostic.Kind.ERROR, + "GraphQL validation error: " + error.getMessage(), + methodElement); + } + } + return false; + } + + public void validateVariableBindings(OperationDefinition operation, ExecutableElement method) { + var paramNames = new HashSet(); + for (var param : method.getParameters()) { + if (param.getAnnotation(Param.class) == null) { + paramNames.add(param.getSimpleName().toString()); + } + } + + for (VariableDefinition varDef : operation.getVariableDefinitions()) { + if (varDef.getType() instanceof NonNullType && varDef.getDefaultValue() == null) { + var varName = varDef.getName(); + if (!paramNames.contains(varName)) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Required GraphQL variable '$%s' (%s) has no corresponding method parameter. Add a parameter named '%s' or provide a default value in the query." + .formatted(varName, typeToString(varDef.getType()), varName), + method); + } + } + } + } + + private String typeToString(Type type) { + if (type instanceof NonNullType nonNullType) { + return typeToString(nonNullType.getType()) + "!"; + } + if (type instanceof ListType listType) { + return "[" + typeToString(listType.getType()) + "]"; + } + if (type instanceof graphql.language.TypeName typeName) { + return typeName.getName(); + } + return type.toString(); + } +} diff --git a/graphql-apt/src/main/java/feign/graphql/apt/SchemaLoader.java b/graphql-apt/src/main/java/feign/graphql/apt/SchemaLoader.java new file mode 100644 index 000000000..16f971d0f --- /dev/null +++ b/graphql-apt/src/main/java/feign/graphql/apt/SchemaLoader.java @@ -0,0 +1,64 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql.apt; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import javax.annotation.processing.Filer; +import javax.annotation.processing.Messager; +import javax.lang.model.element.Element; +import javax.tools.Diagnostic; +import javax.tools.StandardLocation; + +public class SchemaLoader { + + private final Filer filer; + private final Messager messager; + + public SchemaLoader(Filer filer, Messager messager) { + this.filer = filer; + this.messager = messager; + } + + public String load(String path, Element element) { + StandardLocation[] locations = { + StandardLocation.CLASS_PATH, StandardLocation.SOURCE_PATH, StandardLocation.CLASS_OUTPUT + }; + + for (var location : locations) { + try { + var resource = filer.getResource(location, "", path); + try (var is = resource.openInputStream(); + Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + var sb = new StringBuilder(); + var buf = new char[4096]; + int read; + while ((read = reader.read(buf)) != -1) { + sb.append(buf, 0, read); + } + return sb.toString(); + } + } catch (IOException e) { + // try next location + } + } + + messager.printMessage(Diagnostic.Kind.ERROR, "GraphQL schema not found: " + path, element); + return null; + } +} diff --git a/graphql-apt/src/main/java/feign/graphql/apt/TypeAnnotationConfig.java b/graphql-apt/src/main/java/feign/graphql/apt/TypeAnnotationConfig.java new file mode 100644 index 000000000..4cb94b778 --- /dev/null +++ b/graphql-apt/src/main/java/feign/graphql/apt/TypeAnnotationConfig.java @@ -0,0 +1,154 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql.apt; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +record TypeAnnotationConfig( + Set imports, + List annotations, + boolean useOptional, + boolean useAliasForFieldNames, + Map fieldAnnotations, + List nonNullAnnotations) { + + static final TypeAnnotationConfig EMPTY = + new TypeAnnotationConfig(Set.of(), List.of(), false, true, Map.of(), List.of()); + + static TypeAnnotationConfig resolve( + List typeAnnotationFqns, + String[] rawTypeAnnotations, + boolean useOptional, + boolean useAliasForFieldNames) { + return resolve( + typeAnnotationFqns, + rawTypeAnnotations, + useOptional, + useAliasForFieldNames, + Map.of(), + List.of(), + new String[0]); + } + + static TypeAnnotationConfig resolve( + List typeAnnotationFqns, + String[] rawTypeAnnotations, + boolean useOptional, + boolean useAliasForFieldNames, + Map fieldAnnotations) { + return resolve( + typeAnnotationFqns, + rawTypeAnnotations, + useOptional, + useAliasForFieldNames, + fieldAnnotations, + List.of(), + new String[0]); + } + + static TypeAnnotationConfig resolve( + List typeAnnotationFqns, + String[] rawTypeAnnotations, + boolean useOptional, + boolean useAliasForFieldNames, + Map fieldAnnotations, + List nonNullFqns, + String[] nonNullRawAnnotations) { + + var imports = new TreeSet(); + var annotations = resolveAnnotationList(typeAnnotationFqns, rawTypeAnnotations, imports); + + for (var fa : fieldAnnotations.values()) { + imports.addAll(fa.imports()); + } + + var nonNullResolved = resolveAnnotationList(nonNullFqns, nonNullRawAnnotations, imports); + + return new TypeAnnotationConfig( + imports, + annotations, + useOptional, + useAliasForFieldNames, + fieldAnnotations, + nonNullResolved); + } + + static List resolveAnnotationList( + List fqns, String[] rawAnnotations, Set imports) { + var rawSimpleNames = new HashSet(); + for (var raw : rawAnnotations) { + var stripped = raw.startsWith("@") ? raw.substring(1) : raw; + var parenIdx = stripped.indexOf('('); + rawSimpleNames.add(parenIdx > 0 ? stripped.substring(0, parenIdx).trim() : stripped.trim()); + } + + var annotations = new ArrayList(); + + for (var fqn : fqns) { + var simpleName = fqn.substring(fqn.lastIndexOf('.') + 1); + if (!fqn.startsWith("java.lang.")) { + imports.add(fqn); + } + if (!rawSimpleNames.contains(simpleName)) { + annotations.add("@" + simpleName); + } + } + + for (var raw : rawAnnotations) { + annotations.add(raw.startsWith("@") ? raw : "@" + raw); + } + + return annotations; + } + + record FieldAnnotations(Set imports, List annotations, String typeOverride) { + + static FieldAnnotations resolve( + List fqns, String[] rawAnnotations, String typeOverride) { + var imports = new TreeSet(); + var annotations = resolveAnnotationList(fqns, rawAnnotations, imports); + + if (typeOverride != null) { + var simpleName = typeOverride.substring(typeOverride.lastIndexOf('.') + 1); + if (!typeOverride.startsWith("java.lang.")) { + imports.add(typeOverride); + } + return new FieldAnnotations(imports, annotations, simpleName); + } + + return new FieldAnnotations(imports, annotations, null); + } + + static Map merge( + Map classLevel, Map methodLevel) { + if (methodLevel.isEmpty()) { + return classLevel; + } + if (classLevel.isEmpty()) { + return methodLevel; + } + var merged = new HashMap<>(classLevel); + merged.putAll(methodLevel); + return merged; + } + } +} diff --git a/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java b/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java new file mode 100644 index 000000000..05e3064ad --- /dev/null +++ b/graphql-apt/src/main/java/feign/graphql/apt/TypeGenerator.java @@ -0,0 +1,548 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql.apt; + +import com.squareup.javapoet.ClassName; +import com.squareup.javapoet.JavaFile; +import com.squareup.javapoet.ParameterizedTypeName; +import com.squareup.javapoet.TypeName; +import com.squareup.javapoet.TypeSpec; +import graphql.language.EnumTypeDefinition; +import graphql.language.Field; +import graphql.language.InputObjectTypeDefinition; +import graphql.language.ListType; +import graphql.language.NonNullType; +import graphql.language.ObjectTypeDefinition; +import graphql.language.SelectionSet; +import graphql.language.Type; +import graphql.schema.idl.TypeDefinitionRegistry; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import javax.annotation.processing.Filer; +import javax.annotation.processing.FilerException; +import javax.annotation.processing.Messager; +import javax.lang.model.element.Element; +import javax.lang.model.element.Modifier; +import javax.tools.Diagnostic; + +public class TypeGenerator { + + private final Filer filer; + private final Messager messager; + private final TypeDefinitionRegistry registry; + private final GraphqlTypeMapper typeMapper; + private final String targetPackage; + private final Set generatedTypes = new HashSet<>(); + private final Queue pendingTypes = new ArrayDeque<>(); + private final Map resultTypeSignatures = new HashMap<>(); + private TypeAnnotationConfig annotationConfig = TypeAnnotationConfig.EMPTY; + + public TypeGenerator( + Filer filer, + Messager messager, + TypeDefinitionRegistry registry, + GraphqlTypeMapper typeMapper, + String targetPackage) { + this.filer = filer; + this.messager = messager; + this.registry = registry; + this.typeMapper = typeMapper; + this.targetPackage = targetPackage; + } + + public void setAnnotationConfig(TypeAnnotationConfig config) { + this.annotationConfig = config; + } + + public void generateResultType( + String className, + SelectionSet selectionSet, + ObjectTypeDefinition parentType, + Element element) { + var signature = canonicalize(selectionSet); + var fields = describeFields(selectionSet); + var existing = resultTypeSignatures.get(className); + if (existing != null) { + if (!existing.signature.equals(signature)) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Conflicting return type '" + + className + + "': method selects [" + + fields + + "] but method '" + + existing.element.getSimpleName() + + "()' already selects [" + + existing.fields + + "]", + element); + messager.printMessage( + Diagnostic.Kind.ERROR, + "Conflicting return type '" + + className + + "': method selects [" + + existing.fields + + "] but method '" + + element.getSimpleName() + + "()' selects [" + + fields + + "]", + existing.element); + return; + } + return; + } + resultTypeSignatures.put(className, new ResultTypeUsage(signature, fields, element)); + + var tree = buildResultType(className, selectionSet, parentType, ""); + if (tree == null) { + return; + } + + writeResultRecord(tree, element); + processPendingTypes(element); + } + + private ResultTypeDefinition buildResultType( + String className, + SelectionSet selectionSet, + ObjectTypeDefinition parentType, + String pathPrefix) { + var fields = new ArrayList(); + var innerTypes = new ArrayList(); + + for (var selection : selectionSet.getSelections()) { + if (!(selection instanceof Field field)) { + continue; + } + var schemaFieldName = field.getName(); + var schemaDef = GraphqlTypeMapper.findFieldDefinition(parentType, schemaFieldName); + if (schemaDef == null) { + continue; + } + var fieldName = responseKey(field); + + var fieldType = schemaDef.getType(); + var rawTypeName = GraphqlTypeMapper.unwrapTypeName(fieldType); + + if (field.getSelectionSet() != null && !field.getSelectionSet().getSelections().isEmpty()) { + var nestedClassName = capitalize(fieldName); + var nestedPath = pathPrefix.isEmpty() ? fieldName : pathPrefix + "." + fieldName; + var nestedObjectType = + registry.getType(rawTypeName, ObjectTypeDefinition.class).orElse(null); + if (nestedObjectType != null) { + var innerTree = + buildResultType( + nestedClassName, field.getSelectionSet(), nestedObjectType, nestedPath); + if (innerTree != null) { + innerTypes.add(innerTree); + } + } + var nestedType = + wrapType(fieldType, ClassName.get("", nestedClassName), annotationConfig.useOptional()); + var fieldNonNull = fieldType instanceof NonNullType; + fields.add(toRecordField(fieldName, nestedType, fieldNonNull)); + } else { + var javaType = typeMapper.map(fieldType, annotationConfig.useOptional()); + var fieldNonNull = fieldType instanceof NonNullType; + fields.add(toRecordField(fieldName, javaType, fieldNonNull)); + enqueueIfNonScalar(rawTypeName); + } + } + + return new ResultTypeDefinition(className, pathPrefix, fields, innerTypes); + } + + private void writeResultRecord(ResultTypeDefinition tree, Element element) { + var fqn = targetPackage.isEmpty() ? tree.className : targetPackage + "." + tree.className; + try { + var sourceFile = filer.createSourceFile(fqn, element); + try (var out = new PrintWriter(sourceFile.openWriter())) { + if (!targetPackage.isEmpty()) { + out.println("package " + targetPackage + ";"); + out.println(); + } + + var imports = new TreeSet(); + collectAllImports(tree, imports); + imports.addAll(annotationConfig.imports()); + if (!imports.isEmpty()) { + for (var imp : imports) { + out.println("import " + imp + ";"); + } + out.println(); + } + + writeRecordBody(out, tree, ""); + } + } catch (FilerException e) { + // Type already generated by another interface in the same compilation round + } catch (IOException e) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Failed to write generated type " + tree.className + ": " + e.getMessage(), + element); + } + } + + private void writeRecordBody(PrintWriter out, ResultTypeDefinition tree, String indent) { + var pathPrefix = tree.fieldName; + var params = + tree.fields.stream() + .map(f -> formatFieldParam(f, pathPrefix)) + .collect(Collectors.joining(", ")); + + for (var annotation : annotationConfig.annotations()) { + out.println(indent + annotation); + } + + if (tree.innerTypes.isEmpty()) { + out.println(indent + "public record " + tree.className + "(" + params + ") {}"); + } else { + out.println(indent + "public record " + tree.className + "(" + params + ") {"); + out.println(); + for (var inner : tree.innerTypes) { + writeRecordBody(out, inner, indent + " "); + out.println(); + } + out.println(indent + "}"); + } + } + + private void collectAllImports(ResultTypeDefinition tree, Set imports) { + for (var field : tree.fields) { + if (field.typeName != null) { + collectImportsFromTypeName(field.typeName, imports); + } + } + for (var inner : tree.innerTypes) { + collectAllImports(inner, imports); + } + } + + private String responseKey(Field field) { + if (annotationConfig.useAliasForFieldNames() && field.getAlias() != null) { + return field.getAlias(); + } + return field.getName(); + } + + private String canonicalize(SelectionSet selectionSet) { + var entries = new ArrayList(); + for (var selection : selectionSet.getSelections()) { + if (!(selection instanceof Field field)) { + continue; + } + var name = responseKey(field); + if (field.getSelectionSet() != null && !field.getSelectionSet().getSelections().isEmpty()) { + entries.add(name + "{" + canonicalize(field.getSelectionSet()) + "}"); + } else { + entries.add(name); + } + } + entries.sort(String::compareTo); + return String.join(",", entries); + } + + private String describeFields(SelectionSet selectionSet) { + var entries = new ArrayList(); + for (var selection : selectionSet.getSelections()) { + if (!(selection instanceof Field field)) { + continue; + } + var name = responseKey(field); + if (field.getSelectionSet() != null && !field.getSelectionSet().getSelections().isEmpty()) { + entries.add(name + " { " + describeFields(field.getSelectionSet()) + " }"); + } else { + entries.add(name); + } + } + return String.join(", ", entries); + } + + public void generateInputType(String className, String graphqlTypeName, Element element) { + if (generatedTypes.contains(className)) { + return; + } + generatedTypes.add(className); + + var maybeDef = registry.getType(graphqlTypeName, InputObjectTypeDefinition.class); + if (maybeDef.isEmpty()) { + messager.printMessage( + Diagnostic.Kind.ERROR, "GraphQL input type not found: " + graphqlTypeName, element); + return; + } + + var inputDef = maybeDef.get(); + var fields = new ArrayList(); + + for (var valueDef : inputDef.getInputValueDefinitions()) { + var fieldName = valueDef.getName(); + var fieldType = valueDef.getType(); + var javaType = typeMapper.map(fieldType, annotationConfig.useOptional()); + var fieldNonNull = fieldType instanceof NonNullType; + fields.add(toRecordField(fieldName, javaType, fieldNonNull)); + + var rawTypeName = GraphqlTypeMapper.unwrapTypeName(fieldType); + enqueueIfNonScalar(rawTypeName); + } + + writeRecord(className, fields, element); + processPendingTypes(element); + } + + private void processPendingTypes(Element element) { + while (!pendingTypes.isEmpty()) { + var typeName = pendingTypes.poll(); + if (generatedTypes.contains(typeName)) { + continue; + } + + var enumDef = registry.getType(typeName, EnumTypeDefinition.class); + if (enumDef.isPresent()) { + generateEnum(typeName, enumDef.get(), element); + continue; + } + + var inputDef = registry.getType(typeName, InputObjectTypeDefinition.class); + if (inputDef.isPresent()) { + generateInputType(typeName, typeName, element); + continue; + } + + var objectDef = registry.getType(typeName, ObjectTypeDefinition.class); + if (objectDef.isPresent()) { + generateFullObjectType(typeName, objectDef.get(), element); + } + } + } + + private void generateEnum(String className, EnumTypeDefinition enumDef, Element element) { + if (generatedTypes.contains(className)) { + return; + } + generatedTypes.add(className); + + var enumBuilder = TypeSpec.enumBuilder(className).addModifiers(Modifier.PUBLIC); + + for (var value : enumDef.getEnumValueDefinitions()) { + enumBuilder.addEnumConstant(value.getName()); + } + + writeType(enumBuilder.build(), element); + } + + private void generateFullObjectType( + String className, ObjectTypeDefinition objectDef, Element element) { + if (generatedTypes.contains(className)) { + return; + } + generatedTypes.add(className); + + var fields = new ArrayList(); + + for (var fieldDef : objectDef.getFieldDefinitions()) { + var fieldName = fieldDef.getName(); + var fieldType = fieldDef.getType(); + var javaType = typeMapper.map(fieldType, annotationConfig.useOptional()); + var fieldNonNull = fieldType instanceof NonNullType; + fields.add(toRecordField(fieldName, javaType, fieldNonNull)); + + var rawTypeName = GraphqlTypeMapper.unwrapTypeName(fieldDef.getType()); + enqueueIfNonScalar(rawTypeName); + } + + writeRecord(className, fields, element); + processPendingTypes(element); + } + + private void enqueueIfNonScalar(String typeName) { + if (!typeMapper.isScalar(typeName) && !generatedTypes.contains(typeName)) { + pendingTypes.add(typeName); + } + } + + private TypeName wrapType(Type schemaType, TypeName innerType, boolean useOptional) { + boolean nullable = !(schemaType instanceof NonNullType); + var wrapped = wrapTypeInner(schemaType, innerType); + if (useOptional && nullable) { + return ParameterizedTypeName.get(ClassName.get(Optional.class), wrapped); + } + return wrapped; + } + + private TypeName wrapTypeInner(Type schemaType, TypeName innerType) { + if (schemaType instanceof NonNullType type) { + return wrapTypeInner(type.getType(), innerType); + } + if (schemaType instanceof ListType type) { + return ParameterizedTypeName.get( + ClassName.get(List.class), wrapTypeInner(type.getType(), innerType)); + } + return innerType; + } + + private String capitalize(String s) { + if (s == null || s.isEmpty()) { + return s; + } + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + private void writeType(TypeSpec typeSpec, Element element) { + try { + var javaFile = JavaFile.builder(targetPackage, typeSpec).build(); + javaFile.writeTo(filer); + } catch (FilerException e) { + // Type already generated by another interface in the same compilation round + } catch (IOException e) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Failed to write generated type " + typeSpec.name + ": " + e.getMessage(), + element); + } + } + + private String formatFieldParam(RecordField f, String pathPrefix) { + var fieldPath = pathPrefix.isEmpty() ? f.name : pathPrefix + "." + f.name; + var fa = annotationConfig.fieldAnnotations().get(fieldPath); + var hasNonNull = f.nonNull && !annotationConfig.nonNullAnnotations().isEmpty(); + + var typeStr = fa != null && fa.typeOverride() != null ? fa.typeOverride() : f.typeString; + + if (!hasNonNull && (fa == null || fa.annotations().isEmpty())) { + return typeStr + " " + f.name; + } + + var fieldAnns = new ArrayList(); + if (hasNonNull) { + fieldAnns.addAll(annotationConfig.nonNullAnnotations()); + } + if (fa != null) { + fieldAnns.addAll(fa.annotations()); + } + return String.join(" ", fieldAnns) + " " + typeStr + " " + f.name; + } + + private RecordField toRecordField(String name, TypeName typeName, boolean nonNull) { + var typeString = typeNameToString(typeName); + return new RecordField(typeString, name, typeName, nonNull); + } + + private String typeNameToString(TypeName typeName) { + if (typeName instanceof ParameterizedTypeName parameterized) { + var raw = parameterized.rawType.simpleName(); + var typeArgs = + parameterized.typeArguments.stream() + .map(this::typeNameToString) + .collect(Collectors.joining(", ")); + return raw + "<" + typeArgs + ">"; + } + if (typeName instanceof ClassName name) { + return name.simpleName(); + } + return typeName.toString(); + } + + private String fqnIfNeeded(ClassName className) { + var pkg = className.packageName(); + if (pkg.equals("java.lang") || pkg.equals(targetPackage) || pkg.isEmpty()) { + return null; + } + return pkg + "." + className.simpleName(); + } + + private Set collectImports(List fields) { + var imports = new TreeSet(); + for (var field : fields) { + collectImportsFromTypeName(field.typeName, imports); + } + return imports; + } + + private void collectImportsFromTypeName(TypeName typeName, Set imports) { + if (typeName instanceof ParameterizedTypeName parameterized) { + collectImportsFromTypeName(parameterized.rawType, imports); + for (var typeArg : parameterized.typeArguments) { + collectImportsFromTypeName(typeArg, imports); + } + } else if (typeName instanceof ClassName name) { + var fqn = fqnIfNeeded(name); + if (fqn != null) { + imports.add(fqn); + } + } + } + + private void writeRecord(String className, List fields, Element element) { + var fqn = targetPackage.isEmpty() ? className : targetPackage + "." + className; + try { + var sourceFile = filer.createSourceFile(fqn, element); + try (var out = new PrintWriter(sourceFile.openWriter())) { + if (!targetPackage.isEmpty()) { + out.println("package " + targetPackage + ";"); + out.println(); + } + + var imports = collectImports(fields); + imports.addAll(annotationConfig.imports()); + if (!imports.isEmpty()) { + for (var imp : imports) { + out.println("import " + imp + ";"); + } + out.println(); + } + + for (var annotation : annotationConfig.annotations()) { + out.println(annotation); + } + + var params = + fields.stream().map(f -> formatFieldParam(f, "")).collect(Collectors.joining(", ")); + + out.println("public record " + className + "(" + params + ") {}"); + } + } catch (FilerException e) { + // Type already generated by another interface in the same compilation round + } catch (IOException e) { + messager.printMessage( + Diagnostic.Kind.ERROR, + "Failed to write generated type " + className + ": " + e.getMessage(), + element); + } + } + + record ResultTypeUsage(String signature, String fields, Element element) {} + + record ResultTypeDefinition( + String className, + String fieldName, + List fields, + List innerTypes) {} + + record RecordField(String typeString, String name, TypeName typeName, boolean nonNull) {} +} diff --git a/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java new file mode 100644 index 000000000..f60c6e22b --- /dev/null +++ b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java @@ -0,0 +1,1916 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql.apt; + +import static com.google.testing.compile.CompilationSubject.assertThat; +import static com.google.testing.compile.Compiler.javac; + +import com.google.testing.compile.JavaFileObjects; +import org.junit.jupiter.api.Test; + +class GraphqlSchemaProcessorTest { + + @Test + void validMutationGeneratesTypes() { + var source = + JavaFileObjects.forSourceString( + "test.MyApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface MyApi { + @GraphqlQuery(\""" + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id name email appearsIn } + }\""") + CreateCharacterResult createCharacter(CreateCharacterInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CreateCharacterResult"); + assertThat(compilation).generatedSourceFile("test.CreateCharacterInput"); + } + + @Test + void invalidQueryReportsError() { + var source = + JavaFileObjects.forSourceString( + "test.BadApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface BadApi { + @GraphqlQuery("{ nonExistentField }") + BadResult query(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("GraphQL validation error"); + } + + @Test + void missingSchemaReportsError() { + var source = + JavaFileObjects.forSourceString( + "test.NoSchemaApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("nonexistent-schema.graphql") + interface NoSchemaApi { + @GraphqlQuery("{ character(id: \\"1\\") { id } }") + Object query(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("GraphQL schema not found"); + } + + @Test + void nestedTypesAreGeneratedAsInnerRecords() { + var source = + JavaFileObjects.forSourceString( + "test.NestedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface NestedApi { + @GraphqlQuery(\""" + { character(id: "1") { id name location { planet sector region } } } + \""") + CharacterResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CharacterResult"); + assertThat(compilation) + .generatedSourceFile("test.CharacterResult") + .contentsAsUtf8String() + .contains( + "public record Location(Optional planet, Optional sector, Optional region) {}"); + } + + @Test + void enumsAreGeneratedAsJavaEnums() { + var source = + JavaFileObjects.forSourceString( + "test.EnumApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface EnumApi { + @GraphqlQuery(\""" + mutation updateEpisode($id: ID!, $episode: Episode!) { + updateEpisode(id: $id, episode: $episode) { id appearsIn } + }\""") + EpisodeResult updateEpisode(String id, Episode episode); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.EpisodeResult"); + assertThat(compilation).generatedSourceFile("test.Episode"); + } + + @Test + void listTypesMapToJavaList() { + var source = + JavaFileObjects.forSourceString( + "test.ListApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface ListApi { + @GraphqlQuery("{ character(id: \\"1\\") { id name tags } }") + CharacterWithTagsResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CharacterWithTagsResult"); + } + + @Test + void multipleMethodsSharingInputType() { + var source = + JavaFileObjects.forSourceString( + "test.SharedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface SharedApi { + @GraphqlQuery(\""" + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id name } + }\""") + CreateResult1 createCharacter1(CreateCharacterInput input); + + @GraphqlQuery(\""" + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id email } + }\""") + CreateResult2 createCharacter2(CreateCharacterInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CreateCharacterInput"); + assertThat(compilation).generatedSourceFile("test.CreateResult1"); + assertThat(compilation).generatedSourceFile("test.CreateResult2"); + } + + @Test + void deeplyNestedStarshipQuery() { + var source = + JavaFileObjects.forSourceString( + "test.DeepApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface DeepApi { + @GraphqlQuery(\""" + { + starship(id: "1") { + id name + location { planet sector coordinates { latitude longitude } } + squadrons { + id name + leader { id name appearsIn } + members { id name email } + subSquadrons { id name traits { key value } } + traits { key value } + } + specs { lengthMeters classification weapons { name traits { key value } } } + } + }\""") + StarshipResult getStarship(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.StarshipResult"); + assertThat(compilation) + .generatedSourceFile("test.StarshipResult") + .contentsAsUtf8String() + .contains("public record Location("); + assertThat(compilation) + .generatedSourceFile("test.StarshipResult") + .contentsAsUtf8String() + .contains("public record Squadrons("); + assertThat(compilation) + .generatedSourceFile("test.StarshipResult") + .contentsAsUtf8String() + .contains("public record Specs("); + } + + @Test + void complexMutationWithNestedInputs() { + var source = + JavaFileObjects.forSourceString( + "test.ComplexMutationApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface ComplexMutationApi { + @GraphqlQuery(\""" + mutation createStarship($input: CreateStarshipInput!) { + createStarship(input: $input) { + id name + squadrons { id name subSquadrons { id name } } + } + }\""") + CreateStarshipResult createStarship(CreateStarshipInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CreateStarshipResult"); + assertThat(compilation).generatedSourceFile("test.CreateStarshipInput"); + assertThat(compilation).generatedSourceFile("test.SquadronInput"); + assertThat(compilation).generatedSourceFile("test.TraitInput"); + assertThat(compilation).generatedSourceFile("test.LocationInput"); + assertThat(compilation).generatedSourceFile("test.ShipSpecsInput"); + assertThat(compilation).generatedSourceFile("test.WeaponInput"); + } + + @Test + void searchWithComplexFilterInput() { + var source = + JavaFileObjects.forSourceString( + "test.SearchApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface SearchApi { + @GraphqlQuery(\""" + query searchStarships($criteria: StarshipSearchCriteria!) { + searchStarships(criteria: $criteria) { + id name + squadrons { id name leader { id name } traits { key value } } + specs { lengthMeters weapons { name parentWeapon { name } } } + } + }\""") + SearchResult searchStarships(StarshipSearchCriteria criteria); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.SearchResult"); + assertThat(compilation).generatedSourceFile("test.StarshipSearchCriteria"); + assertThat(compilation).generatedSourceFile("test.SquadronFilterInput"); + assertThat(compilation).generatedSourceFile("test.TraitInput"); + } + + @Test + void listReturnTypeGeneratesElementType() { + var source = + JavaFileObjects.forSourceString( + "test.ListReturnApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import java.util.List; + + @GraphqlSchema("test-schema.graphql") + interface ListReturnApi { + @GraphqlQuery(\""" + query listCharacters($filter: CharacterFilter) { + characters(filter: $filter) { id name email appearsIn } + }\""") + List listCharacters(CharacterFilter filter); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CharacterListResult"); + assertThat(compilation).generatedSourceFile("test.CharacterFilter"); + } + + @Test + void existingExternalTypeSkipsGeneration() { + var source = + JavaFileObjects.forSourceString( + "test.ExternalTypeApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface ExternalTypeApi { + @GraphqlQuery(\""" + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id name email } + }\""") + CreateResult createCharacter(feign.graphql.GraphqlQuery input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CreateResult"); + } + + @Test + void characterWithStarshipMultipleLevelReuse() { + var source = + JavaFileObjects.forSourceString( + "test.ReuseApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface ReuseApi { + @GraphqlQuery(\""" + { + character(id: "1") { + id name appearsIn + location { planet sector region coordinates { latitude longitude } } + starship { + id name + location { planet sector coordinates { latitude longitude } } + squadrons { id name members { id name } } + } + } + }\""") + FullCharacterResult getFullCharacter(); + + @GraphqlQuery(\""" + query listCharacters($filter: CharacterFilter) { + characters(filter: $filter) { id name email tags } + }\""") + CharacterListResult listCharacters(CharacterFilter filter); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.FullCharacterResult"); + assertThat(compilation).generatedSourceFile("test.Episode"); + } + + @Test + void scalarAnnotationMapsCustomScalar() { + var source = + JavaFileObjects.forSourceString( + "test.ScalarApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.Scalar; + + @GraphqlSchema("scalar-test-schema.graphql") + interface ScalarApi { + @Scalar("DateTime") + default String dateTime(String raw) { return raw; } + + @GraphqlQuery("{ battle(id: \\"1\\") { id name startTime endTime } }") + BattleResult getBattle(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.BattleResult"); + } + + @Test + void missingScalarAnnotationReportsError() { + var source = + JavaFileObjects.forSourceString( + "test.MissingScalarApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("scalar-test-schema.graphql") + interface MissingScalarApi { + @GraphqlQuery("{ battle(id: \\"1\\") { id name startTime } }") + BattleResult getBattle(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("Custom scalar 'DateTime'"); + assertThat(compilation).hadErrorContaining("@Scalar(\"DateTime\")"); + } + + @Test + void scalarFromParentInterfaceIsInherited() { + var parentSource = + JavaFileObjects.forSourceString( + "test.ScalarDefinitions", + """ + package test; + + import feign.graphql.Scalar; + + interface ScalarDefinitions { + @Scalar("DateTime") + default String dateTime(String raw) { return raw; } + } + """); + + var source = + JavaFileObjects.forSourceString( + "test.ChildApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("scalar-test-schema.graphql") + interface ChildApi extends ScalarDefinitions { + @GraphqlQuery("{ battle(id: \\"1\\") { id name startTime } }") + BattleResult getBattle(); + } + """); + + var compilation = + javac().withProcessors(new GraphqlSchemaProcessor()).compile(parentSource, source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.BattleResult"); + } + + @Test + void conflictingReturnTypesReportsError() { + var source = + JavaFileObjects.forSourceString( + "test.ConflictApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface ConflictApi { + @GraphqlQuery(\""" + { character(id: "1") { id name } } + \""") + CharResult query1(); + + @GraphqlQuery(\""" + { character(id: "2") { id email } } + \""") + CharResult query2(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("Conflicting return type 'CharResult'"); + assertThat(compilation).hadErrorContaining("'query1()'"); + assertThat(compilation).hadErrorContaining("'query2()'"); + assertThat(compilation).hadErrorContaining("id, name"); + assertThat(compilation).hadErrorContaining("id, email"); + } + + @Test + void sameReturnTypeSameFieldsSucceeds() { + var source = + JavaFileObjects.forSourceString( + "test.SameFieldsApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface SameFieldsApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + CharResult query1(); + + @GraphqlQuery(\""" + { character(id: "2") { id name email } } + \""") + CharResult query2(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CharResult"); + } + + @Test + void innerClassContentIsCorrect() { + var source = + JavaFileObjects.forSourceString( + "test.InnerApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface InnerApi { + @GraphqlQuery(\""" + { + starship(id: "1") { + id name + location { planet coordinates { latitude longitude } } + specs { lengthMeters classification } + } + }\""") + ShipResult getShip(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.ShipResult").contentsAsUtf8String(); + + contents.contains( + "public record ShipResult(String id, String name, Optional location, Optional specs)"); + contents.contains( + "public record Location(Optional planet, Optional coordinates)"); + contents.contains( + "public record Coordinates(Optional latitude, Optional longitude) {}"); + contents.contains( + "public record Specs(Optional lengthMeters, Optional classification) {}"); + } + + @Test + void differentQueriesDifferentNestedFields() { + var source = + JavaFileObjects.forSourceString( + "test.DiffNestedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface DiffNestedApi { + @GraphqlQuery(\""" + { character(id: "1") { id location { planet } } } + \""") + CharByPlanet queryByPlanet(); + + @GraphqlQuery(\""" + { character(id: "2") { id location { sector region } } } + \""") + CharByRegion queryByRegion(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + assertThat(compilation).generatedSourceFile("test.CharByPlanet"); + assertThat(compilation).generatedSourceFile("test.CharByRegion"); + + assertThat(compilation) + .generatedSourceFile("test.CharByPlanet") + .contentsAsUtf8String() + .contains("public record Location(Optional planet) {}"); + + assertThat(compilation) + .generatedSourceFile("test.CharByRegion") + .contentsAsUtf8String() + .contains("public record Location(Optional sector, Optional region) {}"); + } + + @Test + void missingRequiredVariableReportsError() { + var source = + JavaFileObjects.forSourceString( + "test.MissingVarApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface MissingVarApi { + @GraphqlQuery("query getChar($id: ID!) { character(id: $id) { id name } }") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("Required GraphQL variable '$id'"); + } + + @Test + void requiredVariableWithDefaultValueIsOptional() { + var source = + JavaFileObjects.forSourceString( + "test.DefaultVarApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface DefaultVarApi { + @GraphqlQuery("query getChar($id: ID! = \\"1\\") { character(id: $id) { id name } }") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + } + + @Test + void nullableVariableIsOptional() { + var source = + JavaFileObjects.forSourceString( + "test.NullableVarApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface NullableVarApi { + @GraphqlQuery(\""" + query listChars($filter: CharacterFilter) { + characters(filter: $filter) { id name } + }\""") + CharResult listCharacters(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + } + + @Test + void paramAnnotationNotCountedAsVariable() { + var source = + JavaFileObjects.forSourceString( + "test.ParamApi", + """ + package test; + + import feign.Param; + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface ParamApi { + @GraphqlQuery("query getChar($id: ID!) { character(id: $id) { id name } }") + CharResult getCharacter(@Param("auth") String token); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("Required GraphQL variable '$id'"); + } + + @Test + void requiredVariableWithMatchingParameterSucceeds() { + var source = + JavaFileObjects.forSourceString( + "test.MatchApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface MatchApi { + @GraphqlQuery("query getChar($id: ID!) { character(id: $id) { id name } }") + CharResult getCharacter(String id); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + } + + @Test + void multipleRequiredVariablesMissing() { + var source = + JavaFileObjects.forSourceString( + "test.MultiMissingApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface MultiMissingApi { + @GraphqlQuery(\""" + mutation updateEpisode($id: ID!, $episode: Episode!) { + updateEpisode(id: $id, episode: $episode) { id appearsIn } + }\""") + EpisodeResult updateEpisode(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("Required GraphQL variable '$id'"); + assertThat(compilation).hadErrorContaining("Required GraphQL variable '$episode'"); + } + + @Test + void inlineInputMissingRequiredFieldReportsError() { + var source = + JavaFileObjects.forSourceString( + "test.InlineApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface InlineApi { + @GraphqlQuery(\""" + mutation create($name: String!) { + createCharacter(input: { name: $name }) { id } + }\""") + Object create(String name); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("email"); + } + + @Test + void useOptionalDisabledGeneratesPlainTypes() { + var source = + JavaFileObjects.forSourceString( + "test.NoOptionalApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface NoOptionalApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email location { planet } } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains( + "public record CharResult(String id, String name, String email, Location location)"); + contents.contains("public record Location(String planet) {}"); + } + + @Test + void useOptionalDefaultWrapsNullableFields() { + var source = + JavaFileObjects.forSourceString( + "test.OptionalApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface OptionalApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email location { planet } } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("import java.util.Optional;"); + contents.contains( + "String id, String name, Optional email, Optional location"); + contents.contains("public record Location(Optional planet) {}"); + } + + @Test + void useOptionalMethodOverridesClassLevel() { + var source = + JavaFileObjects.forSourceString( + "test.OverrideApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.Toggle; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface OverrideApi { + @GraphqlQuery(value = \""" + { character(id: "1") { id name email } } + \""", useOptional = Toggle.TRUE) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("import java.util.Optional;"); + contents.contains("String id, String name, Optional email"); + } + + @Test + void typeAnnotationsAddedToGeneratedRecords() { + var source = + JavaFileObjects.forSourceString( + "test.AnnotatedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + typeAnnotations = {Deprecated.class}) + interface AnnotatedApi { + @GraphqlQuery(\""" + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id name } + }\""") + CreateResult createCharacter(CreateCharacterInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CreateResult") + .contentsAsUtf8String() + .contains("@Deprecated"); + assertThat(compilation) + .generatedSourceFile("test.CreateCharacterInput") + .contentsAsUtf8String() + .contains("@Deprecated"); + } + + @Test + void rawTypeAnnotationsAppendedToGeneratedRecords() { + var source = + JavaFileObjects.forSourceString( + "test.RawAnnotatedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + rawTypeAnnotations = {"@Deprecated"}) + interface RawAnnotatedApi { + @GraphqlQuery(\""" + { character(id: "1") { id name } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("@Deprecated"); + } + + @Test + void collisionBetweenTypeAndRawAnnotationUsesClassAsImportOnly() { + var source = + JavaFileObjects.forSourceString( + "test.CollisionApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + typeAnnotations = {Deprecated.class}, + rawTypeAnnotations = {"@Deprecated(since = \\"1.0\\")"}) + interface CollisionApi { + @GraphqlQuery(\""" + { character(id: "1") { id name } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated(since = \"1.0\")"); + } + + @Test + void methodLevelAnnotationsOverrideClassLevel() { + var source = + JavaFileObjects.forSourceString( + "test.MethodOverrideApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + typeAnnotations = {Deprecated.class}) + interface MethodOverrideApi { + @GraphqlQuery(value = \""" + { character(id: "1") { id name } } + \""", rawTypeAnnotations = {"@SuppressWarnings(\\"unchecked\\")"}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@SuppressWarnings(\"unchecked\")"); + } + + @Test + void optionalOnInputTypeWrapsNullableFields() { + var source = + JavaFileObjects.forSourceString( + "test.OptionalInputApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface OptionalInputApi { + @GraphqlQuery(\""" + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id } + }\""") + Object createCharacter(CreateCharacterInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation) + .generatedSourceFile("test.CreateCharacterInput") + .contentsAsUtf8String(); + contents.contains("String name, String email"); + contents.contains("Optional appearsIn"); + contents.contains("Optional location"); + contents.contains("Optional> tags"); + contents.contains("Optional starshipId"); + } + + @Test + void mixedTypeAndRawAnnotationsWithoutCollision() { + var source = + JavaFileObjects.forSourceString( + "test.MixedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + typeAnnotations = {Deprecated.class}, + rawTypeAnnotations = {"@SuppressWarnings(\\"unchecked\\")"}) + interface MixedApi { + @GraphqlQuery(\""" + { character(id: "1") { id name } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated"); + contents.contains("@SuppressWarnings(\"unchecked\")"); + } + + @Test + void annotationsAppliedToNestedResultRecords() { + var source = + JavaFileObjects.forSourceString( + "test.NestedAnnotatedApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + typeAnnotations = {Deprecated.class}) + interface NestedAnnotatedApi { + @GraphqlQuery(\""" + { + starship(id: "1") { + id name + location { planet coordinates { latitude longitude } } + } + }\""") + ShipResult getShip(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.ShipResult").contentsAsUtf8String(); + contents.contains("@Deprecated\npublic record ShipResult("); + contents.contains("@Deprecated\n public record Location("); + contents.contains("@Deprecated\n public record Coordinates("); + } + + @Test + void fieldAnnotationOnSimpleField() { + var source = + JavaFileObjects.forSourceString( + "test.FieldAnnotApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface FieldAnnotApi { + @GraphqlQuery(\""" + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id name email } + }\""") + @GraphqlField(name = "email", typeAnnotations = {Deprecated.class}) + CreateResult createCharacter(CreateCharacterInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CreateResult") + .contentsAsUtf8String() + .contains("String id, String name, @Deprecated String email"); + } + + @Test + void fieldAnnotationWithRawString() { + var source = + JavaFileObjects.forSourceString( + "test.FieldRawApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface FieldRawApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "name", rawTypeAnnotations = {"@SuppressWarnings(\\"unchecked\\")"}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("@SuppressWarnings(\"unchecked\") String name"); + } + + @Test + void fieldAnnotationWithDotNotationForNestedField() { + var source = + JavaFileObjects.forSourceString( + "test.DotNotationApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface DotNotationApi { + @GraphqlQuery(\""" + { + character(id: "1") { + id name + location { planet sector } + } + }\""") + @GraphqlField(name = "location.planet", typeAnnotations = {Deprecated.class}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("@Deprecated String planet, String sector"); + } + + @Test + void fieldAnnotationCollisionUsesClassAsImportOnly() { + var source = + JavaFileObjects.forSourceString( + "test.FieldCollisionApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface FieldCollisionApi { + @GraphqlQuery(\""" + { character(id: "1") { id name } } + \""") + @GraphqlField(name = "name", + typeAnnotations = {Deprecated.class}, + rawTypeAnnotations = {"@Deprecated(since = \\"2.0\\")"}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("@Deprecated(since = \"2.0\") String name"); + } + + @Test + void multipleFieldAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.MultiFieldApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface MultiFieldApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "name", typeAnnotations = {Deprecated.class}) + @GraphqlField(name = "email", typeAnnotations = {Deprecated.class}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated String name"); + contents.contains("@Deprecated String email"); + } + + @Test + void classLevelTypeAnnotationsWithMethodLevelFieldAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.ClassAndFieldApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + typeAnnotations = {Deprecated.class}) + interface ClassAndFieldApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "email", rawTypeAnnotations = {"@SuppressWarnings(\\"unchecked\\")"}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated\npublic record CharResult("); + contents.contains("@SuppressWarnings(\"unchecked\") String email"); + } + + @Test + void deepNestedDotNotation() { + var source = + JavaFileObjects.forSourceString( + "test.DeepDotApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface DeepDotApi { + @GraphqlQuery(\""" + { + starship(id: "1") { + id name + location { planet coordinates { latitude longitude } } + } + }\""") + @GraphqlField(name = "location.coordinates.latitude", typeAnnotations = {Deprecated.class}) + ShipResult getShip(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.ShipResult") + .contentsAsUtf8String() + .contains("@Deprecated Double latitude, Double longitude"); + } + + @Test + void usesAddsImportsForRawTypeAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.UsesApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + uses = {Deprecated.class}, + rawTypeAnnotations = {"@Deprecated(since = \\"1.0\\")"}) + interface UsesApi { + @GraphqlQuery(\""" + { character(id: "1") { id name } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("@Deprecated(since = \"1.0\")"); + } + + @Test + void usesAddsImportsForRawFieldAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.UsesFieldApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + uses = {Deprecated.class}) + interface UsesFieldApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "email", rawTypeAnnotations = {"@Deprecated(since = \\"2.0\\")"}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated(since = \"2.0\") String email"); + } + + @Test + void fieldTypeOverride() { + var source = + JavaFileObjects.forSourceString( + "test.TypeOverrideApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + import java.time.ZonedDateTime; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface TypeOverrideApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "email", type = ZonedDateTime.class) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("import java.time.ZonedDateTime;"); + contents.contains("String id, String name, ZonedDateTime email"); + } + + @Test + void fieldTypeOverrideWithAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.TypeOverrideAnnotApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + import java.time.ZonedDateTime; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface TypeOverrideAnnotApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "email", type = ZonedDateTime.class, typeAnnotations = {Deprecated.class}) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("@Deprecated ZonedDateTime email"); + } + + @Test + void fieldTypeOverrideOnNestedField() { + var source = + JavaFileObjects.forSourceString( + "test.NestedTypeOverrideApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + import java.math.BigDecimal; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + interface NestedTypeOverrideApi { + @GraphqlQuery(\""" + { + character(id: "1") { + id name + location { planet coordinates { latitude longitude } } + } + }\""") + @GraphqlField(name = "location.coordinates.latitude", type = BigDecimal.class) + @GraphqlField(name = "location.coordinates.longitude", type = BigDecimal.class) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("import java.math.BigDecimal;"); + contents.contains("BigDecimal latitude, BigDecimal longitude"); + } + + @Test + void classLevelFieldTypeOverrideAppliesToAllMethods() { + var source = + JavaFileObjects.forSourceString( + "test.ClassFieldOverrideApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + import java.time.ZonedDateTime; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + @GraphqlField(name = "email", type = ZonedDateTime.class) + interface ClassFieldOverrideApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + CharResult1 getCharacter1(); + + @GraphqlQuery(\""" + { character(id: "2") { id email } } + \""") + CharResult2 getCharacter2(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult1") + .contentsAsUtf8String() + .contains("ZonedDateTime email"); + assertThat(compilation) + .generatedSourceFile("test.CharResult2") + .contentsAsUtf8String() + .contains("ZonedDateTime email"); + } + + @Test + void methodLevelFieldOverridesClassLevel() { + var source = + JavaFileObjects.forSourceString( + "test.MethodOverridesClassFieldApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + import java.time.ZonedDateTime; + import java.time.Instant; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false) + @GraphqlField(name = "email", type = ZonedDateTime.class) + interface MethodOverridesClassFieldApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "email", type = Instant.class) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.CharResult") + .contentsAsUtf8String() + .contains("Instant email"); + } + + @Test + void nonNullAnnotationsAppliedToRequiredFields() { + var source = + JavaFileObjects.forSourceString( + "test.NonNullApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + nonNullTypeAnnotations = {Deprecated.class}) + interface NonNullApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated String id, @Deprecated String name, String email"); + } + + @Test + void nonNullAnnotationsOnInputType() { + var source = + JavaFileObjects.forSourceString( + "test.NonNullInputApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + nonNullTypeAnnotations = {Deprecated.class}) + interface NonNullInputApi { + @GraphqlQuery(\""" + mutation createCharacter($input: CreateCharacterInput!) { + createCharacter(input: $input) { id } + }\""") + Object createCharacter(CreateCharacterInput input); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation) + .generatedSourceFile("test.CreateCharacterInput") + .contentsAsUtf8String(); + contents.contains("@Deprecated String name, @Deprecated String email"); + contents.contains("Episode appearsIn"); + } + + @Test + void nonNullRawAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.NonNullRawApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + nonNullRawTypeAnnotations = {"@SuppressWarnings(\\"required\\")"}) + interface NonNullRawApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@SuppressWarnings(\"required\") String id"); + contents.contains("String email"); + } + + @Test + void nonNullAnnotationsCombinedWithFieldAnnotations() { + var source = + JavaFileObjects.forSourceString( + "test.NonNullFieldComboApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.GraphqlField; + import java.time.ZonedDateTime; + + @GraphqlSchema(value = "test-schema.graphql", useOptional = false, + nonNullTypeAnnotations = {Deprecated.class}) + interface NonNullFieldComboApi { + @GraphqlQuery(\""" + { character(id: "1") { id name email } } + \""") + @GraphqlField(name = "name", type = ZonedDateTime.class) + CharResult getCharacter(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.CharResult").contentsAsUtf8String(); + contents.contains("@Deprecated ZonedDateTime name"); + contents.contains("@Deprecated String id"); + contents.contains("String email"); + } + + @Test + void useAliasForFieldNamesGeneratesAliasedInnerRecords() { + var source = + JavaFileObjects.forSourceString( + "test.AliasApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useAliasForFieldNames = true) + interface AliasApi { + @GraphqlQuery(\""" + { + starship(id: "1") { + id name + specification: specs { lengthMeters classification } + currentLocation: location { planet sector } + } + }\""") + StarshipResult getStarship(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.StarshipResult").contentsAsUtf8String(); + contents.contains("Optional specification"); + contents.contains("Optional currentLocation"); + contents.contains("public record Specification("); + contents.contains("public record CurrentLocation("); + } + + @Test + void useAliasForFieldNamesDisabledUsesFieldName() { + var source = + JavaFileObjects.forSourceString( + "test.NoAliasApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema(value = "test-schema.graphql", useAliasForFieldNames = false) + interface NoAliasApi { + @GraphqlQuery(\""" + { + starship(id: "1") { + id name + specification: specs { lengthMeters classification } + currentLocation: location { planet sector } + } + }\""") + StarshipResult getStarship(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.StarshipResult").contentsAsUtf8String(); + contents.contains("Optional specs"); + contents.contains("Optional location"); + contents.contains("public record Specs("); + contents.contains("public record Location("); + } + + @Test + void useAliasForFieldNamesMethodOverridesClassLevel() { + var source = + JavaFileObjects.forSourceString( + "test.AliasOverrideApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + import feign.graphql.Toggle; + + @GraphqlSchema(value = "test-schema.graphql", useAliasForFieldNames = false) + interface AliasOverrideApi { + @GraphqlQuery(value = \""" + { + starship(id: "1") { + id name + specification: specs { lengthMeters classification } + } + }\""", useAliasForFieldNames = Toggle.TRUE) + AliasedResult getAliased(); + + @GraphqlQuery(\""" + { + starship(id: "1") { + id name + specification: specs { lengthMeters classification } + } + }\""") + PlainResult getPlain(); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var aliased = + assertThat(compilation).generatedSourceFile("test.AliasedResult").contentsAsUtf8String(); + aliased.contains("Optional specification"); + aliased.contains("public record Specification("); + + var plain = + assertThat(compilation).generatedSourceFile("test.PlainResult").contentsAsUtf8String(); + plain.contains("Optional specs"); + plain.contains("public record Specs("); + } +} diff --git a/graphql-apt/src/test/resources/scalar-test-schema.graphql b/graphql-apt/src/test/resources/scalar-test-schema.graphql new file mode 100644 index 000000000..aca3bccec --- /dev/null +++ b/graphql-apt/src/test/resources/scalar-test-schema.graphql @@ -0,0 +1,14 @@ +"An RFC-3339 compliant DateTime Scalar" +scalar DateTime + +type Query { + battle(id: ID!): Battle + battles: [Battle] +} + +type Battle { + id: ID! + name: String! + startTime: DateTime! + endTime: DateTime +} diff --git a/graphql-apt/src/test/resources/test-schema.graphql b/graphql-apt/src/test/resources/test-schema.graphql new file mode 100644 index 000000000..0a4ec7680 --- /dev/null +++ b/graphql-apt/src/test/resources/test-schema.graphql @@ -0,0 +1,154 @@ +# Star Wars schema based on https://graphql.org/learn/schema/ +# Adapted for feign-graphql-apt test purposes + +type Query { + character(id: ID!): Character + characters(filter: CharacterFilter): [Character] + starship(id: ID!): Starship + searchStarships(criteria: StarshipSearchCriteria!): [Starship] +} + +type Mutation { + createCharacter(input: CreateCharacterInput!): Character + updateEpisode(id: ID!, episode: Episode!): Character + createStarship(input: CreateStarshipInput!): Starship +} + +type Character { + id: ID! + name: String! + email: String + appearsIn: Episode! + location: Location + tags: [String] + friends: [Character] + starship: Starship +} + +type Starship { + id: ID! + name: String! + location: Location + squadrons: [Squadron] + specs: ShipSpecs +} + +type Squadron { + id: ID! + name: String! + leader: Character + members: [Character] + subSquadrons: [Squadron] + traits: [Trait] +} + +type Trait { + key: String! + value: String! +} + +type ShipSpecs { + lengthMeters: Int + classification: String + weapons: [Weapon] +} + +type Weapon { + name: String! + parentWeapon: Weapon + traits: [Trait] +} + +type Location { + planet: String + sector: String + region: String + coordinates: Coordinates +} + +type Coordinates { + latitude: Float + longitude: Float +} + +input CreateCharacterInput { + name: String! + email: String! + appearsIn: Episode + location: LocationInput + tags: [String] + starshipId: ID +} + +input CharacterFilter { + nameContains: String + appearsIn: Episode + traitFilter: TraitFilterInput +} + +input TraitFilterInput { + keys: [String] + matchAll: Boolean +} + +input LocationInput { + planet: String + sector: String + region: String + coordinates: CoordinatesInput +} + +input CoordinatesInput { + latitude: Float + longitude: Float +} + +input CreateStarshipInput { + name: String! + location: LocationInput + squadrons: [SquadronInput] + specs: ShipSpecsInput +} + +input SquadronInput { + name: String! + leaderCharacterId: ID + subSquadrons: [SquadronInput] + traits: [TraitInput] +} + +input TraitInput { + key: String! + value: String! +} + +input ShipSpecsInput { + lengthMeters: Int + classification: String + weapons: [WeaponInput] +} + +input WeaponInput { + name: String! + parentWeaponName: String + traits: [TraitInput] +} + +input StarshipSearchCriteria { + nameContains: String + classifications: [String] + minLengthMeters: Int + squadronFilter: SquadronFilterInput +} + +input SquadronFilterInput { + nameContains: String + minMembers: Int + traits: [TraitInput] +} + +enum Episode { + NEWHOPE + EMPIRE + JEDI +} diff --git a/graphql/README.md b/graphql/README.md new file mode 100644 index 000000000..6f9ba883e --- /dev/null +++ b/graphql/README.md @@ -0,0 +1,333 @@ +Feign GraphQL +=================== + +This module adds support for declarative GraphQL clients using Feign. It provides a `GraphqlContract`, `GraphqlEncoder`, and `GraphqlDecoder` that transform annotated interfaces into fully functional GraphQL clients. + +The companion module `feign-graphql-apt` provides compile-time type generation from GraphQL schemas, producing Java records for query results and input types. + +## Dependencies + +Add both modules to use schema-driven type generation: + +```xml + + io.github.openfeign + feign-graphql + ${feign.version} + + + + io.github.openfeign.experimental + feign-graphql-apt + ${feign.version} + provided + +``` + +## Basic Usage + +Define a GraphQL schema in `src/main/resources`: + +```graphql +type Query { + user(id: ID!): User +} + +type User { + id: ID! + name: String! + email: String +} +``` + +Annotate your Feign interface with `@GraphqlSchema` pointing to the schema file and `@GraphqlQuery` on each method with the GraphQL query string: + +```java +@GraphqlSchema("my-schema.graphql") +interface UserApi { + + @GraphqlQuery("query { user(id: $id) { id name email } }") + User getUser(@Param("id") String id); +} +``` + +The annotation processor generates a Java record for `User` at compile time: + +```java +public record User(String id, String name, String email) {} +``` + +Build the client using `GraphqlCapability`, which wires the contract, encoder, decoder, and request interceptor automatically. It takes any `JsonCodec`: + +```java +// Using Jackson +UserApi api = Feign.builder() + .addCapability(new GraphqlCapability(new JacksonCodec())) + .target(UserApi.class, "https://api.example.com/graphql"); + +User user = api.getUser("123"); +``` + +Any JSON codec works the same way: + +```java +// Gson +new GraphqlCapability(new GsonCodec()) + +// Jackson 3 +new GraphqlCapability(new Jackson3Codec()) + +// Fastjson2 +new GraphqlCapability(new Fastjson2Codec()) +``` + +## Mutations with Variables + +Methods with parameters are sent as GraphQL variables: + +```java +@GraphqlSchema("my-schema.graphql") +interface UserApi { + + @GraphqlQuery("mutation($input: CreateUserInput!) { createUser(input: $input) { id name } }") + User createUser(@Param("input") CreateUserInput input); +} +``` + +The processor generates a record for the input type as well: + +```java +public record CreateUserInput(String name, String email) {} +``` + +## Custom Scalars + +When your schema defines custom scalars, map them to Java types using `@Scalar` on default methods: + +```graphql +scalar DateTime + +type Event { + id: ID! + name: String! + startTime: DateTime! +} +``` + +```java +@GraphqlSchema("event-schema.graphql") +interface EventApi { + + @Scalar("DateTime") + default Instant dateTime() { return null; } + + @GraphqlQuery("query { events { id name startTime } }") + List getEvents(); +} +``` + +The processor maps `DateTime` fields to `java.time.Instant` in the generated record: + +```java +public record Event(String id, String name, Instant startTime) {} +``` + +## Single Result from Array Queries + +When a GraphQL query returns an array type (e.g. `[User!]`) but the Java method declares a single return type, the decoder automatically unwraps the first element: + +```java +@GraphqlQuery("query topUser($limit: Int!) { topUsers(limit: $limit) { id name email } }") +User topUser(int limit); +``` + +This is useful when using `limit: 1` to fetch a single result from a list query. If the array is empty, `null` is returned. + +## Optional Return Types + +Methods can return `Optional` to safely handle nullable results: + +```java +@GraphqlQuery("query getUser($id: String!) { getUser(id: $id) { id name email } }") +Optional findUser(String id); +``` + +Returns `Optional.empty()` when the data is null or missing, and `Optional.of(value)` otherwise. This also works with array unwrapping: + +```java +@GraphqlQuery("query topUser($limit: Int!) { topUsers(limit: $limit) { id name email } }") +Optional findTopUser(int limit); +``` + +## Optional Fields + +When using `Optional<>` fields in records, your JSON codec must support `java.util.Optional`. For Jackson, add `jackson-datatype-jdk8` and register it: + +```java +ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); +UserApi api = Feign.builder() + .addCapability(new GraphqlCapability(new JacksonCodec(mapper))) + .target(UserApi.class, "https://api.example.com/graphql"); +``` + +By default, nullable GraphQL fields (without `!`) are wrapped in `Optional<>` in generated records: + +```graphql +type User { + id: ID! # non-null + name: String! # non-null + email: String # nullable +} +``` + +```java +public record User(String id, String name, Optional email) {} +``` + +This is controlled by `useOptional` on `@GraphqlSchema` (defaults to `true`): + +```java +@GraphqlSchema(value = "schema.graphql", useOptional = false) +``` + +Override per method with `Toggle`: + +```java +@GraphqlQuery(value = "...", useOptional = Toggle.FALSE) +``` + +## Type Annotations on Generated Records + +Add annotations to all generated records using `typeAnnotations` (no-arg) and `rawTypeAnnotations` (with args): + +```java +@GraphqlSchema( + value = "schema.graphql", + typeAnnotations = {Builder.class, Jacksonized.class}, + rawTypeAnnotations = {"@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)"} +) +``` + +Generates: + +```java +@Builder +@Jacksonized +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record User(String id, String name) {} +``` + +**Collision rule:** when the same annotation simple name appears in both `typeAnnotations` and `rawTypeAnnotations`, the class provides only the import and the raw string is used: + +```java +typeAnnotations = {Builder.class}, +rawTypeAnnotations = {"@Builder(toBuilder = true)"} +// Result: import lombok.Builder; + @Builder(toBuilder = true) +``` + +Override per method on `@GraphqlQuery` — non-empty arrays replace class-level values. + +## Import-Only Classes with `uses` + +When raw annotations reference classes not in `typeAnnotations`, use `uses` to add their imports: + +```java +@GraphqlSchema( + value = "schema.graphql", + uses = {Min.class, Max.class, Pattern.class} +) +``` + +These classes are added as imports to all generated files but no annotations are generated from them. + +## Non-Null Field Annotations + +Automatically annotate all non-null (`!`) fields with `nonNullTypeAnnotations`: + +```java +@GraphqlSchema( + value = "schema.graphql", + nonNullTypeAnnotations = {NotNull.class} +) +``` + +For `name: String!` and `email: String`, generates: + +```java +public record User(@NotNull String name, Optional email) {} +``` + +Same collision rule applies with `nonNullRawTypeAnnotations`. Overridable per method on `@GraphqlQuery`. + +## Field-Level Annotations with `@GraphqlField` + +Apply annotations or override types on specific fields. Repeatable, works on both the interface (class-level default) and individual methods: + +```java +@GraphqlSchema(value = "schema.graphql", useOptional = false) +@GraphqlField(name = "email", typeAnnotations = {Email.class}) +interface UserApi { + + @GraphqlQuery("{ user(id: \"1\") { id name email } }") + @GraphqlField(name = "name", typeAnnotations = {NotBlank.class}) + UserResult getUser(); +} +``` + +Generates: + +```java +public record UserResult(String id, @NotBlank String name, @Email String email) {} +``` + +### Dot Notation for Nested Fields + +Use dot notation to target fields in nested records: + +```java +@GraphqlField(name = "location.coordinates.latitude", typeAnnotations = {NotNull.class}) +@GraphqlField(name = "location.planet", typeAnnotations = {NotBlank.class}) +``` + +### Field Type Override + +Override the Java type for a field — useful when APIs return strings for dates without declaring custom scalars: + +```java +@GraphqlField(name = "createdAt", type = ZonedDateTime.class) +@GraphqlField(name = "amount", type = BigDecimal.class) +``` + +Combines with annotations: + +```java +@GraphqlField(name = "createdAt", type = ZonedDateTime.class, typeAnnotations = {NotNull.class}) +``` + +Class-level `@GraphqlField` applies to all methods; method-level overrides for the same field name. + +## Disabling Type Generation + +If you provide your own model classes, disable automatic generation: + +```java +@GraphqlSchema(value = "my-schema.graphql", generateTypes = false) +interface UserApi { + // ... +} +``` + +Queries are still validated against the schema at compile time. + +## Error Handling + +GraphQL errors in the response throw `GraphqlErrorException`: + +```java +try { + User user = api.getUser("invalid-id"); +} catch (GraphqlErrorException e) { + String operation = e.operation(); + String errors = e.errors(); +} +``` diff --git a/graphql/pom.xml b/graphql/pom.xml new file mode 100644 index 000000000..b4199d3bd --- /dev/null +++ b/graphql/pom.xml @@ -0,0 +1,83 @@ + + + + 4.0.0 + + + io.github.openfeign + feign-parent + 13.12-SNAPSHOT + + + feign-graphql + Feign GraphQL + Feign GraphQL runtime support for declarative GraphQL clients + + + 17 + + + + + ${project.groupId} + feign-core + + + + ${project.groupId} + feign-core + test-jar + test + + + + com.squareup.okhttp3 + mockwebserver + test + + + + ${project.groupId} + feign-jackson + ${project.version} + test + + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + test + + + + ${project.groupId} + feign-mock + ${project.version} + test + + + + ${project.groupId} + feign-jackson3 + ${project.version} + test + + + + diff --git a/graphql/src/main/java/feign/graphql/GraphqlCapability.java b/graphql/src/main/java/feign/graphql/GraphqlCapability.java new file mode 100644 index 000000000..9ace756bd --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlCapability.java @@ -0,0 +1,68 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.Capability; +import feign.Contract; +import feign.Experimental; +import feign.RequestInterceptors; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.JsonCodec; +import feign.codec.JsonDecoder; +import feign.codec.JsonEncoder; +import java.util.ArrayList; + +@Experimental +public class GraphqlCapability implements Capability { + + private final GraphqlContract contract = new GraphqlContract(); + private final GraphqlEncoder graphqlEncoder; + private final GraphqlDecoder graphqlDecoder; + private final GraphqlRequestInterceptor interceptor; + + public GraphqlCapability(JsonCodec codec) { + this(codec.encoder(), codec.decoder()); + } + + public GraphqlCapability(JsonEncoder encoder, JsonDecoder decoder) { + this.graphqlEncoder = new GraphqlEncoder(encoder, contract); + this.graphqlDecoder = new GraphqlDecoder(decoder); + this.interceptor = new GraphqlRequestInterceptor(encoder, contract); + } + + @Override + public Contract enrich(Contract contract) { + return this.contract; + } + + @Override + public Encoder enrich(Encoder encoder) { + return graphqlEncoder; + } + + @Override + public Decoder enrich(Decoder decoder) { + return graphqlDecoder; + } + + @Override + public RequestInterceptors enrich(RequestInterceptors requestInterceptors) { + var enriched = new ArrayList<>(requestInterceptors.interceptors()); + enriched.add(interceptor); + return new RequestInterceptors(enriched); + } +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlContract.java b/graphql/src/main/java/feign/graphql/GraphqlContract.java new file mode 100644 index 000000000..7e882fcfa --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlContract.java @@ -0,0 +1,109 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.DefaultContract; +import feign.Experimental; +import feign.Request.HttpMethod; +import feign.RequestTemplate; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +@Experimental +public class GraphqlContract extends DefaultContract { + + private static final Pattern OPERATION_FIELD_PATTERN = + Pattern.compile("\\{\\s*(\\w+)\\s*[({]", Pattern.DOTALL); + + private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\s*(\\w+)\\s*:"); + + private final Map metadata = new ConcurrentHashMap<>(); + + public GraphqlContract() { + super.registerMethodAnnotation( + GraphqlQuery.class, + (annotation, data) -> { + var query = annotation.value(); + + if (data.template().method() == null) { + data.template().method(HttpMethod.POST); + data.template().uri("/"); + } + + var variableName = extractFirstVariable(query); + metadata.put(data.configKey(), new QueryMetadata(query, variableName)); + }); + } + + Map queryMetadata() { + return metadata; + } + + QueryMetadata lookupMetadata(RequestTemplate template) { + if (template.methodMetadata() == null) { + return null; + } + return metadata.get(template.methodMetadata().configKey()); + } + + static String extractOperationField(String query) { + int braceCount = 0; + boolean inOperation = false; + + for (int i = 0; i < query.length(); i++) { + char c = query.charAt(i); + if (c == '{') { + braceCount++; + if (braceCount == 1) { + inOperation = true; + } else if (braceCount == 2 && inOperation) { + var prefix = query.substring(0, i).trim(); + var m = OPERATION_FIELD_PATTERN.matcher(prefix + "{"); + if (m.find()) { + return m.group(1); + } + } + } else if (c == '}') { + braceCount--; + } + } + + var m = OPERATION_FIELD_PATTERN.matcher(query); + if (m.find()) { + return m.group(1); + } + return null; + } + + static String extractFirstVariable(String query) { + var m = VARIABLE_PATTERN.matcher(query); + if (m.find()) { + return m.group(1); + } + return null; + } + + static class QueryMetadata { + final String query; + final String variableName; + + QueryMetadata(String query, String variableName) { + this.query = query; + this.variableName = variableName; + } + } +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java new file mode 100644 index 000000000..4486a239f --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java @@ -0,0 +1,164 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.Experimental; +import feign.Response; +import feign.Util; +import feign.codec.Decoder; +import feign.codec.JsonDecoder; +import java.io.IOException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Experimental +public class GraphqlDecoder implements Decoder { + + private final JsonDecoder jsonDecoder; + + public GraphqlDecoder(JsonDecoder jsonDecoder) { + this.jsonDecoder = jsonDecoder; + } + + @Override + public Object decode(Response response, Type type) throws IOException { + Type targetType = type; + boolean optional = isOptionalType(type); + if (optional) { + targetType = extractOptionalInnerType(type); + } + + var result = doDecode(response, targetType); + if (result == null && isCollectionOrArrayType(targetType)) { + result = Util.emptyValueOf(targetType); + } + return optional ? Optional.ofNullable(result) : result; + } + + @SuppressWarnings("unchecked") + private Object doDecode(Response response, Type type) throws IOException { + if (response.status() == 404 || response.status() == 204) { + return Util.emptyValueOf(type); + } + if (response.body() == null) { + return Util.emptyValueOf(type); + } + + var root = (Map) jsonDecoder.decode(response, Map.class); + if (root == null) { + return Util.emptyValueOf(type); + } + + var errors = root.get("errors"); + if (errors instanceof List errorList && !errorList.isEmpty()) { + var operationField = resolveOperationField(root, response); + throw new GraphqlErrorException( + response.status(), operationField, errors.toString(), response.request()); + } + + var data = root.get("data"); + if (!(data instanceof Map)) { + return Util.emptyValueOf(type); + } + + var dataMap = (Map) data; + var fieldNames = dataMap.keySet().iterator(); + if (!fieldNames.hasNext()) { + return Util.emptyValueOf(type); + } + + var firstField = fieldNames.next(); + var operationData = dataMap.get(firstField); + if (operationData == null) { + return Util.emptyValueOf(type); + } + + if (operationData instanceof List list && !isCollectionOrArrayType(type)) { + if (list.isEmpty()) { + return Util.emptyValueOf(type); + } + operationData = list.get(0); + } + + return jsonDecoder.convert(operationData, type); + } + + @SuppressWarnings("unchecked") + private String resolveOperationField(Map root, Response response) { + var data = root.get("data"); + if (data instanceof Map) { + var dataMap = (Map) data; + var names = dataMap.keySet().iterator(); + if (names.hasNext()) { + return names.next(); + } + } + + if (response.request() != null && response.request().body() != null) { + try { + var fakeResponse = + Response.builder() + .status(200) + .headers(Collections.emptyMap()) + .request(response.request()) + .body(response.request().body()) + .build(); + var requestBody = (Map) jsonDecoder.decode(fakeResponse, Map.class); + if (requestBody != null) { + var query = requestBody.get("query"); + if (query instanceof String queryStr) { + return GraphqlContract.extractOperationField(queryStr); + } + } + } catch (Exception e) { + // ignore parsing errors + } + } + + return "unknown"; + } + + private boolean isOptionalType(Type type) { + if (type instanceof ParameterizedType pt && pt.getRawType() instanceof Class cls) { + return cls == Optional.class; + } + if (type instanceof Class cls) { + return cls == Optional.class; + } + return false; + } + + private Type extractOptionalInnerType(Type type) { + if (type instanceof ParameterizedType pt) { + return pt.getActualTypeArguments()[0]; + } + return Object.class; + } + + private boolean isCollectionOrArrayType(Type type) { + if (type instanceof Class cls) { + return cls.isArray() || Iterable.class.isAssignableFrom(cls); + } + if (type instanceof ParameterizedType pt && pt.getRawType() instanceof Class cls) { + return Iterable.class.isAssignableFrom(cls); + } + return false; + } +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlEncoder.java b/graphql/src/main/java/feign/graphql/GraphqlEncoder.java new file mode 100644 index 000000000..4370a2622 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlEncoder.java @@ -0,0 +1,56 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.Experimental; +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import java.lang.reflect.Type; +import java.util.LinkedHashMap; + +@Experimental +public class GraphqlEncoder implements Encoder { + + private final Encoder delegate; + private final GraphqlContract contract; + + public GraphqlEncoder(Encoder delegate, GraphqlContract contract) { + this.delegate = delegate; + this.contract = contract; + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) + throws EncodeException { + var meta = contract.lookupMetadata(template); + if (meta == null) { + delegate.encode(object, bodyType, template); + return; + } + + var graphqlBody = new LinkedHashMap(); + graphqlBody.put("query", meta.query); + + if (object != null && meta.variableName != null) { + var variables = new LinkedHashMap(); + variables.put(meta.variableName, object); + graphqlBody.put("variables", variables); + } + + delegate.encode(graphqlBody, MAP_STRING_WILDCARD, template); + } +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlErrorException.java b/graphql/src/main/java/feign/graphql/GraphqlErrorException.java new file mode 100644 index 000000000..0c66a8ece --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlErrorException.java @@ -0,0 +1,41 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.Experimental; +import feign.FeignException; +import feign.Request; + +@Experimental +public class GraphqlErrorException extends FeignException { + + private final String operation; + private final String errors; + + public GraphqlErrorException(int status, String operation, String errors, Request request) { + super(status, String.format("GraphQL %s operation failed: %s", operation, errors), request); + this.operation = operation; + this.errors = errors; + } + + public String operation() { + return operation; + } + + public String errors() { + return errors; + } +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlField.java b/graphql/src/main/java/feign/graphql/GraphqlField.java new file mode 100644 index 000000000..76f326179 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlField.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.Experimental; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Experimental +@Retention(RetentionPolicy.SOURCE) +@Target({ElementType.TYPE, ElementType.METHOD}) +@Repeatable(GraphqlFields.class) +public @interface GraphqlField { + + String name(); + + Class type() default Void.class; + + Class[] typeAnnotations() default {}; + + String[] rawTypeAnnotations() default {}; +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlFields.java b/graphql/src/main/java/feign/graphql/GraphqlFields.java new file mode 100644 index 000000000..c7652f76d --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlFields.java @@ -0,0 +1,30 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.Experimental; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Experimental +@Retention(RetentionPolicy.SOURCE) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface GraphqlFields { + + GraphqlField[] value(); +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlQuery.java b/graphql/src/main/java/feign/graphql/GraphqlQuery.java new file mode 100644 index 000000000..964c70ddd --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlQuery.java @@ -0,0 +1,42 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.Experimental; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Experimental +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface GraphqlQuery { + + String value(); + + Toggle useOptional() default Toggle.INHERIT; + + Toggle useAliasForFieldNames() default Toggle.INHERIT; + + Class[] typeAnnotations() default {}; + + String[] rawTypeAnnotations() default {}; + + Class[] nonNullTypeAnnotations() default {}; + + String[] nonNullRawTypeAnnotations() default {}; +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlRequestInterceptor.java b/graphql/src/main/java/feign/graphql/GraphqlRequestInterceptor.java new file mode 100644 index 000000000..252834fc3 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlRequestInterceptor.java @@ -0,0 +1,51 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.Experimental; +import feign.RequestInterceptor; +import feign.RequestTemplate; +import feign.codec.Encoder; +import java.util.LinkedHashMap; + +@Experimental +public class GraphqlRequestInterceptor implements RequestInterceptor { + + private final Encoder delegate; + private final GraphqlContract contract; + + public GraphqlRequestInterceptor(Encoder delegate, GraphqlContract contract) { + this.delegate = delegate; + this.contract = contract; + } + + @Override + public void apply(RequestTemplate template) { + if (template.body() != null) { + return; + } + + var meta = contract.lookupMetadata(template); + if (meta == null) { + return; + } + + var graphqlBody = new LinkedHashMap(); + graphqlBody.put("query", meta.query); + + delegate.encode(graphqlBody, Encoder.MAP_STRING_WILDCARD, template); + } +} diff --git a/graphql/src/main/java/feign/graphql/GraphqlSchema.java b/graphql/src/main/java/feign/graphql/GraphqlSchema.java new file mode 100644 index 000000000..4f5799b41 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlSchema.java @@ -0,0 +1,46 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.Experimental; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Experimental +@Retention(RetentionPolicy.SOURCE) +@Target(ElementType.TYPE) +public @interface GraphqlSchema { + + String value(); + + boolean generateTypes() default true; + + boolean useOptional() default true; + + boolean useAliasForFieldNames() default true; + + Class[] uses() default {}; + + Class[] typeAnnotations() default {}; + + String[] rawTypeAnnotations() default {}; + + Class[] nonNullTypeAnnotations() default {}; + + String[] nonNullRawTypeAnnotations() default {}; +} diff --git a/graphql/src/main/java/feign/graphql/Scalar.java b/graphql/src/main/java/feign/graphql/Scalar.java new file mode 100644 index 000000000..4fb9c5a77 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/Scalar.java @@ -0,0 +1,30 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import feign.Experimental; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Experimental +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Scalar { + + String value(); +} diff --git a/graphql/src/main/java/feign/graphql/Toggle.java b/graphql/src/main/java/feign/graphql/Toggle.java new file mode 100644 index 000000000..9dd7b6957 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/Toggle.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +public enum Toggle { + INHERIT, + TRUE, + FALSE +} diff --git a/graphql/src/test/java/feign/graphql/GraphqlClientTest.java b/graphql/src/test/java/feign/graphql/GraphqlClientTest.java new file mode 100644 index 000000000..2de395de0 --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlClientTest.java @@ -0,0 +1,233 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Feign; +import feign.Headers; +import feign.Param; +import feign.jackson.JacksonCodec; +import java.util.List; +import java.util.Optional; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class GraphqlClientTest { + + private final ObjectMapper mapper = + new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + private MockWebServer server; + + public static class User { + public String id; + public String name; + public String email; + } + + public static class CreateUserInput { + public String name; + public String email; + } + + public static class CreateUserResult { + public String id; + public String name; + } + + @Headers("Content-Type: application/json") + interface TestApi { + + @GraphqlQuery( + "mutation createUser($input: CreateUserInput!) {" + + " createUser(input: $input) { id name } }") + CreateUserResult createUser(CreateUserInput input); + + @GraphqlQuery("query getUser($id: String!) {" + " getUser(id: $id) { id name email } }") + User getUser(String id); + + @GraphqlQuery("query listPending { listPending { id name } }") + List listPending(); + + @GraphqlQuery("query getUser($id: String!) {" + " getUser(id: $id) { id name email } }") + @Headers("Authorization: {auth}") + User getUserWithAuth(@Param("auth") String auth, String id); + + @GraphqlQuery("query getUser($id: String!) {" + " getUser(id: $id) { id name email } }") + Optional findUser(String id); + + @GraphqlQuery("query topUser($limit: Int!) {" + " topUsers(limit: $limit) { id name email } }") + User topUser(int limit); + } + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + private TestApi buildClient() { + return Feign.builder() + .addCapability(new GraphqlCapability(new JacksonCodec(mapper))) + .target(TestApi.class, server.url("/graphql").toString()); + } + + @Test + void mutationWithVariables() throws Exception { + server.enqueue( + new MockResponse() + .setBody("{\"data\":{\"createUser\":{\"id\":\"42\",\"name\":\"Alice\"}}}") + .addHeader("Content-Type", "application/json")); + + var input = new CreateUserInput(); + input.name = "Alice"; + input.email = "alice@example.com"; + + var result = buildClient().createUser(input); + + assertThat(result.id).isEqualTo("42"); + assertThat(result.name).isEqualTo("Alice"); + + var recorded = server.takeRequest(); + assertThat(recorded.getMethod()).isEqualTo("POST"); + var body = mapper.readTree(recorded.getBody().readUtf8()); + assertThat(body.get("query").asText()).contains("createUser"); + assertThat(body.get("variables").get("input").get("name").asText()).isEqualTo("Alice"); + } + + @Test + void queryWithStringVariable() throws Exception { + server.enqueue( + new MockResponse() + .setBody( + "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Bob\",\"email\":\"bob@test.com\"}}}") + .addHeader("Content-Type", "application/json")); + + var user = buildClient().getUser("1"); + + assertThat(user.id).isEqualTo("1"); + assertThat(user.name).isEqualTo("Bob"); + assertThat(user.email).isEqualTo("bob@test.com"); + + var recorded = server.takeRequest(); + var body = mapper.readTree(recorded.getBody().readUtf8()); + assertThat(body.get("variables").get("id").asText()).isEqualTo("1"); + } + + @Test + void noVariableQuerySetsBodyViaInterceptor() throws Exception { + server.enqueue( + new MockResponse() + .setBody( + "{\"data\":{\"listPending\":[{\"id\":\"1\",\"name\":\"A\"},{\"id\":\"2\",\"name\":\"B\"}]}}") + .addHeader("Content-Type", "application/json")); + + var users = buildClient().listPending(); + + assertThat(users).hasSize(2); + assertThat(users.getFirst().name).isEqualTo("A"); + + var recorded = server.takeRequest(); + var body = mapper.readTree(recorded.getBody().readUtf8()); + assertThat(body.get("query").asText()).contains("listPending"); + assertThat(body.has("variables")).isFalse(); + } + + @Test + void graphqlErrorsThrowException() { + server.enqueue( + new MockResponse() + .setBody("{\"errors\":[{\"message\":\"Something went wrong\"}],\"data\":null}") + .addHeader("Content-Type", "application/json")); + + var input = new CreateUserInput(); + input.name = "Alice"; + + assertThatThrownBy(() -> buildClient().createUser(input)) + .isInstanceOf(GraphqlErrorException.class) + .hasMessageContaining("createUser") + .hasMessageContaining("Something went wrong"); + } + + @Test + void singleResultFromArrayResponse() throws Exception { + server.enqueue( + new MockResponse() + .setBody( + "{\"data\":{\"topUsers\":[{\"id\":\"1\",\"name\":\"Alice\",\"email\":\"a@test.com\"}]}}") + .addHeader("Content-Type", "application/json")); + + var user = buildClient().topUser(1); + + assertThat(user.id).isEqualTo("1"); + assertThat(user.name).isEqualTo("Alice"); + } + + @Test + void optionalReturnType() throws Exception { + server.enqueue( + new MockResponse() + .setBody( + "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Bob\",\"email\":\"bob@test.com\"}}}") + .addHeader("Content-Type", "application/json")); + + var user = buildClient().findUser("1"); + + assertThat(user).isPresent(); + assertThat(user.get().id).isEqualTo("1"); + assertThat(user.get().name).isEqualTo("Bob"); + } + + @Test + void optionalReturnTypeEmptyWhenNull() throws Exception { + server.enqueue( + new MockResponse() + .setBody("{\"data\":{\"getUser\":null}}") + .addHeader("Content-Type", "application/json")); + + var user = buildClient().findUser("999"); + + assertThat(user).isEmpty(); + } + + @Test + void authHeaderPassedThrough() throws Exception { + server.enqueue( + new MockResponse() + .setBody( + "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Bob\",\"email\":\"bob@test.com\"}}}") + .addHeader("Content-Type", "application/json")); + + var user = buildClient().getUserWithAuth("Bearer mytoken", "1"); + + assertThat(user.id).isEqualTo("1"); + + var recorded = server.takeRequest(); + assertThat(recorded.getHeader("Authorization")).isEqualTo("Bearer mytoken"); + } +} diff --git a/graphql/src/test/java/feign/graphql/GraphqlContractTest.java b/graphql/src/test/java/feign/graphql/GraphqlContractTest.java new file mode 100644 index 000000000..ec5846cf6 --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlContractTest.java @@ -0,0 +1,121 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import feign.Request.HttpMethod; +import org.junit.jupiter.api.Test; + +class GraphqlContractTest { + + private final GraphqlContract contract = new GraphqlContract(); + + interface MutationApi { + @GraphqlQuery( + "mutation backendUpdateRuntimeStatus($event: RuntimeStatusInput!) {" + + " backendUpdateRuntimeStatus(event: $event) { _uuid deploymentId } }") + Object updateStatus(Object event); + } + + interface QueryWithVariableApi { + @GraphqlQuery( + "query backendProjectsLookup($projectId: String!) {" + + " backendProjectsLookup(projectId: $projectId) { projectId orgId } }") + Object lookup(String projectId); + } + + interface NoVariableQueryApi { + @GraphqlQuery( + "query backendPendingDeployments {" + + " backendPendingDeployments { projectId environment } }") + Object pending(); + } + + @Test + void mutationSetsPostMethod() { + var metadata = contract.parseAndValidateMetadata(MutationApi.class); + assertThat(metadata).hasSize(1); + assertThat(metadata.getFirst().template().method()).isEqualTo(HttpMethod.POST.name()); + } + + @Test + void mutationStoresQueryMetadata() { + contract.parseAndValidateMetadata(MutationApi.class); + assertThat(contract.queryMetadata()).hasSize(1); + var meta = contract.queryMetadata().values().iterator().next(); + assertThat(meta.query).contains("backendUpdateRuntimeStatus"); + assertThat(meta.variableName).isEqualTo("event"); + } + + @Test + void queryWithVariableStoresMetadata() { + contract.parseAndValidateMetadata(QueryWithVariableApi.class); + assertThat(contract.queryMetadata()).hasSize(1); + var meta = contract.queryMetadata().values().iterator().next(); + assertThat(meta.query).contains("backendProjectsLookup"); + assertThat(meta.variableName).isEqualTo("projectId"); + } + + @Test + void noVariableQueryHasNullVariableName() { + contract.parseAndValidateMetadata(NoVariableQueryApi.class); + assertThat(contract.queryMetadata()).hasSize(1); + var meta = contract.queryMetadata().values().iterator().next(); + assertThat(meta.query).contains("backendPendingDeployments"); + assertThat(meta.variableName).isNull(); + } + + @Test + void noHeadersSetOnTemplate() { + var metadata = contract.parseAndValidateMetadata(MutationApi.class); + var headers = metadata.getFirst().template().headers(); + assertThat(headers).isEmpty(); + } + + @Test + void extractOperationFieldFromMutation() { + var query = + "mutation backendUpdateRuntimeStatus($event: RuntimeStatusInput!) {" + + " backendUpdateRuntimeStatus(event: $event) { _uuid } }"; + assertThat(GraphqlContract.extractOperationField(query)) + .isEqualTo("backendUpdateRuntimeStatus"); + } + + @Test + void extractOperationFieldFromSimpleQuery() { + var query = "query backendPendingDeployments { backendPendingDeployments { projectId } }"; + assertThat(GraphqlContract.extractOperationField(query)).isEqualTo("backendPendingDeployments"); + } + + @Test + void extractOperationFieldFromAnonymousQuery() { + var query = "{ user(id: \"1\") { id name } }"; + assertThat(GraphqlContract.extractOperationField(query)).isEqualTo("user"); + } + + @Test + void extractFirstVariableFromMutation() { + var query = "mutation backendUpdateRuntimeStatus($event: RuntimeStatusInput!) { x }"; + assertThat(GraphqlContract.extractFirstVariable(query)).isEqualTo("event"); + } + + @Test + void extractFirstVariableReturnsNullWhenNone() { + var query = "query backendPendingDeployments { x }"; + assertThat(GraphqlContract.extractFirstVariable(query)).isNull(); + } +} diff --git a/graphql/src/test/java/feign/graphql/GraphqlDecoderOptionalFieldCodecTest.java b/graphql/src/test/java/feign/graphql/GraphqlDecoderOptionalFieldCodecTest.java new file mode 100644 index 000000000..2b0dced35 --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlDecoderOptionalFieldCodecTest.java @@ -0,0 +1,152 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Feign; +import feign.Headers; +import feign.codec.JsonCodec; +import feign.jackson.JacksonCodec; +import feign.jackson3.Jackson3Codec; +import feign.mock.HttpMethod; +import feign.mock.MockClient; +import feign.mock.MockTarget; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import tools.jackson.databind.json.JsonMapper; + +class GraphqlDecoderOptionalFieldCodecTest { + + public record Item(String name, Optional step) {} + + @Headers("Content-Type: application/json") + interface ItemApi { + + @GraphqlQuery( + """ + query { + item(id: "1") { + name + step + } + } + """) + Item getItem(); + } + + static Stream codecs() { + var jackson = + new JacksonCodec( + new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .findAndRegisterModules()); + + var jackson3 = + new Jackson3Codec( + JsonMapper.builder() + .disable(tools.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build()); + + return Stream.of(Arguments.of("jackson", jackson), Arguments.of("jackson3", jackson3)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("codecs") + void stepExplicitNull(String name, JsonCodec codec) { + var mockClient = new MockClient(); + mockClient.ok( + HttpMethod.POST, + "/", + """ + { + "data": { + "item": { + "name": "test", + "step": null + } + } + } + """); + + var api = buildClient(mockClient, codec); + var item = api.getItem(); + + assertThat(item.name()).isEqualTo("test"); + assertThat(item.step()).isEmpty(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("codecs") + void stepMissing(String name, JsonCodec codec) { + var mockClient = new MockClient(); + mockClient.ok( + HttpMethod.POST, + "/", + """ + { + "data": { + "item": { + "name": "test" + } + } + } + """); + + var api = buildClient(mockClient, codec); + var item = api.getItem(); + + assertThat(item.name()).isEqualTo("test"); + assertThat(item.step()).isEmpty(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("codecs") + void stepPresent(String name, JsonCodec codec) { + var mockClient = new MockClient(); + mockClient.ok( + HttpMethod.POST, + "/", + """ + { + "data": { + "item": { + "name": "test", + "step": "foo" + } + } + } + """); + + var api = buildClient(mockClient, codec); + var item = api.getItem(); + + assertThat(item.name()).isEqualTo("test"); + assertThat(item.step()).isPresent().hasValue("foo"); + } + + private ItemApi buildClient(MockClient mockClient, JsonCodec codec) { + return Feign.builder() + .addCapability(new GraphqlCapability(codec)) + .client(mockClient) + .target(new MockTarget<>(ItemApi.class)); + } +} diff --git a/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java b/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java new file mode 100644 index 000000000..b796a6861 --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java @@ -0,0 +1,379 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Request; +import feign.Request.HttpMethod; +import feign.Response; +import feign.jackson.JacksonDecoder; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class GraphqlDecoderTest { + + private final ObjectMapper mapper = + new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .findAndRegisterModules(); + private final GraphqlDecoder decoder = new GraphqlDecoder(new JacksonDecoder(mapper)); + + public static class User { + public String id; + public String name; + } + + public record UserRecord(String id, String name, Optional email) {} + + public record Address(String city, Optional zip) {} + + public record UserWithAddress(String id, Optional
address) {} + + public record DeeplyNested(String value, Optional nested) {} + + @Test + void decodesDataField() throws Exception { + var json = "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Alice\"}}}"; + var response = buildResponse(json); + + var user = (User) decoder.decode(response, User.class); + + assertThat(user.id).isEqualTo("1"); + assertThat(user.name).isEqualTo("Alice"); + } + + @Test + void decodesListResponse() throws Exception { + var json = + "{\"data\":{\"listUsers\":[{\"id\":\"1\",\"name\":\"Alice\"},{\"id\":\"2\",\"name\":\"Bob\"}]}}"; + var response = buildResponse(json); + + @SuppressWarnings("unchecked") + var users = (List) decoder.decode(response, parameterizedType(List.class, User.class)); + + assertThat(users).hasSize(2); + assertThat(users.getFirst().name).isEqualTo("Alice"); + assertThat(users.get(1).name).isEqualTo("Bob"); + } + + @Test + void throwsGraphqlErrorExceptionOnErrors() { + var json = "{\"errors\":[{\"message\":\"Not found\"}],\"data\":{\"getUser\":null}}"; + var response = buildResponse(json); + + assertThatThrownBy(() -> decoder.decode(response, User.class)) + .isInstanceOf(GraphqlErrorException.class) + .hasMessageContaining("getUser") + .hasMessageContaining("Not found"); + } + + @Test + void returnsNullForNullData() throws Exception { + var json = "{\"data\":{\"getUser\":null}}"; + var response = buildResponse(json); + + var result = decoder.decode(response, User.class); + assertThat(result).isNull(); + } + + @Test + void returnsNullForEmptyData() throws Exception { + var json = "{\"data\":{}}"; + var response = buildResponse(json); + + var result = decoder.decode(response, User.class); + assertThat(result).isNull(); + } + + @Test + void returnsEmptyFor404() throws Exception { + var response = + Response.builder() + .status(404) + .reason("Not Found") + .headers(Collections.emptyMap()) + .request(buildRequest()) + .body(new byte[0]) + .build(); + + var result = decoder.decode(response, User.class); + assertThat(result).isNull(); + } + + @Test + void unwrapsSingleObjectFromArray() throws Exception { + var json = "{\"data\":{\"ingestionStats\":[{\"id\":\"1\",\"name\":\"Alice\"}]}}"; + var response = buildResponse(json); + + var user = (User) decoder.decode(response, User.class); + + assertThat(user.id).isEqualTo("1"); + assertThat(user.name).isEqualTo("Alice"); + } + + @Test + void unwrapsFirstElementFromMultiElementArray() throws Exception { + var json = + "{\"data\":{\"ingestionStats\":[{\"id\":\"1\",\"name\":\"Alice\"},{\"id\":\"2\",\"name\":\"Bob\"}]}}"; + var response = buildResponse(json); + + var user = (User) decoder.decode(response, User.class); + + assertThat(user.id).isEqualTo("1"); + assertThat(user.name).isEqualTo("Alice"); + } + + @Test + void returnsNullForEmptyArrayWithSingleType() throws Exception { + var json = "{\"data\":{\"ingestionStats\":[]}}"; + var response = buildResponse(json); + + var result = decoder.decode(response, User.class); + assertThat(result).isNull(); + } + + @Test + void preservesArrayWhenReturnTypeIsList() throws Exception { + var json = + "{\"data\":{\"listUsers\":[{\"id\":\"1\",\"name\":\"Alice\"},{\"id\":\"2\",\"name\":\"Bob\"}]}}"; + var response = buildResponse(json); + + @SuppressWarnings("unchecked") + var users = (List) decoder.decode(response, parameterizedType(List.class, User.class)); + + assertThat(users).hasSize(2); + } + + @Test + void returnsOptionalWithValue() throws Exception { + var json = "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Alice\"}}}"; + var response = buildResponse(json); + + @SuppressWarnings("unchecked") + var result = (Optional) decoder.decode(response, optionalOf(User.class)); + + assertThat(result).isPresent(); + assertThat(result.get().id).isEqualTo("1"); + assertThat(result.get().name).isEqualTo("Alice"); + } + + @Test + void returnsOptionalEmptyForNullData() throws Exception { + var json = "{\"data\":{\"getUser\":null}}"; + var response = buildResponse(json); + + @SuppressWarnings("unchecked") + var result = (Optional) decoder.decode(response, optionalOf(User.class)); + + assertThat(result).isEmpty(); + } + + @Test + void returnsOptionalEmptyFor404() throws Exception { + var response = + Response.builder() + .status(404) + .reason("Not Found") + .headers(Collections.emptyMap()) + .request(buildRequest()) + .body(new byte[0]) + .build(); + + @SuppressWarnings("unchecked") + var result = (Optional) decoder.decode(response, optionalOf(User.class)); + + assertThat(result).isEmpty(); + } + + @Test + void unwrapsSingleObjectFromArrayIntoOptional() throws Exception { + var json = "{\"data\":{\"ingestionStats\":[{\"id\":\"1\",\"name\":\"Alice\"}]}}"; + var response = buildResponse(json); + + @SuppressWarnings("unchecked") + var result = (Optional) decoder.decode(response, optionalOf(User.class)); + + assertThat(result).isPresent(); + assertThat(result.get().id).isEqualTo("1"); + } + + @Test + void returnsOptionalEmptyForEmptyArray() throws Exception { + var json = "{\"data\":{\"ingestionStats\":[]}}"; + var response = buildResponse(json); + + @SuppressWarnings("unchecked") + var result = (Optional) decoder.decode(response, optionalOf(User.class)); + + assertThat(result).isEmpty(); + } + + @Test + void decodesRecordWithOptionalFieldPresent() throws Exception { + var json = + "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Alice\",\"email\":\"alice@test.com\"}}}"; + var response = buildResponse(json); + + var result = (UserRecord) decoder.decode(response, UserRecord.class); + + assertThat(result.id()).isEqualTo("1"); + assertThat(result.email()).isPresent(); + assertThat(result.email().get()).isEqualTo("alice@test.com"); + } + + @Test + void decodesRecordWithOptionalFieldNull() throws Exception { + var json = "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Alice\",\"email\":null}}}"; + var response = buildResponse(json); + + var result = (UserRecord) decoder.decode(response, UserRecord.class); + + assertThat(result.id()).isEqualTo("1"); + assertThat(result.email()).isEmpty(); + } + + @Test + void decodesRecordWithOptionalFieldMissing() throws Exception { + var json = "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Alice\"}}}"; + var response = buildResponse(json); + + var result = (UserRecord) decoder.decode(response, UserRecord.class); + + assertThat(result.id()).isEqualTo("1"); + assertThat(result.email()).isEmpty(); + } + + @Test + void decodesNestedRecordWithOptionalFields() throws Exception { + var json = + "{\"data\":{\"getUser\":{\"id\":\"1\",\"address\":{\"city\":\"NYC\",\"zip\":null}}}}"; + var response = buildResponse(json); + + var result = (UserWithAddress) decoder.decode(response, UserWithAddress.class); + + assertThat(result.id()).isEqualTo("1"); + assertThat(result.address()).isPresent(); + assertThat(result.address().get().city()).isEqualTo("NYC"); + assertThat(result.address().get().zip()).isEmpty(); + } + + @Test + void decodesDeeplyNestedRecordWithOptionalFields() throws Exception { + var json = + "{\"data\":{\"get\":{\"value\":\"top\",\"nested\":{\"id\":\"1\",\"address\":{\"city\":\"NYC\",\"zip\":null}}}}}"; + var response = buildResponse(json); + + var result = (DeeplyNested) decoder.decode(response, DeeplyNested.class); + + assertThat(result.value()).isEqualTo("top"); + assertThat(result.nested()).isPresent(); + assertThat(result.nested().get().address()).isPresent(); + assertThat(result.nested().get().address().get().zip()).isEmpty(); + } + + @Test + void returnsEmptyListForNullBody() throws Exception { + var response = + Response.builder() + .status(200) + .reason("OK") + .headers(Collections.emptyMap()) + .request(buildRequest()) + .build(); + + @SuppressWarnings("unchecked") + var result = (List) decoder.decode(response, parameterizedType(List.class, User.class)); + + assertThat(result).isEmpty(); + } + + @Test + void returnsEmptyListForNullOperationDataWithListType() throws Exception { + var json = "{\"data\":{\"listUsers\":null}}"; + var response = buildResponse(json); + + @SuppressWarnings("unchecked") + var result = (List) decoder.decode(response, parameterizedType(List.class, User.class)); + + assertThat(result).isEmpty(); + } + + private Response buildResponse(String body) { + return Response.builder() + .status(200) + .reason("OK") + .headers(Collections.emptyMap()) + .request(buildRequest()) + .body(body, StandardCharsets.UTF_8) + .build(); + } + + private Request buildRequest() { + return Request.create( + HttpMethod.POST, + "http://localhost/graphql", + Collections.emptyMap(), + Request.Body.empty(), + null); + } + + private static ParameterizedType optionalOf(Type inner) { + return new ParameterizedType() { + @Override + public Type[] getActualTypeArguments() { + return new Type[] {inner}; + } + + @Override + public Type getRawType() { + return Optional.class; + } + + @Override + public Type getOwnerType() { + return null; + } + }; + } + + private static ParameterizedType parameterizedType(Type raw, Type... args) { + return new ParameterizedType() { + @Override + public Type[] getActualTypeArguments() { + return args; + } + + @Override + public Type getRawType() { + return raw; + } + + @Override + public Type getOwnerType() { + return null; + } + }; + } +} diff --git a/graphql/src/test/java/feign/graphql/GraphqlEncoderTest.java b/graphql/src/test/java/feign/graphql/GraphqlEncoderTest.java new file mode 100644 index 000000000..07a22ed8a --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlEncoderTest.java @@ -0,0 +1,100 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.RequestTemplate; +import feign.jackson.JacksonEncoder; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class GraphqlEncoderTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final GraphqlContract contract = new GraphqlContract(); + private final JacksonEncoder jacksonEncoder = new JacksonEncoder(mapper); + private final GraphqlEncoder encoder = new GraphqlEncoder(jacksonEncoder, contract); + private final GraphqlRequestInterceptor interceptor = + new GraphqlRequestInterceptor(jacksonEncoder, contract); + + interface MutationApi { + @GraphqlQuery( + "mutation createUser($input: CreateUserInput!) { createUser(input: $input) { id } }") + Object createUser(Object input); + } + + interface NoVariableApi { + @GraphqlQuery("query pending { pending { id } }") + Object pending(); + } + + private RequestTemplate templateFor(Class apiClass) { + var metadataList = contract.parseAndValidateMetadata(apiClass); + var md = metadataList.getFirst(); + var template = new RequestTemplate(); + template.methodMetadata(md); + return template; + } + + @Test + void encodesBodyWithVariables() throws Exception { + var template = templateFor(MutationApi.class); + var body = Map.of("name", "John", "email", "john@example.com"); + encoder.encode(body, Map.class, template); + + var result = mapper.readTree(template.body()); + assertThat(result.has("query")).isTrue(); + assertThat(result.get("query").asText()).contains("createUser"); + assertThat(result.has("variables")).isTrue(); + assertThat(result.get("variables").has("input")).isTrue(); + assertThat(result.get("variables").get("input").get("name").asText()).isEqualTo("John"); + } + + @Test + void delegatesToWrappedEncoderForNonGraphql() { + var template = new RequestTemplate(); + encoder.encode("plain body", String.class, template); + assertThat(template.body()).isNotNull(); + } + + @Test + void interceptorSetsBodyForNoVariableQuery() throws Exception { + var template = templateFor(NoVariableApi.class); + interceptor.apply(template); + + var result = mapper.readTree(template.body()); + assertThat(result.get("query").asText()).contains("pending"); + assertThat(result.has("variables")).isFalse(); + } + + @Test + void interceptorSkipsWhenBodyAlreadySet() { + var template = templateFor(MutationApi.class); + template.body("already set"); + interceptor.apply(template); + assertThat(new String(template.body())).isEqualTo("already set"); + } + + @Test + void interceptorSkipsForNonGraphql() { + var template = new RequestTemplate(); + template.body("some body"); + interceptor.apply(template); + assertThat(new String(template.body())).isEqualTo("some body"); + } +} diff --git a/gson/README.md b/gson/README.md index d26c16470..c005699be 100644 --- a/gson/README.md +++ b/gson/README.md @@ -3,7 +3,15 @@ Gson Codec This module adds support for encoding and decoding JSON via the Gson library. -Add `GsonEncoder` and/or `GsonDecoder` to your `Feign.Builder` like so: +Add `GsonCodec` to your `Feign.Builder` like so: + +```java +GitHub github = Feign.builder() + .codec(new GsonCodec()) + .target(GitHub.class, "https://api.github.com"); +``` + +You can also configure the encoder and decoder separately: ```java GitHub github = Feign.builder() diff --git a/gson/pom.xml b/gson/pom.xml index d1a946b03..1f69a0ec5 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-gson diff --git a/gson/src/main/java/feign/gson/GsonCodec.java b/gson/src/main/java/feign/gson/GsonCodec.java new file mode 100644 index 000000000..8975b0648 --- /dev/null +++ b/gson/src/main/java/feign/gson/GsonCodec.java @@ -0,0 +1,55 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.gson; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import feign.Experimental; +import feign.codec.Codec; +import feign.codec.JsonCodec; +import feign.codec.JsonDecoder; +import feign.codec.JsonEncoder; + +@Experimental +public class GsonCodec implements Codec, JsonCodec { + + private final GsonEncoder encoder; + private final GsonDecoder decoder; + + public GsonCodec() { + this(new Gson()); + } + + public GsonCodec(Iterable> adapters) { + this.encoder = new GsonEncoder(adapters); + this.decoder = new GsonDecoder(adapters); + } + + public GsonCodec(Gson gson) { + this.encoder = new GsonEncoder(gson); + this.decoder = new GsonDecoder(gson); + } + + @Override + public JsonEncoder encoder() { + return encoder; + } + + @Override + public JsonDecoder decoder() { + return decoder; + } +} diff --git a/gson/src/main/java/feign/gson/GsonDecoder.java b/gson/src/main/java/feign/gson/GsonDecoder.java index 90d4560ba..699c1d48c 100644 --- a/gson/src/main/java/feign/gson/GsonDecoder.java +++ b/gson/src/main/java/feign/gson/GsonDecoder.java @@ -24,12 +24,13 @@ import feign.Response; import feign.Util; import feign.codec.Decoder; +import feign.codec.JsonDecoder; import java.io.IOException; import java.io.Reader; import java.lang.reflect.Type; import java.util.Collections; -public class GsonDecoder implements Decoder { +public class GsonDecoder implements Decoder, JsonDecoder { private final Gson gson; @@ -61,4 +62,9 @@ public Object decode(Response response, Type type) throws IOException { ensureClosed(reader); } } + + @Override + public Object convert(Object object, Type type) { + return gson.fromJson(gson.toJsonTree(object), type); + } } diff --git a/gson/src/main/java/feign/gson/GsonEncoder.java b/gson/src/main/java/feign/gson/GsonEncoder.java index d1f25d4d1..c4484bc6e 100644 --- a/gson/src/main/java/feign/gson/GsonEncoder.java +++ b/gson/src/main/java/feign/gson/GsonEncoder.java @@ -19,10 +19,11 @@ import com.google.gson.TypeAdapter; import feign.RequestTemplate; import feign.codec.Encoder; +import feign.codec.JsonEncoder; import java.lang.reflect.Type; import java.util.Collections; -public class GsonEncoder implements Encoder { +public class GsonEncoder implements Encoder, JsonEncoder { private final Gson gson; diff --git a/gson/src/test/java/feign/gson/GsonCodecTest.java b/gson/src/test/java/feign/gson/GsonCodecTest.java index 4c226fa76..1ec47b891 100644 --- a/gson/src/test/java/feign/gson/GsonCodecTest.java +++ b/gson/src/test/java/feign/gson/GsonCodecTest.java @@ -49,7 +49,8 @@ void encodesMapObjectNumericalValuesAsInteger() { new GsonEncoder().encode(map, map.getClass(), template); assertThat(template) - .hasBody(""" + .hasBody( + """ { "foo": 1 }\ diff --git a/gson/src/test/java/feign/gson/examples/GitHubExample.java b/gson/src/test/java/feign/gson/examples/GitHubExample.java index 960a1bd8e..3579a5cba 100644 --- a/gson/src/test/java/feign/gson/examples/GitHubExample.java +++ b/gson/src/test/java/feign/gson/examples/GitHubExample.java @@ -28,10 +28,10 @@ public static void main(String... args) { GitHub github = Feign.builder().decoder(new GsonDecoder()).target(GitHub.class, "https://api.github.com"); - System.out.println("Let's fetch and print a list of the contributors to this library."); + IO.println("Let's fetch and print a list of the contributors to this library."); List contributors = github.contributors("netflix", "feign"); for (Contributor contributor : contributors) { - System.out.println(contributor.login + " (" + contributor.contributions + ")"); + IO.println(contributor.login + " (" + contributor.contributions + ")"); } } diff --git a/hc5/pom.xml b/hc5/pom.xml index cca2e7299..3d385a8f9 100644 --- a/hc5/pom.xml +++ b/hc5/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-hc5 @@ -38,7 +38,7 @@ org.apache.httpcomponents.client5 httpclient5 - 5.5 + ${httpclient5.version} diff --git a/hc5/src/test/java/feign/hc5/AsyncApacheHttp5ClientTest.java b/hc5/src/test/java/feign/hc5/AsyncApacheHttp5ClientTest.java index 323e5689e..6d5902d9c 100644 --- a/hc5/src/test/java/feign/hc5/AsyncApacheHttp5ClientTest.java +++ b/hc5/src/test/java/feign/hc5/AsyncApacheHttp5ClientTest.java @@ -25,10 +25,10 @@ import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; -import feign.AsyncClient; import feign.AsyncFeign; import feign.Body; import feign.ChildPojo; +import feign.DefaultAsyncClient; import feign.Feign; import feign.Feign.ResponseMappingDecoder; import feign.FeignException; @@ -50,6 +50,9 @@ import feign.Util; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.DefaultDecoder; +import feign.codec.DefaultEncoder; +import feign.codec.DefaultErrorDecoder; import feign.codec.EncodeException; import feign.codec.Encoder; import feign.codec.ErrorDecoder; @@ -158,7 +161,7 @@ void bodyTypeCorrespondsWithParameterType() throws Exception { final TestInterfaceAsync api = new TestInterfaceAsyncBuilder() .encoder( - new Encoder.Default() { + new DefaultEncoder() { @Override public void encode(Object object, Type bodyType, RequestTemplate template) { encodedType.set(bodyType); @@ -489,7 +492,7 @@ void overrideTypeSpecificDecoder() throws Throwable { final TestInterfaceAsync api = new TestInterfaceAsyncBuilder() - .decoder((response, type) -> "fail") + .decoder((_, _) -> "fail") .target("http://localhost:" + server.getPort()); assertThat(unwrap(api.post())).isEqualTo("fail"); @@ -502,7 +505,7 @@ void doesntRetryAfterResponseIsSent() throws Throwable { final TestInterfaceAsync api = new TestInterfaceAsyncBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target("http://localhost:" + server.getPort()); @@ -520,7 +523,7 @@ void throwsFeignExceptionIncludingBody() throws Throwable { final TestInterfaceAsync api = AsyncFeign.builder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort()); @@ -545,7 +548,7 @@ void throwsFeignExceptionWithoutBody() { final TestInterfaceAsync api = AsyncFeign.builder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort()); @@ -578,7 +581,7 @@ void whenReturnTypeIsResponseNoErrorHandling() throws Throwable { // fake client as Client.Default follows redirects. final TestInterfaceAsync api = AsyncFeign.builder() - .client(new AsyncClient.Default<>((request, options) -> response, execs)) + .client(new DefaultAsyncClient<>((_, _) -> response, execs)) .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort()); assertThat(unwrap(api.response()).headers()) @@ -598,7 +601,7 @@ void okIfDecodeRootCauseHasNoMessage() throws Throwable { final TestInterfaceAsync api = new TestInterfaceAsyncBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new RuntimeException(); }) .target("http://localhost:" + server.getPort()); @@ -614,7 +617,7 @@ void decodingExceptionGetWrappedInDismiss404Mode() throws Throwable { new TestInterfaceAsyncBuilder() .dismiss404() .decoder( - (response, type) -> { + (response, _) -> { assertThat(response.status()).isEqualTo(404); throw new NoSuchElementException(); }) @@ -651,7 +654,7 @@ void okIfEncodeRootCauseHasNoMessage() throws Throwable { final TestInterfaceAsync api = new TestInterfaceAsyncBuilder() .encoder( - (object, bodyType, template) -> { + (_, _, _) -> { throw new RuntimeException(); }) .target("http://localhost:" + server.getPort()); @@ -754,7 +757,7 @@ void responseMapperIsAppliedBeforeDelegate() throws IOException { } private ResponseMapper upperCaseResponseMapper() { - return (response, type) -> { + return (response, _) -> { try { return response.toBuilder() .body(Util.toString(response.body().asReader()).toUpperCase().getBytes()) @@ -1050,7 +1053,7 @@ public void apply(RequestTemplate template) { } } - static class IllegalArgumentExceptionOn400 extends ErrorDecoder.Default { + static class IllegalArgumentExceptionOn400 extends DefaultErrorDecoder { @Override public Exception decode(String methodKey, Response response) { @@ -1061,7 +1064,7 @@ public Exception decode(String methodKey, Response response) { } } - static class IllegalArgumentExceptionOn404 extends ErrorDecoder.Default { + static class IllegalArgumentExceptionOn404 extends DefaultErrorDecoder { @Override public Exception decode(String methodKey, Response response) { @@ -1077,9 +1080,9 @@ static final class TestInterfaceAsyncBuilder { private final AsyncFeign.AsyncBuilder delegate = AsyncFeign.builder() .client(new AsyncApacheHttp5Client()) - .decoder(new Decoder.Default()) + .decoder(new DefaultDecoder()) .encoder( - (object, bodyType, template) -> { + (object, _, template) -> { if (object instanceof Map) { template.body(new Gson().toJson(object)); } else { diff --git a/httpclient/pom.xml b/httpclient/pom.xml index 198b2872e..c277048cf 100644 --- a/httpclient/pom.xml +++ b/httpclient/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-httpclient @@ -38,7 +38,7 @@ --> commons-codec commons-codec - 1.19.0 + ${commons-codec.version} @@ -52,7 +52,7 @@ org.apache.httpcomponents httpclient - 4.5.14 + ${httpclient4.version} diff --git a/hystrix/pom.xml b/hystrix/pom.xml index 34eeaefaa..42e1c9f12 100644 --- a/hystrix/pom.xml +++ b/hystrix/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-hystrix @@ -48,14 +48,14 @@ com.netflix.archaius archaius-core - 0.7.12 + ${archaius-core.version} test com.netflix.hystrix hystrix-core - 1.5.18 + ${hystrix-core.version} diff --git a/hystrix/src/main/java/feign/hystrix/HystrixFeign.java b/hystrix/src/main/java/feign/hystrix/HystrixFeign.java index dee5cd290..54d27a460 100644 --- a/hystrix/src/main/java/feign/hystrix/HystrixFeign.java +++ b/hystrix/src/main/java/feign/hystrix/HystrixFeign.java @@ -18,6 +18,7 @@ import com.netflix.hystrix.HystrixCommand; import feign.Client; import feign.Contract; +import feign.DefaultContract; import feign.Feign; import feign.InvocationHandlerFactory; import feign.Logger; @@ -46,7 +47,7 @@ public static Builder builder() { public static final class Builder extends Feign.Builder { - private Contract contract = new Contract.Default(); + private Contract contract = new DefaultContract(); private SetterFactory setterFactory = new SetterFactory.Default(); /** Allows you to override hystrix properties such as thread pools and command keys. */ diff --git a/hystrix/src/test/java/feign/hystrix/HystrixBuilderTest.java b/hystrix/src/test/java/feign/hystrix/HystrixBuilderTest.java index ed03c7af1..5b48d7b2f 100644 --- a/hystrix/src/test/java/feign/hystrix/HystrixBuilderTest.java +++ b/hystrix/src/test/java/feign/hystrix/HystrixBuilderTest.java @@ -170,7 +170,7 @@ void errorInFallbackHasExpectedBehavior() { server.enqueue(new MockResponse().setResponseCode(500)); final GitHub fallback = - (owner, repo) -> { + (_, _) -> { throw new RuntimeException("oops"); }; @@ -223,7 +223,7 @@ void rxObservable() { final TestSubscriber testSubscriber = new TestSubscriber<>(); observable.subscribe(testSubscriber); testSubscriber.awaitTerminalEvent(); - assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo("foo"); + assertThat(testSubscriber.getOnNextEvents().getFirst()).isEqualTo("foo"); } @Test @@ -240,7 +240,7 @@ void rxObservableFallback() { final TestSubscriber testSubscriber = new TestSubscriber<>(); observable.subscribe(testSubscriber); testSubscriber.awaitTerminalEvent(); - assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo("fallback"); + assertThat(testSubscriber.getOnNextEvents().getFirst()).isEqualTo("fallback"); } @Test @@ -257,7 +257,7 @@ void rxObservableInt() { final TestSubscriber testSubscriber = new TestSubscriber<>(); observable.subscribe(testSubscriber); testSubscriber.awaitTerminalEvent(); - assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo(Integer.valueOf(1)); + assertThat(testSubscriber.getOnNextEvents().getFirst()).isEqualTo(Integer.valueOf(1)); } @Test @@ -274,7 +274,7 @@ void rxObservableIntFallback() { final TestSubscriber testSubscriber = new TestSubscriber<>(); observable.subscribe(testSubscriber); testSubscriber.awaitTerminalEvent(); - assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo(Integer.valueOf(0)); + assertThat(testSubscriber.getOnNextEvents().getFirst()).isEqualTo(Integer.valueOf(0)); } @Test @@ -291,7 +291,7 @@ void rxObservableList() { final TestSubscriber> testSubscriber = new TestSubscriber<>(); observable.subscribe(testSubscriber); testSubscriber.awaitTerminalEvent(); - assertThat(testSubscriber.getOnNextEvents().get(0)).containsExactly("foo", "bar"); + assertThat(testSubscriber.getOnNextEvents().getFirst()).containsExactly("foo", "bar"); } @Test @@ -308,7 +308,7 @@ void rxObservableListFall() { final TestSubscriber> testSubscriber = new TestSubscriber<>(); observable.subscribe(testSubscriber); testSubscriber.awaitTerminalEvent(); - assertThat(testSubscriber.getOnNextEvents().get(0)).containsExactly("fallback"); + assertThat(testSubscriber.getOnNextEvents().getFirst()).containsExactly("fallback"); } @Test @@ -327,7 +327,7 @@ void rxObservableListFall_noFallback() { testSubscriber.awaitTerminalEvent(); assertThat(testSubscriber.getOnNextEvents()).isEmpty(); - assertThat(testSubscriber.getOnErrorEvents().get(0)) + assertThat(testSubscriber.getOnErrorEvents().getFirst()) .isInstanceOf(HystrixRuntimeException.class) .hasMessage("TestInterface#listObservable() failed and no fallback available."); } @@ -346,7 +346,7 @@ void rxSingle() { final TestSubscriber testSubscriber = new TestSubscriber<>(); single.subscribe(testSubscriber); testSubscriber.awaitTerminalEvent(); - assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo("foo"); + assertThat(testSubscriber.getOnNextEvents().getFirst()).isEqualTo("foo"); } @Test @@ -363,7 +363,7 @@ void rxSingleFallback() { final TestSubscriber testSubscriber = new TestSubscriber<>(); single.subscribe(testSubscriber); testSubscriber.awaitTerminalEvent(); - assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo("fallback"); + assertThat(testSubscriber.getOnNextEvents().getFirst()).isEqualTo("fallback"); } @Test @@ -380,7 +380,7 @@ void rxSingleInt() { final TestSubscriber testSubscriber = new TestSubscriber<>(); single.subscribe(testSubscriber); testSubscriber.awaitTerminalEvent(); - assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo(Integer.valueOf(1)); + assertThat(testSubscriber.getOnNextEvents().getFirst()).isEqualTo(Integer.valueOf(1)); } @Test @@ -397,7 +397,7 @@ void rxSingleIntFallback() { final TestSubscriber testSubscriber = new TestSubscriber<>(); single.subscribe(testSubscriber); testSubscriber.awaitTerminalEvent(); - assertThat(testSubscriber.getOnNextEvents().get(0)).isEqualTo(Integer.valueOf(0)); + assertThat(testSubscriber.getOnNextEvents().getFirst()).isEqualTo(Integer.valueOf(0)); } @Test @@ -414,7 +414,7 @@ void rxSingleList() { final TestSubscriber> testSubscriber = new TestSubscriber<>(); single.subscribe(testSubscriber); testSubscriber.awaitTerminalEvent(); - assertThat(testSubscriber.getOnNextEvents().get(0)).containsExactly("foo", "bar"); + assertThat(testSubscriber.getOnNextEvents().getFirst()).containsExactly("foo", "bar"); } @Test @@ -431,7 +431,7 @@ void rxSingleListFallback() { final TestSubscriber> testSubscriber = new TestSubscriber<>(); single.subscribe(testSubscriber); testSubscriber.awaitTerminalEvent(); - assertThat(testSubscriber.getOnNextEvents().get(0)).containsExactly("fallback"); + assertThat(testSubscriber.getOnNextEvents().getFirst()).containsExactly("fallback"); } @Test diff --git a/jackson-jaxb/pom.xml b/jackson-jaxb/pom.xml index 2e79ad6be..7f1fdcdf0 100644 --- a/jackson-jaxb/pom.xml +++ b/jackson-jaxb/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-jackson-jaxb @@ -47,13 +47,13 @@ javax.ws.rs jsr311-api - 1.1.1 + ${jsr311-api.version} javax.ws.rs javax.ws.rs-api - 2.1.1 + ${javax.ws.rs-api.version} test @@ -67,7 +67,7 @@ com.sun.jersey jersey-client - 1.19.4 + ${jersey-client.version} test @@ -81,7 +81,7 @@ com.sun.xml.bind jaxb-impl - 2.3.9 + ${jaxb-impl-2.version} test diff --git a/jackson-jr/pom.xml b/jackson-jr/pom.xml index 061fbb380..af89b3a5d 100644 --- a/jackson-jr/pom.xml +++ b/jackson-jr/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-jackson-jr diff --git a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java index fced9ba2f..3edb1f8dd 100644 --- a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java +++ b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java @@ -22,6 +22,7 @@ import feign.Util; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.JsonDecoder; import java.io.BufferedReader; import java.io.IOException; import java.io.Reader; @@ -30,8 +31,10 @@ import java.util.List; import java.util.Map; -/** A {@link Decoder} that uses Jackson Jr to convert objects to String or byte representation. */ -public class JacksonJrDecoder extends JacksonJrMapper implements Decoder { +/** + * A {@link JsonDecoder} that uses Jackson Jr to convert objects to String or byte representation. + */ +public class JacksonJrDecoder extends JacksonJrMapper implements Decoder, JsonDecoder { @FunctionalInterface protected interface Transformer { @@ -110,4 +113,25 @@ protected Transformer findTransformer(Response response, Type type) { } throw new DecodeException(500, "Cannot decode type: " + type.getTypeName(), response.request()); } + + @Override + public Object convert(Object object, Type type) throws IOException { + String json = mapper.asString(object); + if (type instanceof ParameterizedType) { + ParameterizedType pt = (ParameterizedType) type; + Type rawType = pt.getRawType(); + Type[] args = pt.getActualTypeArguments(); + if (rawType.equals(List.class)) { + return mapper.listOfFrom((Class) args[0], json); + } + if (rawType.equals(Map.class)) { + return mapper.mapOfFrom((Class) args[1], json); + } + type = rawType; + } + if (type instanceof Class) { + return mapper.beanFrom((Class) type, json); + } + throw new IOException("Cannot convert to type: " + type.getTypeName()); + } } diff --git a/jackson-jr/src/test/java/feign/jackson/jr/examples/GitHubExample.java b/jackson-jr/src/test/java/feign/jackson/jr/examples/GitHubExample.java index f33abb4e5..604203f5d 100644 --- a/jackson-jr/src/test/java/feign/jackson/jr/examples/GitHubExample.java +++ b/jackson-jr/src/test/java/feign/jackson/jr/examples/GitHubExample.java @@ -30,10 +30,10 @@ public static void main(String... args) { .decoder(new JacksonJrDecoder()) .target(GitHub.class, "https://api.github.com"); - System.out.println("Let's fetch and print a list of the contributors to this library."); + IO.println("Let's fetch and print a list of the contributors to this library."); List contributors = github.contributors("netflix", "feign"); for (Contributor contributor : contributors) { - System.out.println(contributor.login + " (" + contributor.contributions + ")"); + IO.println(contributor.login + " (" + contributor.contributions + ")"); } } diff --git a/jackson/README.md b/jackson/README.md index 8be632779..31e8cbd43 100644 --- a/jackson/README.md +++ b/jackson/README.md @@ -3,16 +3,15 @@ Jackson Codec This module adds support for encoding and decoding JSON via Jackson. -Add `JacksonEncoder` and/or `JacksonDecoder` to your `Feign.Builder` like so: +Add `JacksonCodec` to your `Feign.Builder` like so: ```java GitHub github = Feign.builder() - .encoder(new JacksonEncoder()) - .decoder(new JacksonDecoder()) + .codec(new JacksonCodec()) .target(GitHub.class, "https://api.github.com"); ``` -If you want to customize the `ObjectMapper` that is used, provide it to the `JacksonEncoder` and `JacksonDecoder`: +If you want to customize the `ObjectMapper` that is used, provide it to the `JacksonCodec`: ```java ObjectMapper mapper = new ObjectMapper() @@ -21,7 +20,15 @@ ObjectMapper mapper = new ObjectMapper() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); GitHub github = Feign.builder() - .encoder(new JacksonEncoder(mapper)) - .decoder(new JacksonDecoder(mapper)) + .codec(new JacksonCodec(mapper)) + .target(GitHub.class, "https://api.github.com"); +``` + +You can also configure the encoder and decoder separately: + +```java +GitHub github = Feign.builder() + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) .target(GitHub.class, "https://api.github.com"); ``` diff --git a/jackson/pom.xml b/jackson/pom.xml index 5035d8b94..9347a731b 100644 --- a/jackson/pom.xml +++ b/jackson/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-jackson diff --git a/jackson/src/main/java/feign/jackson/JacksonCodec.java b/jackson/src/main/java/feign/jackson/JacksonCodec.java new file mode 100644 index 000000000..bb851b23f --- /dev/null +++ b/jackson/src/main/java/feign/jackson/JacksonCodec.java @@ -0,0 +1,55 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.jackson; + +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Experimental; +import feign.codec.Codec; +import feign.codec.JsonCodec; +import feign.codec.JsonDecoder; +import feign.codec.JsonEncoder; + +@Experimental +public class JacksonCodec implements Codec, JsonCodec { + + private final JacksonEncoder encoder; + private final JacksonDecoder decoder; + + public JacksonCodec() { + this(new ObjectMapper()); + } + + public JacksonCodec(Iterable modules) { + this.encoder = new JacksonEncoder(modules); + this.decoder = new JacksonDecoder(modules); + } + + public JacksonCodec(ObjectMapper mapper) { + this.encoder = new JacksonEncoder(mapper); + this.decoder = new JacksonDecoder(mapper); + } + + @Override + public JsonEncoder encoder() { + return encoder; + } + + @Override + public JsonDecoder decoder() { + return decoder; + } +} diff --git a/jackson/src/main/java/feign/jackson/JacksonDecoder.java b/jackson/src/main/java/feign/jackson/JacksonDecoder.java index e3bf26ce2..370f745dc 100644 --- a/jackson/src/main/java/feign/jackson/JacksonDecoder.java +++ b/jackson/src/main/java/feign/jackson/JacksonDecoder.java @@ -22,13 +22,14 @@ import feign.Response; import feign.Util; import feign.codec.Decoder; +import feign.codec.JsonDecoder; import java.io.BufferedReader; import java.io.IOException; import java.io.Reader; import java.lang.reflect.Type; import java.util.Collections; -public class JacksonDecoder implements Decoder { +public class JacksonDecoder implements Decoder, JsonDecoder { private final ObjectMapper mapper; @@ -70,4 +71,9 @@ public Object decode(Response response, Type type) throws IOException { throw e; } } + + @Override + public Object convert(Object object, Type type) { + return mapper.convertValue(object, mapper.constructType(type)); + } } diff --git a/jackson/src/main/java/feign/jackson/JacksonEncoder.java b/jackson/src/main/java/feign/jackson/JacksonEncoder.java index 615854176..48b169a8d 100644 --- a/jackson/src/main/java/feign/jackson/JacksonEncoder.java +++ b/jackson/src/main/java/feign/jackson/JacksonEncoder.java @@ -25,10 +25,11 @@ import feign.Util; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.JsonEncoder; import java.lang.reflect.Type; import java.util.Collections; -public class JacksonEncoder implements Encoder { +public class JacksonEncoder implements Encoder, JsonEncoder { private final ObjectMapper mapper; diff --git a/jackson/src/test/java/feign/jackson/examples/GitHubExample.java b/jackson/src/test/java/feign/jackson/examples/GitHubExample.java index 6f16cd6b5..e7017576e 100644 --- a/jackson/src/test/java/feign/jackson/examples/GitHubExample.java +++ b/jackson/src/test/java/feign/jackson/examples/GitHubExample.java @@ -30,10 +30,10 @@ public static void main(String... args) { .decoder(new JacksonDecoder()) .target(GitHub.class, "https://api.github.com"); - System.out.println("Let's fetch and print a list of the contributors to this library."); + IO.println("Let's fetch and print a list of the contributors to this library."); List contributors = github.contributors("netflix", "feign"); for (Contributor contributor : contributors) { - System.out.println(contributor.login + " (" + contributor.contributions + ")"); + IO.println(contributor.login + " (" + contributor.contributions + ")"); } } diff --git a/jackson/src/test/java/feign/jackson/examples/GitHubIteratorExample.java b/jackson/src/test/java/feign/jackson/examples/GitHubIteratorExample.java index 4d3a6962d..bd5a3987c 100644 --- a/jackson/src/test/java/feign/jackson/examples/GitHubIteratorExample.java +++ b/jackson/src/test/java/feign/jackson/examples/GitHubIteratorExample.java @@ -33,12 +33,12 @@ public static void main(String... args) throws IOException { .doNotCloseAfterDecode() .target(GitHub.class, "https://api.github.com"); - System.out.println("Let's fetch and print a list of the contributors to this library."); + IO.println("Let's fetch and print a list of the contributors to this library."); Iterator contributors = github.contributors("OpenFeign", "feign"); try { while (contributors.hasNext()) { Contributor contributor = contributors.next(); - System.out.println(contributor.login + " (" + contributor.contributions + ")"); + IO.println(contributor.login + " (" + contributor.contributions + ")"); } } finally { ((Closeable) contributors).close(); diff --git a/jackson3/README.md b/jackson3/README.md new file mode 100644 index 000000000..457fd6cce --- /dev/null +++ b/jackson3/README.md @@ -0,0 +1,46 @@ +Jackson 3 Codec +=================== + +This module adds support for encoding and decoding JSON via Jackson 3. + +**Note:** Jackson 3 requires Java 17 or higher. + +Add `Jackson3Codec` to your `Feign.Builder` like so: + +```java +GitHub github = Feign.builder() + .codec(new Jackson3Codec()) + .target(GitHub.class, "https://api.github.com"); +``` + +If you want to customize the `JsonMapper` that is used, provide it to the `Jackson3Codec`: + +```java +JsonMapper mapper = JsonMapper.builder() + .changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.NON_NULL)) + .enable(SerializationFeature.INDENT_OUTPUT) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build(); + +GitHub github = Feign.builder() + .codec(new Jackson3Codec(mapper)) + .target(GitHub.class, "https://api.github.com"); +``` + +You can also configure the encoder and decoder separately: + +```java +GitHub github = Feign.builder() + .encoder(new Jackson3Encoder()) + .decoder(new Jackson3Decoder()) + .target(GitHub.class, "https://api.github.com"); +``` + +## Migration from Jackson 2 to Jackson 3 + +The main differences are: + +- Package changes: `com.fasterxml.jackson` → `tools.jackson` (except for `com.fasterxml.jackson.annotation`) +- GroupId changes: `com.fasterxml.jackson.core` → `tools.jackson.core` +- `ObjectMapper` is immutable and must be configured via `JsonMapper.builder()` +- Java 17 minimum requirement diff --git a/jackson3/pom.xml b/jackson3/pom.xml new file mode 100644 index 000000000..db3a73ea2 --- /dev/null +++ b/jackson3/pom.xml @@ -0,0 +1,66 @@ + + + + 4.0.0 + + + io.github.openfeign + feign-parent + 13.12-SNAPSHOT + + + feign-jackson3 + Feign Jackson 3 + Feign Jackson 3 + + + 17 + + + + + + tools.jackson + jackson-bom + ${jackson3.version} + pom + import + + + + + + + ${project.groupId} + feign-core + + + + tools.jackson.core + jackson-databind + + + + ${project.groupId} + feign-core + test-jar + test + + + diff --git a/jackson3/src/main/java/feign/jackson3/Jackson3Codec.java b/jackson3/src/main/java/feign/jackson3/Jackson3Codec.java new file mode 100644 index 000000000..4413d1031 --- /dev/null +++ b/jackson3/src/main/java/feign/jackson3/Jackson3Codec.java @@ -0,0 +1,55 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.jackson3; + +import feign.Experimental; +import feign.codec.Codec; +import feign.codec.JsonCodec; +import feign.codec.JsonDecoder; +import feign.codec.JsonEncoder; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.json.JsonMapper; + +@Experimental +public class Jackson3Codec implements Codec, JsonCodec { + + private final Jackson3Encoder encoder; + private final Jackson3Decoder decoder; + + public Jackson3Codec() { + this(JsonMapper.builder().build()); + } + + public Jackson3Codec(Iterable modules) { + this.encoder = new Jackson3Encoder(modules); + this.decoder = new Jackson3Decoder(modules); + } + + public Jackson3Codec(JsonMapper mapper) { + this.encoder = new Jackson3Encoder(mapper); + this.decoder = new Jackson3Decoder(mapper); + } + + @Override + public JsonEncoder encoder() { + return encoder; + } + + @Override + public JsonDecoder decoder() { + return decoder; + } +} diff --git a/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java b/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java new file mode 100644 index 000000000..5726d582b --- /dev/null +++ b/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java @@ -0,0 +1,80 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.jackson3; + +import feign.Response; +import feign.Util; +import feign.codec.Decoder; +import feign.codec.JsonDecoder; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.util.Collections; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.json.JsonMapper; + +public class Jackson3Decoder implements Decoder, JsonDecoder { + + private final JsonMapper mapper; + + public Jackson3Decoder() { + this(Collections.emptyList()); + } + + public Jackson3Decoder(Iterable modules) { + this( + JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(modules) + .build()); + } + + public Jackson3Decoder(JsonMapper mapper) { + this.mapper = mapper; + } + + @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; + Reader reader = response.body().asReader(response.charset()); + if (!reader.markSupported()) { + reader = new BufferedReader(reader, 1); + } + try { + // Read the first byte to see if we have any data + reader.mark(1); + if (reader.read() == -1) { + return null; // Eagerly returning null avoids "No content to map due to end-of-input" + } + reader.reset(); + return mapper.readValue(reader, mapper.constructType(type)); + } catch (JacksonException e) { + if (e.getCause() != null && e.getCause() instanceof IOException) { + throw IOException.class.cast(e.getCause()); + } + throw e; + } + } + + @Override + public Object convert(Object object, Type type) { + return mapper.convertValue(object, mapper.constructType(type)); + } +} diff --git a/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java b/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java new file mode 100644 index 000000000..90342c016 --- /dev/null +++ b/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java @@ -0,0 +1,63 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.jackson3; + +import com.fasterxml.jackson.annotation.JsonInclude; +import feign.RequestTemplate; +import feign.Util; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.codec.JsonEncoder; +import java.lang.reflect.Type; +import java.util.Collections; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; + +public class Jackson3Encoder implements Encoder, JsonEncoder { + + private final JsonMapper mapper; + + public Jackson3Encoder() { + this(Collections.emptyList()); + } + + public Jackson3Encoder(Iterable modules) { + this( + JsonMapper.builder() + .changeDefaultPropertyInclusion( + incl -> incl.withValueInclusion(JsonInclude.Include.NON_NULL)) + .enable(SerializationFeature.INDENT_OUTPUT) + .addModules(modules) + .build()); + } + + public Jackson3Encoder(JsonMapper mapper) { + this.mapper = mapper; + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + try { + JavaType javaType = mapper.getTypeFactory().constructType(bodyType); + template.body(mapper.writerFor(javaType).writeValueAsBytes(object), Util.UTF_8); + } catch (JacksonException e) { + throw new EncodeException(e.getMessage(), e); + } + } +} diff --git a/jackson3/src/main/java/feign/jackson3/Jackson3IteratorDecoder.java b/jackson3/src/main/java/feign/jackson3/Jackson3IteratorDecoder.java new file mode 100644 index 000000000..9ba52fa59 --- /dev/null +++ b/jackson3/src/main/java/feign/jackson3/Jackson3IteratorDecoder.java @@ -0,0 +1,199 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.jackson3; + +import static feign.Util.UTF_8; +import static feign.Util.ensureClosed; + +import feign.Response; +import feign.Util; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.Iterator; +import java.util.NoSuchElementException; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.ObjectReader; +import tools.jackson.databind.json.JsonMapper; + +/** + * Jackson 3 decoder which return a closeable iterator. Returned iterator auto-close the {@code + * Response} when it reached json array end or failed to parse stream. If this iterator is not + * fetched till the end, it has to be casted to {@code Closeable} and explicity {@code + * Closeable#close} by the consumer. + * + *

+ * + *

+ * + *

Example:
+ * + *

+ * 
+ * Feign.builder()
+ *   .decoder(Jackson3IteratorDecoder.create())
+ *   .doNotCloseAfterDecode() // Required to fetch the iterator after the response is processed, need to be close
+ *   .target(GitHub.class, "https://api.github.com");
+ * interface GitHub {
+ *  {@literal @}RequestLine("GET /repos/{owner}/{repo}/contributors")
+ *   Iterator contributors(@Param("owner") String owner, @Param("repo") String repo);
+ * }
+ * 
+ */ +public final class Jackson3IteratorDecoder implements Decoder { + + private final JsonMapper mapper; + + Jackson3IteratorDecoder(JsonMapper mapper) { + this.mapper = mapper; + } + + @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; + Reader reader = response.body().asReader(UTF_8); + if (!reader.markSupported()) { + reader = new BufferedReader(reader, 1); + } + try { + // Read the first byte to see if we have any data + reader.mark(1); + if (reader.read() == -1) { + return null; // Eagerly returning null avoids "No content to map due to end-of-input" + } + reader.reset(); + return new Jackson3Iterator( + actualIteratorTypeArgument(type), mapper, response, reader); + } catch (JacksonException e) { + if (e.getCause() != null && e.getCause() instanceof IOException) { + throw IOException.class.cast(e.getCause()); + } + throw e; + } + } + + private static Type actualIteratorTypeArgument(Type type) { + if (!(type instanceof ParameterizedType)) { + throw new IllegalArgumentException("Not supported type " + type.toString()); + } + ParameterizedType parameterizedType = (ParameterizedType) type; + if (!Iterator.class.equals(parameterizedType.getRawType())) { + throw new IllegalArgumentException( + "Not an iterator type " + parameterizedType.getRawType().toString()); + } + return ((ParameterizedType) type).getActualTypeArguments()[0]; + } + + public static Jackson3IteratorDecoder create() { + return create(Collections.emptyList()); + } + + public static Jackson3IteratorDecoder create(Iterable modules) { + return new Jackson3IteratorDecoder( + JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + // Disable FAIL_ON_TRAILING_TOKENS for iterator: we read a JSON array element by + // element, so there are always "trailing tokens" (the remaining array elements) + .disable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .addModules(modules) + .build()); + } + + public static Jackson3IteratorDecoder create(JsonMapper jsonMapper) { + return new Jackson3IteratorDecoder(jsonMapper); + } + + static final class Jackson3Iterator implements Iterator, Closeable { + private final Response response; + private final JsonParser parser; + private final ObjectReader objectReader; + + private T current; + + Jackson3Iterator(Type type, JsonMapper mapper, Response response, Reader reader) + throws IOException { + this.response = response; + this.parser = mapper.createParser(reader); + this.objectReader = mapper.reader().forType(mapper.constructType(type)); + } + + @Override + public boolean hasNext() { + if (current == null) { + current = readNext(); + } + return current != null; + } + + private T readNext() { + try { + JsonToken jsonToken = parser.nextToken(); + if (jsonToken == null) { + return null; + } + + if (jsonToken == JsonToken.START_ARRAY) { + jsonToken = parser.nextToken(); + } + + if (jsonToken == JsonToken.END_ARRAY) { + ensureClosed(this); + return null; + } + + return objectReader.readValue(parser); + } catch (JacksonException e) { + // Input Stream closed automatically by parser + throw new DecodeException(response.status(), e.getMessage(), response.request(), e); + } + } + + @Override + public T next() { + if (current != null) { + T tmp = current; + current = null; + return tmp; + } + T next = readNext(); + if (next == null) { + throw new NoSuchElementException(); + } + return next; + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + + @Override + public void close() throws IOException { + ensureClosed(this.response); + } + } +} diff --git a/jackson3/src/test/java/feign/jackson3/Jackson3CodecTest.java b/jackson3/src/test/java/feign/jackson3/Jackson3CodecTest.java new file mode 100644 index 000000000..d91683033 --- /dev/null +++ b/jackson3/src/test/java/feign/jackson3/Jackson3CodecTest.java @@ -0,0 +1,388 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.jackson3; + +import static feign.Util.UTF_8; +import static feign.assertj.FeignAssertions.assertThat; +import static org.assertj.core.api.Assertions.assertThat; + +import feign.Request; +import feign.Request.HttpMethod; +import feign.RequestTemplate; +import feign.Response; +import feign.Util; +import java.io.Closeable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.deser.std.StdDeserializer; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.ser.std.StdSerializer; + +@SuppressWarnings("deprecation") +class Jackson3CodecTest { + + private String zonesJson = + "" // + + "[" + + System.lineSeparator() // + + " {" + + System.lineSeparator() // + + " \"name\": \"denominator.io.\"" + + System.lineSeparator() // + + " }," + + System.lineSeparator() // + + " {" + + System.lineSeparator() // + + " \"name\": \"denominator.io.\"," + + System.lineSeparator() // + + " \"id\": \"ABCD\"" + + System.lineSeparator() // + + " }" + + System.lineSeparator() // + + "]" + + System.lineSeparator(); + + @Test + void encodesMapObjectNumericalValuesAsInteger() { + Map map = new LinkedHashMap<>(); + map.put("foo", 1); + + RequestTemplate template = new RequestTemplate(); + new Jackson3Encoder().encode(map, map.getClass(), template); + + assertThat(template) + .hasBody( + "" // + + "{" + + System.lineSeparator() // + + " \"foo\" : 1" + + System.lineSeparator() // + + "}"); + } + + @Test + void encodesFormParams() { + Map form = new LinkedHashMap<>(); + form.put("foo", 1); + form.put("bar", Arrays.asList(2, 3)); + + RequestTemplate template = new RequestTemplate(); + new Jackson3Encoder().encode(form, new TypeReference>() {}.getType(), template); + + assertThat(template) + .hasBody( + "" // + + "{" + + System.lineSeparator() // + + " \"foo\" : 1," + + System.lineSeparator() // + + " \"bar\" : [ 2, 3 ]" + + System.lineSeparator() // + + "}"); + } + + @Test + void decodes() throws Exception { + List zones = new LinkedList<>(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "ABCD")); + + Response response = + Response.builder() + .status(200) + .reason("OK") + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(zonesJson, UTF_8) + .build(); + assertThat(new Jackson3Decoder().decode(response, new TypeReference>() {}.getType())) + .isEqualTo(zones); + } + + @Test + void nullBodyDecodesToNull() throws Exception { + Response response = + Response.builder() + .status(204) + .reason("OK") + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .build(); + assertThat(new Jackson3Decoder().decode(response, String.class)).isNull(); + } + + @Test + void emptyBodyDecodesToNull() throws Exception { + Response response = + Response.builder() + .status(204) + .reason("OK") + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(new byte[0]) + .build(); + assertThat(new Jackson3Decoder().decode(response, String.class)).isNull(); + } + + @Test + void customDecoder() throws Exception { + Jackson3Decoder decoder = + new Jackson3Decoder( + Arrays.asList(new SimpleModule().addDeserializer(Zone.class, new ZoneDeserializer()))); + + List zones = new LinkedList<>(); + zones.add(new Zone("DENOMINATOR.IO.")); + zones.add(new Zone("DENOMINATOR.IO.", "ABCD")); + + Response response = + Response.builder() + .status(200) + .reason("OK") + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(zonesJson, UTF_8) + .build(); + assertThat(decoder.decode(response, new TypeReference>() {}.getType())) + .isEqualTo(zones); + } + + @Test + void customEncoder() { + Jackson3Encoder encoder = + new Jackson3Encoder( + Arrays.asList(new SimpleModule().addSerializer(Zone.class, new ZoneSerializer()))); + + List zones = new LinkedList<>(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "abcd")); + + RequestTemplate template = new RequestTemplate(); + encoder.encode(zones, new TypeReference>() {}.getType(), template); + + assertThat(template) + .hasBody( + "" // + + "[ {" + + System.lineSeparator() + + " \"name\" : \"DENOMINATOR.IO.\"" + + System.lineSeparator() + + "}, {" + + System.lineSeparator() + + " \"name\" : \"DENOMINATOR.IO.\"," + + System.lineSeparator() + + " \"id\" : \"ABCD\"" + + System.lineSeparator() + + "} ]"); + } + + @Test + void decoderCharset() throws IOException { + Zone zone = new Zone("denominator.io.", "ÁÉÍÓÚÀÈÌÒÙÄËÏÖÜÑ"); + + Map> headers = new HashMap<>(); + headers.put("Content-Type", Arrays.asList("application/json;charset=ISO-8859-1")); + + Response response = + Response.builder() + .status(200) + .reason("OK") + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(headers) + .body( + new String( + "" // + + "{" + + System.lineSeparator() + + " \"name\" : \"DENOMINATOR.IO.\"," + + System.lineSeparator() + + " \"id\" : \"ÁÉÍÓÚÀÈÌÒÙÄËÏÖÜÑ\"" + + System.lineSeparator() + + "}") + .getBytes(StandardCharsets.ISO_8859_1)) + .build(); + assertThat( + ((Zone) new Jackson3Decoder().decode(response, new TypeReference() {}.getType()))) + .containsEntry("id", zone.get("id")); + } + + @Test + void decodesIterator() throws Exception { + List zones = new LinkedList<>(); + zones.add(new Zone("denominator.io.")); + zones.add(new Zone("denominator.io.", "ABCD")); + + Response response = + Response.builder() + .status(200) + .reason("OK") + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(zonesJson, UTF_8) + .build(); + Object decoded = + Jackson3IteratorDecoder.create() + .decode(response, new TypeReference>() {}.getType()); + assertThat(Iterator.class.isAssignableFrom(decoded.getClass())).isTrue(); + assertThat(Closeable.class.isAssignableFrom(decoded.getClass())).isTrue(); + assertThat(asList((Iterator) decoded)).isEqualTo(zones); + } + + private List asList(Iterator iter) { + final List copy = new ArrayList<>(); + while (iter.hasNext()) { + copy.add(iter.next()); + } + return copy; + } + + @Test + void nullBodyDecodesToEmptyIterator() throws Exception { + Response response = + Response.builder() + .status(204) + .reason("OK") + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .build(); + assertThat((byte[]) Jackson3IteratorDecoder.create().decode(response, byte[].class)).isEmpty(); + } + + @Test + void emptyBodyDecodesToEmptyIterator() throws Exception { + Response response = + Response.builder() + .status(204) + .reason("OK") + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(new byte[0]) + .build(); + assertThat((byte[]) Jackson3IteratorDecoder.create().decode(response, byte[].class)).isEmpty(); + } + + static class Zone extends LinkedHashMap { + + private static final long serialVersionUID = 1L; + + Zone() { + // for reflective instantiation. + } + + Zone(String name) { + this(name, null); + } + + Zone(String name, String id) { + put("name", name); + if (id != null) { + put("id", id); + } + } + } + + static class ZoneDeserializer extends StdDeserializer { + + public ZoneDeserializer() { + super(Zone.class); + } + + @Override + public Zone deserialize(JsonParser jp, DeserializationContext ctxt) { + Zone zone = new Zone(); + jp.nextToken(); + while (jp.nextToken() != JsonToken.END_OBJECT) { + String name = jp.currentName(); + String value = jp.getValueAsString(); + if (value != null) { + zone.put(name, value.toUpperCase()); + } + } + return zone; + } + } + + static class ZoneSerializer extends StdSerializer { + + public ZoneSerializer() { + super(Zone.class); + } + + @Override + public void serialize(Zone value, JsonGenerator jgen, SerializationContext provider) + throws JacksonException { + jgen.writeStartObject(); + for (Map.Entry entry : value.entrySet()) { + jgen.writeName(entry.getKey()); + jgen.writeString(entry.getValue().toString().toUpperCase()); + } + jgen.writeEndObject(); + } + } + + /** Enabled via {@link feign.Feign.Builder#dismiss404()} */ + @Test + void notFoundDecodesToEmpty() throws Exception { + Response response = + Response.builder() + .status(404) + .reason("NOT FOUND") + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .build(); + assertThat((byte[]) new Jackson3Decoder().decode(response, byte[].class)).isEmpty(); + } + + /** Enabled via {@link feign.Feign.Builder#dismiss404()} */ + @Test + void notFoundDecodesToEmptyIterator() throws Exception { + Response response = + Response.builder() + .status(404) + .reason("NOT FOUND") + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .build(); + assertThat((byte[]) Jackson3IteratorDecoder.create().decode(response, byte[].class)).isEmpty(); + } +} diff --git a/jackson3/src/test/java/feign/jackson3/examples/GitHubExample.java b/jackson3/src/test/java/feign/jackson3/examples/GitHubExample.java new file mode 100644 index 000000000..4bced86a9 --- /dev/null +++ b/jackson3/src/test/java/feign/jackson3/examples/GitHubExample.java @@ -0,0 +1,59 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.jackson3.examples; + +import feign.Feign; +import feign.Param; +import feign.RequestLine; +import feign.jackson3.Jackson3Decoder; +import java.util.List; + +/** adapted from {@code com.example.retrofit.GitHubClient} */ +public class GitHubExample { + + public static void main(String... args) { + GitHub github = + Feign.builder() + .decoder(new Jackson3Decoder()) + .target(GitHub.class, "https://api.github.com"); + + System.out.println("Let's fetch and print a list of the contributors to this library."); + List contributors = github.contributors("netflix", "feign"); + for (Contributor contributor : contributors) { + System.out.println(contributor.login + " (" + contributor.contributions + ")"); + } + } + + interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + List contributors(@Param("owner") String owner, @Param("repo") String repo); + } + + static class Contributor { + + private String login; + private int contributions; + + void setLogin(String login) { + this.login = login; + } + + void setContributions(int contributions) { + this.contributions = contributions; + } + } +} diff --git a/jackson3/src/test/java/feign/jackson3/examples/GitHubIteratorExample.java b/jackson3/src/test/java/feign/jackson3/examples/GitHubIteratorExample.java new file mode 100644 index 000000000..9bca01424 --- /dev/null +++ b/jackson3/src/test/java/feign/jackson3/examples/GitHubIteratorExample.java @@ -0,0 +1,67 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.jackson3.examples; + +import feign.Feign; +import feign.Param; +import feign.RequestLine; +import feign.jackson3.Jackson3IteratorDecoder; +import java.io.Closeable; +import java.io.IOException; +import java.util.Iterator; + +/** adapted from {@code com.example.retrofit.GitHubClient} */ +public class GitHubIteratorExample { + + public static void main(String... args) throws IOException { + GitHub github = + Feign.builder() + .decoder(Jackson3IteratorDecoder.create()) + .doNotCloseAfterDecode() + .target(GitHub.class, "https://api.github.com"); + + System.out.println("Let's fetch and print a list of the contributors to this library."); + Iterator contributors = github.contributors("OpenFeign", "feign"); + try { + while (contributors.hasNext()) { + Contributor contributor = contributors.next(); + System.out.println(contributor.login + " (" + contributor.contributions + ")"); + } + } finally { + ((Closeable) contributors).close(); + } + } + + interface GitHub { + + @RequestLine("GET /repos/{owner}/{repo}/contributors") + Iterator contributors(@Param("owner") String owner, @Param("repo") String repo); + } + + static class Contributor { + + private String login; + private int contributions; + + void setLogin(String login) { + this.login = login; + } + + void setContributions(int contributions) { + this.contributions = contributions; + } + } +} diff --git a/jakarta/pom.xml b/jakarta/pom.xml index e1cff9985..be5af8600 100644 --- a/jakarta/pom.xml +++ b/jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-jakarta diff --git a/java11/pom.xml b/java11/pom.xml index deb84b4e0..4a9d9d05b 100644 --- a/java11/pom.xml +++ b/java11/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-java11 diff --git a/java11/src/test/java/feign/http2client/test/Http2ClientAsyncTest.java b/java11/src/test/java/feign/http2client/test/Http2ClientAsyncTest.java index 52b177733..0d3cd6570 100644 --- a/java11/src/test/java/feign/http2client/test/Http2ClientAsyncTest.java +++ b/java11/src/test/java/feign/http2client/test/Http2ClientAsyncTest.java @@ -25,10 +25,10 @@ import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; -import feign.AsyncClient; import feign.AsyncFeign; import feign.Body; import feign.ChildPojo; +import feign.DefaultAsyncClient; import feign.Feign; import feign.Feign.ResponseMappingDecoder; import feign.FeignException; @@ -50,6 +50,9 @@ import feign.Util; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.DefaultDecoder; +import feign.codec.DefaultEncoder; +import feign.codec.DefaultErrorDecoder; import feign.codec.EncodeException; import feign.codec.Encoder; import feign.codec.ErrorDecoder; @@ -166,7 +169,7 @@ void bodyTypeCorrespondsWithParameterType() throws Exception { final TestInterfaceAsync api = newAsyncBuilder() .encoder( - new Encoder.Default() { + new DefaultEncoder() { @Override public void encode(Object object, Type bodyType, RequestTemplate template) { encodedType.set(bodyType); @@ -460,7 +463,7 @@ void configKeyUsesChildType() throws Exception { private T unwrap(CompletableFuture cf) throws Throwable { try { - return cf.get(1, TimeUnit.SECONDS); + return cf.get(10, TimeUnit.SECONDS); } catch (final ExecutionException e) { throw e.getCause(); } @@ -484,9 +487,7 @@ void overrideTypeSpecificDecoder() throws Throwable { server.enqueue(new MockResponse().setBody("success!")); final TestInterfaceAsync api = - newAsyncBuilder() - .decoder((response, type) -> "fail") - .target("http://localhost:" + server.getPort()); + newAsyncBuilder().decoder((_, _) -> "fail").target("http://localhost:" + server.getPort()); assertThat(unwrap(api.post())).isEqualTo("fail"); } @@ -498,7 +499,7 @@ void doesntRetryAfterResponseIsSent() throws Throwable { final TestInterfaceAsync api = newAsyncBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target("http://localhost:" + server.getPort()); @@ -516,7 +517,7 @@ void throwsFeignExceptionIncludingBody() throws Throwable { final TestInterfaceAsync api = newAsyncBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target("http://localhost:" + server.getPort()); @@ -541,7 +542,7 @@ void throwsFeignExceptionWithoutBody() { final TestInterfaceAsync api = newAsyncBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target("http://localhost:" + server.getPort()); @@ -574,7 +575,7 @@ void whenReturnTypeIsResponseNoErrorHandling() throws Throwable { // fake client as Client.Default follows redirects. final TestInterfaceAsync api = AsyncFeign.builder() - .client(new AsyncClient.Default<>((request, options) -> response, execs)) + .client(new DefaultAsyncClient<>((_, _) -> response, execs)) .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort()); assertThat(unwrap(api.response()).headers().get("Location")).contains("http://bar.com"); @@ -589,7 +590,7 @@ void okIfDecodeRootCauseHasNoMessage() throws Throwable { final TestInterfaceAsync api = newAsyncBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new RuntimeException(); }) .target("http://localhost:" + server.getPort()); @@ -605,7 +606,7 @@ void decodingExceptionGetWrappedInDismiss404Mode() throws Throwable { newAsyncBuilder() .dismiss404() .decoder( - (response, type) -> { + (response, _) -> { assertEquals(404, response.status()); throw new NoSuchElementException(); }) @@ -642,7 +643,7 @@ void okIfEncodeRootCauseHasNoMessage() throws Throwable { final TestInterfaceAsync api = newAsyncBuilder() .encoder( - (object, bodyType, template) -> { + (_, _, _) -> { throw new RuntimeException(); }) .target("http://localhost:" + server.getPort()); @@ -704,7 +705,7 @@ void encodeLogicSupportsByteArray() throws Exception { final OtherTestInterfaceAsync api = newAsyncBuilder() - .encoder(new Encoder.Default()) + .encoder(new DefaultEncoder()) .target( new HardCodedTarget<>( OtherTestInterfaceAsync.class, "http://localhost:" + server.getPort())); @@ -753,7 +754,7 @@ private static TestInterfaceAsyncBuilder newAsyncBuilder() { } private ResponseMapper upperCaseResponseMapper() { - return (response, type) -> { + return (response, _) -> { try { return response.toBuilder() .body(Util.toString(response.body().asReader()).toUpperCase().getBytes()) @@ -996,7 +997,7 @@ public void apply(RequestTemplate template) { } } - static class IllegalArgumentExceptionOn400 extends ErrorDecoder.Default { + static class IllegalArgumentExceptionOn400 extends DefaultErrorDecoder { @Override public Exception decode(String methodKey, Response response) { @@ -1007,7 +1008,7 @@ public Exception decode(String methodKey, Response response) { } } - static class IllegalArgumentExceptionOn404 extends ErrorDecoder.Default { + static class IllegalArgumentExceptionOn404 extends DefaultErrorDecoder { @Override public Exception decode(String methodKey, Response response) { @@ -1023,9 +1024,9 @@ static final class TestInterfaceAsyncBuilder { private final AsyncFeign.AsyncBuilder delegate = AsyncFeign.builder() .client(new Http2Client()) - .decoder(new Decoder.Default()) + .decoder(new DefaultDecoder()) .encoder( - (object, bodyType, template) -> { + (object, _, template) -> { if (object instanceof Map) { template.body(new Gson().toJson(object)); } else { diff --git a/jaxb-jakarta/pom.xml b/jaxb-jakarta/pom.xml index 44b40a63b..1dd83992b 100644 --- a/jaxb-jakarta/pom.xml +++ b/jaxb-jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-jaxb-jakarta @@ -50,14 +50,14 @@ jakarta.xml.bind jakarta.xml.bind-api - 4.0.2 + ${jakarta.xml.bind-api.version} com.sun.xml.bind jaxb-impl - 4.0.3 + ${jaxb-impl-4.version} test diff --git a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBCodec.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBCodec.java new file mode 100644 index 000000000..4c351c008 --- /dev/null +++ b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBCodec.java @@ -0,0 +1,43 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.jaxb; + +import feign.Experimental; +import feign.codec.Codec; +import feign.codec.Decoder; +import feign.codec.Encoder; + +@Experimental +public class JAXBCodec implements Codec { + + private final JAXBEncoder encoder; + private final JAXBDecoder decoder; + + public JAXBCodec(JAXBContextFactory jaxbContextFactory) { + this.encoder = new JAXBEncoder(jaxbContextFactory); + this.decoder = new JAXBDecoder(jaxbContextFactory); + } + + @Override + public Encoder encoder() { + return encoder; + } + + @Override + public Decoder decoder() { + return decoder; + } +} diff --git a/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java index fa3dfabb9..d79d3c472 100644 --- a/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java +++ b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java @@ -148,7 +148,7 @@ void encodesXmlWithCustomJAXBNoNamespaceSchemaLocation() throws Exception { assertThat(template) .hasBody( - """ +""" \ @@ -350,7 +350,7 @@ void decodesIgnoringErrorsWithEventHandler() throws Exception { JAXBContextFactory factory = new JAXBContextFactory.Builder() .withUnmarshallerSchema(getMockIntObjSchema()) - .withUnmarshallerEventHandler(event -> true) + .withUnmarshallerEventHandler(_ -> true) .build(); assertThat(new JAXBDecoder(factory).decode(response, MockIntObject.class)) .isEqualTo(new MockIntObject()); @@ -378,7 +378,7 @@ void encodesIgnoringErrorsWithEventHandler() throws Exception { JAXBContextFactory jaxbContextFactory = new JAXBContextFactory.Builder() .withMarshallerSchema(getMockIntObjSchema()) - .withMarshallerEventHandler(event -> true) + .withMarshallerEventHandler(_ -> true) .build(); Encoder encoder = new JAXBEncoder(jaxbContextFactory); @@ -420,7 +420,7 @@ public int hashCode() { private static Schema getMockIntObjSchema() throws Exception { String schema = - """ +""" \ \ diff --git a/jaxb-jakarta/src/test/java/feign/jaxb/JAXBContextFactoryTest.java b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBContextFactoryTest.java index 14c12216b..e3301ec76 100644 --- a/jaxb-jakarta/src/test/java/feign/jaxb/JAXBContextFactoryTest.java +++ b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBContextFactoryTest.java @@ -106,7 +106,7 @@ void buildsUnmarshallerWithSchema() throws Exception { @Test void buildsMarshallerWithCustomEventHandler() throws Exception { - ValidationEventHandler handler = event -> false; + ValidationEventHandler handler = _ -> false; JAXBContextFactory factory = new JAXBContextFactory.Builder().withMarshallerEventHandler(handler).build(); @@ -124,7 +124,7 @@ void buildsMarshallerWithDefaultEventHandler() throws Exception { @Test void buildsUnmarshallerWithCustomEventHandler() throws Exception { - ValidationEventHandler handler = event -> false; + ValidationEventHandler handler = _ -> false; JAXBContextFactory factory = new JAXBContextFactory.Builder().withUnmarshallerEventHandler(handler).build(); diff --git a/jaxb-jakarta/src/test/java/feign/jaxb/examples/IAMExample.java b/jaxb-jakarta/src/test/java/feign/jaxb/examples/IAMExample.java index e04275855..d62cef365 100644 --- a/jaxb-jakarta/src/test/java/feign/jaxb/examples/IAMExample.java +++ b/jaxb-jakarta/src/test/java/feign/jaxb/examples/IAMExample.java @@ -37,7 +37,7 @@ public static void main(String... args) { .target(new IAMTarget(args[0], args[1])); GetUserResponse response = iam.userResponse(); - System.out.println("UserId: " + response.result.user.id); + IO.println("UserId: " + response.result.user.id); } interface IAM { diff --git a/jaxb/README.md b/jaxb/README.md index 1d36725f1..8ba82407a 100644 --- a/jaxb/README.md +++ b/jaxb/README.md @@ -3,7 +3,7 @@ JAXB Codec This module adds support for encoding and decoding XML via JAXB. -Add `JAXBEncoder` and/or `JAXBDecoder` to your `Feign.Builder` like so: +Add `JAXBCodec` to your `Feign.Builder` like so: ```java JAXBContextFactory jaxbFactory = new JAXBContextFactory.Builder() @@ -11,6 +11,14 @@ JAXBContextFactory jaxbFactory = new JAXBContextFactory.Builder() .withMarshallerSchemaLocation("http://apihost http://apihost/schema.xsd") .build(); +Response response = Feign.builder() + .codec(new JAXBCodec(jaxbFactory)) + .target(Response.class, "https://apihost"); +``` + +You can also configure the encoder and decoder separately: + +```java Response response = Feign.builder() .encoder(new JAXBEncoder(jaxbFactory)) .decoder(new JAXBDecoder(jaxbFactory)) diff --git a/jaxb/pom.xml b/jaxb/pom.xml index 99ab6003d..bc8c15a21 100644 --- a/jaxb/pom.xml +++ b/jaxb/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-jaxb @@ -44,14 +44,14 @@ javax.xml.bind jaxb-api - 2.3.1 + ${jaxb-api.version} com.sun.xml.bind jaxb-impl - 2.3.9 + ${jaxb-impl-2.version} test diff --git a/jaxb/src/main/java/feign/jaxb/JAXBCodec.java b/jaxb/src/main/java/feign/jaxb/JAXBCodec.java new file mode 100644 index 000000000..4c351c008 --- /dev/null +++ b/jaxb/src/main/java/feign/jaxb/JAXBCodec.java @@ -0,0 +1,43 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.jaxb; + +import feign.Experimental; +import feign.codec.Codec; +import feign.codec.Decoder; +import feign.codec.Encoder; + +@Experimental +public class JAXBCodec implements Codec { + + private final JAXBEncoder encoder; + private final JAXBDecoder decoder; + + public JAXBCodec(JAXBContextFactory jaxbContextFactory) { + this.encoder = new JAXBEncoder(jaxbContextFactory); + this.decoder = new JAXBDecoder(jaxbContextFactory); + } + + @Override + public Encoder encoder() { + return encoder; + } + + @Override + public Decoder decoder() { + return decoder; + } +} diff --git a/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java b/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java index 2eccb4101..464d857b8 100644 --- a/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java +++ b/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java @@ -148,7 +148,7 @@ void encodesXmlWithCustomJAXBNoNamespaceSchemaLocation() throws Exception { assertThat(template) .hasBody( - """ +""" \ @@ -350,7 +350,7 @@ void decodesIgnoringErrorsWithEventHandler() throws Exception { JAXBContextFactory factory = new JAXBContextFactory.Builder() .withUnmarshallerSchema(getMockIntObjSchema()) - .withUnmarshallerEventHandler(event -> true) + .withUnmarshallerEventHandler(_ -> true) .build(); assertThat(new JAXBDecoder(factory).decode(response, MockIntObject.class)) .isEqualTo(new MockIntObject()); @@ -379,7 +379,7 @@ void encodesIgnoringErrorsWithEventHandler() throws Exception { JAXBContextFactory jaxbContextFactory = new JAXBContextFactory.Builder() .withMarshallerSchema(getMockIntObjSchema()) - .withMarshallerEventHandler(event -> true) + .withMarshallerEventHandler(_ -> true) .build(); Encoder encoder = new JAXBEncoder(jaxbContextFactory); @@ -421,7 +421,7 @@ public int hashCode() { private static Schema getMockIntObjSchema() throws Exception { String schema = - """ +""" \ \ diff --git a/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java b/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java index 5d4ab4d87..baee12e45 100644 --- a/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java +++ b/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java @@ -106,7 +106,7 @@ void buildsUnmarshallerWithSchema() throws Exception { @Test void buildsMarshallerWithCustomEventHandler() throws Exception { - ValidationEventHandler handler = event -> false; + ValidationEventHandler handler = _ -> false; JAXBContextFactory factory = new JAXBContextFactory.Builder().withMarshallerEventHandler(handler).build(); @@ -124,7 +124,7 @@ void buildsMarshallerWithDefaultEventHandler() throws Exception { @Test void buildsUnmarshallerWithCustomEventHandler() throws Exception { - ValidationEventHandler handler = event -> false; + ValidationEventHandler handler = _ -> false; JAXBContextFactory factory = new JAXBContextFactory.Builder().withUnmarshallerEventHandler(handler).build(); diff --git a/jaxb/src/test/java/feign/jaxb/examples/IAMExample.java b/jaxb/src/test/java/feign/jaxb/examples/IAMExample.java index 53ea7d30d..d73a4a25b 100644 --- a/jaxb/src/test/java/feign/jaxb/examples/IAMExample.java +++ b/jaxb/src/test/java/feign/jaxb/examples/IAMExample.java @@ -37,7 +37,7 @@ public static void main(String... args) { .target(new IAMTarget(args[0], args[1])); GetUserResponse response = iam.userResponse(); - System.out.println("UserId: " + response.result.user.id); + IO.println("UserId: " + response.result.user.id); } interface IAM { diff --git a/jaxrs/pom.xml b/jaxrs/pom.xml index 1584af597..6d6fcff7b 100644 --- a/jaxrs/pom.xml +++ b/jaxrs/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-jaxrs @@ -38,7 +38,7 @@ javax.ws.rs jsr311-api - 1.1.1 + ${jsr311-api.version} @@ -71,7 +71,7 @@ org.eclipse.transformer org.eclipse.transformer.maven - 0.2.0 + ${org.eclipse.transformer.maven.version} jakarta-ee diff --git a/jaxrs/src/test/java/feign/jaxrs/examples/GitHubExample.java b/jaxrs/src/test/java/feign/jaxrs/examples/GitHubExample.java index c579a1482..f8e167af1 100644 --- a/jaxrs/src/test/java/feign/jaxrs/examples/GitHubExample.java +++ b/jaxrs/src/test/java/feign/jaxrs/examples/GitHubExample.java @@ -31,10 +31,10 @@ public static void main(String... args) throws InterruptedException { .contract(new JAXRSContract()) .target(GitHub.class, "https://api.github.com"); - System.out.println("Let's fetch and print a list of the contributors to this library."); + IO.println("Let's fetch and print a list of the contributors to this library."); List contributors = github.contributors("netflix", "feign"); for (Contributor contributor : contributors) { - System.out.println(contributor.login + " (" + contributor.contributions + ")"); + IO.println(contributor.login + " (" + contributor.contributions + ")"); } } diff --git a/jaxrs2/pom.xml b/jaxrs2/pom.xml index 2ddaf0f7f..7e8983a0d 100644 --- a/jaxrs2/pom.xml +++ b/jaxrs2/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-jaxrs2 @@ -31,7 +31,7 @@ true - 2.45 + 2.48 @@ -43,7 +43,7 @@ javax.ws.rs javax.ws.rs-api - 2.1.1 + ${javax.ws.rs-api.version} @@ -118,7 +118,7 @@ org.eclipse.transformer org.eclipse.transformer.maven - 0.2.0 + ${org.eclipse.transformer.maven.version} jakarta-ee diff --git a/jaxrs2/src/test/java/feign/jaxrs2/AbstractJAXRSClientTest.java b/jaxrs2/src/test/java/feign/jaxrs2/AbstractJAXRSClientTest.java index aaace9106..21936f0e0 100644 --- a/jaxrs2/src/test/java/feign/jaxrs2/AbstractJAXRSClientTest.java +++ b/jaxrs2/src/test/java/feign/jaxrs2/AbstractJAXRSClientTest.java @@ -39,7 +39,7 @@ public abstract class AbstractJAXRSClientTest extends AbstractClientTest { public void patch() throws Exception { try { super.patch(); - } catch (final RuntimeException e) { + } catch (final RuntimeException _) { Assumptions.assumeFalse(false, "JaxRS client do not support PATCH requests"); } } @@ -48,7 +48,7 @@ public void patch() throws Exception { public void noResponseBodyForPut() throws Exception { try { super.noResponseBodyForPut(); - } catch (final IllegalStateException e) { + } catch (final IllegalStateException _) { Assumptions.assumeFalse(false, "JaxRS client do not support empty bodies on PUT"); } } @@ -57,7 +57,7 @@ public void noResponseBodyForPut() throws Exception { public void noResponseBodyForPatch() { try { super.noResponseBodyForPatch(); - } catch (final IllegalStateException e) { + } catch (final IllegalStateException _) { Assumptions.assumeFalse(false, "JaxRS client do not support PATCH requests"); } } diff --git a/jaxrs3/pom.xml b/jaxrs3/pom.xml index 762cacd2a..5b13d3e4e 100644 --- a/jaxrs3/pom.xml +++ b/jaxrs3/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-jaxrs3 @@ -31,7 +31,7 @@ 11 - 3.1.8 + 3.1.11 @@ -49,7 +49,7 @@ jakarta.ws.rs jakarta.ws.rs-api - 3.1.0 + ${jakarta.ws.rs-api-3.version} diff --git a/jaxrs4/pom.xml b/jaxrs4/pom.xml index fde82d6bd..42f7cbae5 100644 --- a/jaxrs4/pom.xml +++ b/jaxrs4/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-jaxrs4 @@ -31,7 +31,7 @@ 17 - 4.0.0-M1 + 4.0.2 @@ -43,7 +43,7 @@ jakarta.ws.rs jakarta.ws.rs-api - 4.0.0 + ${jakarta.ws.rs-api-4.version} diff --git a/json/pom.xml b/json/pom.xml index 127fd1b48..a11e3d45d 100644 --- a/json/pom.xml +++ b/json/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-json diff --git a/json/src/main/java/feign/json/JsonDecoder.java b/json/src/main/java/feign/json/JsonDecoder.java index bd60a6fd0..edf7fd80f 100644 --- a/json/src/main/java/feign/json/JsonDecoder.java +++ b/json/src/main/java/feign/json/JsonDecoder.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.io.Reader; import java.lang.reflect.Type; +import java.util.Map; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -52,12 +53,13 @@ * System.out.println(contributors.getJSONObject(0).getString("login")); * */ -public class JsonDecoder implements Decoder { +public class JsonDecoder implements Decoder, feign.codec.JsonDecoder { @Override public Object decode(Response response, Type type) throws IOException, DecodeException { if (response.status() == 404 || response.status() == 204) - if (JSONObject.class.isAssignableFrom((Class) type)) return new JSONObject(); + if (Map.class.equals(type)) return null; + else if (JSONObject.class.isAssignableFrom((Class) type)) return new JSONObject(); else if (JSONArray.class.isAssignableFrom((Class) type)) return new JSONArray(); else if (String.class.equals(type)) return null; else @@ -86,7 +88,8 @@ public Object decode(Response response, Type type) throws IOException, DecodeExc private Object decodeBody(Response response, Type type, Reader reader) throws IOException { if (String.class.equals(type)) return Util.toString(reader); JSONTokener tokenizer = new JSONTokener(reader); - if (JSONObject.class.isAssignableFrom((Class) type)) return new JSONObject(tokenizer); + if (Map.class.equals(type)) return new JSONObject(tokenizer).toMap(); + else if (JSONObject.class.isAssignableFrom((Class) type)) return new JSONObject(tokenizer); else if (JSONArray.class.isAssignableFrom((Class) type)) return new JSONArray(tokenizer); else throw new DecodeException( @@ -94,4 +97,21 @@ private Object decodeBody(Response response, Type type, Reader reader) throws IO format("%s is not a type supported by this decoder.", type), response.request()); } + + @Override + public Object convert(Object object, Type type) throws IOException { + if (type instanceof Class) { + Class cls = (Class) type; + if (cls == JSONObject.class && object instanceof Map) { + return new JSONObject((Map) object); + } + if (cls == String.class) { + return object.toString(); + } + } + if (object instanceof Map) { + return new JSONObject((Map) object); + } + throw new IOException(type.getTypeName() + " is not a type supported by this decoder."); + } } diff --git a/json/src/test/java/feign/json/examples/GitHubExample.java b/json/src/test/java/feign/json/examples/GitHubExample.java index 4b122a852..aca185a5e 100644 --- a/json/src/test/java/feign/json/examples/GitHubExample.java +++ b/json/src/test/java/feign/json/examples/GitHubExample.java @@ -35,11 +35,11 @@ public static void main(String... args) { GitHub github = Feign.builder().decoder(new JsonDecoder()).target(GitHub.class, "https://api.github.com"); - System.out.println("Let's fetch and print a list of the contributors to this library."); + IO.println("Let's fetch and print a list of the contributors to this library."); JSONArray contributors = github.contributors("netflix", "feign"); contributors.forEach( contributor -> { - System.out.println(((JSONObject) contributor).getString("login")); + IO.println(((JSONObject) contributor).getString("login")); }); } } diff --git a/kotlin/pom.xml b/kotlin/pom.xml index 19ed595cb..9ad1bc723 100644 --- a/kotlin/pom.xml +++ b/kotlin/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-kotlin @@ -30,7 +30,7 @@ Feign Kotlin - 2.2.0 + 2.3.20 1.10.2 diff --git a/kotlin/src/main/java/feign/kotlin/CoroutineFeign.java b/kotlin/src/main/java/feign/kotlin/CoroutineFeign.java index 9f6c912a5..4027e1d14 100644 --- a/kotlin/src/main/java/feign/kotlin/CoroutineFeign.java +++ b/kotlin/src/main/java/feign/kotlin/CoroutineFeign.java @@ -19,7 +19,8 @@ import feign.AsyncContextSupplier; import feign.AsyncFeign; import feign.BaseBuilder; -import feign.Client; +import feign.DefaultAsyncClient; +import feign.DefaultClient; import feign.Experimental; import feign.MethodInfoResolver; import feign.Target; @@ -118,8 +119,8 @@ public static class CoroutineBuilder private AsyncContextSupplier defaultContextSupplier = () -> null; private AsyncClient client = - new AsyncClient.Default<>( - new Client.Default(null, null), LazyInitializedExecutorService.instance); + new DefaultAsyncClient<>( + new DefaultClient(null, null), LazyInitializedExecutorService.instance); private MethodInfoResolver methodInfoResolver = KotlinMethodInfo::createInstance; @Deprecated diff --git a/kotlin/src/test/kotlin/feign/kotlin/CoroutineFeignTest.kt b/kotlin/src/test/kotlin/feign/kotlin/CoroutineFeignTest.kt index d60b980f1..011ee32ac 100644 --- a/kotlin/src/test/kotlin/feign/kotlin/CoroutineFeignTest.kt +++ b/kotlin/src/test/kotlin/feign/kotlin/CoroutineFeignTest.kt @@ -24,6 +24,7 @@ import feign.RequestLine import feign.Response import feign.Util import feign.codec.Decoder +import feign.codec.DefaultDecoder import feign.codec.Encoder import feign.codec.ErrorDecoder import kotlinx.coroutines.runBlocking @@ -148,7 +149,7 @@ class CoroutineFeignTest { internal class TestInterfaceAsyncBuilder { private val delegate = CoroutineFeign.builder() - .decoder(Decoder.Default()).encoder { `object`, bodyType, template -> + .decoder(DefaultDecoder()).encoder { `object`, bodyType, template -> if (`object` is Map<*, *>) { template.body(Gson().toJson(`object`)) } else { diff --git a/micrometer/pom.xml b/micrometer/pom.xml index c6fd73623..58d5976a2 100644 --- a/micrometer/pom.xml +++ b/micrometer/pom.xml @@ -21,14 +21,14 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-micrometer Feign Micrometer Feign Micrometer Application Metrics - 1.15.2 + 1.16.4 diff --git a/micrometer/src/test/java/feign/micrometer/AbstractMetricsTestBase.java b/micrometer/src/test/java/feign/micrometer/AbstractMetricsTestBase.java index 36663d7ab..9f55299c4 100644 --- a/micrometer/src/test/java/feign/micrometer/AbstractMetricsTestBase.java +++ b/micrometer/src/test/java/feign/micrometer/AbstractMetricsTestBase.java @@ -159,7 +159,7 @@ void clientPropagatesUncheckedException() { customizeBuilder( Feign.builder() .client( - (request, options) -> { + (request, _) -> { notFound.set(new FeignException.NotFound("test", request, null, null)); throw notFound.get(); }) @@ -196,7 +196,7 @@ void decoderPropagatesUncheckedException() { Feign.builder() .client(new MockClient().ok(HttpMethod.GET, "/get", "1234567890abcde")) .decoder( - (response, type) -> { + (response, _) -> { notFound.set( new FeignException.NotFound("test", response.request(), null, null)); throw notFound.get(); @@ -215,7 +215,7 @@ void shouldMetricCollectionWithCustomException() { customizeBuilder( Feign.builder() .client( - (request, options) -> { + (_, _) -> { throw new RuntimeException("Test error"); }) .addCapability(createMetricCapability())) @@ -304,7 +304,7 @@ void decoderExceptionCounterHasUriLabelWithPathExpression() { Feign.builder() .client(new MockClient().ok(HttpMethod.GET, "/get/123", "1234567890abcde")) .decoder( - (response, type) -> { + (response, _) -> { notFound.set( new FeignException.NotFound("test", response.request(), null, null)); throw notFound.get(); diff --git a/mock/pom.xml b/mock/pom.xml index 02a2863ed..698cff215 100644 --- a/mock/pom.xml +++ b/mock/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-mock diff --git a/moshi/README.md b/moshi/README.md index 1ffdb6fae..6715ad4e7 100644 --- a/moshi/README.md +++ b/moshi/README.md @@ -3,7 +3,15 @@ Moshi Codec This module adds support for encoding and decoding JSON via the Moshi library. -Add `MoshiEncoder` and/or `MoshiDecoder` to your `Feign.Builder` like so: +Add `MoshiCodec` to your `Feign.Builder` like so: + +```java +GitHub github = Feign.builder() + .codec(new MoshiCodec()) + .target(GitHub.class, "https://api.github.com"); +``` + +You can also configure the encoder and decoder separately: ```java GitHub github = Feign.builder() diff --git a/moshi/pom.xml b/moshi/pom.xml index 2b3d51cf1..d80167cee 100644 --- a/moshi/pom.xml +++ b/moshi/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-moshi diff --git a/moshi/src/main/java/feign/moshi/MoshiCodec.java b/moshi/src/main/java/feign/moshi/MoshiCodec.java new file mode 100644 index 000000000..e5270d22d --- /dev/null +++ b/moshi/src/main/java/feign/moshi/MoshiCodec.java @@ -0,0 +1,55 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.moshi; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.Moshi; +import feign.Experimental; +import feign.codec.Codec; +import feign.codec.JsonCodec; +import feign.codec.JsonDecoder; +import feign.codec.JsonEncoder; + +@Experimental +public class MoshiCodec implements Codec, JsonCodec { + + private final MoshiEncoder encoder; + private final MoshiDecoder decoder; + + public MoshiCodec() { + this(new Moshi.Builder().build()); + } + + public MoshiCodec(Iterable> adapters) { + this.encoder = new MoshiEncoder(adapters); + this.decoder = new MoshiDecoder(adapters); + } + + public MoshiCodec(Moshi moshi) { + this.encoder = new MoshiEncoder(moshi); + this.decoder = new MoshiDecoder(moshi); + } + + @Override + public JsonEncoder encoder() { + return encoder; + } + + @Override + public JsonDecoder decoder() { + return decoder; + } +} diff --git a/moshi/src/main/java/feign/moshi/MoshiDecoder.java b/moshi/src/main/java/feign/moshi/MoshiDecoder.java index 02d508609..ac08ee96a 100644 --- a/moshi/src/main/java/feign/moshi/MoshiDecoder.java +++ b/moshi/src/main/java/feign/moshi/MoshiDecoder.java @@ -21,12 +21,13 @@ import feign.Response; import feign.Util; import feign.codec.Decoder; +import feign.codec.JsonDecoder; import java.io.IOException; import java.lang.reflect.Type; import okio.BufferedSource; import okio.Okio; -public class MoshiDecoder implements Decoder { +public class MoshiDecoder implements Decoder, JsonDecoder { private final Moshi moshi; public MoshiDecoder(Moshi moshi) { @@ -60,4 +61,10 @@ public Object decode(Response response, Type type) throws IOException { throw e; } } + + @Override + public Object convert(Object object, Type type) throws IOException { + JsonAdapter adapter = moshi.adapter(type); + return adapter.fromJsonValue(object); + } } diff --git a/moshi/src/main/java/feign/moshi/MoshiEncoder.java b/moshi/src/main/java/feign/moshi/MoshiEncoder.java index 3e998b286..b65f705e2 100644 --- a/moshi/src/main/java/feign/moshi/MoshiEncoder.java +++ b/moshi/src/main/java/feign/moshi/MoshiEncoder.java @@ -19,9 +19,10 @@ import com.squareup.moshi.Moshi; import feign.RequestTemplate; import feign.codec.Encoder; +import feign.codec.JsonEncoder; import java.lang.reflect.Type; -public class MoshiEncoder implements Encoder { +public class MoshiEncoder implements Encoder, JsonEncoder { private final Moshi moshi; diff --git a/moshi/src/test/java/feign/moshi/MoshiEncoderTest.java b/moshi/src/test/java/feign/moshi/MoshiEncoderTest.java index ba7654c98..2a76d7597 100644 --- a/moshi/src/test/java/feign/moshi/MoshiEncoderTest.java +++ b/moshi/src/test/java/feign/moshi/MoshiEncoderTest.java @@ -39,7 +39,8 @@ void encodesMapObjectNumericalValuesAsInteger() { new MoshiEncoder().encode(map, Map.class, template); assertThat(template) - .hasBody(""" + .hasBody( + """ { "foo": 1 }\ diff --git a/moshi/src/test/java/feign/moshi/examples/GithubExample.java b/moshi/src/test/java/feign/moshi/examples/GithubExample.java index 0c93d744e..b1e5e2c04 100644 --- a/moshi/src/test/java/feign/moshi/examples/GithubExample.java +++ b/moshi/src/test/java/feign/moshi/examples/GithubExample.java @@ -31,10 +31,10 @@ public static void main(String... args) { .decoder(new MoshiDecoder()) .target(GitHub.class, "https://api.github.com"); - System.out.println("Let's fetch and print a list of the contributors to this library."); + IO.println("Let's fetch and print a list of the contributors to this library."); List contributors = github.contributors("netflix", "feign"); for (Contributor contributor : contributors) { - System.out.println(contributor.login + " (" + contributor.contributions + ")"); + IO.println(contributor.login + " (" + contributor.contributions + ")"); } } diff --git a/okhttp/pom.xml b/okhttp/pom.xml index db203eac5..7fa6c1b6d 100644 --- a/okhttp/pom.xml +++ b/okhttp/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-okhttp @@ -42,7 +42,7 @@ com.squareup.okhttp3 - okhttp + okhttp-jvm diff --git a/okhttp/src/test/java/feign/okhttp/OkHttpClientAsyncTest.java b/okhttp/src/test/java/feign/okhttp/OkHttpClientAsyncTest.java index a268c7610..79150b86b 100644 --- a/okhttp/src/test/java/feign/okhttp/OkHttpClientAsyncTest.java +++ b/okhttp/src/test/java/feign/okhttp/OkHttpClientAsyncTest.java @@ -25,10 +25,10 @@ import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; -import feign.AsyncClient; import feign.AsyncFeign; import feign.Body; import feign.ChildPojo; +import feign.DefaultAsyncClient; import feign.Feign; import feign.Feign.ResponseMappingDecoder; import feign.FeignException; @@ -50,6 +50,9 @@ import feign.Util; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.DefaultDecoder; +import feign.codec.DefaultEncoder; +import feign.codec.DefaultErrorDecoder; import feign.codec.EncodeException; import feign.codec.Encoder; import feign.codec.ErrorDecoder; @@ -165,7 +168,7 @@ void bodyTypeCorrespondsWithParameterType() throws Exception { final TestInterfaceAsync api = newAsyncBuilder() .encoder( - new Encoder.Default() { + new DefaultEncoder() { @Override public void encode(Object object, Type bodyType, RequestTemplate template) { encodedType.set(bodyType); @@ -483,9 +486,7 @@ void overrideTypeSpecificDecoder() throws Throwable { server.enqueue(new MockResponse().setBody("success!")); final TestInterfaceAsync api = - newAsyncBuilder() - .decoder((response, type) -> "fail") - .target("http://localhost:" + server.getPort()); + newAsyncBuilder().decoder((_, _) -> "fail").target("http://localhost:" + server.getPort()); assertThat(unwrap(api.post())).isEqualTo("fail"); } @@ -497,7 +498,7 @@ void doesntRetryAfterResponseIsSent() throws Throwable { final TestInterfaceAsync api = newAsyncBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target("http://localhost:" + server.getPort()); @@ -515,7 +516,7 @@ void throwsFeignExceptionIncludingBody() throws Throwable { final TestInterfaceAsync api = newAsyncBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target("http://localhost:" + server.getPort()); @@ -540,7 +541,7 @@ void throwsFeignExceptionWithoutBody() { final TestInterfaceAsync api = newAsyncBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new IOException("timeout"); }) .target("http://localhost:" + server.getPort()); @@ -573,7 +574,7 @@ void whenReturnTypeIsResponseNoErrorHandling() throws Throwable { // fake client as Client.Default follows redirects. final TestInterfaceAsync api = AsyncFeign.builder() - .client(new AsyncClient.Default<>((request, options) -> response, execs)) + .client(new DefaultAsyncClient<>((_, _) -> response, execs)) .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort()); assertThat(unwrap(api.response()).headers().get("Location")).contains("http://bar.com"); @@ -588,7 +589,7 @@ void okIfDecodeRootCauseHasNoMessage() throws Throwable { final TestInterfaceAsync api = newAsyncBuilder() .decoder( - (response, type) -> { + (_, _) -> { throw new RuntimeException(); }) .target("http://localhost:" + server.getPort()); @@ -604,7 +605,7 @@ void decodingExceptionGetWrappedInDismiss404Mode() throws Throwable { newAsyncBuilder() .dismiss404() .decoder( - (response, type) -> { + (response, _) -> { assertEquals(404, response.status()); throw new NoSuchElementException(); }) @@ -641,7 +642,7 @@ void okIfEncodeRootCauseHasNoMessage() throws Throwable { final TestInterfaceAsync api = newAsyncBuilder() .encoder( - (object, bodyType, template) -> { + (_, _, _) -> { throw new RuntimeException(); }) .target("http://localhost:" + server.getPort()); @@ -703,7 +704,7 @@ void encodeLogicSupportsByteArray() throws Exception { final OtherTestInterfaceAsync api = newAsyncBuilder() - .encoder(new Encoder.Default()) + .encoder(new DefaultEncoder()) .target( new HardCodedTarget<>( OtherTestInterfaceAsync.class, "http://localhost:" + server.getPort())); @@ -752,7 +753,7 @@ private static TestInterfaceAsyncBuilder newAsyncBuilder() { } private ResponseMapper upperCaseResponseMapper() { - return (response, type) -> { + return (response, _) -> { try { return response.toBuilder() .body(Util.toString(response.body().asReader()).toUpperCase().getBytes()) @@ -995,7 +996,7 @@ public void apply(RequestTemplate template) { } } - static class IllegalArgumentExceptionOn400 extends ErrorDecoder.Default { + static class IllegalArgumentExceptionOn400 extends DefaultErrorDecoder { @Override public Exception decode(String methodKey, Response response) { @@ -1006,7 +1007,7 @@ public Exception decode(String methodKey, Response response) { } } - static class IllegalArgumentExceptionOn404 extends ErrorDecoder.Default { + static class IllegalArgumentExceptionOn404 extends DefaultErrorDecoder { @Override public Exception decode(String methodKey, Response response) { @@ -1022,9 +1023,9 @@ static final class TestInterfaceAsyncBuilder { private final AsyncFeign.AsyncBuilder delegate = AsyncFeign.builder() .client(new OkHttpClient()) - .decoder(new Decoder.Default()) + .decoder(new DefaultDecoder()) .encoder( - (object, bodyType, template) -> { + (object, _, template) -> { if (object instanceof Map) { template.body(new Gson().toJson(object)); } else { diff --git a/pom.xml b/pom.xml index 31d305891..522d8c8f7 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT pom Feign (Parent) @@ -89,6 +89,7 @@ hc5 hystrix jackson + jackson3 jackson-jaxb jackson-jr jaxb @@ -116,6 +117,8 @@ micrometer mock apt-test-generator + graphql + graphql-apt annotation-error-decoder example-github example-github-with-coroutine @@ -141,17 +144,6 @@ https://github.com/openfeign/feign/issues - - - central - https://central.sonatype.com/api/v1/publisher - - - central - https://central.sonatype.com/api/v1/publisher - - - UTF-8 UTF-8 @@ -162,48 +154,99 @@ 1.8 - 21 + 25 ${main.java.version} ${main.java.version} - 4.12.0 - 33.4.8-jre - 1.47.1 - 2.13.1 + 5.3.2 + 33.5.0-jre + 2.1.0 + 2.13.2 1.15.2 2.0.17 - 20250517 - 3.3.5 - - 5.13.4 - 2.19.2 - 3.27.3 - 5.18.0 - 2.0.58 + 20251224 + 4.0.4 + + 6.0.3 + 2.21.2 + 3.1.0 + 3.27.7 + 5.23.0 + 2.0.61.android8 1.5.3 - 5.3 - 3.14.0 + 6.0 + 3.15.0 3.1.4 - 3.3.1 - 3.11.2 + 3.4.0 + 3.12.0 5.0.0 - 3.4.2 - 3.1.1 - 6.0.0 + 3.5.0 + 3.3.1 + 6.0.2 0.1.1 - 0.8.0 - 3.5.2 - 0.200.4 + 0.10.0 + 3.5.5 + 0.230.2 file://${project.basedir}/src/config/bom.xml - 2.1.0 - 2.18.0 + 2.2.1 + 2.21.0 3.2.8 3.1.4 1.2.2 1.3.0.Final + 3.6.2 + 3.2.0 + 1.2.8 + 4.0.0 + 6.34.0 + 3.30.0 + 3.30.1 + 0.25.4 + 1.0 + + + 0.7.12 + 1.1.1 + 1.21.0 + 1.6.0 + 1.6.0 + 1.15.0 + 0.23.0 + 25.0 + 4.5.0 + 4.5.14 + 5.6 + 1.13.0 + 1.5.18 + 3.1.0 + 4.0.0 + 4.0.5 + 3.0.2 + 4.0.3 + 2.1.1 + 2.3.1 + 2.3.9 + 4.0.3 + 2.3.1 + 1.19.4 + 1.1.1 + 1.18.44 + 3.6.2 + 4.2.38 + 5.0.6 + 0.2.0 + 2.1.1 + 1.5.3 + 3.0.2 + 2025.1.1 + 5.0.1 + 7.0.6 + 7.0.6 + 2.3.24.Final + 1.18.0 @@ -373,6 +416,12 @@ test + + ${project.groupId} + feign-graphql + ${project.version} + + ${project.groupId} feign-form @@ -431,33 +480,11 @@ - com.fasterxml.jackson.core - jackson-databind - ${jackson.version} - - - - com.fasterxml.jackson.core - jackson-core - ${jackson.version} - - - - com.fasterxml.jackson.core - jackson-annotations - ${jackson.version} - - - - com.fasterxml.jackson.jr - jackson-jr-objects - ${jackson.version} - - - - com.fasterxml.jackson.jr - jackson-jr-annotation-support + com.fasterxml.jackson + jackson-bom ${jackson.version} + pom + import @@ -539,7 +566,7 @@ true false - ${jvm.options} ${argLine} + ${jvm.options} @@ -637,6 +664,7 @@ true -parameters + -proc:full ${latest.java.version} ${latest.java.version} @@ -711,6 +739,7 @@ etc/header.txt **/.idea/** **/target/** + **/m2e-target/** **/scripts/** **/src/config/** **/codequality/** @@ -783,7 +812,7 @@ de.qaware.maven go-offline-maven-plugin - 1.2.8 + ${go-offline-maven-plugin.version} @@ -795,7 +824,7 @@ org.codehaus.mojo.signature java18 - 1.0 + ${java18-signature.version} signature MAIN @@ -805,7 +834,7 @@ com.github.ekryd.sortpom sortpom-maven-plugin - 4.0.0 + ${sortpom-maven-plugin.version} true \n @@ -876,22 +905,10 @@ ${project.version} - - org.sonatype.central - central-publishing-maven-plugin - ${central-publishing-maven-plugin.version} - true - - central - true - published - all - - org.apache.maven.plugins maven-enforcer-plugin - 3.6.1 + ${maven-enforcer-plugin.version} enforce-no-repositories @@ -908,46 +925,39 @@ - - - org.jacoco - jacoco-maven-plugin - 0.8.13 - - - feign/** - - - - com.marvinformatics.jacoco - easy-jacoco-maven-plugin - 0.1.4 + com.github.siom79.japicmp + japicmp-maven-plugin + ${japicmp-maven-plugin.version} - - - - - INSTRUCTION - COVEREDRATIO - 0.70 - - - - - true - - true - - - feign/** - - - feign-benchmark - feign-apt-test-generator - + + true + true + true + true + true + true + + feign-example-.* + feign-benchmark + + + @feign.Experimental + feign.graphql + + public + + + + + cmp + + verify + + + @@ -1001,9 +1011,6 @@ org.apache.maven.plugins maven-javadoc-plugin ${maven-javadoc-plugin.version} - - false - attach-javadocs @@ -1011,6 +1018,12 @@ jar package + + true + true + true + none + @@ -1034,6 +1047,25 @@ + + org.sonatype.central + central-publishing-maven-plugin + ${central-publishing-maven-plugin.version} + true + + central + true + published + required + + + + org.apache.maven.plugins + maven-deploy-plugin + + true + + @@ -1066,18 +1098,18 @@ org.openrewrite.maven rewrite-maven-plugin - 6.15.0 + ${rewrite-maven-plugin.version} org.openrewrite.recipe rewrite-testing-frameworks - 3.14.1 + ${rewrite-testing-frameworks.version} org.openrewrite.recipe rewrite-migrate-java - 3.14.1 + ${rewrite-migrate-java.version} @@ -1095,7 +1127,7 @@ org.openrewrite.java.testing.junit5.AssertToAssertions org.openrewrite.java.testing.assertj.JUnitToAssertj org.openrewrite.java.testing.assertj.Assertj - org.openrewrite.java.migrate.UpgradeToJava21 + org.openrewrite.java.migrate.UpgradeToJava25 **/src/main/java/** @@ -1122,6 +1154,18 @@ + + m2e + + + m2e.version + + + + ${project.basedir}/m2e-target + + + toolchain @@ -1135,7 +1179,7 @@ org.apache.maven.plugins maven-toolchains-plugin - 3.2.0 + ${maven-toolchains-plugin.version} diff --git a/reactive/pom.xml b/reactive/pom.xml index 4c7e80488..a7eb57ff7 100644 --- a/reactive/pom.xml +++ b/reactive/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-reactive-wrappers @@ -29,7 +29,7 @@ Reactive Wrapper for Feign Clients - 3.7.8 + 3.8.4 1.0.4 2.2.21 diff --git a/reactive/src/main/java/feign/reactive/ReactiveFeign.java b/reactive/src/main/java/feign/reactive/ReactiveFeign.java index b7b6c24c9..8518666aa 100644 --- a/reactive/src/main/java/feign/reactive/ReactiveFeign.java +++ b/reactive/src/main/java/feign/reactive/ReactiveFeign.java @@ -16,13 +16,14 @@ package feign.reactive; import feign.Contract; +import feign.DefaultContract; import feign.Feign; abstract class ReactiveFeign { public static class Builder extends Feign.Builder { - private Contract contract = new Contract.Default(); + private Contract contract = new DefaultContract(); /** * Extend the current contract to support Reactive Stream return types. diff --git a/reactive/src/test/java/feign/reactive/ReactiveDelegatingContractTest.java b/reactive/src/test/java/feign/reactive/ReactiveDelegatingContractTest.java index d042a48cb..f4bbd87d0 100644 --- a/reactive/src/test/java/feign/reactive/ReactiveDelegatingContractTest.java +++ b/reactive/src/test/java/feign/reactive/ReactiveDelegatingContractTest.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; import feign.Contract; +import feign.DefaultContract; import feign.Param; import feign.RequestLine; import io.reactivex.Flowable; @@ -33,20 +34,20 @@ void onlyReactiveReturnTypesSupported() { assertThatExceptionOfType(IllegalArgumentException.class) .isThrownBy( () -> { - Contract contract = new ReactiveDelegatingContract(new Contract.Default()); + Contract contract = new ReactiveDelegatingContract(new DefaultContract()); contract.parseAndValidateMetadata(TestSynchronousService.class); }); } @Test void reactorTypes() { - Contract contract = new ReactiveDelegatingContract(new Contract.Default()); + Contract contract = new ReactiveDelegatingContract(new DefaultContract()); contract.parseAndValidateMetadata(TestReactorService.class); } @Test void reactivexTypes() { - Contract contract = new ReactiveDelegatingContract(new Contract.Default()); + Contract contract = new ReactiveDelegatingContract(new DefaultContract()); contract.parseAndValidateMetadata(TestReactiveXService.class); } @@ -55,7 +56,7 @@ void streamsAreNotSupported() { assertThatExceptionOfType(IllegalArgumentException.class) .isThrownBy( () -> { - Contract contract = new ReactiveDelegatingContract(new Contract.Default()); + Contract contract = new ReactiveDelegatingContract(new DefaultContract()); contract.parseAndValidateMetadata(StreamsService.class); }); } diff --git a/reactive/src/test/java/feign/reactive/ReactiveFeignIntegrationTest.java b/reactive/src/test/java/feign/reactive/ReactiveFeignIntegrationTest.java index 482ca09c8..d268c4d01 100644 --- a/reactive/src/test/java/feign/reactive/ReactiveFeignIntegrationTest.java +++ b/reactive/src/test/java/feign/reactive/ReactiveFeignIntegrationTest.java @@ -28,6 +28,7 @@ import static org.mockito.Mockito.when; import feign.Client; +import feign.DefaultRetryer; import feign.FeignIgnore; import feign.Logger; import feign.Logger.Level; @@ -44,6 +45,7 @@ import feign.RetryableException; import feign.Retryer; import feign.codec.Decoder; +import feign.codec.DefaultDecoder; import feign.codec.ErrorDecoder; import feign.jackson.JacksonDecoder; import feign.jackson.JacksonEncoder; @@ -134,7 +136,7 @@ void reactorTargetFull() throws Exception { StepVerifier.create(service.usersMono()) .assertNext( - users -> assertThat(users.get(0)).hasFieldOrPropertyWithValue("username", "test")) + users -> assertThat(users.getFirst()).hasFieldOrPropertyWithValue("username", "test")) .expectComplete() .verify(); assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/users"); @@ -167,7 +169,7 @@ void rxJavaTarget() throws Exception { StepVerifier.create(service.users()) .assertNext( - users -> assertThat(users.get(0)).hasFieldOrPropertyWithValue("username", "test")) + users -> assertThat(users.getFirst()).hasFieldOrPropertyWithValue("username", "test")) .expectComplete() .verify(); assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/users"); @@ -179,7 +181,7 @@ void invocationFactoryIsNotSupported() { .isThrownBy( () -> { ReactorFeign.builder() - .invocationHandlerFactory((target, dispatch) -> null) + .invocationHandlerFactory((_, _) -> null) .target(TestReactiveXService.class, "http://localhost"); }); } @@ -203,7 +205,7 @@ void requestInterceptor() { TestReactorService service = ReactorFeign.builder() .requestInterceptor(mockInterceptor) - .decoder(new ReactorDecoder(new Decoder.Default())) + .decoder(new ReactorDecoder(new DefaultDecoder())) .target(TestReactorService.class, this.getServerUrl()); StepVerifier.create(service.version()).expectNext("1.0").expectComplete().verify(); verify(mockInterceptor, times(1)).apply(any(RequestTemplate.class)); @@ -217,7 +219,7 @@ void requestInterceptors() { TestReactorService service = ReactorFeign.builder() .requestInterceptors(Arrays.asList(mockInterceptor, mockInterceptor)) - .decoder(new ReactorDecoder(new Decoder.Default())) + .decoder(new ReactorDecoder(new DefaultDecoder())) .target(TestReactorService.class, this.getServerUrl()); StepVerifier.create(service.version()).expectNext("1.0").expectComplete().verify(); verify(mockInterceptor, times(2)).apply(any(RequestTemplate.class)); @@ -251,7 +253,7 @@ void queryMapEncoders() { TestReactiveXService service = RxJavaFeign.builder() .queryMapEncoder(encoder) - .decoder(new RxJavaDecoder(new Decoder.Default())) + .decoder(new RxJavaDecoder(new DefaultDecoder())) .target(TestReactiveXService.class, this.getServerUrl()); StepVerifier.create(service.search(new SearchQuery())) .expectNext("No Results Found") @@ -286,13 +288,13 @@ void retryer() { this.webServer.enqueue(new MockResponse().setBody("Not Available").setResponseCode(-1)); this.webServer.enqueue(new MockResponse().setBody("1.0")); - Retryer retryer = new Retryer.Default(); + Retryer retryer = new DefaultRetryer(); Retryer spy = spy(retryer); when(spy.clone()).thenReturn(spy); TestReactorService service = ReactorFeign.builder() .retryer(spy) - .decoder(new ReactorDecoder(new Decoder.Default())) + .decoder(new ReactorDecoder(new DefaultDecoder())) .target(TestReactorService.class, this.getServerUrl()); StepVerifier.create(service.version()).expectNext("1.0").expectComplete().verify(); verify(spy, times(1)).continueOrPropagate(any(RetryableException.class)); @@ -315,7 +317,7 @@ void client() throws Exception { TestReactorService service = ReactorFeign.builder() .client(client) - .decoder(new ReactorDecoder(new Decoder.Default())) + .decoder(new ReactorDecoder(new DefaultDecoder())) .target(TestReactorService.class, this.getServerUrl()); StepVerifier.create(service.version()).expectNext("1.0").expectComplete().verify(); verify(client, times(1)).execute(any(Request.class), any(Options.class)); @@ -328,7 +330,7 @@ void differentContract() throws Exception { TestJaxRSReactorService service = ReactorFeign.builder() .contract(new JAXRSContract()) - .decoder(new ReactorDecoder(new Decoder.Default())) + .decoder(new ReactorDecoder(new DefaultDecoder())) .target(TestJaxRSReactorService.class, this.getServerUrl()); StepVerifier.create(service.version()).expectNext("1.0").expectComplete().verify(); assertThat(webServer.takeRequest().getPath()).isEqualToIgnoringCase("/version"); @@ -399,7 +401,7 @@ public String query() { public static class ConsoleLogger extends Logger { @Override protected void log(String configKey, String format, Object... args) { - System.out.println(String.format(methodTag(configKey) + format, args)); + IO.println(String.format(methodTag(configKey) + format, args)); } } diff --git a/reactive/src/test/java/feign/reactive/examples/ConsoleLogger.java b/reactive/src/test/java/feign/reactive/examples/ConsoleLogger.java index fd0d0c274..4f27d1c31 100644 --- a/reactive/src/test/java/feign/reactive/examples/ConsoleLogger.java +++ b/reactive/src/test/java/feign/reactive/examples/ConsoleLogger.java @@ -20,6 +20,6 @@ public class ConsoleLogger extends Logger { @Override protected void log(String configKey, String format, Object... args) { - System.out.println(String.format(methodTag(configKey) + format, args)); + IO.println(String.format(methodTag(configKey) + format, args)); } } diff --git a/reactive/src/test/java/feign/reactive/examples/ReactorGitHubExample.java b/reactive/src/test/java/feign/reactive/examples/ReactorGitHubExample.java index 700f0736f..aae9d5610 100644 --- a/reactive/src/test/java/feign/reactive/examples/ReactorGitHubExample.java +++ b/reactive/src/test/java/feign/reactive/examples/ReactorGitHubExample.java @@ -36,19 +36,17 @@ public static void main(String... args) { .logLevel(Logger.Level.FULL) .target(GitHub.class, "https://api.github.com"); - System.out.println( - "Let's fetch and print a list of the contributors to this library (Using Flux)."); + IO.println("Let's fetch and print a list of the contributors to this library (Using Flux)."); List contributorsFromFlux = github.contributorsFlux("OpenFeign", "feign").collectList().block(); for (Contributor contributor : contributorsFromFlux) { - System.out.println(contributor.login + " (" + contributor.contributions + ")"); + IO.println(contributor.login + " (" + contributor.contributions + ")"); } - System.out.println( - "Let's fetch and print a list of the contributors to this library (Using Mono)."); + IO.println("Let's fetch and print a list of the contributors to this library (Using Mono)."); List contributorsFromMono = github.contributorsMono("OpenFeign", "feign").block(); for (Contributor contributor : contributorsFromMono) { - System.out.println(contributor.login + " (" + contributor.contributions + ")"); + IO.println(contributor.login + " (" + contributor.contributions + ")"); } } diff --git a/reactive/src/test/java/feign/reactive/examples/RxJavaGitHubExample.java b/reactive/src/test/java/feign/reactive/examples/RxJavaGitHubExample.java index 960b9826b..f3631a526 100644 --- a/reactive/src/test/java/feign/reactive/examples/RxJavaGitHubExample.java +++ b/reactive/src/test/java/feign/reactive/examples/RxJavaGitHubExample.java @@ -35,11 +35,11 @@ public static void main(String... args) { .logLevel(Logger.Level.FULL) .target(GitHub.class, "https://api.github.com"); - System.out.println("Let's fetch and print a list of the contributors to this library."); + IO.println("Let's fetch and print a list of the contributors to this library."); List contributorsFromFlux = github.contributors("OpenFeign", "feign").blockingLast(); for (Contributor contributor : contributorsFromFlux) { - System.out.println(contributor.login + " (" + contributor.contributions + ")"); + IO.println(contributor.login + " (" + contributor.contributions + ")"); } } diff --git a/ribbon/pom.xml b/ribbon/pom.xml index 88a205f87..941bad579 100644 --- a/ribbon/pom.xml +++ b/ribbon/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-ribbon diff --git a/ribbon/src/main/java/feign/ribbon/RibbonClient.java b/ribbon/src/main/java/feign/ribbon/RibbonClient.java index bae03c4bf..c9a8e9389 100644 --- a/ribbon/src/main/java/feign/ribbon/RibbonClient.java +++ b/ribbon/src/main/java/feign/ribbon/RibbonClient.java @@ -19,6 +19,7 @@ import com.netflix.client.config.CommonClientConfigKey; import com.netflix.client.config.DefaultClientConfigImpl; import feign.Client; +import feign.DefaultClient; import feign.Request; import feign.Response; import java.io.IOException; @@ -54,7 +55,7 @@ public static Builder builder() { */ @Deprecated public RibbonClient() { - this(new Client.Default(null, null)); + this(new DefaultClient(null, null)); } /** @@ -138,7 +139,7 @@ public Builder lbClientFactory(LBClientFactory lbClientFactory) { public RibbonClient build() { return new RibbonClient( - delegate != null ? delegate : new Client.Default(null, null), + delegate != null ? delegate : new DefaultClient(null, null), lbClientFactory != null ? lbClientFactory : new LBClientFactory.Default()); } } diff --git a/ribbon/src/test/java/feign/ribbon/LBClientTest.java b/ribbon/src/test/java/feign/ribbon/LBClientTest.java index 14f0d96bc..ad29059cb 100644 --- a/ribbon/src/test/java/feign/ribbon/LBClientTest.java +++ b/ribbon/src/test/java/feign/ribbon/LBClientTest.java @@ -58,8 +58,8 @@ void ribbonRequest() throws URISyntaxException { // test that requestOrigin and requestRecreate are same except the header 'Content-Length' // ps, requestOrigin and requestRecreate won't be null assertThat(requestOrigin.toString()) - .contains(String.format("%s %s HTTP/1.1\n", method, urlWithEncodedJson)); + .contains("%s %s HTTP/1.1\n".formatted(method, urlWithEncodedJson)); assertThat(requestRecreate.toString()) - .contains(String.format("%s %s HTTP/1.1\nContent-Length: 0\n", method, urlWithEncodedJson)); + .contains("%s %s HTTP/1.1\nContent-Length: 0\n".formatted(method, urlWithEncodedJson)); } } diff --git a/ribbon/src/test/java/feign/ribbon/RibbonClientTest.java b/ribbon/src/test/java/feign/ribbon/RibbonClientTest.java index d725ec595..3abc4cde0 100644 --- a/ribbon/src/test/java/feign/ribbon/RibbonClientTest.java +++ b/ribbon/src/test/java/feign/ribbon/RibbonClientTest.java @@ -22,6 +22,7 @@ import com.netflix.client.config.CommonClientConfigKey; import com.netflix.client.config.IClientConfig; import feign.Client; +import feign.DefaultClient; import feign.Feign; import feign.Param; import feign.Request; @@ -139,7 +140,7 @@ void ioExceptionFailsAfterTooManyFailures() throws IOException, InterruptedExcep try { api.post(); fail("No exception thrown"); - } catch (RetryableException ignored) { + } catch (RetryableException _) { } // TODO: why are these retrying? @@ -170,7 +171,7 @@ void ribbonRetryConfigurationOnSameServer() throws IOException, InterruptedExcep try { api.post(); fail("No exception thrown"); - } catch (RetryableException ignored) { + } catch (RetryableException _) { } assertThat(server1.getRequestCount() >= 2 || server2.getRequestCount() >= 2).isTrue(); @@ -201,7 +202,7 @@ void ribbonRetryConfigurationOnMultipleServers() throws IOException, Interrupted try { api.post(); fail("No exception thrown"); - } catch (RetryableException ignored) { + } catch (RetryableException _) { } assertThat(server1.getRequestCount()).isGreaterThanOrEqualTo(1); @@ -243,7 +244,7 @@ void urlEncodeQueryStringParameters() throws IOException, InterruptedException { @Test void hTTPSViaRibbon() { - Client trustSSLSockets = new Client.Default(TrustingSSLSocketFactory.get(), null); + Client trustSSLSockets = new DefaultClient(TrustingSSLSocketFactory.get(), null); server1.useHttps(TrustingSSLSocketFactory.get("localhost"), false); server1.enqueue(new MockResponse().setBody("success!")); @@ -298,7 +299,7 @@ void ribbonRetryOnStatusCodes() throws IOException, InterruptedException { try { api.post(); fail("No exception thrown"); - } catch (Exception ignored) { + } catch (Exception _) { } assertThat(server1.getRequestCount()).isEqualTo(1); diff --git a/sax/pom.xml b/sax/pom.xml index 9ad392c4e..327b9e490 100644 --- a/sax/pom.xml +++ b/sax/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-sax diff --git a/sax/src/test/java/feign/sax/SAXDecoderTest.java b/sax/src/test/java/feign/sax/SAXDecoderTest.java index 45900b2c2..21fdc2989 100644 --- a/sax/src/test/java/feign/sax/SAXDecoderTest.java +++ b/sax/src/test/java/feign/sax/SAXDecoderTest.java @@ -35,7 +35,7 @@ class SAXDecoderTest { static String statusFailed = - """ +""" @@ -112,7 +112,7 @@ void notFoundDecodesToEmpty() throws Exception { static enum NetworkStatus { GOOD, - FAILED; + FAILED } static class NetworkStatusStringHandler extends DefaultHandler diff --git a/sax/src/test/java/feign/sax/examples/IAMExample.java b/sax/src/test/java/feign/sax/examples/IAMExample.java index cc4be444a..5b2527cdb 100644 --- a/sax/src/test/java/feign/sax/examples/IAMExample.java +++ b/sax/src/test/java/feign/sax/examples/IAMExample.java @@ -30,7 +30,7 @@ public static void main(String... args) { Feign.builder() // .decoder(SAXDecoder.builder().registerContentHandler(UserIdHandler.class).build()) // .target(new IAMTarget(args[0], args[1])); - System.out.println(iam.userId()); + IO.println(iam.userId()); } interface IAM { diff --git a/scripts/release.sh b/scripts/release.sh index 9871d2faf..7fb51e3cc 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -19,17 +19,27 @@ function increment() { echo "${result}-SNAPSHOT" } -# extract the release version from the pom file -version=`./mvnw -B help:evaluate -N -Dexpression=project.version | sed -n '/^[0-9]/p'` -tag=`echo ${version} | cut -d'-' -f 1` - -# determine the next snapshot version -snapshot=$(increment ${tag}) +if [ -n "$1" ]; then + tag=$1 + if [ -n "$2" ]; then + snapshot=$2 + else + snapshot=$(increment ${tag}) + fi +else + # extract the release version from the pom file + version=`./mvnw -B help:evaluate -N -Dexpression=project.version | sed -n '/^[0-9]/p'` + tag=`echo ${version} | cut -d'-' -f 1` + snapshot=$(increment ${tag}) +fi echo "release version is: ${tag} and next snapshot is: ${snapshot}" # Update the versions, removing the snapshots, then create a new tag for the release, this will # start the travis-ci release process. +if [ -n "$1" ]; then + ./mvnw -B versions:set -DnewVersion="${tag}" -DgenerateBackupPoms=false +fi ./mvnw -B versions:set license:format scm:checkin -DremoveSnapshot -DgenerateBackupPoms=false -Dmessage="prepare release ${tag}" -DpushChanges=false # tag the release diff --git a/slf4j/pom.xml b/slf4j/pom.xml index 4552ec48b..497558323 100644 --- a/slf4j/pom.xml +++ b/slf4j/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-slf4j diff --git a/soap-jakarta/README.md b/soap-jakarta/README.md index 6bcb5ce2c..dfac4cc43 100644 --- a/soap-jakarta/README.md +++ b/soap-jakarta/README.md @@ -3,18 +3,18 @@ SOAP Codec This module adds support for encoding and decoding SOAP Body objects via JAXB and SOAPMessage. It also provides SOAPFault decoding capabilities by wrapping them into the original `javax.xml.ws.soap.SOAPFaultException`, so that you'll only need to catch `SOAPFaultException` in order to handle SOAPFault. -Add `SOAPEncoder` and/or `SOAPDecoder` to your `Feign.Builder` like so: +Add `SOAPCodec` to your `Feign.Builder` like so: ```java public interface MyApi { - + @RequestLine("POST /getObject") @Headers({ "SOAPAction: getObject", "Content-Type: text/xml" }) MyJaxbObjectResponse getObject(MyJaxbObjectRequest request); - + } ... @@ -25,8 +25,7 @@ public interface MyApi { .build(); api = Feign.builder() - .encoder(new SOAPEncoder(jaxbFactory)) - .decoder(new SOAPDecoder(jaxbFactory)) + .codec(new SOAPCodec(jaxbFactory)) .target(MyApi.class, "http://api"); ... @@ -36,15 +35,23 @@ public interface MyApi { } catch (SOAPFaultException faultException) { log.info(faultException.getFault().getFaultString()); } - + ``` -Because a SOAP Fault can be returned as well with a 200 http code than a 4xx or 5xx HTTP error code (depending on the used API), you may also use `SOAPErrorDecoder` in your API configuration, in order to be able to catch `SOAPFaultException` in case of SOAP Fault. Add it, like below: +You can also configure the encoder and decoder separately: ```java api = Feign.builder() .encoder(new SOAPEncoder(jaxbFactory)) .decoder(new SOAPDecoder(jaxbFactory)) + .target(MyApi.class, "http://api"); +``` + +Because a SOAP Fault can be returned as well with a 200 http code than a 4xx or 5xx HTTP error code (depending on the used API), you may also use `SOAPErrorDecoder` in your API configuration, in order to be able to catch `SOAPFaultException` in case of SOAP Fault. Add it, like below: + +```java +api = Feign.builder() + .codec(new SOAPCodec(jaxbFactory)) .errorDecoder(new SOAPErrorDecoder()) .target(MyApi.class, "http://api"); ``` diff --git a/soap-jakarta/pom.xml b/soap-jakarta/pom.xml index 6c2328fff..b8b8f1405 100644 --- a/soap-jakarta/pom.xml +++ b/soap-jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-soap-jakarta @@ -58,27 +58,27 @@ jakarta.xml.ws jakarta.xml.ws-api - 4.0.2 + ${jakarta.xml.ws-api.version} jakarta.xml.soap jakarta.xml.soap-api - 3.0.2 + ${jakarta.xml.soap-api.version} jakarta.xml.bind jakarta.xml.bind-api - 4.0.2 + ${jakarta.xml.bind-api.version} com.sun.xml.messaging.saaj saaj-impl - 3.0.2 + ${saaj-impl-3.version} com.sun.xml.bind jaxb-impl - 4.0.3 + ${jaxb-impl-4.version} runtime diff --git a/soap-jakarta/src/main/java/feign/soap/SOAPCodec.java b/soap-jakarta/src/main/java/feign/soap/SOAPCodec.java new file mode 100644 index 000000000..9295506e9 --- /dev/null +++ b/soap-jakarta/src/main/java/feign/soap/SOAPCodec.java @@ -0,0 +1,44 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.soap; + +import feign.Experimental; +import feign.codec.Codec; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.jaxb.JAXBContextFactory; + +@Experimental +public class SOAPCodec implements Codec { + + private final SOAPEncoder encoder; + private final SOAPDecoder decoder; + + public SOAPCodec(JAXBContextFactory jaxbContextFactory) { + this.encoder = new SOAPEncoder(jaxbContextFactory); + this.decoder = new SOAPDecoder(jaxbContextFactory); + } + + @Override + public Encoder encoder() { + return encoder; + } + + @Override + public Decoder decoder() { + return decoder; + } +} diff --git a/soap-jakarta/src/main/java/feign/soap/SOAPErrorDecoder.java b/soap-jakarta/src/main/java/feign/soap/SOAPErrorDecoder.java index b54da6365..ce2210f30 100644 --- a/soap-jakarta/src/main/java/feign/soap/SOAPErrorDecoder.java +++ b/soap-jakarta/src/main/java/feign/soap/SOAPErrorDecoder.java @@ -16,6 +16,7 @@ package feign.soap; import feign.Response; +import feign.codec.DefaultErrorDecoder; import feign.codec.ErrorDecoder; import jakarta.xml.soap.*; import jakarta.xml.ws.soap.SOAPFaultException; @@ -69,6 +70,6 @@ public Exception decode(String methodKey, Response response) { } private Exception defaultErrorDecoder(String methodKey, Response response) { - return new ErrorDecoder.Default().decode(methodKey, response); + return new DefaultErrorDecoder().decode(methodKey, response); } } diff --git a/soap-jakarta/src/test/java/feign/soap/SOAPCodecTest.java b/soap-jakarta/src/test/java/feign/soap/SOAPCodecTest.java index 5a43d1c7e..0cbb94be4 100644 --- a/soap-jakarta/src/test/java/feign/soap/SOAPCodecTest.java +++ b/soap-jakarta/src/test/java/feign/soap/SOAPCodecTest.java @@ -149,7 +149,7 @@ void encodesSoapWithCustomJAXBSchemaLocation() { assertThat(template) .hasBody( - """ +""" \ \ \ @@ -180,7 +180,7 @@ void encodesSoapWithCustomJAXBNoSchemaLocation() { assertThat(template) .hasBody( - """ +""" \ \ \ @@ -238,7 +238,7 @@ void decodesSoap() throws Exception { mock.item.value = "Apples"; String mockSoapEnvelop = - """ +""" \ \ \ @@ -272,7 +272,7 @@ void decodesSoapWithSchemaOnEnvelope() throws Exception { mock.item.value = "Apples"; String mockSoapEnvelop = - """ +""" \ \ \ \ diff --git a/soap/README.md b/soap/README.md index 6bcb5ce2c..dfac4cc43 100644 --- a/soap/README.md +++ b/soap/README.md @@ -3,18 +3,18 @@ SOAP Codec This module adds support for encoding and decoding SOAP Body objects via JAXB and SOAPMessage. It also provides SOAPFault decoding capabilities by wrapping them into the original `javax.xml.ws.soap.SOAPFaultException`, so that you'll only need to catch `SOAPFaultException` in order to handle SOAPFault. -Add `SOAPEncoder` and/or `SOAPDecoder` to your `Feign.Builder` like so: +Add `SOAPCodec` to your `Feign.Builder` like so: ```java public interface MyApi { - + @RequestLine("POST /getObject") @Headers({ "SOAPAction: getObject", "Content-Type: text/xml" }) MyJaxbObjectResponse getObject(MyJaxbObjectRequest request); - + } ... @@ -25,8 +25,7 @@ public interface MyApi { .build(); api = Feign.builder() - .encoder(new SOAPEncoder(jaxbFactory)) - .decoder(new SOAPDecoder(jaxbFactory)) + .codec(new SOAPCodec(jaxbFactory)) .target(MyApi.class, "http://api"); ... @@ -36,15 +35,23 @@ public interface MyApi { } catch (SOAPFaultException faultException) { log.info(faultException.getFault().getFaultString()); } - + ``` -Because a SOAP Fault can be returned as well with a 200 http code than a 4xx or 5xx HTTP error code (depending on the used API), you may also use `SOAPErrorDecoder` in your API configuration, in order to be able to catch `SOAPFaultException` in case of SOAP Fault. Add it, like below: +You can also configure the encoder and decoder separately: ```java api = Feign.builder() .encoder(new SOAPEncoder(jaxbFactory)) .decoder(new SOAPDecoder(jaxbFactory)) + .target(MyApi.class, "http://api"); +``` + +Because a SOAP Fault can be returned as well with a 200 http code than a 4xx or 5xx HTTP error code (depending on the used API), you may also use `SOAPErrorDecoder` in your API configuration, in order to be able to catch `SOAPFaultException` in case of SOAP Fault. Add it, like below: + +```java +api = Feign.builder() + .codec(new SOAPCodec(jaxbFactory)) .errorDecoder(new SOAPErrorDecoder()) .target(MyApi.class, "http://api"); ``` diff --git a/soap/pom.xml b/soap/pom.xml index f9057e440..ecd6d1d68 100644 --- a/soap/pom.xml +++ b/soap/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-soap @@ -54,22 +54,22 @@ javax.xml.ws jaxws-api - 2.3.1 + ${jaxws-api.version} javax.xml.bind jaxb-api - 2.3.1 + ${jaxb-api.version} com.sun.xml.messaging.saaj saaj-impl - 1.5.3 + ${saaj-impl-1.version} com.sun.xml.bind jaxb-impl - 2.3.9 + ${jaxb-impl-2.version} test diff --git a/soap/src/main/java/feign/soap/SOAPCodec.java b/soap/src/main/java/feign/soap/SOAPCodec.java new file mode 100644 index 000000000..9295506e9 --- /dev/null +++ b/soap/src/main/java/feign/soap/SOAPCodec.java @@ -0,0 +1,44 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.soap; + +import feign.Experimental; +import feign.codec.Codec; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.jaxb.JAXBContextFactory; + +@Experimental +public class SOAPCodec implements Codec { + + private final SOAPEncoder encoder; + private final SOAPDecoder decoder; + + public SOAPCodec(JAXBContextFactory jaxbContextFactory) { + this.encoder = new SOAPEncoder(jaxbContextFactory); + this.decoder = new SOAPDecoder(jaxbContextFactory); + } + + @Override + public Encoder encoder() { + return encoder; + } + + @Override + public Decoder decoder() { + return decoder; + } +} diff --git a/soap/src/main/java/feign/soap/SOAPErrorDecoder.java b/soap/src/main/java/feign/soap/SOAPErrorDecoder.java index fa08a089b..74f64061b 100644 --- a/soap/src/main/java/feign/soap/SOAPErrorDecoder.java +++ b/soap/src/main/java/feign/soap/SOAPErrorDecoder.java @@ -16,6 +16,7 @@ package feign.soap; import feign.Response; +import feign.codec.DefaultErrorDecoder; import feign.codec.ErrorDecoder; import java.io.IOException; import javax.xml.soap.MessageFactory; @@ -73,6 +74,6 @@ public Exception decode(String methodKey, Response response) { } private Exception defaultErrorDecoder(String methodKey, Response response) { - return new ErrorDecoder.Default().decode(methodKey, response); + return new DefaultErrorDecoder().decode(methodKey, response); } } diff --git a/soap/src/test/java/feign/soap/SOAPCodecTest.java b/soap/src/test/java/feign/soap/SOAPCodecTest.java index b814c63a8..002a0e33b 100644 --- a/soap/src/test/java/feign/soap/SOAPCodecTest.java +++ b/soap/src/test/java/feign/soap/SOAPCodecTest.java @@ -149,7 +149,7 @@ void encodesSoapWithCustomJAXBSchemaLocation() { assertThat(template) .hasBody( - """ +""" \ \ \ @@ -180,7 +180,7 @@ void encodesSoapWithCustomJAXBNoSchemaLocation() { assertThat(template) .hasBody( - """ +""" \ \ \ @@ -238,7 +238,7 @@ void decodesSoap() throws Exception { mock.item.value = "Apples"; String mockSoapEnvelop = - """ +""" \ \ \ @@ -272,7 +272,7 @@ void decodesSoapWithSchemaOnEnvelope() throws Exception { mock.item.value = "Apples"; String mockSoapEnvelop = - """ +""" \ \ \ \ diff --git a/spring/pom.xml b/spring/pom.xml index e27e4e4d9..fe3ca30ce 100644 --- a/spring/pom.xml +++ b/spring/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-spring @@ -31,7 +31,7 @@ 17 - 6.1.21 + 7.0.6 diff --git a/spring4/pom.xml b/spring4/pom.xml index 0e480f31d..173f05db3 100644 --- a/spring4/pom.xml +++ b/spring4/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-spring4 diff --git a/src/docs/overview-mindmap.iuml b/src/docs/overview-mindmap.iuml index d4fa6d069..bd8437575 100644 --- a/src/docs/overview-mindmap.iuml +++ b/src/docs/overview-mindmap.iuml @@ -13,15 +13,20 @@ *** Apache HC5 *** OkHttp *** Vertx +*** Reactive Wrappers ** contracts *** Feign *** JAX-RS *** JAX-RS 2 *** JAX-RS 3 / Jakarta -*** Spring 5 +*** JAX-RS 4 +*** Spring *** SOAP *** SOAP Jakarta *** Spring boot (3rd party) +** language +*** Kotlin +*** GraphQL left side @@ -29,15 +34,18 @@ left side *** GSON *** JAXB *** JAXB Jakarta -*** Jackson 1 -*** Jackson 2 +*** Jackson +*** Jackson 3 *** Jackson JAXB *** Jackson Jr *** Sax *** JSON-java *** Moshi *** Fastjson2 +*** Form +*** Form Spring ** metrics +*** Dropwizard Metrics 4 *** Dropwizard Metrics 5 *** Micrometer ** extras diff --git a/vertx/feign-vertx/pom.xml b/vertx/feign-vertx/pom.xml index b1847eda3..832b396a3 100644 --- a/vertx/feign-vertx/pom.xml +++ b/vertx/feign-vertx/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-vertx @@ -31,7 +31,7 @@ 11 - 2.19.2 + 2.21.2 diff --git a/vertx/feign-vertx/src/main/java/feign/VertxFeign.java b/vertx/feign-vertx/src/main/java/feign/VertxFeign.java index e03e6e26a..09a6264eb 100644 --- a/vertx/feign-vertx/src/main/java/feign/VertxFeign.java +++ b/vertx/feign-vertx/src/main/java/feign/VertxFeign.java @@ -20,6 +20,9 @@ import feign.InvocationHandlerFactory.MethodHandler; import feign.codec.Decoder; +import feign.codec.DefaultDecoder; +import feign.codec.DefaultEncoder; +import feign.codec.DefaultErrorDecoder; import feign.codec.Encoder; import feign.codec.ErrorDecoder; import feign.querymap.FieldQueryMapEncoder; @@ -94,14 +97,14 @@ public static final class Builder extends Feign.Builder { private WebClient webClient; private final List requestInterceptors = new ArrayList<>(); private Logger.Level logLevel = Logger.Level.NONE; - private Contract contract = new VertxDelegatingContract(new Contract.Default()); - private Retryer retryer = new Retryer.Default(); + private Contract contract = new VertxDelegatingContract(new DefaultContract()); + private Retryer retryer = new DefaultRetryer(); private Logger logger = new Logger.NoOpLogger(); - private Encoder encoder = new Encoder.Default(); - private Decoder decoder = new Decoder.Default(); + private Encoder encoder = new DefaultEncoder(); + private Decoder decoder = new DefaultDecoder(); private QueryMapEncoder queryMapEncoder = new FieldQueryMapEncoder(); private List capabilities = new ArrayList<>(); - private ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + private ErrorDecoder errorDecoder = new DefaultErrorDecoder(); private long timeout = -1; private boolean decode404; private UnaryOperator> requestPreProcessor = UnaryOperator.identity(); diff --git a/vertx/feign-vertx/src/test/java/feign/vertx/AbstractClientReconnectTest.java b/vertx/feign-vertx/src/test/java/feign/vertx/AbstractClientReconnectTest.java index 9f9a809ac..5419ce504 100644 --- a/vertx/feign-vertx/src/test/java/feign/vertx/AbstractClientReconnectTest.java +++ b/vertx/feign-vertx/src/test/java/feign/vertx/AbstractClientReconnectTest.java @@ -112,9 +112,7 @@ void allRequestsShouldBeAnswered(VertxTestContext testContext) { CompositeFuture sendRequests(int requests) { List> requestList = - IntStream.range(0, requests) - .mapToObj(ignored -> client.hello()) - .collect(Collectors.toList()); + IntStream.range(0, requests).mapToObj(_ -> client.hello()).collect(Collectors.toList()); return Future.all(requestList); } diff --git a/vertx/feign-vertx/src/test/java/feign/vertx/ConnectionsLeakTests.java b/vertx/feign-vertx/src/test/java/feign/vertx/ConnectionsLeakTests.java index 4d236f1cb..dae2645f3 100644 --- a/vertx/feign-vertx/src/test/java/feign/vertx/ConnectionsLeakTests.java +++ b/vertx/feign-vertx/src/test/java/feign/vertx/ConnectionsLeakTests.java @@ -112,11 +112,11 @@ void http2NoConnectionLeak(Vertx vertx, VertxTestContext testContext) { void assertNotLeaks( HelloServiceAPI client, VertxTestContext testContext, int nbRequests, int poolSize) { List> futures = - IntStream.range(0, nbRequests).mapToObj(ignored -> client.hello()).collect(toList()); + IntStream.range(0, nbRequests).mapToObj(_ -> client.hello()).collect(toList()); Future.all(futures) .onComplete( - ignored -> + _ -> testContext.verify( () -> { try { diff --git a/vertx/feign-vertx/src/test/java/feign/vertx/RetryingTest.java b/vertx/feign-vertx/src/test/java/feign/vertx/RetryingTest.java index c5ce7cba3..b294d9936 100644 --- a/vertx/feign-vertx/src/test/java/feign/vertx/RetryingTest.java +++ b/vertx/feign-vertx/src/test/java/feign/vertx/RetryingTest.java @@ -22,9 +22,9 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; +import feign.DefaultRetryer; import feign.Logger; import feign.RetryableException; -import feign.Retryer; import feign.VertxFeign; import feign.jackson.JacksonDecoder; import feign.slf4j.Slf4jLogger; @@ -50,7 +50,7 @@ static void createClient(Vertx vertx) { VertxFeign.builder() .webClient(WebClient.create(vertx)) .decoder(new JacksonDecoder(MAPPER)) - .retryer(new Retryer.Default(100, SECONDS.toMillis(1), 5)) + .retryer(new DefaultRetryer(100, SECONDS.toMillis(1), 5)) .logger(new Slf4jLogger()) .logLevel(Logger.Level.FULL) .target(IcecreamServiceApi.class, wireMock.baseUrl()); diff --git a/vertx/feign-vertx/src/test/java/feign/vertx/TestUtils.java b/vertx/feign-vertx/src/test/java/feign/vertx/TestUtils.java index 96b4779d0..9ecd178a1 100644 --- a/vertx/feign-vertx/src/test/java/feign/vertx/TestUtils.java +++ b/vertx/feign-vertx/src/test/java/feign/vertx/TestUtils.java @@ -29,7 +29,7 @@ public class TestUtils { public static String encodeAsJsonString(final Object object) { try { return MAPPER.writeValueAsString(object); - } catch (JsonProcessingException unexpectedException) { + } catch (JsonProcessingException _) { return ""; } } diff --git a/vertx/feign-vertx/src/test/java/feign/vertx/testcase/domain/OrderGenerator.java b/vertx/feign-vertx/src/test/java/feign/vertx/testcase/domain/OrderGenerator.java index e411b18cd..176d96642 100644 --- a/vertx/feign-vertx/src/test/java/feign/vertx/testcase/domain/OrderGenerator.java +++ b/vertx/feign-vertx/src/test/java/feign/vertx/testcase/domain/OrderGenerator.java @@ -34,9 +34,9 @@ public IceCreamOrder generate() { final int nbBalls = peekBallsNumber(); final int nbMixins = peekMixinNumber(); - IntStream.rangeClosed(1, nbBalls).mapToObj(i -> this.peekFlavor()).forEach(order::addBall); + IntStream.rangeClosed(1, nbBalls).mapToObj(_ -> this.peekFlavor()).forEach(order::addBall); - IntStream.rangeClosed(1, nbMixins).mapToObj(i -> this.peekMixin()).forEach(order::addMixin); + IntStream.rangeClosed(1, nbMixins).mapToObj(_ -> this.peekMixin()).forEach(order::addMixin); return order; } diff --git a/vertx/feign-vertx4-test/pom.xml b/vertx/feign-vertx4-test/pom.xml index 5254468fb..047f51608 100644 --- a/vertx/feign-vertx4-test/pom.xml +++ b/vertx/feign-vertx4-test/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-vertx4-test @@ -30,7 +30,7 @@ Tests with Vertx 4.x. - 4.5.14 + 4.5.25 diff --git a/vertx/feign-vertx4-test/src/test/java/feign/vertx/AbstractClientReconnectTest.java b/vertx/feign-vertx4-test/src/test/java/feign/vertx/AbstractClientReconnectTest.java index 169d49dfc..4a09259ce 100644 --- a/vertx/feign-vertx4-test/src/test/java/feign/vertx/AbstractClientReconnectTest.java +++ b/vertx/feign-vertx4-test/src/test/java/feign/vertx/AbstractClientReconnectTest.java @@ -112,9 +112,7 @@ void allRequestsShouldBeAnswered(VertxTestContext testContext) { CompositeFuture sendRequests(int requests) { List requestList = - IntStream.range(0, requests) - .mapToObj(ignored -> client.hello()) - .collect(Collectors.toList()); + IntStream.range(0, requests).mapToObj(_ -> client.hello()).collect(Collectors.toList()); return CompositeFuture.all(requestList); } diff --git a/vertx/feign-vertx4-test/src/test/java/feign/vertx/ConnectionsLeakTests.java b/vertx/feign-vertx4-test/src/test/java/feign/vertx/ConnectionsLeakTests.java index cc67db08c..15669e2c7 100644 --- a/vertx/feign-vertx4-test/src/test/java/feign/vertx/ConnectionsLeakTests.java +++ b/vertx/feign-vertx4-test/src/test/java/feign/vertx/ConnectionsLeakTests.java @@ -115,11 +115,11 @@ void http2NoConnectionLeak(Vertx vertx, VertxTestContext testContext) { void assertNotLeaks( HelloServiceAPI client, VertxTestContext testContext, int nbRequests, int poolSize) { List futures = - IntStream.range(0, nbRequests).mapToObj(ignored -> client.hello()).collect(toList()); + IntStream.range(0, nbRequests).mapToObj(_ -> client.hello()).collect(toList()); CompositeFuture.all(futures) .onComplete( - ignored -> + _ -> testContext.verify( () -> { try { diff --git a/vertx/feign-vertx4-test/src/test/java/feign/vertx/RetryingTest.java b/vertx/feign-vertx4-test/src/test/java/feign/vertx/RetryingTest.java index c5ce7cba3..b294d9936 100644 --- a/vertx/feign-vertx4-test/src/test/java/feign/vertx/RetryingTest.java +++ b/vertx/feign-vertx4-test/src/test/java/feign/vertx/RetryingTest.java @@ -22,9 +22,9 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; +import feign.DefaultRetryer; import feign.Logger; import feign.RetryableException; -import feign.Retryer; import feign.VertxFeign; import feign.jackson.JacksonDecoder; import feign.slf4j.Slf4jLogger; @@ -50,7 +50,7 @@ static void createClient(Vertx vertx) { VertxFeign.builder() .webClient(WebClient.create(vertx)) .decoder(new JacksonDecoder(MAPPER)) - .retryer(new Retryer.Default(100, SECONDS.toMillis(1), 5)) + .retryer(new DefaultRetryer(100, SECONDS.toMillis(1), 5)) .logger(new Slf4jLogger()) .logLevel(Logger.Level.FULL) .target(IcecreamServiceApi.class, wireMock.baseUrl()); diff --git a/vertx/feign-vertx5-test/pom.xml b/vertx/feign-vertx5-test/pom.xml index 02367decd..dd3856c51 100644 --- a/vertx/feign-vertx5-test/pom.xml +++ b/vertx/feign-vertx5-test/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-vertx5-test @@ -30,7 +30,7 @@ Tests with Vertx 5.x. - 5.0.1 + 5.0.8 diff --git a/vertx/pom.xml b/vertx/pom.xml index a3febc950..f5ec1f1c5 100644 --- a/vertx/pom.xml +++ b/vertx/pom.xml @@ -21,11 +21,12 @@ io.github.openfeign feign-parent - 13.7-SNAPSHOT + 13.12-SNAPSHOT feign-vertx-parent pom + Feign Vertx (Parent) feign-vertx @@ -34,7 +35,7 @@ - 5.0.1 + 5.0.4 2.0.16 3.0.1