From 31870f6de06c3e8c03449117abcf5beadc79d9b8 Mon Sep 17 00:00:00 2001 From: "Justin \"J.R.\" Hill" Date: Fri, 3 Nov 2023 17:48:18 -0700 Subject: [PATCH 01/15] feat(client): implement batchCheck --- .../openfga/sdk/api/client/OpenFgaClient.java | 28 ++++++++++++- .../ClientBatchCheckOptions.java | 40 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/main/java/dev/openfga/sdk/api/configuration/ClientBatchCheckOptions.java 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 9941726..3635e88 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -18,8 +18,12 @@ import dev.openfga.sdk.api.configuration.*; import dev.openfga.sdk.api.model.*; import dev.openfga.sdk.errors.*; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executors; +import java.util.function.Consumer; public class OpenFgaClient { private final ApiClient apiClient; @@ -383,7 +387,29 @@ public CompletableFuture check(ClientCheckRequest request, * * @throws FgaInvalidParameterException When the Store ID is null, empty, or whitespace */ - // TODO + public CompletableFuture> batchCheck( + List requests, ClientBatchCheckOptions options) { + int maxParallelRequests = options.getMaxParallelRequests() != null + ? options.getMaxParallelRequests() + : DEFAULT_MAX_METHOD_PARALLEL_REQS; + var executor = Executors.newWorkStealingPool(maxParallelRequests); + + var responses = new ConcurrentLinkedQueue(); + + final var clientCheckOptions = options.asClientCheckOptions(); + + Consumer singleClientCheckRequest = + request -> call(() -> this.check(request, clientCheckOptions)).thenApply(responses::add); + + requests.forEach(request -> executor.execute(() -> singleClientCheckRequest.accept(request))); + + try { + executor.wait(); + return CompletableFuture.completedFuture(new ArrayList<>(responses)); + } catch (InterruptedException e) { + return CompletableFuture.failedFuture(e); + } + } /** * Expand - Expands the relationships in userset tree format (evaluates) diff --git a/src/main/java/dev/openfga/sdk/api/configuration/ClientBatchCheckOptions.java b/src/main/java/dev/openfga/sdk/api/configuration/ClientBatchCheckOptions.java new file mode 100644 index 0000000..abc4a80 --- /dev/null +++ b/src/main/java/dev/openfga/sdk/api/configuration/ClientBatchCheckOptions.java @@ -0,0 +1,40 @@ +/* + * 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 ClientBatchCheckOptions { + private Integer maxParallelRequests; + private String authorizationModelId; + + public ClientBatchCheckOptions maxParallelRequests(Integer maxParallelRequests) { + this.maxParallelRequests = maxParallelRequests; + return this; + } + + public Integer getMaxParallelRequests() { + return maxParallelRequests; + } + + public ClientBatchCheckOptions authorizationModelId(String authorizationModelId) { + this.authorizationModelId = authorizationModelId; + return this; + } + + public String getAuthorizationModelId() { + return authorizationModelId; + } + + public ClientCheckOptions asClientCheckOptions() { + return new ClientCheckOptions().authorizationModelId(authorizationModelId); + } +} From a951dd2d758b7992921b5bc8a8821537dcc58039 Mon Sep 17 00:00:00 2001 From: "Justin \"J.R.\" Hill" Date: Tue, 7 Nov 2023 17:25:24 -0800 Subject: [PATCH 02/15] test: add tests on batchCheck() --- .../api/client/ClientBatchCheckResponse.java | 80 +++++++++++++++++++ .../openfga/sdk/api/client/OpenFgaClient.java | 29 ++++--- .../sdk/api/client/OpenFgaClientTest.java | 53 ++++++++++++ 3 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java diff --git a/src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java b/src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java new file mode 100644 index 0000000..4dda625 --- /dev/null +++ b/src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java @@ -0,0 +1,80 @@ +/* + * 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.client; + +import dev.openfga.sdk.api.model.CheckResponse; +import java.util.List; +import java.util.Map; + +public class ClientBatchCheckResponse extends CheckResponse { + private final ClientCheckRequest request; + private final Throwable throwable; + private final int statusCode; + private final Map> headers; + private final String rawResponse; + + public ClientBatchCheckResponse( + ClientCheckRequest request, ClientCheckResponse clientCheckResponse, Throwable throwable) { + this.request = request; + this.throwable = throwable; + + this.statusCode = clientCheckResponse.getStatusCode(); + this.headers = clientCheckResponse.getHeaders(); + this.rawResponse = clientCheckResponse.getRawResponse(); + this.setAllowed(clientCheckResponse.getAllowed()); + this.setResolution(clientCheckResponse.getResolution()); + } + + public ClientCheckRequest getRequest() { + return request; + } + + /** + * Returns the result of the check. + *

+ * If the HTTP request was unsuccessful, this result will be null. If this is the case, you can examine the + * original request with {@link ClientBatchCheckResponse#getRequest()} and the exception with + * {@link ClientBatchCheckResponse#getThrowable()}. + * + * @return the check result. Is null if the HTTP request was unsuccessful. + */ + @Override + public Boolean getAllowed() { + return super.getAllowed(); + } + + /** + * Returns the caught exception if the HTTP request was unsuccessful. + *

+ * If the HTTP request was unsuccessful, this result will be null. If this is the case, you can examine the + * original request with {@link ClientBatchCheckResponse#getRequest()} and the exception with + * {@link ClientBatchCheckResponse#getThrowable()}. + * + * @return the caught exception. Is null if the HTTP request was successful. + */ + public Throwable getThrowable() { + return throwable; + } + + public int getStatusCode() { + return statusCode; + } + + public Map> getHeaders() { + return headers; + } + + public String getRawResponse() { + return rawResponse; + } +} 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 3635e88..d14be26 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -20,9 +20,7 @@ import dev.openfga.sdk.errors.*; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.Executors; +import java.util.concurrent.*; import java.util.function.Consumer; public class OpenFgaClient { @@ -387,26 +385,37 @@ public CompletableFuture check(ClientCheckRequest request, * * @throws FgaInvalidParameterException When the Store ID is null, empty, or whitespace */ - public CompletableFuture> batchCheck( - List requests, ClientBatchCheckOptions options) { + public CompletableFuture> batchCheck( + List requests, ClientBatchCheckOptions options) throws FgaInvalidParameterException { + configuration.assertValid(); + configuration.assertValidStoreId(); + int maxParallelRequests = options.getMaxParallelRequests() != null ? options.getMaxParallelRequests() : DEFAULT_MAX_METHOD_PARALLEL_REQS; var executor = Executors.newWorkStealingPool(maxParallelRequests); + var latch = new CountDownLatch(requests.size()); - var responses = new ConcurrentLinkedQueue(); + var responses = new ConcurrentLinkedQueue(); final var clientCheckOptions = options.asClientCheckOptions(); Consumer singleClientCheckRequest = - request -> call(() -> this.check(request, clientCheckOptions)).thenApply(responses::add); + request -> call(() -> this.check(request, clientCheckOptions)).handle((response, exception) -> { + try { + responses.add(new ClientBatchCheckResponse(request, response, exception)); + } finally { + latch.countDown(); + } + return true; + }); requests.forEach(request -> executor.execute(() -> singleClientCheckRequest.accept(request))); try { - executor.wait(); + latch.await(); return CompletableFuture.completedFuture(new ArrayList<>(responses)); - } catch (InterruptedException e) { + } catch (Exception e) { return CompletableFuture.failedFuture(e); } } @@ -573,7 +582,7 @@ private interface CheckedInvocation { private CompletableFuture call(CheckedInvocation action) { try { return action.call(); - } catch (FgaInvalidParameterException | ApiException exception) { + } catch (Exception exception) { return CompletableFuture.failedFuture(exception); } } 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 d0cf513..5c5c957 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -25,6 +25,8 @@ import java.time.Duration; import java.util.List; import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -1309,6 +1311,57 @@ public void check_500() throws Exception { "{\"code\":\"internal_error\",\"message\":\"Internal Server Error\"}", exception.getResponseData()); } + /** + * Check whether a user is authorized to access an object. + */ + @Test + public void batchCheck() 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}"); + ClientCheckRequest request = new ClientCheckRequest() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER); + ClientBatchCheckOptions options = new ClientBatchCheckOptions().authorizationModelId(DEFAULT_AUTH_MODEL_ID); + + // When + List response = + fga.batchCheck(List.of(request), options).get(); + + // Then + mockHttpClient.verify().post(postUrl).withBody(is(expectedBody)).called(1); + assertEquals(Boolean.TRUE, response.get(0).getAllowed()); + } + + @Test + public void batchCheck_twentyTimes() 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}"); + List requests = IntStream.range(0, 20) + .mapToObj(ignored -> new ClientCheckRequest() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER)) + .collect(Collectors.toList()); + ClientBatchCheckOptions options = new ClientBatchCheckOptions().authorizationModelId(DEFAULT_AUTH_MODEL_ID); + + // When + List responses = + fga.batchCheck(requests, options).get(); + + // Then + mockHttpClient.verify().post(postUrl).withBody(is(expectedBody)).called(20); + responses.forEach(response -> assertEquals(Boolean.TRUE, response.getAllowed())); + } + /** * Expand all relationships in userset tree format, and following userset rewrite rules. Useful to reason * about and debug a certain relationship. From eda3a3676cf3cd6b8e0b04f70fd9f24968292f5b Mon Sep 17 00:00:00 2001 From: "Justin \"J.R.\" Hill" Date: Thu, 9 Nov 2023 17:00:58 -0800 Subject: [PATCH 03/15] test: add more tests on batchCheck() --- .../api/client/ClientBatchCheckResponse.java | 32 +++++-- .../openfga/sdk/api/client/OpenFgaClient.java | 19 ++-- .../sdk/api/client/OpenFgaClientTest.java | 92 ++++++++++++++++++- 3 files changed, 125 insertions(+), 18 deletions(-) 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 4dda625..8b0922f 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java +++ b/src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java @@ -13,13 +13,16 @@ package dev.openfga.sdk.api.client; import dev.openfga.sdk.api.model.CheckResponse; +import dev.openfga.sdk.errors.FgaError; + import java.util.List; import java.util.Map; +import java.util.function.BiFunction; public class ClientBatchCheckResponse extends CheckResponse { private final ClientCheckRequest request; private final Throwable throwable; - private final int statusCode; + private final Integer statusCode; private final Map> headers; private final String rawResponse; @@ -28,11 +31,23 @@ public ClientBatchCheckResponse( this.request = request; this.throwable = throwable; - this.statusCode = clientCheckResponse.getStatusCode(); - this.headers = clientCheckResponse.getHeaders(); - this.rawResponse = clientCheckResponse.getRawResponse(); - this.setAllowed(clientCheckResponse.getAllowed()); - this.setResolution(clientCheckResponse.getResolution()); + if (clientCheckResponse != null) { + this.statusCode = clientCheckResponse.getStatusCode(); + this.headers = clientCheckResponse.getHeaders(); + this.rawResponse = clientCheckResponse.getRawResponse(); + this.setAllowed(clientCheckResponse.getAllowed()); + this.setResolution(clientCheckResponse.getResolution()); + } else if (throwable instanceof FgaError) { + FgaError error = (FgaError) throwable; + this.statusCode = error.getStatusCode(); + this.headers = error.getResponseHeaders().map(); + this.rawResponse = error.getResponseData(); + } else { + // Should be unreachable, but required for type completion + this.statusCode = null; + this.headers = null; + this.rawResponse = null; + } } public ClientCheckRequest getRequest() { @@ -77,4 +92,9 @@ public Map> getHeaders() { public String getRawResponse() { return rawResponse; } + + 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/OpenFgaClient.java b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java index d14be26..2bcbcfb 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -393,7 +393,7 @@ public CompletableFuture> batchCheck( int maxParallelRequests = options.getMaxParallelRequests() != null ? options.getMaxParallelRequests() : DEFAULT_MAX_METHOD_PARALLEL_REQS; - var executor = Executors.newWorkStealingPool(maxParallelRequests); + var executor = Executors.newScheduledThreadPool(maxParallelRequests); var latch = new CountDownLatch(requests.size()); var responses = new ConcurrentLinkedQueue(); @@ -401,18 +401,13 @@ public CompletableFuture> batchCheck( final var clientCheckOptions = options.asClientCheckOptions(); Consumer singleClientCheckRequest = - request -> call(() -> this.check(request, clientCheckOptions)).handle((response, exception) -> { - try { - responses.add(new ClientBatchCheckResponse(request, response, exception)); - } finally { - latch.countDown(); - } - return true; - }); - - requests.forEach(request -> executor.execute(() -> singleClientCheckRequest.accept(request))); + request -> call(() -> this.check(request, clientCheckOptions)) + .handleAsync(ClientBatchCheckResponse.asyncHandler(request)) + .thenAccept(responses::add) + .thenRun(latch::countDown); try { + requests.forEach(request -> executor.execute(() -> singleClientCheckRequest.accept(request))); latch.await(); return CompletableFuture.completedFuture(new ArrayList<>(responses)); } catch (Exception e) { @@ -582,6 +577,8 @@ private interface CheckedInvocation { private CompletableFuture call(CheckedInvocation action) { try { return action.call(); + } catch (CompletionException completionException) { + return CompletableFuture.failedFuture(completionException.getCause()); } catch (Exception exception) { return CompletableFuture.failedFuture(exception); } 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 5c5c957..f73f9f6 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -1359,7 +1359,97 @@ public void batchCheck_twentyTimes() throws Exception { // Then mockHttpClient.verify().post(postUrl).withBody(is(expectedBody)).called(20); - responses.forEach(response -> assertEquals(Boolean.TRUE, response.getAllowed())); + } + + @Test + public void batchCheck_storeIdRequired() { + // Given + clientConfiguration.storeId(null); + + // When + var exception = assertThrows(FgaInvalidParameterException.class, () -> fga.batchCheck( + List.of(new ClientCheckRequest()), new ClientBatchCheckOptions()) + .get()); + + // Then + assertEquals( + "Required parameter storeId was invalid when calling ClientConfiguration.", exception.getMessage()); + } + + @Test + public void batchCheck_400() throws Exception { + // Given + String postUrl = String.format("https://localhost/stores/%s/check", DEFAULT_STORE_ID); + mockHttpClient + .onPost(postUrl) + .doReturn(400, "{\"code\":\"validation_error\",\"message\":\"Generic validation error\"}"); + + // When + List response = fga.batchCheck( + List.of(new ClientCheckRequest()), new ClientBatchCheckOptions()) + .join(); + + // Then + mockHttpClient.verify().post(postUrl).called(1); + assertNotNull(response); + assertEquals(1, response.size()); + assertNull(response.get(0).getAllowed()); + Throwable execException = response.get(0).getThrowable(); + var exception = assertInstanceOf(FgaApiValidationError.class, execException.getCause()); + assertEquals(400, exception.getStatusCode()); + assertEquals( + "{\"code\":\"validation_error\",\"message\":\"Generic validation error\"}", + exception.getResponseData()); + } + + @Test + public void batchCheck_404() throws Exception { + // Given + String postUrl = String.format("https://localhost/stores/%s/check", DEFAULT_STORE_ID); + mockHttpClient + .onPost(postUrl) + .doReturn(404, "{\"code\":\"undefined_endpoint\",\"message\":\"Endpoint not enabled\"}"); + + // When + List response = fga.batchCheck( + List.of(new ClientCheckRequest()), new ClientBatchCheckOptions()) + .join(); + + // Then + mockHttpClient.verify().post(postUrl).called(1); + assertNotNull(response); + assertEquals(1, response.size()); + assertNull(response.get(0).getAllowed()); + Throwable execException = response.get(0).getThrowable(); + var exception = assertInstanceOf(FgaApiNotFoundError.class, execException.getCause()); + assertEquals(404, exception.getStatusCode()); + assertEquals( + "{\"code\":\"undefined_endpoint\",\"message\":\"Endpoint not enabled\"}", exception.getResponseData()); + } + + @Test + public void batchCheck_500() throws Exception { + // Given + String postUrl = String.format("https://localhost/stores/%s/check", DEFAULT_STORE_ID); + mockHttpClient + .onPost(postUrl) + .doReturn(500, "{\"code\":\"internal_error\",\"message\":\"Internal Server Error\"}"); + + // When + List response = fga.batchCheck( + List.of(new ClientCheckRequest()), new ClientBatchCheckOptions()) + .join(); + + // Then + mockHttpClient.verify().post(postUrl).called(1 + DEFAULT_MAX_RETRIES); + assertNotNull(response); + assertEquals(1, response.size()); + assertNull(response.get(0).getAllowed()); + Throwable execException = response.get(0).getThrowable(); + var exception = assertInstanceOf(FgaApiInternalError.class, execException.getCause()); + assertEquals(500, exception.getStatusCode()); + assertEquals( + "{\"code\":\"internal_error\",\"message\":\"Internal Server Error\"}", exception.getResponseData()); } /** From 4b404f2dd4be4caa9d7bd26cacf2b0d7644ed126 Mon Sep 17 00:00:00 2001 From: "Justin \"J.R.\" Hill" Date: Mon, 13 Nov 2023 17:07:22 -0800 Subject: [PATCH 04/15] feat(client): implement listRelations --- .../api/client/ClientBatchCheckResponse.java | 1 - .../client/ClientListRelationsResponse.java | 23 +++++++++++++++++++ .../openfga/sdk/api/client/OpenFgaClient.java | 20 ++++++++++++++-- .../errors/FgaInvalidParameterException.java | 4 ++++ 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java 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 8b0922f..2f14a78 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java +++ b/src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java @@ -14,7 +14,6 @@ import dev.openfga.sdk.api.model.CheckResponse; import dev.openfga.sdk.errors.FgaError; - import java.util.List; import java.util.Map; import java.util.function.BiFunction; diff --git a/src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java b/src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java new file mode 100644 index 0000000..6a85969 --- /dev/null +++ b/src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java @@ -0,0 +1,23 @@ +package dev.openfga.sdk.api.client; + +import java.util.List; +import java.util.stream.Collectors; + +public class ClientListRelationsResponse { + private final List relations; + + public ClientListRelationsResponse(List relations) { + this.relations = relations; + } + + public List getRelations() { + return relations; + } + + public static ClientListRelationsResponse fromBatchCheckResponses(List responses) { + return new ClientListRelationsResponse(responses.stream() + .filter(ClientBatchCheckResponse::getAllowed) + .map(batchCheckResponse -> batchCheckResponse.getRequest().getRelation()) + .collect(Collectors.toList())); + } +} 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 2bcbcfb..b1504df 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.concurrent.*; import java.util.function.Consumer; +import java.util.stream.Collectors; public class OpenFgaClient { private final ApiClient apiClient; @@ -491,9 +492,24 @@ public CompletableFuture listObjects( } /* - * ListRelations - List all the relations a user has with an object (evaluates) + * ListRelations - List allowed relations a user has with an object (evaluates) */ - // TODO + public CompletableFuture listRelations( + ClientListRelationsRequest request, ClientBatchCheckOptions options) throws FgaInvalidParameterException { + if (request.getRelations().isEmpty()) { + throw new FgaInvalidParameterException( + "At least 1 relation to check has to be provided when calling ListRelations"); + } + + var batchCheckRequests = request.getRelations().stream() + .map(relation -> new ClientCheckRequest() + .user(request.getUser()) + .relation(relation) + ._object(request.getObject())) + .collect(Collectors.toList()); + + return batchCheck(batchCheckRequests, options).thenApply(ClientListRelationsResponse::fromBatchCheckResponses); + } /* ************ * Assertions * diff --git a/src/main/java/dev/openfga/sdk/errors/FgaInvalidParameterException.java b/src/main/java/dev/openfga/sdk/errors/FgaInvalidParameterException.java index 49ad99d..1819330 100644 --- a/src/main/java/dev/openfga/sdk/errors/FgaInvalidParameterException.java +++ b/src/main/java/dev/openfga/sdk/errors/FgaInvalidParameterException.java @@ -1,6 +1,10 @@ package dev.openfga.sdk.errors; public class FgaInvalidParameterException extends Exception { + public FgaInvalidParameterException(String message) { + super(message); + } + public FgaInvalidParameterException(String paramName, String functionName) { super(message(paramName, functionName)); } From 30e11243cb117ca3325c61943db48d494eb230c4 Mon Sep 17 00:00:00 2001 From: "Justin \"J.R.\" Hill" Date: Tue, 14 Nov 2023 15:33:30 -0800 Subject: [PATCH 05/15] test(client): add tests on listRelations, flesh out logic --- .../api/client/ClientBatchCheckResponse.java | 4 + .../client/ClientListRelationsResponse.java | 18 +- .../openfga/sdk/api/client/OpenFgaClient.java | 37 +++- .../ClientListRelationsOptions.java | 42 ++++ .../sdk/api/client/OpenFgaClientTest.java | 198 ++++++++++++++++++ 5 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 src/main/java/dev/openfga/sdk/api/configuration/ClientListRelationsOptions.java 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. From 3cf7942295305429b4d836be5b74594a331ac641 Mon Sep 17 00:00:00 2001 From: "J.R. Hill" Date: Thu, 16 Nov 2023 17:05:51 -0800 Subject: [PATCH 06/15] feat(client): implement non-transactional writes --- .../sdk/api/client/ClientWriteRequest.java | 8 ++++ .../openfga/sdk/api/client/OpenFgaClient.java | 48 +++++++++++++++++++ .../api/configuration/ClientWriteOptions.java | 20 ++++++++ 3 files changed, 76 insertions(+) diff --git a/src/main/java/dev/openfga/sdk/api/client/ClientWriteRequest.java b/src/main/java/dev/openfga/sdk/api/client/ClientWriteRequest.java index ecbebbc..8dc1f35 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ClientWriteRequest.java +++ b/src/main/java/dev/openfga/sdk/api/client/ClientWriteRequest.java @@ -18,6 +18,10 @@ public class ClientWriteRequest { private List writes; private List deletes; + public static ClientWriteRequest ofWrites(List writes) { + return new ClientWriteRequest().writes(writes); + } + public ClientWriteRequest writes(List writes) { this.writes = writes; return this; @@ -27,6 +31,10 @@ public List getWrites() { return writes; } + public static ClientWriteRequest ofDeletes(List deletes) { + return new ClientWriteRequest().deletes(deletes); + } + public ClientWriteRequest deletes(List deletes) { this.deletes = deletes; return this; 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 d253127..1c1ce95 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -23,6 +23,7 @@ import java.util.concurrent.*; import java.util.function.Consumer; import java.util.stream.Collectors; +import java.util.stream.Stream; public class OpenFgaClient { private final ApiClient apiClient; @@ -276,6 +277,15 @@ public CompletableFuture write(ClientWriteRequest request, configuration.assertValid(); String storeId = configuration.getStoreIdChecked(); + if (options != null && options.enableTransactions()) { + return writeTransactions(storeId, request, options); + } else { + return writeOneTransaction(storeId, request, options); + } + } + + private CompletableFuture writeOneTransaction( + String storeId, ClientWriteRequest request, ClientWriteOptions options) { WriteRequest body = new WriteRequest(); if (request != null) { @@ -300,6 +310,44 @@ public CompletableFuture write(ClientWriteRequest request, return call(() -> api.write(storeId, body)).thenApply(ClientWriteResponse::new); } + private CompletableFuture writeTransactions( + String storeId, ClientWriteRequest request, ClientWriteOptions options) { + + int chunkSize = options.getTransactionChunkSize(); + + var writeTransactions = + chunksOf(chunkSize, request.getWrites()).stream().map(ClientWriteRequest::ofWrites); + var deleteTransactions = + chunksOf(chunkSize, request.getDeletes()).stream().map(ClientWriteRequest::ofDeletes); + + var transactions = Stream.concat(writeTransactions, deleteTransactions).collect(Collectors.toList()); + var futureResponse = this.writeOneTransaction(storeId, transactions.get(0), options); + + for (int i = 1; i < transactions.size(); i++) { + final int index = i; // Must be final in this scope for closure. + + // The resulting completable future of this chain will result in either: + // 1. The first exception thrown in a failed completion. Other thenApply() will not be evaluated. + // 2. The final successful ClientWriteResponse. + futureResponse.thenApply(_response -> this.writeOneTransaction(storeId, transactions.get(index), options)); + } + + return futureResponse; + } + + private List> chunksOf(int chunkSize, List list) { + int nChunks = list.size() / chunkSize; + + int finalEndExclusive = list.size(); + List> chunks = new ArrayList<>(); + + for (int i = 0; i < nChunks; i++) { + List chunk = list.subList(i * chunkSize, Math.min((i + 1) * chunkSize, finalEndExclusive)); + chunks.add(chunk); + } + return chunks; + } + /** * WriteTuples - Utility method to write tuples, wraps Write * diff --git a/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java b/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java index cd60f92..ac2c08c 100644 --- a/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java +++ b/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java @@ -14,6 +14,8 @@ public class ClientWriteOptions { private String authorizationModelId; + private Boolean enableTransactions; + private int transactionChunkSize; public ClientWriteOptions authorizationModelId(String authorizationModelId) { this.authorizationModelId = authorizationModelId; @@ -23,4 +25,22 @@ public ClientWriteOptions authorizationModelId(String authorizationModelId) { public String getAuthorizationModelId() { return authorizationModelId; } + + public ClientWriteOptions enableTransactions(boolean enableTransactions) { + this.enableTransactions = enableTransactions; + return this; + } + + public boolean enableTransactions() { + return enableTransactions != null && enableTransactions; + } + + public ClientWriteOptions transactionChunkSize(int transactionChunkSize) { + this.transactionChunkSize = transactionChunkSize; + return this; + } + + public int getTransactionChunkSize() { + return transactionChunkSize >= 0 ? transactionChunkSize : 1; + } } From 1db23c97dcc58e2c143bceb9c2c664eb1a8e9e61 Mon Sep 17 00:00:00 2001 From: "J.R. Hill" Date: Fri, 17 Nov 2023 13:28:36 -0800 Subject: [PATCH 07/15] refactor: simplify tuple key mapping --- .../sdk/api/client/ClientTupleKey.java | 7 ++++--- .../openfga/sdk/api/client/OpenFgaClient.java | 21 +++++++------------ 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/main/java/dev/openfga/sdk/api/client/ClientTupleKey.java b/src/main/java/dev/openfga/sdk/api/client/ClientTupleKey.java index 7993519..e61711d 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ClientTupleKey.java +++ b/src/main/java/dev/openfga/sdk/api/client/ClientTupleKey.java @@ -16,6 +16,7 @@ import dev.openfga.sdk.api.model.TupleKey; import dev.openfga.sdk.api.model.TupleKeys; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; public class ClientTupleKey { @@ -66,12 +67,12 @@ public TupleKey asTupleKey() { return new TupleKey().user(user).relation(relation)._object(_object); } - public static TupleKeys asTupleKeys(List clientTupleKeys) { + public static Optional asTupleKeys(List clientTupleKeys) { if (clientTupleKeys == null || clientTupleKeys.size() == 0) { - return new TupleKeys(); + return Optional.empty(); } - return new TupleKeys().tupleKeys(asListOfTupleKey(clientTupleKeys)); + return Optional.of(new TupleKeys().tupleKeys(asListOfTupleKey(clientTupleKeys))); } public static ContextualTupleKeys asContextualTupleKeys(List clientTupleKeys) { 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 1c1ce95..4818c35 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -288,17 +288,8 @@ private CompletableFuture writeOneTransaction( String storeId, ClientWriteRequest request, ClientWriteOptions options) { WriteRequest body = new WriteRequest(); - if (request != null) { - TupleKeys writes = ClientTupleKey.asTupleKeys(request.getWrites()); - if (!writes.getTupleKeys().isEmpty()) { - body.writes(writes); - } - - TupleKeys deletes = ClientTupleKey.asTupleKeys(request.getDeletes()); - if (!deletes.getTupleKeys().isEmpty()) { - body.deletes(deletes); - } - } + ClientTupleKey.asTupleKeys(request.getWrites()).ifPresent(body::writes); + ClientTupleKey.asTupleKeys(request.getDeletes()).ifPresent(body::deletes); if (options != null && !isNullOrWhitespace(options.getAuthorizationModelId())) { body.authorizationModelId(options.getAuthorizationModelId()); @@ -358,7 +349,9 @@ public CompletableFuture writeTuples(List t configuration.assertValid(); String storeId = configuration.getStoreIdChecked(); - var request = new WriteRequest().writes(ClientTupleKey.asTupleKeys(tupleKeys)); + var request = new WriteRequest(); + ClientTupleKey.asTupleKeys(tupleKeys).ifPresent(request::writes); + String authorizationModelId = configuration.getAuthorizationModelId(); if (!isNullOrWhitespace(authorizationModelId)) { request.authorizationModelId(authorizationModelId); @@ -377,7 +370,9 @@ public CompletableFuture deleteTuples(List configuration.assertValid(); String storeId = configuration.getStoreIdChecked(); - var request = new WriteRequest().deletes(ClientTupleKey.asTupleKeys(tupleKeys)); + var request = new WriteRequest(); + ClientTupleKey.asTupleKeys(tupleKeys).ifPresent(request::deletes); + String authorizationModelId = configuration.getAuthorizationModelId(); if (!isNullOrWhitespace(authorizationModelId)) { request.authorizationModelId(authorizationModelId); From e33b91d8bcc5fba1e801eeae5370dda5af971207 Mon Sep 17 00:00:00 2001 From: "J.R. Hill" Date: Fri, 17 Nov 2023 16:52:14 -0800 Subject: [PATCH 08/15] test(client): add tests on non-transactional write --- .../openfga/sdk/api/client/OpenFgaClient.java | 2 +- .../sdk/api/client/OpenFgaClientTest.java | 52 +++++++++++++++++-- 2 files changed, 48 insertions(+), 6 deletions(-) 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 4818c35..35f02ec 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -327,7 +327,7 @@ private CompletableFuture writeTransactions( } private List> chunksOf(int chunkSize, List list) { - int nChunks = list.size() / chunkSize; + int nChunks = (int) Math.ceil(list.size() / (double) chunkSize); int finalEndExclusive = list.size(); List> chunks = new ArrayList<>(); 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 f766c1d..75a167d 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -49,13 +49,12 @@ public class OpenFgaClientTest { private OpenFgaClient fga; private ClientConfiguration clientConfiguration; private HttpClientMock mockHttpClient; - private HttpClient.Builder mockHttpClientBuilder; @BeforeEach public void beforeEachTest() throws Exception { mockHttpClient = new HttpClientMock(); - mockHttpClientBuilder = mock(HttpClient.Builder.class); + HttpClient.Builder mockHttpClientBuilder = mock(HttpClient.Builder.class); when(mockHttpClientBuilder.executor(any())).thenReturn(mockHttpClientBuilder); when(mockHttpClientBuilder.build()).thenReturn(mockHttpClient); @@ -955,7 +954,7 @@ public void read_emptyRequestSendsNoTupleKey() throws Exception { ClientReadRequest request = new ClientReadRequest(); // When - ClientReadResponse response = fga.read(request).get(); + fga.read(request).get(); // Then mockHttpClient.verify().post(postUrl).withBody(is(expectedBody)).called(1); @@ -1088,6 +1087,50 @@ public void writeTest_deletes() throws Exception { mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); } + @Test + public void writeTest_transactions() throws Exception { + // Given + String postPath = "https://localhost/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String tupleBody = String.format( + "{\"object\":\"%s\",\"relation\":\"%s\",\"user\":\"%s\"}", + DEFAULT_OBJECT, DEFAULT_RELATION, DEFAULT_USER); + ClientTupleKey tuple = new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER); + String write2Body = String.format( + "{\"writes\":{\"tuple_keys\":[%s,%s]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + tupleBody, tupleBody, DEFAULT_AUTH_MODEL_ID); + String write1Body = String.format( + "{\"writes\":{\"tuple_keys\":[%s]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + tupleBody, DEFAULT_AUTH_MODEL_ID); + String delete2Body = String.format( + "{\"writes\":null,\"deletes\":{\"tuple_keys\":[%s,%s]},\"authorization_model_id\":\"%s\"}", + tupleBody, tupleBody, DEFAULT_AUTH_MODEL_ID); + String delete1Body = String.format( + "{\"writes\":null,\"deletes\":{\"tuple_keys\":[%s]},\"authorization_model_id\":\"%s\"}", + tupleBody, DEFAULT_AUTH_MODEL_ID); + mockHttpClient + .onPost(postPath) + .withBody(isOneOf(write2Body, write1Body, delete2Body, delete1Body)) + .doReturn(200, EMPTY_RESPONSE_BODY); + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(tuple, tuple, tuple, tuple, tuple)) + .deletes(List.of(tuple, tuple, tuple, tuple, tuple)); + ClientWriteOptions options = + new ClientWriteOptions().enableTransactions(true).transactionChunkSize(2); + + // When + var response = fga.write(request, options).get(); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(write2Body)).called(2); + mockHttpClient.verify().post(postPath).withBody(is(write1Body)).called(1); + mockHttpClient.verify().post(postPath).withBody(is(delete2Body)).called(2); + mockHttpClient.verify().post(postPath).withBody(is(delete1Body)).called(1); + assertEquals(200, response.getStatusCode()); + } + @Test public void writeTuplesTest() throws Exception { // Given @@ -1354,8 +1397,7 @@ public void batchCheck_twentyTimes() throws Exception { ClientBatchCheckOptions options = new ClientBatchCheckOptions().authorizationModelId(DEFAULT_AUTH_MODEL_ID); // When - List responses = - fga.batchCheck(requests, options).get(); + fga.batchCheck(requests, options).get(); // Then mockHttpClient.verify().post(postUrl).withBody(is(expectedBody)).called(20); From 4a350afcff6044720241b78e20e2d5306e660621 Mon Sep 17 00:00:00 2001 From: "J.R. Hill" Date: Mon, 20 Nov 2023 15:24:28 -0800 Subject: [PATCH 09/15] test(client): add tests on non-transactional write failure path --- .github/workflows/semgrep.yaml | 4 +- .openapi-generator/FILES | 4 ++ .../client/ClientListRelationsResponse.java | 12 ++++ .../openfga/sdk/api/client/OpenFgaClient.java | 33 +++++----- .../api/configuration/ClientWriteOptions.java | 10 +-- .../sdk/api/client/OpenFgaClientTest.java | 62 ++++++++++++++++++- 6 files changed, 102 insertions(+), 23 deletions(-) diff --git a/.github/workflows/semgrep.yaml b/.github/workflows/semgrep.yaml index 44718e2..92f9ad6 100644 --- a/.github/workflows/semgrep.yaml +++ b/.github/workflows/semgrep.yaml @@ -11,7 +11,9 @@ jobs: image: returntocorp/semgrep if: (github.actor != 'dependabot[bot]' && github.actor != 'snyk-bot') steps: - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.2 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + fetch-depth: 0 - run: semgrep ci env: SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} diff --git a/.openapi-generator/FILES b/.openapi-generator/FILES index 7b9a022..66cb427 100644 --- a/.openapi-generator/FILES +++ b/.openapi-generator/FILES @@ -83,6 +83,7 @@ src/main/java/dev/openfga/sdk/api/auth/OAuth2Client.java src/main/java/dev/openfga/sdk/api/client/ApiClient.java src/main/java/dev/openfga/sdk/api/client/ApiResponse.java src/main/java/dev/openfga/sdk/api/client/ClientAssertion.java +src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java src/main/java/dev/openfga/sdk/api/client/ClientCheckRequest.java src/main/java/dev/openfga/sdk/api/client/ClientCheckResponse.java src/main/java/dev/openfga/sdk/api/client/ClientCreateStoreResponse.java @@ -93,6 +94,7 @@ src/main/java/dev/openfga/sdk/api/client/ClientGetStoreResponse.java src/main/java/dev/openfga/sdk/api/client/ClientListObjectsRequest.java src/main/java/dev/openfga/sdk/api/client/ClientListObjectsResponse.java src/main/java/dev/openfga/sdk/api/client/ClientListRelationsRequest.java +src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java src/main/java/dev/openfga/sdk/api/client/ClientListStoresResponse.java src/main/java/dev/openfga/sdk/api/client/ClientReadAssertionsResponse.java src/main/java/dev/openfga/sdk/api/client/ClientReadAuthorizationModelResponse.java @@ -109,11 +111,13 @@ src/main/java/dev/openfga/sdk/api/client/HttpRequestAttempt.java src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java src/main/java/dev/openfga/sdk/api/configuration/ApiToken.java src/main/java/dev/openfga/sdk/api/configuration/BaseConfiguration.java +src/main/java/dev/openfga/sdk/api/configuration/ClientBatchCheckOptions.java src/main/java/dev/openfga/sdk/api/configuration/ClientCheckOptions.java src/main/java/dev/openfga/sdk/api/configuration/ClientConfiguration.java src/main/java/dev/openfga/sdk/api/configuration/ClientCredentials.java src/main/java/dev/openfga/sdk/api/configuration/ClientExpandOptions.java src/main/java/dev/openfga/sdk/api/configuration/ClientListObjectsOptions.java +src/main/java/dev/openfga/sdk/api/configuration/ClientListRelationsOptions.java src/main/java/dev/openfga/sdk/api/configuration/ClientListStoresOptions.java src/main/java/dev/openfga/sdk/api/configuration/ClientReadAssertionsOptions.java src/main/java/dev/openfga/sdk/api/configuration/ClientReadAuthorizationModelOptions.java 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 8a5b435..4d4d0e6 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java +++ b/src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java @@ -1,3 +1,15 @@ +/* + * 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.client; import java.util.List; 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 35f02ec..9b10e4f 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -277,15 +277,16 @@ public CompletableFuture write(ClientWriteRequest request, configuration.assertValid(); String storeId = configuration.getStoreIdChecked(); - if (options != null && options.enableTransactions()) { + if (options != null && !options.disableTransactions()) { return writeTransactions(storeId, request, options); - } else { - return writeOneTransaction(storeId, request, options); } + + return writeNonTransaction(storeId, request, options); } - private CompletableFuture writeOneTransaction( + private CompletableFuture writeNonTransaction( String storeId, ClientWriteRequest request, ClientWriteOptions options) { + WriteRequest body = new WriteRequest(); ClientTupleKey.asTupleKeys(request.getWrites()).ifPresent(body::writes); @@ -306,37 +307,41 @@ private CompletableFuture writeTransactions( int chunkSize = options.getTransactionChunkSize(); - var writeTransactions = - chunksOf(chunkSize, request.getWrites()).stream().map(ClientWriteRequest::ofWrites); - var deleteTransactions = - chunksOf(chunkSize, request.getDeletes()).stream().map(ClientWriteRequest::ofDeletes); + var writeTransactions = chunksOf(chunkSize, request.getWrites()).map(ClientWriteRequest::ofWrites); + var deleteTransactions = chunksOf(chunkSize, request.getDeletes()).map(ClientWriteRequest::ofDeletes); var transactions = Stream.concat(writeTransactions, deleteTransactions).collect(Collectors.toList()); - var futureResponse = this.writeOneTransaction(storeId, transactions.get(0), options); + var futureResponse = this.writeNonTransaction(storeId, transactions.get(0), options); for (int i = 1; i < transactions.size(); i++) { final int index = i; // Must be final in this scope for closure. // The resulting completable future of this chain will result in either: - // 1. The first exception thrown in a failed completion. Other thenApply() will not be evaluated. + // 1. The first exception thrown in a failed completion. Other thenCompose() will not be evaluated. // 2. The final successful ClientWriteResponse. - futureResponse.thenApply(_response -> this.writeOneTransaction(storeId, transactions.get(index), options)); + futureResponse = futureResponse.thenCompose( + _response -> this.writeNonTransaction(storeId, transactions.get(index), options)); } return futureResponse; } - private List> chunksOf(int chunkSize, List list) { + private Stream> chunksOf(int chunkSize, List list) { + if (list == null || list.isEmpty()) { + return Stream.empty(); + } + int nChunks = (int) Math.ceil(list.size() / (double) chunkSize); int finalEndExclusive = list.size(); - List> chunks = new ArrayList<>(); + Stream.Builder> chunks = Stream.builder(); for (int i = 0; i < nChunks; i++) { List chunk = list.subList(i * chunkSize, Math.min((i + 1) * chunkSize, finalEndExclusive)); chunks.add(chunk); } - return chunks; + + return chunks.build(); } /** diff --git a/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java b/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java index ac2c08c..fde80af 100644 --- a/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java +++ b/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java @@ -14,7 +14,7 @@ public class ClientWriteOptions { private String authorizationModelId; - private Boolean enableTransactions; + private Boolean disableTransactions = false; private int transactionChunkSize; public ClientWriteOptions authorizationModelId(String authorizationModelId) { @@ -26,13 +26,13 @@ public String getAuthorizationModelId() { return authorizationModelId; } - public ClientWriteOptions enableTransactions(boolean enableTransactions) { - this.enableTransactions = enableTransactions; + public ClientWriteOptions disableTransactions(boolean disableTransactions) { + this.disableTransactions = disableTransactions; return this; } - public boolean enableTransactions() { - return enableTransactions != null && enableTransactions; + public boolean disableTransactions() { + return disableTransactions != null && disableTransactions; } public ClientWriteOptions transactionChunkSize(int transactionChunkSize) { 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 75a167d..43d3e2c 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -25,8 +25,10 @@ import java.time.Duration; import java.util.List; import java.util.concurrent.ExecutionException; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.IntStream; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -53,8 +55,9 @@ public class OpenFgaClientTest { @BeforeEach public void beforeEachTest() throws Exception { mockHttpClient = new HttpClientMock(); + // mockHttpClient.debugOn(); // Uncomment when debugging HTTP requests. - HttpClient.Builder mockHttpClientBuilder = mock(HttpClient.Builder.class); + var mockHttpClientBuilder = mock(HttpClient.Builder.class); when(mockHttpClientBuilder.executor(any())).thenReturn(mockHttpClientBuilder); when(mockHttpClientBuilder.build()).thenReturn(mockHttpClient); @@ -67,7 +70,7 @@ public void beforeEachTest() throws Exception { .maxRetries(DEFAULT_MAX_RETRIES) .minimumRetryDelay(DEFAULT_RETRY_DELAY); - ApiClient mockApiClient = mock(ApiClient.class); + var mockApiClient = mock(ApiClient.class); when(mockApiClient.getHttpClient()).thenReturn(mockHttpClient); when(mockApiClient.getObjectMapper()).thenReturn(new ObjectMapper()); when(mockApiClient.getHttpClientBuilder()).thenReturn(mockHttpClientBuilder); @@ -1118,7 +1121,7 @@ public void writeTest_transactions() throws Exception { .writes(List.of(tuple, tuple, tuple, tuple, tuple)) .deletes(List.of(tuple, tuple, tuple, tuple, tuple)); ClientWriteOptions options = - new ClientWriteOptions().enableTransactions(true).transactionChunkSize(2); + new ClientWriteOptions().disableTransactions(false).transactionChunkSize(2); // When var response = fga.write(request, options).get(); @@ -1131,6 +1134,58 @@ public void writeTest_transactions() throws Exception { assertEquals(200, response.getStatusCode()); } + @Test + public void writeTest_transactionsWithFailure() throws Exception { + // Given + String postPath = "https://localhost/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String firstUser = "user:first"; + String failedUser = "user:SECOND"; + String skippedUser = "user:third"; + Function writeBody = user -> String.format( + "{\"writes\":{\"tuple_keys\":[{\"object\":\"%s\",\"relation\":\"%s\",\"user\":\"%s\"}]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + DEFAULT_OBJECT, DEFAULT_RELATION, user, DEFAULT_AUTH_MODEL_ID); + mockHttpClient + .onPost(postPath) + .withBody(isOneOf(writeBody.apply(firstUser), writeBody.apply(skippedUser))) + .doReturn(200, EMPTY_RESPONSE_BODY); + mockHttpClient + .onPost(postPath) + .withBody(is(writeBody.apply(failedUser))) + .doReturn(400, "{\"code\":\"validation_error\",\"message\":\"Generic validation error\"}"); + ClientWriteRequest request = new ClientWriteRequest() + .writes(Stream.of(firstUser, failedUser, skippedUser) + .map(user -> new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(user)) + .collect(Collectors.toList())); + ClientWriteOptions options = + new ClientWriteOptions().disableTransactions(false).transactionChunkSize(1); + + // When + var execException = assertThrows( + ExecutionException.class, () -> fga.write(request, options).get()); + + // Then + mockHttpClient + .verify() + .post(postPath) + .withBody(is(writeBody.apply(firstUser))) + .called(1); + mockHttpClient + .verify() + .post(postPath) + .withBody(is(writeBody.apply(failedUser))) + .called(1); + mockHttpClient + .verify() + .post(postPath) + .withBody(is(writeBody.apply(skippedUser))) + .called(0); + var exception = assertInstanceOf(FgaApiValidationError.class, execException.getCause()); + assertEquals(400, exception.getStatusCode()); + } + @Test public void writeTuplesTest() throws Exception { // Given @@ -1713,6 +1768,7 @@ 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. */ From 6cb5468b9f4cbdcf6718dcf2c66203ef2f209456 Mon Sep 17 00:00:00 2001 From: "J.R. Hill" Date: Mon, 20 Nov 2023 17:23:57 -0800 Subject: [PATCH 10/15] docs: Add README info on listRelations, batchCheck, and non-txn write --- README.md | 126 +++++++++++++++++- .../sdk/api/client/ClientCheckRequest.java | 1 + 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d82e380..e9233af 100644 --- a/README.md +++ b/README.md @@ -471,7 +471,30 @@ Convenience `WriteTuples` and `DeleteTuples` methods are also available. The SDK will split the writes into separate requests and send them sequentially to avoid violating rate limits. ```java -// Coming soon +var request = new ClientWriteRequest() + .writes(List.of( + new ClientTupleKey() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("viewer") + ._object("document:roadmap"), + new ClientTupleKey() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("viewer") + ._object("document:budget") + )) + .deletes(List.of( + new ClientTupleKey() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("writer") + ._object("document:roadmap") + )); +var options = new ClientWriteOptions() + // You can rely on the model id set in the configuration or override it for this specific request + .authorizationModelId("01GXSA8YR785C4FYS3C0RTG7B1") + .disableTransactions(true) + .transactionChunkSize(5); // Maximum number of requests to be sent in a transaction in a particular chunk + +var response = fgaClient.write(request, options).get(); ``` #### Relationship Queries @@ -501,7 +524,85 @@ Run a set of [checks](#check). Batch Check will return `allowed: false` if it en If 429s or 5xxs are encountered, the underlying check will retry up to 15 times before giving up. ```java -// Coming soon +var request = List.of( + new ClientCheckRequest() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("viewer") + ._object("document:roadmap") + .contextualTuples(List.of( + new ClientTupleKey() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("editor") + ._object("document:roadmap") + )), + new ClientCheckRequest() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("admin") + ._object("document:roadmap"), + .contextualTuples(List.of( + new ClientTupleKey() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("editor") + ._object("document:roadmap") + )), + new ClientCheckRequest() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("creator") + ._object("document:roadmap"), + new ClientCheckRequest() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("deleter") + ._object("document:roadmap") +); +var options = new ClientBatchCheckOptions() + // You can rely on the model id set in the configuration or override it for this specific request + .authorizationModelId("01GXSA8YR785C4FYS3C0RTG7B1") + .maxParallelRequests(5); // Max number of requests to issue in parallel, defaults to 10 + +var response = fgaClient.batchCheck(request, options).get(); + +/* +response.getResponses() = [{ + allowed: false, + request: { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "viewer", + _object: "document:roadmap", + contextualTuples: [{ + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "editor", + _object: "document:roadmap" + }] + } +}, { + allowed: false, + request: { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + _object: "document:roadmap", + contextualTuples: [{ + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "editor", + _object: "document:roadmap" + }] + } +}, { + allowed: false, + request: { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "creator", + _object: "document:roadmap", + }, + error: +}, { + allowed: true, + request: { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "deleter", + _object: "document:roadmap", + }}, +] +*/ ``` ##### Expand @@ -554,7 +655,26 @@ var response = fgaClient.listObjects(request, options).get(); List the relations a user has on an object. ```java -// Coming soon. +var request = new ClientListRelationsRequest() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + ._object("document:roadmap") + .relations(List.of("can_view", "can_edit", "can_delete", "can_rename")) + .contextualTuples(List.of( + new ClientTupleKey() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("editor") + ._object("document:roadmap") + ) + ); +var options = new ClientListRelationsOptions() + // When unspecified, defaults to 10 + .maxParallelRequests() + // You can rely on the model id set in the configuration or override it for this specific request + .authorizationModelId(DEFAULT_AUTH_MODEL_ID); + +var response = fgaClient.listRelations(request, options).get(); + +// response.getRelations() = ["can_view", "can_edit"] ``` #### Assertions diff --git a/src/main/java/dev/openfga/sdk/api/client/ClientCheckRequest.java b/src/main/java/dev/openfga/sdk/api/client/ClientCheckRequest.java index a8d4005..afc2ffd 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ClientCheckRequest.java +++ b/src/main/java/dev/openfga/sdk/api/client/ClientCheckRequest.java @@ -16,6 +16,7 @@ public class ClientCheckRequest { private String user; private String relation; private String _object; + // TODO: Add "contextual tuples" public ClientCheckRequest _object(String _object) { this._object = _object; From 861b0c53abb36feede6619f0e215d6bb7483df1e Mon Sep 17 00:00:00 2001 From: "J.R. Hill" Date: Mon, 20 Nov 2023 17:33:28 -0800 Subject: [PATCH 11/15] feat(client): add contextual tuples to ClientCheckRequest --- .../openfga/sdk/api/client/ClientCheckRequest.java | 13 ++++++++++++- .../dev/openfga/sdk/api/client/OpenFgaClient.java | 5 +++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/java/dev/openfga/sdk/api/client/ClientCheckRequest.java b/src/main/java/dev/openfga/sdk/api/client/ClientCheckRequest.java index afc2ffd..1ce257c 100644 --- a/src/main/java/dev/openfga/sdk/api/client/ClientCheckRequest.java +++ b/src/main/java/dev/openfga/sdk/api/client/ClientCheckRequest.java @@ -12,11 +12,13 @@ package dev.openfga.sdk.api.client; +import java.util.List; + public class ClientCheckRequest { private String user; private String relation; private String _object; - // TODO: Add "contextual tuples" + private List contextualTuples; public ClientCheckRequest _object(String _object) { this._object = _object; @@ -56,4 +58,13 @@ public ClientCheckRequest user(String user) { public String getUser() { return user; } + + public ClientCheckRequest contextualTuples(List contextualTuples) { + this.contextualTuples = contextualTuples; + return this; + } + + public List getContextualTuples() { + return contextualTuples; + } } 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 9b10e4f..51f8bfc 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -417,6 +417,11 @@ public CompletableFuture check(ClientCheckRequest request, .user(request.getUser()) .relation(request.getRelation()) ._object(request.getObject())); + + var contextualTuples = request.getContextualTuples(); + if (contextualTuples != null && !contextualTuples.isEmpty()) { + body.contextualTuples(ClientTupleKey.asContextualTupleKeys(contextualTuples)); + } } if (options != null && !isNullOrWhitespace(options.getAuthorizationModelId())) { From eb8a9b7e932efb0ae91ccaa0d193d953a42e51d1 Mon Sep 17 00:00:00 2001 From: "J.R. Hill" Date: Tue, 21 Nov 2023 09:30:16 -0800 Subject: [PATCH 12/15] test: add contextualTuples in client check() test --- .../openfga/sdk/api/client/OpenFgaClientTest.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 43d3e2c..0b8827e 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -1314,13 +1314,19 @@ public void check() 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); + "{\"tuple_key\":{\"object\":\"%s\",\"relation\":\"%s\",\"user\":\"%s\"}," + + "\"contextual_tuples\":{\"tuple_keys\":[{\"object\":\"%s\",\"relation\":\"owner\",\"user\":\"%s\"}]}," + + "\"authorization_model_id\":\"01G5JAVJ41T49E9TT3SKVS7X1J\",\"trace\":null}", + DEFAULT_OBJECT, DEFAULT_RELATION, DEFAULT_USER, DEFAULT_OBJECT, DEFAULT_USER); mockHttpClient.onPost(postUrl).withBody(is(expectedBody)).doReturn(200, "{\"allowed\":true}"); ClientCheckRequest request = new ClientCheckRequest() ._object(DEFAULT_OBJECT) .relation(DEFAULT_RELATION) - .user(DEFAULT_USER); + .user(DEFAULT_USER) + .contextualTuples(List.of(new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation("owner") + .user(DEFAULT_USER))); ClientCheckOptions options = new ClientCheckOptions().authorizationModelId(DEFAULT_AUTH_MODEL_ID); // When From af7d739929b14abd20be0cb6cfc5a5a0187aaefc Mon Sep 17 00:00:00 2001 From: "J.R. Hill" Date: Tue, 21 Nov 2023 09:42:38 -0800 Subject: [PATCH 13/15] chore: bump version to 0.2.3 --- CHANGELOG.md | 7 +++++++ README.md | 12 ++++++------ build.gradle | 2 +- publish.gradle | 2 +- .../openfga/sdk/api/configuration/Configuration.java | 4 ++-- .../sdk/api/configuration/ConfigurationTest.java | 2 +- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7c334a..42862e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v0.2.3 + +### [0.2.3](https://github.com/openfga/java-sdk/compare/v0.2.2...v0.2.3) (2023-11-21) + +- feat(client): implement batchCheck, listRelations, and non-transaction write +- fix(client): adds missing "contextual tuples" field to check request + ## v0.2.2 ### [0.2.2](https://github.com/openfga/java-sdk/compare/v0.2.1...v0.2.2) (2023-10-31) diff --git a/README.md b/README.md index e9233af..2ad2a0a 100644 --- a/README.md +++ b/README.md @@ -74,13 +74,13 @@ It can be used with the following: * Gradle (Groovy) ```groovy -implementation 'dev.openfga:openfga-sdk:0.2.2' +implementation 'dev.openfga:openfga-sdk:0.2.3' ``` * Gradle (Kotlin) ```kotlin -implementation("dev.openfga:openfga-sdk:0.2.2") +implementation("dev.openfga:openfga-sdk:0.2.3") ``` * Apache Maven @@ -89,26 +89,26 @@ implementation("dev.openfga:openfga-sdk:0.2.2") dev.openfga openfga-sdk - 0.2.2 + 0.2.3 ``` * Ivy ```xml - + ``` * SBT ```scala -libraryDependencies += "dev.openfga" % "openfga-sdk" % "0.2.2" +libraryDependencies += "dev.openfga" % "openfga-sdk" % "0.2.3" ``` * Leiningen ```edn -[dev.openfga/openfga-sdk "0.2.2"] +[dev.openfga/openfga-sdk "0.2.3"] ``` diff --git a/build.gradle b/build.gradle index e24731c..0ebe58e 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,7 @@ plugins { apply from: 'publish.gradle' group = 'dev.openfga' -version = '0.2.2' +version = '0.2.3' repositories { mavenCentral() diff --git a/publish.gradle b/publish.gradle index a5c39de..ec416a1 100644 --- a/publish.gradle +++ b/publish.gradle @@ -6,7 +6,7 @@ publishing { pom { group = 'dev.openfga' name = 'openfga-sdk' - version = '0.2.2' + version = '0.2.3' description = 'This is an autogenerated Java SDK for OpenFGA. It provides a wrapper around the [OpenFGA API definition](https://openfga.dev/api).' url = 'https://openfga.dev' licenses { diff --git a/src/main/java/dev/openfga/sdk/api/configuration/Configuration.java b/src/main/java/dev/openfga/sdk/api/configuration/Configuration.java index 9c1d27b..1cb7058 100644 --- a/src/main/java/dev/openfga/sdk/api/configuration/Configuration.java +++ b/src/main/java/dev/openfga/sdk/api/configuration/Configuration.java @@ -27,10 +27,10 @@ * Configurations for an api client. */ public class Configuration implements BaseConfiguration { - public static final String VERSION = "0.2.2"; + public static final String VERSION = "0.2.3"; private static final String DEFAULT_API_URL = "http://localhost:8080"; - private static final String DEFAULT_USER_AGENT = "openfga-sdk java/0.2.2"; + private static final String DEFAULT_USER_AGENT = "openfga-sdk java/0.2.3"; private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(10); private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); diff --git a/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java b/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java index 0d93001..bbf09fa 100644 --- a/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java +++ b/src/test/java/dev/openfga/sdk/api/configuration/ConfigurationTest.java @@ -20,7 +20,7 @@ class ConfigurationTest { private static final String DEFAULT_API_URL = "http://localhost:8080"; - private static final String DEFAULT_USER_AGENT = "openfga-sdk java/0.2.2"; + private static final String DEFAULT_USER_AGENT = "openfga-sdk java/0.2.3"; private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(10); private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); From e0edb109f8c92b9a2e41b76ff3390d5105f60d28 Mon Sep 17 00:00:00 2001 From: "J.R. Hill" Date: Tue, 21 Nov 2023 12:36:32 -0800 Subject: [PATCH 14/15] fix: default write to transaction mode --- .../openfga/sdk/api/client/OpenFgaClient.java | 8 +++---- .../sdk/api/client/OpenFgaClientTest.java | 24 ++++++++++++++----- 2 files changed, 22 insertions(+), 10 deletions(-) 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 51f8bfc..b403378 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -277,11 +277,11 @@ public CompletableFuture write(ClientWriteRequest request, configuration.assertValid(); String storeId = configuration.getStoreIdChecked(); - if (options != null && !options.disableTransactions()) { - return writeTransactions(storeId, request, options); + if (options != null && options.disableTransactions()) { + return writeNonTransaction(storeId, request, options); } - return writeNonTransaction(storeId, request, options); + return writeTransactions(storeId, request, options); } private CompletableFuture writeNonTransaction( @@ -305,7 +305,7 @@ private CompletableFuture writeNonTransaction( private CompletableFuture writeTransactions( String storeId, ClientWriteRequest request, ClientWriteOptions options) { - int chunkSize = options.getTransactionChunkSize(); + int chunkSize = options == null ? DEFAULT_MAX_METHOD_PARALLEL_REQS : options.getTransactionChunkSize(); var writeTransactions = chunksOf(chunkSize, request.getWrites()).map(ClientWriteRequest::ofWrites); var deleteTransactions = chunksOf(chunkSize, request.getDeletes()).map(ClientWriteRequest::ofDeletes); 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 0b8827e..d5c1414 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -1249,11 +1249,15 @@ public void write_400() throws Exception { mockHttpClient .onPost(postUrl) .doReturn(400, "{\"code\":\"validation_error\",\"message\":\"Generic validation error\"}"); + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))); // When ExecutionException execException = - assertThrows(ExecutionException.class, () -> fga.write(new ClientWriteRequest()) - .get()); + assertThrows(ExecutionException.class, () -> fga.write(request).get()); // Then mockHttpClient.verify().post(postUrl).called(1); @@ -1271,11 +1275,15 @@ public void write_404() throws Exception { mockHttpClient .onPost(postUrl) .doReturn(404, "{\"code\":\"undefined_endpoint\",\"message\":\"Endpoint not enabled\"}"); + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))); // When ExecutionException execException = - assertThrows(ExecutionException.class, () -> fga.write(new ClientWriteRequest()) - .get()); + assertThrows(ExecutionException.class, () -> fga.write(request).get()); // Then mockHttpClient.verify().post(postUrl).called(1); @@ -1292,11 +1300,15 @@ public void write_500() throws Exception { mockHttpClient .onPost(postUrl) .doReturn(500, "{\"code\":\"internal_error\",\"message\":\"Internal Server Error\"}"); + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))); // When ExecutionException execException = - assertThrows(ExecutionException.class, () -> fga.write(new ClientWriteRequest()) - .get()); + assertThrows(ExecutionException.class, () -> fga.write(request).get()); // Then mockHttpClient.verify().post(postUrl).called(1 + DEFAULT_MAX_RETRIES); From 1495f145f8d5a29131088c07352323dfa7eed440 Mon Sep 17 00:00:00 2001 From: "J.R. Hill" Date: Wed, 22 Nov 2023 13:16:02 -0800 Subject: [PATCH 15/15] test: add explicit non-transaction write tests --- .../sdk/api/client/OpenFgaClientTest.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) 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 d5c1414..8afcd53 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -1186,6 +1186,71 @@ public void writeTest_transactionsWithFailure() throws Exception { assertEquals(400, exception.getStatusCode()); } + @Test + public void writeTest_nonTransaction() throws Exception { + // Given + String postPath = "https://localhost/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String tupleBody = String.format( + "{\"object\":\"%s\",\"relation\":\"%s\",\"user\":\"%s\"}", + DEFAULT_OBJECT, DEFAULT_RELATION, DEFAULT_USER); + String expectedBody = String.format( + "{\"writes\":{\"tuple_keys\":[%s,%s,%s]},\"deletes\":{\"tuple_keys\":[%s,%s,%s]},\"authorization_model_id\":\"%s\"}", + tupleBody, tupleBody, tupleBody, tupleBody, tupleBody, tupleBody, DEFAULT_AUTH_MODEL_ID); + mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); + ClientTupleKey tuple = new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER); + ClientWriteRequest request = + new ClientWriteRequest().writes(List.of(tuple, tuple, tuple)).deletes(List.of(tuple, tuple, tuple)); + + // We expect transactionChunkSize will be ignored, and exactly one request will be sent. + ClientWriteOptions options = + new ClientWriteOptions().disableTransactions(true).transactionChunkSize(1); + + // When + var response = fga.write(request, options).get(); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); + assertEquals(200, response.getStatusCode()); + } + + @Test + public void writeTest_nonTransactionsWithFailure() throws Exception { + // Given + String postPath = "https://localhost/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String tupleBody = String.format( + "{\"object\":\"%s\",\"relation\":\"%s\",\"user\":\"%s\"}", + DEFAULT_OBJECT, DEFAULT_RELATION, DEFAULT_USER); + String expectedBody = String.format( + "{\"writes\":{\"tuple_keys\":[%s,%s,%s]},\"deletes\":{\"tuple_keys\":[%s,%s,%s]},\"authorization_model_id\":\"%s\"}", + tupleBody, tupleBody, tupleBody, tupleBody, tupleBody, tupleBody, DEFAULT_AUTH_MODEL_ID); + mockHttpClient + .onPost(postPath) + .withBody(is(expectedBody)) + .doReturn(400, "{\"code\":\"validation_error\",\"message\":\"Generic validation error\"}"); + ClientTupleKey tuple = new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER); + ClientWriteRequest request = + new ClientWriteRequest().writes(List.of(tuple, tuple, tuple)).deletes(List.of(tuple, tuple, tuple)); + + // We expect transactionChunkSize will be ignored, and exactly one request will be sent. + ClientWriteOptions options = + new ClientWriteOptions().disableTransactions(true).transactionChunkSize(1); + + // When + var execException = assertThrows( + ExecutionException.class, () -> fga.write(request, options).get()); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); + var exception = assertInstanceOf(FgaApiValidationError.class, execException.getCause()); + assertEquals(400, exception.getStatusCode()); + } + @Test public void writeTuplesTest() throws Exception { // Given