From 8baffcacbb317c8f7473c14caa14d507aab69405 Mon Sep 17 00:00:00 2001 From: javorosas Date: Sun, 7 Jun 2026 00:28:29 +0200 Subject: [PATCH] Expose API error metadata --- CHANGELOG.md | 3 +- pom.xml | 2 +- .../java/io/facturapi/FacturapiException.java | 48 ++++++++++++++++++- .../facturapi/http/FacturapiHttpClient.java | 32 +++++++++++-- .../io/facturapi/FacturapiHttpClientTest.java | 11 ++++- .../java/io/facturapi/StubHttpClient.java | 8 +++- 6 files changed, 94 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ced40..ba72fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.3.0] - 2026-06-07 ### Added - Add `receipts.toInvoice(Map data)` for `POST /receipts/to-invoice`. - Add `receipts.previewToInvoicePdf(Map data)` for `POST /receipts/to-invoice/preview`. +- Expose structured API error metadata on `FacturapiException`, including `errorLocation`, `errors`, `logId`, and response `headers`. ## [1.2.0] - 2026-04-23 diff --git a/pom.xml b/pom.xml index f184d21..4235f4a 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ io.facturapi facturapi-java - 1.2.0 + 1.3.0 facturapi-java Official Java SDK for Facturapi https://github.com/facturapi/facturapi-java diff --git a/src/main/java/io/facturapi/FacturapiException.java b/src/main/java/io/facturapi/FacturapiException.java index ec6b9d9..0e0839c 100644 --- a/src/main/java/io/facturapi/FacturapiException.java +++ b/src/main/java/io/facturapi/FacturapiException.java @@ -1,12 +1,21 @@ package io.facturapi; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Collections; +import java.util.List; +import java.util.Map; + public class FacturapiException extends RuntimeException { private final int statusCode; private final Object errorCode; private final String errorPath; + private final String errorLocation; + private final JsonNode errors; + private final String logId; + private final Map> headers; public FacturapiException(String message) { - this(message, -1, null, null); + this(message, -1, null, null, null, null, null, Collections.emptyMap()); } public FacturapiException(String message, Throwable cause) { @@ -14,13 +23,34 @@ public FacturapiException(String message, Throwable cause) { this.statusCode = -1; this.errorCode = null; this.errorPath = null; + this.errorLocation = null; + this.errors = null; + this.logId = null; + this.headers = Collections.emptyMap(); } public FacturapiException(String message, int statusCode, Object errorCode, String errorPath) { + this(message, statusCode, errorCode, errorPath, null, null, null, Collections.emptyMap()); + } + + public FacturapiException( + String message, + int statusCode, + Object errorCode, + String errorPath, + String errorLocation, + JsonNode errors, + String logId, + Map> headers + ) { super(message); this.statusCode = statusCode; this.errorCode = errorCode; this.errorPath = errorPath; + this.errorLocation = errorLocation; + this.errors = errors; + this.logId = logId; + this.headers = headers == null ? Collections.emptyMap() : headers; } public int getStatusCode() { @@ -34,4 +64,20 @@ public Object getErrorCode() { public String getErrorPath() { return errorPath; } + + public String getErrorLocation() { + return errorLocation; + } + + public JsonNode getErrors() { + return errors; + } + + public String getLogId() { + return logId; + } + + public Map> getHeaders() { + return headers; + } } diff --git a/src/main/java/io/facturapi/http/FacturapiHttpClient.java b/src/main/java/io/facturapi/http/FacturapiHttpClient.java index 18578d5..0fee0d7 100644 --- a/src/main/java/io/facturapi/http/FacturapiHttpClient.java +++ b/src/main/java/io/facturapi/http/FacturapiHttpClient.java @@ -104,7 +104,7 @@ private JsonNode requestJsonNode( Request request = buildRequest(method, path, queryParams, body, multipartBody); try (Response response = httpClient.newCall(request).execute()) { if (!response.isSuccessful()) { - throw buildApiException(readBodyText(response), response.code()); + throw buildApiException(readBodyText(response), response); } ResponseBody responseBody = response.body(); @@ -131,7 +131,7 @@ private byte[] requestBytes(String method, String path, Object body) { Request request = buildRequest(method, path, null, body, null); try (Response response = httpClient.newCall(request).execute()) { if (!response.isSuccessful()) { - throw buildApiException(readBodyText(response), response.code()); + throw buildApiException(readBodyText(response), response); } ResponseBody responseBody = response.body(); return responseBody == null ? new byte[0] : responseBody.bytes(); @@ -146,7 +146,7 @@ private InputStream requestStream(String method, String path, Object body) { Response response = httpClient.newCall(request).execute(); if (!response.isSuccessful()) { try { - throw buildApiException(readBodyText(response), response.code()); + throw buildApiException(readBodyText(response), response); } finally { response.close(); } @@ -207,10 +207,13 @@ private static JsonNode firstDefined(JsonNode node, String... keys) { return null; } - private FacturapiException buildApiException(String bodyText, int statusCode) { + private FacturapiException buildApiException(String bodyText, Response response) { + int statusCode = response.code(); int resolvedStatus = statusCode; Object errorCode = null; String errorPath = null; + String errorLocation = null; + JsonNode errors = null; String message = "Request failed with status " + statusCode; if (bodyText != null && !bodyText.isBlank()) { @@ -261,6 +264,16 @@ private FacturapiException buildApiException(String bodyText, int statusCode) { errorPath = pathNode.asText(); } + JsonNode locationNode = firstDefined(root, "location"); + if (locationNode != null && locationNode.isTextual()) { + errorLocation = locationNode.asText(); + } + + JsonNode errorsNode = firstDefined(root, "errors"); + if (errorsNode != null && errorsNode.isArray()) { + errors = errorsNode; + } + if (firstDefined(root, "message", "error", "detail") == null) { message = bodyText; } @@ -270,7 +283,16 @@ private FacturapiException buildApiException(String bodyText, int statusCode) { } } - return new FacturapiException(message, resolvedStatus, errorCode, errorPath); + return new FacturapiException( + message, + resolvedStatus, + errorCode, + errorPath, + errorLocation, + errors, + response.header("x-facturapi-log-id"), + response.headers().toMultimap() + ); } private static String readBodyText(Response response) throws IOException { diff --git a/src/test/java/io/facturapi/FacturapiHttpClientTest.java b/src/test/java/io/facturapi/FacturapiHttpClientTest.java index dc1d41b..4917970 100644 --- a/src/test/java/io/facturapi/FacturapiHttpClientTest.java +++ b/src/test/java/io/facturapi/FacturapiHttpClientTest.java @@ -50,7 +50,11 @@ void returnsBinaryBytesForPdf() { @Test void throwsFacturapiExceptionWithApiMessage() { StubHttpClient httpClient = new StubHttpClient(); - httpClient.enqueueJson(400, "{\"message\":\"Invalid customer\",\"status\":\"400\",\"code\":\"validation_error\",\"path\":\"customer.tax_id\"}"); + httpClient.enqueueJson( + 400, + "{\"message\":\"Invalid customer\",\"status\":\"400\",\"code\":\"validation_error\",\"path\":\"customer.tax_id\",\"location\":\"body\",\"errors\":[{\"code\":\"required\",\"message\":\"tax id is required\",\"path\":\"customer.tax_id\",\"location\":\"body\"}]}", + Map.of("Retry-After", java.util.List.of("3"), "x-facturapi-log-id", java.util.List.of("log_123")) + ); FacturapiHttpClient client = new FacturapiHttpClient( FacturapiConfig.builder("sk_test_123") @@ -67,5 +71,10 @@ void throwsFacturapiExceptionWithApiMessage() { assertTrue(ex.getMessage().contains("Invalid customer")); assertEquals("validation_error", ex.getErrorCode()); assertEquals("customer.tax_id", ex.getErrorPath()); + assertEquals("body", ex.getErrorLocation()); + assertEquals("log_123", ex.getLogId()); + assertEquals("required", ex.getErrors().get(0).get("code").asText()); + assertEquals("3", ex.getHeaders().get("Retry-After").get(0)); + assertEquals("log_123", ex.getHeaders().get("x-facturapi-log-id").get(0)); } } diff --git a/src/test/java/io/facturapi/StubHttpClient.java b/src/test/java/io/facturapi/StubHttpClient.java index a247f82..7a151ea 100644 --- a/src/test/java/io/facturapi/StubHttpClient.java +++ b/src/test/java/io/facturapi/StubHttpClient.java @@ -105,11 +105,17 @@ OkHttpClient client() { } void enqueueJson(int statusCode, String json) { + enqueueJson(statusCode, json, Map.of()); + } + + void enqueueJson(int statusCode, String json, Map> extraHeaders) { + Map> headers = new java.util.HashMap<>(extraHeaders); + headers.put("Content-Type", List.of("application/json")); queue.add( new QueuedResponse( statusCode, json.getBytes(StandardCharsets.UTF_8), - Map.of("Content-Type", List.of("application/json")) + headers ) ); }