From 023590f206c691d72ba1a975d32b5bfcc30a29e2 Mon Sep 17 00:00:00 2001 From: Evan Sims Date: Thu, 16 Oct 2025 17:46:58 -0500 Subject: [PATCH 1/7] feat(DXAZD-1962): add write conflict resolution options --- config/clients/dotnet/CHANGELOG.md.mustache | 9 + .../dotnet/template/Client/Client.mustache | 17 +- .../Client/Model/ClientWriteOptions.mustache | 32 ++ .../template/OpenFgaClientTests.mustache | 484 ++++++++++++++++++ .../template/README_calling_api.mustache | 39 ++ 5 files changed, 578 insertions(+), 3 deletions(-) diff --git a/config/clients/dotnet/CHANGELOG.md.mustache b/config/clients/dotnet/CHANGELOG.md.mustache index f58d6fbe3..c405c01c2 100644 --- a/config/clients/dotnet/CHANGELOG.md.mustache +++ b/config/clients/dotnet/CHANGELOG.md.mustache @@ -8,6 +8,11 @@ - `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 +- 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 2e0a27375..0d615243b 100644 --- a/config/clients/dotnet/template/Client/Client.mustache +++ b/config/clients/dotnet/template/Client/Client.mustache @@ -341,10 +341,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()), + options?.Conflict?.OnDuplicateWrites ?? WriteRequestWrites.OnDuplicateEnum.Error + ); } 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()), + options?.Conflict?.OnMissingDeletes ?? WriteRequestDeletes.OnMissingEnum.Error + ); } await api.Write(GetStoreId(options), requestBody, options, cancellationToken); @@ -360,7 +366,12 @@ public class {{appShortName}}Client : IDisposable { }; } - var clientWriteOpts = new ClientWriteOptions() { StoreId = StoreId, AuthorizationModelId = authorizationModelId, Headers = options?.Headers }; + var clientWriteOpts = new ClientWriteOptions() { + StoreId = StoreId, + AuthorizationModelId = authorizationModelId, + Headers = ExtractHeaders(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 a47287c43..a0f923d3b 100644 --- a/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache @@ -1,9 +1,37 @@ {{>partial_header}} +using {{packageName}}.Model; using System.Collections.Generic; namespace {{packageName}}.Client.Model; +/// +/// ConflictOptions - Controls behavior for duplicate writes and missing deletes +/// +public interface IConflictOptions { + /// + /// Controls behavior when writing a tuple that already exists + /// + WriteRequestWrites.OnDuplicateEnum? OnDuplicateWrites { get; set; } + + /// + /// Controls behavior when deleting a tuple that doesn't exist + /// + WriteRequestDeletes.OnMissingEnum? OnMissingDeletes { get; set; } +} + +public class ConflictOptions : IConflictOptions { + /// + /// Controls behavior when writing a tuple that already exists + /// + public WriteRequestWrites.OnDuplicateEnum? OnDuplicateWrites { get; set; } + + /// + /// Controls behavior when deleting a tuple that doesn't exist + /// + public WriteRequestDeletes.OnMissingEnum? OnMissingDeletes { get; set; } +} + /// /// TransactionOpts /// @@ -43,6 +71,7 @@ public class TransactionOptions : ITransactionOpts { public interface IClientWriteOptions : IClientRequestOptionsWithAuthZModelId { ITransactionOpts Transaction { get; set; } + IConflictOptions? Conflict { get; set; } } public class ClientWriteOptions : IClientWriteOptions { @@ -57,6 +86,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 f2b761766..ca4c00313 100644 --- a/config/clients/dotnet/template/OpenFgaClientTests.mustache +++ b/config/clients/dotnet/template/OpenFgaClientTests.mustache @@ -2172,6 +2172,490 @@ 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", + } + }, + }; + + var response = await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnDuplicateWrites = WriteRequestWrites.OnDuplicateEnum.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", + } + }, + }; + + var response = await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnDuplicateWrites = WriteRequestWrites.OnDuplicateEnum.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", + } + }, + }; + + var response = await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnMissingDeletes = WriteRequestDeletes.OnMissingEnum.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", + } + }, + }; + + var response = await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnMissingDeletes = WriteRequestDeletes.OnMissingEnum.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", + } + }, + }; + + var response = await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnDuplicateWrites = WriteRequestWrites.OnDuplicateEnum.Ignore, + OnMissingDeletes = WriteRequestDeletes.OnMissingEnum.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", + } + }, + }; + + var response = await fgaClient.Write(body, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Transaction = new TransactionOptions { + Disable = true, + MaxPerChunk = 1 + }, + Conflict = new ConflictOptions { + OnDuplicateWrites = WriteRequestWrites.OnDuplicateEnum.Ignore, + OnMissingDeletes = WriteRequestDeletes.OnMissingEnum.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 + var response = 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; + 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 tuples = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }; + + var response = await fgaClient.WriteTuples(tuples, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnDuplicateWrites = WriteRequestWrites.OnDuplicateEnum.Ignore + } + }); + + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest.Writes); + Assert.Equal(WriteRequestWrites.OnDuplicateEnum.Ignore, capturedRequest.Writes.OnDuplicate); + } + + /// + /// Test DeleteTuples convenience method with conflict options + /// + [Fact] + public async Task DeleteTuples_WithConflictOptions_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 tuples = new List { + new() { + User = "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + Relation = "viewer", + Object = "document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a", + } + }; + + var response = await fgaClient.DeleteTuples(tuples, new ClientWriteOptions { + AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", + Conflict = new ConflictOptions { + OnMissingDeletes = WriteRequestDeletes.OnMissingEnum.Ignore + } + }); + + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest.Deletes); + Assert.Equal(WriteRequestDeletes.OnMissingEnum.Ignore, capturedRequest.Deletes.OnMissing); + } + /// /// Test Read with custom headers /// diff --git a/config/clients/dotnet/template/README_calling_api.mustache b/config/clients/dotnet/template/README_calling_api.mustache index ecbfe04bb..1b18a7a59 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 + - `WriteRequestWrites.OnDuplicateEnum.Error` - Return error on duplicates (default) + - `WriteRequestWrites.OnDuplicateEnum.Ignore` - Silently skip duplicate writes + +- **OnMissingDeletes**: Controls behavior when deleting a tuple that doesn't exist + - `WriteRequestDeletes.OnMissingEnum.Error` - Return error on missing deletes (default) + - `WriteRequestDeletes.OnMissingEnum.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 = WriteRequestWrites.OnDuplicateEnum.Ignore, + OnMissingDeletes = WriteRequestDeletes.OnMissingEnum.Ignore + } +}; +var response = await fgaClient.Write(body, options); +``` + #### Relationship Queries ##### Check From 60f48f3cd556bf8514b3ae28d8b6391df08937e0 Mon Sep 17 00:00:00 2001 From: Evan Sims Date: Thu, 16 Oct 2025 18:07:12 -0500 Subject: [PATCH 2/7] refactor: enum naming improvements --- .../dotnet/template/Client/Client.mustache | 18 ++++++++- .../Client/Model/ClientWriteOptions.mustache | 38 +++++++++++++++++-- .../template/OpenFgaClientTests.mustache | 20 +++++----- .../template/README_calling_api.mustache | 12 +++--- 4 files changed, 66 insertions(+), 22 deletions(-) diff --git a/config/clients/dotnet/template/Client/Client.mustache b/config/clients/dotnet/template/Client/Client.mustache index 0d615243b..b11f372b0 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 * **********/ @@ -343,13 +357,13 @@ public class {{appShortName}}Client : IDisposable { if (body.Writes?.Count > 0) { requestBody.Writes = new WriteRequestWrites( body.Writes.ConvertAll(key => key.ToTupleKey()), - options?.Conflict?.OnDuplicateWrites ?? WriteRequestWrites.OnDuplicateEnum.Error + MapOnDuplicateWrites(options?.Conflict?.OnDuplicateWrites) ); } if (body.Deletes?.Count > 0) { requestBody.Deletes = new WriteRequestDeletes( body.Deletes.ConvertAll(key => key.ToTupleKeyWithoutCondition()), - options?.Conflict?.OnMissingDeletes ?? WriteRequestDeletes.OnMissingEnum.Error + MapOnMissingDeletes(options?.Conflict?.OnMissingDeletes) ); } diff --git a/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache index a0f923d3b..4fe151505 100644 --- a/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache @@ -5,6 +5,36 @@ 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 /// @@ -12,24 +42,24 @@ public interface IConflictOptions { /// /// Controls behavior when writing a tuple that already exists /// - WriteRequestWrites.OnDuplicateEnum? OnDuplicateWrites { get; set; } + OnDuplicateWrites? OnDuplicateWrites { get; set; } /// /// Controls behavior when deleting a tuple that doesn't exist /// - WriteRequestDeletes.OnMissingEnum? OnMissingDeletes { get; set; } + OnMissingDeletes? OnMissingDeletes { get; set; } } public class ConflictOptions : IConflictOptions { /// /// Controls behavior when writing a tuple that already exists /// - public WriteRequestWrites.OnDuplicateEnum? OnDuplicateWrites { get; set; } + public OnDuplicateWrites? OnDuplicateWrites { get; set; } /// /// Controls behavior when deleting a tuple that doesn't exist /// - public WriteRequestDeletes.OnMissingEnum? OnMissingDeletes { get; set; } + public OnMissingDeletes? OnMissingDeletes { get; set; } } /// diff --git a/config/clients/dotnet/template/OpenFgaClientTests.mustache b/config/clients/dotnet/template/OpenFgaClientTests.mustache index ca4c00313..a7c029cd8 100644 --- a/config/clients/dotnet/template/OpenFgaClientTests.mustache +++ b/config/clients/dotnet/template/OpenFgaClientTests.mustache @@ -2212,7 +2212,7 @@ public class {{appShortName}}ClientTests : IDisposable { var response = await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { - OnDuplicateWrites = WriteRequestWrites.OnDuplicateEnum.Ignore + OnDuplicateWrites = OnDuplicateWrites.Ignore } }); @@ -2261,7 +2261,7 @@ public class {{appShortName}}ClientTests : IDisposable { var response = await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { - OnDuplicateWrites = WriteRequestWrites.OnDuplicateEnum.Error + OnDuplicateWrites = OnDuplicateWrites.Error } }); @@ -2310,7 +2310,7 @@ public class {{appShortName}}ClientTests : IDisposable { var response = await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { - OnMissingDeletes = WriteRequestDeletes.OnMissingEnum.Ignore + OnMissingDeletes = OnMissingDeletes.Ignore } }); @@ -2359,7 +2359,7 @@ public class {{appShortName}}ClientTests : IDisposable { var response = await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { - OnMissingDeletes = WriteRequestDeletes.OnMissingEnum.Error + OnMissingDeletes = OnMissingDeletes.Error } }); @@ -2415,8 +2415,8 @@ public class {{appShortName}}ClientTests : IDisposable { var response = await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { - OnDuplicateWrites = WriteRequestWrites.OnDuplicateEnum.Ignore, - OnMissingDeletes = WriteRequestDeletes.OnMissingEnum.Ignore + OnDuplicateWrites = OnDuplicateWrites.Ignore, + OnMissingDeletes = OnMissingDeletes.Ignore } }); @@ -2486,8 +2486,8 @@ public class {{appShortName}}ClientTests : IDisposable { MaxPerChunk = 1 }, Conflict = new ConflictOptions { - OnDuplicateWrites = WriteRequestWrites.OnDuplicateEnum.Ignore, - OnMissingDeletes = WriteRequestDeletes.OnMissingEnum.Ignore + OnDuplicateWrites = OnDuplicateWrites.Ignore, + OnMissingDeletes = OnMissingDeletes.Ignore } }); @@ -2600,7 +2600,7 @@ public class {{appShortName}}ClientTests : IDisposable { var response = await fgaClient.WriteTuples(tuples, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { - OnDuplicateWrites = WriteRequestWrites.OnDuplicateEnum.Ignore + OnDuplicateWrites = OnDuplicateWrites.Ignore } }); @@ -2647,7 +2647,7 @@ public class {{appShortName}}ClientTests : IDisposable { var response = await fgaClient.DeleteTuples(tuples, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { - OnMissingDeletes = WriteRequestDeletes.OnMissingEnum.Ignore + OnMissingDeletes = OnMissingDeletes.Ignore } }); diff --git a/config/clients/dotnet/template/README_calling_api.mustache b/config/clients/dotnet/template/README_calling_api.mustache index 1b18a7a59..4f2d7b442 100644 --- a/config/clients/dotnet/template/README_calling_api.mustache +++ b/config/clients/dotnet/template/README_calling_api.mustache @@ -323,12 +323,12 @@ var response = await fgaClient.Write(body, options); 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 - - `WriteRequestWrites.OnDuplicateEnum.Error` - Return error on duplicates (default) - - `WriteRequestWrites.OnDuplicateEnum.Ignore` - Silently skip duplicate writes + - `OnDuplicateWrites.Error` - Return error on duplicates (default) + - `OnDuplicateWrites.Ignore` - Silently skip duplicate writes - **OnMissingDeletes**: Controls behavior when deleting a tuple that doesn't exist - - `WriteRequestDeletes.OnMissingEnum.Error` - Return error on missing deletes (default) - - `WriteRequestDeletes.OnMissingEnum.Ignore` - Silently skip missing deletes + - `OnMissingDeletes.Error` - Return error on missing deletes (default) + - `OnMissingDeletes.Ignore` - Silently skip missing deletes ```csharp var body = new ClientWriteRequest() { @@ -350,8 +350,8 @@ var body = new ClientWriteRequest() { var options = new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { - OnDuplicateWrites = WriteRequestWrites.OnDuplicateEnum.Ignore, - OnMissingDeletes = WriteRequestDeletes.OnMissingEnum.Ignore + OnDuplicateWrites = OnDuplicateWrites.Ignore, + OnMissingDeletes = OnMissingDeletes.Ignore } }; var response = await fgaClient.Write(body, options); From 9d48e9fdb1192ae64ade9318e542973b524f740d Mon Sep 17 00:00:00 2001 From: Evan Sims Date: Thu, 16 Oct 2025 18:29:41 -0500 Subject: [PATCH 3/7] refactor: implement coderabbit feedback --- .../dotnet/template/Client/Client.mustache | 2 +- .../template/OpenFgaClientTests.mustache | 30 ++++++++++--------- .../template/README_initializing.mustache | 2 +- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/config/clients/dotnet/template/Client/Client.mustache b/config/clients/dotnet/template/Client/Client.mustache index b11f372b0..80c3b078d 100644 --- a/config/clients/dotnet/template/Client/Client.mustache +++ b/config/clients/dotnet/template/Client/Client.mustache @@ -381,7 +381,7 @@ public class {{appShortName}}Client : IDisposable { } var clientWriteOpts = new ClientWriteOptions() { - StoreId = StoreId, + StoreId = options?.StoreId ?? StoreId, AuthorizationModelId = authorizationModelId, Headers = ExtractHeaders(options)?.Headers, Conflict = options?.Conflict diff --git a/config/clients/dotnet/template/OpenFgaClientTests.mustache b/config/clients/dotnet/template/OpenFgaClientTests.mustache index a7c029cd8..ef6ec3e82 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", }); @@ -2209,7 +2212,7 @@ public class {{appShortName}}ClientTests : IDisposable { }, }; - var response = await fgaClient.Write(body, new ClientWriteOptions { + await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { OnDuplicateWrites = OnDuplicateWrites.Ignore @@ -2258,7 +2261,7 @@ public class {{appShortName}}ClientTests : IDisposable { }, }; - var response = await fgaClient.Write(body, new ClientWriteOptions { + await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { OnDuplicateWrites = OnDuplicateWrites.Error @@ -2307,7 +2310,7 @@ public class {{appShortName}}ClientTests : IDisposable { }, }; - var response = await fgaClient.Write(body, new ClientWriteOptions { + await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { OnMissingDeletes = OnMissingDeletes.Ignore @@ -2356,7 +2359,7 @@ public class {{appShortName}}ClientTests : IDisposable { }, }; - var response = await fgaClient.Write(body, new ClientWriteOptions { + await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { OnMissingDeletes = OnMissingDeletes.Error @@ -2412,7 +2415,7 @@ public class {{appShortName}}ClientTests : IDisposable { }, }; - var response = await fgaClient.Write(body, new ClientWriteOptions { + await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { OnDuplicateWrites = OnDuplicateWrites.Ignore, @@ -2479,7 +2482,7 @@ public class {{appShortName}}ClientTests : IDisposable { }, }; - var response = await fgaClient.Write(body, new ClientWriteOptions { + await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Transaction = new TransactionOptions { Disable = true, @@ -2550,7 +2553,7 @@ public class {{appShortName}}ClientTests : IDisposable { }; // Don't specify Conflict options - should use API defaults - var response = await fgaClient.Write(body, new ClientWriteOptions { + await fgaClient.Write(body, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1" }); @@ -2597,7 +2600,7 @@ public class {{appShortName}}ClientTests : IDisposable { } }; - var response = await fgaClient.WriteTuples(tuples, new ClientWriteOptions { + await fgaClient.WriteTuples(tuples, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { OnDuplicateWrites = OnDuplicateWrites.Ignore @@ -2644,7 +2647,7 @@ public class {{appShortName}}ClientTests : IDisposable { } }; - var response = await fgaClient.DeleteTuples(tuples, new ClientWriteOptions { + await fgaClient.DeleteTuples(tuples, new ClientWriteOptions { AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", Conflict = new ConflictOptions { OnMissingDeletes = OnMissingDeletes.Ignore @@ -2880,8 +2883,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_initializing.mustache b/config/clients/dotnet/template/README_initializing.mustache index ef6241f52..35de85f1d 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 From 2eac4393c097ef36e8780a965e2eee760b02b5b3 Mon Sep 17 00:00:00 2001 From: Evan Sims Date: Thu, 16 Oct 2025 19:04:00 -0500 Subject: [PATCH 4/7] refactor: implement AI feedback --- config/clients/dotnet/CHANGELOG.md.mustache | 2 +- .../template/OpenFgaClientTests.mustache | 48 +++++++++++++------ 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/config/clients/dotnet/CHANGELOG.md.mustache b/config/clients/dotnet/CHANGELOG.md.mustache index c405c01c2..f98ea0552 100644 --- a/config/clients/dotnet/CHANGELOG.md.mustache +++ b/config/clients/dotnet/CHANGELOG.md.mustache @@ -7,7 +7,7 @@ - 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 diff --git a/config/clients/dotnet/template/OpenFgaClientTests.mustache b/config/clients/dotnet/template/OpenFgaClientTests.mustache index ef6ec3e82..865894a30 100644 --- a/config/clients/dotnet/template/OpenFgaClientTests.mustache +++ b/config/clients/dotnet/template/OpenFgaClientTests.mustache @@ -2571,6 +2571,7 @@ public class {{appShortName}}ClientTests : IDisposable { [Fact] public async Task WriteTuples_WithConflictOptions_ShouldPassOptionToApi() { WriteRequest? capturedRequest = null; + HttpResponseMessage? responseMsg = null; var mockHandler = new Mock(MockBehavior.Strict); mockHandler.Protected() .Setup>( @@ -2580,9 +2581,12 @@ public class {{appShortName}}ClientTests : IDisposable { req.Method == HttpMethod.Post), ItExpr.IsAny() ) - .ReturnsAsync(new HttpResponseMessage() { - StatusCode = HttpStatusCode.OK, - Content = Utils.CreateJsonStringContent(new Object()), + .ReturnsAsync(() => { + responseMsg = new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(new Object()), + }; + return responseMsg; }) .Callback((req, _) => { var content = req.Content!.ReadAsStringAsync().Result; @@ -2600,16 +2604,24 @@ public class {{appShortName}}ClientTests : IDisposable { } }; - await fgaClient.WriteTuples(tuples, new ClientWriteOptions { - AuthorizationModelId = "01GXSA8YR785C4FYS3C0RTG7B1", - Conflict = new ConflictOptions { - OnDuplicateWrites = OnDuplicateWrites.Ignore - } - }); + 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); + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest.Writes); + Assert.Equal(WriteRequestWrites.OnDuplicateEnum.Ignore, capturedRequest.Writes.OnDuplicate); + } + finally + { + if (responseMsg != null) + responseMsg.Dispose(); + } } /// @@ -2618,6 +2630,7 @@ public class {{appShortName}}ClientTests : IDisposable { [Fact] public async Task DeleteTuples_WithConflictOptions_ShouldPassOptionToApi() { WriteRequest? capturedRequest = null; + HttpResponseMessage? httpResponse = null; var mockHandler = new Mock(MockBehavior.Strict); mockHandler.Protected() .Setup>( @@ -2627,9 +2640,12 @@ public class {{appShortName}}ClientTests : IDisposable { req.Method == HttpMethod.Post), ItExpr.IsAny() ) - .ReturnsAsync(new HttpResponseMessage() { - StatusCode = HttpStatusCode.OK, - Content = Utils.CreateJsonStringContent(new Object()), + .ReturnsAsync(() => { + httpResponse = new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(new Object()), + }; + return httpResponse; }) .Callback((req, _) => { var content = req.Content!.ReadAsStringAsync().Result; @@ -2657,6 +2673,8 @@ public class {{appShortName}}ClientTests : IDisposable { Assert.NotNull(capturedRequest); Assert.NotNull(capturedRequest.Deletes); Assert.Equal(WriteRequestDeletes.OnMissingEnum.Ignore, capturedRequest.Deletes.OnMissing); + + httpResponse?.Dispose(); } /// From 49a7adca44237a1b07a96009d4969c9771ac6748 Mon Sep 17 00:00:00 2001 From: Evan Sims Date: Thu, 16 Oct 2025 21:09:41 -0500 Subject: [PATCH 5/7] refactor: implement AI feedback --- config/clients/dotnet/template/README_calling_api.mustache | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/clients/dotnet/template/README_calling_api.mustache b/config/clients/dotnet/template/README_calling_api.mustache index 4f2d7b442..a115856dc 100644 --- a/config/clients/dotnet/template/README_calling_api.mustache +++ b/config/clients/dotnet/template/README_calling_api.mustache @@ -318,7 +318,7 @@ var options = new ClientWriteOptions { var response = await fgaClient.Write(body, options); ``` -###### Conflict Options for Write Operations +##### Conflict Options for Write Operations The SDK allows you to control how write conflicts are handled using the `Conflict` option: From 6c48c76bf4a4422c9e331a9ac3e055a7e6336cd9 Mon Sep 17 00:00:00 2001 From: Evan Sims Date: Mon, 20 Oct 2025 19:22:38 -0500 Subject: [PATCH 6/7] fix: merge conflict --- config/clients/dotnet/template/Client/Client.mustache | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/clients/dotnet/template/Client/Client.mustache b/config/clients/dotnet/template/Client/Client.mustache index 80c3b078d..2677273e0 100644 --- a/config/clients/dotnet/template/Client/Client.mustache +++ b/config/clients/dotnet/template/Client/Client.mustache @@ -383,7 +383,7 @@ public class {{appShortName}}Client : IDisposable { var clientWriteOpts = new ClientWriteOptions() { StoreId = options?.StoreId ?? StoreId, AuthorizationModelId = authorizationModelId, - Headers = ExtractHeaders(options)?.Headers, + Headers = options?.Headers, Conflict = options?.Conflict }; From 200396cbf4ce0d6755074360e6634c72eb40a395 Mon Sep 17 00:00:00 2001 From: Evan Sims Date: Mon, 20 Oct 2025 19:35:42 -0500 Subject: [PATCH 7/7] refactor: implement AI feedback --- config/clients/dotnet/template/Client/Client.mustache | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/clients/dotnet/template/Client/Client.mustache b/config/clients/dotnet/template/Client/Client.mustache index 2677273e0..483d1632f 100644 --- a/config/clients/dotnet/template/Client/Client.mustache +++ b/config/clients/dotnet/template/Client/Client.mustache @@ -381,7 +381,7 @@ public class {{appShortName}}Client : IDisposable { } var clientWriteOpts = new ClientWriteOptions() { - StoreId = options?.StoreId ?? StoreId, + StoreId = GetStoreId(options), AuthorizationModelId = authorizationModelId, Headers = options?.Headers, Conflict = options?.Conflict