Skip to content

Commit 8baffca

Browse files
committed
Expose API error metadata
1 parent 78a4497 commit 8baffca

6 files changed

Lines changed: 94 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased]
8+
## [1.3.0] - 2026-06-07
99

1010
### Added
1111

1212
- Add `receipts.toInvoice(Map<String, Object> data)` for `POST /receipts/to-invoice`.
1313
- Add `receipts.previewToInvoicePdf(Map<String, Object> data)` for `POST /receipts/to-invoice/preview`.
14+
- Expose structured API error metadata on `FacturapiException`, including `errorLocation`, `errors`, `logId`, and response `headers`.
1415

1516
## [1.2.0] - 2026-04-23
1617

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
<groupId>io.facturapi</groupId>
77
<artifactId>facturapi-java</artifactId>
8-
<version>1.2.0</version>
8+
<version>1.3.0</version>
99
<name>facturapi-java</name>
1010
<description>Official Java SDK for Facturapi</description>
1111
<url>https://github.com/facturapi/facturapi-java</url>

src/main/java/io/facturapi/FacturapiException.java

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,56 @@
11
package io.facturapi;
22

3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import java.util.Collections;
5+
import java.util.List;
6+
import java.util.Map;
7+
38
public class FacturapiException extends RuntimeException {
49
private final int statusCode;
510
private final Object errorCode;
611
private final String errorPath;
12+
private final String errorLocation;
13+
private final JsonNode errors;
14+
private final String logId;
15+
private final Map<String, List<String>> headers;
716

817
public FacturapiException(String message) {
9-
this(message, -1, null, null);
18+
this(message, -1, null, null, null, null, null, Collections.emptyMap());
1019
}
1120

1221
public FacturapiException(String message, Throwable cause) {
1322
super(message, cause);
1423
this.statusCode = -1;
1524
this.errorCode = null;
1625
this.errorPath = null;
26+
this.errorLocation = null;
27+
this.errors = null;
28+
this.logId = null;
29+
this.headers = Collections.emptyMap();
1730
}
1831

1932
public FacturapiException(String message, int statusCode, Object errorCode, String errorPath) {
33+
this(message, statusCode, errorCode, errorPath, null, null, null, Collections.emptyMap());
34+
}
35+
36+
public FacturapiException(
37+
String message,
38+
int statusCode,
39+
Object errorCode,
40+
String errorPath,
41+
String errorLocation,
42+
JsonNode errors,
43+
String logId,
44+
Map<String, List<String>> headers
45+
) {
2046
super(message);
2147
this.statusCode = statusCode;
2248
this.errorCode = errorCode;
2349
this.errorPath = errorPath;
50+
this.errorLocation = errorLocation;
51+
this.errors = errors;
52+
this.logId = logId;
53+
this.headers = headers == null ? Collections.emptyMap() : headers;
2454
}
2555

2656
public int getStatusCode() {
@@ -34,4 +64,20 @@ public Object getErrorCode() {
3464
public String getErrorPath() {
3565
return errorPath;
3666
}
67+
68+
public String getErrorLocation() {
69+
return errorLocation;
70+
}
71+
72+
public JsonNode getErrors() {
73+
return errors;
74+
}
75+
76+
public String getLogId() {
77+
return logId;
78+
}
79+
80+
public Map<String, List<String>> getHeaders() {
81+
return headers;
82+
}
3783
}

src/main/java/io/facturapi/http/FacturapiHttpClient.java

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ private JsonNode requestJsonNode(
104104
Request request = buildRequest(method, path, queryParams, body, multipartBody);
105105
try (Response response = httpClient.newCall(request).execute()) {
106106
if (!response.isSuccessful()) {
107-
throw buildApiException(readBodyText(response), response.code());
107+
throw buildApiException(readBodyText(response), response);
108108
}
109109

110110
ResponseBody responseBody = response.body();
@@ -131,7 +131,7 @@ private byte[] requestBytes(String method, String path, Object body) {
131131
Request request = buildRequest(method, path, null, body, null);
132132
try (Response response = httpClient.newCall(request).execute()) {
133133
if (!response.isSuccessful()) {
134-
throw buildApiException(readBodyText(response), response.code());
134+
throw buildApiException(readBodyText(response), response);
135135
}
136136
ResponseBody responseBody = response.body();
137137
return responseBody == null ? new byte[0] : responseBody.bytes();
@@ -146,7 +146,7 @@ private InputStream requestStream(String method, String path, Object body) {
146146
Response response = httpClient.newCall(request).execute();
147147
if (!response.isSuccessful()) {
148148
try {
149-
throw buildApiException(readBodyText(response), response.code());
149+
throw buildApiException(readBodyText(response), response);
150150
} finally {
151151
response.close();
152152
}
@@ -207,10 +207,13 @@ private static JsonNode firstDefined(JsonNode node, String... keys) {
207207
return null;
208208
}
209209

210-
private FacturapiException buildApiException(String bodyText, int statusCode) {
210+
private FacturapiException buildApiException(String bodyText, Response response) {
211+
int statusCode = response.code();
211212
int resolvedStatus = statusCode;
212213
Object errorCode = null;
213214
String errorPath = null;
215+
String errorLocation = null;
216+
JsonNode errors = null;
214217
String message = "Request failed with status " + statusCode;
215218

216219
if (bodyText != null && !bodyText.isBlank()) {
@@ -261,6 +264,16 @@ private FacturapiException buildApiException(String bodyText, int statusCode) {
261264
errorPath = pathNode.asText();
262265
}
263266

267+
JsonNode locationNode = firstDefined(root, "location");
268+
if (locationNode != null && locationNode.isTextual()) {
269+
errorLocation = locationNode.asText();
270+
}
271+
272+
JsonNode errorsNode = firstDefined(root, "errors");
273+
if (errorsNode != null && errorsNode.isArray()) {
274+
errors = errorsNode;
275+
}
276+
264277
if (firstDefined(root, "message", "error", "detail") == null) {
265278
message = bodyText;
266279
}
@@ -270,7 +283,16 @@ private FacturapiException buildApiException(String bodyText, int statusCode) {
270283
}
271284
}
272285

273-
return new FacturapiException(message, resolvedStatus, errorCode, errorPath);
286+
return new FacturapiException(
287+
message,
288+
resolvedStatus,
289+
errorCode,
290+
errorPath,
291+
errorLocation,
292+
errors,
293+
response.header("x-facturapi-log-id"),
294+
response.headers().toMultimap()
295+
);
274296
}
275297

276298
private static String readBodyText(Response response) throws IOException {

src/test/java/io/facturapi/FacturapiHttpClientTest.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ void returnsBinaryBytesForPdf() {
5050
@Test
5151
void throwsFacturapiExceptionWithApiMessage() {
5252
StubHttpClient httpClient = new StubHttpClient();
53-
httpClient.enqueueJson(400, "{\"message\":\"Invalid customer\",\"status\":\"400\",\"code\":\"validation_error\",\"path\":\"customer.tax_id\"}");
53+
httpClient.enqueueJson(
54+
400,
55+
"{\"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\"}]}",
56+
Map.of("Retry-After", java.util.List.of("3"), "x-facturapi-log-id", java.util.List.of("log_123"))
57+
);
5458

5559
FacturapiHttpClient client = new FacturapiHttpClient(
5660
FacturapiConfig.builder("sk_test_123")
@@ -67,5 +71,10 @@ void throwsFacturapiExceptionWithApiMessage() {
6771
assertTrue(ex.getMessage().contains("Invalid customer"));
6872
assertEquals("validation_error", ex.getErrorCode());
6973
assertEquals("customer.tax_id", ex.getErrorPath());
74+
assertEquals("body", ex.getErrorLocation());
75+
assertEquals("log_123", ex.getLogId());
76+
assertEquals("required", ex.getErrors().get(0).get("code").asText());
77+
assertEquals("3", ex.getHeaders().get("Retry-After").get(0));
78+
assertEquals("log_123", ex.getHeaders().get("x-facturapi-log-id").get(0));
7079
}
7180
}

src/test/java/io/facturapi/StubHttpClient.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,17 @@ OkHttpClient client() {
105105
}
106106

107107
void enqueueJson(int statusCode, String json) {
108+
enqueueJson(statusCode, json, Map.of());
109+
}
110+
111+
void enqueueJson(int statusCode, String json, Map<String, List<String>> extraHeaders) {
112+
Map<String, List<String>> headers = new java.util.HashMap<>(extraHeaders);
113+
headers.put("Content-Type", List.of("application/json"));
108114
queue.add(
109115
new QueuedResponse(
110116
statusCode,
111117
json.getBytes(StandardCharsets.UTF_8),
112-
Map.of("Content-Type", List.of("application/json"))
118+
headers
113119
)
114120
);
115121
}

0 commit comments

Comments
 (0)