diff --git a/config/clients/dotnet/CHANGELOG.md.mustache b/config/clients/dotnet/CHANGELOG.md.mustache index f58d6fbe..f98ea055 100644 --- a/config/clients/dotnet/CHANGELOG.md.mustache +++ b/config/clients/dotnet/CHANGELOG.md.mustache @@ -7,7 +7,12 @@ - per-request headers support via `Headers` property on all client options classes - `IRequestOptions` interface and `RequestOptions` class for API-level header support - `IClientRequestOptions` interface and `ClientRequestOptions` class for client-level header support - - add header validation to prevent overiding of reserved headers + - add header validation to prevent overriding of reserved headers +- feat: add write conflict resolution options + - `ConflictOptions` to control behavior for duplicate writes and missing deletes + - `OnDuplicateWrites` option: `Error` (default) or `Ignore` for handling duplicate tuple writes + - `OnMissingDeletes` option: `Error` (default) or `Ignore` for handling missing tuple deletes + - Available in `ClientWriteOptions.Conflict` property [!WARNING] BREAKING CHANGES: @@ -15,11 +20,13 @@ BREAKING CHANGES: - **OpenFgaApi methods**: All API methods now accept an `IRequestOptions? options` parameter. If you are using the low-level `OpenFgaApi` directly, you may need to update your calls: Before: + ```csharp await api.Check(storeId, body, cancellationToken); ``` After: + ```csharp var options = new RequestOptions { Headers = new Dictionary { { "X-Custom-Header", "value" } } @@ -30,11 +37,13 @@ BREAKING CHANGES: - **ClientRequestOptions renamed**: The base client request options interface has been renamed from `ClientRequestOptions` to `IClientRequestOptions` to better follow .NET naming conventions. A concrete `ClientRequestOptions` class is now also available. If you were casting to or implementing this interface, update your code: Before: + ```csharp var options = obj as ClientRequestOptions; ``` After: + ```csharp var options = obj as IClientRequestOptions; ``` diff --git a/config/clients/dotnet/template/Client/Client.mustache b/config/clients/dotnet/template/Client/Client.mustache index 2e0a2737..483d1632 100644 --- a/config/clients/dotnet/template/Client/Client.mustache +++ b/config/clients/dotnet/template/Client/Client.mustache @@ -212,6 +212,20 @@ public class {{appShortName}}Client : IDisposable { return authorizationModelId; } + private static WriteRequestWrites.OnDuplicateEnum MapOnDuplicateWrites(OnDuplicateWrites? behavior) { + if (behavior == null) return WriteRequestWrites.OnDuplicateEnum.Error; + return behavior.Value == OnDuplicateWrites.Error + ? WriteRequestWrites.OnDuplicateEnum.Error + : WriteRequestWrites.OnDuplicateEnum.Ignore; + } + + private static WriteRequestDeletes.OnMissingEnum MapOnMissingDeletes(OnMissingDeletes? behavior) { + if (behavior == null) return WriteRequestDeletes.OnMissingEnum.Error; + return behavior.Value == OnMissingDeletes.Error + ? WriteRequestDeletes.OnMissingEnum.Error + : WriteRequestDeletes.OnMissingEnum.Ignore; + } + /********** * Stores * **********/ @@ -341,10 +355,16 @@ public class {{appShortName}}Client : IDisposable { AuthorizationModelId = authorizationModelId }; if (body.Writes?.Count > 0) { - requestBody.Writes = new WriteRequestWrites(body.Writes.ConvertAll(key => key.ToTupleKey())); + requestBody.Writes = new WriteRequestWrites( + body.Writes.ConvertAll(key => key.ToTupleKey()), + MapOnDuplicateWrites(options?.Conflict?.OnDuplicateWrites) + ); } if (body.Deletes?.Count > 0) { - requestBody.Deletes = new WriteRequestDeletes(body.Deletes.ConvertAll(key => key.ToTupleKeyWithoutCondition())); + requestBody.Deletes = new WriteRequestDeletes( + body.Deletes.ConvertAll(key => key.ToTupleKeyWithoutCondition()), + MapOnMissingDeletes(options?.Conflict?.OnMissingDeletes) + ); } await api.Write(GetStoreId(options), requestBody, options, cancellationToken); @@ -360,7 +380,12 @@ public class {{appShortName}}Client : IDisposable { }; } - var clientWriteOpts = new ClientWriteOptions() { StoreId = StoreId, AuthorizationModelId = authorizationModelId, Headers = options?.Headers }; + var clientWriteOpts = new ClientWriteOptions() { + StoreId = GetStoreId(options), + AuthorizationModelId = authorizationModelId, + Headers = options?.Headers, + Conflict = options?.Conflict + }; var writeChunks = body.Writes?.Chunk(maxPerChunk).ToList() ?? new List(); var writeResponses = new ConcurrentBag(); diff --git a/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache index a47287c4..4fe15150 100644 --- a/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache @@ -1,9 +1,67 @@ {{>partial_header}} +using {{packageName}}.Model; using System.Collections.Generic; namespace {{packageName}}.Client.Model; +/// +/// Behavior for handling duplicate tuple writes +/// +public enum OnDuplicateWrites { + /// + /// Return an error when attempting to write a tuple that already exists (default) + /// + Error = 1, + + /// + /// Silently ignore duplicate tuple writes (no-op) + /// + Ignore = 2 +} + +/// +/// Behavior for handling missing tuple deletes +/// +public enum OnMissingDeletes { + /// + /// Return an error when attempting to delete a tuple that doesn't exist (default) + /// + Error = 1, + + /// + /// Silently ignore missing tuple deletes (no-op) + /// + Ignore = 2 +} + +/// +/// ConflictOptions - Controls behavior for duplicate writes and missing deletes +/// +public interface IConflictOptions { + /// + /// Controls behavior when writing a tuple that already exists + /// + OnDuplicateWrites? OnDuplicateWrites { get; set; } + + /// + /// Controls behavior when deleting a tuple that doesn't exist + /// + OnMissingDeletes? OnMissingDeletes { get; set; } +} + +public class ConflictOptions : IConflictOptions { + /// + /// Controls behavior when writing a tuple that already exists + /// + public OnDuplicateWrites? OnDuplicateWrites { get; set; } + + /// + /// Controls behavior when deleting a tuple that doesn't exist + /// + public OnMissingDeletes? OnMissingDeletes { get; set; } +} + /// /// TransactionOpts /// @@ -43,6 +101,7 @@ public class TransactionOptions : ITransactionOpts { public interface IClientWriteOptions : IClientRequestOptionsWithAuthZModelId { ITransactionOpts Transaction { get; set; } + IConflictOptions? Conflict { get; set; } } public class ClientWriteOptions : IClientWriteOptions { @@ -57,6 +116,9 @@ public class ClientWriteOptions : IClientWriteOptions { /// public ITransactionOpts Transaction { get; set; } + /// + public IConflictOptions? Conflict { get; set; } + /// public IDictionary? Headers { get; set; } } diff --git a/config/clients/dotnet/template/OpenFgaClientTests.mustache b/config/clients/dotnet/template/OpenFgaClientTests.mustache index f2b76176..865894a3 100644 --- a/config/clients/dotnet/template/OpenFgaClientTests.mustache +++ b/config/clients/dotnet/template/OpenFgaClientTests.mustache @@ -1012,7 +1012,8 @@ public class {{appShortName}}ClientTests : IDisposable { }, Deletes = new List(), // should not get passed }; - var response = await fgaClient.Write(body, new ClientWriteOptions { + + await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", }); @@ -1057,7 +1058,8 @@ public class {{appShortName}}ClientTests : IDisposable { } }, }; - var response = await fgaClient.Write(body, new ClientWriteOptions { + + await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", }); @@ -1109,7 +1111,8 @@ public class {{appShortName}}ClientTests : IDisposable { } }, }; - var response = await fgaClient.Write(body, new ClientWriteOptions { + + await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", }); @@ -2172,6 +2175,508 @@ public class {{appShortName}}ClientTests : IDisposable { AssertHeaderPresent(mockHandler, TestHeaders.TraceId, "trace-456"); } + /// + /// Test Write with OnDuplicateWrites = Ignore + /// + [Fact] + public async Task Write_WithConflictOnDuplicateWritesIgnore_ShouldPassOptionToApi() { + WriteRequest? capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_config.StoreId}/write") && + req.Method == HttpMethod.Post), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(new Object()), + }) + .Callback((req, _) => { + var content = req.Content!.ReadAsStringAsync().Result; + capturedRequest = JsonSerializer.Deserialize(content); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var fgaClient = new OpenFgaClient(_config, httpClient); + + var body = new ClientWriteRequest() { + Writes = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }, + }; + + await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnDuplicateWrites = OnDuplicateWrites.Ignore + } + }); + + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest.Writes); + Assert.Equal(WriteRequestWrites.OnDuplicateEnum.Ignore, capturedRequest.Writes.OnDuplicate); + } + + /// + /// Test Write with OnDuplicateWrites = Error + /// + [Fact] + public async Task Write_WithConflictOnDuplicateWritesError_ShouldPassOptionToApi() { + WriteRequest? capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_config.StoreId}/write") && + req.Method == HttpMethod.Post), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(new Object()), + }) + .Callback((req, _) => { + var content = req.Content!.ReadAsStringAsync().Result; + capturedRequest = JsonSerializer.Deserialize(content); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var fgaClient = new OpenFgaClient(_config, httpClient); + + var body = new ClientWriteRequest() { + Writes = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }, + }; + + await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnDuplicateWrites = OnDuplicateWrites.Error + } + }); + + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest.Writes); + Assert.Equal(WriteRequestWrites.OnDuplicateEnum.Error, capturedRequest.Writes.OnDuplicate); + } + + /// + /// Test Write with OnMissingDeletes = Ignore + /// + [Fact] + public async Task Write_WithConflictOnMissingDeletesIgnore_ShouldPassOptionToApi() { + WriteRequest? capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_config.StoreId}/write") && + req.Method == HttpMethod.Post), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(new Object()), + }) + .Callback((req, _) => { + var content = req.Content!.ReadAsStringAsync().Result; + capturedRequest = JsonSerializer.Deserialize(content); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var fgaClient = new OpenFgaClient(_config, httpClient); + + var body = new ClientWriteRequest() { + Deletes = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }, + }; + + await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnMissingDeletes = OnMissingDeletes.Ignore + } + }); + + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest.Deletes); + Assert.Equal(WriteRequestDeletes.OnMissingEnum.Ignore, capturedRequest.Deletes.OnMissing); + } + + /// + /// Test Write with OnMissingDeletes = Error + /// + [Fact] + public async Task Write_WithConflictOnMissingDeletesError_ShouldPassOptionToApi() { + WriteRequest? capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_config.StoreId}/write") && + req.Method == HttpMethod.Post), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(new Object()), + }) + .Callback((req, _) => { + var content = req.Content!.ReadAsStringAsync().Result; + capturedRequest = JsonSerializer.Deserialize(content); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var fgaClient = new OpenFgaClient(_config, httpClient); + + var body = new ClientWriteRequest() { + Deletes = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }, + }; + + await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnMissingDeletes = OnMissingDeletes.Error + } + }); + + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest.Deletes); + Assert.Equal(WriteRequestDeletes.OnMissingEnum.Error, capturedRequest.Deletes.OnMissing); + } + + /// + /// Test Write with both OnDuplicateWrites and OnMissingDeletes + /// + [Fact] + public async Task Write_WithBothConflictOptions_ShouldPassBothOptionsToApi() { + WriteRequest? capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_config.StoreId}/write") && + req.Method == HttpMethod.Post), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(new Object()), + }) + .Callback((req, _) => { + var content = req.Content!.ReadAsStringAsync().Result; + capturedRequest = JsonSerializer.Deserialize(content); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var fgaClient = new OpenFgaClient(_config, httpClient); + + var body = new ClientWriteRequest() { + Writes = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }, + Deletes = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "writer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }, + }; + + await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnDuplicateWrites = OnDuplicateWrites.Ignore, + OnMissingDeletes = OnMissingDeletes.Ignore + } + }); + + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest.Writes); + Assert.NotNull(capturedRequest.Deletes); + Assert.Equal(WriteRequestWrites.OnDuplicateEnum.Ignore, capturedRequest.Writes.OnDuplicate); + Assert.Equal(WriteRequestDeletes.OnMissingEnum.Ignore, capturedRequest.Deletes.OnMissing); + } + + /// + /// Test Write in non-transaction mode with conflict options + /// + [Fact] + public async Task Write_NonTransactionWithConflictOptions_ShouldPassOptionsToAllRequests() { + var capturedRequests = new List(); + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_config.StoreId}/write") && + req.Method == HttpMethod.Post), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(new Object()), + }) + .Callback((req, _) => { + var content = req.Content!.ReadAsStringAsync().Result; + var writeRequest = JsonSerializer.Deserialize(content); + if (writeRequest != null) { + capturedRequests.Add(writeRequest); + } + }); + + var httpClient = new HttpClient(mockHandler.Object); + var fgaClient = new OpenFgaClient(_config, httpClient); + + var body = new ClientWriteRequest() { + Writes = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + }, + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:budget", + } + }, + Deletes = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "writer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }, + }; + + await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Transaction = new TransactionOptions { + Disable = true, + MaxPerChunk = 1 + }, + Conflict = new ConflictOptions { + OnDuplicateWrites = OnDuplicateWrites.Ignore, + OnMissingDeletes = OnMissingDeletes.Ignore + } + }); + + // Should have made 3 requests (2 writes + 1 delete in non-transaction mode) + Assert.Equal(3, capturedRequests.Count); + + // Verify each request has the conflict options + foreach (var request in capturedRequests) { + if (request.Writes != null) { + Assert.Equal(WriteRequestWrites.OnDuplicateEnum.Ignore, request.Writes.OnDuplicate); + } + if (request.Deletes != null) { + Assert.Equal(WriteRequestDeletes.OnMissingEnum.Ignore, request.Deletes.OnMissing); + } + } + } + + /// + /// Test Write with null conflict options (default behavior) + /// + [Fact] + public async Task Write_WithNullConflictOptions_ShouldUseApiDefaults() { + WriteRequest? capturedRequest = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_config.StoreId}/write") && + req.Method == HttpMethod.Post), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(new Object()), + }) + .Callback((req, _) => { + var content = req.Content!.ReadAsStringAsync().Result; + capturedRequest = JsonSerializer.Deserialize(content); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var fgaClient = new OpenFgaClient(_config, httpClient); + + var body = new ClientWriteRequest() { + Writes = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }, + Deletes = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "writer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }, + }; + + // Don't specify Conflict options - should use API defaults + await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1" + }); + + Assert.NotNull(capturedRequest); + // When no options are specified, the SDK explicitly sends Error as the default + Assert.NotNull(capturedRequest.Writes); + Assert.Equal(WriteRequestWrites.OnDuplicateEnum.Error, capturedRequest.Writes.OnDuplicate); + Assert.NotNull(capturedRequest.Deletes); + Assert.Equal(WriteRequestDeletes.OnMissingEnum.Error, capturedRequest.Deletes.OnMissing); + } + + /// + /// Test WriteTuples convenience method with conflict options + /// + [Fact] + public async Task WriteTuples_WithConflictOptions_ShouldPassOptionToApi() { + WriteRequest? capturedRequest = null; + HttpResponseMessage? responseMsg = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_config.StoreId}/write") && + req.Method == HttpMethod.Post), + ItExpr.IsAny() + ) + .ReturnsAsync(() => { + responseMsg = new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(new Object()), + }; + return responseMsg; + }) + .Callback((req, _) => { + var content = req.Content!.ReadAsStringAsync().Result; + capturedRequest = JsonSerializer.Deserialize(content); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var fgaClient = new OpenFgaClient(_config, httpClient); + + var tuples = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }; + + try + { + await fgaClient.WriteTuples(tuples, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnDuplicateWrites = OnDuplicateWrites.Ignore + } + }); + + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest.Writes); + Assert.Equal(WriteRequestWrites.OnDuplicateEnum.Ignore, capturedRequest.Writes.OnDuplicate); + } + finally + { + if (responseMsg != null) + responseMsg.Dispose(); + } + } + + /// + /// Test DeleteTuples convenience method with conflict options + /// + [Fact] + public async Task DeleteTuples_WithConflictOptions_ShouldPassOptionToApi() { + WriteRequest? capturedRequest = null; + HttpResponseMessage? httpResponse = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_config.StoreId}/write") && + req.Method == HttpMethod.Post), + ItExpr.IsAny() + ) + .ReturnsAsync(() => { + httpResponse = new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(new Object()), + }; + return httpResponse; + }) + .Callback((req, _) => { + var content = req.Content!.ReadAsStringAsync().Result; + capturedRequest = JsonSerializer.Deserialize(content); + }); + + var httpClient = new HttpClient(mockHandler.Object); + var fgaClient = new OpenFgaClient(_config, httpClient); + + var tuples = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }; + + await fgaClient.DeleteTuples(tuples, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnMissingDeletes = OnMissingDeletes.Ignore + } + }); + + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest.Deletes); + Assert.Equal(WriteRequestDeletes.OnMissingEnum.Ignore, capturedRequest.Deletes.OnMissing); + + httpResponse?.Dispose(); + } + /// /// Test Read with custom headers /// @@ -2396,8 +2901,7 @@ public class {{appShortName}}ClientTests : IDisposable { var options = new ClientWriteOptions { Headers = new Dictionary { { "X-Latest-Model", "latest-xyz" } - }, - Transaction = new TransactionOptions() + } }; var response = await client.ReadLatestAuthorizationModel(options); diff --git a/config/clients/dotnet/template/README_calling_api.mustache b/config/clients/dotnet/template/README_calling_api.mustache index ecbfe04b..a115856d 100644 --- a/config/clients/dotnet/template/README_calling_api.mustache +++ b/config/clients/dotnet/template/README_calling_api.mustache @@ -318,6 +318,45 @@ var options = new ClientWriteOptions { var response = await fgaClient.Write(body, options); ``` +##### Conflict Options for Write Operations + +The SDK allows you to control how write conflicts are handled using the `Conflict` option: + +- **OnDuplicateWrites**: Controls behavior when writing a tuple that already exists + - `OnDuplicateWrites.Error` - Return error on duplicates (default) + - `OnDuplicateWrites.Ignore` - Silently skip duplicate writes + +- **OnMissingDeletes**: Controls behavior when deleting a tuple that doesn't exist + - `OnMissingDeletes.Error` - Return error on missing deletes (default) + - `OnMissingDeletes.Ignore` - Silently skip missing deletes + +```csharp +var body = new ClientWriteRequest() { + Writes = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }, + Deletes = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "writer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }, +}; +var options = new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnDuplicateWrites = OnDuplicateWrites.Ignore, + OnMissingDeletes = OnMissingDeletes.Ignore + } +}; +var response = await fgaClient.Write(body, options); +``` + #### Relationship Queries ##### Check diff --git a/config/clients/dotnet/template/README_initializing.mustache b/config/clients/dotnet/template/README_initializing.mustache index ef6241f5..35de85f1 100644 --- a/config/clients/dotnet/template/README_initializing.mustache +++ b/config/clients/dotnet/template/README_initializing.mustache @@ -110,7 +110,7 @@ var configuration = new ClientConfiguration() { { "X-Request-Source", "my-app" } } }; -var fgaClient = new OpenFgaClient(configuration); +var fgaClient = new {{appShortName}}Client(configuration); ``` #### Per-Request Headers