> 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);
+ }
+}
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..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,10 +12,13 @@
package dev.openfga.sdk.api.client;
+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;
@@ -55,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/ClientListRelationsResponse.java b/src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java
new file mode 100644
index 0000000..4d4d0e6
--- /dev/null
+++ b/src/main/java/dev/openfga/sdk/api/client/ClientListRelationsResponse.java
@@ -0,0 +1,45 @@
+/*
+ * 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;
+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);
+ }
+}
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/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 9941726..b403378 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.*;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
public class OpenFgaClient {
private final ApiClient apiClient;
@@ -273,19 +277,20 @@ public CompletableFuture write(ClientWriteRequest request,
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());
@@ -297,6 +302,48 @@ 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 == 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
*
@@ -307,7 +354,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);
@@ -326,7 +375,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);
@@ -366,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())) {
@@ -383,7 +439,35 @@ public CompletableFuture check(ClientCheckRequest request,
*
* @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)
@@ -461,9 +545,26 @@ 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, 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 *
@@ -540,15 +641,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();
- } 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/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);
+ }
+}
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/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java b/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java
index cd60f92..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,6 +14,8 @@
public class ClientWriteOptions {
private String authorizationModelId;
+ private Boolean disableTransactions = false;
+ 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 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/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/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));
}
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..8afcd53 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,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;
@@ -47,13 +51,13 @@ 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);
@@ -66,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);
@@ -953,7 +957,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);
@@ -1086,6 +1090,167 @@ 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().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
@@ -1149,11 +1314,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);
@@ -1171,11 +1340,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);
@@ -1192,11 +1365,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);
@@ -1214,13 +1391,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
@@ -1309,6 +1492,146 @@ 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
+ 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.
@@ -1529,6 +1852,205 @@ public void listObjects_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 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/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);