diff --git a/README.md b/README.md index 4507c1c7..d2c2e365 100644 --- a/README.md +++ b/README.md @@ -641,6 +641,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/docs/OpenFgaApi.md b/docs/OpenFgaApi.md index fcb6a7af..0826a09a 100644 --- a/docs/OpenFgaApi.md +++ b/docs/OpenFgaApi.md @@ -2303,7 +2303,7 @@ No authorization required Add or delete tuples from the store -The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ``` +The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` ### Example @@ -2378,7 +2378,7 @@ No authorization required Add or delete tuples from the store -The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ``` +The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` ### Example diff --git a/docs/WriteRequestDeletes.md b/docs/WriteRequestDeletes.md index 3e38682f..9e10fd39 100644 --- a/docs/WriteRequestDeletes.md +++ b/docs/WriteRequestDeletes.md @@ -8,6 +8,17 @@ | Name | Type | Description | Notes | |------------ | ------------- | ------------- | -------------| |**tupleKeys** | [**List<TupleKeyWithoutCondition>**](TupleKeyWithoutCondition.md) | | | +|**onMissing** | [**OnMissingEnum**](#OnMissingEnum) | On 'error', the API returns an error when deleting a tuple that does not exist. On 'ignore', deletes of non-existent tuples are treated as no-ops. | [optional] | + + + +## Enum: OnMissingEnum + +| Name | Value | +|---- | -----| +| ERROR | "error" | +| IGNORE | "ignore" | +| UNKNOWN_DEFAULT_OPEN_API | "unknown_default_open_api" | diff --git a/docs/WriteRequestWrites.md b/docs/WriteRequestWrites.md index 8a2fcba9..6963f1a7 100644 --- a/docs/WriteRequestWrites.md +++ b/docs/WriteRequestWrites.md @@ -8,6 +8,17 @@ | Name | Type | Description | Notes | |------------ | ------------- | ------------- | -------------| |**tupleKeys** | [**List<TupleKey>**](TupleKey.md) | | | +|**onDuplicate** | [**OnDuplicateEnum**](#OnDuplicateEnum) | On 'error' ( or unspecified ), the API returns an error if an identical tuple already exists. On 'ignore', identical writes are treated as no-ops (matching on user, relation, object, and RelationshipCondition). | [optional] | + + + +## Enum: OnDuplicateEnum + +| Name | Value | +|---- | -----| +| ERROR | "error" | +| IGNORE | "ignore" | +| UNKNOWN_DEFAULT_OPEN_API | "unknown_default_open_api" | diff --git a/src/main/java/dev/openfga/sdk/api/OpenFgaApi.java b/src/main/java/dev/openfga/sdk/api/OpenFgaApi.java index d141ad0b..70eddbba 100644 --- a/src/main/java/dev/openfga/sdk/api/OpenFgaApi.java +++ b/src/main/java/dev/openfga/sdk/api/OpenFgaApi.java @@ -906,7 +906,7 @@ private CompletableFuture> readChanges( /** * Add or delete tuples from the store - * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ``` + * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` * @param storeId (required) * @param body (required) * @return CompletableFuture<ApiResponse<Object>> @@ -919,7 +919,7 @@ public CompletableFuture> write(String storeId, WriteRequest /** * Add or delete tuples from the store - * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ``` + * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` * @param storeId (required) * @param body (required) * @param configurationOverride Override the {@link Configuration} this OpenFgaApi was constructed with 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 bd1816bf..edc058d4 100644 --- a/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java +++ b/src/main/java/dev/openfga/sdk/api/client/OpenFgaClient.java @@ -484,12 +484,14 @@ private CompletableFuture writeTransactions( 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())) { @@ -708,7 +710,8 @@ public CompletableFuture writeTuples( 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)) { @@ -748,7 +751,8 @@ public CompletableFuture deleteTuples( 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/src/main/java/dev/openfga/sdk/api/client/model/ClientTupleKey.java b/src/main/java/dev/openfga/sdk/api/client/model/ClientTupleKey.java index dc7269ca..6adb608d 100644 --- a/src/main/java/dev/openfga/sdk/api/client/model/ClientTupleKey.java +++ b/src/main/java/dev/openfga/sdk/api/client/model/ClientTupleKey.java @@ -46,8 +46,17 @@ public static ContextualTupleKeys asContextualTupleKeys(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/src/main/java/dev/openfga/sdk/api/client/model/ClientTupleKeyWithoutCondition.java b/src/main/java/dev/openfga/sdk/api/client/model/ClientTupleKeyWithoutCondition.java index c7b2c389..1a8eaa08 100644 --- a/src/main/java/dev/openfga/sdk/api/client/model/ClientTupleKeyWithoutCondition.java +++ b/src/main/java/dev/openfga/sdk/api/client/model/ClientTupleKeyWithoutCondition.java @@ -27,10 +27,19 @@ public TupleKeyWithoutCondition asTupleKeyWithoutCondition() { } 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/src/main/java/dev/openfga/sdk/api/configuration/ClientDeleteTuplesOptions.java b/src/main/java/dev/openfga/sdk/api/configuration/ClientDeleteTuplesOptions.java index 1efc2d89..58388cb1 100644 --- a/src/main/java/dev/openfga/sdk/api/configuration/ClientDeleteTuplesOptions.java +++ b/src/main/java/dev/openfga/sdk/api/configuration/ClientDeleteTuplesOptions.java @@ -12,10 +12,12 @@ package dev.openfga.sdk.api.configuration; +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; @@ -26,4 +28,13 @@ public ClientDeleteTuplesOptions additionalHeaders(Map additiona 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/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java b/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java index b874d815..dbec2da9 100644 --- a/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java +++ b/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteOptions.java @@ -12,6 +12,8 @@ package dev.openfga.sdk.api.configuration; +import dev.openfga.sdk.api.model.WriteRequestDeletes; +import dev.openfga.sdk.api.model.WriteRequestWrites; import java.util.Map; public class ClientWriteOptions implements AdditionalHeadersSupplier { @@ -19,6 +21,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; @@ -56,4 +60,22 @@ public ClientWriteOptions transactionChunkSize(int transactionChunkSize) { 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/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteTuplesOptions.java b/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteTuplesOptions.java index dae7dd58..0d897bc1 100644 --- a/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteTuplesOptions.java +++ b/src/main/java/dev/openfga/sdk/api/configuration/ClientWriteTuplesOptions.java @@ -12,10 +12,12 @@ package dev.openfga.sdk.api.configuration; +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; @@ -26,4 +28,13 @@ public ClientWriteTuplesOptions additionalHeaders(Map additional 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/src/main/java/dev/openfga/sdk/api/model/WriteRequestDeletes.java b/src/main/java/dev/openfga/sdk/api/model/WriteRequestDeletes.java index c58d55b2..b0b22e1a 100644 --- a/src/main/java/dev/openfga/sdk/api/model/WriteRequestDeletes.java +++ b/src/main/java/dev/openfga/sdk/api/model/WriteRequestDeletes.java @@ -12,9 +12,13 @@ package dev.openfga.sdk.api.model; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonValue; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -23,11 +27,51 @@ /** * WriteRequestDeletes */ -@JsonPropertyOrder({WriteRequestDeletes.JSON_PROPERTY_TUPLE_KEYS}) +@JsonPropertyOrder({WriteRequestDeletes.JSON_PROPERTY_TUPLE_KEYS, WriteRequestDeletes.JSON_PROPERTY_ON_MISSING}) public class WriteRequestDeletes { public static final String JSON_PROPERTY_TUPLE_KEYS = "tuple_keys"; private List tupleKeys = new ArrayList<>(); + /** + * On 'error', the API returns an error when deleting a tuple that does not exist. On 'ignore', deletes of non-existent tuples are treated as no-ops. + */ + public enum OnMissingEnum { + ERROR("error"), + + IGNORE("ignore"), + + UNKNOWN_DEFAULT_OPEN_API("unknown_default_open_api"); + + private String value; + + OnMissingEnum(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } + + @JsonCreator + public static OnMissingEnum fromValue(String value) { + for (OnMissingEnum b : OnMissingEnum.values()) { + if (b.value.equals(value)) { + return b; + } + } + return UNKNOWN_DEFAULT_OPEN_API; + } + } + + public static final String JSON_PROPERTY_ON_MISSING = "on_missing"; + private OnMissingEnum onMissing = OnMissingEnum.ERROR; + public WriteRequestDeletes() {} public WriteRequestDeletes tupleKeys(List tupleKeys) { @@ -60,6 +104,28 @@ public void setTupleKeys(List tupleKeys) { this.tupleKeys = tupleKeys; } + public WriteRequestDeletes onMissing(OnMissingEnum onMissing) { + this.onMissing = onMissing; + return this; + } + + /** + * On 'error', the API returns an error when deleting a tuple that does not exist. On 'ignore', deletes of non-existent tuples are treated as no-ops. + * @return onMissing + **/ + @javax.annotation.Nullable + @JsonProperty(JSON_PROPERTY_ON_MISSING) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OnMissingEnum getOnMissing() { + return onMissing; + } + + @JsonProperty(JSON_PROPERTY_ON_MISSING) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public void setOnMissing(OnMissingEnum onMissing) { + this.onMissing = onMissing; + } + /** * Return true if this WriteRequestDeletes object is equal to o. */ @@ -72,12 +138,13 @@ public boolean equals(Object o) { return false; } WriteRequestDeletes writeRequestDeletes = (WriteRequestDeletes) o; - return Objects.equals(this.tupleKeys, writeRequestDeletes.tupleKeys); + return Objects.equals(this.tupleKeys, writeRequestDeletes.tupleKeys) + && Objects.equals(this.onMissing, writeRequestDeletes.onMissing); } @Override public int hashCode() { - return Objects.hash(tupleKeys); + return Objects.hash(tupleKeys, onMissing); } @Override @@ -85,6 +152,7 @@ public String toString() { StringBuilder sb = new StringBuilder(); sb.append("class WriteRequestDeletes {\n"); sb.append(" tupleKeys: ").append(toIndentedString(tupleKeys)).append("\n"); + sb.append(" onMissing: ").append(toIndentedString(onMissing)).append("\n"); sb.append("}"); return sb.toString(); } @@ -149,6 +217,16 @@ public String toUrlQueryString(String prefix) { } } + // add `on_missing` to the URL query string + if (getOnMissing() != null) { + joiner.add(String.format( + "%son_missing%s=%s", + prefix, + suffix, + URLEncoder.encode(String.valueOf(getOnMissing()), StandardCharsets.UTF_8) + .replaceAll("\\+", "%20"))); + } + return joiner.toString(); } } diff --git a/src/main/java/dev/openfga/sdk/api/model/WriteRequestWrites.java b/src/main/java/dev/openfga/sdk/api/model/WriteRequestWrites.java index 8231b430..46acd0c2 100644 --- a/src/main/java/dev/openfga/sdk/api/model/WriteRequestWrites.java +++ b/src/main/java/dev/openfga/sdk/api/model/WriteRequestWrites.java @@ -12,9 +12,13 @@ package dev.openfga.sdk.api.model; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonValue; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -23,11 +27,51 @@ /** * WriteRequestWrites */ -@JsonPropertyOrder({WriteRequestWrites.JSON_PROPERTY_TUPLE_KEYS}) +@JsonPropertyOrder({WriteRequestWrites.JSON_PROPERTY_TUPLE_KEYS, WriteRequestWrites.JSON_PROPERTY_ON_DUPLICATE}) public class WriteRequestWrites { public static final String JSON_PROPERTY_TUPLE_KEYS = "tuple_keys"; private List tupleKeys = new ArrayList<>(); + /** + * On 'error' ( or unspecified ), the API returns an error if an identical tuple already exists. On 'ignore', identical writes are treated as no-ops (matching on user, relation, object, and RelationshipCondition). + */ + public enum OnDuplicateEnum { + ERROR("error"), + + IGNORE("ignore"), + + UNKNOWN_DEFAULT_OPEN_API("unknown_default_open_api"); + + private String value; + + OnDuplicateEnum(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } + + @JsonCreator + public static OnDuplicateEnum fromValue(String value) { + for (OnDuplicateEnum b : OnDuplicateEnum.values()) { + if (b.value.equals(value)) { + return b; + } + } + return UNKNOWN_DEFAULT_OPEN_API; + } + } + + public static final String JSON_PROPERTY_ON_DUPLICATE = "on_duplicate"; + private OnDuplicateEnum onDuplicate = OnDuplicateEnum.ERROR; + public WriteRequestWrites() {} public WriteRequestWrites tupleKeys(List tupleKeys) { @@ -60,6 +104,28 @@ public void setTupleKeys(List tupleKeys) { this.tupleKeys = tupleKeys; } + public WriteRequestWrites onDuplicate(OnDuplicateEnum onDuplicate) { + this.onDuplicate = onDuplicate; + return this; + } + + /** + * On 'error' ( or unspecified ), the API returns an error if an identical tuple already exists. On 'ignore', identical writes are treated as no-ops (matching on user, relation, object, and RelationshipCondition). + * @return onDuplicate + **/ + @javax.annotation.Nullable + @JsonProperty(JSON_PROPERTY_ON_DUPLICATE) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public OnDuplicateEnum getOnDuplicate() { + return onDuplicate; + } + + @JsonProperty(JSON_PROPERTY_ON_DUPLICATE) + @JsonInclude(value = JsonInclude.Include.USE_DEFAULTS) + public void setOnDuplicate(OnDuplicateEnum onDuplicate) { + this.onDuplicate = onDuplicate; + } + /** * Return true if this WriteRequestWrites object is equal to o. */ @@ -72,12 +138,13 @@ public boolean equals(Object o) { return false; } WriteRequestWrites writeRequestWrites = (WriteRequestWrites) o; - return Objects.equals(this.tupleKeys, writeRequestWrites.tupleKeys); + return Objects.equals(this.tupleKeys, writeRequestWrites.tupleKeys) + && Objects.equals(this.onDuplicate, writeRequestWrites.onDuplicate); } @Override public int hashCode() { - return Objects.hash(tupleKeys); + return Objects.hash(tupleKeys, onDuplicate); } @Override @@ -85,6 +152,7 @@ public String toString() { StringBuilder sb = new StringBuilder(); sb.append("class WriteRequestWrites {\n"); sb.append(" tupleKeys: ").append(toIndentedString(tupleKeys)).append("\n"); + sb.append(" onDuplicate: ").append(toIndentedString(onDuplicate)).append("\n"); sb.append("}"); return sb.toString(); } @@ -149,6 +217,16 @@ public String toUrlQueryString(String prefix) { } } + // add `on_duplicate` to the URL query string + if (getOnDuplicate() != null) { + joiner.add(String.format( + "%son_duplicate%s=%s", + prefix, + suffix, + URLEncoder.encode(String.valueOf(getOnDuplicate()), StandardCharsets.UTF_8) + .replaceAll("\\+", "%20"))); + } + return joiner.toString(); } } diff --git a/src/test-integration/java/dev/openfga/sdk/api/OpenFgaApiIntegrationTest.java b/src/test-integration/java/dev/openfga/sdk/api/OpenFgaApiIntegrationTest.java index 6182a5fd..8d544cae 100644 --- a/src/test-integration/java/dev/openfga/sdk/api/OpenFgaApiIntegrationTest.java +++ b/src/test-integration/java/dev/openfga/sdk/api/OpenFgaApiIntegrationTest.java @@ -36,7 +36,7 @@ public class OpenFgaApiIntegrationTest { @Container - private static final OpenFGAContainer openfga = new OpenFGAContainer("openfga/openfga:v1.5.1"); + private static final OpenFGAContainer openfga = new OpenFGAContainer("openfga/openfga:v1.10.2"); private static final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); private static final String DEFAULT_USER = "user:81684243-9356-4421-8fbf-a4f8d36aa31b"; diff --git a/src/test-integration/java/dev/openfga/sdk/api/client/OpenFgaClientIntegrationTest.java b/src/test-integration/java/dev/openfga/sdk/api/client/OpenFgaClientIntegrationTest.java index 76e93b13..4bd48b28 100644 --- a/src/test-integration/java/dev/openfga/sdk/api/client/OpenFgaClientIntegrationTest.java +++ b/src/test-integration/java/dev/openfga/sdk/api/client/OpenFgaClientIntegrationTest.java @@ -37,7 +37,7 @@ public class OpenFgaClientIntegrationTest { @Container - private static final OpenFGAContainer openfga = new OpenFGAContainer("openfga/openfga:v1.5.1"); + private static final OpenFGAContainer openfga = new OpenFGAContainer("openfga/openfga:v1.10.2"); private static final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); private static final String DEFAULT_USER = "user:81684243-9356-4421-8fbf-a4f8d36aa31b"; diff --git a/src/test-integration/java/dev/openfga/sdk/example/ExampleTest.java b/src/test-integration/java/dev/openfga/sdk/example/ExampleTest.java index a233f91d..c3c94167 100644 --- a/src/test-integration/java/dev/openfga/sdk/example/ExampleTest.java +++ b/src/test-integration/java/dev/openfga/sdk/example/ExampleTest.java @@ -8,7 +8,7 @@ @Testcontainers public class ExampleTest { @Container - private static final OpenFGAContainer openfga = new OpenFGAContainer("openfga/openfga:v1.5.1"); + private static final OpenFGAContainer openfga = new OpenFGAContainer("openfga/openfga:v1.10.2"); private final Example1 example1 = new Example1(); diff --git a/src/test/java/dev/openfga/sdk/api/OpenFgaApiTest.java b/src/test/java/dev/openfga/sdk/api/OpenFgaApiTest.java index e0ff20b7..4a0cf7d6 100644 --- a/src/test/java/dev/openfga/sdk/api/OpenFgaApiTest.java +++ b/src/test/java/dev/openfga/sdk/api/OpenFgaApiTest.java @@ -1098,7 +1098,7 @@ public void writeTest_writes() 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}]},\"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() @@ -1124,7 +1124,7 @@ public void writeTest_deletes() 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\"}]},\"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() @@ -1147,7 +1147,7 @@ public void writeWithContext_map() 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\":{\"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<>(); @@ -1179,7 +1179,7 @@ public void writeWithContext_modeledObj() throws Exception { 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); @@ -1320,6 +1320,147 @@ public void write_500() throws Exception { "{\"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/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientHeadersTest.java b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientHeadersTest.java index 5b9804a7..4239d035 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientHeadersTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientHeadersTest.java @@ -399,7 +399,7 @@ public void write_withHeaders() 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\":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) @@ -905,7 +905,7 @@ public void write_multipleOverrides() 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\":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) 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 2f1d064d..8b964a73 100644 --- a/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java +++ b/src/test/java/dev/openfga/sdk/api/client/OpenFgaClientTest.java @@ -1143,7 +1143,7 @@ public void writeTest_writes() 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}]},\"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() @@ -1175,7 +1175,7 @@ public void writeTest_deletes() 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\"}]},\"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() @@ -1204,19 +1204,19 @@ public void writeTest_nonTransaction() throws Exception { .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) @@ -1273,7 +1273,7 @@ public void writeTest_nonTransactionsWithFailure() throws Exception { 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) @@ -1354,7 +1354,7 @@ public void writeTest_transaction() throws Exception { "{\"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, @@ -1394,7 +1394,7 @@ public void writeTest_transactionWithFailure() { "{\"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, @@ -1433,7 +1433,7 @@ public void writeTuplesTest() 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\"}}}]}," + "{\"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); @@ -1456,7 +1456,7 @@ public void deleteTuplesTest() 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\"}]},\"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() @@ -1472,6 +1472,172 @@ public void deleteTuplesTest() throws Exception { 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 {