diff --git a/CHANGELOG.md b/CHANGELOG.md index 6171468c..1e8a29d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased](https://github.com/openfga/java-sdk/compare/v0.9.3...HEAD) +- feat: Improve error messaging by parsing error details from response bodies (#258) ## v0.9.3 diff --git a/src/main/java/dev/openfga/sdk/errors/FgaApiValidationError.java b/src/main/java/dev/openfga/sdk/errors/FgaApiValidationError.java index 16a573e2..1b37af05 100644 --- a/src/main/java/dev/openfga/sdk/errors/FgaApiValidationError.java +++ b/src/main/java/dev/openfga/sdk/errors/FgaApiValidationError.java @@ -1,14 +1,147 @@ package dev.openfga.sdk.errors; +import com.fasterxml.jackson.databind.JsonNode; import java.net.http.HttpHeaders; public class FgaApiValidationError extends FgaError { + + // String prefixes for parsing error messages + private static final String RELATION_PREFIX = "relation '"; + private static final String TYPE_PREFIX = "type '"; + private static final String CHECK_REQUEST_TUPLE_KEY_PREFIX = "CheckRequestTupleKey."; + private static final String TUPLE_KEY_PREFIX = "TupleKey."; + private static final String QUOTE_SUFFIX = "'"; + private static final String NOT_FOUND_SUFFIX = "' not found"; + private static final String MUST_NOT_BE_EMPTY = "must not be empty"; + + private String invalidField; + private String invalidValue; + public FgaApiValidationError( String message, Throwable cause, int code, HttpHeaders responseHeaders, String responseBody) { super(message, cause, code, responseHeaders, responseBody); + parseValidationDetails(responseBody, null); } public FgaApiValidationError(String message, int code, HttpHeaders responseHeaders, String responseBody) { super(message, code, responseHeaders, responseBody); + parseValidationDetails(responseBody, null); + } + + /** + * Constructor that accepts a pre-parsed JsonNode to avoid re-parsing the response body. + * This is more efficient when the JSON has already been parsed by the parent class. + * + * @param message The error message + * @param cause The underlying cause (if any) + * @param code The HTTP status code + * @param responseHeaders The response headers + * @param responseBody The raw response body + * @param parsedJson The already-parsed JSON root node (may be null) + */ + public FgaApiValidationError( + String message, + Throwable cause, + int code, + HttpHeaders responseHeaders, + String responseBody, + JsonNode parsedJson) { + super(message, cause, code, responseHeaders, responseBody); + parseValidationDetails(responseBody, parsedJson); + } + + /** + * Try to extract specific validation details from the error message. + *

+ * This parsing is best-effort and based on current OpenFGA API error message formats. + * If the message format changes or doesn't match expected patterns, fields will be null. + * The application should not rely on these fields for critical logic. + * + * @param responseBody The API error response body + * @param parsedJson The already-parsed JSON root node (may be null, in which case we parse it) + */ + private void parseValidationDetails(String responseBody, JsonNode parsedJson) { + if (responseBody == null || responseBody.trim().isEmpty()) { + return; + } + + try { + // Use the pre-parsed JSON node if available, otherwise parse it + JsonNode root = parsedJson != null ? parsedJson : getErrorMapper().readTree(responseBody); + String message = root.has("message") ? root.get("message").asText() : null; + + if (message != null) { + // Parse patterns like: "relation 'document#invalid_relation' not found" + if (message.contains(RELATION_PREFIX) && message.contains(NOT_FOUND_SUFFIX)) { + int start = message.indexOf(RELATION_PREFIX) + RELATION_PREFIX.length(); + int end = message.indexOf(QUOTE_SUFFIX, start); + if (end > start) { + this.invalidField = "relation"; + this.invalidValue = message.substring(start, end); + addMetadata("invalid_field", invalidField); + addMetadata("invalid_value", invalidValue); + } + } + // Parse patterns like: "type 'invalid_type' not found" + else if (message.contains(TYPE_PREFIX) && message.contains(NOT_FOUND_SUFFIX)) { + int start = message.indexOf(TYPE_PREFIX) + TYPE_PREFIX.length(); + int end = message.indexOf(QUOTE_SUFFIX, start); + if (end > start) { + this.invalidField = "type"; + this.invalidValue = message.substring(start, end); + addMetadata("invalid_field", invalidField); + addMetadata("invalid_value", invalidValue); + } + } + // Parse patterns like: "invalid CheckRequestTupleKey.User: value does not match regex..." + else if (message.contains(CHECK_REQUEST_TUPLE_KEY_PREFIX)) { + int start = + message.indexOf(CHECK_REQUEST_TUPLE_KEY_PREFIX) + CHECK_REQUEST_TUPLE_KEY_PREFIX.length(); + // Search for ": " (colon followed by space) for more robust matching + int end = message.indexOf(": ", start); + if (end > start) { + this.invalidField = message.substring(start, end); + addMetadata("invalid_field", invalidField); + } + } + // Parse patterns like: "invalid TupleKey.User: value does not match regex..." + else if (message.contains(TUPLE_KEY_PREFIX)) { + int start = message.indexOf(TUPLE_KEY_PREFIX) + TUPLE_KEY_PREFIX.length(); + int end = message.indexOf(": ", start); + if (end > start) { + this.invalidField = message.substring(start, end); + addMetadata("invalid_field", invalidField); + } + } + // Parse patterns like: "object must not be empty" + else if (message.contains(MUST_NOT_BE_EMPTY)) { + String[] parts = message.trim().split("\\s+"); + if (parts.length > 0 && !parts[0].isEmpty()) { + this.invalidField = parts[0]; + addMetadata("invalid_field", invalidField); + } + } + } + } catch (Exception e) { + // Parsing is best-effort, ignore failures + } + } + + /** + * Gets the field name that failed validation, if it could be parsed from the error message. + * + * @return The invalid field name (e.g., "relation", "type", "User"), or null if not parsed + */ + public String getInvalidField() { + return invalidField; + } + + /** + * Gets the invalid value that caused the validation error, if available. + * + * @return The invalid value, or null if not parsed from the error message + */ + public String getInvalidValue() { + return invalidValue; } } diff --git a/src/main/java/dev/openfga/sdk/errors/FgaError.java b/src/main/java/dev/openfga/sdk/errors/FgaError.java index 85f54d88..e274507b 100644 --- a/src/main/java/dev/openfga/sdk/errors/FgaError.java +++ b/src/main/java/dev/openfga/sdk/errors/FgaError.java @@ -2,15 +2,26 @@ import static dev.openfga.sdk.errors.HttpStatusCode.*; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import dev.openfga.sdk.api.configuration.Configuration; import dev.openfga.sdk.api.configuration.CredentialsMethod; import dev.openfga.sdk.constants.FgaConstants; import java.net.http.HttpHeaders; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; public class FgaError extends ApiException { + /** + * Shared ObjectMapper instance for parsing error responses. + * ObjectMapper is thread-safe for read operations (parsing JSON). + * This instance is shared across all error classes to reduce memory overhead. + */ + private static final ObjectMapper ERROR_MAPPER = new ObjectMapper(); + private String method = null; private String requestUrl = null; private String clientId = null; @@ -18,8 +29,18 @@ public class FgaError extends ApiException { private String grantType = null; private String requestId = null; private String apiErrorCode = null; + private String apiErrorMessage = null; + private String operationName = null; private String retryAfterHeader = null; + /** + * Metadata map for additional error context. + *

+ * Note: Error instances follow a single-threaded lifecycle (create → populate → throw → catch). + * They are not shared between threads, so thread-safety is not required. + */ + private final Map metadata = new HashMap<>(); + public FgaError(String message, Throwable cause, int code, HttpHeaders responseHeaders, String responseBody) { super(message, cause, code, responseHeaders, responseBody); } @@ -28,6 +49,56 @@ public FgaError(String message, int code, HttpHeaders responseHeaders, String re super(message, code, responseHeaders, responseBody); } + /** + * Container for parsed error response data. + */ + private static class ParsedErrorResponse { + final String message; + final String code; + final JsonNode rootNode; + + ParsedErrorResponse(String message, String code, JsonNode rootNode) { + this.message = message; + this.code = code; + this.rootNode = rootNode; + } + } + + /** + * Parse the API error response body once to extract the error message, code, and root JSON node. + * This method parses the JSON only once and extracts all needed fields, improving efficiency. + * + * @param methodName The API method name that was called + * @param responseBody The response body JSON string + * @return ParsedErrorResponse containing message, code, and root JSON node + */ + private static ParsedErrorResponse parseErrorResponse(String methodName, String responseBody) { + if (responseBody == null || responseBody.trim().isEmpty()) { + return new ParsedErrorResponse(methodName, null, null); + } + + try { + JsonNode rootNode = ERROR_MAPPER.readTree(responseBody); + + // Extract message field + JsonNode messageNode = rootNode.get("message"); + String message = (messageNode != null && !messageNode.isNull()) ? messageNode.asText() : null; + + // Extract code field + JsonNode codeNode = rootNode.get("code"); + String code = (codeNode != null && !codeNode.isNull()) ? codeNode.asText() : null; + + // If we have a message, use it, otherwise fall back to method name + String finalMessage = (message != null && !message.trim().isEmpty()) ? message : methodName; + + return new ParsedErrorResponse(finalMessage, code, rootNode); + } catch (Exception e) { + // If parsing fails, fall back to the method name + // This is intentional to ensure errors are still reported even if the response format is unexpected + return new ParsedErrorResponse(methodName, null, null); + } + } + public static Optional getError( String name, HttpRequest request, @@ -43,25 +114,54 @@ public static Optional getError( final String body = response.body(); final var headers = response.headers(); + + // Parse the error response once to extract message, code, and JSON node + final ParsedErrorResponse parsedResponse = parseErrorResponse(name, body); final FgaError error; if (status == BAD_REQUEST || status == UNPROCESSABLE_ENTITY) { - error = new FgaApiValidationError(name, previousError, status, headers, body); + error = new FgaApiValidationError( + parsedResponse.message, previousError, status, headers, body, parsedResponse.rootNode); } else if (status == UNAUTHORIZED || status == FORBIDDEN) { - error = new FgaApiAuthenticationError(name, previousError, status, headers, body); + error = new FgaApiAuthenticationError(parsedResponse.message, previousError, status, headers, body); } else if (status == NOT_FOUND) { - error = new FgaApiNotFoundError(name, previousError, status, headers, body); + error = new FgaApiNotFoundError(parsedResponse.message, previousError, status, headers, body); } else if (status == TOO_MANY_REQUESTS) { - error = new FgaApiRateLimitExceededError(name, previousError, status, headers, body); + error = new FgaApiRateLimitExceededError(parsedResponse.message, previousError, status, headers, body); } else if (isServerError(status)) { - error = new FgaApiInternalError(name, previousError, status, headers, body); + error = new FgaApiInternalError(parsedResponse.message, previousError, status, headers, body); } else { - error = new FgaError(name, previousError, status, headers, body); + error = new FgaError(parsedResponse.message, previousError, status, headers, body); } error.setMethod(request.method()); error.setRequestUrl(configuration.getApiUrl()); + // Set the operation name + error.setOperationName(name); + + // Set API error code if extracted from response + if (parsedResponse.code != null) { + error.setApiErrorCode(parsedResponse.code); + } + + // Set the API error message (same as what was parsed for the constructor) + // This allows getMessage() to return a formatted version + if (!parsedResponse.message.equals(name)) { + // Only set apiErrorMessage if we actually got a message from the API + // (not just falling back to the operation name) + error.setApiErrorMessage(parsedResponse.message); + } + + // Extract and set request ID from response headers if present + // Common request ID header names + Optional requestId = headers.firstValue("X-Request-Id") + .or(() -> headers.firstValue("x-request-id")) + .or(() -> headers.firstValue("Request-Id")); + if (requestId.isPresent()) { + error.setRequestId(requestId.get()); + } + // Extract and set Retry-After header if present Optional retryAfter = headers.firstValue(FgaConstants.RETRY_AFTER_HEADER_NAME); if (retryAfter.isPresent()) { @@ -83,6 +183,11 @@ public void setMethod(String method) { this.method = method; } + /** + * Gets the HTTP method used for the request that caused this error. + * + * @return The HTTP method (e.g., "GET", "POST"), or null if not set + */ public String getMethod() { return method; } @@ -91,6 +196,11 @@ public void setRequestUrl(String requestUrl) { this.requestUrl = requestUrl; } + /** + * Gets the API URL for the request that caused this error. + * + * @return The request URL, or null if not set + */ public String getRequestUrl() { return requestUrl; } @@ -99,6 +209,11 @@ public void setClientId(String clientId) { this.clientId = clientId; } + /** + * Gets the OAuth2 client ID used in the request, if client credentials authentication was used. + * + * @return The client ID, or null if not using client credentials or not set + */ public String getClientId() { return clientId; } @@ -107,6 +222,11 @@ public void setAudience(String audience) { this.audience = audience; } + /** + * Gets the OAuth2 audience used in the request, if client credentials authentication was used. + * + * @return The audience, or null if not using client credentials or not set + */ public String getAudience() { return audience; } @@ -115,6 +235,11 @@ public void setGrantType(String grantType) { this.grantType = grantType; } + /** + * Gets the OAuth2 grant type used in the request. + * + * @return The grant type, or null if not set + */ public String getGrantType() { return grantType; } @@ -123,6 +248,11 @@ public void setRequestId(String requestId) { this.requestId = requestId; } + /** + * Gets the request ID from the response headers, useful for debugging and support. + * + * @return The request ID (from X-Request-Id header), or null if not present + */ public String getRequestId() { return requestId; } @@ -131,15 +261,183 @@ public void setApiErrorCode(String apiErrorCode) { this.apiErrorCode = apiErrorCode; } + /** + * Gets the error code returned by the API in the response body. + * + * @return The API error code, or null if not available in the response + */ public String getApiErrorCode() { return apiErrorCode; } + /** + * Get the API error code. + * This is an alias for getApiErrorCode() for convenience. + * @return The API error code from the response + */ + public String getCode() { + return apiErrorCode; + } + public void setRetryAfterHeader(String retryAfterHeader) { this.retryAfterHeader = retryAfterHeader; } + /** + * Gets the Retry-After header value from rate limit responses. + * + * @return The Retry-After header value (in seconds or HTTP date), or null if not present + */ public String getRetryAfterHeader() { return retryAfterHeader; } + + public void setApiErrorMessage(String apiErrorMessage) { + this.apiErrorMessage = apiErrorMessage; + } + + /** + * Gets the error message parsed from the API response body. + * + * @return The API error message, or null if not available in the response + */ + public String getApiErrorMessage() { + return apiErrorMessage; + } + + public void setOperationName(String operationName) { + this.operationName = operationName; + } + + /** + * Gets the operation name that resulted in this error. + * + * @return The operation name (e.g., "check", "write"), or null if not set + */ + public String getOperationName() { + return operationName; + } + + /** + * Gets the metadata map containing additional error context. + * + * @return A map of metadata key-value pairs (never null) + */ + public Map getMetadata() { + return metadata; + } + + public void addMetadata(String key, Object value) { + getMetadata().put(key, value); + } + + /** + * Provides access to the shared ObjectMapper for subclasses. + * This mapper is thread-safe for read operations. + * + * @return The shared ObjectMapper instance + */ + protected static ObjectMapper getErrorMapper() { + return ERROR_MAPPER; + } + + /** + * Override getMessage() to return the actual API error message + * instead of the generic operation name. + * + * This makes errors understandable everywhere they're displayed: + * - Exception stack traces + * - IDE error tooltips + * - Log files + * - toString() output + */ + @Override + public String getMessage() { + // Return the actual API error if available + if (apiErrorMessage != null && !apiErrorMessage.isEmpty()) { + StringBuilder sb = new StringBuilder(); + + // Include operation context + if (operationName != null) { + sb.append("[").append(operationName).append("] "); + } + + // Main error message from API + sb.append(apiErrorMessage); + + // Add error code if available + if (apiErrorCode != null) { + sb.append(" (").append(apiErrorCode).append(")"); + } + + return sb.toString(); + } + + // Fallback to original message (operation name) + return super.getMessage(); + } + + /** + * Build the core error message (operation + message + code) + */ + private String buildCoreMessage() { + StringBuilder sb = new StringBuilder(); + + if (operationName != null) { + sb.append("[").append(operationName).append("] "); + } + + if (apiErrorMessage != null) { + sb.append(apiErrorMessage); + } else if (super.getMessage() != null) { + sb.append(super.getMessage()); + } + + return sb.toString(); + } + + /** + * Returns a developer-friendly error message with all context + */ + public String getDetailedMessage() { + StringBuilder sb = new StringBuilder(buildCoreMessage()); + + if (apiErrorCode != null) { + sb.append(" (code: ").append(apiErrorCode).append(")"); + } + + if (requestId != null) { + sb.append(" [request-id: ").append(requestId).append("]"); + } + + if (getStatusCode() > 0) { + sb.append(" [HTTP ").append(getStatusCode()).append("]"); + } + + return sb.toString(); + } + + /** + * Override toString() to provide a formatted string for better logging + */ + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(getClass().getSimpleName()).append(": "); + sb.append(buildCoreMessage()); + + if (getStatusCode() > 0) { + sb.append(" (HTTP ").append(getStatusCode()).append(")"); + } + + if (apiErrorCode != null) { + sb.append(" [code: ").append(apiErrorCode).append("]"); + } + + if (requestId != null) { + sb.append(" [request-id: ").append(requestId).append("]"); + } + + return sb.toString(); + } } diff --git a/src/test-integration/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java b/src/test-integration/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java new file mode 100644 index 00000000..c308d1ec --- /dev/null +++ b/src/test-integration/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java @@ -0,0 +1,289 @@ +package dev.openfga.sdk.errors; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.openfga.sdk.api.client.OpenFgaClient; +import dev.openfga.sdk.api.client.model.*; +import dev.openfga.sdk.api.configuration.ClientConfiguration; +import dev.openfga.sdk.api.model.*; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.openfga.OpenFGAContainer; + +@TestInstance(Lifecycle.PER_CLASS) +@Testcontainers +class FgaErrorIntegrationTest { + + @Container + private static final OpenFGAContainer openfga = new OpenFGAContainer("openfga/openfga:v1.10.2"); + + private static final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + private static WriteAuthorizationModelRequest authModelRequest; + + private OpenFgaClient fga; + private String storeId; + + @BeforeAll + static void loadAuthModel() throws IOException { + String authModelJson = Files.readString(Paths.get("src", "test-integration", "resources", "auth-model.json")); + authModelRequest = mapper.readValue(authModelJson, WriteAuthorizationModelRequest.class); + } + + @BeforeEach + void setUp() throws Exception { + ClientConfiguration config = new ClientConfiguration().apiUrl(openfga.getHttpEndpoint()); + fga = new OpenFgaClient(config); + + CreateStoreResponse storeResponse = + fga.createStore(new CreateStoreRequest().name("test-store")).get(); + storeId = storeResponse.getId(); + fga.setStoreId(storeId); + } + + @Test + void testValidationError_InvalidType() throws Exception { + WriteAuthorizationModelResponse authModelResponse = + fga.writeAuthorizationModel(authModelRequest).get(); + fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId()); + + ClientCheckRequest request = + new ClientCheckRequest().user("user:123").relation("viewer")._object("invalid_type:doc1"); + + CompletableFuture future = fga.check(request); + ExecutionException exception = assertThrows(ExecutionException.class, future::get); + + String exceptionMessage = exception.getMessage(); + assertNotNull(exceptionMessage); + assertTrue(exceptionMessage.contains("FgaApiValidationError"), "Should include error class name"); + assertTrue(exceptionMessage.contains("[check]"), "Should include operation name"); + assertTrue( + exceptionMessage.contains("type 'invalid_type' not found"), "Should include actual error from server"); + + Throwable cause = exception.getCause(); + assertInstanceOf(FgaApiValidationError.class, cause); + + FgaApiValidationError error = (FgaApiValidationError) cause; + assertEquals("check", error.getOperationName()); + assertEquals(400, error.getStatusCode()); + assertEquals("validation_error", error.getApiErrorCode()); + assertEquals("type 'invalid_type' not found", error.getApiErrorMessage()); + + // Verify formatted messages + String errorMessage = error.getMessage(); + assertNotNull(errorMessage); + assertTrue(errorMessage.contains("[check]"), "Should include operation name"); + assertTrue(errorMessage.contains("type 'invalid_type' not found"), "Should include server error"); + assertTrue(errorMessage.contains("validation_error"), "Should include error code"); + } + + @Test + void testValidationError_InvalidRelation() throws Exception { + WriteAuthorizationModelResponse authModelResponse = + fga.writeAuthorizationModel(authModelRequest).get(); + fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId()); + + ClientCheckRequest request = new ClientCheckRequest() + .user("user:123") + .relation("invalid_relation") + ._object("document:doc1"); + + CompletableFuture future = fga.check(request); + ExecutionException exception = assertThrows(ExecutionException.class, future::get); + + // Verify ExecutionException message contains the full error details from server + // Note: These assertions will fail if OpenFGA server changes its error message format, + // which is intentional - integration tests should catch server behavior changes + String exceptionMessage = exception.getMessage(); + assertNotNull(exceptionMessage); + assertTrue(exceptionMessage.contains("FgaApiValidationError"), "Should include error class name"); + assertTrue(exceptionMessage.contains("[check]"), "Should include operation name"); + assertTrue( + exceptionMessage.contains("relation 'document#invalid_relation' not found"), + "Should include actual error from server"); + + Throwable cause = exception.getCause(); + assertInstanceOf(FgaApiValidationError.class, cause); + + FgaApiValidationError error = (FgaApiValidationError) cause; + + // Verify error object contains expected details + assertEquals("check", error.getOperationName()); + assertEquals(400, error.getStatusCode()); + assertEquals("validation_error", error.getApiErrorCode()); + assertEquals("relation 'document#invalid_relation' not found", error.getApiErrorMessage()); + + // Verify formatted messages + String errorMessage = error.getMessage(); + assertNotNull(errorMessage); + assertTrue(errorMessage.contains("[check]"), "Should include operation name"); + assertTrue( + errorMessage.contains("relation 'document#invalid_relation' not found"), "Should include server error"); + assertTrue(errorMessage.contains("validation_error"), "Should include error code"); + } + + @Test + void testValidationError_EmptyUser() throws Exception { + WriteAuthorizationModelResponse authModelResponse = + fga.writeAuthorizationModel(authModelRequest).get(); + fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId()); + + ClientCheckRequest request = + new ClientCheckRequest().user("").relation("reader")._object("document:doc1"); + + CompletableFuture future = fga.check(request); + ExecutionException exception = assertThrows(ExecutionException.class, future::get); + + Throwable cause = exception.getCause(); + assertInstanceOf(FgaApiValidationError.class, cause); + + FgaApiValidationError error = (FgaApiValidationError) cause; + assertEquals(400, error.getStatusCode()); + assertNotNull(error.getApiErrorMessage()); + } + + @Test + void testValidationError_InvalidStoreId() throws Exception { + fga.setStoreId("01HZZZZZZZZZZZZZZZZZZZZZZ"); + + CompletableFuture future = fga.getStore(); + ExecutionException exception = assertThrows(ExecutionException.class, future::get); + + Throwable cause = exception.getCause(); + assertInstanceOf(FgaApiValidationError.class, cause); + + FgaApiValidationError error = (FgaApiValidationError) cause; + assertEquals(400, error.getStatusCode()); + assertNotNull(error.getMessage()); + } + + @Test + void testValidationError_InvalidAuthorizationModelId() throws Exception { + fga.setAuthorizationModelId("01HZZZZZZZZZZZZZZZZZZZZZZ"); + + CompletableFuture future = fga.readAuthorizationModel(); + ExecutionException exception = assertThrows(ExecutionException.class, future::get); + + Throwable cause = exception.getCause(); + assertInstanceOf(FgaApiValidationError.class, cause); + + FgaApiValidationError error = (FgaApiValidationError) cause; + assertEquals(400, error.getStatusCode()); + assertEquals("readAuthorizationModel", error.getOperationName()); + } + + @Test + void testErrorMetadataExtensibility() throws Exception { + WriteAuthorizationModelResponse authModelResponse = + fga.writeAuthorizationModel(authModelRequest).get(); + fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId()); + + ClientCheckRequest request = + new ClientCheckRequest().user("user:123").relation("viewer")._object("invalid_type:doc1"); + + CompletableFuture future = fga.check(request); + + try { + future.get(); + fail("Expected ExecutionException"); + } catch (ExecutionException e) { + FgaError error = (FgaError) e.getCause(); + + error.addMetadata("retry_attempt", 1); + error.addMetadata("user_context", "admin"); + error.addMetadata("store_id", storeId); + + Map metadata = error.getMetadata(); + assertEquals(1, metadata.get("retry_attempt")); + assertEquals("admin", metadata.get("user_context")); + assertEquals(storeId, metadata.get("store_id")); + } + } + + @Test + void testErrorMessageVisibilityInExecutionException() throws Exception { + WriteAuthorizationModelResponse authModelResponse = + fga.writeAuthorizationModel(authModelRequest).get(); + fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId()); + + ClientCheckRequest request = + new ClientCheckRequest().user("user:123").relation("viewer")._object("invalid_type:doc1"); + + CompletableFuture future = fga.check(request); + ExecutionException exception = assertThrows(ExecutionException.class, future::get); + + String exceptionMessage = exception.getMessage(); + assertNotNull(exceptionMessage); + assertFalse(exceptionMessage.trim().isEmpty()); + + FgaApiValidationError error = (FgaApiValidationError) exception.getCause(); + assertEquals("check", error.getOperationName()); + assertNotNull(error.getMessage()); + assertNotNull(error.getDetailedMessage()); + assertNotNull(error.toString()); + } + + @Test + void testCompleteErrorContext() throws Exception { + WriteAuthorizationModelResponse authModelResponse = + fga.writeAuthorizationModel(authModelRequest).get(); + fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId()); + + ClientCheckRequest request = + new ClientCheckRequest().user("user:123").relation("viewer")._object("invalid_type:doc1"); + + CompletableFuture future = fga.check(request); + ExecutionException exception = assertThrows(ExecutionException.class, future::get); + FgaApiValidationError error = (FgaApiValidationError) exception.getCause(); + + assertEquals(400, error.getStatusCode()); + assertEquals("POST", error.getMethod()); + assertNotNull(error.getRequestUrl()); + assertEquals("check", error.getOperationName()); + assertNotNull(error.getApiErrorMessage()); + + assertDoesNotThrow(() -> error.getRequestId()); + assertDoesNotThrow(() -> error.getApiErrorCode()); + + assertNotNull(error.getMessage()); + assertFalse(error.getMessage().isEmpty()); + assertNotNull(error.getDetailedMessage()); + assertFalse(error.getDetailedMessage().isEmpty()); + assertNotNull(error.toString()); + assertTrue(error.toString().startsWith("FgaApiValidationError")); + } + + @Test + void testWriteValidationError_InvalidTupleKey() throws Exception { + WriteAuthorizationModelResponse authModelResponse = + fga.writeAuthorizationModel(authModelRequest).get(); + fga.setAuthorizationModelId(authModelResponse.getAuthorizationModelId()); + + ClientWriteRequest writeRequest = new ClientWriteRequest() + .writes(List.of( + new ClientTupleKey().user("user:123").relation("reader")._object("invalid_type:doc1"))); + + CompletableFuture future = fga.write(writeRequest); + ExecutionException exception = assertThrows(ExecutionException.class, future::get); + + Throwable cause = exception.getCause(); + assertInstanceOf(FgaApiValidationError.class, cause); + + FgaApiValidationError error = (FgaApiValidationError) cause; + assertEquals("write", error.getOperationName()); + assertEquals(400, error.getStatusCode()); + assertNotNull(error.getApiErrorMessage()); + } +} diff --git a/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java b/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java index 8fcae337..bb5deafb 100644 --- a/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/auth/OAuth2ClientTest.java @@ -160,7 +160,11 @@ public void exchangeOAuth2TokenWithRetriesFailure(WireMockRuntimeInfo wm) throws var exception = assertThrows(java.util.concurrent.ExecutionException.class, () -> auth0.getAccessToken() .get()); - assertEquals("dev.openfga.sdk.errors.FgaApiRateLimitExceededError: exchangeToken", exception.getMessage()); + // The error message now includes the formatted message from getMessage() override + // which shows: FgaApiRateLimitExceededError [operation]: message (error_code) + // Since the response has no "message" field, it falls back to operation name + assertTrue(exception.getMessage().contains("FgaApiRateLimitExceededError")); + assertTrue(exception.getMessage().contains("exchangeToken")); verify(3, postRequestedFor(urlEqualTo("/oauth/token"))); } diff --git a/src/test/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java b/src/test/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java new file mode 100644 index 00000000..4237a080 --- /dev/null +++ b/src/test/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java @@ -0,0 +1,340 @@ +package dev.openfga.sdk.errors; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.openfga.sdk.api.configuration.ClientConfiguration; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import org.junit.jupiter.api.Test; + +/** + * Integration tests that verify the complete error handling flow, + * especially the getMessage() override behavior when wrapped in ExecutionException. + */ +class FgaErrorIntegrationTest { + + @Test + void testErrorMessageVisibilityInExecutionException() throws Exception { + // Given - Simulate a validation error from the API + String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/stores/01ARZ3NDEKTSV4RRFFQ69G5FAV/check")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + Optional errorOpt = FgaError.getError("check", request, config, response, null); + assertThat(errorOpt).isPresent(); + FgaError fgaError = errorOpt.get(); + + // When - Simulate wrapping in CompletableFuture (as the SDK does) + CompletableFuture future = CompletableFuture.failedFuture(fgaError); + + // Then - Verify the error message is visible even when wrapped + try { + future.get(); + throw new AssertionError("Expected ExecutionException to be thrown"); + } catch (ExecutionException e) { + // This is what developers will see in their IDE and logs + String exceptionMessage = e.getMessage(); + + // Before Phase 2: Would have shown "dev.openfga.sdk.errors.FgaApiValidationError: check" + // After Phase 2: Shows the actual error + assertThat(exceptionMessage).contains("type 'invalid_type' not found"); + assertThat(exceptionMessage).contains("validation_error"); + assertThat(exceptionMessage).contains("check"); + + // Verify cause is properly accessible + assertThat(e.getCause()).isInstanceOf(FgaApiValidationError.class); + FgaApiValidationError validationError = (FgaApiValidationError) e.getCause(); + assertThat(validationError.getInvalidField()).isEqualTo("type"); + assertThat(validationError.getInvalidValue()).isEqualTo("invalid_type"); + } + } + + @Test + void testCompleteErrorContext() { + // Given - A fully populated error response + String responseBody = "{\"code\":\"relation_not_found\",\"message\":\"relation 'document#editor' not found\"}"; + Map> headers = Map.of( + "X-Request-Id", List.of("550e8400-e29b-41d4-a716-446655440000"), + "Retry-After", List.of("30")); + HttpResponse response = createMockResponse(400, responseBody, headers); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/stores/01ARZ3NDEKTSV4RRFFQ69G5FAV/check")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then - Verify all error context is available + assertThat(errorOpt).isPresent(); + FgaApiValidationError error = (FgaApiValidationError) errorOpt.get(); + + // Basic error info + assertThat(error.getStatusCode()).isEqualTo(400); + assertThat(error.getMethod()).isEqualTo("POST"); + assertThat(error.getRequestUrl()).isEqualTo("http://localhost:8080"); + + // Parsed error details + assertThat(error.getOperationName()).isEqualTo("check"); + assertThat(error.getApiErrorCode()).isEqualTo("relation_not_found"); + assertThat(error.getApiErrorMessage()).isEqualTo("relation 'document#editor' not found"); + + // Headers + assertThat(error.getRequestId()).isEqualTo("550e8400-e29b-41d4-a716-446655440000"); + assertThat(error.getRetryAfterHeader()).isEqualTo("30"); + + // Validation-specific parsing + assertThat(error.getInvalidField()).isEqualTo("relation"); + assertThat(error.getInvalidValue()).isEqualTo("document#editor"); + assertThat(error.getMetadata()).containsEntry("invalid_field", "relation"); + assertThat(error.getMetadata()).containsEntry("invalid_value", "document#editor"); + + // Formatted messages + assertThat(error.getMessage()).isEqualTo("[check] relation 'document#editor' not found (relation_not_found)"); + + assertThat(error.getDetailedMessage()) + .contains("[check]") + .contains("relation 'document#editor' not found") + .contains("(code: relation_not_found)") + .contains("[request-id: 550e8400-e29b-41d4-a716-446655440000]") + .contains("[HTTP 400]"); + + assertThat(error.toString()) + .startsWith("FgaApiValidationError") + .contains("[check]") + .contains("relation 'document#editor' not found") + .contains("(HTTP 400)") + .contains("[code: relation_not_found]") + .contains("[request-id: 550e8400-e29b-41d4-a716-446655440000]"); + } + + @Test + void testMetadataExtensibility() { + // Given + String responseBody = "{\"code\":\"validation_error\",\"message\":\"invalid tuple\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + Optional errorOpt = FgaError.getError("write", request, config, response, null); + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + + // When - Add custom metadata for application-specific error tracking + error.addMetadata("retry_attempt", 1); + error.addMetadata("user_id", "user:123"); + error.addMetadata("store_id", "01ARZ3NDEKTSV4RRFFQ69G5FAV"); + error.addMetadata("client_version", "1.0.0"); + + // Then - Metadata is accessible + Map metadata = error.getMetadata(); + assertThat(metadata).hasSize(4); + assertThat(metadata).containsEntry("retry_attempt", 1); + assertThat(metadata).containsEntry("user_id", "user:123"); + assertThat(metadata).containsEntry("store_id", "01ARZ3NDEKTSV4RRFFQ69G5FAV"); + assertThat(metadata).containsEntry("client_version", "1.0.0"); + + // Metadata can be used for structured logging + assertThat(metadata.get("retry_attempt")).isEqualTo(1); + } + + @Test + void testValidationErrorPatternMatching() { + // Test all supported validation error patterns + + // Pattern 1: type 'X' not found + testPattern("{\"code\":\"validation_error\",\"message\":\"type 'user' not found\"}", "type", "user"); + + // Pattern 2: relation 'X' not found + testPattern( + "{\"code\":\"validation_error\",\"message\":\"relation 'document#viewer' not found\"}", + "relation", + "document#viewer"); + + // Pattern 3: invalid CheckRequestTupleKey.Field + testPatternFieldOnly( + "{\"code\":\"validation_error\",\"message\":\"invalid CheckRequestTupleKey.Object: value does not match regex\"}", + "Object"); + + // Pattern 4: invalid TupleKey.Field + testPatternFieldOnly( + "{\"code\":\"validation_error\",\"message\":\"invalid TupleKey.Relation: value does not match regex\"}", + "Relation"); + + // Pattern 5: field must not be empty + testPatternFieldOnly("{\"code\":\"validation_error\",\"message\":\"user must not be empty\"}", "user"); + } + + @Test + void testDifferentErrorTypes() { + // Test that different HTTP status codes create the right error types + + // 400/422 -> FgaApiValidationError + testErrorType(400, "{\"code\":\"validation_error\",\"message\":\"invalid\"}", FgaApiValidationError.class); + + // 401 -> FgaApiAuthenticationError + testErrorType(401, "{\"code\":\"unauthorized\",\"message\":\"auth failed\"}", FgaApiAuthenticationError.class); + + // 403 -> FgaApiAuthenticationError + testErrorType(403, "{\"code\":\"forbidden\",\"message\":\"access denied\"}", FgaApiAuthenticationError.class); + + // 404 -> FgaApiNotFoundError + testErrorType(404, "{\"code\":\"not_found\",\"message\":\"store not found\"}", FgaApiNotFoundError.class); + + // 429 -> FgaApiRateLimitExceededError + testErrorType( + 429, "{\"code\":\"rate_limit\",\"message\":\"too many requests\"}", FgaApiRateLimitExceededError.class); + + // 500+ -> FgaApiInternalError + testErrorType(500, "{\"code\":\"internal_error\",\"message\":\"server error\"}", FgaApiInternalError.class); + } + + @Test + void testErrorMessageFormattingVariations() { + // Test getMessage() formatting with different combinations of available data + + // Full data: operation + message + code + FgaError error1 = + createError("{\"code\":\"validation_error\",\"message\":\"type 'user' not found\"}", 400, "check"); + assertThat(error1.getMessage()).isEqualTo("[check] type 'user' not found (validation_error)"); + + // Message without code + FgaError error2 = createError("{\"message\":\"something went wrong\"}", 500, "write"); + assertThat(error2.getMessage()).isEqualTo("[write] something went wrong"); + + // No message (fallback to operation name) + FgaError error3 = createError("{\"code\":\"error\"}", 500, "read"); + assertThat(error3.getMessage()).isEqualTo("read"); + + // Empty body (fallback to operation name) + FgaError error4 = createError("", 500, "listStores"); + assertThat(error4.getMessage()).isEqualTo("listStores"); + } + + // Helper methods + + private void testPattern(String responseBody, String expectedField, String expectedValue) { + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + Optional errorOpt = FgaError.getError("check", request, config, response, null); + assertThat(errorOpt).isPresent(); + assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class); + + FgaApiValidationError error = (FgaApiValidationError) errorOpt.get(); + assertThat(error.getInvalidField()).isEqualTo(expectedField); + assertThat(error.getInvalidValue()).isEqualTo(expectedValue); + } + + private void testPatternFieldOnly(String responseBody, String expectedField) { + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + Optional errorOpt = FgaError.getError("check", request, config, response, null); + assertThat(errorOpt).isPresent(); + assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class); + + FgaApiValidationError error = (FgaApiValidationError) errorOpt.get(); + assertThat(error.getInvalidField()).isEqualTo(expectedField); + } + + private void testErrorType(int statusCode, String responseBody, Class expectedType) { + HttpResponse response = createMockResponse(statusCode, responseBody, Map.of()); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .GET() + .build(); + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + Optional errorOpt = FgaError.getError("test", request, config, response, null); + assertThat(errorOpt).isPresent(); + assertThat(errorOpt.get()).isInstanceOf(expectedType); + } + + private FgaError createError(String responseBody, int statusCode, String operationName) { + HttpResponse response = createMockResponse(statusCode, responseBody, Map.of()); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + Optional errorOpt = FgaError.getError(operationName, request, config, response, null); + assertThat(errorOpt).isPresent(); + return errorOpt.get(); + } + + private HttpResponse createMockResponse(int statusCode, String body, Map> headers) { + return new HttpResponse<>() { + @Override + public int statusCode() { + return statusCode; + } + + @Override + public HttpRequest request() { + return null; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(headers, (k, v) -> true); + } + + @Override + public String body() { + return body; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return URI.create("http://localhost:8080/test"); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + }; + } +} diff --git a/src/test/java/dev/openfga/sdk/errors/FgaErrorTest.java b/src/test/java/dev/openfga/sdk/errors/FgaErrorTest.java new file mode 100644 index 00000000..1cacc601 --- /dev/null +++ b/src/test/java/dev/openfga/sdk/errors/FgaErrorTest.java @@ -0,0 +1,926 @@ +package dev.openfga.sdk.errors; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.openfga.sdk.api.configuration.ClientConfiguration; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class FgaErrorTest { + + @Test + void shouldParseValidationErrorMessageFromResponseBody() { + // Given + String responseBody = "{\"code\":\"validation_error\",\"message\":\"invalid relation 'foo'\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("write", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error).isInstanceOf(FgaApiValidationError.class); + assertThat(error.getMessage()).isEqualTo("[write] invalid relation 'foo' (validation_error)"); + assertThat(error.getApiErrorMessage()).isEqualTo("invalid relation 'foo'"); + assertThat(error.getCode()).isEqualTo("validation_error"); + assertThat(error.getApiErrorCode()).isEqualTo("validation_error"); + assertThat(error.getStatusCode()).isEqualTo(400); + assertThat(error.getMethod()).isEqualTo("POST"); + } + + @Test + void shouldParseInternalErrorMessageFromResponseBody() { + // Given + String responseBody = "{\"code\":\"internal_error\",\"message\":\"database connection failed\"}"; + HttpResponse response = createMockResponse(500, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .GET() + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error).isInstanceOf(FgaApiInternalError.class); + assertThat(error.getMessage()).isEqualTo("[check] database connection failed (internal_error)"); + assertThat(error.getApiErrorMessage()).isEqualTo("database connection failed"); + assertThat(error.getCode()).isEqualTo("internal_error"); + assertThat(error.getStatusCode()).isEqualTo(500); + assertThat(error.getMethod()).isEqualTo("GET"); + } + + @Test + void shouldParseNotFoundErrorMessageFromResponseBody() { + // Given + String responseBody = "{\"code\":\"store_id_not_found\",\"message\":\"store not found\"}"; + HttpResponse response = createMockResponse(404, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .GET() + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("getStore", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error).isInstanceOf(FgaApiNotFoundError.class); + assertThat(error.getMessage()).isEqualTo("[getStore] store not found (store_id_not_found)"); + assertThat(error.getApiErrorMessage()).isEqualTo("store not found"); + assertThat(error.getCode()).isEqualTo("store_id_not_found"); + assertThat(error.getStatusCode()).isEqualTo(404); + } + + @Test + void shouldFallBackToMethodNameWhenMessageIsMissing() { + // Given + String responseBody = "{\"code\":\"validation_error\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("write", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error.getMessage()).isEqualTo("write"); + assertThat(error.getCode()).isEqualTo("validation_error"); + } + + @Test + void shouldFallBackToMethodNameWhenResponseBodyIsEmpty() { + // Given + String responseBody = ""; + HttpResponse response = createMockResponse(500, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("write", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error.getMessage()).isEqualTo("write"); + assertThat(error.getCode()).isNull(); + } + + @Test + void shouldFallBackToMethodNameWhenResponseBodyIsNotJson() { + // Given + String responseBody = "Server Error"; + HttpResponse response = createMockResponse(500, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("write", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error.getMessage()).isEqualTo("write"); + assertThat(error.getCode()).isNull(); + } + + @Test + void shouldExtractRequestIdFromHeaders() { + // Given + String responseBody = "{\"code\":\"validation_error\",\"message\":\"invalid tuple\"}"; + Map> headers = Map.of("X-Request-Id", List.of("abc-123-def-456")); + HttpResponse response = createMockResponse(400, responseBody, headers); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("write", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error.getRequestId()).isEqualTo("abc-123-def-456"); + } + + @Test + void shouldHandleUnprocessableEntityAsValidationError() { + // Given + String responseBody = "{\"code\":\"invalid_tuple\",\"message\":\"tuple validation failed\"}"; + HttpResponse response = createMockResponse(422, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("write", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error).isInstanceOf(FgaApiValidationError.class); + assertThat(error.getMessage()).isEqualTo("[write] tuple validation failed (invalid_tuple)"); + assertThat(error.getApiErrorMessage()).isEqualTo("tuple validation failed"); + assertThat(error.getCode()).isEqualTo("invalid_tuple"); + assertThat(error.getStatusCode()).isEqualTo(422); + } + + @Test + void shouldHandleAuthenticationError() { + // Given + String responseBody = "{\"code\":\"auth_failed\",\"message\":\"authentication failed\"}"; + HttpResponse response = createMockResponse(401, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .GET() + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("read", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error).isInstanceOf(FgaApiAuthenticationError.class); + assertThat(error.getMessage()).isEqualTo("[read] authentication failed (auth_failed)"); + assertThat(error.getApiErrorMessage()).isEqualTo("authentication failed"); + assertThat(error.getCode()).isEqualTo("auth_failed"); + assertThat(error.getStatusCode()).isEqualTo(401); + } + + @Test + void shouldHandleRateLimitError() { + // Given + String responseBody = "{\"code\":\"rate_limit_exceeded\",\"message\":\"too many requests\"}"; + HttpResponse response = createMockResponse(429, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .GET() + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error).isInstanceOf(FgaApiRateLimitExceededError.class); + assertThat(error.getMessage()).isEqualTo("[check] too many requests (rate_limit_exceeded)"); + assertThat(error.getApiErrorMessage()).isEqualTo("too many requests"); + assertThat(error.getCode()).isEqualTo("rate_limit_exceeded"); + assertThat(error.getStatusCode()).isEqualTo(429); + } + + @Test + void shouldReturnEmptyForSuccessfulResponse() { + // Given + HttpResponse response = createMockResponse(200, "{}", Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .GET() + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("read", request, config, response, null); + + // Then + assertThat(errorOpt).isEmpty(); + } + + @Test + void shouldSetApiErrorMessageWhenMessageIsParsedFromResponse() { + // Given + String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error.getApiErrorMessage()).isEqualTo("type 'invalid_type' not found"); + assertThat(error.getApiErrorCode()).isEqualTo("validation_error"); + assertThat(error.getOperationName()).isEqualTo("check"); + } + + @Test + void shouldNotSetApiErrorMessageWhenFallingBackToOperationName() { + // Given + String responseBody = "{\"code\":\"validation_error\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("write", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error.getApiErrorMessage()).isNull(); // Should be null when falling back + assertThat(error.getMessage()).isEqualTo("write"); // Falls back to operation name + assertThat(error.getOperationName()).isEqualTo("write"); + } + + @Test + void shouldSetOperationNameForAllErrorTypes() { + // Given + String responseBody = "{\"code\":\"store_not_found\",\"message\":\"store not found\"}"; + HttpResponse response = createMockResponse(404, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .GET() + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("getStore", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error.getOperationName()).isEqualTo("getStore"); + assertThat(error.getApiErrorMessage()).isEqualTo("store not found"); + } + + @Test + void shouldHandleAllFieldsWhenFullyPopulated() { + // Given + String responseBody = "{\"code\":\"rate_limit_exceeded\",\"message\":\"too many requests\"}"; + Map> headers = Map.of( + "X-Request-Id", List.of("req-123-456"), + "Retry-After", List.of("60")); + HttpResponse response = createMockResponse(429, responseBody, headers); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("write", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error.getOperationName()).isEqualTo("write"); + assertThat(error.getApiErrorMessage()).isEqualTo("too many requests"); + assertThat(error.getApiErrorCode()).isEqualTo("rate_limit_exceeded"); + assertThat(error.getCode()).isEqualTo("rate_limit_exceeded"); // Alias method + assertThat(error.getRequestId()).isEqualTo("req-123-456"); + assertThat(error.getRetryAfterHeader()).isEqualTo("60"); + assertThat(error.getMethod()).isEqualTo("POST"); + assertThat(error.getRequestUrl()).isEqualTo("http://localhost:8080"); + } + + @Test + void shouldHandleEmptyBodyGracefully() { + // Given + HttpResponse response = createMockResponse(500, "", Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .GET() + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error.getOperationName()).isEqualTo("check"); + assertThat(error.getApiErrorMessage()).isNull(); // No message in empty body + assertThat(error.getApiErrorCode()).isNull(); // No code in empty body + assertThat(error.getMessage()).isEqualTo("check"); // Falls back to operation name + } + + @Test + void shouldHandleNonJsonBodyGracefully() { + // Given + HttpResponse response = createMockResponse(500, "Internal Server Error", Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .GET() + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("listStores", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + assertThat(error.getOperationName()).isEqualTo("listStores"); + assertThat(error.getApiErrorMessage()).isNull(); // Can't parse non-JSON + assertThat(error.getApiErrorCode()).isNull(); // Can't parse non-JSON + assertThat(error.getMessage()).isEqualTo("listStores"); // Falls back to operation name + } + + // ============================================================================ + // PHASE 2 TESTS: getMessage() override, metadata, getDetailedMessage(), toString() + // ============================================================================ + + @Test + void testGetMessageOverrideReturnsApiErrorMessage() { + // Given + String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + + // getMessage() should return formatted message with operation name and error code + assertThat(error.getMessage()).isEqualTo("[check] type 'invalid_type' not found (validation_error)"); + } + + @Test + void testGetMessageFallsBackToSuperWhenNoApiErrorMessage() { + // Given + String responseBody = "{\"code\":\"validation_error\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("write", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + + // Should fall back to the original message (operation name) + assertThat(error.getMessage()).isEqualTo("write"); + } + + @Test + void testGetMessageWithoutErrorCode() { + // Given - API returns message but no code + String responseBody = "{\"message\":\"something went wrong\"}"; + HttpResponse response = createMockResponse(500, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .GET() + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + + // getMessage() should include operation name but not error code + assertThat(error.getMessage()).isEqualTo("[check] something went wrong"); + } + + @Test + void testMetadataOperations() { + // Given + String responseBody = "{\"code\":\"validation_error\",\"message\":\"invalid relation\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("write", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + + // Add metadata + error.addMetadata("custom_field", "custom_value"); + error.addMetadata("retry_count", 3); + + // Verify metadata + assertThat(error.getMetadata()).isNotNull(); + assertThat(error.getMetadata()).containsEntry("custom_field", "custom_value"); + assertThat(error.getMetadata()).containsEntry("retry_count", 3); + } + + @Test + void testGetDetailedMessage() { + // Given + String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}"; + Map> headers = Map.of("X-Request-Id", List.of("req-12345")); + HttpResponse response = createMockResponse(400, responseBody, headers); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + + String detailedMessage = error.getDetailedMessage(); + + // Should include operation name, message, code, request-id, and HTTP status + assertThat(detailedMessage).contains("[check]"); + assertThat(detailedMessage).contains("type 'invalid_type' not found"); + assertThat(detailedMessage).contains("(code: validation_error)"); + assertThat(detailedMessage).contains("[request-id: req-12345]"); + assertThat(detailedMessage).contains("[HTTP 400]"); + } + + @Test + void testGetDetailedMessageWithMinimalInfo() { + // Given + String responseBody = ""; + HttpResponse response = createMockResponse(500, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .GET() + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + + String detailedMessage = error.getDetailedMessage(); + + // Should at least include operation name and HTTP status + assertThat(detailedMessage).contains("[check]"); + assertThat(detailedMessage).contains("[HTTP 500]"); + } + + @Test + void testToString() { + // Given + String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}"; + Map> headers = Map.of("X-Request-Id", List.of("req-67890")); + HttpResponse response = createMockResponse(400, responseBody, headers); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + + String toString = error.toString(); + + // Should include class name, operation, message, HTTP status, code, and request-id + assertThat(toString).startsWith("FgaApiValidationError"); + assertThat(toString).contains("[check]"); + assertThat(toString).contains("type 'invalid_type' not found"); + assertThat(toString).contains("(HTTP 400)"); + assertThat(toString).contains("[code: validation_error]"); + assertThat(toString).contains("[request-id: req-67890]"); + } + + @Test + void testToStringWithMinimalInfo() { + // Given + String responseBody = ""; + HttpResponse response = createMockResponse(500, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .GET() + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("listStores", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + + String toString = error.toString(); + + // Should include class name, operation, and HTTP status + assertThat(toString).startsWith("FgaApiInternalError"); + assertThat(toString).contains("[listStores]"); + assertThat(toString).contains("(HTTP 500)"); + } + + // ============================================================================ + // VALIDATION ERROR SPECIFIC TESTS + // ============================================================================ + + @Test + void testValidationErrorParsesInvalidRelation() { + // Given + String responseBody = + "{\"code\":\"validation_error\",\"message\":\"relation 'document#invalid_relation' not found\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class); + + FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get(); + assertThat(validationError.getInvalidField()).isEqualTo("relation"); + assertThat(validationError.getInvalidValue()).isEqualTo("document#invalid_relation"); + assertThat(validationError.getMetadata()).containsEntry("invalid_field", "relation"); + assertThat(validationError.getMetadata()).containsEntry("invalid_value", "document#invalid_relation"); + } + + @Test + void testValidationErrorParsesInvalidType() { + // Given + String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class); + + FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get(); + assertThat(validationError.getInvalidField()).isEqualTo("type"); + assertThat(validationError.getInvalidValue()).isEqualTo("invalid_type"); + assertThat(validationError.getMetadata()).containsEntry("invalid_field", "type"); + assertThat(validationError.getMetadata()).containsEntry("invalid_value", "invalid_type"); + } + + @Test + void testValidationErrorParsesCheckRequestTupleKeyField() { + // Given + String responseBody = + "{\"code\":\"validation_error\",\"message\":\"invalid CheckRequestTupleKey.User: value does not match regex\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class); + + FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get(); + assertThat(validationError.getInvalidField()).isEqualTo("User"); + assertThat(validationError.getMetadata()).containsEntry("invalid_field", "User"); + } + + @Test + void testValidationErrorParsesTupleKeyField() { + // Given + String responseBody = + "{\"code\":\"validation_error\",\"message\":\"invalid TupleKey.Object: value does not match regex\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("write", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class); + + FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get(); + assertThat(validationError.getInvalidField()).isEqualTo("Object"); + assertThat(validationError.getMetadata()).containsEntry("invalid_field", "Object"); + } + + @Test + void testValidationErrorParsesEmptyFieldMessage() { + // Given + String responseBody = "{\"code\":\"validation_error\",\"message\":\"object must not be empty\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class); + + FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get(); + assertThat(validationError.getInvalidField()).isEqualTo("object"); + assertThat(validationError.getMetadata()).containsEntry("invalid_field", "object"); + } + + @Test + void testValidationErrorHandlesUnparsableMessage() { + // Given + String responseBody = "{\"code\":\"validation_error\",\"message\":\"some unexpected format\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class); + + FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get(); + // Should not throw, should gracefully handle unparsable message + assertThat(validationError.getInvalidField()).isNull(); + assertThat(validationError.getInvalidValue()).isNull(); + } + + @Test + void testValidationErrorHandlesEmptyBody() { + // Given + String responseBody = ""; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + assertThat(errorOpt.get()).isInstanceOf(FgaApiValidationError.class); + + FgaApiValidationError validationError = (FgaApiValidationError) errorOpt.get(); + // Should not throw, should handle empty body gracefully + assertThat(validationError.getInvalidField()).isNull(); + assertThat(validationError.getInvalidValue()).isNull(); + } + + @Test + void testGetMessageWorksInExecutionExceptionScenario() { + // This test simulates how errors appear when wrapped in ExecutionException + // Given + String responseBody = "{\"code\":\"validation_error\",\"message\":\"type 'invalid_type' not found\"}"; + HttpResponse response = createMockResponse(400, responseBody, Map.of()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8080/test")) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + + ClientConfiguration config = new ClientConfiguration().apiUrl("http://localhost:8080"); + + // When + Optional errorOpt = FgaError.getError("check", request, config, response, null); + + // Then + assertThat(errorOpt).isPresent(); + FgaError error = errorOpt.get(); + + // Simulate what would be shown in ExecutionException + String executionExceptionMessage = error.getClass().getName() + ": " + error.getMessage(); + + // Before the fix, this would have shown: "...FgaApiValidationError: check" + // After the fix, it shows the actual error: + assertThat(executionExceptionMessage) + .contains("FgaApiValidationError") + .contains("[check] type 'invalid_type' not found (validation_error)"); + } + + // Helper method to create mock HttpResponse + private HttpResponse createMockResponse(int statusCode, String body, Map> headers) { + return new HttpResponse() { + @Override + public int statusCode() { + return statusCode; + } + + @Override + public HttpRequest request() { + return null; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(headers, (k, v) -> true); + } + + @Override + public String body() { + return body; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return URI.create("http://localhost:8080/test"); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + }; + } +}