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/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 }} 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: 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 diff --git a/README.md b/README.md index b2e306d..08dcf1e 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,13 @@ 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). +- **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/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/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/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()); } 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/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/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/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/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/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 cbc3489..253cfdb 100644 --- a/docs/async.md +++ b/docs/async.md @@ -109,6 +109,69 @@ 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(); +``` + +--- + +## 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 e6c3076..29679ef 100644 --- a/docs/mutiny.md +++ b/docs/mutiny.md @@ -169,6 +169,67 @@ 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(); +``` + +--- + +## 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 ebc49c5..8163302 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 @@ -232,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 cd0a3f9..72bb41b 100644 --- a/docs/reactor.md +++ b/docs/reactor.md @@ -195,6 +195,67 @@ 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(); +``` + +--- + +## 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/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 diff --git a/docs/sync.md b/docs/sync.md index 23d59d7..2f760f0 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -176,6 +176,65 @@ 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); +``` + +--- + +## 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/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-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-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-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-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/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/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/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-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-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-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-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/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/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/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-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-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) { 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-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/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/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/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(); } 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(); } 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(); + } +} 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(); + } +} 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. */ 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 ) {