diff --git a/src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java b/src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java index 2f14a78..f853d36 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java +++ b/src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java @@ -92,6 +92,10 @@ public String getRawResponse() { return rawResponse; } + public String getRelation() { + return request == null ? null : request.getRelation(); + } + public static BiFunction asyncHandler( ClientCheckRequest request) { return (response, throwable) -> new ClientBatchCheckResponse(request, response, throwable); diff --git a/src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java b/src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java index 6a85969..8a5b435 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java +++ b/src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java @@ -14,10 +14,20 @@ public List getRelations() { return relations; } - public static ClientListRelationsResponse fromBatchCheckResponses(List responses) { - return new ClientListRelationsResponse(responses.stream() + public static ClientListRelationsResponse fromBatchCheckResponses(List responses) + throws Throwable { + // If any response ultimately failed (with retries) we throw the first exception encountered. + var failedResponse = responses.stream() + .filter(response -> response.getThrowable() != null) + .findFirst(); + if (failedResponse.isPresent()) { + throw failedResponse.get().getThrowable(); + } + + var relations = responses.stream() .filter(ClientBatchCheckResponse::getAllowed) - .map(batchCheckResponse -> batchCheckResponse.getRequest().getRelation()) - .collect(Collectors.toList())); + .map(ClientBatchCheckResponse::getRelation) + .collect(Collectors.toList()); + return new ClientListRelationsResponse(relations); } } diff --git a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java index b1504df..d253127 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -495,8 +495,9 @@ public CompletableFuture listObjects( * ListRelations - List allowed relations a user has with an object (evaluates) */ public CompletableFuture listRelations( - ClientListRelationsRequest request, ClientBatchCheckOptions options) throws FgaInvalidParameterException { - if (request.getRelations().isEmpty()) { + ClientListRelationsRequest request, ClientListRelationsOptions options) + throws FgaInvalidParameterException { + if (request.getRelations() == null || request.getRelations().isEmpty()) { throw new FgaInvalidParameterException( "At least 1 relation to check has to be provided when calling ListRelations"); } @@ -508,7 +509,8 @@ public CompletableFuture listRelations( ._object(request.getObject())) .collect(Collectors.toList()); - return batchCheck(batchCheckRequests, options).thenApply(ClientListRelationsResponse::fromBatchCheckResponses); + return batchCheck(batchCheckRequests, options.asClientBatchCheckOptions()) + .thenCompose(responses -> call(() -> ClientListRelationsResponse.fromBatchCheckResponses(responses))); } /* ************ @@ -586,17 +588,38 @@ public CompletableFuture writeAssertions( * @param The type of API response */ @FunctionalInterface + private interface CheckedAsyncInvocation { + CompletableFuture call() throws Throwable; + } + + private CompletableFuture call(CheckedAsyncInvocation action) { + try { + return action.call(); + } catch (CompletionException completionException) { + return CompletableFuture.failedFuture(completionException.getCause()); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); + } + } + + /** + * A {@link FunctionalInterface} for calling any function that could throw an exception. + * It wraps exceptions encountered with {@link CompletableFuture#failedFuture(Throwable)} + * + * @param The return type + */ + @FunctionalInterface private interface CheckedInvocation { - CompletableFuture call() throws FgaInvalidParameterException, ApiException; + R call() throws Throwable; } private CompletableFuture call(CheckedInvocation action) { try { - return action.call(); + return CompletableFuture.completedFuture(action.call()); } catch (CompletionException completionException) { return CompletableFuture.failedFuture(completionException.getCause()); - } catch (Exception exception) { - return CompletableFuture.failedFuture(exception); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); } } } diff --git a/src/main/java/dev/openfga/sdk/api/configuration/ClientListRelationsOptions.java b/src/main/java/dev/openfga/sdk/api/configuration/ClientListRelationsOptions.java new file mode 100644 index 0000000..344379c --- /dev/null +++ b/src/main/java/dev/openfga/sdk/api/configuration/ClientListRelationsOptions.java @@ -0,0 +1,42 @@ +/* + * OpenFGA + * A high performance and flexible authorization/permission engine built for developers and inspired by Google Zanzibar. + * + * The version of the OpenAPI document: 0.1 + * Contact: community@openfga.dev + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +package dev.openfga.sdk.api.configuration; + +public class ClientListRelationsOptions { + private Integer maxParallelRequests; + private String authorizationModelId; + + public ClientListRelationsOptions maxParallelRequests(Integer maxParallelRequests) { + this.maxParallelRequests = maxParallelRequests; + return this; + } + + public Integer getMaxParallelRequests() { + return maxParallelRequests; + } + + public ClientListRelationsOptions authorizationModelId(String authorizationModelId) { + this.authorizationModelId = authorizationModelId; + return this; + } + + public String getAuthorizationModelId() { + return authorizationModelId; + } + + public ClientBatchCheckOptions asClientBatchCheckOptions() { + return new ClientBatchCheckOptions() + .authorizationModelId(authorizationModelId) + .maxParallelRequests(maxParallelRequests); + } +} diff --git a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java index f73f9f6..f766c1d 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -1671,6 +1671,204 @@ public void listObjects_500() throws Exception { assertEquals( "{\"code\":\"internal_error\",\"message\":\"Internal Server Error\"}", exception.getResponseData()); } + /** + * Check whether a user is authorized to access an object. + */ + @Test + public void listRelations() throws Exception { + // Given + String postUrl = String.format("https://localhost/stores/%s/check", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"object\":\"%s\",\"relation\":\"%s\",\"user\":\"%s\"},\"contextual_tuples\":null,\"authorization_model_id\":\"01G5JAVJ41T49E9TT3SKVS7X1J\",\"trace\":null}", + DEFAULT_OBJECT, DEFAULT_RELATION, DEFAULT_USER); + mockHttpClient.onPost(postUrl).withBody(is(expectedBody)).doReturn(200, "{\"allowed\":true}"); + ClientListRelationsRequest request = new ClientListRelationsRequest() + .relations(List.of(DEFAULT_RELATION)) + .user(DEFAULT_USER) + ._object(DEFAULT_OBJECT); + ClientListRelationsOptions options = + new ClientListRelationsOptions().authorizationModelId(DEFAULT_AUTH_MODEL_ID); + + // When + ClientListRelationsResponse response = + fga.listRelations(request, options).get(); + + // Then + mockHttpClient.verify().post(postUrl).withBody(is(expectedBody)).called(1); + assertNotNull(response); + assertNotNull(response.getRelations()); + assertEquals(1, response.getRelations().size()); + assertEquals(DEFAULT_RELATION, response.getRelations().get(0)); + } + + @Test + public void listRelations_deny() throws Exception { + // Given + String postUrl = String.format("https://localhost/stores/%s/check", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"object\":\"%s\",\"relation\":\"%s\",\"user\":\"%s\"},\"contextual_tuples\":null,\"authorization_model_id\":\"01G5JAVJ41T49E9TT3SKVS7X1J\",\"trace\":null}", + DEFAULT_OBJECT, "owner", DEFAULT_USER); + mockHttpClient.onPost(postUrl).withBody(is(expectedBody)).doReturn(200, "{\"allowed\":false}"); + ClientListRelationsRequest request = new ClientListRelationsRequest() + .relations(List.of("owner")) + ._object(DEFAULT_OBJECT) + .user(DEFAULT_USER); + ClientListRelationsOptions options = + new ClientListRelationsOptions().authorizationModelId(DEFAULT_AUTH_MODEL_ID); + + // When + ClientListRelationsResponse response = + fga.listRelations(request, options).get(); + + // Then + mockHttpClient.verify().post(postUrl).withBody(is(expectedBody)).called(1); + assertNotNull(response); + assertNotNull(response.getRelations()); + assertTrue(response.getRelations().isEmpty()); + } + + @Test + public void listRelations_storeIdRequired() { + // Given + clientConfiguration.storeId(null); + ClientListRelationsRequest request = new ClientListRelationsRequest() + .user(DEFAULT_USER) + .relations(List.of(DEFAULT_RELATION)) + ._object(DEFAULT_OBJECT); + + // When + var exception = assertThrows( + FgaInvalidParameterException.class, () -> fga.listRelations(request, new ClientListRelationsOptions()) + .get()); + + // Then + assertEquals( + "Required parameter storeId was invalid when calling ClientConfiguration.", exception.getMessage()); + } + + @Test + public void listRelations_nonNullRelationsRequired() { + // Given + ClientListRelationsRequest request = new ClientListRelationsRequest() + .user(DEFAULT_USER) + .relations(null) // Should fail + ._object(DEFAULT_OBJECT); + + // When + var exception = assertThrows( + FgaInvalidParameterException.class, () -> fga.listRelations(request, new ClientListRelationsOptions()) + .get()); + + // Then + assertEquals( + "At least 1 relation to check has to be provided when calling ListRelations", exception.getMessage()); + } + + @Test + public void listRelations_atLeastOneRelationRequired() { + // Given + ClientListRelationsRequest request = new ClientListRelationsRequest() + .user(DEFAULT_USER) + .relations(List.of()) // Should fail + ._object(DEFAULT_OBJECT); + + // When + var exception = assertThrows( + FgaInvalidParameterException.class, () -> fga.listRelations(request, new ClientListRelationsOptions()) + .get()); + + // Then + assertEquals( + "At least 1 relation to check has to be provided when calling ListRelations", exception.getMessage()); + } + + @Test + public void listRelations_400() throws Exception { + // Given + String postUrl = String.format("https://localhost/stores/%s/check", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"object\":\"%s\",\"relation\":\"%s\",\"user\":\"%s\"},\"contextual_tuples\":null,\"authorization_model_id\":\"01G5JAVJ41T49E9TT3SKVS7X1J\",\"trace\":null}", + DEFAULT_OBJECT, DEFAULT_RELATION, DEFAULT_USER); + mockHttpClient + .onPost(postUrl) + .withBody(is(expectedBody)) + .doReturn(400, "{\"code\":\"validation_error\",\"message\":\"Generic validation error\"}"); + ClientListRelationsRequest request = new ClientListRelationsRequest() + .user(DEFAULT_USER) + .relations(List.of(DEFAULT_RELATION)) + ._object(DEFAULT_OBJECT); + + // When + var execException = assertThrows( + ExecutionException.class, () -> fga.listRelations(request, new ClientListRelationsOptions()) + .get()); + + // Then + mockHttpClient.verify().post(postUrl).withBody(is(expectedBody)).called(1); + var exception = assertInstanceOf(FgaApiValidationError.class, execException.getCause()); + assertEquals(400, exception.getStatusCode()); + assertEquals( + "{\"code\":\"validation_error\",\"message\":\"Generic validation error\"}", + exception.getResponseData()); + } + + @Test + public void listRelations_404() throws Exception { + // Given + String postUrl = String.format("https://localhost/stores/%s/check", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"object\":\"%s\",\"relation\":\"%s\",\"user\":\"%s\"},\"contextual_tuples\":null,\"authorization_model_id\":\"01G5JAVJ41T49E9TT3SKVS7X1J\",\"trace\":null}", + DEFAULT_OBJECT, DEFAULT_RELATION, DEFAULT_USER); + mockHttpClient + .onPost(postUrl) + .withBody(is(expectedBody)) + .doReturn(404, "{\"code\":\"undefined_endpoint\",\"message\":\"Endpoint not enabled\"}"); + ClientListRelationsRequest request = new ClientListRelationsRequest() + .user(DEFAULT_USER) + .relations(List.of(DEFAULT_RELATION)) + ._object(DEFAULT_OBJECT); + + // When + var execException = assertThrows( + ExecutionException.class, () -> fga.listRelations(request, new ClientListRelationsOptions()) + .get()); + + // Then + mockHttpClient.verify().post(postUrl).withBody(is(expectedBody)).called(1); + var exception = assertInstanceOf(FgaApiNotFoundError.class, execException.getCause()); + assertEquals(404, exception.getStatusCode()); + assertEquals( + "{\"code\":\"undefined_endpoint\",\"message\":\"Endpoint not enabled\"}", exception.getResponseData()); + } + + @Test + public void listRelations_500() throws Exception { + // Given + String postUrl = String.format("https://localhost/stores/%s/check", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"tuple_key\":{\"object\":\"%s\",\"relation\":\"%s\",\"user\":\"%s\"},\"contextual_tuples\":null,\"authorization_model_id\":\"01G5JAVJ41T49E9TT3SKVS7X1J\",\"trace\":null}", + DEFAULT_OBJECT, DEFAULT_RELATION, DEFAULT_USER); + mockHttpClient + .onPost(postUrl) + .withBody(is(expectedBody)) + .doReturn(500, "{\"code\":\"internal_error\",\"message\":\"Internal Server Error\"}"); + ClientListRelationsRequest request = new ClientListRelationsRequest() + .user(DEFAULT_USER) + .relations(List.of(DEFAULT_RELATION)) + ._object(DEFAULT_OBJECT); + + // When + var execException = assertThrows( + ExecutionException.class, () -> fga.listRelations(request, new ClientListRelationsOptions()) + .get()); + + // Then + mockHttpClient.verify().post(postUrl).withBody(is(expectedBody)).called(1 + DEFAULT_MAX_RETRIES); + var exception = assertInstanceOf(FgaApiInternalError.class, execException.getCause()); + assertEquals(500, exception.getStatusCode()); + assertEquals( + "{\"code\":\"internal_error\",\"message\":\"Internal Server Error\"}", exception.getResponseData()); + } /** * Read assertions for an authorization model ID.