Skip to content

Commit b5a7b10

Browse files
committed
feat: expose typed api error
1 parent 2a9fa53 commit b5a7b10

4 files changed

Lines changed: 185 additions & 31 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,55 @@
11
package io.facturapi;
22

3+
import io.facturapi.models.ApiError;
4+
35
public class FacturapiException extends RuntimeException {
6+
private final ApiError apiError;
47
private final int statusCode;
58
private final String responseBody;
69

710
public FacturapiException(String message) {
811
super(message);
12+
this.apiError = null;
913
this.statusCode = -1;
1014
this.responseBody = null;
1115
}
1216

1317
public FacturapiException(String message, Throwable cause) {
1418
super(message, cause);
19+
this.apiError = null;
1520
this.statusCode = -1;
1621
this.responseBody = null;
1722
}
1823

1924
public FacturapiException(String message, int statusCode, String responseBody) {
2025
super(message);
26+
this.apiError = null;
2127
this.statusCode = statusCode;
2228
this.responseBody = responseBody;
2329
}
2430

31+
public FacturapiException(ApiError apiError, String responseBody) {
32+
super(apiError != null && apiError.getMessage() != null && !apiError.getMessage().isBlank()
33+
? apiError.getMessage()
34+
: "An error occurred");
35+
this.apiError = apiError;
36+
this.statusCode = apiError != null && apiError.getStatus() != null ? apiError.getStatus() : -1;
37+
this.responseBody = responseBody;
38+
}
39+
2540
public int getStatusCode() {
2641
return statusCode;
2742
}
2843

2944
public String getResponseBody() {
3045
return responseBody;
3146
}
47+
48+
public ApiError getApiError() {
49+
return apiError;
50+
}
51+
52+
public ApiError getError() {
53+
return apiError;
54+
}
3255
}

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

