diff --git a/config/clients/java/CHANGELOG.md.mustache b/config/clients/java/CHANGELOG.md.mustache index 210cf087..a449658b 100644 --- a/config/clients/java/CHANGELOG.md.mustache +++ b/config/clients/java/CHANGELOG.md.mustache @@ -1,5 +1,12 @@ # Changelog +## v0.2.3 + +### [0.2.3](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/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://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.2.1...v0.2.2) (2023-10-31) diff --git a/config/clients/java/config.overrides.json b/config/clients/java/config.overrides.json index 56b97c2f..3ec726be 100644 --- a/config/clients/java/config.overrides.json +++ b/config/clients/java/config.overrides.json @@ -3,7 +3,7 @@ "gitRepoId": "java-sdk", "artifactId": "openfga-sdk", "groupId": "dev.openfga", - "packageVersion": "0.2.2", + "packageVersion": "0.2.3", "apiPackage": "dev.openfga.sdk.api", "authPackage": "dev.openfga.sdk.api.auth", "clientPackage": "dev.openfga.sdk.api.client", @@ -47,6 +47,10 @@ "destinationFilename": "src/main/java/dev/openfga/sdk/api/client/ClientAssertion.java", "templateType": "SupportingFiles" }, + "client-ClientBatchCheckResponse.java.mustache" : { + "destinationFilename": "src/main/java/dev/openfga/sdk/api/client/ClientBatchCheckResponse.java", + "templateType": "SupportingFiles" + }, "client-ClientCheckRequest.java.mustache" : { "destinationFilename": "src/main/java/dev/openfga/sdk/api/client/ClientCheckRequest.java", "templateType": "SupportingFiles" @@ -91,6 +95,10 @@ "destinationFilename": "src/main/java/dev/openfga/sdk/api/client/ClientListRelationsRequest.java", "templateType": "SupportingFiles" }, + "client-ClientListRelationsResponse.java.mustache" : { + "destinationFilename": "src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java", + "templateType": "SupportingFiles" + }, "client-ClientReadAssertionsResponse.java.mustache" : { "destinationFilename": "src/main/java/dev/openfga/sdk/api/client/ClientReadAssertionsResponse.java", "templateType": "SupportingFiles" @@ -179,6 +187,10 @@ "destinationFilename": "src/main/java/dev/openfga/sdk/api/configuration/BaseConfiguration.java", "templateType": "SupportingFiles" }, + "config-ClientBatchCheckOptions.java.mustache" : { + "destinationFilename": "src/main/java/dev/openfga/sdk/api/configuration/ClientBatchCheckOptions.java", + "templateType": "SupportingFiles" + }, "config-ClientCheckOptions.java.mustache" : { "destinationFilename": "src/main/java/dev/openfga/sdk/api/configuration/ClientCheckOptions.java", "templateType": "SupportingFiles" @@ -203,6 +215,10 @@ "destinationFilename": "src/main/java/dev/openfga/sdk/api/configuration/ClientListObjectsOptions.java", "templateType": "SupportingFiles" }, + "config-ClientListRelationsOptions.java.mustache" : { + "destinationFilename": "src/main/java/dev/openfga/sdk/api/configuration/ClientListRelationsOptions.java", + "templateType": "SupportingFiles" + }, "config-ClientListStoresOptions.java.mustache" : { "destinationFilename": "src/main/java/dev/openfga/sdk/api/configuration/ClientListStoresOptions.java", "templateType": "SupportingFiles" diff --git a/config/clients/java/template/README_calling_api.mustache b/config/clients/java/template/README_calling_api.mustache index 56a18e5c..67e6624e 100644 --- a/config/clients/java/template/README_calling_api.mustache +++ b/config/clients/java/template/README_calling_api.mustache @@ -265,7 +265,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 @@ -295,7 +318,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 {{defaultMaxRetry}} 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 {{clientMaxMethodParallelRequests}} + +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 @@ -348,7 +449,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 {{clientMaxMethodParallelRequests}} + .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/config/clients/java/template/client-ClientBatchCheckResponse.java.mustache b/config/clients/java/template/client-ClientBatchCheckResponse.java.mustache new file mode 100644 index 00000000..d1b5ac20 --- /dev/null +++ b/config/clients/java/template/client-ClientBatchCheckResponse.java.mustache @@ -0,0 +1,92 @@ +{{>licenseInfo}} +package {{clientPackage}}; + +import {{modelPackage}}.CheckResponse; +import {{errorsPackage}}.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 Integer statusCode; + private final Map> headers; + private final String rawResponse; + + public ClientBatchCheckResponse( + ClientCheckRequest request, ClientCheckResponse clientCheckResponse, Throwable throwable) { + this.request = request; + this.throwable = throwable; + + 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() { + 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; + } + + public String getRelation() { + return request == null ? null : request.getRelation(); + } + + public static BiFunction asyncHandler( + ClientCheckRequest request) { + return (response, throwable) -> new ClientBatchCheckResponse(request, response, throwable); + } +} \ No newline at end of file diff --git a/config/clients/java/template/client-ClientCheckRequest.java.mustache b/config/clients/java/template/client-ClientCheckRequest.java.mustache index bba36f7a..ff2da5b6 100644 --- a/config/clients/java/template/client-ClientCheckRequest.java.mustache +++ b/config/clients/java/template/client-ClientCheckRequest.java.mustache @@ -1,10 +1,13 @@ {{>licenseInfo}} package {{invokerPackage}}; +import java.util.List; + public class ClientCheckRequest { private String user; private String relation; private String _object; + private List contextualTuples; public ClientCheckRequest _object(String _object) { this._object = _object; @@ -44,4 +47,13 @@ public class ClientCheckRequest { public String getUser() { return user; } + + public ClientCheckRequest contextualTuples(List contextualTuples) { + this.contextualTuples = contextualTuples; + return this; + } + + public List getContextualTuples() { + return contextualTuples; + } } diff --git a/config/clients/java/template/client-ClientListRelationsResponse.java.mustache b/config/clients/java/template/client-ClientListRelationsResponse.java.mustache new file mode 100644 index 00000000..cc287e85 --- /dev/null +++ b/config/clients/java/template/client-ClientListRelationsResponse.java.mustache @@ -0,0 +1,34 @@ +{{>licenseInfo}} +package {{clientPackage}}; + +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) + 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(ClientBatchCheckResponse::getRelation) + .collect(Collectors.toList()); + return new ClientListRelationsResponse(relations); + } +} \ No newline at end of file diff --git a/config/clients/java/template/client-ClientTupleKey.java.mustache b/config/clients/java/template/client-ClientTupleKey.java.mustache index 358b2dee..c380b0ed 100644 --- a/config/clients/java/template/client-ClientTupleKey.java.mustache +++ b/config/clients/java/template/client-ClientTupleKey.java.mustache @@ -5,6 +5,7 @@ import {{modelPackage}}.ContextualTupleKeys; import {{modelPackage}}.TupleKey; import {{modelPackage}}.TupleKeys; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; public class ClientTupleKey { @@ -55,12 +56,12 @@ public class ClientTupleKey { 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/config/clients/java/template/client-ClientWriteRequest.java.mustache b/config/clients/java/template/client-ClientWriteRequest.java.mustache index ffedf024..c065d658 100644 --- a/config/clients/java/template/client-ClientWriteRequest.java.mustache +++ b/config/clients/java/template/client-ClientWriteRequest.java.mustache @@ -7,6 +7,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; @@ -16,6 +20,10 @@ public class ClientWriteRequest { 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/config/clients/java/template/client-OpenFgaClient.java.mustache b/config/clients/java/template/client-OpenFgaClient.java.mustache index 218d52fd..2c28bf44 100644 --- a/config/clients/java/template/client-OpenFgaClient.java.mustache +++ b/config/clients/java/template/client-OpenFgaClient.java.mustache @@ -7,8 +7,12 @@ import {{apiPackage}}.*; import {{configPackage}}.*; import {{modelPackage}}.*; import {{errorsPackage}}.*; +import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CompletableFuture; +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; @@ -261,19 +265,20 @@ public class OpenFgaClient { configuration.assertValid(); String storeId = configuration.getStoreIdChecked(); - WriteRequest body = new WriteRequest(); + if (options != null && options.disableTransactions()) { + return writeNonTransaction(storeId, request, options); + } - if (request != null) { - TupleKeys writes = ClientTupleKey.asTupleKeys(request.getWrites()); - if (!writes.getTupleKeys().isEmpty()) { - body.writes(writes); - } + return writeTransactions(storeId, request, options); + } - TupleKeys deletes = ClientTupleKey.asTupleKeys(request.getDeletes()); - if (!deletes.getTupleKeys().isEmpty()) { - body.deletes(deletes); - } - } + private CompletableFuture writeNonTransaction( + String storeId, ClientWriteRequest request, ClientWriteOptions options) { + + WriteRequest body = new WriteRequest(); + + ClientTupleKey.asTupleKeys(request.getWrites()).ifPresent(body::writes); + ClientTupleKey.asTupleKeys(request.getDeletes()).ifPresent(body::deletes); if (options != null && !isNullOrWhitespace(options.getAuthorizationModelId())) { body.authorizationModelId(options.getAuthorizationModelId()); @@ -285,6 +290,48 @@ public class OpenFgaClient { return call(() -> api.write(storeId, body)).thenApply(ClientWriteResponse::new); } + private CompletableFuture writeTransactions( + String storeId, ClientWriteRequest request, ClientWriteOptions options) { + + 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); + + var transactions = Stream.concat(writeTransactions, deleteTransactions).collect(Collectors.toList()); + 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 thenCompose() will not be evaluated. + // 2. The final successful ClientWriteResponse. + futureResponse = futureResponse.thenCompose( + _response -> this.writeNonTransaction(storeId, transactions.get(index), options)); + } + + return futureResponse; + } + + 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(); + 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.build(); + } + /** * WriteTuples - Utility method to write tuples, wraps Write * @@ -295,7 +342,9 @@ public class OpenFgaClient { 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); @@ -314,7 +363,9 @@ public class OpenFgaClient { 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); @@ -354,6 +405,11 @@ public class OpenFgaClient { .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())) { @@ -371,7 +427,35 @@ public class OpenFgaClient { * * @throws FgaInvalidParameterException When the Store ID is null, empty, or whitespace */ - // TODO + 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.newScheduledThreadPool(maxParallelRequests); + var latch = new CountDownLatch(requests.size()); + + var responses = new ConcurrentLinkedQueue(); + + final var clientCheckOptions = options.asClientCheckOptions(); + + Consumer singleClientCheckRequest = + 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) { + return CompletableFuture.failedFuture(e); + } + } /** * Expand - Expands the relationships in userset tree format (evaluates) @@ -451,9 +535,26 @@ public class OpenFgaClient { } /* - * 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, 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"); + } + + var batchCheckRequests = request.getRelations().stream() + .map(relation -> new ClientCheckRequest() + .user(request.getUser()) + .relation(relation) + ._object(request.getObject())) + .collect(Collectors.toList()); + + return batchCheck(batchCheckRequests, options.asClientBatchCheckOptions()) + .thenCompose(responses -> call(() -> ClientListRelationsResponse.fromBatchCheckResponses(responses))); + } /* ************ * Assertions * @@ -530,15 +631,38 @@ public class OpenFgaClient { * @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(); - } catch (FgaInvalidParameterException | ApiException exception) { - return CompletableFuture.failedFuture(exception); + return CompletableFuture.completedFuture(action.call()); + } catch (CompletionException completionException) { + return CompletableFuture.failedFuture(completionException.getCause()); + } catch (Throwable throwable) { + return CompletableFuture.failedFuture(throwable); } } } diff --git a/config/clients/java/template/client-OpenFgaClientTest.java.mustache b/config/clients/java/template/client-OpenFgaClientTest.java.mustache index 785dd37b..e4ab6614 100644 --- a/config/clients/java/template/client-OpenFgaClientTest.java.mustache +++ b/config/clients/java/template/client-OpenFgaClientTest.java.mustache @@ -16,6 +16,10 @@ import java.net.http.HttpClient; 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; @@ -38,13 +42,14 @@ 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(); + // mockHttpClient.debugOn(); // Uncomment when debugging HTTP requests. - mockHttpClientBuilder = mock(HttpClient.Builder.class); + var mockHttpClientBuilder = mock(HttpClient.Builder.class); when(mockHttpClientBuilder.executor(any())).thenReturn(mockHttpClientBuilder); when(mockHttpClientBuilder.build()).thenReturn(mockHttpClient); @@ -57,7 +62,7 @@ public class OpenFgaClientTest { .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); @@ -944,7 +949,7 @@ public class OpenFgaClientTest { 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); @@ -1077,6 +1082,171 @@ public class OpenFgaClientTest { 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().disableTransactions(false).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 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 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 @@ -1140,11 +1310,15 @@ public class OpenFgaClientTest { 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); @@ -1162,11 +1336,15 @@ public class OpenFgaClientTest { 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); @@ -1183,11 +1361,15 @@ public class OpenFgaClientTest { 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); @@ -1205,13 +1387,21 @@ public class OpenFgaClientTest { // 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 @@ -1300,6 +1490,146 @@ public class OpenFgaClientTest { "{\"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 + fga.batchCheck(requests, options).get(); + + // Then + mockHttpClient.verify().post(postUrl).withBody(is(expectedBody)).called(20); + } + + @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()); + } + /** * Expand all relationships in userset tree format, and following userset rewrite rules. Useful to reason * about and debug a certain relationship. @@ -1521,6 +1851,205 @@ public class OpenFgaClientTest { "{\"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. */ diff --git a/config/clients/java/template/config-ClientBatchCheckOptions.java.mustache b/config/clients/java/template/config-ClientBatchCheckOptions.java.mustache new file mode 100644 index 00000000..1a376c1b --- /dev/null +++ b/config/clients/java/template/config-ClientBatchCheckOptions.java.mustache @@ -0,0 +1,29 @@ +{{>licenseInfo}} +package {{configPackage}}; + +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); + } +} diff --git a/config/clients/java/template/config-ClientListRelationsOptions.java.mustache b/config/clients/java/template/config-ClientListRelationsOptions.java.mustache new file mode 100644 index 00000000..60b6d90b --- /dev/null +++ b/config/clients/java/template/config-ClientListRelationsOptions.java.mustache @@ -0,0 +1,31 @@ +{{>licenseInfo}} +package {{configPackage}}; + +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/config/clients/java/template/config-ClientWriteOptions.java.mustache b/config/clients/java/template/config-ClientWriteOptions.java.mustache index c5673e38..612d2208 100644 --- a/config/clients/java/template/config-ClientWriteOptions.java.mustache +++ b/config/clients/java/template/config-ClientWriteOptions.java.mustache @@ -3,6 +3,8 @@ package {{configPackage}}; public class ClientWriteOptions { private String authorizationModelId; + private Boolean disableTransactions = false; + private int transactionChunkSize; public ClientWriteOptions authorizationModelId(String authorizationModelId) { this.authorizationModelId = authorizationModelId; @@ -12,4 +14,22 @@ public class ClientWriteOptions { public String getAuthorizationModelId() { return authorizationModelId; } + + public ClientWriteOptions disableTransactions(boolean disableTransactions) { + this.disableTransactions = disableTransactions; + return this; + } + + public boolean disableTransactions() { + return disableTransactions != null && disableTransactions; + } + + public ClientWriteOptions transactionChunkSize(int transactionChunkSize) { + this.transactionChunkSize = transactionChunkSize; + return this; + } + + public int getTransactionChunkSize() { + return transactionChunkSize >= 0 ? transactionChunkSize : 1; + } } diff --git a/config/clients/java/template/errors-FgaInvalidParameterException.java.mustache b/config/clients/java/template/errors-FgaInvalidParameterException.java.mustache index c9040193..69f08ffe 100644 --- a/config/clients/java/template/errors-FgaInvalidParameterException.java.mustache +++ b/config/clients/java/template/errors-FgaInvalidParameterException.java.mustache @@ -1,6 +1,10 @@ package {{errorsPackage}}; public class FgaInvalidParameterException extends Exception { + public FgaInvalidParameterException(String message) { + super(message); + } + public FgaInvalidParameterException(String paramName, String functionName) { super(message(paramName, functionName)); }