diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index fbd7b9e3d..61ca8d55c 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -175,6 +175,8 @@ jobs: - name: Run All Tests run: |- make test-integration-client-java + env: + OPEN_API_REF: 0ac19aac54f21f3c78970126b84b4c69c6e3b9a2 - name: Check for SDK changes run: | diff --git a/config/clients/java/template/README_calling_api.mustache b/config/clients/java/template/README_calling_api.mustache index d50da74e5..29da94fe7 100644 --- a/config/clients/java/template/README_calling_api.mustache +++ b/config/clients/java/template/README_calling_api.mustache @@ -330,6 +330,76 @@ var options = new ClientWriteOptions() var response = fgaClient.write(request, options).get(); ``` +###### Conflict options for write operations + +Write conflict handling can be controlled using the `onDuplicate` option for writes and the `onMissing` option for deletes. + +> Note: this requires OpenFGA [v1.10.0](https://github.com/openfga/openfga/releases/tag/v1.10.0) or later. + +- `onDuplicate`: Controls behavior when attempting to create a tuple that already exists + - `WriteRequestWrites.OnDuplicateEnum.ERROR` (default): Return an error + - `WriteRequestWrites.OnDuplicateEnum.IGNORE`: Skip the duplicate tuple and continue + +- `onMissing`: Controls behavior when attempting to delete a tuple that doesn't exist + - `WriteRequestDeletes.OnMissingEnum.ERROR` (default): Return an error + - `WriteRequestDeletes.OnMissingEnum.IGNORE`: Skip the missing tuple and continue + +**Using conflict options with the `write()` method:** + +```java +var request = new ClientWriteRequest() + .writes(List.of( + new ClientTupleKey() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("viewer") + ._object("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a") + )) + .deletes(List.of( + new ClientTupleKeyWithoutCondition() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("writer") + ._object("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a") + )); + +var options = new ClientWriteOptions() + .onDuplicate(WriteRequestWrites.OnDuplicateEnum.IGNORE) + .onMissing(WriteRequestDeletes.OnMissingEnum.IGNORE); + +var response = fgaClient.write(request, options).get(); +``` + +**Using conflict options with the `writeTuples()` convenience method:** + +```java +var tuples = List.of( + new ClientTupleKey() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("viewer") + ._object("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a") +); + +var options = new ClientWriteTuplesOptions() + .onDuplicate(WriteRequestWrites.OnDuplicateEnum.IGNORE); + +var response = fgaClient.writeTuples(tuples, options).get(); +``` + +**Using conflict options with the `deleteTuples()` convenience method:** + +```java +var tuples = List.of( + new ClientTupleKeyWithoutCondition() + .user("user:81684243-9356-4421-8fbf-a4f8d36aa31b") + .relation("writer") + ._object("document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a") +); + +var options = new ClientDeleteTuplesOptions() + .onMissing(WriteRequestDeletes.OnMissingEnum.IGNORE); + +var response = fgaClient.deleteTuples(tuples, options).get(); +``` + #### Relationship Queries ##### Check diff --git a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache index 890c28691..4f39aade7 100644 --- a/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache +++ b/config/clients/java/template/src/main/api/client/OpenFgaClient.java.mustache @@ -469,12 +469,14 @@ public class OpenFgaClient { var writeTuples = request.getWrites(); if (writeTuples != null && !writeTuples.isEmpty()) { - body.writes(ClientTupleKey.asWriteRequestWrites(writeTuples)); + var onDuplicate = options != null ? options.getOnDuplicate() : null; + body.writes(ClientTupleKey.asWriteRequestWrites(writeTuples, onDuplicate)); } var deleteTuples = request.getDeletes(); if (deleteTuples != null && !deleteTuples.isEmpty()) { - body.deletes(ClientTupleKeyWithoutCondition.asWriteRequestDeletes(deleteTuples)); + var onMissing = options != null ? options.getOnMissing() : null; + body.deletes(ClientTupleKeyWithoutCondition.asWriteRequestDeletes(deleteTuples, onMissing)); } if (options != null && !isNullOrWhitespace(options.getAuthorizationModelId())) { @@ -677,7 +679,8 @@ public class OpenFgaClient { var body = new WriteRequest(); - body.writes(ClientTupleKey.asWriteRequestWrites(tupleKeys)); + var onDuplicate = options != null ? options.getOnDuplicate() : null; + body.writes(ClientTupleKey.asWriteRequestWrites(tupleKeys, onDuplicate)); String authorizationModelId = configuration.getAuthorizationModelId(); if (!isNullOrWhitespace(authorizationModelId)) { @@ -717,7 +720,8 @@ public class OpenFgaClient { var body = new WriteRequest(); - body.deletes(ClientTupleKeyWithoutCondition.asWriteRequestDeletes(tupleKeys)); + var onMissing = options != null ? options.getOnMissing() : null; + body.deletes(ClientTupleKeyWithoutCondition.asWriteRequestDeletes(tupleKeys, onMissing)); String authorizationModelId = configuration.getAuthorizationModelId(); if (!isNullOrWhitespace(authorizationModelId)) { diff --git a/config/clients/java/template/src/main/api/client/model/ClientTupleKey.java.mustache b/config/clients/java/template/src/main/api/client/model/ClientTupleKey.java.mustache index a62355d34..db21bbb4e 100644 --- a/config/clients/java/template/src/main/api/client/model/ClientTupleKey.java.mustache +++ b/config/clients/java/template/src/main/api/client/model/ClientTupleKey.java.mustache @@ -37,10 +37,19 @@ public class ClientTupleKey extends ClientTupleKeyWithoutCondition { } public static WriteRequestWrites asWriteRequestWrites(Collection tupleKeys) { - return new WriteRequestWrites() + return asWriteRequestWrites(tupleKeys, null); + } + + public static WriteRequestWrites asWriteRequestWrites( + Collection tupleKeys, WriteRequestWrites.OnDuplicateEnum onDuplicate) { + WriteRequestWrites writes = new WriteRequestWrites() .tupleKeys(tupleKeys.stream() .map(ClientTupleKey::asTupleKey) .collect(Collectors.toList())); + if (onDuplicate != null) { + writes.onDuplicate(onDuplicate); + } + return writes; } /* Overrides for correct typing */ diff --git a/config/clients/java/template/src/main/api/client/model/ClientTupleKeyWithoutCondition.java.mustache b/config/clients/java/template/src/main/api/client/model/ClientTupleKeyWithoutCondition.java.mustache index 3e382ea2d..debca745b 100644 --- a/config/clients/java/template/src/main/api/client/model/ClientTupleKeyWithoutCondition.java.mustache +++ b/config/clients/java/template/src/main/api/client/model/ClientTupleKeyWithoutCondition.java.mustache @@ -16,10 +16,19 @@ public class ClientTupleKeyWithoutCondition { } public static WriteRequestDeletes asWriteRequestDeletes(Collection tupleKeys) { - return new WriteRequestDeletes() + return asWriteRequestDeletes(tupleKeys, null); + } + + public static WriteRequestDeletes asWriteRequestDeletes( + Collection tupleKeys, WriteRequestDeletes.OnMissingEnum onMissing) { + WriteRequestDeletes deletes = new WriteRequestDeletes() .tupleKeys(tupleKeys.stream() .map(ClientTupleKeyWithoutCondition::asTupleKeyWithoutCondition) .collect(Collectors.toList())); + if (onMissing != null) { + deletes.onMissing(onMissing); + } + return deletes; } public ClientTupleKeyWithoutCondition _object(String _object) { diff --git a/config/clients/java/template/src/main/api/configuration/ClientDeleteTuplesOptions.java.mustache b/config/clients/java/template/src/main/api/configuration/ClientDeleteTuplesOptions.java.mustache index 55bc17082..72018a7ce 100644 --- a/config/clients/java/template/src/main/api/configuration/ClientDeleteTuplesOptions.java.mustache +++ b/config/clients/java/template/src/main/api/configuration/ClientDeleteTuplesOptions.java.mustache @@ -1,10 +1,12 @@ {{>licenseInfo}} package {{configPackage}}; +import dev.openfga.sdk.api.model.WriteRequestDeletes; import java.util.Map; public class ClientDeleteTuplesOptions implements AdditionalHeadersSupplier { private Map additionalHeaders; + private WriteRequestDeletes.OnMissingEnum onMissing; public ClientDeleteTuplesOptions additionalHeaders(Map additionalHeaders) { this.additionalHeaders = additionalHeaders; @@ -15,4 +17,13 @@ public class ClientDeleteTuplesOptions implements AdditionalHeadersSupplier { public Map getAdditionalHeaders() { return this.additionalHeaders; } + + public ClientDeleteTuplesOptions onMissing(WriteRequestDeletes.OnMissingEnum onMissing) { + this.onMissing = onMissing; + return this; + } + + public WriteRequestDeletes.OnMissingEnum getOnMissing() { + return onMissing; + } } diff --git a/config/clients/java/template/src/main/api/configuration/ClientWriteOptions.java.mustache b/config/clients/java/template/src/main/api/configuration/ClientWriteOptions.java.mustache index fd6a5bac9..122faf605 100644 --- a/config/clients/java/template/src/main/api/configuration/ClientWriteOptions.java.mustache +++ b/config/clients/java/template/src/main/api/configuration/ClientWriteOptions.java.mustache @@ -1,6 +1,8 @@ {{>licenseInfo}} package {{configPackage}}; +import dev.openfga.sdk.api.model.WriteRequestDeletes; +import dev.openfga.sdk.api.model.WriteRequestWrites; import java.util.Map; public class ClientWriteOptions implements AdditionalHeadersSupplier { @@ -8,6 +10,8 @@ public class ClientWriteOptions implements AdditionalHeadersSupplier { private String authorizationModelId; private Boolean disableTransactions = false; private int transactionChunkSize; + private WriteRequestWrites.OnDuplicateEnum onDuplicate; + private WriteRequestDeletes.OnMissingEnum onMissing; public ClientWriteOptions additionalHeaders(Map additionalHeaders) { this.additionalHeaders = additionalHeaders; @@ -45,4 +49,22 @@ public class ClientWriteOptions implements AdditionalHeadersSupplier { public int getTransactionChunkSize() { return transactionChunkSize > 0 ? transactionChunkSize : 1; } + + public ClientWriteOptions onDuplicate(WriteRequestWrites.OnDuplicateEnum onDuplicate) { + this.onDuplicate = onDuplicate; + return this; + } + + public WriteRequestWrites.OnDuplicateEnum getOnDuplicate() { + return onDuplicate; + } + + public ClientWriteOptions onMissing(WriteRequestDeletes.OnMissingEnum onMissing) { + this.onMissing = onMissing; + return this; + } + + public WriteRequestDeletes.OnMissingEnum getOnMissing() { + return onMissing; + } } diff --git a/config/clients/java/template/src/main/api/configuration/ClientWriteTuplesOptions.java.mustache b/config/clients/java/template/src/main/api/configuration/ClientWriteTuplesOptions.java.mustache index 930a9289f..6f20b0cdf 100644 --- a/config/clients/java/template/src/main/api/configuration/ClientWriteTuplesOptions.java.mustache +++ b/config/clients/java/template/src/main/api/configuration/ClientWriteTuplesOptions.java.mustache @@ -1,10 +1,12 @@ {{>licenseInfo}} package {{configPackage}}; +import dev.openfga.sdk.api.model.WriteRequestWrites; import java.util.Map; public class ClientWriteTuplesOptions implements AdditionalHeadersSupplier { private Map additionalHeaders; + private WriteRequestWrites.OnDuplicateEnum onDuplicate; public ClientWriteTuplesOptions additionalHeaders(Map additionalHeaders) { this.additionalHeaders = additionalHeaders; @@ -15,4 +17,13 @@ public class ClientWriteTuplesOptions implements AdditionalHeadersSupplier { public Map getAdditionalHeaders() { return this.additionalHeaders; } + + public ClientWriteTuplesOptions onDuplicate(WriteRequestWrites.OnDuplicateEnum onDuplicate) { + this.onDuplicate = onDuplicate; + return this; + } + + public WriteRequestWrites.OnDuplicateEnum getOnDuplicate() { + return onDuplicate; + } } diff --git a/config/clients/java/template/src/test/api/OpenFgaApiTest.java.mustache b/config/clients/java/template/src/test/api/OpenFgaApiTest.java.mustache index 1e2c5772e..33eb565f7 100644 --- a/config/clients/java/template/src/test/api/OpenFgaApiTest.java.mustache +++ b/config/clients/java/template/src/test/api/OpenFgaApiTest.java.mustache @@ -1111,7 +1111,7 @@ public class OpenFgaApiTest { // Given String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; String expectedBody = String.format( - "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}],\"on_duplicate\":\"error\"},\"deletes\":null,\"authorization_model_id\":\"%s\"}", DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); WriteRequest request = new WriteRequest() @@ -1137,7 +1137,7 @@ public class OpenFgaApiTest { // Given String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; String expectedBody = String.format( - "{\"writes\":null,\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}]},\"authorization_model_id\":\"%s\"}", + "{\"writes\":null,\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}],\"on_missing\":\"error\"},\"authorization_model_id\":\"%s\"}", DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); WriteRequest request = new WriteRequest() @@ -1160,7 +1160,7 @@ public class OpenFgaApiTest { // Given String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; String expectedBody = String.format( - "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":{\"name\":\"conditionName\",\"context\":{\"num\":1,\"str\":\"banana\",\"list\":[],\"obj\":{}}}}]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":{\"name\":\"conditionName\",\"context\":{\"num\":1,\"str\":\"banana\",\"list\":[],\"obj\":{}}}}],\"on_duplicate\":\"error\"},\"deletes\":null,\"authorization_model_id\":\"%s\"}", DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); var context = new LinkedHashMap<>(); @@ -1192,7 +1192,7 @@ public class OpenFgaApiTest { String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; String expectedBody = String.format( - "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":{\"name\":\"conditionName\",\"context\":{\"num\":1,\"str\":\"apple\",\"list\":[2,\"banana\",[],{\"num\":3,\"str\":\"cupcake\",\"list\":null,\"obj\":null}],\"obj\":{\"num\":4,\"str\":\"dolphin\",\"list\":null,\"obj\":null}}}}]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":{\"name\":\"conditionName\",\"context\":{\"num\":1,\"str\":\"apple\",\"list\":[2,\"banana\",[],{\"num\":3,\"str\":\"cupcake\",\"list\":null,\"obj\":null}],\"obj\":{\"num\":4,\"str\":\"dolphin\",\"list\":null,\"obj\":null}}}}],\"on_duplicate\":\"error\"},\"deletes\":null,\"authorization_model_id\":\"%s\"}", DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); @@ -1333,6 +1333,147 @@ public class OpenFgaApiTest { "{\"code\":\"internal_error\",\"message\":\"Internal Server Error\"}", exception.getResponseData()); } + /** + * Test write with onDuplicate option set to IGNORE. + */ + @Test + public void writeTest_writes_withOnDuplicateIgnore() throws Exception { + // Given + String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String expectedBody = String.format( + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}],\"on_duplicate\":\"ignore\"},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); + WriteRequest request = new WriteRequest() + .authorizationModelId(DEFAULT_AUTH_MODEL_ID) + .writes(new WriteRequestWrites() + .tupleKeys(List.of(new TupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))) + .onDuplicate(WriteRequestWrites.OnDuplicateEnum.IGNORE)); + + // When + fga.write(DEFAULT_STORE_ID, request).get(); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); + } + + /** + * Test write with onDuplicate option set to ERROR (default). + */ + @Test + public void writeTest_writes_withOnDuplicateError() throws Exception { + // Given + String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String expectedBody = String.format( + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}],\"on_duplicate\":\"error\"},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); + WriteRequest request = new WriteRequest() + .authorizationModelId(DEFAULT_AUTH_MODEL_ID) + .writes(new WriteRequestWrites() + .tupleKeys(List.of(new TupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))) + .onDuplicate(WriteRequestWrites.OnDuplicateEnum.ERROR)); + + // When + fga.write(DEFAULT_STORE_ID, request).get(); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); + } + + /** + * Test write with onMissing option set to IGNORE. + */ + @Test + public void writeTest_deletes_withOnMissingIgnore() throws Exception { + // Given + String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String expectedBody = String.format( + "{\"writes\":null,\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}],\"on_missing\":\"ignore\"},\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); + WriteRequest request = new WriteRequest() + .authorizationModelId(DEFAULT_AUTH_MODEL_ID) + .deletes(new WriteRequestDeletes() + .tupleKeys(List.of(new TupleKeyWithoutCondition() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))) + .onMissing(WriteRequestDeletes.OnMissingEnum.IGNORE)); + + // When + fga.write(DEFAULT_STORE_ID, request).get(); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); + } + + /** + * Test write with onMissing option set to ERROR (default). + */ + @Test + public void writeTest_deletes_withOnMissingError() throws Exception { + // Given + String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String expectedBody = String.format( + "{\"writes\":null,\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}],\"on_missing\":\"error\"},\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); + WriteRequest request = new WriteRequest() + .authorizationModelId(DEFAULT_AUTH_MODEL_ID) + .deletes(new WriteRequestDeletes() + .tupleKeys(List.of(new TupleKeyWithoutCondition() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))) + .onMissing(WriteRequestDeletes.OnMissingEnum.ERROR)); + + // When + fga.write(DEFAULT_STORE_ID, request).get(); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); + } + + /** + * Test write with both onDuplicate and onMissing options. + */ + @Test + public void writeTest_withBothConflictOptions() throws Exception { + // Given + String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String expectedBody = String.format( + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}],\"on_duplicate\":\"ignore\"},\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"writer\",\"object\":\"%s\"}],\"on_missing\":\"ignore\"},\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_USER, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); + WriteRequest request = new WriteRequest() + .authorizationModelId(DEFAULT_AUTH_MODEL_ID) + .writes(new WriteRequestWrites() + .tupleKeys(List.of(new TupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))) + .onDuplicate(WriteRequestWrites.OnDuplicateEnum.IGNORE)) + .deletes(new WriteRequestDeletes() + .tupleKeys(List.of(new TupleKeyWithoutCondition() + ._object(DEFAULT_OBJECT) + .relation("writer") + .user(DEFAULT_USER))) + .onMissing(WriteRequestDeletes.OnMissingEnum.IGNORE)); + + // When + fga.write(DEFAULT_STORE_ID, request).get(); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); + } + /** * Check whether a user is authorized to access an object. */ diff --git a/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache b/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache index 60bab2597..e4247650d 100644 --- a/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache +++ b/config/clients/java/template/src/test/api/client/OpenFgaClientTest.java.mustache @@ -1154,7 +1154,7 @@ public class OpenFgaClientTest { // Given String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; String expectedBody = String.format( - "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}],\"on_duplicate\":\"error\"},\"deletes\":null,\"authorization_model_id\":\"%s\"}", DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); ClientWriteRequest request = new ClientWriteRequest() @@ -1186,7 +1186,7 @@ public class OpenFgaClientTest { // Given String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; String expectedBody = String.format( - "{\"writes\":null,\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}]},\"authorization_model_id\":\"%s\"}", + "{\"writes\":null,\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}],\"on_missing\":\"error\"},\"authorization_model_id\":\"%s\"}", DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); ClientWriteRequest request = new ClientWriteRequest() @@ -1215,19 +1215,19 @@ public class OpenFgaClientTest { .user(DEFAULT_USER); ClientTupleKey writeTuple = tuple.condition(DEFAULT_CONDITION); String write2Body = String.format( - "{\"writes\":{\"tuple_keys\":[%s,%s]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + "{\"writes\":{\"tuple_keys\":[%s,%s],\"on_duplicate\":\"error\"},\"deletes\":null,\"authorization_model_id\":\"%s\"}", writeTupleBody, writeTupleBody, DEFAULT_AUTH_MODEL_ID); String write1Body = String.format( - "{\"writes\":{\"tuple_keys\":[%s]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + "{\"writes\":{\"tuple_keys\":[%s],\"on_duplicate\":\"error\"},\"deletes\":null,\"authorization_model_id\":\"%s\"}", writeTupleBody, DEFAULT_AUTH_MODEL_ID); String deleteTupleBody = String.format( "{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}", DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT); String delete2Body = String.format( - "{\"writes\":null,\"deletes\":{\"tuple_keys\":[%s,%s]},\"authorization_model_id\":\"%s\"}", + "{\"writes\":null,\"deletes\":{\"tuple_keys\":[%s,%s],\"on_missing\":\"error\"},\"authorization_model_id\":\"%s\"}", deleteTupleBody, deleteTupleBody, DEFAULT_AUTH_MODEL_ID); String delete1Body = String.format( - "{\"writes\":null,\"deletes\":{\"tuple_keys\":[%s]},\"authorization_model_id\":\"%s\"}", + "{\"writes\":null,\"deletes\":{\"tuple_keys\":[%s],\"on_missing\":\"error\"},\"authorization_model_id\":\"%s\"}", deleteTupleBody, DEFAULT_AUTH_MODEL_ID); mockHttpClient .onPost(postPath) @@ -1284,7 +1284,7 @@ public class OpenFgaClientTest { String failedUser = "user:SECOND"; String thirdUser = "user:third"; Function writeBody = user -> String.format( - "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":{\"name\":\"condition\",\"context\":{\"some\":\"context\"}}}]},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":{\"name\":\"condition\",\"context\":{\"some\":\"context\"}}}],\"on_duplicate\":\"error\"},\"deletes\":null,\"authorization_model_id\":\"%s\"}", user, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); mockHttpClient .onPost(postPath) @@ -1365,7 +1365,7 @@ public class OpenFgaClientTest { "{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}", DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT); String expectedBody = String.format( - "{\"writes\":{\"tuple_keys\":[%s,%s,%s]},\"deletes\":{\"tuple_keys\":[%s,%s,%s]},\"authorization_model_id\":\"%s\"}", + "{\"writes\":{\"tuple_keys\":[%s,%s,%s],\"on_duplicate\":\"error\"},\"deletes\":{\"tuple_keys\":[%s,%s,%s],\"on_missing\":\"error\"},\"authorization_model_id\":\"%s\"}", writeTupleBody, writeTupleBody, writeTupleBody, @@ -1405,7 +1405,7 @@ public class OpenFgaClientTest { "{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}", DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT); String expectedBody = String.format( - "{\"writes\":{\"tuple_keys\":[%s,%s,%s]},\"deletes\":{\"tuple_keys\":[%s,%s,%s]},\"authorization_model_id\":\"%s\"}", + "{\"writes\":{\"tuple_keys\":[%s,%s,%s],\"on_duplicate\":\"error\"},\"deletes\":{\"tuple_keys\":[%s,%s,%s],\"on_missing\":\"error\"},\"authorization_model_id\":\"%s\"}", writeTupleBody, writeTupleBody, writeTupleBody, @@ -1444,7 +1444,7 @@ public class OpenFgaClientTest { // Given String postPath = String.format("https://api.fga.example/stores/%s/write", DEFAULT_STORE_ID); String expectedBody = String.format( - "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":{\"name\":\"condition\",\"context\":{\"some\":\"context\"}}}]}," + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":{\"name\":\"condition\",\"context\":{\"some\":\"context\"}}}],\"on_duplicate\":\"error\"}," + "\"deletes\":null,\"authorization_model_id\":\"%s\"}", DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); @@ -1467,7 +1467,7 @@ public class OpenFgaClientTest { // Given String postPath = String.format("https://api.fga.example/stores/%s/write", DEFAULT_STORE_ID); String expectedBody = String.format( - "{\"writes\":null,\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}]},\"authorization_model_id\":\"%s\"}", + "{\"writes\":null,\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}],\"on_missing\":\"error\"},\"authorization_model_id\":\"%s\"}", DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); List tuples = List.of(new ClientTupleKeyWithoutCondition() @@ -1483,6 +1483,172 @@ public class OpenFgaClientTest { assertEquals(200, response.getStatusCode()); } + @Test + public void writeTest_withOnDuplicateIgnore() throws Exception { + // Given + String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String expectedBody = String.format( + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}],\"on_duplicate\":\"ignore\"},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))); + ClientWriteOptions options = new ClientWriteOptions().onDuplicate(WriteRequestWrites.OnDuplicateEnum.IGNORE); + + // When + ClientWriteResponse response = fga.write(request, options).get(); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); + assertEquals(200, response.getStatusCode()); + } + + @Test + public void writeTest_withOnMissingIgnore() throws Exception { + // Given + String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String expectedBody = String.format( + "{\"writes\":null,\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}],\"on_missing\":\"ignore\"},\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); + ClientWriteRequest request = new ClientWriteRequest() + .deletes(List.of(new ClientTupleKeyWithoutCondition() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))); + ClientWriteOptions options = new ClientWriteOptions().onMissing(WriteRequestDeletes.OnMissingEnum.IGNORE); + + // When + ClientWriteResponse response = fga.write(request, options).get(); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); + assertEquals(200, response.getStatusCode()); + } + + @Test + public void writeTest_withBothConflictOptions() throws Exception { + // Given + String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + String expectedBody = String.format( + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}],\"on_duplicate\":\"ignore\"},\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"writer\",\"object\":\"%s\"}],\"on_missing\":\"ignore\"},\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_USER, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); + ClientWriteRequest request = new ClientWriteRequest() + .writes(List.of(new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER))) + .deletes(List.of(new ClientTupleKeyWithoutCondition() + ._object(DEFAULT_OBJECT) + .relation("writer") + .user(DEFAULT_USER))); + ClientWriteOptions options = new ClientWriteOptions() + .onDuplicate(WriteRequestWrites.OnDuplicateEnum.IGNORE) + .onMissing(WriteRequestDeletes.OnMissingEnum.IGNORE); + + // When + ClientWriteResponse response = fga.write(request, options).get(); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); + assertEquals(200, response.getStatusCode()); + } + + @Test + public void writeTuplesTest_withOnDuplicateIgnore() throws Exception { + // Given + String postPath = String.format("https://api.fga.example/stores/%s/write", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":{\"name\":\"condition\",\"context\":{\"some\":\"context\"}}}],\"on_duplicate\":\"ignore\"}," + + "\"deletes\":null,\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); + List tuples = List.of(new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER) + .condition(DEFAULT_CONDITION)); + ClientWriteTuplesOptions options = + new ClientWriteTuplesOptions().onDuplicate(WriteRequestWrites.OnDuplicateEnum.IGNORE); + + // When + ClientWriteResponse response = fga.writeTuples(tuples, options).get(); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); + assertEquals(200, response.getStatusCode()); + } + + @Test + public void deleteTuplesTest_withOnMissingIgnore() throws Exception { + // Given + String postPath = String.format("https://api.fga.example/stores/%s/write", DEFAULT_STORE_ID); + String expectedBody = String.format( + "{\"writes\":null,\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}],\"on_missing\":\"ignore\"},\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + mockHttpClient.onPost(postPath).withBody(is(expectedBody)).doReturn(200, EMPTY_RESPONSE_BODY); + List tuples = List.of(new ClientTupleKeyWithoutCondition() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER)); + ClientDeleteTuplesOptions options = + new ClientDeleteTuplesOptions().onMissing(WriteRequestDeletes.OnMissingEnum.IGNORE); + + // When + ClientWriteResponse response = fga.deleteTuples(tuples, options).get(); + + // Then + mockHttpClient.verify().post(postPath).withBody(is(expectedBody)).called(1); + assertEquals(200, response.getStatusCode()); + } + + @Test + public void writeTest_nonTransaction_withConflictOptions() throws Exception { + // Given + String postPath = "https://api.fga.example/stores/01YCP46JKYM8FJCQ37NMBYHE5X/write"; + ClientTupleKey writeTuple = new ClientTupleKey() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER); + ClientTupleKeyWithoutCondition deleteTuple = new ClientTupleKeyWithoutCondition() + ._object(DEFAULT_OBJECT) + .relation(DEFAULT_RELATION) + .user(DEFAULT_USER); + + // Expect requests with conflict options in non-transaction mode + String writeBody = String.format( + "{\"writes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\",\"condition\":null}],\"on_duplicate\":\"ignore\"},\"deletes\":null,\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + String deleteBody = String.format( + "{\"writes\":null,\"deletes\":{\"tuple_keys\":[{\"user\":\"%s\",\"relation\":\"%s\",\"object\":\"%s\"}],\"on_missing\":\"ignore\"},\"authorization_model_id\":\"%s\"}", + DEFAULT_USER, DEFAULT_RELATION, DEFAULT_OBJECT, DEFAULT_AUTH_MODEL_ID); + + mockHttpClient + .onPost(postPath) + .withBody(isOneOf(writeBody, deleteBody)) + .withHeader(CLIENT_METHOD_HEADER, "Write") + .withHeader(CLIENT_BULK_REQUEST_ID_HEADER, anyValidUUID()) + .doReturn(200, EMPTY_RESPONSE_BODY); + + ClientWriteRequest request = + new ClientWriteRequest().writes(List.of(writeTuple)).deletes(List.of(deleteTuple)); + ClientWriteOptions options = new ClientWriteOptions() + .disableTransactions(true) + .onDuplicate(WriteRequestWrites.OnDuplicateEnum.IGNORE) + .onMissing(WriteRequestDeletes.OnMissingEnum.IGNORE); + + // When + ClientWriteResponse response = fga.write(request, options).get(); + + // Then + mockHttpClient.verify().post(postPath).called(2); // One for writes, one for deletes + assertEquals(200, response.getStatusCode()); + } + @Test public void write_nothingSentWhenWritesAndDeletesAreEmpty() throws FgaInvalidParameterException, ExecutionException, InterruptedException { diff --git a/config/common/config.base.json b/config/common/config.base.json index 987547859..ffc7c8584 100644 --- a/config/common/config.base.json +++ b/config/common/config.base.json @@ -89,5 +89,5 @@ "tokenExpiryJitterInSec": 300, "clientMethodHeader": "X-OpenFGA-Client-Method", "clientBulkRequestIdHeader": "X-OpenFGA-Client-Bulk-Request-Id", - "openFGADockerTag": "v1.5.1" + "openFGADockerTag": "v1.10.2" }