From decb851dd880555bfacbd2fc71da4c6ed10ba93a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Fri, 19 Jun 2026 16:27:48 -0400 Subject: [PATCH 01/15] refactor(pom): drop redundant spring-framework-bom (use spring-boot's transitive) Spring Boot's BOM already imports the matching spring-framework-bom transitively. The explicit import was double-pinning the Spring Framework version and would have required override during framework matrix tests. Effective Spring Framework version is unchanged (7.0.8, sourced via spring-boot-dependencies). --- pom.xml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pom.xml b/pom.xml index 0f171e5..670292c 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,6 @@ ${java.version} ${java.version} 4.1.0 - 7.0.8 3.35.0 5.6 @@ -55,13 +54,6 @@ pom import - - org.springframework - spring-framework-bom - ${spring-framework.version} - pom - import - io.quarkus quarkus-bom From 61ccd578edf36d780cb1b9c447e43dcf00588f96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Fri, 19 Jun 2026 17:00:14 -0400 Subject: [PATCH 02/15] ci(compat): add weekly cron workflow for Spring Boot + Quarkus matrix Replaces the per-PR framework-matrix job (which used hardcoded versions and never caught upstream regressions) with a separate weekly workflow that queries Maven Central for the LATEST patch versions of Spring Boot 4.0/4.1 and Quarkus 3.33 LTS/3.35 and runs the full test suite against the 4 latest-patch combinations. Runs every Monday 06:00 UTC and on-demand via workflow_dispatch. fail-fast: false so one broken cell does not hide the others. On scheduled failure, auto-creates an issue with the 'compat-regression' label so the reviewer can triage upstream regressions promptly. --- .github/workflows/compat-weekly.yml | 118 ++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 .github/workflows/compat-weekly.yml diff --git a/.github/workflows/compat-weekly.yml b/.github/workflows/compat-weekly.yml new file mode 100644 index 0000000..2735505 --- /dev/null +++ b/.github/workflows/compat-weekly.yml @@ -0,0 +1,118 @@ +name: Compat Weekly + +on: + schedule: + - cron: '0 6 * * 1' # Mondays at 06:00 UTC + workflow_dispatch: {} # manual trigger button + +jobs: + fetch-latest: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + sb-4-0: ${{ steps.q.outputs.sb-4-0 }} + sb-4-1: ${{ steps.q.outputs.sb-4-1 }} + qu-3-33: ${{ steps.q.outputs.qu-3-33 }} + qu-3-35: ${{ steps.q.outputs.qu-3-35 }} + steps: + - name: Query Maven Central for latest patches + id: q + run: | + set -euo pipefail + # Spring Boot 4.0.x latest + SB_4_0=$(curl -fsSL "https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-dependencies/maven-metadata.xml" \ + | grep -oE '4\.0\.[0-9]+' \ + | sed -E 's|||g' | sort -V -u | tail -1) + # Spring Boot 4.1.x latest + SB_4_1=$(curl -fsSL "https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-dependencies/maven-metadata.xml" \ + | grep -oE '4\.1\.[0-9]+' \ + | sed -E 's|||g' | sort -V -u | tail -1) + # Quarkus 3.33.x LTS latest + QU_3_33=$(curl -fsSL "https://repo1.maven.org/maven2/io/quarkus/quarkus-bom/maven-metadata.xml" \ + | grep -oE '3\.33\.[0-9]+' \ + | sed -E 's|||g' | sort -V -u | tail -1) + # Quarkus 3.35.x latest + QU_3_35=$(curl -fsSL "https://repo1.maven.org/maven2/io/quarkus/quarkus-bom/maven-metadata.xml" \ + | grep -oE '3\.35\.[0-9]+' \ + | sed -E 's|||g' | sort -V -u | tail -1) + echo "sb-4-0=$SB_4_0" >> "$GITHUB_OUTPUT" + echo "sb-4-1=$SB_4_1" >> "$GITHUB_OUTPUT" + echo "qu-3-33=$QU_3_33" >> "$GITHUB_OUTPUT" + echo "qu-3-35=$QU_3_35" >> "$GITHUB_OUTPUT" + echo "## Latest patch versions" >> "$GITHUB_STEP_SUMMARY" + echo "- Spring Boot 4.0.x: \`$SB_4_0\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Spring Boot 4.1.x: \`$SB_4_1\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Quarkus 3.33.x LTS: \`$QU_3_33\`" >> "$GITHUB_STEP_SUMMARY" + echo "- Quarkus 3.35.x: \`$QU_3_35\`" >> "$GITHUB_STEP_SUMMARY" + + framework-matrix: + needs: fetch-latest + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - spring-boot: ${{ needs.fetch-latest.outputs.sb-4-1 }} + quarkus: ${{ needs.fetch-latest.outputs.qu-3-35 }} + label: "SB-4.1 latest + Q-3.35 latest" + - spring-boot: ${{ needs.fetch-latest.outputs.sb-4-0 }} + quarkus: ${{ needs.fetch-latest.outputs.qu-3-35 }} + label: "SB-4.0 latest + Q-3.35 latest" + - spring-boot: ${{ needs.fetch-latest.outputs.sb-4-1 }} + quarkus: ${{ needs.fetch-latest.outputs.qu-3-33 }} + label: "SB-4.1 latest + Q-3.33-LTS latest" + - spring-boot: ${{ needs.fetch-latest.outputs.sb-4-0 }} + quarkus: ${{ needs.fetch-latest.outputs.qu-3-33 }} + label: "SB-4.0 latest + Q-3.33-LTS latest" + name: ${{ matrix.label }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + cache: maven + - name: Build and Test + run: | + ./mvnw clean verify \ + -Dspring-boot.version=${{ matrix.spring-boot }} \ + -Dquarkus.version=${{ matrix.quarkus }} + + notify-failure: + needs: framework-matrix + if: failure() && github.event_name == 'schedule' + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Create issue on regression + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh issue create \ + --title "Weekly compat regression $(date +%Y-%m-%d)" \ + --label "compat-regression" \ + --body "$(cat <<'EOF' + The weekly compatibility matrix workflow failed. + + See run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + This means at least one of the Spring Boot / Quarkus combinations + documented in [docs/compatibility.md](../blob/main/docs/compatibility.md) + no longer passes against the latest published patches. + + Triage steps: + 1. Open the failing job to see which cell failed. + 2. Check whether a new upstream patch (Spring Boot or Quarkus) + introduced the regression. + 3. If yes — investigate the change, file upstream issue if needed, + pin to a previous patch in pom.xml, or update Ark to be compatible. + 4. Update docs/compatibility.md if a cell needs to be marked + Known-broken. + EOF + )" From d01aad93039778b475069abf80c71354eb4760c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Fri, 19 Jun 2026 17:00:26 -0400 Subject: [PATCH 03/15] docs(compat): add compatibility matrix verified by weekly cron Publishes docs/compatibility.md documenting which framework combinations Ark 1.0.x supports. The default Spring Boot + Quarkus combination is verified on every PR; the latest-patch combinations of Spring Boot 4.0/ 4.1 and Quarkus 3.33 LTS/3.35 are verified weekly by the compat-weekly workflow. Status tiers: 'Verified on every PR', 'Verified weekly', 'Untested', 'Known-broken'. --- README.md | 1 + docs/compatibility.md | 66 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 docs/compatibility.md diff --git a/README.md b/README.md index 7eba413..fa32638 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,7 @@ Ark validates TLS certificates by default. To use a custom truststore (self-sign ## Documentation - [CHANGELOG](CHANGELOG.md) - release notes and migration guidance +- [Compatibility Matrix](docs/compatibility.md) - supported Spring Boot, Quarkus, and Java versions - [Getting Started](docs/getting-started.md) - [Sync Client](docs/sync.md) - [Async Client](docs/async.md) diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..3780149 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,66 @@ +# Compatibility Matrix + +Ark publishes a single artifact line, but supports a small range of Spring +Boot and Quarkus versions verified by CI. This page records what is known +to work, what is known not to, and what is untested. + +## Current matrix + +| Ark version | Spring Boot | Quarkus | Java | Status | +|-------------|-------------------|-------------------|------------|-------------------------------------| +| 1.0.x | 4.1.x (default) | 3.35.x (default) | 17, 21, 25 | ✅ Verified on every PR | +| 1.0.x | 4.0.x latest | 3.35.x latest | 17 | ✅ Verified weekly (latest patches) | +| 1.0.x | 4.1.x latest | 3.33.x LTS latest | 17 | ✅ Verified weekly (latest patches) | +| 1.0.x | 4.0.x latest | 3.33.x LTS latest | 17 | ✅ Verified weekly (latest patches) | +| 1.0.x | 3.5.x or earlier | any | any | ⚠️ Untested — likely incompatible (Spring 6.x vs 7.x APIs) | +| 1.0.x | any | 3.15.x or earlier | any | ⚠️ Untested | + +Legend: +- **Verified in CI** — full test suite passes on every PR. +- **Untested** — may work, no guarantee. +- **Known-broken** — has a documented failure mode (see notes below). + +## How CI verifies this + +Two GitHub Actions workflows in `.github/workflows/`: + +1. **`test.yml` → `test` job** — runs on every PR and push to `main`. Java + matrix (17, 21, 25) against the default Spring Boot and Quarkus + versions pinned in root `pom.xml`. Catches Ark-code regressions + immediately. + +2. **`compat-weekly.yml` → `framework-matrix` job** — runs every Monday + 06:00 UTC (and on-demand via `workflow_dispatch`). Queries Maven + Central for the latest patches of Spring Boot 4.0.x / 4.1.x and + Quarkus 3.33.x LTS / 3.35.x, then runs the full test suite against + four combinations. Catches **upstream regressions** introduced by new + patch releases of Spring Boot or Quarkus. + +When the weekly run fails, an issue is auto-created with the +`compat-regression` label. + +## Why these versions + +- **Spring Boot 4.0+**: Ark depends on Spring Framework 7.x APIs + (`@HttpExchange`, AOT processors, `ConfigurationProperties` with record + binding). Spring Boot 3.x ships Spring Framework 6.x; supporting both + major lines would require split modules or a maintenance branch. +- **Quarkus 3.33 LTS**: current Quarkus long-term-support release. + Many enterprise users pin to LTS. +- **Quarkus 3.35** (default): latest Quarkus release at the time of Ark + 1.0 release. +- **Java 17/21/25**: Java 17 is the minimum baseline. + +## Known incompatibilities + +None known. + +## Reporting a new combination + +If you use Ark on a combination not in the matrix and it works (or +doesn't), please open an issue with: + +- Spring Boot version +- Quarkus version (if applicable) +- Java version +- Output of `./mvnw clean verify` (or your build's equivalent) From 08203bbf67c3566f69e8ef0d417752b9ba7c7686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Fri, 19 Jun 2026 17:29:46 -0400 Subject: [PATCH 04/15] ci(release): gate release PR on compat-matrix passing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds compat-weekly workflow as a callable target and invokes it from prepare-release.yml's compat job (runs in parallel with test). The release PR is only opened if both Java matrix and framework-matrix pass. This catches the case where an upstream Spring Boot or Quarkus patch regression slipped through between cron runs — release is blocked until either the regression is fixed or the compat matrix is updated to mark the affected cell as Known-broken. --- .github/workflows/compat-weekly.yml | 1 + .github/workflows/prepare-release.yml | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/compat-weekly.yml b/.github/workflows/compat-weekly.yml index 2735505..261462a 100644 --- a/.github/workflows/compat-weekly.yml +++ b/.github/workflows/compat-weekly.yml @@ -4,6 +4,7 @@ on: schedule: - cron: '0 6 * * 1' # Mondays at 06:00 UTC workflow_dispatch: {} # manual trigger button + workflow_call: {} # callable from prepare-release.yml jobs: fetch-latest: diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 11657ca..d706284 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -56,8 +56,14 @@ jobs: uses: ./.github/workflows/test.yml secrets: inherit + compat: + needs: preflight + if: needs.preflight.outputs.skip != 'true' + uses: ./.github/workflows/compat-weekly.yml + secrets: inherit + create-release-pr: - needs: [preflight, test] + needs: [preflight, test, compat] if: needs.preflight.outputs.skip != 'true' runs-on: ubuntu-latest permissions: From cc6bd40cdbf909b7997cf2e5a0c8be8fb7a9a836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Fri, 19 Jun 2026 17:58:45 -0400 Subject: [PATCH 05/15] fix(ci): skip snapshot deploy when pom version is not -SNAPSHOT Previously, deploy-snapshot tried to publish every push to main to the Nexus snapshot repo, gated only by a brittle commit-message filter (if: !contains(message, 'Release v')). That filter failed to match prepare-release.yml's actual commit subject ('chore(release): prepare v1.0.7 (#88)'), so release-prep PR merges triggered deploys that pushed non-SNAPSHOT versions to a snapshot repo. Nexus rejected those, leaving red CI runs that needed manual triage. Replaces the commit-message filter with a preflight job that reads the project version from pom.xml. If it ends in -SNAPSHOT, the test and deploy jobs proceed; otherwise both are skipped and the run completes green with a notice annotation explaining what was decided. Source of truth is now the artifact being deployed, not the commit message. --- .github/workflows/deploy-snapshot.yml | 33 ++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-snapshot.yml b/.github/workflows/deploy-snapshot.yml index d9233c9..f6d0ba9 100644 --- a/.github/workflows/deploy-snapshot.yml +++ b/.github/workflows/deploy-snapshot.yml @@ -12,13 +12,40 @@ concurrency: cancel-in-progress: false jobs: + preflight: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + is-snapshot: ${{ steps.check.outputs.is-snapshot }} + version: ${{ steps.check.outputs.version }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - id: check + name: Check pom version is -SNAPSHOT + run: | + set -euo pipefail + VERSION=$(grep -m1 -oE '[^<]+' pom.xml | sed -E 's|||g') + echo "Detected project version: $VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [[ "$VERSION" == *-SNAPSHOT ]]; then + echo "is-snapshot=true" >> "$GITHUB_OUTPUT" + echo "::notice::Will deploy SNAPSHOT $VERSION to Nexus" + else + echo "is-snapshot=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping snapshot deploy — version $VERSION is not a SNAPSHOT (release-prep commit)" + fi + test: - if: "!contains(github.event.head_commit.message, 'Release v')" + needs: preflight + if: needs.preflight.outputs.is-snapshot == 'true' uses: ./.github/workflows/test.yml secrets: inherit deploy: - needs: test + needs: [preflight, test] + if: needs.preflight.outputs.is-snapshot == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -37,4 +64,4 @@ jobs: env: NEXUS_SNAPSHOTS_URL: ${{ secrets.NEXUS_SNAPSHOTS_URL }} NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }} - NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }} \ No newline at end of file + NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }} From 76fd10f4d86d03546dc03d323ca4f8fa6b09ff66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Wed, 24 Jun 2026 10:08:43 -0400 Subject: [PATCH 06/15] feat(core): add throwOnError flag + noThrow() opt-out in AbstractClientRequest Adds a per-request 'throwOnError' field to AbstractClientRequest (default true, preserves current behavior). validateResponse() now short-circuits when the flag is false, letting the caller inspect the response regardless of status. Fluent opt-out: request.noThrow(). Protected setter throwOnError(boolean) is used by AbstractArkClient to apply the client-level default after request construction. AbstractArkBuilder gains throwOnError(boolean) so users can change the default across all requests on a single client. This commit leaves AbstractArkClient's constructor breaking (new param) and each *ArkClient subclass will be updated in Phase B. --- .../juandiii/ark/core/AbstractArkBuilder.java | 15 +++++++++ .../juandiii/ark/core/AbstractArkClient.java | 5 ++- .../ark/core/http/AbstractClientRequest.java | 31 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkBuilder.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkBuilder.java index 2723b45..e9429ee 100644 --- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkBuilder.java +++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkBuilder.java @@ -30,6 +30,7 @@ public abstract class AbstractArkBuilder> { protected HttpVersion httpVersion; protected int connectTimeoutSecs = -1; protected int readTimeoutSecs = -1; + protected boolean throwOnErrorDefault = true; protected final List requestInterceptors = new ArrayList<>(); protected final List responseInterceptors = new ArrayList<>(); @@ -126,6 +127,20 @@ public B responseInterceptor(ResponseInterceptor interceptor) { return self(); } + /** + * Set the client-level default for HTTP error behavior. When {@code true} + * (the default), HTTP 4xx/5xx responses raise {@code ApiException}. When + * {@code false}, the response is returned unchanged regardless of status. + * Individual requests may still opt out via {@code request.noThrow()}. + * + * @param throwOnError {@code true} (default) to throw on HTTP error status, {@code false} to return the response + * @return this builder for chaining + */ + public B throwOnError(boolean throwOnError) { + this.throwOnErrorDefault = throwOnError; + return self(); + } + protected B self() { return (B) this; } diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkClient.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkClient.java index 5dcbef2..3931826 100644 --- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkClient.java +++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/AbstractArkClient.java @@ -18,15 +18,18 @@ public abstract class AbstractArkClient> { protected final String baseUrl; protected final List requestInterceptors; protected final List responseInterceptors; + protected final boolean throwOnErrorDefault; protected AbstractArkClient(JsonSerializer serializer, String userAgent, String baseUrl, List requestInterceptors, - List responseInterceptors) { + List responseInterceptors, + boolean throwOnErrorDefault) { this.serializer = serializer; this.userAgent = userAgent; this.baseUrl = baseUrl; this.requestInterceptors = List.copyOf(requestInterceptors); this.responseInterceptors = List.copyOf(responseInterceptors); + this.throwOnErrorDefault = throwOnErrorDefault; } protected abstract R createRequest(String method, String path); diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/AbstractClientRequest.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/AbstractClientRequest.java index babdc88..1d47219 100644 --- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/AbstractClientRequest.java +++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/AbstractClientRequest.java @@ -40,6 +40,7 @@ public abstract class AbstractClientRequest> protected final JsonSerializer serializer; protected final List requestInterceptors; protected final List responseInterceptors; + private boolean throwOnError = true; protected AbstractClientRequest(String method, String baseUrl, String path, JsonSerializer serializer, @@ -126,6 +127,35 @@ public T timeout(Duration timeout) { return self(); } + /** + * Opt out of throwing {@link xyz.juandiii.ark.core.exceptions.ApiException} + * on HTTP error status codes (4xx/5xx). When called, the response is + * returned to the caller unchanged regardless of status. Use + * {@link RawResponse#isError()} or + * {@code clientResponse.toEntity(...).isSuccessful()} to branch on outcome. + * + * @return this request for chaining + */ + @SuppressWarnings("unchecked") + public T noThrow() { + this.throwOnError = false; + return (T) this; + } + + /** + * Programmatic setter used by {@code AbstractArkClient} to apply the + * client-level {@code throwOnError} default to a freshly created request. + * Prefer the fluent {@link #noThrow()} on the request itself. + * + * @param throwOnError {@code true} to throw on HTTP error status (default), {@code false} to return the response + * @return this request for chaining + */ + @SuppressWarnings("unchecked") + public T throwOnError(boolean throwOnError) { + this.throwOnError = throwOnError; + return (T) this; + } + protected void applyInterceptors() { requestInterceptors.forEach(interceptor -> interceptor.intercept(this)); } @@ -148,6 +178,7 @@ protected SerializedBody prepareBody() { } protected void validateResponse(RawResponse raw) { + if (!throwOnError) return; if (raw.isError()) { throw ApiException.of(raw.statusCode(), raw.body()); } From 1a2390e86a8804eadf1c0f9152417f64148ff4a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Wed, 24 Jun 2026 10:13:56 -0400 Subject: [PATCH 07/15] feat(client): expose noThrow() in all 5 ClientRequest interfaces + throwOnError builder option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each execution model's request interface (sync, async, reactor, mutiny, vertx) now declares noThrow() returning its own type for fluent chaining. The implementation is inherited from AbstractClientRequest — no per-implementation changes. Each *ArkClient's createRequest() applies the client-level throwOnErrorDefault to the new request before returning. Updates AbstractArkClientTest stub to thread the new constructor parameter so the build compiles after the AbstractArkClient signature change introduced in the previous commit. --- .../main/java/xyz/juandiii/ark/core/ArkClient.java | 14 +++++++++----- .../xyz/juandiii/ark/core/http/ClientRequest.java | 9 +++++++++ .../juandiii/ark/core/AbstractArkClientTest.java | 8 +++++--- .../xyz/juandiii/ark/async/AsyncArkClient.java | 14 +++++++++----- .../ark/async/http/AsyncClientRequest.java | 9 +++++++++ .../xyz/juandiii/ark/mutiny/MutinyArkClient.java | 14 +++++++++----- .../ark/mutiny/http/MutinyClientRequest.java | 9 +++++++++ .../xyz/juandiii/ark/reactor/ReactorArkClient.java | 14 +++++++++----- .../ark/reactor/http/ReactorClientRequest.java | 9 +++++++++ .../xyz/juandiii/ark/vertx/VertxArkClient.java | 14 +++++++++----- .../ark/vertx/http/VertxClientRequest.java | 9 +++++++++ 11 files changed, 95 insertions(+), 28 deletions(-) diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/ArkClient.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/ArkClient.java index b078963..08d9406 100644 --- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/ArkClient.java +++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/ArkClient.java @@ -28,15 +28,18 @@ public class ArkClient extends AbstractArkClient implement private ArkClient(Transport transport, JsonSerializer serializer, String userAgent, String baseUrl, List requestInterceptors, - List responseInterceptors) { - super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors); + List responseInterceptors, + boolean throwOnErrorDefault) { + super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors, + throwOnErrorDefault); this.transport = transport; } @Override protected DefaultClientRequest createRequest(String method, String path) { - return new DefaultClientRequest(method, baseUrl, path, transport, serializer, - requestInterceptors, responseInterceptors) + DefaultClientRequest req = new DefaultClientRequest(method, baseUrl, path, transport, serializer, + requestInterceptors, responseInterceptors); + return req.throwOnError(throwOnErrorDefault) .header("User-Agent", userAgent); } @@ -85,7 +88,8 @@ public Ark build() { Objects.requireNonNull(transport, "transport must not be null"); logConfiguration("ArkClient (sync)", transport.getClass().getSimpleName()); return new ArkClient(transport, serializer, buildUserAgent(), - baseUrl, requestInterceptors, responseInterceptors); + baseUrl, requestInterceptors, responseInterceptors, + throwOnErrorDefault); } } } diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientRequest.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientRequest.java index 7feb0b6..cc7841d 100644 --- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientRequest.java +++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientRequest.java @@ -69,6 +69,15 @@ public interface ClientRequest extends RequestContext { */ ClientRequest timeout(Duration timeout); + /** + * Opt out of throwing {@link xyz.juandiii.ark.core.exceptions.ApiException} + * on HTTP error status codes (4xx/5xx). When called, the response is + * returned to the caller unchanged regardless of status. + * + * @return this request for chaining + */ + ClientRequest noThrow(); + /** * Execute the HTTP request. * diff --git a/core/ark-core/src/test/java/xyz/juandiii/ark/core/AbstractArkClientTest.java b/core/ark-core/src/test/java/xyz/juandiii/ark/core/AbstractArkClientTest.java index d0e240a..4622d93 100644 --- a/core/ark-core/src/test/java/xyz/juandiii/ark/core/AbstractArkClientTest.java +++ b/core/ark-core/src/test/java/xyz/juandiii/ark/core/AbstractArkClientTest.java @@ -25,7 +25,7 @@ class AbstractArkClientTest { private TestArkClient client() { return new TestArkClient(transport, serializer, "TestAgent/1.0", - "https://api.example.com", Collections.emptyList(), Collections.emptyList()); + "https://api.example.com", Collections.emptyList(), Collections.emptyList(), true); } @Test @@ -64,8 +64,10 @@ static class TestArkClient extends AbstractArkClient { TestArkClient(HttpTransport transport, JsonSerializer serializer, String userAgent, String baseUrl, List requestInterceptors, - List responseInterceptors) { - super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors); + List responseInterceptors, + boolean throwOnErrorDefault) { + super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors, + throwOnErrorDefault); this.transport = transport; } diff --git a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/AsyncArkClient.java b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/AsyncArkClient.java index bcfbda8..c1690b2 100644 --- a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/AsyncArkClient.java +++ b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/AsyncArkClient.java @@ -25,15 +25,18 @@ public class AsyncArkClient extends AbstractArkClient private AsyncArkClient(Transport> transport, JsonSerializer serializer, String userAgent, String baseUrl, List requestInterceptors, - List responseInterceptors) { - super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors); + List responseInterceptors, + boolean throwOnErrorDefault) { + super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors, + throwOnErrorDefault); this.transport = transport; } @Override protected DefaultAsyncClientRequest createRequest(String method, String path) { - return new DefaultAsyncClientRequest(method, baseUrl, path, transport, serializer, - requestInterceptors, responseInterceptors) + DefaultAsyncClientRequest req = new DefaultAsyncClientRequest(method, baseUrl, path, transport, serializer, + requestInterceptors, responseInterceptors); + return req.throwOnError(throwOnErrorDefault) .header("User-Agent", userAgent); } @@ -68,7 +71,8 @@ public AsyncArk build() { Objects.requireNonNull(transport, "transport must not be null"); logConfiguration("AsyncArkClient (CompletableFuture)", transport.getClass().getSimpleName()); return new AsyncArkClient(transport, serializer, buildUserAgent(), - baseUrl, requestInterceptors, responseInterceptors); + baseUrl, requestInterceptors, responseInterceptors, + throwOnErrorDefault); } } } diff --git a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientRequest.java b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientRequest.java index 747b8a7..77ca370 100644 --- a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientRequest.java +++ b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientRequest.java @@ -23,5 +23,14 @@ public interface AsyncClientRequest extends RequestContext { AsyncClientRequest timeout(Duration timeout); + /** + * Opt out of throwing {@link xyz.juandiii.ark.core.exceptions.ApiException} + * on HTTP error status codes (4xx/5xx). When called, the response is + * returned to the caller unchanged regardless of status. + * + * @return this request for chaining + */ + AsyncClientRequest noThrow(); + AsyncClientResponse retrieve(); } diff --git a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/MutinyArkClient.java b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/MutinyArkClient.java index c79a1ad..8311a23 100644 --- a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/MutinyArkClient.java +++ b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/MutinyArkClient.java @@ -23,15 +23,18 @@ public class MutinyArkClient extends AbstractArkClient requestInterceptors, - List responseInterceptors) { - super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors); + List responseInterceptors, + boolean throwOnErrorDefault) { + super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors, + throwOnErrorDefault); this.transport = transport; } @Override protected DefaultMutinyClientRequest createRequest(String method, String path) { - return new DefaultMutinyClientRequest(method, baseUrl, path, transport, serializer, - requestInterceptors, responseInterceptors) + DefaultMutinyClientRequest req = new DefaultMutinyClientRequest(method, baseUrl, path, transport, serializer, + requestInterceptors, responseInterceptors); + return req.throwOnError(throwOnErrorDefault) .header("User-Agent", userAgent); } @@ -57,7 +60,8 @@ public MutinyArk build() { Objects.requireNonNull(transport, "transport must not be null"); logConfiguration("MutinyArkClient (Uni/Multi)", transport.getClass().getSimpleName()); return new MutinyArkClient(transport, serializer, buildUserAgent(), - baseUrl, requestInterceptors, responseInterceptors); + baseUrl, requestInterceptors, responseInterceptors, + throwOnErrorDefault); } } } diff --git a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientRequest.java b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientRequest.java index 5e63465..b029692 100644 --- a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientRequest.java +++ b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientRequest.java @@ -23,5 +23,14 @@ public interface MutinyClientRequest extends RequestContext { MutinyClientRequest timeout(Duration timeout); + /** + * Opt out of throwing {@link xyz.juandiii.ark.core.exceptions.ApiException} + * on HTTP error status codes (4xx/5xx). When called, the response is + * returned to the caller unchanged regardless of status. + * + * @return this request for chaining + */ + MutinyClientRequest noThrow(); + MutinyClientResponse retrieve(); } diff --git a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/ReactorArkClient.java b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/ReactorArkClient.java index 24b9c91..c88fdce 100644 --- a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/ReactorArkClient.java +++ b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/ReactorArkClient.java @@ -23,15 +23,18 @@ public class ReactorArkClient extends AbstractArkClient requestInterceptors, - List responseInterceptors) { - super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors); + List responseInterceptors, + boolean throwOnErrorDefault) { + super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors, + throwOnErrorDefault); this.transport = transport; } @Override protected DefaultReactorClientRequest createRequest(String method, String path) { - return new DefaultReactorClientRequest(method, baseUrl, path, transport, serializer, - requestInterceptors, responseInterceptors) + DefaultReactorClientRequest req = new DefaultReactorClientRequest(method, baseUrl, path, transport, serializer, + requestInterceptors, responseInterceptors); + return req.throwOnError(throwOnErrorDefault) .header("User-Agent", userAgent); } @@ -57,7 +60,8 @@ public ReactorArk build() { Objects.requireNonNull(transport, "transport must not be null"); logConfiguration("ReactorArkClient (Mono/Flux)", transport.getClass().getSimpleName()); return new ReactorArkClient(transport, serializer, buildUserAgent(), - baseUrl, requestInterceptors, responseInterceptors); + baseUrl, requestInterceptors, responseInterceptors, + throwOnErrorDefault); } } } diff --git a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientRequest.java b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientRequest.java index 87c91b8..7465c46 100644 --- a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientRequest.java +++ b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientRequest.java @@ -23,5 +23,14 @@ public interface ReactorClientRequest extends RequestContext { ReactorClientRequest timeout(Duration timeout); + /** + * Opt out of throwing {@link xyz.juandiii.ark.core.exceptions.ApiException} + * on HTTP error status codes (4xx/5xx). When called, the response is + * returned to the caller unchanged regardless of status. + * + * @return this request for chaining + */ + ReactorClientRequest noThrow(); + ReactorClientResponse retrieve(); } diff --git a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/VertxArkClient.java b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/VertxArkClient.java index cda680c..e13b18a 100644 --- a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/VertxArkClient.java +++ b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/VertxArkClient.java @@ -23,15 +23,18 @@ public class VertxArkClient extends AbstractArkClient private VertxArkClient(VertxHttpTransport transport, JsonSerializer serializer, String userAgent, String baseUrl, List requestInterceptors, - List responseInterceptors) { - super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors); + List responseInterceptors, + boolean throwOnErrorDefault) { + super(serializer, userAgent, baseUrl, requestInterceptors, responseInterceptors, + throwOnErrorDefault); this.transport = transport; } @Override protected DefaultVertxClientRequest createRequest(String method, String path) { - return new DefaultVertxClientRequest(method, baseUrl, path, transport, serializer, - requestInterceptors, responseInterceptors) + DefaultVertxClientRequest req = new DefaultVertxClientRequest(method, baseUrl, path, transport, serializer, + requestInterceptors, responseInterceptors); + return req.throwOnError(throwOnErrorDefault) .header("User-Agent", userAgent); } @@ -59,7 +62,8 @@ public VertxArk build() { Objects.requireNonNull(transport, "transport must not be null"); logConfiguration("VertxArkClient (Future)", transport.getClass().getSimpleName()); return new VertxArkClient(transport, serializer, buildUserAgent(), - baseUrl, requestInterceptors, responseInterceptors); + baseUrl, requestInterceptors, responseInterceptors, + throwOnErrorDefault); } } } diff --git a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientRequest.java b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientRequest.java index 54f68cf..4db0ae9 100644 --- a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientRequest.java +++ b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientRequest.java @@ -23,5 +23,14 @@ public interface VertxClientRequest extends RequestContext { VertxClientRequest timeout(Duration timeout); + /** + * Opt out of throwing {@link xyz.juandiii.ark.core.exceptions.ApiException} + * on HTTP error status codes (4xx/5xx). When called, the response is + * returned to the caller unchanged regardless of status. + * + * @return this request for chaining + */ + VertxClientRequest noThrow(); + VertxClientResponse retrieve(); } From 452092ffe5590c89aca5b2b6ae4ad4c1513d9c6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Wed, 24 Jun 2026 10:21:32 -0400 Subject: [PATCH 08/15] test(noThrow): cover sync/async/reactor/mutiny/vertx noThrow behavior + client-level default Adds 4 tests per execution model (20 total) covering: - default behavior: 4xx/5xx throws ApiException (unchanged) - per-request .noThrow(): response returned, status preserved - client-level throwOnError(false): all requests on that client permissive - combined: client default wins when request does not call .noThrow() Adds Permissive error handling section to each execution model doc (sync.md, async.md, reactor.md, mutiny.md) and a feature bullet in README.md. --- README.md | 3 + .../juandiii/ark/core/http/NoThrowTest.java | 94 +++++++++++++ docs/async.md | 36 +++++ docs/mutiny.md | 34 +++++ docs/reactor.md | 34 +++++ docs/sync.md | 34 +++++ .../ark/async/http/AsyncNoThrowTest.java | 111 ++++++++++++++++ .../ark/mutiny/http/MutinyNoThrowTest.java | 108 +++++++++++++++ .../ark/reactor/http/ReactorNoThrowTest.java | 105 +++++++++++++++ .../ark/vertx/http/VertxNoThrowTest.java | 124 ++++++++++++++++++ 10 files changed, 683 insertions(+) create mode 100644 core/ark-core/src/test/java/xyz/juandiii/ark/core/http/NoThrowTest.java create mode 100644 execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncNoThrowTest.java create mode 100644 execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyNoThrowTest.java create mode 100644 execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorNoThrowTest.java create mode 100644 execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxNoThrowTest.java diff --git a/README.md b/README.md index b2e306d..056c493 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,9 @@ The chain composes **outside-in** — the last `.with(...)` is the outermost wra - Trust-all SSL for development (with runtime warning) - Request/response logging with sensitive-header and credential-body redaction (`NONE`, `BASIC`, `HEADERS`, `BODY`) - Typed exception hierarchy (400-504 mapped to specific exceptions) +- **Permissive error handling** — opt out of throw-on-4xx/5xx per request + (`.noThrow()`) or at the client level (`throwOnError(false)`). Useful + when 4xx is business semantics (e.g. 404 = not found, not an error). - Per-request timeout support - HTTP/2 by default - Spring Boot (sync + async + WebFlux) and Quarkus integration diff --git a/core/ark-core/src/test/java/xyz/juandiii/ark/core/http/NoThrowTest.java b/core/ark-core/src/test/java/xyz/juandiii/ark/core/http/NoThrowTest.java new file mode 100644 index 0000000..8c837d3 --- /dev/null +++ b/core/ark-core/src/test/java/xyz/juandiii/ark/core/http/NoThrowTest.java @@ -0,0 +1,94 @@ +package xyz.juandiii.ark.core.http; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import xyz.juandiii.ark.core.Ark; +import xyz.juandiii.ark.core.ArkClient; +import xyz.juandiii.ark.core.JsonSerializer; +import xyz.juandiii.ark.core.exceptions.NotFoundException; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Verifies the {@code noThrow()} per-request opt-out and the + * client-level {@code throwOnError(false)} default for the sync execution model. + */ +@ExtendWith(MockitoExtension.class) +class NoThrowTest { + + @Mock + JsonSerializer serializer; + + @Mock + HttpTransport transport; + + private Ark client(boolean throwOnError) { + return ArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .throwOnError(throwOnError) + .build(); + } + + private Ark defaultClient() { + return ArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .build(); + } + + @Test + void defaultBehavior_404_throwsNotFoundException() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")); + + Ark ark = defaultClient(); + assertThrows(NotFoundException.class, () -> ark.get("/users/1").retrieve()); + } + + @Test + void perRequestNoThrow_404_returnsResponseWithStatus404() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")); + + Ark ark = defaultClient(); + ArkResponse response = ark.get("/users/1").noThrow().retrieve().toEntity(String.class); + + assertEquals(404, response.statusCode()); + assertFalse(response.isSuccessful()); + } + + @Test + void clientLevelThrowOnErrorFalse_404_isPermissive() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}")); + + Ark ark = client(false); + ArkResponse response = ark.get("/users/1").retrieve().toEntity(String.class); + + assertEquals(404, response.statusCode()); + assertFalse(response.isSuccessful()); + } + + @Test + void clientLevelThrowOnErrorFalse_withoutNoThrow_stillPermissive() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(new RawResponse(500, Map.of(), "boom")); + + Ark ark = client(false); + ArkResponse response = ark.get("/users/1").retrieve().toEntity(String.class); + + assertEquals(500, response.statusCode()); + assertFalse(response.isSuccessful()); + } +} diff --git a/docs/async.md b/docs/async.md index cbc3489..531fb19 100644 --- a/docs/async.md +++ b/docs/async.md @@ -109,6 +109,42 @@ client.get("/users/1") --- +## Permissive error handling + +By default, Ark fails the `CompletableFuture` with an `ApiException` +subtype for any HTTP 4xx/5xx status. When 4xx is a meaningful business +outcome, opt out and inspect the response. + +Per-request opt-out via `.noThrow()`: + +```java +CompletableFuture> response = client.get("/users/1") + .noThrow() + .retrieve() + .toEntity(User.class); + +response.thenAccept(r -> { + if (r.statusCode() == 404) { + // not found, treat as business outcome + } else if (r.isSuccessful()) { + User body = r.body(); + } +}); +``` + +Client-level default via `throwOnError(false)`: + +```java +AsyncArk permissive = AsyncArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .throwOnError(false) + .build(); +``` + +--- + ## Related - [Error Handling](error-handling.md) diff --git a/docs/mutiny.md b/docs/mutiny.md index e6c3076..7796136 100644 --- a/docs/mutiny.md +++ b/docs/mutiny.md @@ -169,6 +169,40 @@ client.get("/users/1") --- +## Permissive error handling + +By default, Ark signals an `ApiException` subtype on the `Uni` for any +HTTP 4xx/5xx status. When 4xx is a meaningful business outcome, opt out +and inspect the response. + +Per-request opt-out via `.noThrow()`: + +```java +Uni> response = client.get("/users/1") + .noThrow() + .retrieve() + .toEntity(User.class); + +response.onItem().transformToUni(r -> { + if (r.statusCode() == 404) return Uni.createFrom().nullItem(); + if (r.isSuccessful()) return Uni.createFrom().item(r.body()); + return Uni.createFrom().failure(new IllegalStateException("status " + r.statusCode())); +}); +``` + +Client-level default via `throwOnError(false)`: + +```java +MutinyArk permissive = MutinyArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .throwOnError(false) + .build(); +``` + +--- + ## Related - [Quarkus Jackson Extension](quarkus-jackson.md) diff --git a/docs/reactor.md b/docs/reactor.md index cd0a3f9..966fc91 100644 --- a/docs/reactor.md +++ b/docs/reactor.md @@ -195,6 +195,40 @@ client.get("/users/1") --- +## Permissive error handling + +By default, Ark signals an `ApiException` subtype on the `Mono` for any +HTTP 4xx/5xx status. When 4xx is a meaningful business outcome, opt out +and inspect the response. + +Per-request opt-out via `.noThrow()`: + +```java +Mono> response = client.get("/users/1") + .noThrow() + .retrieve() + .toEntity(User.class); + +response.flatMap(r -> { + if (r.statusCode() == 404) return Mono.empty(); + if (r.isSuccessful()) return Mono.just(r.body()); + return Mono.error(new IllegalStateException("status " + r.statusCode())); +}); +``` + +Client-level default via `throwOnError(false)`: + +```java +ReactorArk permissive = ReactorArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .throwOnError(false) + .build(); +``` + +--- + ## Related - [Spring Boot Integration](spring-boot.md) diff --git a/docs/sync.md b/docs/sync.md index 23d59d7..65c860e 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -176,6 +176,40 @@ try { --- +## Permissive error handling + +By default, Ark throws an `ApiException` subtype for any HTTP 4xx/5xx +status. When 4xx is a meaningful business outcome (e.g. 404 = "not +found", not an error), opt out and inspect the response yourself. + +Per-request opt-out via `.noThrow()`: + +```java +ArkResponse response = client.get("/users/1") + .noThrow() + .retrieve() + .toEntity(User.class); + +if (response.statusCode() == 404) return Optional.empty(); +if (response.isSuccessful()) return Optional.of(response.body()); +``` + +Client-level default via `throwOnError(false)`: + +```java +Ark permissive = ArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .throwOnError(false) + .build(); + +// All requests on this client return responses regardless of status +ArkResponse response = permissive.get("/users/1").retrieve().toEntity(User.class); +``` + +--- + ## Related - [Error Handling](error-handling.md) - full exception hierarchy diff --git a/execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncNoThrowTest.java b/execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncNoThrowTest.java new file mode 100644 index 0000000..535e6dd --- /dev/null +++ b/execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncNoThrowTest.java @@ -0,0 +1,111 @@ +package xyz.juandiii.ark.async.http; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import xyz.juandiii.ark.async.AsyncArk; +import xyz.juandiii.ark.async.AsyncArkClient; +import xyz.juandiii.ark.core.JsonSerializer; +import xyz.juandiii.ark.core.exceptions.NotFoundException; +import xyz.juandiii.ark.core.http.ArkResponse; +import xyz.juandiii.ark.core.http.RawResponse; +import xyz.juandiii.ark.core.http.Transport; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Verifies the {@code noThrow()} per-request opt-out and the + * client-level {@code throwOnError(false)} default for the async execution model. + */ +@ExtendWith(MockitoExtension.class) +class AsyncNoThrowTest { + + @Mock + JsonSerializer serializer; + + @Mock + @SuppressWarnings("unchecked") + Transport> transport; + + private AsyncArk client(boolean throwOnError) { + return AsyncArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .throwOnError(throwOnError) + .build(); + } + + private AsyncArk defaultClient() { + return AsyncArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .build(); + } + + @Test + void defaultBehavior_404_completesExceptionallyWithNotFoundException() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(CompletableFuture.completedFuture( + new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"))); + + AsyncArk ark = defaultClient(); + CompletableFuture> future = + ark.get("/users/1").retrieve().toEntity(String.class); + + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + assertInstanceOf(NotFoundException.class, ex.getCause()); + } + + @Test + void perRequestNoThrow_404_returnsResponseWithStatus404() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(CompletableFuture.completedFuture( + new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"))); + + AsyncArk ark = defaultClient(); + ArkResponse response = + ark.get("/users/1").noThrow().retrieve().toEntity(String.class).get(); + + assertEquals(404, response.statusCode()); + assertFalse(response.isSuccessful()); + } + + @Test + void clientLevelThrowOnErrorFalse_404_isPermissive() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(CompletableFuture.completedFuture( + new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"))); + + AsyncArk ark = client(false); + ArkResponse response = + ark.get("/users/1").retrieve().toEntity(String.class).get(); + + assertEquals(404, response.statusCode()); + assertFalse(response.isSuccessful()); + } + + @Test + void clientLevelThrowOnErrorFalse_withoutNoThrow_stillPermissive() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(CompletableFuture.completedFuture( + new RawResponse(500, Map.of(), "boom"))); + + AsyncArk ark = client(false); + ArkResponse response = + ark.get("/users/1").retrieve().toEntity(String.class).get(); + + assertEquals(500, response.statusCode()); + assertFalse(response.isSuccessful()); + } +} diff --git a/execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyNoThrowTest.java b/execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyNoThrowTest.java new file mode 100644 index 0000000..3812090 --- /dev/null +++ b/execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyNoThrowTest.java @@ -0,0 +1,108 @@ +package xyz.juandiii.ark.mutiny.http; + +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.helpers.test.UniAssertSubscriber; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import xyz.juandiii.ark.core.JsonSerializer; +import xyz.juandiii.ark.core.exceptions.NotFoundException; +import xyz.juandiii.ark.core.http.ArkResponse; +import xyz.juandiii.ark.core.http.RawResponse; +import xyz.juandiii.ark.mutiny.MutinyArk; +import xyz.juandiii.ark.mutiny.MutinyArkClient; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Verifies the {@code noThrow()} per-request opt-out and the + * client-level {@code throwOnError(false)} default for the Mutiny execution model. + */ +@ExtendWith(MockitoExtension.class) +class MutinyNoThrowTest { + + @Mock + JsonSerializer serializer; + + @Mock + MutinyHttpTransport transport; + + private MutinyArk client(boolean throwOnError) { + return MutinyArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .throwOnError(throwOnError) + .build(); + } + + private MutinyArk defaultClient() { + return MutinyArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .build(); + } + + @Test + void defaultBehavior_404_uniFailsWithNotFoundException() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Uni.createFrom().item(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"))); + + MutinyArk ark = defaultClient(); + UniAssertSubscriber> subscriber = + ark.get("/users/1").retrieve().toEntity(String.class) + .subscribe().withSubscriber(UniAssertSubscriber.create()); + + subscriber.assertFailedWith(NotFoundException.class); + } + + @Test + void perRequestNoThrow_404_returnsResponseWithStatus404() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Uni.createFrom().item(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"))); + + MutinyArk ark = defaultClient(); + ArkResponse response = ark.get("/users/1").noThrow().retrieve() + .toEntity(String.class) + .await().indefinitely(); + + assertEquals(404, response.statusCode()); + assertFalse(response.isSuccessful()); + } + + @Test + void clientLevelThrowOnErrorFalse_404_isPermissive() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Uni.createFrom().item(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"))); + + MutinyArk ark = client(false); + ArkResponse response = ark.get("/users/1").retrieve() + .toEntity(String.class) + .await().indefinitely(); + + assertEquals(404, response.statusCode()); + assertFalse(response.isSuccessful()); + } + + @Test + void clientLevelThrowOnErrorFalse_withoutNoThrow_stillPermissive() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Uni.createFrom().item(new RawResponse(500, Map.of(), "boom"))); + + MutinyArk ark = client(false); + ArkResponse response = ark.get("/users/1").retrieve() + .toEntity(String.class) + .await().indefinitely(); + + assertEquals(500, response.statusCode()); + assertFalse(response.isSuccessful()); + } +} diff --git a/execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorNoThrowTest.java b/execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorNoThrowTest.java new file mode 100644 index 0000000..c30a508 --- /dev/null +++ b/execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorNoThrowTest.java @@ -0,0 +1,105 @@ +package xyz.juandiii.ark.reactor.http; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import xyz.juandiii.ark.core.JsonSerializer; +import xyz.juandiii.ark.core.exceptions.NotFoundException; +import xyz.juandiii.ark.core.http.RawResponse; +import xyz.juandiii.ark.reactor.ReactorArk; +import xyz.juandiii.ark.reactor.ReactorArkClient; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Verifies the {@code noThrow()} per-request opt-out and the + * client-level {@code throwOnError(false)} default for the reactor execution model. + */ +@ExtendWith(MockitoExtension.class) +class ReactorNoThrowTest { + + @Mock + JsonSerializer serializer; + + @Mock + ReactorHttpTransport transport; + + private ReactorArk client(boolean throwOnError) { + return ReactorArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .throwOnError(throwOnError) + .build(); + } + + private ReactorArk defaultClient() { + return ReactorArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .build(); + } + + @Test + void defaultBehavior_404_monoEmitsNotFoundException() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Mono.just(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"))); + + ReactorArk ark = defaultClient(); + StepVerifier.create(ark.get("/users/1").retrieve().toEntity(String.class)) + .expectError(NotFoundException.class) + .verify(); + } + + @Test + void perRequestNoThrow_404_returnsResponseWithStatus404() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Mono.just(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"))); + + ReactorArk ark = defaultClient(); + StepVerifier.create(ark.get("/users/1").noThrow().retrieve().toEntity(String.class)) + .assertNext(response -> { + assertEquals(404, response.statusCode()); + assertFalse(response.isSuccessful()); + }) + .verifyComplete(); + } + + @Test + void clientLevelThrowOnErrorFalse_404_isPermissive() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Mono.just(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"))); + + ReactorArk ark = client(false); + StepVerifier.create(ark.get("/users/1").retrieve().toEntity(String.class)) + .assertNext(response -> { + assertEquals(404, response.statusCode()); + assertFalse(response.isSuccessful()); + }) + .verifyComplete(); + } + + @Test + void clientLevelThrowOnErrorFalse_withoutNoThrow_stillPermissive() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Mono.just(new RawResponse(500, Map.of(), "boom"))); + + ReactorArk ark = client(false); + StepVerifier.create(ark.get("/users/1").retrieve().toEntity(String.class)) + .assertNext(response -> { + assertEquals(500, response.statusCode()); + assertFalse(response.isSuccessful()); + }) + .verifyComplete(); + } +} diff --git a/execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxNoThrowTest.java b/execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxNoThrowTest.java new file mode 100644 index 0000000..efa3b6d --- /dev/null +++ b/execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxNoThrowTest.java @@ -0,0 +1,124 @@ +package xyz.juandiii.ark.vertx.http; + +import io.vertx.core.Future; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import xyz.juandiii.ark.core.JsonSerializer; +import xyz.juandiii.ark.core.exceptions.NotFoundException; +import xyz.juandiii.ark.core.http.ArkResponse; +import xyz.juandiii.ark.core.http.RawResponse; +import xyz.juandiii.ark.vertx.VertxArk; +import xyz.juandiii.ark.vertx.VertxArkClient; + +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Verifies the {@code noThrow()} per-request opt-out and the + * client-level {@code throwOnError(false)} default for the Vert.x execution model. + */ +@ExtendWith(MockitoExtension.class) +class VertxNoThrowTest { + + @Mock + JsonSerializer serializer; + + @Mock + VertxHttpTransport transport; + + private VertxArk client(boolean throwOnError) { + return VertxArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .throwOnError(throwOnError) + .build(); + } + + private VertxArk defaultClient() { + return VertxArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .build(); + } + + @Test + void defaultBehavior_404_futureFailsWithNotFoundException() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Future.succeededFuture(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"))); + + VertxArk ark = defaultClient(); + Future> future = ark.get("/users/1").retrieve().toEntity(String.class); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + future.onComplete(ar -> { + if (ar.failed()) error.set(ar.cause()); + latch.countDown(); + }); + + assertTrue(latch.await(5, TimeUnit.SECONDS)); + assertInstanceOf(NotFoundException.class, error.get()); + } + + @Test + void perRequestNoThrow_404_returnsResponseWithStatus404() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Future.succeededFuture(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"))); + + VertxArk ark = defaultClient(); + ArkResponse response = await(ark.get("/users/1").noThrow().retrieve().toEntity(String.class)); + + assertEquals(404, response.statusCode()); + assertFalse(response.isSuccessful()); + } + + @Test + void clientLevelThrowOnErrorFalse_404_isPermissive() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Future.succeededFuture(new RawResponse(404, Map.of(), "{\"error\":\"not found\"}"))); + + VertxArk ark = client(false); + ArkResponse response = await(ark.get("/users/1").retrieve().toEntity(String.class)); + + assertEquals(404, response.statusCode()); + assertFalse(response.isSuccessful()); + } + + @Test + void clientLevelThrowOnErrorFalse_withoutNoThrow_stillPermissive() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Future.succeededFuture(new RawResponse(500, Map.of(), "boom"))); + + VertxArk ark = client(false); + ArkResponse response = await(ark.get("/users/1").retrieve().toEntity(String.class)); + + assertEquals(500, response.statusCode()); + assertFalse(response.isSuccessful()); + } + + private T await(Future future) throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference value = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + future.onComplete(ar -> { + if (ar.succeeded()) value.set(ar.result()); + else error.set(ar.cause()); + latch.countDown(); + }); + assertTrue(latch.await(5, TimeUnit.SECONDS)); + if (error.get() != null) throw new RuntimeException(error.get()); + return value.get(); + } +} From b599fbb27ddd499bbca203e6f5f62fd6cf3c881e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Wed, 24 Jun 2026 10:39:20 -0400 Subject: [PATCH 09/15] feat(spring): bind throw-on-error in ArkProperties + apply via ArkClientFactoryBean (sync + webflux) Adds @DefaultValue("true") boolean throwOnError to ClientProperties in both ArkProperties (sync) and ArkWebFluxProperties (webflux). Default true preserves current behavior. ArkClientFactoryBean and ArkWebFluxClientFactoryBean apply the value to the underlying builder via builder.throwOnError(props.throwOnError()), threading the per-client config down to the runtime client instance. Allows @RegisterArkClient users to configure permissive error handling declaratively: ark.client.users-api.throw-on-error=false --- .../ark/spring/webflux/ArkWebFluxClientFactoryBean.java | 3 +++ .../xyz/juandiii/ark/spring/webflux/ArkWebFluxProperties.java | 1 + .../java/xyz/juandiii/ark/spring/ArkClientFactoryBean.java | 3 +++ .../src/main/java/xyz/juandiii/ark/spring/ArkProperties.java | 1 + 4 files changed, 8 insertions(+) diff --git a/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxClientFactoryBean.java b/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxClientFactoryBean.java index bfbe4c2..1b46c3e 100644 --- a/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxClientFactoryBean.java +++ b/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxClientFactoryBean.java @@ -92,6 +92,9 @@ public T getObject() { InterceptorResolver.applyHeaders(builder, config != null ? config.headers() : null); InterceptorResolver.applyInterceptors(builder, annotation.interceptors(), beanFactory::getBean); LoggingInterceptor.apply(builder, arkProperties.logging().level()); + if (config != null) { + builder.throwOnError(config.throwOnError()); + } return (T) ArkProxy.create(clientInterface, builder.build()); } diff --git a/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxProperties.java b/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxProperties.java index ddecc70..e58c311 100644 --- a/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxProperties.java +++ b/starters/ark-spring-boot-starter-webflux/src/main/java/xyz/juandiii/ark/spring/webflux/ArkWebFluxProperties.java @@ -38,6 +38,7 @@ public record ClientProperties( @DefaultValue("30") int readTimeout, String tlsConfigurationName, @DefaultValue("false") boolean trustAll, + @DefaultValue("true") boolean throwOnError, @DefaultValue Map headers ) { public ClientProperties { diff --git a/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkClientFactoryBean.java b/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkClientFactoryBean.java index d0fc02c..141bfc3 100644 --- a/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkClientFactoryBean.java +++ b/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkClientFactoryBean.java @@ -160,6 +160,9 @@ private > void applyCommon InterceptorResolver.applyHeaders(builder, config != null ? config.headers() : null); InterceptorResolver.applyInterceptors(builder, annotation.interceptors(), beanFactory::getBean); LoggingInterceptor.apply(builder, arkProperties.logging().level()); + if (config != null) { + builder.throwOnError(config.throwOnError()); + } } @Override diff --git a/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkProperties.java b/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkProperties.java index db3ac12..525de90 100644 --- a/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkProperties.java +++ b/starters/ark-spring-boot-starter/src/main/java/xyz/juandiii/ark/spring/ArkProperties.java @@ -53,6 +53,7 @@ public record ClientProperties( @DefaultValue("30") int readTimeout, String tlsConfigurationName, @DefaultValue("false") boolean trustAll, + @DefaultValue("true") boolean throwOnError, @DefaultValue Map headers, RetryProperties retry ) { From 175ea30e8e1aff95a489cf682babd6c279f5b0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Wed, 24 Jun 2026 10:42:30 -0400 Subject: [PATCH 10/15] feat(quarkus): bind throw-on-error in ArkClientNamedConfig + apply via ArkRecorder Adds @WithName("throw-on-error") @WithDefault("true") boolean throwOnError() to ArkClientNamedConfig and threads the value through ArkRecorder to the underlying client builder. Allows @RegisterArkClient users on Quarkus to configure permissive error handling declaratively: ark.client."users-api".throw-on-error=false --- .../java/xyz/juandiii/ark/quarkus/ArkRecorder.java | 5 ++++- .../ark/quarkus/config/ArkClientNamedConfig.java | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/ArkRecorder.java b/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/ArkRecorder.java index 26a1879..812ecf2 100644 --- a/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/ArkRecorder.java +++ b/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/ArkRecorder.java @@ -75,7 +75,8 @@ public Supplier createArkClient(String interfaceName, String configKey) { private record ResolvedConfig(String clientName, String baseUrl, HttpVersion httpVersion, int connectTimeout, int readTimeout, String tlsConfigName, - boolean trustAll, Map headers, + boolean trustAll, boolean throwOnError, + Map headers, Class[] interceptorClasses, RetryPolicy retryPolicy, LoggingInterceptor.Level loggingLevel) {} @@ -91,6 +92,7 @@ private static ResolvedConfig resolveConfig(String clientName, ArkClientNamedCon config != null ? config.readTimeout() : annotation.readTimeout(), config != null ? config.tlsConfigurationName().orElse(null) : null, config != null && config.trustAll(), + config == null || config.throwOnError(), config != null ? config.headers() : Map.of(), annotation != null ? annotation.interceptors() : new Class[0], resolveRetryPolicy(config), @@ -147,6 +149,7 @@ private static > void applyInterceptors( InterceptorResolver.applyInterceptors(builder, rc.interceptorClasses(), clazz -> Arc.container().instance(clazz).get()); LoggingInterceptor.apply(builder, rc.loggingLevel()); + builder.throwOnError(rc.throwOnError()); } private static String resolveBaseUrl(ArkClientNamedConfig config, RegisterArkClient annotation) { diff --git a/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/config/ArkClientNamedConfig.java b/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/config/ArkClientNamedConfig.java index 2e21ea3..748199d 100644 --- a/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/config/ArkClientNamedConfig.java +++ b/extensions/ark-quarkus-jackson/runtime/src/main/java/xyz/juandiii/ark/quarkus/config/ArkClientNamedConfig.java @@ -57,6 +57,17 @@ public interface ArkClientNamedConfig { @WithDefault("false") boolean trustAll(); + /** + * If true (default), HTTP 4xx/5xx responses raise ApiException. If false, + * the response is returned to the caller regardless of status — useful + * when 4xx is expected business semantics (e.g. 404 = not found, not an + * error). Per-request .noThrow() can still opt out on a client where + * this is true. + */ + @WithName("throw-on-error") + @WithDefault("true") + boolean throwOnError(); + /** * Default headers to add to every request. */ From e9aa1b6d68aa8aa83a95555cbbdbcc1c66850e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Wed, 24 Jun 2026 10:44:53 -0400 Subject: [PATCH 11/15] docs(noThrow): document throw-on-error property in Spring and Quarkus guides Adds throw-on-error row to the per-client properties table in docs/spring-boot.md (and docs/quarkus*.md if it exists), pointing at the Permissive error handling section in sync.md for examples. --- docs/quarkus-jackson.md | 3 +++ docs/spring-boot.md | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/docs/quarkus-jackson.md b/docs/quarkus-jackson.md index ebc49c5..512c365 100644 --- a/docs/quarkus-jackson.md +++ b/docs/quarkus-jackson.md @@ -171,6 +171,7 @@ ark.client."user-api".connect-timeout=5 ark.client."user-api".read-timeout=15 ark.client."user-api".tls-configuration-name=my-cert ark.client."user-api".trust-all=false +ark.client."user-api".throw-on-error=true ark.client."user-api".headers.X-Api-Key=${API_KEY} ark.client."user-api".retry.max-attempts=3 ark.client."user-api".retry.delay=500 @@ -178,6 +179,8 @@ ark.client."user-api".retry.delay=500 > ⚠️ `trust-all=true` disables certificate validation. Use only in local development. See [Security & TLS in README](../README.md#tls). +> Set `throw-on-error=false` to return HTTP 4xx/5xx responses instead of raising `ApiException`. See [Permissive error handling](sync.md#permissive-error-handling). + See [Retry & Backoff](retry.md) for full retry configuration. ```java diff --git a/docs/spring-boot.md b/docs/spring-boot.md index 90bbe48..188c17e 100644 --- a/docs/spring-boot.md +++ b/docs/spring-boot.md @@ -186,6 +186,7 @@ ark.client.user-api.http-version=HTTP_2 ark.client.user-api.connect-timeout=5 ark.client.user-api.read-timeout=15 ark.client.user-api.trust-all=false +ark.client.user-api.throw-on-error=true ark.client.user-api.headers.Authorization=Bearer ${TOKEN} ark.client.user-api.tls-configuration-name=my-cert @@ -195,6 +196,8 @@ spring.ssl.bundle.pem.my-cert.truststore.certificate=classpath:certs/ca.crt > ⚠️ `trust-all: true` disables certificate validation. Use only in local development. See [Security & TLS in README](../README.md#tls). +> Set `throw-on-error=false` to return HTTP 4xx/5xx responses instead of raising `ApiException`. See [Permissive error handling](sync.md#permissive-error-handling). + Same configuration structure as the sync starter. See [Declarative Spring Clients](declarative-spring.md) for full annotation details. > **Note:** Retry is not configured via properties for reactive clients - use Reactor's built-in `.retryWhen()` instead. See [Retry & Backoff](retry.md#reactive-reactor--mutiny). @@ -228,6 +231,7 @@ ark.client.user-api.connect-timeout=5 ark.client.user-api.read-timeout=15 ark.client.user-api.tls-configuration-name=my-cert ark.client.user-api.trust-all=false +ark.client.user-api.throw-on-error=true ark.client.user-api.headers.X-Api-Key=${API_KEY} ark.client.user-api.retry.max-attempts=3 ark.client.user-api.retry.delay=500 @@ -235,6 +239,8 @@ ark.client.user-api.retry.delay=500 > ⚠️ `trust-all: true` disables certificate validation. Use only in local development. See [Security & TLS in README](../README.md#tls). +> Set `throw-on-error=false` to return HTTP 4xx/5xx responses instead of raising `ApiException`. See [Permissive error handling](sync.md#permissive-error-handling). + See [Retry & Backoff](retry.md) for full retry configuration. ```java From 2a27f80381c822f839e3f36f58ece0f8a15db015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Wed, 24 Jun 2026 10:48:43 -0400 Subject: [PATCH 12/15] chore: add gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index c20e796..84a991e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ build/ CLAUDE.md .flattened-pom.xml + +plans From 83ebf20074d20edc3068a3356edc8a7a7243a64a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Wed, 24 Jun 2026 11:21:09 -0400 Subject: [PATCH 13/15] feat(core): expose RawResponse via ClientResponse.raw() across all 5 execution models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a public raw() method to each *ClientResponse interface returning the underlying RawResponse (sync), CompletableFuture (async), Mono (reactor), Uni (mutiny), or io.vertx.core.Future (vertx). Each Default*ClientResponse already holds the raw payload as a field — this exposes it via the public API. Useful in combination with .noThrow() or client-level throwOnError(false): callers can inspect status, headers, and the raw body String regardless of HTTP status, without going through type-based deserialization. --- .../xyz/juandiii/ark/core/http/ClientResponse.java | 10 ++++++++++ .../juandiii/ark/core/http/DefaultClientResponse.java | 5 +++++ .../juandiii/ark/async/http/AsyncClientResponse.java | 11 +++++++++++ .../ark/async/http/DefaultAsyncClientResponse.java | 5 +++++ .../ark/mutiny/http/DefaultMutinyClientResponse.java | 5 +++++ .../ark/mutiny/http/MutinyClientResponse.java | 11 +++++++++++ .../reactor/http/DefaultReactorClientResponse.java | 5 +++++ .../ark/reactor/http/ReactorClientResponse.java | 11 +++++++++++ .../ark/vertx/http/DefaultVertxClientResponse.java | 5 +++++ .../juandiii/ark/vertx/http/VertxClientResponse.java | 11 +++++++++++ 10 files changed, 79 insertions(+) diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientResponse.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientResponse.java index cc4e373..49bf583 100644 --- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientResponse.java +++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/ClientResponse.java @@ -55,4 +55,14 @@ public interface ClientResponse { * @return response wrapper with a {@code Void} body */ ArkResponse toBodilessEntity(); + + /** + * Returns the raw HTTP response — status code, headers, and body as a String — + * without deserialization. Useful with {@link ClientRequest#noThrow()} (or + * client-level {@code throwOnError(false)}) to inspect error bodies that + * don't match a typed schema. + * + * @return the raw response wrapper produced by the transport + */ + RawResponse raw(); } diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/DefaultClientResponse.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/DefaultClientResponse.java index d35169d..ede4f0d 100644 --- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/DefaultClientResponse.java +++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/http/DefaultClientResponse.java @@ -43,4 +43,9 @@ public ArkResponse toEntity(Class type) { public ArkResponse toBodilessEntity() { return new ArkResponse<>(raw.statusCode(), raw.headers(), null); } + + @Override + public RawResponse raw() { + return raw; + } } diff --git a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientResponse.java b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientResponse.java index a498059..f8d6544 100644 --- a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientResponse.java +++ b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/AsyncClientResponse.java @@ -2,6 +2,7 @@ import xyz.juandiii.ark.core.TypeRef; import xyz.juandiii.ark.core.http.ArkResponse; +import xyz.juandiii.ark.core.http.RawResponse; import java.util.concurrent.CompletableFuture; @@ -21,4 +22,14 @@ public interface AsyncClientResponse { CompletableFuture> toEntity(Class type); CompletableFuture> toBodilessEntity(); + + /** + * Returns the raw HTTP response — status code, headers, and body as a String — + * without deserialization. Useful with {@link AsyncClientRequest#noThrow()} (or + * client-level {@code throwOnError(false)}) to inspect error bodies that + * don't match a typed schema. + * + * @return future completed with the raw response wrapper produced by the transport + */ + CompletableFuture raw(); } diff --git a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/DefaultAsyncClientResponse.java b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/DefaultAsyncClientResponse.java index 0da6912..a60a711 100644 --- a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/DefaultAsyncClientResponse.java +++ b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/http/DefaultAsyncClientResponse.java @@ -50,4 +50,9 @@ public CompletableFuture> toBodilessEntity() { return future.thenApply(raw -> new ArkResponse<>(raw.statusCode(), raw.headers(), null)); } + + @Override + public CompletableFuture raw() { + return future; + } } diff --git a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/DefaultMutinyClientResponse.java b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/DefaultMutinyClientResponse.java index 122cc6f..d7d1f3a 100644 --- a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/DefaultMutinyClientResponse.java +++ b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/DefaultMutinyClientResponse.java @@ -49,4 +49,9 @@ public Uni> toBodilessEntity() { return uni.onItem().transform(raw -> new ArkResponse<>(raw.statusCode(), raw.headers(), null)); } + + @Override + public Uni raw() { + return uni; + } } diff --git a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientResponse.java b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientResponse.java index a2e22ab..1286e6b 100644 --- a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientResponse.java +++ b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/http/MutinyClientResponse.java @@ -4,6 +4,7 @@ import io.smallrye.mutiny.Uni; import xyz.juandiii.ark.core.TypeRef; import xyz.juandiii.ark.core.http.ArkResponse; +import xyz.juandiii.ark.core.http.RawResponse; import java.util.List; @@ -35,4 +36,14 @@ default Multi bodyAsMulti(Class type) { Uni> toEntity(Class type); Uni> toBodilessEntity(); + + /** + * Returns the raw HTTP response — status code, headers, and body as a String — + * without deserialization. Useful with {@link MutinyClientRequest#noThrow()} (or + * client-level {@code throwOnError(false)}) to inspect error bodies that + * don't match a typed schema. + * + * @return Uni emitting the raw response wrapper produced by the transport + */ + Uni raw(); } diff --git a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/DefaultReactorClientResponse.java b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/DefaultReactorClientResponse.java index 78259e0..6ee7ae8 100644 --- a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/DefaultReactorClientResponse.java +++ b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/DefaultReactorClientResponse.java @@ -49,4 +49,9 @@ public Mono> toBodilessEntity() { return mono.map(raw -> new ArkResponse<>(raw.statusCode(), raw.headers(), null)); } + + @Override + public Mono raw() { + return mono; + } } diff --git a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientResponse.java b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientResponse.java index 3401242..38e454a 100644 --- a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientResponse.java +++ b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/http/ReactorClientResponse.java @@ -4,6 +4,7 @@ import reactor.core.publisher.Mono; import xyz.juandiii.ark.core.TypeRef; import xyz.juandiii.ark.core.http.ArkResponse; +import xyz.juandiii.ark.core.http.RawResponse; import java.util.List; @@ -35,4 +36,14 @@ default Flux bodyAsFlux(Class type) { Mono> toEntity(Class type); Mono> toBodilessEntity(); + + /** + * Returns the raw HTTP response — status code, headers, and body as a String — + * without deserialization. Useful with {@link ReactorClientRequest#noThrow()} (or + * client-level {@code throwOnError(false)}) to inspect error bodies that + * don't match a typed schema. + * + * @return Mono emitting the raw response wrapper produced by the transport + */ + Mono raw(); } diff --git a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/DefaultVertxClientResponse.java b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/DefaultVertxClientResponse.java index 9c877c2..607cc75 100644 --- a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/DefaultVertxClientResponse.java +++ b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/DefaultVertxClientResponse.java @@ -49,4 +49,9 @@ public Future> toBodilessEntity() { return future.map(raw -> new ArkResponse<>(raw.statusCode(), raw.headers(), null)); } + + @Override + public Future raw() { + return future; + } } diff --git a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientResponse.java b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientResponse.java index 20dcb5b..6b95b3b 100644 --- a/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientResponse.java +++ b/execution-models/ark-vertx/src/main/java/xyz/juandiii/ark/vertx/http/VertxClientResponse.java @@ -3,6 +3,7 @@ import io.vertx.core.Future; import xyz.juandiii.ark.core.TypeRef; import xyz.juandiii.ark.core.http.ArkResponse; +import xyz.juandiii.ark.core.http.RawResponse; /** * Interface for Vert.x response extraction. @@ -20,4 +21,14 @@ public interface VertxClientResponse { Future> toEntity(Class type); Future> toBodilessEntity(); + + /** + * Returns the raw HTTP response — status code, headers, and body as a String — + * without deserialization. Useful with {@link VertxClientRequest#noThrow()} (or + * client-level {@code throwOnError(false)}) to inspect error bodies that + * don't match a typed schema. + * + * @return Future completed with the raw response wrapper produced by the transport + */ + Future raw(); } From a791caf292ca53d7c2b423d3a458dd56f3a35f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Wed, 24 Jun 2026 11:24:39 -0400 Subject: [PATCH 14/15] feat(proxy): handle RawResponse return type in *ReturnTypeHandlers (auto-noThrow + retrieve raw) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each execution-model ReturnTypeHandler now detects RawResponse (or CompletableFuture / Mono / Uni) as a valid proxy return type and: 1. Toggles per-request noThrow so HTTP 4xx/5xx do not raise ApiException before the handler can return — opting into raw mode implies the caller wants to inspect the response regardless of status. 2. Calls retrieve().raw() instead of retrieve().body(type), returning the raw response wrapper instead of going through deserialization. The auto-noThrow only applies when the return type is RawResponse — other return types on the same client preserve the configured default. Enables @RegisterArkClient interfaces to declare methods like: @GetExchange("/{id}") RawResponse getUserRaw(@PathVariable String id); without setting throw-on-error=false at the client level. Note: Vert.x execution model has no return-type handler today, so this change is limited to sync/async/reactor/mutiny. --- .../ark/core/proxy/SyncReturnTypeHandler.java | 5 +++++ .../ark/async/proxy/AsyncReturnTypeHandler.java | 16 +++++++++++----- .../mutiny/proxy/MutinyReturnTypeHandler.java | 17 +++++++++++------ .../reactor/proxy/ReactorReturnTypeHandler.java | 17 +++++++++++------ 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/core/ark-core/src/main/java/xyz/juandiii/ark/core/proxy/SyncReturnTypeHandler.java b/core/ark-core/src/main/java/xyz/juandiii/ark/core/proxy/SyncReturnTypeHandler.java index 3442759..c7d2d6a 100644 --- a/core/ark-core/src/main/java/xyz/juandiii/ark/core/proxy/SyncReturnTypeHandler.java +++ b/core/ark-core/src/main/java/xyz/juandiii/ark/core/proxy/SyncReturnTypeHandler.java @@ -3,6 +3,7 @@ import xyz.juandiii.ark.core.TypeRef; import xyz.juandiii.ark.core.http.ArkResponse; import xyz.juandiii.ark.core.http.ClientRequest; +import xyz.juandiii.ark.core.http.RawResponse; import xyz.juandiii.ark.core.interceptor.RequestContext; import java.lang.reflect.ParameterizedType; @@ -24,6 +25,10 @@ public Object handle(RequestContext request, Type returnType) { return null; } + if (returnType == RawResponse.class) { + return syncRequest.noThrow().retrieve().raw(); + } + if (returnType instanceof ParameterizedType pt && pt.getRawType() == ArkResponse.class) { Type bodyType = pt.getActualTypeArguments()[0]; diff --git a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/proxy/AsyncReturnTypeHandler.java b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/proxy/AsyncReturnTypeHandler.java index e0bae56..3664a62 100644 --- a/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/proxy/AsyncReturnTypeHandler.java +++ b/execution-models/ark-async/src/main/java/xyz/juandiii/ark/async/proxy/AsyncReturnTypeHandler.java @@ -4,6 +4,7 @@ import xyz.juandiii.ark.async.http.AsyncClientRequest; import xyz.juandiii.ark.async.http.AsyncClientResponse; import xyz.juandiii.ark.core.http.ArkResponse; +import xyz.juandiii.ark.core.http.RawResponse; import xyz.juandiii.ark.core.interceptor.RequestContext; import xyz.juandiii.ark.core.proxy.ReturnTypeHandler; @@ -13,7 +14,8 @@ /** * Dispatches async request execution based on method return type. - * Supports CompletableFuture<T>, CompletableFuture<ArkResponse<T>>, and void. + * Supports CompletableFuture<T>, CompletableFuture<ArkResponse<T>>, + * CompletableFuture<RawResponse>, and void. * * @author Juan Diego Lopez V. */ @@ -22,19 +24,23 @@ public final class AsyncReturnTypeHandler implements ReturnTypeHandler { @Override public Object handle(RequestContext request, Type returnType) { AsyncClientRequest asyncRequest = (AsyncClientRequest) request; - AsyncClientResponse response = asyncRequest.retrieve(); if (returnType == void.class || returnType == Void.class) { - return response.toBodilessEntity(); + return asyncRequest.retrieve().toBodilessEntity(); } if (returnType instanceof ParameterizedType pt && pt.getRawType() == CompletableFuture.class) { Type innerType = pt.getActualTypeArguments()[0]; - return handleFutureType(response, innerType); + + if (innerType == RawResponse.class) { + return asyncRequest.noThrow().retrieve().raw(); + } + + return handleFutureType(asyncRequest.retrieve(), innerType); } - return response.body(TypeRef.of(returnType)); + return asyncRequest.retrieve().body(TypeRef.of(returnType)); } private Object handleFutureType(AsyncClientResponse response, Type innerType) { diff --git a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/proxy/MutinyReturnTypeHandler.java b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/proxy/MutinyReturnTypeHandler.java index 0b904eb..cea4113 100644 --- a/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/proxy/MutinyReturnTypeHandler.java +++ b/execution-models/ark-mutiny/src/main/java/xyz/juandiii/ark/mutiny/proxy/MutinyReturnTypeHandler.java @@ -4,6 +4,7 @@ import io.smallrye.mutiny.Uni; import xyz.juandiii.ark.core.TypeRef; import xyz.juandiii.ark.core.http.ArkResponse; +import xyz.juandiii.ark.core.http.RawResponse; import xyz.juandiii.ark.core.interceptor.RequestContext; import xyz.juandiii.ark.mutiny.http.MutinyClientRequest; import xyz.juandiii.ark.mutiny.http.MutinyClientResponse; @@ -14,7 +15,8 @@ /** * Dispatches Mutiny request execution based on method return type. - * Supports Uni<T>, Uni<ArkResponse<T>>, Multi<T>, and void. + * Supports Uni<T>, Uni<ArkResponse<T>>, Uni<RawResponse>, + * Multi<T>, and void. * * @author Juan Diego Lopez V. */ @@ -23,22 +25,25 @@ public final class MutinyReturnTypeHandler implements ReturnTypeHandler { @Override public Object handle(RequestContext request, Type returnType) { MutinyClientRequest mutinyRequest = (MutinyClientRequest) request; - MutinyClientResponse response = mutinyRequest.retrieve(); if (returnType == void.class || returnType == Void.class) { - return response.toBodilessEntity(); + return mutinyRequest.retrieve().toBodilessEntity(); } if (returnType instanceof ParameterizedType pt) { if (pt.getRawType() == Uni.class) { - return handleUniType(response, pt.getActualTypeArguments()[0]); + Type innerType = pt.getActualTypeArguments()[0]; + if (innerType == RawResponse.class) { + return mutinyRequest.noThrow().retrieve().raw(); + } + return handleUniType(mutinyRequest.retrieve(), innerType); } if (pt.getRawType() == Multi.class) { - return handleMultiType(response, pt.getActualTypeArguments()[0]); + return handleMultiType(mutinyRequest.retrieve(), pt.getActualTypeArguments()[0]); } } - return response.body(TypeRef.of(returnType)); + return mutinyRequest.retrieve().body(TypeRef.of(returnType)); } private Object handleUniType(MutinyClientResponse response, Type innerType) { diff --git a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/proxy/ReactorReturnTypeHandler.java b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/proxy/ReactorReturnTypeHandler.java index 7a1cbb3..af13cdd 100644 --- a/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/proxy/ReactorReturnTypeHandler.java +++ b/execution-models/ark-reactor/src/main/java/xyz/juandiii/ark/reactor/proxy/ReactorReturnTypeHandler.java @@ -4,6 +4,7 @@ import reactor.core.publisher.Mono; import xyz.juandiii.ark.core.TypeRef; import xyz.juandiii.ark.core.http.ArkResponse; +import xyz.juandiii.ark.core.http.RawResponse; import xyz.juandiii.ark.core.interceptor.RequestContext; import xyz.juandiii.ark.core.proxy.ReturnTypeHandler; import xyz.juandiii.ark.reactor.http.ReactorClientRequest; @@ -14,7 +15,8 @@ /** * Dispatches Reactor request execution based on method return type. - * Supports Mono<T>, Mono<ArkResponse<T>>, Flux<T>, and void. + * Supports Mono<T>, Mono<ArkResponse<T>>, Mono<RawResponse>, + * Flux<T>, and void. * * @author Juan Diego Lopez V. */ @@ -23,22 +25,25 @@ public final class ReactorReturnTypeHandler implements ReturnTypeHandler { @Override public Object handle(RequestContext request, Type returnType) { ReactorClientRequest reactorRequest = (ReactorClientRequest) request; - ReactorClientResponse response = reactorRequest.retrieve(); if (returnType == void.class || returnType == Void.class) { - return response.toBodilessEntity(); + return reactorRequest.retrieve().toBodilessEntity(); } if (returnType instanceof ParameterizedType pt) { if (pt.getRawType() == Mono.class) { - return handleMonoType(response, pt.getActualTypeArguments()[0]); + Type innerType = pt.getActualTypeArguments()[0]; + if (innerType == RawResponse.class) { + return reactorRequest.noThrow().retrieve().raw(); + } + return handleMonoType(reactorRequest.retrieve(), innerType); } if (pt.getRawType() == Flux.class) { - return handleFluxType(response, pt.getActualTypeArguments()[0]); + return handleFluxType(reactorRequest.retrieve(), pt.getActualTypeArguments()[0]); } } - return response.body(TypeRef.of(returnType)); + return reactorRequest.retrieve().body(TypeRef.of(returnType)); } private Object handleMonoType(ReactorClientResponse response, Type innerType) { From 34ecedb1ecdd6cb0497a1401c6275b43d397b2b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20L=C3=B3pez?= Date: Wed, 24 Jun 2026 11:31:06 -0400 Subject: [PATCH 15/15] test(raw): cover RawResponse access via fluent .raw() and proxy return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 3 tests per execution model (15 total): - fluent .raw() happy path (200 — body matches transport output) - fluent .raw() with .noThrow() on 4xx (404 — returns wrapper, no throw) - proxy RawResponse return type — auto-noThrow + status/body inspectable (Vert.x has no proxy handler today, so its third test exercises the client-level throwOnError(false) path with .raw() instead) Docs additions: - "Capturing the raw response" section in sync.md, async.md, reactor.md, mutiny.md - "RawResponse as a return type" section in declarative-spring.md and quarkus-jackson.md - README feature bullet --- README.md | 4 + .../ark/core/http/RawResponseAccessTest.java | 77 +++++++++++++++ docs/async.md | 27 +++++ docs/declarative-spring.md | 26 +++++ docs/mutiny.md | 27 +++++ docs/quarkus-jackson.md | 24 +++++ docs/reactor.md | 27 +++++ docs/sync.md | 25 +++++ .../http/AsyncRawResponseAccessTest.java | 90 +++++++++++++++++ .../http/MutinyRawResponseAccessTest.java | 86 ++++++++++++++++ .../http/ReactorRawResponseAccessTest.java | 94 ++++++++++++++++++ .../http/VertxRawResponseAccessTest.java | 98 +++++++++++++++++++ 12 files changed, 605 insertions(+) create mode 100644 core/ark-core/src/test/java/xyz/juandiii/ark/core/http/RawResponseAccessTest.java create mode 100644 execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncRawResponseAccessTest.java create mode 100644 execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyRawResponseAccessTest.java create mode 100644 execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorRawResponseAccessTest.java create mode 100644 execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxRawResponseAccessTest.java diff --git a/README.md b/README.md index 056c493..08dcf1e 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,10 @@ The chain composes **outside-in** — the last `.with(...)` is the outermost wra - **Permissive error handling** — opt out of throw-on-4xx/5xx per request (`.noThrow()`) or at the client level (`throwOnError(false)`). Useful when 4xx is business semantics (e.g. 404 = not found, not an error). +- **Raw response access** — `.raw()` on every `*ClientResponse`, or declare + `RawResponse` as a proxy method return type. Bypasses deserialization + and auto-disables throw-on-error — useful for inspecting error bodies + or non-JSON responses. - Per-request timeout support - HTTP/2 by default - Spring Boot (sync + async + WebFlux) and Quarkus integration diff --git a/core/ark-core/src/test/java/xyz/juandiii/ark/core/http/RawResponseAccessTest.java b/core/ark-core/src/test/java/xyz/juandiii/ark/core/http/RawResponseAccessTest.java new file mode 100644 index 0000000..984c825 --- /dev/null +++ b/core/ark-core/src/test/java/xyz/juandiii/ark/core/http/RawResponseAccessTest.java @@ -0,0 +1,77 @@ +package xyz.juandiii.ark.core.http; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import xyz.juandiii.ark.core.Ark; +import xyz.juandiii.ark.core.ArkClient; +import xyz.juandiii.ark.core.JsonSerializer; +import xyz.juandiii.ark.core.proxy.SyncReturnTypeHandler; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Verifies fluent {@code .raw()} access and proxy {@code RawResponse} return type + * auto-toggling {@code noThrow} for the sync execution model. + */ +@ExtendWith(MockitoExtension.class) +class RawResponseAccessTest { + + @Mock JsonSerializer serializer; + @Mock HttpTransport transport; + + private Ark defaultClient() { + return ArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .build(); + } + + @Test + void fluentRaw_200_returnsTransportRawResponse() { + RawResponse expected = new RawResponse(200, Map.of("X-Trace", java.util.List.of("abc")), "{\"x\":1}"); + when(transport.send(anyString(), any(), anyMap(), any(), any())).thenReturn(expected); + + RawResponse raw = defaultClient().get("/foo").retrieve().raw(); + + assertSame(expected, raw); + assertEquals(200, raw.statusCode()); + assertEquals("{\"x\":1}", raw.body()); + } + + @Test + void fluentRawWithNoThrow_404_doesNotThrowAndExposesStatusAndBody() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(new RawResponse(404, Map.of(), "{\"error\":\"missing\"}")); + + RawResponse raw = defaultClient().get("/foo").noThrow().retrieve().raw(); + + assertEquals(404, raw.statusCode()); + assertEquals("{\"error\":\"missing\"}", raw.body()); + assertTrue(raw.isError()); + } + + @Test + void proxyRawResponseReturnType_404_autoNoThrowAndReturnsRaw() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(new RawResponse(404, Map.of(), "{\"err\":\"x\"}")); + + Ark ark = defaultClient(); + SyncReturnTypeHandler handler = new SyncReturnTypeHandler(); + java.lang.reflect.Type returnType = RawResponse.class; + Object result = handler.handle(ark.get("/foo"), returnType); + + assertInstanceOf(RawResponse.class, result); + RawResponse raw = (RawResponse) result; + assertEquals(404, raw.statusCode()); + assertEquals("{\"err\":\"x\"}", raw.body()); + } +} diff --git a/docs/async.md b/docs/async.md index 531fb19..253cfdb 100644 --- a/docs/async.md +++ b/docs/async.md @@ -145,6 +145,33 @@ AsyncArk permissive = AsyncArkClient.builder() --- +## Capturing the raw response + +When you need the raw response — status, headers, and body as a String — +without going through deserialization (e.g. to inspect an error body that +doesn't match your typed schema), use `.raw()`: + +```java +CompletableFuture futureRaw = client.get("/users/1") + .noThrow() + .retrieve() + .raw(); + +futureRaw.thenAccept(raw -> { + if (raw.isError()) { + log.warn("Error {}: {}", raw.statusCode(), raw.body()); + } else { + User user = serializer.deserialize(raw.body(), User.class); + } +}); +``` + +`.raw()` returns a `CompletableFuture` (no deserialization). +Use it together with `.noThrow()` (or client-level `throwOnError(false)`) +to inspect bodies on 4xx/5xx without the future failing. + +--- + ## Related - [Error Handling](error-handling.md) diff --git a/docs/declarative-spring.md b/docs/declarative-spring.md index 68e114d..d5499be 100644 --- a/docs/declarative-spring.md +++ b/docs/declarative-spring.md @@ -132,13 +132,39 @@ Both `value` and `url` work: `@GetExchange("/users")` and `@GetExchange(url = "/ | `T` | Deserializes response body | | `void` | Calls `toBodilessEntity()` | | `ArkResponse` | Full response (status + headers + body) | +| `RawResponse` | Raw status + headers + body String, auto-disables throw-on-error | | `String` | Raw response body | | `Mono` | Reactor reactive (requires `ark-spring-boot-starter-webflux`) | | `Mono>` | Reactor full response | +| `Mono` | Reactor raw response, auto-disables throw-on-error | | `Flux` | Reactor stream from JSON array | --- +## RawResponse as a return type + +Proxy methods can declare `RawResponse` (or `CompletableFuture` / +`Mono`) as the return type. This bypasses deserialization and +auto-disables throw-on-error for that method — useful for full access to +the response regardless of status. + +```java +@RegisterArkClient(configKey = "users-api") +@HttpExchange("/users") +public interface UserApi { + @GetExchange("/{id}") + User getUser(@PathVariable String id); // type-safe, throws on 4xx/5xx + + @GetExchange("/{id}") + RawResponse getUserRaw(@PathVariable String id); // raw, never throws +} +``` + +The raw method auto-disables throw-on-error for its requests — no need to +set `throw-on-error=false` at the client level just for this method. + +--- + ## Reactive Client (Reactor) Pass a `ReactorArk` client to `ArkProxy.create()` for reactive Spring WebFlux clients: diff --git a/docs/mutiny.md b/docs/mutiny.md index 7796136..29679ef 100644 --- a/docs/mutiny.md +++ b/docs/mutiny.md @@ -203,6 +203,33 @@ MutinyArk permissive = MutinyArkClient.builder() --- +## Capturing the raw response + +When you need the raw response — status, headers, and body as a String — +without going through deserialization (e.g. to inspect an error body that +doesn't match your typed schema), use `.raw()`: + +```java +Uni raw = client.get("/users/1") + .noThrow() + .retrieve() + .raw(); + +raw.onItem().transformToUni(r -> { + if (r.isError()) { + log.warn("Error {}: {}", r.statusCode(), r.body()); + return Uni.createFrom().nullItem(); + } + return Uni.createFrom().item(serializer.deserialize(r.body(), User.class)); +}); +``` + +`.raw()` returns a `Uni` (no deserialization). Use it +together with `.noThrow()` (or client-level `throwOnError(false)`) to +inspect bodies on 4xx/5xx without the Uni failing. + +--- + ## Related - [Quarkus Jackson Extension](quarkus-jackson.md) diff --git a/docs/quarkus-jackson.md b/docs/quarkus-jackson.md index 512c365..8163302 100644 --- a/docs/quarkus-jackson.md +++ b/docs/quarkus-jackson.md @@ -235,6 +235,30 @@ See [Declarative JAX-RS Clients](declarative-jaxrs.md) for full details. --- +## RawResponse as a return type + +Proxy methods can declare `RawResponse` (or `Uni`) as the +return type. This bypasses deserialization and auto-disables +throw-on-error for that method — useful for inspecting error bodies that +don't match a typed schema or for non-JSON responses. + +```java +@RegisterArkClient(configKey = "users-api") +@Path("/users") +public interface UserApi { + @GET @Path("/{id}") + Uni getUser(@PathParam("id") String id); // type-safe, fails Uni on 4xx/5xx + + @GET @Path("/{id}") + Uni getUserRaw(@PathParam("id") String id); // raw, never fails the Uni +} +``` + +The raw method auto-disables throw-on-error for its requests — no need to +set `throw-on-error=false` at the client level just for this method. + +--- + ## Native Image Supports GraalVM native image out of the box. The extension auto-discovers `@RegisterArkClient` interfaces at build time and registers JDK proxy definitions. diff --git a/docs/reactor.md b/docs/reactor.md index 966fc91..72bb41b 100644 --- a/docs/reactor.md +++ b/docs/reactor.md @@ -229,6 +229,33 @@ ReactorArk permissive = ReactorArkClient.builder() --- +## Capturing the raw response + +When you need the raw response — status, headers, and body as a String — +without going through deserialization (e.g. to inspect an error body that +doesn't match your typed schema), use `.raw()`: + +```java +Mono raw = client.get("/users/1") + .noThrow() + .retrieve() + .raw(); + +raw.flatMap(r -> { + if (r.isError()) { + log.warn("Error {}: {}", r.statusCode(), r.body()); + return Mono.empty(); + } + return Mono.just(serializer.deserialize(r.body(), User.class)); +}); +``` + +`.raw()` returns a `Mono` (no deserialization). Use it +together with `.noThrow()` (or client-level `throwOnError(false)`) to +inspect bodies on 4xx/5xx without the Mono failing. + +--- + ## Related - [Spring Boot Integration](spring-boot.md) diff --git a/docs/sync.md b/docs/sync.md index 65c860e..2f760f0 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -210,6 +210,31 @@ ArkResponse response = permissive.get("/users/1").retrieve().toEntity(User --- +## Capturing the raw response + +When you need the raw response — status, headers, and body as a String — +without going through deserialization (e.g. to inspect an error body that +doesn't match your typed schema), use `.raw()`: + +```java +RawResponse raw = client.get("/users/1") + .noThrow() + .retrieve() + .raw(); + +if (raw.isError()) { + log.warn("Error {}: {}", raw.statusCode(), raw.body()); +} else { + User user = serializer.deserialize(raw.body(), User.class); +} +``` + +`.raw()` returns a `RawResponse` directly (no deserialization). Use it +together with `.noThrow()` (or client-level `throwOnError(false)`) to +inspect bodies on 4xx/5xx without exceptions. + +--- + ## Related - [Error Handling](error-handling.md) - full exception hierarchy diff --git a/execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncRawResponseAccessTest.java b/execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncRawResponseAccessTest.java new file mode 100644 index 0000000..4f73da8 --- /dev/null +++ b/execution-models/ark-async/src/test/java/xyz/juandiii/ark/async/http/AsyncRawResponseAccessTest.java @@ -0,0 +1,90 @@ +package xyz.juandiii.ark.async.http; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import xyz.juandiii.ark.async.AsyncArk; +import xyz.juandiii.ark.async.AsyncArkClient; +import xyz.juandiii.ark.async.proxy.AsyncReturnTypeHandler; +import xyz.juandiii.ark.core.JsonSerializer; +import xyz.juandiii.ark.core.http.RawResponse; +import xyz.juandiii.ark.core.http.Transport; + +import java.lang.reflect.Type; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Verifies fluent {@code .raw()} access and proxy {@code RawResponse} return type + * auto-toggling {@code noThrow} for the async execution model. + */ +@ExtendWith(MockitoExtension.class) +class AsyncRawResponseAccessTest { + + @Mock JsonSerializer serializer; + @Mock @SuppressWarnings("unchecked") + Transport> transport; + + interface TypeHelper { + CompletableFuture futureRaw(); + } + + private AsyncArk defaultClient() { + return AsyncArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .build(); + } + + @Test + void fluentRaw_200_returnsTransportRawResponse() throws Exception { + RawResponse expected = new RawResponse(200, Map.of(), "ok"); + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(expected)); + + RawResponse raw = defaultClient().get("/foo").retrieve().raw().get(); + + assertSame(expected, raw); + assertEquals(200, raw.statusCode()); + } + + @Test + void fluentRawWithNoThrow_404_doesNotThrowAndExposesStatusAndBody() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(CompletableFuture.completedFuture( + new RawResponse(404, Map.of(), "{\"error\":\"missing\"}"))); + + RawResponse raw = defaultClient().get("/foo").noThrow().retrieve().raw().get(); + + assertEquals(404, raw.statusCode()); + assertEquals("{\"error\":\"missing\"}", raw.body()); + assertTrue(raw.isError()); + } + + @Test + @SuppressWarnings("unchecked") + void proxyFutureRawResponseReturnType_404_autoNoThrowAndReturnsRaw() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(CompletableFuture.completedFuture( + new RawResponse(404, Map.of(), "{\"err\":\"x\"}"))); + + AsyncArk ark = defaultClient(); + AsyncReturnTypeHandler handler = new AsyncReturnTypeHandler(); + Type returnType = TypeHelper.class.getMethod("futureRaw").getGenericReturnType(); + + Object result = handler.handle(ark.get("/foo"), returnType); + + assertInstanceOf(CompletableFuture.class, result); + RawResponse raw = ((CompletableFuture) result).get(); + assertEquals(404, raw.statusCode()); + assertEquals("{\"err\":\"x\"}", raw.body()); + } +} diff --git a/execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyRawResponseAccessTest.java b/execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyRawResponseAccessTest.java new file mode 100644 index 0000000..dc7e994 --- /dev/null +++ b/execution-models/ark-mutiny/src/test/java/xyz/juandiii/ark/mutiny/http/MutinyRawResponseAccessTest.java @@ -0,0 +1,86 @@ +package xyz.juandiii.ark.mutiny.http; + +import io.smallrye.mutiny.Uni; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import xyz.juandiii.ark.core.JsonSerializer; +import xyz.juandiii.ark.core.http.RawResponse; +import xyz.juandiii.ark.mutiny.MutinyArk; +import xyz.juandiii.ark.mutiny.MutinyArkClient; +import xyz.juandiii.ark.mutiny.proxy.MutinyReturnTypeHandler; + +import java.lang.reflect.Type; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Verifies fluent {@code .raw()} access and proxy {@code RawResponse} return type + * auto-toggling {@code noThrow} for the Mutiny execution model. + */ +@ExtendWith(MockitoExtension.class) +class MutinyRawResponseAccessTest { + + @Mock JsonSerializer serializer; + @Mock MutinyHttpTransport transport; + + interface TypeHelper { + Uni uniRaw(); + } + + private MutinyArk defaultClient() { + return MutinyArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .build(); + } + + @Test + void fluentRaw_200_returnsTransportRawResponse() { + RawResponse expected = new RawResponse(200, Map.of(), "ok"); + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Uni.createFrom().item(expected)); + + RawResponse raw = defaultClient().get("/foo").retrieve().raw().await().indefinitely(); + + assertSame(expected, raw); + assertEquals(200, raw.statusCode()); + } + + @Test + void fluentRawWithNoThrow_404_doesNotThrowAndExposesStatusAndBody() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Uni.createFrom().item(new RawResponse(404, Map.of(), "{\"error\":\"missing\"}"))); + + RawResponse raw = defaultClient().get("/foo").noThrow().retrieve().raw().await().indefinitely(); + + assertEquals(404, raw.statusCode()); + assertEquals("{\"error\":\"missing\"}", raw.body()); + assertTrue(raw.isError()); + } + + @Test + @SuppressWarnings("unchecked") + void proxyUniRawResponseReturnType_404_autoNoThrowAndReturnsRaw() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Uni.createFrom().item(new RawResponse(404, Map.of(), "{\"err\":\"x\"}"))); + + MutinyArk ark = defaultClient(); + MutinyReturnTypeHandler handler = new MutinyReturnTypeHandler(); + Type returnType = TypeHelper.class.getMethod("uniRaw").getGenericReturnType(); + + Object result = handler.handle(ark.get("/foo"), returnType); + + assertInstanceOf(Uni.class, result); + RawResponse raw = ((Uni) result).await().indefinitely(); + assertEquals(404, raw.statusCode()); + assertEquals("{\"err\":\"x\"}", raw.body()); + } +} diff --git a/execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorRawResponseAccessTest.java b/execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorRawResponseAccessTest.java new file mode 100644 index 0000000..897f98e --- /dev/null +++ b/execution-models/ark-reactor/src/test/java/xyz/juandiii/ark/reactor/http/ReactorRawResponseAccessTest.java @@ -0,0 +1,94 @@ +package xyz.juandiii.ark.reactor.http; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import xyz.juandiii.ark.core.JsonSerializer; +import xyz.juandiii.ark.core.http.RawResponse; +import xyz.juandiii.ark.reactor.ReactorArk; +import xyz.juandiii.ark.reactor.ReactorArkClient; +import xyz.juandiii.ark.reactor.proxy.ReactorReturnTypeHandler; + +import java.lang.reflect.Type; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Verifies fluent {@code .raw()} access and proxy {@code RawResponse} return type + * auto-toggling {@code noThrow} for the Reactor execution model. + */ +@ExtendWith(MockitoExtension.class) +class ReactorRawResponseAccessTest { + + @Mock JsonSerializer serializer; + @Mock ReactorHttpTransport transport; + + interface TypeHelper { + Mono monoRaw(); + } + + private ReactorArk defaultClient() { + return ReactorArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .build(); + } + + @Test + void fluentRaw_200_returnsTransportRawResponse() { + RawResponse expected = new RawResponse(200, Map.of(), "ok"); + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Mono.just(expected)); + + StepVerifier.create(defaultClient().get("/foo").retrieve().raw()) + .assertNext(raw -> { + assertSame(expected, raw); + assertEquals(200, raw.statusCode()); + }) + .verifyComplete(); + } + + @Test + void fluentRawWithNoThrow_404_doesNotThrowAndExposesStatusAndBody() { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Mono.just(new RawResponse(404, Map.of(), "{\"error\":\"missing\"}"))); + + StepVerifier.create(defaultClient().get("/foo").noThrow().retrieve().raw()) + .assertNext(raw -> { + assertEquals(404, raw.statusCode()); + assertEquals("{\"error\":\"missing\"}", raw.body()); + assertTrue(raw.isError()); + }) + .verifyComplete(); + } + + @Test + @SuppressWarnings("unchecked") + void proxyMonoRawResponseReturnType_404_autoNoThrowAndReturnsRaw() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Mono.just(new RawResponse(404, Map.of(), "{\"err\":\"x\"}"))); + + ReactorArk ark = defaultClient(); + ReactorReturnTypeHandler handler = new ReactorReturnTypeHandler(); + Type returnType = TypeHelper.class.getMethod("monoRaw").getGenericReturnType(); + + Object result = handler.handle(ark.get("/foo"), returnType); + + assertInstanceOf(Mono.class, result); + StepVerifier.create((Mono) result) + .assertNext(raw -> { + assertEquals(404, raw.statusCode()); + assertEquals("{\"err\":\"x\"}", raw.body()); + }) + .verifyComplete(); + } +} diff --git a/execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxRawResponseAccessTest.java b/execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxRawResponseAccessTest.java new file mode 100644 index 0000000..2245934 --- /dev/null +++ b/execution-models/ark-vertx/src/test/java/xyz/juandiii/ark/vertx/http/VertxRawResponseAccessTest.java @@ -0,0 +1,98 @@ +package xyz.juandiii.ark.vertx.http; + +import io.vertx.core.Future; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import xyz.juandiii.ark.core.JsonSerializer; +import xyz.juandiii.ark.core.http.RawResponse; +import xyz.juandiii.ark.vertx.VertxArk; +import xyz.juandiii.ark.vertx.VertxArkClient; + +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Verifies fluent {@code .raw()} access for the Vert.x execution model. + * Vert.x has no proxy return-type handler yet, so only fluent paths are covered. + */ +@ExtendWith(MockitoExtension.class) +class VertxRawResponseAccessTest { + + @Mock JsonSerializer serializer; + @Mock VertxHttpTransport transport; + + private VertxArk defaultClient() { + return VertxArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .build(); + } + + @Test + void fluentRaw_200_returnsTransportRawResponse() throws Exception { + RawResponse expected = new RawResponse(200, Map.of(), "ok"); + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Future.succeededFuture(expected)); + + RawResponse raw = await(defaultClient().get("/foo").retrieve().raw()); + + assertSame(expected, raw); + assertEquals(200, raw.statusCode()); + } + + @Test + void fluentRawWithNoThrow_404_doesNotThrowAndExposesStatusAndBody() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Future.succeededFuture(new RawResponse(404, Map.of(), "{\"error\":\"missing\"}"))); + + RawResponse raw = await(defaultClient().get("/foo").noThrow().retrieve().raw()); + + assertEquals(404, raw.statusCode()); + assertEquals("{\"error\":\"missing\"}", raw.body()); + assertTrue(raw.isError()); + } + + @Test + void fluentRawWithClientThrowOnErrorFalse_500_exposesRawBody() throws Exception { + when(transport.send(anyString(), any(), anyMap(), any(), any())) + .thenReturn(Future.succeededFuture(new RawResponse(500, Map.of(), "boom"))); + + VertxArk ark = VertxArkClient.builder() + .serializer(serializer) + .transport(transport) + .baseUrl("https://api.example.com") + .throwOnError(false) + .build(); + + RawResponse raw = await(ark.get("/foo").retrieve().raw()); + + assertEquals(500, raw.statusCode()); + assertEquals("boom", raw.body()); + assertTrue(raw.isError()); + } + + private T await(Future future) throws Exception { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference value = new AtomicReference<>(); + AtomicReference error = new AtomicReference<>(); + future.onComplete(ar -> { + if (ar.succeeded()) value.set(ar.result()); + else error.set(ar.cause()); + latch.countDown(); + }); + assertTrue(latch.await(5, TimeUnit.SECONDS)); + if (error.get() != null) throw new RuntimeException(error.get()); + return value.get(); + } +}