diff --git a/CHANGELOG.md b/CHANGELOG.md index bc766c57..f2a2095a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased](https://github.com/openfga/java-sdk/compare/v0.9.3...HEAD) +### Changed +- Improved error handling and integration test coverage for FgaError and related classes. (#260) + ## v0.9.3 ### [0.9.3](https://github.com/openfga/java-sdk/compare/v0.9.2...v0.9.3)) (2025-11-10) diff --git a/src/main/java/dev/openfga/sdk/errors/FgaError.java b/src/main/java/dev/openfga/sdk/errors/FgaError.java index 85f54d88..ef23ef9f 100644 --- a/src/main/java/dev/openfga/sdk/errors/FgaError.java +++ b/src/main/java/dev/openfga/sdk/errors/FgaError.java @@ -2,6 +2,12 @@ import static dev.openfga.sdk.errors.HttpStatusCode.*; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import dev.openfga.sdk.api.configuration.Configuration; import dev.openfga.sdk.api.configuration.CredentialsMethod; import dev.openfga.sdk.constants.FgaConstants; @@ -9,8 +15,11 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.Optional; +import org.openapitools.jackson.nullable.JsonNullableModule; public class FgaError extends ApiException { + private static final String UNKNOWN_ERROR_CODE = "unknown_error"; + private String method = null; private String requestUrl = null; private String clientId = null; @@ -19,6 +28,60 @@ public class FgaError extends ApiException { private String requestId = null; private String apiErrorCode = null; private String retryAfterHeader = null; + private String apiErrorMessage = null; + private String operationName = null; + + private static final ObjectMapper OBJECT_MAPPER = createConfiguredObjectMapper(); + + private static ObjectMapper createConfiguredObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + mapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); + mapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); + mapper.disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE); + mapper.registerModule(new JavaTimeModule()); + mapper.registerModule(new JsonNullableModule()); + return mapper; + } + + @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true) + private static class ApiErrorResponse { + @com.fasterxml.jackson.annotation.JsonProperty("code") + private String code; + + @com.fasterxml.jackson.annotation.JsonProperty("message") + private String message; + + @com.fasterxml.jackson.annotation.JsonProperty("error") + private String error; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getMessage() { + return message != null ? message : error; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + } public FgaError(String message, Throwable cause, int code, HttpHeaders responseHeaders, String responseBody) { super(message, cause, code, responseHeaders, responseBody); @@ -53,7 +116,7 @@ public static Optional getError( error = new FgaApiNotFoundError(name, previousError, status, headers, body); } else if (status == TOO_MANY_REQUESTS) { error = new FgaApiRateLimitExceededError(name, previousError, status, headers, body); - } else if (isServerError(status)) { + } else if (HttpStatusCode.isServerError(status)) { error = new FgaApiInternalError(name, previousError, status, headers, body); } else { error = new FgaError(name, previousError, status, headers, body); @@ -75,6 +138,27 @@ public static Optional getError( error.setAudience(clientCredentials.getApiAudience()); } + error.setOperationName(name); + + // Parse API error response + if (body != null && !body.trim().isEmpty()) { + try { + ApiErrorResponse resp = OBJECT_MAPPER.readValue(body, ApiErrorResponse.class); + error.setApiErrorCode(resp.getCode()); + error.setApiErrorMessage(resp.getMessage()); + } catch (JsonProcessingException e) { + // Wrap unparseable response + error.setApiErrorCode(UNKNOWN_ERROR_CODE); + error.setApiErrorMessage("Unable to parse error response. Raw response: " + body); + } + } + + // Extract requestId from headers + Optional requestIdOpt = headers.firstValue("x-request-id"); + if (requestIdOpt.isPresent()) { + error.setRequestId(requestIdOpt.get()); + } + // Unknown error return Optional.of(error); } @@ -142,4 +226,143 @@ public void setRetryAfterHeader(String retryAfterHeader) { public String getRetryAfterHeader() { return retryAfterHeader; } + + public void setApiErrorMessage(String apiErrorMessage) { + this.apiErrorMessage = apiErrorMessage; + } + + public String getApiErrorMessage() { + return apiErrorMessage; + } + + public void setOperationName(String operationName) { + this.operationName = operationName; + } + + public String getOperationName() { + return operationName; + } + + /** + * Returns a formatted error message for FgaError. + *

+ * The message is formatted as: + *

+     *     [operationName] HTTP statusCode apiErrorMessage (apiErrorCode) [request-id: requestId]
+     * 
+ * Example: [write] HTTP 400 type 'invalid_type' not found (validation_error) [request-id: abc-123] + *

+ * + * @return the formatted error message string + */ + @Override + public String getMessage() { + // Use apiErrorMessage if available, otherwise fall back to the original message + String message = (apiErrorMessage != null && !apiErrorMessage.isEmpty()) ? apiErrorMessage : super.getMessage(); + + StringBuilder sb = new StringBuilder(); + + // [operationName] + if (operationName != null && !operationName.isEmpty()) { + sb.append("[").append(operationName).append("] "); + } + + // HTTP 400 + sb.append("HTTP ").append(getStatusCode()).append(" "); + + // type 'invalid_type' not found + if (message != null && !message.isEmpty()) { + sb.append(message); + } + + // (validation_error) + if (apiErrorCode != null && !apiErrorCode.isEmpty()) { + sb.append(" (").append(apiErrorCode).append(")"); + } + + // [request-id: abc-123] + if (requestId != null && !requestId.isEmpty()) { + sb.append(" [request-id: ").append(requestId).append("]"); + } + + return sb.toString().trim(); + } + + // --- Helper Methods --- + + /** + * Checks if this is a validation error. + * Reliable error type checking based on error code. + * + * @return true if this is a validation error + */ + public boolean isValidationError() { + return "validation_error".equals(apiErrorCode); + } + + /** + * Checks if this is an unknown error due to unparseable response. + * This occurs when the error response could not be parsed as JSON. + * + * @return true if this is an unknown error + */ + public boolean isUnknownError() { + return UNKNOWN_ERROR_CODE.equals(apiErrorCode); + } + + /** + * Checks if this is a not found (404) error. + * + * @return true if this is a 404 error + */ + public boolean isNotFoundError() { + return getStatusCode() == NOT_FOUND; + } + + /** + * Checks if this is an authentication (401) error. + * + * @return true if this is a 401 error + */ + public boolean isAuthenticationError() { + return getStatusCode() == UNAUTHORIZED; + } + + /** + * Checks if this is a rate limit (429) error. + * + * @return true if this is a rate limit error + */ + public boolean isRateLimitError() { + return getStatusCode() == TOO_MANY_REQUESTS || "rate_limit_exceeded".equals(apiErrorCode); + } + + /** + * Checks if this error should be retried. + * 429 (Rate Limit) and 5xx (Server Errors) are typically retryable. + * + * @return true if this error is retryable + */ + public boolean isRetryable() { + return HttpStatusCode.isRetryable(getStatusCode()); + } + + /** + * Checks if this is a client error (4xx). + * + * @return true if this is a 4xx error + */ + public boolean isClientError() { + int status = getStatusCode(); + return status >= 400 && status < 500; + } + + /** + * Checks if this is a server error (5xx). + * + * @return true if this is a 5xx error + */ + public boolean isServerError() { + return HttpStatusCode.isServerError(getStatusCode()); + } } 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..21b7aec3 --- /dev/null +++ b/src/test-integration/java/dev/openfga/sdk/errors/FgaErrorIntegrationTest.java @@ -0,0 +1,581 @@ +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.ClientTupleKey; +import dev.openfga.sdk.api.client.model.ClientWriteRequest; +import dev.openfga.sdk.api.configuration.ClientConfiguration; +import dev.openfga.sdk.api.model.CreateStoreRequest; +import dev.openfga.sdk.api.model.WriteAuthorizationModelRequest; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +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 +public class FgaErrorIntegrationTest { + + @Container + private static final OpenFGAContainer openfga = new OpenFGAContainer("openfga/openfga:v1.10.2"); + + private static final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + + // Test constants + private static final String ERROR_CODE_VALIDATION_ERROR = "validation_error"; + private static final String ERROR_CODE_STORE_ID_NOT_FOUND = "store_id_not_found"; + private static final String OPERATION_WRITE = "write"; + private static final String OPERATION_CHECK = "check"; + private static final String OPERATION_READ = "read"; + private static final String OPERATION_EXPAND = "expand"; + private static final int HTTP_STATUS_BAD_REQUEST = 400; + private static final int HTTP_STATUS_NOT_FOUND = 404; + + private String authModelJson; + private OpenFgaClient fga; + + @BeforeAll + public void loadAuthModelJson() throws IOException { + authModelJson = Files.readString(Paths.get("src", "test-integration", "resources", "auth-model.json")); + } + + @BeforeEach + public void initializeApi() throws Exception { + ClientConfiguration apiConfig = new ClientConfiguration().apiUrl(openfga.getHttpEndpoint()); + fga = new OpenFgaClient(apiConfig); + + // Create a store + String storeName = "ErrorTestStore"; + var createStoreResponse = + fga.createStore(new CreateStoreRequest().name(storeName)).get(); + String storeId = createStoreResponse.getId(); + fga.setStoreId(storeId); + + // Write the authorization model + WriteAuthorizationModelRequest writeModelRequest = + mapper.readValue(authModelJson, WriteAuthorizationModelRequest.class); + fga.writeAuthorizationModel(writeModelRequest).get(); + } + + @Test + public void writeValidationError() throws Exception { + // Try to write a tuple with invalid type + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("invalid_type:readme") + .relation("viewer") + .user("user:anne"))); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + fga.write(request).get(); + }); + + FgaApiValidationError exception = assertInstanceOf(FgaApiValidationError.class, executionException.getCause()); + assertTrue(exception.getMessage().contains("type 'invalid_type' not found")); + assertEquals(ERROR_CODE_VALIDATION_ERROR, exception.getApiErrorCode()); + assertTrue(exception.getApiErrorMessage().contains("type 'invalid_type' not found")); + assertEquals(OPERATION_WRITE, exception.getOperationName()); + assertNotNull(exception.getRequestId()); + } + + @Test + public void writeValidationErrorWithInvalidRelation() throws Exception { + // Try to write a tuple with valid type but invalid relation + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("document:readme") + .relation("invalid_relation") + .user("user:anne"))); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + fga.write(request).get(); + }); + + FgaApiValidationError exception = assertInstanceOf(FgaApiValidationError.class, executionException.getCause()); + // Verify the formatted message includes operation name and API error details + assertTrue(exception.getMessage().contains("write")); + assertTrue(exception.getMessage().contains("relation 'document#invalid_relation' not found")); + assertEquals(ERROR_CODE_VALIDATION_ERROR, exception.getApiErrorCode()); + assertNotNull(exception.getApiErrorMessage()); + assertEquals(OPERATION_WRITE, exception.getOperationName()); + assertNotNull(exception.getRequestId()); + } + + @Test + public void errorMessageFormattingWithAllFields() throws Exception { + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("invalid_type:readme") + .relation("viewer") + .user("user:anne"))); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + fga.write(request).get(); + }); + + FgaApiValidationError exception = assertInstanceOf(FgaApiValidationError.class, executionException.getCause()); + + String message = exception.getMessage(); + assertTrue(message.startsWith("[write]")); + assertTrue(message.contains("HTTP 400")); + assertTrue(message.contains("type 'invalid_type' not found")); + assertTrue(message.contains("(" + ERROR_CODE_VALIDATION_ERROR + ")")); + assertTrue(message.contains("[request-id: ")); + assertTrue(message.endsWith("]")); + + assertEquals(OPERATION_WRITE, exception.getOperationName()); + assertEquals(ERROR_CODE_VALIDATION_ERROR, exception.getApiErrorCode()); + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertNotNull(exception.getApiErrorMessage()); + assertNotNull(exception.getRequestId()); + } + + @Test + public void checkValidationError() throws Exception { + var checkRequest = new dev.openfga.sdk.api.client.model.ClientCheckRequest() + ._object("invalid_type:readme") + .relation("viewer") + .user("user:anne"); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + fga.check(checkRequest).get(); + }); + + FgaApiValidationError exception = assertInstanceOf(FgaApiValidationError.class, executionException.getCause()); + + assertEquals(OPERATION_CHECK, exception.getOperationName()); + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertEquals(ERROR_CODE_VALIDATION_ERROR, exception.getApiErrorCode()); + assertTrue(exception.getMessage().contains("[check]")); + assertTrue(exception.getMessage().contains("HTTP 400")); + assertNotNull(exception.getApiErrorMessage()); + assertNotNull(exception.getRequestId()); + } + + @Test + public void errorDetailsAreNotLostInStackTrace() throws Exception { + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("invalid_type:readme") + .relation("viewer") + .user("user:anne"))); + + try { + fga.write(request).get(); + fail("Expected ExecutionException to be thrown"); + } catch (ExecutionException e) { + FgaApiValidationError exception = assertInstanceOf(FgaApiValidationError.class, e.getCause()); + + String errorString = exception.toString(); + assertTrue(errorString.contains("type 'invalid_type' not found")); + + String errorMessage = exception.getMessage(); + assertTrue(errorMessage.contains("[write]")); + assertTrue(errorMessage.contains("HTTP 400")); + assertTrue(errorMessage.contains(ERROR_CODE_VALIDATION_ERROR)); + + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertNotNull(exception.getResponseData()); + } + } + + @Test + public void multipleTupleErrorsShowDetailedMessage() throws Exception { + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of( + new ClientTupleKey() + ._object("invalid_type1:readme") + .relation("viewer") + .user("user:anne"), + new ClientTupleKey() + ._object("invalid_type2:readme") + .relation("viewer") + .user("user:bob"))); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + fga.write(request).get(); + }); + + FgaApiValidationError exception = assertInstanceOf(FgaApiValidationError.class, executionException.getCause()); + + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertEquals(ERROR_CODE_VALIDATION_ERROR, exception.getApiErrorCode()); + assertTrue(exception.getMessage().contains("[write]")); + assertTrue(exception.getMessage().contains("HTTP 400")); + assertNotNull(exception.getApiErrorMessage()); + assertNotNull(exception.getRequestId()); + } + + @Test + public void getStoreWithInvalidStoreIdFormat() throws Exception { + ClientConfiguration badConfig = + new ClientConfiguration().apiUrl(openfga.getHttpEndpoint()).storeId("01HVJPQR3TXYZ9NQXABCDEFGHI"); + OpenFgaClient badClient = new OpenFgaClient(badConfig); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + badClient.getStore().get(); + }); + + FgaError exception = assertInstanceOf(FgaError.class, executionException.getCause()); + + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertEquals(ERROR_CODE_VALIDATION_ERROR, exception.getApiErrorCode()); + assertTrue(exception.getApiErrorMessage().contains("does not match regex pattern")); + assertNotNull(exception.getRequestId()); + assertTrue(exception.getMessage().contains("HTTP 400")); + assertTrue(exception.getMessage().contains("[getStore]")); + } + + @Test + public void invalidParameterException() throws Exception { + ClientWriteRequest request = new ClientWriteRequest(); + + dev.openfga.sdk.errors.FgaInvalidParameterException exception = + assertThrows(dev.openfga.sdk.errors.FgaInvalidParameterException.class, () -> { + ClientConfiguration config = new ClientConfiguration().apiUrl(openfga.getHttpEndpoint()); + OpenFgaClient client = new OpenFgaClient(config); + client.write(request).get(); + }); + + assertNotNull(exception.getMessage()); + assertTrue(exception.getMessage().contains("storeId") + || exception.getMessage().contains("parameter")); + } + + @Test + public void invalidStoreIdFormat() throws Exception { + ClientConfiguration badConfig = + new ClientConfiguration().apiUrl(openfga.getHttpEndpoint()).storeId("non-existent-store-id-12345"); + OpenFgaClient badClient = new OpenFgaClient(badConfig); + + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("document:readme") + .relation("viewer") + .user("user:anne"))); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + badClient.write(request).get(); + }); + + FgaError exception = assertInstanceOf(FgaError.class, executionException.getCause()); + + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertEquals(ERROR_CODE_VALIDATION_ERROR, exception.getApiErrorCode()); + assertTrue(exception.getMessage().contains("[write]")); + assertTrue(exception.getMessage().contains("HTTP 400")); + assertTrue(exception.getMessage().contains("StoreId")); + assertNotNull(exception.getRequestId()); + } + + @Test + public void readOperationError() throws Exception { + var readRequest = new dev.openfga.sdk.api.client.model.ClientReadRequest()._object("invalid_type:"); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + fga.read(readRequest).get(); + }); + + FgaApiValidationError exception = assertInstanceOf(FgaApiValidationError.class, executionException.getCause()); + + assertEquals(OPERATION_READ, exception.getOperationName()); + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertEquals(ERROR_CODE_VALIDATION_ERROR, exception.getApiErrorCode()); + assertTrue(exception.getMessage().contains("[read]")); + assertTrue(exception.getMessage().contains("HTTP 400")); + assertNotNull(exception.getApiErrorMessage()); + assertNotNull(exception.getRequestId()); + } + + @Test + public void expandOperationError() throws Exception { + var expandRequest = new dev.openfga.sdk.api.client.model.ClientExpandRequest() + ._object("invalid_type:readme") + .relation("viewer"); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + fga.expand(expandRequest).get(); + }); + + FgaApiValidationError exception = assertInstanceOf(FgaApiValidationError.class, executionException.getCause()); + + assertEquals(OPERATION_EXPAND, exception.getOperationName()); + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertEquals(ERROR_CODE_VALIDATION_ERROR, exception.getApiErrorCode()); + assertTrue(exception.getMessage().contains("[expand]")); + assertTrue(exception.getMessage().contains("HTTP 400")); + assertNotNull(exception.getApiErrorMessage()); + assertNotNull(exception.getRequestId()); + } + + @Test + public void differentErrorCodesAreSurfaced() throws Exception { + // Test that different validation_error subcases are properly surfaced + // Case 1: Invalid type + ClientWriteRequest request1 = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("invalid_type:readme") + .relation("viewer") + .user("user:anne"))); + + ExecutionException ex1 = + assertThrows(ExecutionException.class, () -> fga.write(request1).get()); + FgaApiValidationError error1 = assertInstanceOf(FgaApiValidationError.class, ex1.getCause()); + assertTrue(error1.getApiErrorMessage().contains("type")); + + // Case 2: Invalid relation + ClientWriteRequest request2 = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("document:readme") + .relation("invalid_relation") + .user("user:anne"))); + + ExecutionException ex2 = + assertThrows(ExecutionException.class, () -> fga.write(request2).get()); + FgaApiValidationError error2 = assertInstanceOf(FgaApiValidationError.class, ex2.getCause()); + assertTrue(error2.getApiErrorMessage().contains("relation")); + + // Both should have the same error code but different messages + assertEquals(error1.getApiErrorCode(), error2.getApiErrorCode()); + assertNotEquals(error1.getApiErrorMessage(), error2.getApiErrorMessage()); + } + + @Test + public void errorMessageContainsOperationContext() throws Exception { + ClientWriteRequest writeReq = new ClientWriteRequest() + .writes(List.of( + new ClientTupleKey()._object("invalid:x").relation("r").user("user:x"))); + + ExecutionException writeEx = + assertThrows(ExecutionException.class, () -> fga.write(writeReq).get()); + FgaError writeError = assertInstanceOf(FgaError.class, writeEx.getCause()); + assertEquals(OPERATION_WRITE, writeError.getOperationName()); + assertEquals(HTTP_STATUS_BAD_REQUEST, writeError.getStatusCode()); + assertTrue(writeError.getMessage().contains("[write]")); + assertTrue(writeError.getMessage().contains("HTTP 400")); + + var checkReq = new dev.openfga.sdk.api.client.model.ClientCheckRequest() + ._object("invalid:x") + .relation("r") + .user("user:x"); + + ExecutionException checkEx = + assertThrows(ExecutionException.class, () -> fga.check(checkReq).get()); + FgaError checkError = assertInstanceOf(FgaError.class, checkEx.getCause()); + assertEquals(OPERATION_CHECK, checkError.getOperationName()); + assertEquals(HTTP_STATUS_BAD_REQUEST, checkError.getStatusCode()); + assertTrue(checkError.getMessage().contains("[check]")); + assertTrue(checkError.getMessage().contains("HTTP 400")); + + assertNotEquals(writeError.getOperationName(), checkError.getOperationName()); + } + + // --- Tests for New Helper Methods --- + + @Test + public void isValidationErrorHelper() throws Exception { + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("invalid_type:readme") + .relation("viewer") + .user("user:anne"))); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + fga.write(request).get(); + }); + + FgaError exception = assertInstanceOf(FgaError.class, executionException.getCause()); + + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertEquals(ERROR_CODE_VALIDATION_ERROR, exception.getApiErrorCode()); + assertTrue(exception.isValidationError()); + assertTrue(exception.isClientError()); + assertFalse(exception.isServerError()); + assertFalse(exception.isRetryable()); + } + + @Test + public void isNotFoundErrorHelper() throws Exception { + var tempStoreResponse = fga.createStore(new CreateStoreRequest().name("TempStoreForNotFoundTest")) + .get(); + String tempStoreId = tempStoreResponse.getId(); + + ClientConfiguration tempConfig = new ClientConfiguration().apiUrl(openfga.getHttpEndpoint()); + OpenFgaClient tempClient = new OpenFgaClient(tempConfig); + tempClient.setStoreId(tempStoreId); + tempClient.deleteStore().get(); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + tempClient.getStore().get(); + }); + + FgaError exception = assertInstanceOf(FgaError.class, executionException.getCause()); + + assertEquals(HTTP_STATUS_NOT_FOUND, exception.getStatusCode()); + assertEquals(ERROR_CODE_STORE_ID_NOT_FOUND, exception.getApiErrorCode()); + assertTrue(exception.isNotFoundError()); + assertTrue(exception.isClientError()); + assertFalse(exception.isServerError()); + assertFalse(exception.isRetryable()); + assertFalse(exception.isValidationError()); + assertTrue(exception.getMessage().contains("HTTP 404")); + assertTrue(exception.getMessage().contains("[getStore]")); + } + + @Test + public void isClientErrorHelper() throws Exception { + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("invalid_type:readme") + .relation("viewer") + .user("user:anne"))); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + fga.write(request).get(); + }); + + FgaError exception = assertInstanceOf(FgaError.class, executionException.getCause()); + + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertTrue(exception.isClientError()); + assertFalse(exception.isServerError()); + } + + @Test + public void errorCategorizationHelpers() throws Exception { + ClientConfiguration badConfig = + new ClientConfiguration().apiUrl(openfga.getHttpEndpoint()).storeId("non-existent-store-id"); + OpenFgaClient badClient = new OpenFgaClient(badConfig); + + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("document:readme") + .relation("viewer") + .user("user:anne"))); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + badClient.write(request).get(); + }); + + FgaError exception = assertInstanceOf(FgaError.class, executionException.getCause()); + + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertEquals(ERROR_CODE_VALIDATION_ERROR, exception.getApiErrorCode()); + assertTrue(exception.isClientError()); + assertTrue(exception.isValidationError()); + assertFalse(exception.isServerError()); + assertFalse(exception.isRetryable()); + assertFalse(exception.isAuthenticationError()); + assertFalse(exception.isNotFoundError()); + } + + @Test + public void isRetryableHelper() throws Exception { + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("invalid_type:readme") + .relation("viewer") + .user("user:anne"))); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + fga.write(request).get(); + }); + + FgaError exception = assertInstanceOf(FgaError.class, executionException.getCause()); + + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertFalse(exception.isRetryable()); + } + + @Test + public void helperMethodsConsistency() throws Exception { + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("invalid_type:readme") + .relation("viewer") + .user("user:anne"))); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + fga.write(request).get(); + }); + + FgaError exception = assertInstanceOf(FgaError.class, executionException.getCause()); + + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertTrue(exception.isClientError()); + assertFalse(exception.isServerError()); + assertTrue(exception.isValidationError()); + assertFalse(exception.isRetryable()); + } + + @Test + public void errorCodeFieldsAccessibility() throws Exception { + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object("invalid_type:readme") + .relation("viewer") + .user("user:anne"))); + + ExecutionException executionException = assertThrows(ExecutionException.class, () -> { + fga.write(request).get(); + }); + + FgaError exception = assertInstanceOf(FgaError.class, executionException.getCause()); + + assertEquals(HTTP_STATUS_BAD_REQUEST, exception.getStatusCode()); + assertEquals(ERROR_CODE_VALIDATION_ERROR, exception.getApiErrorCode()); + assertEquals(OPERATION_WRITE, exception.getOperationName()); + assertNotNull(exception.getApiErrorMessage()); + assertNotNull(exception.getRequestId()); + assertTrue(exception.getRequestId().matches("[a-zA-Z0-9-]+")); + } + + @Test + public void messageFormatConsistency() throws Exception { + ClientWriteRequest writeReq = new ClientWriteRequest() + .writes(List.of( + new ClientTupleKey()._object("invalid:x").relation("r").user("user:x"))); + + ExecutionException writeEx = + assertThrows(ExecutionException.class, () -> fga.write(writeReq).get()); + FgaError writeError = assertInstanceOf(FgaError.class, writeEx.getCause()); + + var checkReq = new dev.openfga.sdk.api.client.model.ClientCheckRequest() + ._object("invalid:x") + .relation("r") + .user("user:x"); + + ExecutionException checkEx = + assertThrows(ExecutionException.class, () -> fga.check(checkReq).get()); + FgaError checkError = assertInstanceOf(FgaError.class, checkEx.getCause()); + + String writeMsg = writeError.getMessage(); + String checkMsg = checkError.getMessage(); + + assertTrue(writeMsg.matches("\\[\\w+\\] HTTP \\d{3} .+")); + assertTrue(checkMsg.matches("\\[\\w+\\] HTTP \\d{3} .+")); + + assertEquals(HTTP_STATUS_BAD_REQUEST, writeError.getStatusCode()); + assertEquals(HTTP_STATUS_BAD_REQUEST, checkError.getStatusCode()); + assertTrue(writeMsg.contains("[write]")); + assertTrue(writeMsg.contains("HTTP 400")); + assertTrue(writeMsg.contains("(" + ERROR_CODE_VALIDATION_ERROR + ")")); + assertTrue(writeMsg.contains("[request-id: ")); + + assertTrue(checkMsg.contains("[check]")); + assertTrue(checkMsg.contains("HTTP 400")); + assertTrue(checkMsg.contains("(" + ERROR_CODE_VALIDATION_ERROR + ")")); + assertTrue(checkMsg.contains("[request-id: ")); + } +} 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..45c112ed 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,9 @@ 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()); + assertTrue(exception.getMessage().contains("FgaApiRateLimitExceededError")); + assertTrue(exception.getMessage().contains("exchangeToken")); + assertTrue(exception.getMessage().contains("HTTP 429")); verify(3, postRequestedFor(urlEqualTo("/oauth/token"))); }