Lines changed: 63 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.fasterxml.jackson.databind.JsonNode;
55
import com.fasterxml.jackson.databind.ObjectMapper;
66
import io.facturapi.FacturapiException;
7+
import io.facturapi.models.ApiError;
78
import java.io.IOException;
89
import java.io.InputStream;
910
import java.net.URI;
@@ -146,21 +147,7 @@ private InputStream requestStream(String method, String path, Object body) {
146147
responseBytes = errorStream == null ? new byte[0] : errorStream.readAllBytes();
147148
}
148149
String bodyText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8);
149-
String message = "Request failed with status " + statusCode;
150-
try {
151-
JsonNode error = objectMapper.readTree(bodyText);
152-
JsonNode messageNode = firstDefined(error, "message", "error", "detail");
153-
if (messageNode != null && messageNode.isTextual()) {
154-
message = messageNode.asText();
155-
} else if (!bodyText.isEmpty()) {
156-
message = bodyText;
157-
}
158-
} catch (Exception ignored) {
159-
if (!bodyText.isEmpty()) {
160-
message = bodyText;
161-
}
162-
}
163-
throw new FacturapiException(message, statusCode, bodyText);
150+
throw new FacturapiException(parseApiError(bodyText, statusCode), bodyText);
164151
}
165152
return response.body();
166153
} catch (IOException e) {
@@ -215,6 +202,66 @@ private static JsonNode firstDefined(JsonNode node, String... keys) {
215202
return null;
216203
}
217204

205+
private ApiError parseApiError(String bodyText, int statusCode) {
206+
ApiError apiError = null;
207+
if (bodyText != null && !bodyText.isBlank()) {
208+
try {
209+
apiError = objectMapper.readValue(bodyText, ApiError.class);
210+
} catch (Exception ignored) {
211+
try {
212+
JsonNode error = objectMapper.readTree(bodyText);
213+
apiError = new ApiError();
214+
215+
JsonNode messageNode = firstDefined(error, "message", "error", "detail");
216+
if (messageNode != null && messageNode.isTextual()) {
217+
apiError.setMessage(messageNode.asText());
218+
} else {
219+
apiError.setMessage(bodyText);
220+
}
221+
222+
JsonNode statusNode = firstDefined(error, "status");
223+
if (statusNode != null) {
224+
apiError.setStatus(statusNode);
225+
}
226+
227+
JsonNode codeNode = firstDefined(error, "code");
228+
if (codeNode != null) {
229+
apiError.setCode(codeNode);
230+
}
231+
232+
JsonNode pathNode = firstDefined(error, "path");
233+
if (pathNode != null && pathNode.isTextual()) {
234+
apiError.setPath(pathNode.asText());
235+
}
236+
237+
JsonNode okNode = firstDefined(error, "ok");
238+
if (okNode != null && okNode.isBoolean()) {
239+
apiError.setOk(okNode.asBoolean());
240+
}
241+
} catch (Exception secondaryIgnored) {
242+
apiError = new ApiError();
243+
apiError.setMessage(bodyText.isBlank() ? "Request failed with status " + statusCode : bodyText);
244+
}
245+
}
246+
}
247+
248+
if (apiError == null) {
249+
apiError = new ApiError();
250+
apiError.setMessage("Request failed with status " + statusCode);
251+
}
252+
253+
if (apiError.getMessage() == null || apiError.getMessage().isBlank()) {
254+
apiError.setMessage("Request failed with status " + statusCode);
255+
}
256+
if (apiError.getStatus() == null) {
257+
apiError.setStatus(statusCode);
258+
}
259+
if (!apiError.isOk()) {
260+
apiError.setOk(false);
261+
}
262+
return apiError;
263+
}
264+
218265
private HttpRequest buildRequest(
219266
String method,
220267
String path,
@@ -248,21 +295,7 @@ private void validateResponse(HttpResponse<byte[]> response) {
248295
int statusCode = response.statusCode();
249296
if (statusCode < 200 || statusCode >= 300) {
250297
String bodyText = response.body() == null ? "" : new String(response.body(), StandardCharsets.UTF_8);
251-
String message = "Request failed with status " + statusCode;
252-
try {
253-
JsonNode error = objectMapper.readTree(bodyText);
254-
JsonNode messageNode = firstDefined(error, "message", "error", "detail");
255-
if (messageNode != null && messageNode.isTextual()) {
256-
message = messageNode.asText();
257-
} else if (!bodyText.isEmpty()) {
258-
message = bodyText;
259-
}
260-
} catch (Exception ignored) {
261-
if (!bodyText.isEmpty()) {
262-
message = bodyText;
263-
}
264-
}
265-
throw new FacturapiException(message, statusCode, bodyText);
298+
throw new FacturapiException(parseApiError(bodyText, statusCode), bodyText);
266299
}
267300
}
268301

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package io.facturapi.models;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import com.fasterxml.jackson.annotation.JsonSetter;
5+
import com.fasterxml.jackson.databind.JsonNode;
6+
7+
@JsonIgnoreProperties(ignoreUnknown = true)
8+
public class ApiError {
9+
private String message;
10+
private Integer status;
11+
private boolean ok;
12+
private Object code;
13+
private String path;
14+
15+
public String getMessage() {
16+
return message;
17+
}
18+
19+
public void setMessage(String message) {
20+
this.message = message;
21+
}
22+
23+
public Integer getStatus() {
24+
return status;
25+
}
26+
27+
public void setStatus(Integer status) {
28+
this.status = status;
29+
}
30+
31+
@JsonSetter("status")
32+
public void setStatus(JsonNode status) {
33+
if (status == null || status.isNull()) {
34+
this.status = null;
35+
return;
36+
}
37+
if (status.isIntegralNumber()) {
38+
this.status = status.intValue();
39+
return;
40+
}
41+
if (status.isNumber()) {
42+
this.status = (int) Math.round(status.asDouble());
43+
return;
44+
}
45+
if (status.isTextual()) {
46+
try {
47+
this.status = Integer.parseInt(status.asText());
48+
} catch (NumberFormatException ignored) {
49+
this.status = null;
50+
}
51+
}
52+
}
53+
54+
public boolean isOk() {
55+
return ok;
56+
}
57+
58+
public void setOk(boolean ok) {
59+
this.ok = ok;
60+
}
61+
62+
public Object getCode() {
63+
return code;
64+
}
65+
66+
@JsonSetter("code")
67+
public void setCode(JsonNode code) {
68+
if (code == null || code.isNull()) {
69+
this.code = null;
70+
} else if (code.isTextual()) {
71+
this.code = code.asText();
72+
} else if (code.isIntegralNumber()) {
73+
this.code = code.intValue();
74+
} else if (code.isNumber()) {
75+
this.code = code.numberValue();
76+
} else if (code.isBoolean()) {
77+
this.code = code.asBoolean();
78+
} else {
79+
this.code = code.toString();
80+
}
81+
}
82+
83+
public String getPath() {
84+
return path;
85+
}
86+
87+
public void setPath(String path) {
88+
this.path = path;
89+
}
90+
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
44
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
import static org.junit.jupiter.api.Assertions.assertFalse;
6+
import static org.junit.jupiter.api.Assertions.assertNotNull;
57
import static org.junit.jupiter.api.Assertions.assertThrows;
68
import static org.junit.jupiter.api.Assertions.assertTrue;
79

@@ -50,7 +52,7 @@ void returnsBinaryBytesForPdf() {
5052
@Test
5153
void throwsFacturapiExceptionWithApiMessage() {
5254
StubHttpClient httpClient = new StubHttpClient();
53-
httpClient.enqueueJson(400, "{\"message\":\"Invalid customer\"}");
55+
httpClient.enqueueJson(400, "{\"message\":\"Invalid customer\",\"status\":\"400\",\"ok\":false,\"code\":\"validation_error\",\"path\":\"customer.tax_id\"}");
5456

5557
FacturapiHttpClient client = new FacturapiHttpClient(
5658
FacturapiConfig.builder("sk_test_123")
@@ -65,5 +67,11 @@ void throwsFacturapiExceptionWithApiMessage() {
6567

6668
assertEquals(400, ex.getStatusCode());
6769
assertTrue(ex.getMessage().contains("Invalid customer"));
70+
assertNotNull(ex.getApiError());
71+
assertEquals("Invalid customer", ex.getApiError().getMessage());
72+
assertEquals(400, ex.getApiError().getStatus());
73+
assertFalse(ex.getApiError().isOk());
74+
assertEquals("validation_error", ex.getApiError().getCode());
75+
assertEquals("customer.tax_id", ex.getApiError().getPath());
6876
}
6977
}

0 commit comments

Comments
 (0)