diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a15a270 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing + +## Generating Javadoc + +Build the aggregated API reference locally with `just javadoc` (or `./gradlew aggregateJavadoc`). The HTML output is placed under `build/docs/javadoc/index.html`. diff --git a/README.md b/README.md index 1b1a4a6..187d4aa 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,25 @@ readerIdFuture .join(); ``` +## Handling errors + +Catch `ApiStatusException` to inspect an unsuccessful API response: + +```java +import com.sumup.sdk.core.ApiStatusException; + +try { + client.checkouts().get("your-checkout-id"); +} catch (ApiStatusException error) { + error.getProblem().ifPresent(problem -> + System.out.println(problem.type() + ": " + problem.detail())); +} +``` + +`getStatusCode()`, `getResponseBody()`, and `getResponseHeaders()` provide the HTTP +status, original response text, and headers. Connection failures raise +`ApiConnectionException`; timeouts raise `ApiTimeoutException`. Both keep the original cause. + ## Handling events ```java @@ -302,8 +321,6 @@ export SUMUP_MERCHANT_CODE="your_merchant_code" ./gradlew :examples:card-reader-checkout:run ``` -## Generating Javadoc - -Build the aggregated API reference locally with `just javadoc` (or `./gradlew aggregateJavadoc`). The HTML output is placed under `build/docs/javadoc/index.html`. +For development instructions, see [Contributing](CONTRIBUTING.md). [docs-badge]: https://img.shields.io/badge/SumUp-documentation-white.svg?logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgY29sb3I9IndoaXRlIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogICAgPHBhdGggZD0iTTIyLjI5IDBIMS43Qy43NyAwIDAgLjc3IDAgMS43MVYyMi4zYzAgLjkzLjc3IDEuNyAxLjcxIDEuN0gyMi4zYy45NCAwIDEuNzEtLjc3IDEuNzEtMS43MVYxLjdDMjQgLjc3IDIzLjIzIDAgMjIuMjkgMFptLTcuMjIgMTguMDdhNS42MiA1LjYyIDAgMCAxLTcuNjguMjQuMzYuMzYgMCAwIDEtLjAxLS40OWw3LjQ0LTcuNDRhLjM1LjM1IDAgMCAxIC40OSAwIDUuNiA1LjYgMCAwIDEtLjI0IDcuNjlabTEuNTUtMTEuOS03LjQ0IDcuNDVhLjM1LjM1IDAgMCAxLS41IDAgNS42MSA1LjYxIDAgMCAxIDcuOS03Ljk2bC4wMy4wM2MuMTMuMTMuMTQuMzUuMDEuNDlaIiBmaWxsPSJjdXJyZW50Q29sb3IiLz4KPC9zdmc+ diff --git a/codegen/internal/generator/templates/model.tmpl b/codegen/internal/generator/templates/model.tmpl index 6d7bdfc..f8e7ed4 100644 --- a/codegen/internal/generator/templates/model.tmpl +++ b/codegen/internal/generator/templates/model.tmpl @@ -82,7 +82,7 @@ public record {{ .ClassName }}( public {{ .ClassName }} { additionalProperties = additionalProperties == null ? java.util.Map.of() - : java.util.Map.copyOf(additionalProperties); + : java.util.Collections.unmodifiableMap(new java.util.LinkedHashMap<>(additionalProperties)); } @JsonAnyGetter diff --git a/src/main/java/com/sumup/sdk/core/ApiClient.java b/src/main/java/com/sumup/sdk/core/ApiClient.java index a7fc87d..9f27a92 100644 --- a/src/main/java/com/sumup/sdk/core/ApiClient.java +++ b/src/main/java/com/sumup/sdk/core/ApiClient.java @@ -4,8 +4,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.sumup.sdk.models.Problem; import java.io.IOException; import java.lang.reflect.Array; import java.net.URI; @@ -13,6 +15,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.OffsetDateTime; @@ -23,7 +26,9 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; /** Minimal HTTP client wrapper used by the generated SDK clients. */ public final class ApiClient { @@ -75,15 +80,19 @@ public T send( RequestOptions requestOptions) { HttpRequest request = buildRequest(method, path, queryParams, headerParams, body, requestOptions); + HttpResponse response; try { - HttpResponse response = - httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - return readResponse(response, responseType); + response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); } catch (IOException | InterruptedException e) { if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); } - throw new ApiException("Failed to execute request", e); + throw transportException(e); + } + try { + return readResponse(response, responseType); + } catch (IOException e) { + throw new ApiException("Failed to decode response", e); } } @@ -109,12 +118,22 @@ public CompletableFuture sendAsync( buildRequest(method, path, queryParams, headerParams, body, requestOptions); return httpClient .sendAsync(request, HttpResponse.BodyHandlers.ofString()) - .thenApply( - response -> { + .handle( + (response, failure) -> { + if (failure != null) { + Throwable cause = + failure instanceof CompletionException && failure.getCause() != null + ? failure.getCause() + : failure; + if (cause instanceof CancellationException cancelled) { + throw cancelled; + } + throw transportException(cause); + } try { return readResponse(response, responseType); } catch (IOException e) { - throw new ApiException("Failed to execute request", e); + throw new ApiException("Failed to decode response", e); } }); } @@ -169,7 +188,32 @@ private T readResponse(HttpResponse response, TypeReference respo } return objectMapper.readValue(responseBody, responseType); } - throw new ApiException("Request failed", status, response.body()); + Problem problem = null; + try { + JsonNode json = objectMapper.readTree(response.body()); + if (json != null && json.hasNonNull("type") && json.get("type").isTextual()) { + problem = objectMapper.treeToValue(json, Problem.class); + } + } catch (IOException | IllegalArgumentException ignored) { + // Preserve unrecognized or malformed bodies in the HTTP exception. + } + String message = "Request failed"; + if (problem != null) { + if (problem.detail() != null && !problem.detail().isBlank()) { + message = problem.detail(); + } else if (problem.title() != null && !problem.title().isBlank()) { + message = problem.title(); + } + } + throw new ApiStatusException( + message, status, response.body(), problem, response.headers(), response.uri()); + } + + private static ApiConnectionException transportException(Throwable cause) { + if (cause instanceof HttpTimeoutException) { + return new ApiTimeoutException("Request timed out", cause); + } + return new ApiConnectionException("Failed to execute request", cause); } private Duration effectiveTimeout(RequestOptions requestOptions) { @@ -319,7 +363,7 @@ private static ObjectMapper defaultObjectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL); return mapper; } diff --git a/src/main/java/com/sumup/sdk/core/ApiConnectionException.java b/src/main/java/com/sumup/sdk/core/ApiConnectionException.java new file mode 100644 index 0000000..4fa399e --- /dev/null +++ b/src/main/java/com/sumup/sdk/core/ApiConnectionException.java @@ -0,0 +1,8 @@ +package com.sumup.sdk.core; + +/** The request could not be completed. */ +public class ApiConnectionException extends ApiException { + public ApiConnectionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sumup/sdk/core/ApiException.java b/src/main/java/com/sumup/sdk/core/ApiException.java index ea79351..c14a747 100644 --- a/src/main/java/com/sumup/sdk/core/ApiException.java +++ b/src/main/java/com/sumup/sdk/core/ApiException.java @@ -1,22 +1,48 @@ package com.sumup.sdk.core; +import com.sumup.sdk.models.Problem; +import java.net.URI; +import java.net.http.HttpHeaders; +import java.util.List; +import java.util.Map; +import java.util.Optional; + /** * Exception thrown when the SumUp API responds with a non-success status or the HTTP request fails. */ -public final class ApiException extends RuntimeException { +public class ApiException extends RuntimeException { private final int statusCode; private final String responseBody; + private final Problem problem; + private final Map> responseHeaders; + private final URI requestUri; public ApiException(String message, int statusCode, String responseBody) { + this(message, statusCode, responseBody, null, null, null); + } + + public ApiException( + String message, + int statusCode, + String responseBody, + Problem problem, + HttpHeaders headers, + URI requestUri) { super(message + " (" + statusCode + ")"); this.statusCode = statusCode; this.responseBody = responseBody; + this.problem = problem; + this.responseHeaders = headers == null ? Map.of() : headers.map(); + this.requestUri = requestUri; } public ApiException(String message, Throwable cause) { super(message, cause); this.statusCode = -1; this.responseBody = null; + this.problem = null; + this.responseHeaders = Map.of(); + this.requestUri = null; } public int getStatusCode() { @@ -26,4 +52,17 @@ public int getStatusCode() { public String getResponseBody() { return responseBody; } + + /** Typed Problem details, when the API returned a Problem response. */ + public Optional getProblem() { + return Optional.ofNullable(problem); + } + + public Map> getResponseHeaders() { + return responseHeaders; + } + + public Optional getRequestUri() { + return Optional.ofNullable(requestUri); + } } diff --git a/src/main/java/com/sumup/sdk/core/ApiStatusException.java b/src/main/java/com/sumup/sdk/core/ApiStatusException.java new file mode 100644 index 0000000..46d45db --- /dev/null +++ b/src/main/java/com/sumup/sdk/core/ApiStatusException.java @@ -0,0 +1,18 @@ +package com.sumup.sdk.core; + +import com.sumup.sdk.models.Problem; +import java.net.URI; +import java.net.http.HttpHeaders; + +/** The API returned an unsuccessful HTTP response. */ +public final class ApiStatusException extends ApiException { + public ApiStatusException( + String message, + int status, + String body, + Problem problem, + HttpHeaders headers, + URI requestUri) { + super(message, status, body, problem, headers, requestUri); + } +} diff --git a/src/main/java/com/sumup/sdk/core/ApiTimeoutException.java b/src/main/java/com/sumup/sdk/core/ApiTimeoutException.java new file mode 100644 index 0000000..49ff59a --- /dev/null +++ b/src/main/java/com/sumup/sdk/core/ApiTimeoutException.java @@ -0,0 +1,8 @@ +package com.sumup.sdk.core; + +/** The request timed out. */ +public class ApiTimeoutException extends ApiConnectionException { + public ApiTimeoutException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sumup/sdk/models/Problem.java b/src/main/java/com/sumup/sdk/models/Problem.java index 820a6f9..e8abebf 100644 --- a/src/main/java/com/sumup/sdk/models/Problem.java +++ b/src/main/java/com/sumup/sdk/models/Problem.java @@ -34,7 +34,8 @@ public record Problem( additionalProperties = additionalProperties == null ? java.util.Map.of() - : java.util.Map.copyOf(additionalProperties); + : java.util.Collections.unmodifiableMap( + new java.util.LinkedHashMap<>(additionalProperties)); } @JsonAnyGetter diff --git a/src/test/java/com/sumup/sdk/core/ApiClientTest.java b/src/test/java/com/sumup/sdk/core/ApiClientTest.java index 3d04a0e..17928ec 100644 --- a/src/test/java/com/sumup/sdk/core/ApiClientTest.java +++ b/src/test/java/com/sumup/sdk/core/ApiClientTest.java @@ -1,9 +1,16 @@ package com.sumup.sdk.core; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.core.type.TypeReference; import com.sumup.sdk.clients.ReadersClient; +import java.io.IOException; import java.net.Authenticator; import java.net.CookieHandler; import java.net.ProxySelector; @@ -124,8 +131,130 @@ void readerPathParamsEncodeStrings() { httpClient.lastRequest().uri()); } + @Test + void apiErrorsExposeTypedProblemsAndKeepResponseContext() { + String body = "{\"type\":\"about:blank\",\"detail\":\"Invalid amount\"}"; + ApiClient client = ApiClient.builder().httpClient(new CapturingHttpClient(400, body)).build(); + ApiStatusException error = + assertThrows( + ApiStatusException.class, + () -> client.send(HttpMethod.GET, "/error", null, null, null, null, null)); + assertEquals("Invalid amount", error.getProblem().orElseThrow().detail()); + assertEquals(body, error.getResponseBody()); + assertEquals(400, error.getStatusCode()); + assertEquals("10", error.getResponseHeaders().get("Retry-After").get(0)); + java.util.concurrent.CompletionException asyncError = + assertThrows( + java.util.concurrent.CompletionException.class, + () -> client.sendAsync(HttpMethod.GET, "/error", null, null, null, null, null).join()); + assertInstanceOf(ApiStatusException.class, asyncError.getCause()); + } + + @Test + void problemExtensionsCanContainNull() { + String body = "{\"type\":\"about:blank\",\"detail\":\"Token expired\",\"trace_id\":null}"; + ApiClient client = ApiClient.builder().httpClient(new CapturingHttpClient(401, body)).build(); + ApiStatusException error = + assertThrows( + ApiStatusException.class, + () -> client.send(HttpMethod.GET, "/error", null, null, null, null, null)); + var problem = error.getProblem().orElseThrow(); + assertEquals("Token expired", problem.detail()); + assertTrue(problem.additionalProperties().containsKey("trace_id")); + assertNull(problem.additionalProperties().get("trace_id")); + assertThrows( + UnsupportedOperationException.class, + () -> problem.additionalProperties().put("trace_id", "changed")); + } + + @Test + void problemTitleIsUsedWhenDetailIsMissing() { + String body = "{\"type\":\"about:blank\",\"title\":\"Unauthorized\"}"; + ApiClient client = ApiClient.builder().httpClient(new CapturingHttpClient(401, body)).build(); + ApiStatusException error = + assertThrows( + ApiStatusException.class, + () -> client.send(HttpMethod.GET, "/error", null, null, null, null, null)); + assertEquals("Unauthorized (401)", error.getMessage()); + } + + @Test + void malformedErrorBodiesRemainHttpErrors() { + ApiClient client = + ApiClient.builder().httpClient(new CapturingHttpClient(502, "{invalid")).build(); + ApiStatusException error = + assertThrows( + ApiStatusException.class, + () -> client.send(HttpMethod.GET, "/error", null, null, null, null, null)); + assertTrue(error.getProblem().isEmpty()); + assertEquals("{invalid", error.getResponseBody()); + assertEquals(502, error.getStatusCode()); + } + + @Test + void malformedSuccessIsNotAConnectionFailure() { + ApiClient client = + ApiClient.builder().httpClient(new CapturingHttpClient(200, "{invalid")).build(); + var type = new TypeReference>() {}; + ApiException error = + assertThrows( + ApiException.class, + () -> client.send(HttpMethod.GET, "/test", null, null, null, type, null)); + assertFalse(error instanceof ApiConnectionException); + assertEquals("Failed to decode response", error.getMessage()); + var asyncError = + assertThrows( + java.util.concurrent.CompletionException.class, + () -> client.sendAsync(HttpMethod.GET, "/test", null, null, null, type, null).join()); + assertEquals(ApiException.class, asyncError.getCause().getClass()); + assertEquals(error.getMessage(), asyncError.getCause().getMessage()); + } + + @Test + void transportFailuresPreserveTheirTypeAndCause() { + for (IOException cause : + new IOException[] { + new IOException("Disconnected"), new java.net.http.HttpTimeoutException("Deadline") + }) { + ApiClient client = ApiClient.builder().httpClient(new CapturingHttpClient(cause)).build(); + var error = + assertThrows( + ApiConnectionException.class, + () -> client.send(HttpMethod.GET, "/test", null, null, null, null, null)); + assertSame(cause, error.getCause()); + assertEquals( + cause instanceof java.net.http.HttpTimeoutException, + error instanceof ApiTimeoutException); + var asyncError = + assertThrows( + java.util.concurrent.CompletionException.class, + () -> client.sendAsync(HttpMethod.GET, "/test", null, null, null, null, null).join()); + assertEquals(error.getClass(), asyncError.getCause().getClass()); + assertSame(cause, asyncError.getCause().getCause()); + } + } + private static final class CapturingHttpClient extends HttpClient { private HttpRequest lastRequest; + private final int status; + private final String body; + private final IOException failure; + + CapturingHttpClient() { + this(200, ""); + } + + CapturingHttpClient(int status, String body) { + this.status = status; + this.body = body; + this.failure = null; + } + + CapturingHttpClient(IOException failure) { + this.status = 0; + this.body = ""; + this.failure = failure; + } HttpRequest lastRequest() { return lastRequest; @@ -178,17 +307,19 @@ public Optional executor() { @Override public HttpResponse send( - HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { + HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) throws IOException { this.lastRequest = request; - return (HttpResponse) new CapturingHttpResponse(request); + if (failure != null) throw failure; + return (HttpResponse) new CapturingHttpResponse(request, status, body); } @Override public CompletableFuture> sendAsync( HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { this.lastRequest = request; + if (failure != null) return CompletableFuture.failedFuture(failure); return CompletableFuture.completedFuture( - (HttpResponse) new CapturingHttpResponse(request)); + (HttpResponse) new CapturingHttpResponse(request, status, body)); } @Override @@ -197,21 +328,26 @@ public CompletableFuture> sendAsync( HttpResponse.BodyHandler responseBodyHandler, HttpResponse.PushPromiseHandler pushPromiseHandler) { this.lastRequest = request; + if (failure != null) return CompletableFuture.failedFuture(failure); return CompletableFuture.completedFuture( - (HttpResponse) new CapturingHttpResponse(request)); + (HttpResponse) new CapturingHttpResponse(request, status, body)); } } private static final class CapturingHttpResponse implements HttpResponse { private final HttpRequest request; + private final int status; + private final String body; - CapturingHttpResponse(HttpRequest request) { + CapturingHttpResponse(HttpRequest request, int status, String body) { this.request = request; + this.status = status; + this.body = body; } @Override public int statusCode() { - return 200; + return status; } @Override @@ -226,12 +362,12 @@ public Optional> previousResponse() { @Override public HttpHeaders headers() { - return HttpHeaders.of(Map.of(), (a, b) -> true); + return HttpHeaders.of(Map.of("Retry-After", java.util.List.of("10")), (a, b) -> true); } @Override public String body() { - return ""; + return body; } @Override