diff --git a/config/clients/dotnet/CHANGELOG.md.mustache b/config/clients/dotnet/CHANGELOG.md.mustache index 4bb9668cf..f58d6fbe3 100644 --- a/config/clients/dotnet/CHANGELOG.md.mustache +++ b/config/clients/dotnet/CHANGELOG.md.mustache @@ -1,34 +1,75 @@ # Changelog -## [Unreleased](https://github.com/openfga/dotnet-sdk/compare/v{{packageVersion}}...HEAD) +## [Unreleased](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v{{packageVersion}}...HEAD) + +- feat!: add per-request custom headers support + - `DefaultHeaders` support to `ClientConfiguration` for headers sent with every request + - 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 + +[!WARNING] +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" } } + }; + await api.Check(storeId, body, options, cancellationToken); + ``` + +- **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; + ``` + +Note: If you are using the high-level `OpenFgaClient`, no changes are required to your existing code. The new headers functionality is additive via the existing options parameters. ## v{{packageVersion}} ### [{{packageVersion}}](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.6.0...v{{packageVersion}}) (2025-10-01) + - feat!: add support for .NET Standard 2.0, .NET 8.0 and .NET 9.0 [!WARNING] BREAKING CHANGES: + - While we have dropped .NET 6.0 as a target framework, the SDK now supports .NET Standard 2.0 - which means it can still be used in .NET 6.0 projects. - We have updated the underlying OpenAPI generator to a newer version, which has caused some changes in the generated code. Below is a summary of the changes: -| Old Name | New Name | Affected Models | -| -- | -- | -- | -| _Nodes | VarNodes | Nodes (and related constructors/references) | -| _Users | VarUsers | Users (and related constructors/references) | -| _base | VarBase | Difference, UsersetTreeDifference | -| _object | VarObject | CheckRequestTupleKey, ExpandRequestTupleKey, TupleKey, TupleKeyWithoutCondition, FgaObject, ObjectRelation, ReadRequestTupleKey, User | -| _this | VarThis | Userset | -| UNSPECIFIED | TYPENAMEUNSPECIFIED | TypeName (and other members in this enum) | +| Old Name | New Name | Affected Models | +| ----------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| \_Nodes | VarNodes | Nodes (and related constructors/references) | +| \_Users | VarUsers | Users (and related constructors/references) | +| \_base | VarBase | Difference, UsersetTreeDifference | +| \_object | VarObject | CheckRequestTupleKey, ExpandRequestTupleKey, TupleKey, TupleKeyWithoutCondition, FgaObject, ObjectRelation, ReadRequestTupleKey, User | +| \_this | VarThis | Userset | +| UNSPECIFIED | TYPENAMEUNSPECIFIED | TypeName (and other members in this enum) | -| Old Enum Value | New Enum Value | Affected Enum | -| -- | -- | -- | -| WRITE | TUPLEOPERATIONWRITE | TupleOperation | -| DELETE | TUPLEOPERATIONDELETE | TupleOperation | +| Old Enum Value | New Enum Value | Affected Enum | +| -------------- | -------------------- | -------------- | +| WRITE | TUPLEOPERATIONWRITE | TupleOperation | +| DELETE | TUPLEOPERATIONDELETE | TupleOperation | ## v0.6.0 -### [0.6.0](https://github.com/openfga/dotnet-sdk/compare/v0.5.1...v0.6.0) (2025-09-30) +### [0.6.0](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.5.1...v0.6.0) (2025-09-30) - feat: add support for `start_time` parameter in `ReadChanges` endpoint - feat: update API definitions @@ -42,11 +83,13 @@ BREAKING CHANGES: [!WARNING] BREAKING CHANGES: + - The `ListStores` method now accepts a body parameter with an optional `Name` to filter the stores. This is a breaking change as it changes the method contract to allow passing in a body with the name. ## v0.5.1 ### [0.5.1](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.5.0...v0.5.1) (2024-09-09) + - feat: export OpenTelemetry metrics. Refer to the [https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/blob/main/OpenTelemetry.md](documentation) for more. ## v0.5.0 @@ -54,12 +97,13 @@ BREAKING CHANGES: ### [0.5.0](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.4.0...v0.5.0) (2024-08-28) - feat: support consistency parameter (#70) -Note: To use this feature, you need to be running OpenFGA v1.5.7+ with the experimental flag `enable-consistency-params` enabled. -See the [v1.5.7 release notes](https://github.com/openfga/openfga/releases/tag/v1.5.7) for details. + Note: To use this feature, you need to be running OpenFGA v1.5.7+ with the experimental flag `enable-consistency-params` enabled. + See the [v1.5.7 release notes](https://github.com/openfga/openfga/releases/tag/v1.5.7) for details. ## v0.4.0 ### [0.4.0](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.3.2...v0.4.0) (2024-06-14) + - chore!: remove excluded users from ListUsers response BREAKING CHANGE: @@ -84,6 +128,7 @@ for more details see the [associated API change](https://github.com/openfga/api/ ## v0.3.0 ### [0.3.0](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.2.5...v0.3.0) (2023-12-20) + - feat!: initial support for [conditions](https://openfga.dev/blog/conditional-tuples-announcement) - feat!: allow overriding storeId per request (#33) - feat: support specifying a port and path for the API (You can now set the `ApiUrl` to something like: `https://api.fga.exampleL8080/some_path`) @@ -109,6 +154,7 @@ and so the Pointer-to-String conversion is no longer needed. Some of the changes to expect: - When initializing a client, please use `ApiUrl`. The separate `ApiScheme` and `ApiHost` fields have been deprecated + ```csharp var configuration = new ClientConfiguration() { ApiUrl = Environment.GetEnvironmentVariable("FGA_API_URL"), // required, e.g. https://api.fga.example @@ -117,40 +163,44 @@ var configuration = new ClientConfiguration() { }; var fgaClient = new OpenFgaClient(configuration); ``` + - `OpenFgaApi` now requires `storeId` as first param when needed - `Configuration` no longer accepts `storeId` (`ClientConfiguration` is not affected) - The following request interfaces changed: - - `CheckRequest`: the `TupleKey` field is now of interface `CheckRequestTupleKey`, you can also now pass in `Context` - - `ExpandRequest`: the `TupleKey` field is now of interface `ExpandRequestTupleKey` - - `ReadRequest`: the `TupleKey` field is now of interface `ReadRequestTupleKey` - - `WriteRequest`: now takes `WriteRequestWrites` and `WriteRequestDeletes`, the latter of which accepts `TupleKeyWithoutCondition` - - And more + - `CheckRequest`: the `TupleKey` field is now of interface `CheckRequestTupleKey`, you can also now pass in `Context` + - `ExpandRequest`: the `TupleKey` field is now of interface `ExpandRequestTupleKey` + - `ReadRequest`: the `TupleKey` field is now of interface `ReadRequestTupleKey` + - `WriteRequest`: now takes `WriteRequestWrites` and `WriteRequestDeletes`, the latter of which accepts `TupleKeyWithoutCondition` + - And more - The following interfaces had fields that were pointers are are now the direct value: - - `CreateStoreResponse` - - `GetStoreResponse` - - `ListStoresResponse` - - `ListObjectsResponse` - - `ReadChangesResponse` - - `ReadResponse` - - `AuthorizationModel` - - And more + - `CreateStoreResponse` + - `GetStoreResponse` + - `ListStoresResponse` + - `ListObjectsResponse` + - `ReadChangesResponse` + - `ReadResponse` + - `AuthorizationModel` + - And more Take a look at https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/commit/fa43463ded102df3f660bae6d741e1a8c1dea090 for more model changes. ## v0.2.5 ### [0.2.5](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.2.4...v0.2.5) (2023-12-01) + - fix(client): read with no filter (read all tuples) - chore(deps): update dependencies ## v0.2.4 ### [0.2.4](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.2.3...v0.2.4) (2023-05-01) + - fix: client credentials token expiry period was being evaluated as ms instead of seconds, leading to token refreshes on every call ## v0.2.3 ### [0.2.3](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.2.2...v0.2.3) (2023-04-13) + - fix: changed interface of contextual tuples in `ClientListObjects` to be `ClientTupleKey` instead of `TupleKey` - fix: Client `WriteAuthorizationModel` now expects `ClientWriteAuthorizationModelRequest` instead of `WriteAuthorizationModelRequest` - chore: changed a few interfaces to expect interfaces instead of classes @@ -179,13 +229,16 @@ Take a look at https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/commit/fa43463ded Updated to include support for [OpenFGA 0.3.0](https://github.com/openfga/openfga/releases/tag/v0.3.0) Changes: + - [BREAKING] feat(list-objects)!: response has been changed to include the object type - e.g. response that was `{"object_ids":["roadmap"]}`, will now be `{"objects":["document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a"]}` + e.g. response that was `{"object_ids":["roadmap"]}`, will now be `{"objects":["document:0192ab2a-d83f-756d-9397-c5ed9f3cb69a"]}` Fixes: + - fix(models): update interfaces that had incorrectly optional fields to make them required Chore: + - chore(deps): update dev dependencies ## v0.1.2 @@ -193,8 +246,8 @@ Chore: ### [0.1.2](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.1.1...v0.1.2) (2022-11-15) - feat: regenerate from latest API Document, changes include: - - documentation fixes - - types that represent enabling wildcards in authorization models + - documentation fixes + - types that represent enabling wildcards in authorization models - fix: send authorization header to server when ApiToken used (https://github.com/openfga/sdk-generator/issues/58) - chore: update test dependencies @@ -202,14 +255,14 @@ Chore: ### [0.1.1](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.1.0...v0.1.1) (2022-10-07) -- Fix for issue in deserializing nullable DateTime (https://github.com/openfga/dotnet-sdk/issues/5) +- Fix for issue in deserializing nullable DateTime (https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/issues/5) ## v0.1.0 ### [0.1.0](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/compare/v0.0.3...v0.1.0) (2022-09-29) - BREAKING: exported interface `TypeDefinitions` is now `WriteAuthorizationModelRequest` - This is only a breaking change on the SDK, not the API. It was changed to conform to the proto changes in [openfga/api](https://github.com/openfga/api/pull/27). + This is only a breaking change on the SDK, not the API. It was changed to conform to the proto changes in [openfga/api](https://github.com/openfga/api/pull/27). - chore(deps): upgrade dependencies ## v0.0.3 @@ -245,6 +298,7 @@ var response = await {{appCamelCaseName}}Api.ListObjects(body); ### [0.0.1](https://{{gitHost}}/{{gitUserId}}/{{gitRepoId}}/releases/tag/v0.0.1) (2022-06-17) Initial OpenFGA .NET SDK release + - Support for [OpenFGA](https://github.com/openfga/openfga) API - CRUD stores - Create, read & list authorization models diff --git a/config/clients/dotnet/config.overrides.json b/config/clients/dotnet/config.overrides.json index 1aa4edba4..5bb78cb7d 100644 --- a/config/clients/dotnet/config.overrides.json +++ b/config/clients/dotnet/config.overrides.json @@ -330,6 +330,10 @@ "destinationFilename": "src/OpenFga.Sdk/Model/JsonStringEnumMemberConverter.cs", "templateType": "SupportingFiles" }, + "modelRequestOptions.mustache": { + "destinationFilename": "src/OpenFga.Sdk/Model/RequestOptions.cs", + "templateType": "SupportingFiles" + }, ".fossa.yml.mustache": { "destinationFilename": ".fossa.yml", "templateType": "SupportingFiles" diff --git a/config/clients/dotnet/template/Client/Client.mustache b/config/clients/dotnet/template/Client/Client.mustache index 23d1d0502..2e0a27375 100644 --- a/config/clients/dotnet/template/Client/Client.mustache +++ b/config/clients/dotnet/template/Client/Client.mustache @@ -9,6 +9,7 @@ using System.Threading; using System.Threading.Tasks; using {{packageName}}.Api; +using {{packageName}}.ApiClient; using {{packageName}}.Client.Model; #if NETSTANDARD2_0 || NET48 using {{packageName}}.Client.Extensions; @@ -53,8 +54,7 @@ public class {{appShortName}}Client : IDisposable { public void Dispose() => api.Dispose(); - - #if NET6_0_OR_GREATER +#if NET6_0_OR_GREATER private async Task ProcessWriteChunksAsync( IEnumerable chunks, Func createRequest, @@ -63,7 +63,7 @@ public class {{appShortName}}Client : IDisposable { ClientWriteOptions clientWriteOpts, int maxParallelReqs, CancellationToken cancellationToken) { - + await Parallel.ForEachAsync(chunks, new ParallelOptions { MaxDegreeOfParallelism = maxParallelReqs, CancellationToken = cancellationToken }, async (chunk, token) => { @@ -96,7 +96,7 @@ public class {{appShortName}}Client : IDisposable { IClientBatchCheckOptions? options, int maxParallelReqs, CancellationToken cancellationToken) { - + await Parallel.ForEachAsync(requests, new ParallelOptions { MaxDegreeOfParallelism = maxParallelReqs, CancellationToken = cancellationToken }, async (request, token) => { @@ -114,7 +114,7 @@ public class {{appShortName}}Client : IDisposable { } }); } - #else +#else private async Task ProcessWriteChunksAsync( IEnumerable chunks, Func createRequest, @@ -123,7 +123,7 @@ public class {{appShortName}}Client : IDisposable { ClientWriteOptions clientWriteOpts, int maxParallelReqs, CancellationToken cancellationToken) { - + using (var throttler = new SemaphoreSlim(maxParallelReqs)) { var tasks = chunks.Select(async chunk => { await throttler.WaitAsync(cancellationToken); @@ -164,7 +164,7 @@ public class {{appShortName}}Client : IDisposable { IClientBatchCheckOptions? options, int maxParallelReqs, CancellationToken cancellationToken) { - + using (var throttler = new SemaphoreSlim(maxParallelReqs)) { var tasks = requests.Select(async request => { await throttler.WaitAsync(cancellationToken); @@ -190,7 +190,7 @@ public class {{appShortName}}Client : IDisposable { await Task.WhenAll(tasks); } } - #endif +#endif private string GetStoreId(StoreIdOptions? options) { var storeId = options?.StoreId ?? StoreId; @@ -221,27 +221,27 @@ public class {{appShortName}}Client : IDisposable { */ public async Task ListStores(IClientListStoresRequest? body, IClientListStoresOptions? options = default, CancellationToken cancellationToken = default) => - await api.ListStores(options?.PageSize, options?.ContinuationToken, body?.Name, cancellationToken); + await api.ListStores(options?.PageSize, options?.ContinuationToken, body?.Name, options, cancellationToken); /** * CreateStore - Initialize a store */ public async Task CreateStore(ClientCreateStoreRequest body, - ClientRequestOptions? options = default, + IClientRequestOptions? options = default, CancellationToken cancellationToken = default) => - await api.CreateStore(body, cancellationToken); + await api.CreateStore(body, options, cancellationToken); /** * GetStore - Get information about the current store */ public async Task GetStore(IClientRequestOptionsWithStoreId? options = default, CancellationToken cancellationToken = default) => - await api.GetStore(GetStoreId(options), cancellationToken); + await api.GetStore(GetStoreId(options), options, cancellationToken); /** * DeleteStore - Delete a store */ public async Task DeleteStore(IClientRequestOptionsWithStoreId? options = default, CancellationToken cancellationToken = default) => - await api.DeleteStore(GetStoreId(options), cancellationToken); + await api.DeleteStore(GetStoreId(options), options, cancellationToken); /************************ * Authorization Models * @@ -253,7 +253,7 @@ public class {{appShortName}}Client : IDisposable { public async Task ReadAuthorizationModels( IClientReadAuthorizationModelsOptions? options = default, CancellationToken cancellationToken = default) => - await api.ReadAuthorizationModels(GetStoreId(options), options?.PageSize, options?.ContinuationToken, cancellationToken); + await api.ReadAuthorizationModels(GetStoreId(options), options?.PageSize, options?.ContinuationToken, options, cancellationToken); /** * WriteAuthorizationModel - Create a new version of the authorization model @@ -261,7 +261,7 @@ public class {{appShortName}}Client : IDisposable { public async Task WriteAuthorizationModel(ClientWriteAuthorizationModelRequest body, IClientRequestOptionsWithStoreId? options = default, CancellationToken cancellationToken = default) => - await api.WriteAuthorizationModel(GetStoreId(options), body, cancellationToken); + await api.WriteAuthorizationModel(GetStoreId(options), body, options, cancellationToken); /** * ReadAuthorizationModel - Read the current authorization model @@ -274,7 +274,7 @@ public class {{appShortName}}Client : IDisposable { throw new FgaRequiredParamError("ClientConfiguration", "AuthorizationModelId"); } - return await api.ReadAuthorizationModel(GetStoreId(options), authorizationModelId, cancellationToken); + return await api.ReadAuthorizationModel(GetStoreId(options), authorizationModelId, options, cancellationToken); } /** @@ -284,7 +284,7 @@ public class {{appShortName}}Client : IDisposable { IClientRequestOptionsWithAuthZModelId? options = default, CancellationToken cancellationToken = default) { var response = - await ReadAuthorizationModels(new ClientReadAuthorizationModelsOptions {StoreId = options?.StoreId, PageSize = 1}, cancellationToken); + await ReadAuthorizationModels(new ClientReadAuthorizationModelsOptions { StoreId = options?.StoreId, PageSize = 1, Headers = options?.Headers }, cancellationToken); if (response.AuthorizationModels.Count > 0) { return new ReadAuthorizationModelResponse { AuthorizationModel = response.AuthorizationModels?[0] }; @@ -303,7 +303,7 @@ public class {{appShortName}}Client : IDisposable { public async Task ReadChanges(ClientReadChangesRequest? body = default, ClientReadChangesOptions? options = default, CancellationToken cancellationToken = default) => - await api.ReadChanges(GetStoreId(options), body?.Type, options?.PageSize, options?.ContinuationToken, body?.StartTime, cancellationToken); + await api.ReadChanges(GetStoreId(options), body?.Type, options?.PageSize, options?.ContinuationToken, body?.StartTime, options, cancellationToken); /** * Read - Read tuples previously written to the store (does not evaluate) @@ -317,8 +317,11 @@ public class {{appShortName}}Client : IDisposable { return await api.Read( GetStoreId(options), new ReadRequest { - TupleKey = tupleKey, PageSize = options?.PageSize, ContinuationToken = options?.ContinuationToken, Consistency = options?.Consistency, - }, cancellationToken); + TupleKey = tupleKey, + PageSize = options?.PageSize, + ContinuationToken = options?.ContinuationToken, + Consistency = options?.Consistency, + }, options, cancellationToken); } /** @@ -344,7 +347,7 @@ public class {{appShortName}}Client : IDisposable { requestBody.Deletes = new WriteRequestDeletes(body.Deletes.ConvertAll(key => key.ToTupleKeyWithoutCondition())); } - await api.Write(GetStoreId(options), requestBody, cancellationToken); + await api.Write(GetStoreId(options), requestBody, options, cancellationToken); return new ClientWriteResponse { Writes = body.Writes?.ConvertAll(tupleKey => @@ -357,7 +360,7 @@ public class {{appShortName}}Client : IDisposable { }; } - var clientWriteOpts = new ClientWriteOptions() { StoreId = StoreId, AuthorizationModelId = authorizationModelId }; + var clientWriteOpts = new ClientWriteOptions() { StoreId = StoreId, AuthorizationModelId = authorizationModelId, Headers = options?.Headers }; var writeChunks = body.Writes?.Chunk(maxPerChunk).ToList() ?? new List(); var writeResponses = new ConcurrentBag(); @@ -389,14 +392,14 @@ public class {{appShortName}}Client : IDisposable { */ public async Task WriteTuples(List body, IClientWriteOptions? options = default, CancellationToken cancellationToken = default) => - await Write(new ClientWriteRequest {Writes = body}, options, cancellationToken); + await Write(new ClientWriteRequest { Writes = body }, options, cancellationToken); /** * DeleteTuples - Utility method to delete tuples, wraps Write */ public async Task DeleteTuples(List body, IClientWriteOptions? options = default, CancellationToken cancellationToken = default) => - await Write(new ClientWriteRequest {Deletes = body}, options, cancellationToken); + await Write(new ClientWriteRequest { Deletes = body }, options, cancellationToken); /************************ * Relationship Queries * @@ -411,7 +414,7 @@ public class {{appShortName}}Client : IDisposable { await api.Check( GetStoreId(options), new CheckRequest { - TupleKey = new CheckRequestTupleKey {User = body.User, Relation = body.Relation, Object = body.Object}, + TupleKey = new CheckRequestTupleKey { User = body.User, Relation = body.Relation, Object = body.Object }, ContextualTuples = new ContextualTupleKeys { TupleKeys = body.ContextualTuples?.ConvertAll(tupleKey => tupleKey.ToTupleKey()) ?? @@ -420,7 +423,7 @@ public class {{appShortName}}Client : IDisposable { Context = body.Context, AuthorizationModelId = GetAuthorizationModelId(options), Consistency = options?.Consistency, - }, cancellationToken); + }, options, cancellationToken); /** * BatchCheck - Run a set of checks (evaluates) @@ -431,7 +434,7 @@ public class {{appShortName}}Client : IDisposable { var responses = new ConcurrentBag(); var maxParallelReqs = options?.MaxParallelRequests ?? DEFAULT_MAX_METHOD_PARALLEL_REQS; - + await ProcessCheckRequestsAsync(body, responses, options, maxParallelReqs, cancellationToken); return new ClientBatchCheckClientResponse { Responses = responses.ToList() }; @@ -446,7 +449,7 @@ public class {{appShortName}}Client : IDisposable { await api.Expand( GetStoreId(options), new ExpandRequest { - TupleKey = new ExpandRequestTupleKey {Relation = body.Relation, Object = body.Object}, + TupleKey = new ExpandRequestTupleKey { Relation = body.Relation, Object = body.Object }, ContextualTuples = new ContextualTupleKeys { TupleKeys = body.ContextualTuples?.ConvertAll(tupleKey => tupleKey.ToTupleKey()) ?? @@ -454,7 +457,7 @@ public class {{appShortName}}Client : IDisposable { }, AuthorizationModelId = GetAuthorizationModelId(options), Consistency = options?.Consistency - }, cancellationToken); + }, options, cancellationToken); /** * ListObjects - List the objects of a particular type that the user has a certain relation to (evaluates) @@ -474,7 +477,7 @@ public class {{appShortName}}Client : IDisposable { Context = body.Context, AuthorizationModelId = GetAuthorizationModelId(options), Consistency = options?.Consistency, - }, cancellationToken); + }, options, cancellationToken); /** @@ -526,12 +529,11 @@ public class {{appShortName}}Client : IDisposable { Object = body.Object, Relation = body.Relation, UserFilters = body.UserFilters, - ContextualTuples = body.ContextualTuples?.ConvertAll(tupleKey => tupleKey.ToTupleKey()) ?? - new List(), + ContextualTuples = body.ContextualTuples?.ConvertAll(tupleKey => tupleKey.ToTupleKey()) ?? new List(), Context = body.Context, AuthorizationModelId = GetAuthorizationModelId(options), Consistency = options?.Consistency - }); + }, options, cancellationToken); /************** * Assertions * @@ -547,7 +549,7 @@ public class {{appShortName}}Client : IDisposable { throw new FgaRequiredParamError("ClientConfiguration", "AuthorizationModelId"); } - return await api.ReadAssertions(GetStoreId(options), authorizationModelId, cancellationToken); + return await api.ReadAssertions(GetStoreId(options), authorizationModelId, options, cancellationToken); } /** @@ -576,6 +578,6 @@ public class {{appShortName}}Client : IDisposable { } await api.WriteAssertions(GetStoreId(options), authorizationModelId, - new WriteAssertionsRequest {Assertions = assertions}, cancellationToken); + new WriteAssertionsRequest { Assertions = assertions }, options, cancellationToken); } } diff --git a/config/clients/dotnet/template/Client/ClientConfiguration.mustache b/config/clients/dotnet/template/Client/ClientConfiguration.mustache index 96895052b..31609404d 100644 --- a/config/clients/dotnet/template/Client/ClientConfiguration.mustache +++ b/config/clients/dotnet/template/Client/ClientConfiguration.mustache @@ -1,7 +1,8 @@ {{>partial_header}} +using System; +using System.Collections.Generic; using System.Text.RegularExpressions; - using {{packageName}}.Client.Model; using {{packageName}}.Exceptions; diff --git a/config/clients/dotnet/template/Client/Model/ClientBatchCheckOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientBatchCheckOptions.mustache index 22cd348d0..9563a62e0 100644 --- a/config/clients/dotnet/template/Client/Model/ClientBatchCheckOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientBatchCheckOptions.mustache @@ -1,6 +1,7 @@ {{>partial_header}} -using OpenFga.Sdk.Model; +using {{packageName}}.Model; +using System.Collections.Generic; namespace {{packageName}}.Client.Model; @@ -25,4 +26,7 @@ public class ClientBatchCheckOptions : IClientBatchCheckOptions { /// public ConsistencyPreference? Consistency { get; set; } + + /// + public IDictionary? Headers { get; set; } } diff --git a/config/clients/dotnet/template/Client/Model/ClientCheckOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientCheckOptions.mustache index 43e107b58..c77a80153 100644 --- a/config/clients/dotnet/template/Client/Model/ClientCheckOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientCheckOptions.mustache @@ -1,6 +1,7 @@ {{>partial_header}} -using OpenFga.Sdk.Model; +using {{packageName}}.Model; +using System.Collections.Generic; namespace {{packageName}}.Client.Model; @@ -16,4 +17,7 @@ public class ClientCheckOptions : IClientCheckOptions { /// public ConsistencyPreference? Consistency { get; set; } -} \ No newline at end of file + + /// + public IDictionary? Headers { get; set; } +} diff --git a/config/clients/dotnet/template/Client/Model/ClientConsistencyOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientConsistencyOptions.mustache index 1271cc36e..12d4f94f2 100644 --- a/config/clients/dotnet/template/Client/Model/ClientConsistencyOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientConsistencyOptions.mustache @@ -1,6 +1,6 @@ {{>partial_header}} -using OpenFga.Sdk.Model; +using {{packageName}}.Model; namespace {{packageName}}.Client.Model; @@ -9,4 +9,4 @@ public interface IClientConsistencyOptions { /// ConsistencyPreference - Can be used to indicate preference for lower latency or higher consistency /// public ConsistencyPreference? Consistency { get; set; } -} \ No newline at end of file +} diff --git a/config/clients/dotnet/template/Client/Model/ClientCreateStoreOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientCreateStoreOptions.mustache index a6e5ff85c..23e5263f5 100644 --- a/config/clients/dotnet/template/Client/Model/ClientCreateStoreOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientCreateStoreOptions.mustache @@ -1,7 +1,12 @@ {{>partial_header}} +using System.Collections.Generic; + namespace {{packageName}}.Client.Model; -public interface IClientCreateStoreOptions : ClientRequestOptions {} +public interface IClientCreateStoreOptions : IClientRequestOptions {} -public class ClientCreateStoreOptions: IClientCreateStoreOptions {} +public class ClientCreateStoreOptions : IClientCreateStoreOptions { + /// + public IDictionary? Headers { get; set; } +} diff --git a/config/clients/dotnet/template/Client/Model/ClientExpandOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientExpandOptions.mustache index fe694df49..0f32548ba 100644 --- a/config/clients/dotnet/template/Client/Model/ClientExpandOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientExpandOptions.mustache @@ -1,6 +1,7 @@ {{>partial_header}} -using OpenFga.Sdk.Model; +using {{packageName}}.Model; +using System.Collections.Generic; namespace {{packageName}}.Client.Model; @@ -16,4 +17,7 @@ public class ClientExpandOptions : IClientExpandOptions { /// public ConsistencyPreference? Consistency { get; set; } -} \ No newline at end of file + + /// + public IDictionary? Headers { get; set; } +} diff --git a/config/clients/dotnet/template/Client/Model/ClientListObjectsOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientListObjectsOptions.mustache index e5fd578b8..d7f093ac6 100644 --- a/config/clients/dotnet/template/Client/Model/ClientListObjectsOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientListObjectsOptions.mustache @@ -1,6 +1,7 @@ {{>partial_header}} -using OpenFga.Sdk.Model; +using {{packageName}}.Model; +using System.Collections.Generic; namespace {{packageName}}.Client.Model; @@ -16,4 +17,7 @@ public class ClientListObjectsOptions : IClientListObjectsOptions { /// public ConsistencyPreference? Consistency { get; set; } -} \ No newline at end of file + + /// + public IDictionary? Headers { get; set; } +} diff --git a/config/clients/dotnet/template/Client/Model/ClientListRelationsOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientListRelationsOptions.mustache index 6dea8b8c6..ae3484b16 100644 --- a/config/clients/dotnet/template/Client/Model/ClientListRelationsOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientListRelationsOptions.mustache @@ -1,6 +1,7 @@ {{>partial_header}} -using OpenFga.Sdk.Model; +using {{packageName}}.Model; +using System.Collections.Generic; namespace {{packageName}}.Client.Model; @@ -21,4 +22,7 @@ public class ClientListRelationsOptions : IClientListRelationsOptions { /// public ConsistencyPreference? Consistency { get; set; } -} \ No newline at end of file + + /// + public IDictionary? Headers { get; set; } +} diff --git a/config/clients/dotnet/template/Client/Model/ClientListStoresOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientListStoresOptions.mustache index f13c61788..d06744bec 100644 --- a/config/clients/dotnet/template/Client/Model/ClientListStoresOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientListStoresOptions.mustache @@ -1,11 +1,13 @@ {{>partial_header}} +using System.Collections.Generic; + namespace {{packageName}}.Client.Model; /// /// ClientListStoresOptions - Client Options for ListStores /// -public interface IClientListStoresOptions : ClientRequestOptions, AuthorizationModelIdOptions, ClientPaginationOptions { +public interface IClientListStoresOptions : IClientRequestOptions, AuthorizationModelIdOptions, ClientPaginationOptions { } /// @@ -19,4 +21,7 @@ public class ClientListStoresOptions : IClientListStoresOptions { /// public string? ContinuationToken { get; set; } + + /// + public IDictionary? Headers { get; set; } } diff --git a/config/clients/dotnet/template/Client/Model/ClientListUsersOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientListUsersOptions.mustache index 80cfaad80..21b609395 100644 --- a/config/clients/dotnet/template/Client/Model/ClientListUsersOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientListUsersOptions.mustache @@ -1,6 +1,7 @@ {{>partial_header}} -using OpenFga.Sdk.Model; +using {{packageName}}.Model; +using System.Collections.Generic; namespace {{packageName}}.Client.Model; @@ -16,4 +17,7 @@ public class ClientListUsersOptions : IClientListUsersOptions { /// public ConsistencyPreference? Consistency { get; set; } -} \ No newline at end of file + + /// + public IDictionary? Headers { get; set; } +} diff --git a/config/clients/dotnet/template/Client/Model/ClientReadAssertionsOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientReadAssertionsOptions.mustache index a712cf8eb..5489b946d 100644 --- a/config/clients/dotnet/template/Client/Model/ClientReadAssertionsOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientReadAssertionsOptions.mustache @@ -1,5 +1,7 @@ {{>partial_header}} +using System.Collections.Generic; + namespace {{packageName}}.Client.Model; /// @@ -15,4 +17,7 @@ public class ClientReadAssertionsOptions : IClientReadAssertionsOptions { /// public string? AuthorizationModelId { get; set; } + + /// + public IDictionary? Headers { get; set; } } diff --git a/config/clients/dotnet/template/Client/Model/ClientReadAuthorizaionModelOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientReadAuthorizaionModelOptions.mustache index 349f2c7f8..6e1e515b5 100644 --- a/config/clients/dotnet/template/Client/Model/ClientReadAuthorizaionModelOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientReadAuthorizaionModelOptions.mustache @@ -1,5 +1,7 @@ {{>partial_header}} +using System.Collections.Generic; + namespace {{packageName}}.Client.Model; /// @@ -15,4 +17,7 @@ public class ClientReadAuthorizationModelOptions : IClientReadAuthorizationModel /// public string? AuthorizationModelId { get; set; } + + /// + public IDictionary? Headers { get; set; } } diff --git a/config/clients/dotnet/template/Client/Model/ClientReadAuthorizaionModelsOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientReadAuthorizaionModelsOptions.mustache index bacf23fca..249300347 100644 --- a/config/clients/dotnet/template/Client/Model/ClientReadAuthorizaionModelsOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientReadAuthorizaionModelsOptions.mustache @@ -1,5 +1,7 @@ {{>partial_header}} +using System.Collections.Generic; + namespace {{packageName}}.Client.Model; /// @@ -18,4 +20,7 @@ public class ClientReadAuthorizationModelsOptions : IClientReadAuthorizationMode /// public string? ContinuationToken { get; set; } + + /// + public IDictionary? Headers { get; set; } } diff --git a/config/clients/dotnet/template/Client/Model/ClientReadChangesOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientReadChangesOptions.mustache index 117930b77..44529201c 100644 --- a/config/clients/dotnet/template/Client/Model/ClientReadChangesOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientReadChangesOptions.mustache @@ -1,5 +1,7 @@ {{>partial_header}} +using System.Collections.Generic; + namespace {{packageName}}.Client.Model; /// @@ -18,4 +20,7 @@ public class ClientReadChangesOptions : IClientReadChangesOptions { /// public string? ContinuationToken { get; set; } + + /// + public IDictionary? Headers { get; set; } } diff --git a/config/clients/dotnet/template/Client/Model/ClientReadOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientReadOptions.mustache index 70107e6f6..2986a5111 100644 --- a/config/clients/dotnet/template/Client/Model/ClientReadOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientReadOptions.mustache @@ -1,6 +1,7 @@ {{>partial_header}} -using OpenFga.Sdk.Model; +using {{packageName}}.Model; +using System.Collections.Generic; namespace {{packageName}}.Client.Model; @@ -22,5 +23,8 @@ public class ClientReadOptions : IClientReadOptions { public string? ContinuationToken { get; set; } /// - public ConsistencyPreference? Consistency { get; set; } + public ConsistencyPreference? Consistency { get; set; } + + /// + public IDictionary? Headers { get; set; } } diff --git a/config/clients/dotnet/template/Client/Model/ClientRequestOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientRequestOptions.mustache index 03f92e17c..6aba7bb0b 100644 --- a/config/clients/dotnet/template/Client/Model/ClientRequestOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientRequestOptions.mustache @@ -1,9 +1,34 @@ {{>partial_header}} +using {{packageName}}.Model; +using System.Collections.Generic; + namespace {{packageName}}.Client.Model; /// -/// Base Client Request Options +/// Client Request Options /// -public interface ClientRequestOptions { +public interface IClientRequestOptions : IRequestOptions { +} + +/// +/// Client Request Options +/// +public partial class ClientRequestOptions : IClientRequestOptions { + /// + /// Initializes a new instance of the class. + /// + public ClientRequestOptions() { + } + + /// + /// Initializes a new instance of the class. + /// + /// Custom headers to include with this specific request. + public ClientRequestOptions(IDictionary? headers = default) { + this.Headers = headers; + } + + /// + public IDictionary? Headers { get; set; } } diff --git a/config/clients/dotnet/template/Client/Model/ClientRequestOptsWithStoreId.mustache b/config/clients/dotnet/template/Client/Model/ClientRequestOptsWithStoreId.mustache index d83161c4d..e396ee0c8 100644 --- a/config/clients/dotnet/template/Client/Model/ClientRequestOptsWithStoreId.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientRequestOptsWithStoreId.mustache @@ -3,5 +3,5 @@ namespace {{packageName}}.Client.Model; /// -public interface IClientRequestOptionsWithStoreId : ClientRequestOptions, StoreIdOptions { +public interface IClientRequestOptionsWithStoreId : IClientRequestOptions, StoreIdOptions { } diff --git a/config/clients/dotnet/template/Client/Model/ClientWriteAssertionsOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientWriteAssertionsOptions.mustache index 3a45de3c7..46966a082 100644 --- a/config/clients/dotnet/template/Client/Model/ClientWriteAssertionsOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientWriteAssertionsOptions.mustache @@ -1,5 +1,7 @@ {{>partial_header}} +using System.Collections.Generic; + namespace {{packageName}}.Client.Model; /// @@ -16,4 +18,7 @@ public class ClientWriteAssertionsOptions : IClientWriteAssertionsOptions { /// public string? AuthorizationModelId { get; set; } + + /// + public IDictionary? Headers { get; set; } } diff --git a/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache b/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache index cd3c1a204..a47287c43 100644 --- a/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache +++ b/config/clients/dotnet/template/Client/Model/ClientWriteOptions.mustache @@ -1,5 +1,7 @@ {{>partial_header}} +using System.Collections.Generic; + namespace {{packageName}}.Client.Model; /// @@ -54,4 +56,7 @@ public class ClientWriteOptions : IClientWriteOptions { /// public ITransactionOpts Transaction { get; set; } + + /// + public IDictionary? Headers { get; set; } } diff --git a/config/clients/dotnet/template/Client_ApiClient.mustache b/config/clients/dotnet/template/Client_ApiClient.mustache index 3752b4f1b..690ae6e8c 100644 --- a/config/clients/dotnet/template/Client_ApiClient.mustache +++ b/config/clients/dotnet/template/Client_ApiClient.mustache @@ -1,15 +1,17 @@ {{>partial_header}} -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; +using {{packageName}}.Client; using {{packageName}}.Client.Model; using {{packageName}}.Configuration; using {{packageName}}.Exceptions; +using {{packageName}}.Model; using {{packageName}}.Telemetry; +using System; +using System.Collections.Generic; using System.Diagnostics; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; namespace {{packageName}}.ApiClient; @@ -62,28 +64,28 @@ public class ApiClient : IDisposable { /// /// /// + /// Request options. /// /// Response Type /// /// public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName, + IRequestOptions? options = null, CancellationToken cancellationToken = default) { - IDictionary additionalHeaders = new Dictionary(); - var sw = Stopwatch.StartNew(); + + string? oauthToken = null; if (_oauth2Client != null) { try { - var token = await _oauth2Client.GetAccessTokenAsync(); - - if (!string.IsNullOrEmpty(token)) { - additionalHeaders["Authorization"] = $"Bearer {token}"; - } + oauthToken = await _oauth2Client.GetAccessTokenAsync(); } catch (ApiException e) { throw new FgaApiAuthenticationError("Invalid Client Credentials", apiName, e); } } + var additionalHeaders = BuildHeaders(_configuration, oauthToken, options); + var response = await Retry(async () => await _baseClient.SendRequestAsync(requestBuilder, additionalHeaders, apiName, cancellationToken)); @@ -101,26 +103,26 @@ public class ApiClient : IDisposable { /// /// /// + /// Request options. /// /// public async Task SendRequestAsync(RequestBuilder requestBuilder, string apiName, + IRequestOptions? options = null, CancellationToken cancellationToken = default) { - IDictionary additionalHeaders = new Dictionary(); - var sw = Stopwatch.StartNew(); + + string? oauthToken = null; if (_oauth2Client != null) { try { - var token = await _oauth2Client.GetAccessTokenAsync(); - - if (!string.IsNullOrEmpty(token)) { - additionalHeaders["Authorization"] = $"Bearer {token}"; - } + oauthToken = await _oauth2Client.GetAccessTokenAsync(); } catch (ApiException e) { throw new FgaApiAuthenticationError("Invalid Client Credentials", apiName, e); } } + var additionalHeaders = BuildHeaders(_configuration, oauthToken, options); + var response = await Retry(async () => await _baseClient.SendRequestAsync(requestBuilder, additionalHeaders, apiName, cancellationToken)); @@ -166,5 +168,55 @@ public class ApiClient : IDisposable { } } + /// + /// Builds the complete headers dictionary by merging default headers, OAuth token, and per-request headers. + /// Validates per-request headers and performs case-insensitive merging. + /// Header precedence (lowest to highest): DefaultHeaders → OAuth token → Per-request headers + /// + /// Configuration containing default headers + /// OAuth access token if available + /// Request options containing custom headers + /// Merged headers dictionary or null if no headers to add + /// Thrown when header key is null, empty, or whitespace + /// Thrown when header value is null + private static IDictionary? BuildHeaders(Configuration.Configuration configuration, string? oauthToken, IRequestOptions? options) { + var defaultHeaders = configuration.DefaultHeaders; + var perRequestHeaders = options?.Headers; + + // Validate per-request headers + Configuration.Configuration.ValidateHeaders(perRequestHeaders, "options.Headers"); + + // Return empty dictionary if no headers to add + if (string.IsNullOrEmpty(oauthToken) && + (defaultHeaders == null || defaultHeaders.Count == 0) && + (perRequestHeaders == null || perRequestHeaders.Count == 0)) { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + // Use case-insensitive dictionary for proper header merging + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Default headers from configuration, if set + if (defaultHeaders != null) { + foreach (var header in defaultHeaders) { + headers[header.Key] = header.Value; + } + } + + // Authorization token header, if set + if (!string.IsNullOrEmpty(oauthToken)) { + headers["Authorization"] = $"Bearer {oauthToken}"; + } + + // Per-request headers, if set + if (perRequestHeaders != null) { + foreach (var header in perRequestHeaders) { + headers[header.Key] = header.Value; + } + } + + return headers; + } + public void Dispose() => _baseClient.Dispose(); -} \ No newline at end of file +} diff --git a/config/clients/dotnet/template/Configuration_Configuration.mustache b/config/clients/dotnet/template/Configuration_Configuration.mustache index 337992c28..e420a45a1 100644 --- a/config/clients/dotnet/template/Configuration_Configuration.mustache +++ b/config/clients/dotnet/template/Configuration_Configuration.mustache @@ -33,11 +33,69 @@ public class Configuration { (uriResult.ToString().Equals(uri) || uriResult.ToString().Equals($"{uri}/")) && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps); + /// + /// Reserved HTTP headers that should not be overridden via custom headers. + /// Note: User-Agent is intentionally excluded as the SDK sets a default value + /// but allows users to customize it via DefaultHeaders. + /// + private static readonly HashSet ReservedHeaders = new HashSet(StringComparer.OrdinalIgnoreCase) { + "Authorization", + "Content-Type", + "Content-Length", + "Host", + "Accept", + "Accept-Encoding", + "Transfer-Encoding", + "Connection", + "Cookie", + "Set-Cookie", + "Date" + }; + + /// + /// Validates that HTTP headers are safe to use with HTTP requests + /// + /// The headers dictionary to validate + /// The parameter name for exception messages + /// Thrown when headers contain invalid data + internal static void ValidateHeaders(IDictionary? headers, string paramName = "headers") { + if (headers == null) { + return; + } + + foreach (var header in headers) { + if (string.IsNullOrWhiteSpace(header.Key)) { + throw new ArgumentException("Header name cannot be null, empty, or whitespace.", paramName); + } + + if (header.Value == null) { + throw new ArgumentException($"Header '{header.Key}' has a null value. Header values cannot be null.", paramName); + } + + // Prevent HTTP header injection attacks by checking for newline characters + if (header.Value.Contains("\r") || header.Value.Contains("\n")) { + throw new ArgumentException( + $"Header '{header.Key}' contains invalid characters (CR/LF). Header values cannot contain newline characters as this may lead to header injection vulnerabilities.", + paramName); + } + + // Reject reserved headers that may cause unexpected behavior + if (ReservedHeaders.Contains(header.Key)) { + throw new ArgumentException( + $"Header '{header.Key}' is a reserved HTTP header and should not be set via custom headers. " + + $"Setting this header may cause authentication failures, request corruption, or other unexpected behavior. " + + $"Reserved headers include: {string.Join(", ", ReservedHeaders)}.", + paramName); + } + } + } + /// /// Ensures that the configuration is valid otherwise throws an error /// /// /// + /// Thrown when DefaultHeaders contain reserved or invalid headers public void EnsureValid() { if (BasePath == null || BasePath == "") { throw new FgaRequiredParamError("Configuration", "ApiUrl"); @@ -52,6 +110,9 @@ public class Configuration { throw new FgaValidationError("Configuration.MaxRetry exceeds maximum allowed limit of {{retryMaxAllowedNumber}}"); } + // Validate that DefaultHeaders don't contain reserved headers + ValidateHeaders(DefaultHeaders, nameof(DefaultHeaders)); + Credentials?.EnsureValid(); Telemetry?.EnsureValid(); } @@ -70,7 +131,7 @@ public class Configuration { #endregion Constants - + #region Properties /// diff --git a/config/clients/dotnet/template/OpenFgaClientTests.mustache b/config/clients/dotnet/template/OpenFgaClientTests.mustache index 72c8d6185..f2b761766 100644 --- a/config/clients/dotnet/template/OpenFgaClientTests.mustache +++ b/config/clients/dotnet/template/OpenFgaClientTests.mustache @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using System.Text; @@ -27,6 +28,15 @@ public class {{appShortName}}ClientTests : IDisposable { private readonly string _apiUrl = "https://api.{{sampleApiDomain}}"; private readonly ClientConfiguration _config; + private static class TestHeaders { + public const string RequestId = "X-Request-ID"; + public const string TraceId = "X-Trace-ID"; + public const string SessionId = "X-Session-ID"; + public const string UserId = "X-User-ID"; + public const string CorrelationId = "X-Correlation-ID"; + public const string CustomHeader = "X-Custom-Header"; + } + public {{appShortName}}ClientTests() { _storeId = "01H0H015178Y2V4CX10C2KGHF4"; _config = new ClientConfiguration() { StoreId = _storeId, ApiUrl = _apiUrl }; @@ -48,6 +58,39 @@ public class {{appShortName}}ClientTests : IDisposable { return response; } + private ({{appShortName}}Client client, Mock handler) CreateTestClientForHeaders( + TResponse response, + Func? requestValidator = null, + ClientConfiguration? config = null) { + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + requestValidator != null + ? ItExpr.Is(req => requestValidator(req)) + : ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(() => new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(response), + }); + + var httpClient = new HttpClient(mockHandler.Object); + return (new {{appShortName}}Client(config ?? _config, httpClient), mockHandler); + } + + private void AssertHeaderPresent(Mock mockHandler, string headerName, string expectedValue) { + mockHandler.Protected().Verify( + "SendAsync", + Times.AtLeastOnce(), + ItExpr.Is(req => + req.Headers.Contains(headerName) && + req.Headers.GetValues(headerName).First() == expectedValue), + ItExpr.IsAny() + ); + } + public void Dispose() { // Cleanup when everything is done. } @@ -99,6 +142,164 @@ public class {{appShortName}}ClientTests : IDisposable { Assert.Equal("AuthorizationModelId is not in a valid ulid format", exception.Message); } + /// + /// Test DefaultHeaders with reserved headers should throw + /// + [Theory] + [InlineData("Content-Type", "application/xml")] + [InlineData("content-type", "text/plain")] + [InlineData("CONTENT-TYPE", "application/json")] + [InlineData("Authorization", "Bearer fake-token")] + [InlineData("authorization", "Bearer fake-token")] + [InlineData("Content-Length", "1234")] + [InlineData("content-length", "1234")] + [InlineData("Host", "evil.com")] + [InlineData("host", "evil.com")] + [InlineData("Accept", "application/xml")] + [InlineData("accept", "application/xml")] + [InlineData("Accept-Encoding", "gzip")] + [InlineData("accept-encoding", "gzip")] + [InlineData("Transfer-Encoding", "chunked")] + [InlineData("transfer-encoding", "chunked")] + [InlineData("Connection", "close")] + [InlineData("connection", "close")] + [InlineData("Cookie", "sessionid=abc123")] + [InlineData("cookie", "sessionid=abc123")] + [InlineData("Set-Cookie", "sessionid=abc123")] + [InlineData("set-cookie", "sessionid=abc123")] + [InlineData("Date", "Mon, 01 Jan 2024 00:00:00 GMT")] + [InlineData("date", "Mon, 01 Jan 2024 00:00:00 GMT")] + public void EnsureValid_WithReservedDefaultHeader_ShouldThrowArgumentException(string headerName, string headerValue) { + var config = new ClientConfiguration { + ApiUrl = _apiUrl, + StoreId = _storeId + }; + config.DefaultHeaders[headerName] = headerValue; + + var exception = Assert.Throws(() => config.EnsureValid()); + + Assert.Contains("is a reserved HTTP header", exception.Message); + Assert.Contains("should not be set via custom headers", exception.Message); + Assert.Contains(headerName, exception.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Test DefaultHeaders with valid custom headers should not throw + /// + [Fact] + public void EnsureValid_WithValidCustomDefaultHeaders_ShouldNotThrow() { + var config = new ClientConfiguration { + ApiUrl = _apiUrl, + StoreId = _storeId + }; + config.DefaultHeaders["X-Custom-Header"] = "custom-value"; + config.DefaultHeaders["X-Request-ID"] = "req-123"; + config.DefaultHeaders["X-Correlation-ID"] = "corr-456"; + + config.EnsureValid(); + } + + /// + /// Test DefaultHeaders with empty header name should throw + /// + [Fact] + public void EnsureValid_WithEmptyDefaultHeaderName_ShouldThrowArgumentException() { + var config = new ClientConfiguration { + ApiUrl = _apiUrl, + StoreId = _storeId + }; + config.DefaultHeaders[""] = "value"; + + var exception = Assert.Throws(() => config.EnsureValid()); + Assert.Contains("Header name cannot be null, empty, or whitespace", exception.Message); + } + + /// + /// Test DefaultHeaders with null header value should throw + /// + [Fact] + public void EnsureValid_WithNullDefaultHeaderValue_ShouldThrowArgumentException() { + var config = new ClientConfiguration { + ApiUrl = _apiUrl, + StoreId = _storeId + }; + config.DefaultHeaders["X-Custom"] = null!; + + var exception = Assert.Throws(() => config.EnsureValid()); + Assert.Contains("has a null value", exception.Message); + } + + /// + /// Test DefaultHeaders with header injection should throw + /// + [Fact] + public void EnsureValid_WithHeaderInjectionInDefaultHeaders_ShouldThrowArgumentException() { + var config = new ClientConfiguration { + ApiUrl = _apiUrl, + StoreId = _storeId + }; + config.DefaultHeaders["X-Custom"] = "value\r\nX-Injected: malicious"; + + var exception = Assert.Throws(() => config.EnsureValid()); + Assert.Contains("CR/LF", exception.Message); + Assert.Contains("header injection", exception.Message); + } + + /// + /// Test Content-Type in DefaultHeaders should throw with specific error + /// + [Fact] + public void EnsureValid_ContentTypeInDefaultHeaders_ShouldThrowWithSpecificError() { + var config = new ClientConfiguration { + ApiUrl = _apiUrl, + StoreId = _storeId + }; + config.DefaultHeaders["Content-Type"] = "application/xml"; + + var exception = Assert.Throws(() => config.EnsureValid()); + + Assert.Contains("Content-Type", exception.Message); + Assert.Contains("reserved", exception.Message); + Assert.Contains("DefaultHeaders", exception.ParamName); + } + + /// + /// Test Content-Type in DefaultHeaders is case-insensitive + /// + [Fact] + public void EnsureValid_ContentTypeInDefaultHeaders_CaseInsensitive_ShouldThrow() { + var casings = new[] { "content-type", "CONTENT-TYPE", "Content-type", "CoNtEnT-tYpE" }; + + foreach (var casing in casings) { + var config = new ClientConfiguration { + ApiUrl = _apiUrl, + StoreId = _storeId + }; + config.DefaultHeaders[casing] = "application/xml"; + + var exception = Assert.Throws(() => config.EnsureValid()); + Assert.Contains("reserved", exception.Message); + } + } + + /// + /// Test Authorization in DefaultHeaders should throw with specific error + /// + [Fact] + public void EnsureValid_AuthorizationInDefaultHeaders_ShouldThrowWithSpecificError() { + var config = new ClientConfiguration { + ApiUrl = _apiUrl, + StoreId = _storeId + }; + config.DefaultHeaders["Authorization"] = "Bearer custom-token"; + + var exception = Assert.Throws(() => config.EnsureValid()); + + Assert.Contains("Authorization", exception.Message); + Assert.Contains("reserved", exception.Message); + Assert.Contains("authentication failures", exception.Message); + } + /// /// Test that updating StoreId after initialization works /// @@ -1229,7 +1430,7 @@ public class {{appShortName}}ClientTests : IDisposable { var allowedResponses = response.Responses.FindAll(res => res.Allowed); Assert.Equal(2, allowedResponses.Count); - var notAllowedResponses = response.Responses.FindAll(res => res.Allowed == false); + var notAllowedResponses = response.Responses.FindAll(res => !res.Allowed); Assert.Equal(2, notAllowedResponses.Count); var failedResponses = response.Responses.FindAll(res => res.Error != null); Assert.Single(failedResponses); @@ -1820,4 +2021,853 @@ public class {{appShortName}}ClientTests : IDisposable { ItExpr.IsAny() ); } -} \ No newline at end of file + + #region Custom Headers Tests + + /// + /// Test that Client*Options classes implement Headers property + /// + [Theory] + [InlineData(typeof(ClientCheckOptions))] + [InlineData(typeof(ClientWriteOptions))] + [InlineData(typeof(ClientExpandOptions))] + [InlineData(typeof(ClientListObjectsOptions))] + [InlineData(typeof(ClientReadOptions))] + public void ClientOptions_ShouldImplementHeadersProperty(Type optionsType) { + var options = Activator.CreateInstance(optionsType) as IClientRequestOptions; + Assert.NotNull(options); + + var headers = new Dictionary { + { TestHeaders.RequestId, "test-123" } + }; + + options!.Headers = headers; + Assert.NotNull(options.Headers); + Assert.Equal("test-123", options.Headers[TestHeaders.RequestId]); + } + + /// + /// Test that Client*Options classes allow null headers + /// + [Fact] + public void ClientOptions_ShouldAllowNullHeaders() { + var options = new ClientCheckOptions { Headers = null }; + Assert.Null(options.Headers); + } + + /// + /// Test Check with custom headers includes headers in request + /// + [Fact] + public async Task Check_WithCustomHeaders_ShouldIncludeHeadersInRequest() { + var expectedResponse = new CheckResponse() { Allowed = true }; + var (client, mockHandler) = CreateTestClientForHeaders(expectedResponse, req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_storeId}/check") && + req.Method == HttpMethod.Post && + req.Headers.Contains(TestHeaders.RequestId) && + req.Headers.GetValues(TestHeaders.RequestId).First() == "test-123" && + req.Headers.Contains(TestHeaders.CustomHeader) && + req.Headers.GetValues(TestHeaders.CustomHeader).First() == "custom-value" + ); + + var options = new ClientCheckOptions { + Headers = new Dictionary { + { TestHeaders.RequestId, "test-123" }, + { TestHeaders.CustomHeader, "custom-value" } + } + }; + + var response = await client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + options + ); + + Assert.True(response.Allowed); + AssertHeaderPresent(mockHandler, TestHeaders.RequestId, "test-123"); + AssertHeaderPresent(mockHandler, TestHeaders.CustomHeader, "custom-value"); + } + + /// + /// Test Check with null headers should not fail + /// + [Fact] + public async Task Check_WithNullHeaders_ShouldNotFail() { + var expectedResponse = new CheckResponse() { Allowed = false }; + var (client, _) = CreateTestClientForHeaders(expectedResponse); + + var options = new ClientCheckOptions { Headers = null }; + + var response = await client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + options + ); + + Assert.False(response.Allowed); + } + + /// + /// Test Check with invalid header value (CRLF injection) should throw + /// + [Fact] + public async Task Check_WithInvalidHeaderValue_ShouldThrowArgumentException() { + var (client, _) = CreateTestClientForHeaders(new CheckResponse()); + + var options = new ClientCheckOptions { + Headers = new Dictionary { + { TestHeaders.RequestId, "value\r\nX-Injected: malicious" } + } + }; + + var exception = await Assert.ThrowsAsync(async () => + await client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + options + ) + ); + + Assert.Contains("CR/LF", exception.Message); + Assert.Contains("header injection", exception.Message); + } + + /// + /// Test Write with custom headers + /// + [Fact] + public async Task Write_WithCustomHeaders_ShouldIncludeHeadersInRequest() { + var (client, mockHandler) = CreateTestClientForHeaders(new { }, req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_storeId}/write") && + req.Method == HttpMethod.Post && + req.Headers.Contains(TestHeaders.TraceId) && + req.Headers.GetValues(TestHeaders.TraceId).First() == "trace-456" + ); + + var options = new ClientWriteOptions { + Headers = new Dictionary { + { TestHeaders.TraceId, "trace-456" } + }, + Transaction = new TransactionOptions() + }; + + await client.Write( + new ClientWriteRequest { + Writes = new List { + new() { User = "user:anne", Relation = "writer", Object = "document:budget" } + } + }, + options + ); + + AssertHeaderPresent(mockHandler, TestHeaders.TraceId, "trace-456"); + } + + /// + /// Test Read with custom headers + /// + [Fact] + public async Task Read_WithCustomHeaders_ShouldIncludeHeadersInRequest() { + var expectedResponse = new ReadResponse() { Tuples = new List() }; + var (client, mockHandler) = CreateTestClientForHeaders(expectedResponse, req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_storeId}/read") && + req.Method == HttpMethod.Post && + req.Headers.Contains(TestHeaders.SessionId) + ); + + var options = new ClientReadOptions { + Headers = new Dictionary { + { TestHeaders.SessionId, "session-xyz" } + } + }; + + var response = await client.Read( + new ClientReadRequest { User = "user:anne" }, + options + ); + + Assert.NotNull(response.Tuples); + AssertHeaderPresent(mockHandler, TestHeaders.SessionId, "session-xyz"); + } + + /// + /// Test Expand with custom headers + /// + [Fact] + public async Task Expand_WithCustomHeaders_ShouldIncludeHeadersInRequest() { + var expectedResponse = new ExpandResponse() { + Tree = new UsersetTree() { Root = new Node() } + }; + var (client, mockHandler) = CreateTestClientForHeaders(expectedResponse, req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_storeId}/expand") && + req.Method == HttpMethod.Post && + req.Headers.Contains(TestHeaders.UserId) + ); + + var options = new ClientExpandOptions { + Headers = new Dictionary { + { TestHeaders.UserId, "user-789" } + } + }; + + var response = await client.Expand( + new ClientExpandRequest { + Relation = "reader", + Object = "document:budget" + }, + options + ); + + Assert.NotNull(response.Tree); + AssertHeaderPresent(mockHandler, TestHeaders.UserId, "user-789"); + } + + /// + /// Test ListObjects with custom headers + /// + [Fact] + public async Task ListObjects_WithCustomHeaders_ShouldIncludeHeadersInRequest() { + var expectedResponse = new ListObjectsResponse() { Objects = new List() }; + var (client, mockHandler) = CreateTestClientForHeaders(expectedResponse, req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_storeId}/list-objects") && + req.Method == HttpMethod.Post && + req.Headers.Contains(TestHeaders.CorrelationId) + ); + + var options = new ClientListObjectsOptions { + Headers = new Dictionary { + { TestHeaders.CorrelationId, "corr-abc" } + } + }; + + var response = await client.ListObjects( + new ClientListObjectsRequest { + User = "user:anne", + Relation = "reader", + Type = "document" + }, + options + ); + + Assert.NotNull(response.Objects); + AssertHeaderPresent(mockHandler, TestHeaders.CorrelationId, "corr-abc"); + } + + /// + /// Test ListUsers with custom headers + /// + [Fact] + public async Task ListUsers_WithCustomHeaders_ShouldIncludeHeadersInRequest() { + var expectedResponse = new ListUsersResponse() { Users = new List() }; + var (client, mockHandler) = CreateTestClientForHeaders(expectedResponse, req => + req.RequestUri == new Uri($"{_config.BasePath}/stores/{_storeId}/list-users") && + req.Method == HttpMethod.Post && + req.Headers.Contains("X-List-Users") + ); + + var options = new ClientListUsersOptions { + Headers = new Dictionary { + { "X-List-Users", "users-123" } + } + }; + + var response = await client.ListUsers( + new ClientListUsersRequest { + Object = new FgaObject { Type = "document", Id = "budget" }, + Relation = "reader", + UserFilters = new List() + }, + options + ); + + Assert.NotNull(response.Users); + AssertHeaderPresent(mockHandler, "X-List-Users", "users-123"); + } + + /// + /// Test CreateStore with custom headers + /// + [Fact] + public async Task CreateStore_WithCustomHeaders_ShouldIncludeHeadersInRequest() { + var expectedResponse = new CreateStoreResponse() { + Id = "01H0H015178Y2V4CX10C2KGHF6", + Name = "Test Store" + }; + var (client, mockHandler) = CreateTestClientForHeaders(expectedResponse, req => + req.RequestUri == new Uri($"{_config.BasePath}/stores") && + req.Method == HttpMethod.Post && + req.Headers.Contains("X-Store-Create") + ); + + var options = new ClientCreateStoreOptions { + Headers = new Dictionary { + { "X-Store-Create", "create-456" } + } + }; + + var response = await client.CreateStore( + new ClientCreateStoreRequest { Name = "Test Store" }, + options + ); + + Assert.Equal("Test Store", response.Name); + AssertHeaderPresent(mockHandler, "X-Store-Create", "create-456"); + } + + /// + /// Test ListStores with custom headers + /// + [Fact] + public async Task ListStores_WithCustomHeaders_ShouldIncludeHeadersInRequest() { + var expectedResponse = new ListStoresResponse() { Stores = new List() }; + var (client, mockHandler) = CreateTestClientForHeaders(expectedResponse, req => + req.RequestUri.ToString().StartsWith($"{_config.BasePath}/stores") && + req.Method == HttpMethod.Get && + req.Headers.Contains("X-List-Stores") + ); + + var options = new ClientListStoresOptions { + Headers = new Dictionary { + { "X-List-Stores", "list-789" } + } + }; + + var response = await client.ListStores( + new ClientListStoresRequest { }, + options + ); + + Assert.NotNull(response.Stores); + AssertHeaderPresent(mockHandler, "X-List-Stores", "list-789"); + } + + /// + /// Test ReadAuthorizationModels with custom headers + /// + [Fact] + public async Task ReadAuthorizationModels_WithCustomHeaders_ShouldIncludeHeadersInRequest() { + var expectedResponse = new ReadAuthorizationModelsResponse() { + AuthorizationModels = new List() + }; + var (client, mockHandler) = CreateTestClientForHeaders(expectedResponse, req => + req.RequestUri.ToString().StartsWith($"{_config.BasePath}/stores/{_storeId}/authorization-models") && + req.Method == HttpMethod.Get && + req.Headers.Contains("X-Model-Request") + ); + + var options = new ClientReadAuthorizationModelsOptions { + Headers = new Dictionary { + { "X-Model-Request", "model-123" } + } + }; + + var response = await client.ReadAuthorizationModels(options); + + Assert.NotNull(response.AuthorizationModels); + AssertHeaderPresent(mockHandler, "X-Model-Request", "model-123"); + } + + /// + /// Test ReadLatestAuthorizationModel with custom headers + /// + [Fact] + public async Task ReadLatestAuthorizationModel_WithCustomHeaders_ShouldPropagateHeaders() { + var authModelId = "01GXSA8YR785C4FYS3C0RTG7B1"; + var expectedResponse = new ReadAuthorizationModelsResponse() { + AuthorizationModels = new List { + new AuthorizationModel { Id = authModelId } + } + }; + var (client, mockHandler) = CreateTestClientForHeaders(expectedResponse, req => + req.RequestUri.ToString().Contains("page_size=1") && + req.Method == HttpMethod.Get && + req.Headers.Contains("X-Latest-Model") + ); + + var options = new ClientWriteOptions { + Headers = new Dictionary { + { "X-Latest-Model", "latest-xyz" } + }, + Transaction = new TransactionOptions() + }; + + var response = await client.ReadLatestAuthorizationModel(options); + + Assert.NotNull(response); + AssertHeaderPresent(mockHandler, "X-Latest-Model", "latest-xyz"); + } + + /// + /// Test ReadChanges with custom headers + /// + [Fact] + public async Task ReadChanges_WithCustomHeaders_ShouldIncludeHeadersInRequest() { + var expectedResponse = new ReadChangesResponse() { + Changes = new List() + }; + var (client, mockHandler) = CreateTestClientForHeaders(expectedResponse, req => + req.RequestUri.ToString().StartsWith($"{_config.BasePath}/stores/{_storeId}/changes") && + req.Method == HttpMethod.Get && + req.Headers.Contains("X-Changes-Request") + ); + + var options = new ClientReadChangesOptions { + Headers = new Dictionary { + { "X-Changes-Request", "changes-abc" } + } + }; + + var response = await client.ReadChanges( + new ClientReadChangesRequest { }, + options + ); + + Assert.NotNull(response.Changes); + AssertHeaderPresent(mockHandler, "X-Changes-Request", "changes-abc"); + } + + /// + /// Test Check with empty header name should throw + /// + [Fact] + public async Task Check_WithEmptyHeaderName_ShouldThrowArgumentException() { + var (client, _) = CreateTestClientForHeaders(new CheckResponse()); + + var options = new ClientCheckOptions { + Headers = new Dictionary { + { "", "value" } + } + }; + + var exception = await Assert.ThrowsAsync(async () => + await client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + options + ) + ); + + Assert.Contains("Header name cannot be null, empty, or whitespace", exception.Message); + } + + /// + /// Test Check with null header value should throw + /// + [Fact] + public async Task Check_WithNullHeaderValue_ShouldThrowArgumentException() { + var (client, _) = CreateTestClientForHeaders(new CheckResponse()); + + var options = new ClientCheckOptions { + Headers = new Dictionary { + { TestHeaders.RequestId, null! } + } + }; + + var exception = await Assert.ThrowsAsync(async () => + await client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + options + ) + ); + + Assert.Contains("has a null value", exception.Message); + } + + /// + /// Test Check with reserved headers should throw + /// + [Theory] + [InlineData("Authorization")] + [InlineData("authorization")] + [InlineData("Content-Type")] + [InlineData("content-type")] + [InlineData("Content-Length")] + [InlineData("Host")] + [InlineData("Accept")] + [InlineData("Accept-Encoding")] + public async Task Check_WithReservedHeader_ShouldThrowArgumentException(string headerName) { + var (client, _) = CreateTestClientForHeaders(new CheckResponse()); + + var options = new ClientCheckOptions { + Headers = new Dictionary { + { headerName, "some-value" } + } + }; + + var exception = await Assert.ThrowsAsync(async () => + await client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + options + ) + ); + + Assert.Contains("is a reserved HTTP header", exception.Message); + Assert.Contains("should not be set via custom headers", exception.Message); + } + + /// + /// Test concurrent requests with different headers should not interfere + /// + [Fact] + public async Task ConcurrentRequests_WithDifferentHeaders_ShouldNotInterfere() { + var expectedResponse = new CheckResponse() { Allowed = true }; + var (client, mockHandler) = CreateTestClientForHeaders(expectedResponse); + + var task1 = client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + new ClientCheckOptions { + Headers = new Dictionary { + { TestHeaders.RequestId, "request-1" } + } + } + ); + + var task2 = client.Check( + new ClientCheckRequest { + User = "user:bob", + Relation = "writer", + Object = "document:report" + }, + new ClientCheckOptions { + Headers = new Dictionary { + { TestHeaders.RequestId, "request-2" } + } + } + ); + + var results = await Task.WhenAll(task1, task2); + + Assert.All(results, r => Assert.True(r.Allowed)); + mockHandler.Protected().Verify( + "SendAsync", + Times.Exactly(2), + ItExpr.IsAny(), + ItExpr.IsAny() + ); + } + + /// + /// Test per-request header overriding default header + /// + [Fact] + public async Task Check_WithHeaderOverridingDefaultHeader_ShouldUsePerRequestValue() { + // Setup client with default headers + var configWithDefaults = new ClientConfiguration() { + StoreId = _storeId, + ApiUrl = _apiUrl + }; + configWithDefaults.DefaultHeaders[TestHeaders.RequestId] = "default-request-id"; + + var expectedResponse = new CheckResponse() { Allowed = true }; + var (client, mockHandler) = CreateTestClientForHeaders(expectedResponse, req => + req.Headers.Contains(TestHeaders.RequestId) && + req.Headers.GetValues(TestHeaders.RequestId).First() == "override-request-id", + configWithDefaults + ); + + var options = new ClientCheckOptions { + Headers = new Dictionary { + { TestHeaders.RequestId, "override-request-id" } + } + }; + + var response = await client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + options + ); + + Assert.True(response.Allowed); + AssertHeaderPresent(mockHandler, TestHeaders.RequestId, "override-request-id"); + } + + /// + /// Test header precedence: per-request headers override default headers + /// + [Fact] + public async Task Check_HeaderPrecedence_PerRequestOverridesDefault() { + // Setup: Configure client with default headers + var configWithDefaults = new ClientConfiguration() { + StoreId = _storeId, + ApiUrl = _apiUrl + }; + configWithDefaults.DefaultHeaders["X-Default-Header"] = "default-value"; + configWithDefaults.DefaultHeaders["X-Override-Test"] = "default-value"; + + var expectedResponse = new CheckResponse() { Allowed = true }; + + // Track all headers that were actually sent + IDictionary>? sentHeaders = null; + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync((HttpRequestMessage req, CancellationToken ct) => { + sentHeaders = req.Headers.ToDictionary(h => h.Key, h => h.Value); + return new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(expectedResponse), + }; + }); + + var httpClient = new HttpClient(mockHandler.Object); + var client = new {{appShortName}}Client(configWithDefaults, httpClient); + + // Per-request headers should override the default header with same name + var options = new ClientCheckOptions { + Headers = new Dictionary { + { "X-Override-Test", "per-request-value" }, + { "X-Per-Request-Only", "per-request-only-value" } + } + }; + + await client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + options + ); + + // Verify per-request header overrides default + Assert.NotNull(sentHeaders); + Assert.True(sentHeaders.TryGetValue("X-Override-Test", out var headerOverride)); + Assert.Equal("per-request-value", headerOverride.First()); + + // Verify per-request-only header is present + Assert.True(sentHeaders.TryGetValue("X-Per-Request-Only", out var headerPerRequestOnly)); + Assert.Equal("per-request-only-value", headerPerRequestOnly.First()); + + // Verify default header is still present when not overridden + Assert.True(sentHeaders.TryGetValue("X-Default-Header", out var headerDefault)); + Assert.Equal("default-value", headerDefault.First()); + } + + /// + /// Test that per-request headers properly override default headers while non-conflicting headers are preserved + /// + [Fact] + public async Task Check_HeaderPrecedence_AllLayersIntegration() { + // This test verifies that per-request headers properly override default headers + // while non-conflicting headers from both layers are preserved. + + var configWithDefaults = new ClientConfiguration() { + StoreId = _storeId, + ApiUrl = _apiUrl + }; + + // Default headers (lowest priority) + configWithDefaults.DefaultHeaders["X-Layer"] = "default"; + configWithDefaults.DefaultHeaders["X-Default-Only"] = "default-only"; + + var expectedResponse = new CheckResponse() { Allowed = true }; + IDictionary>? sentHeaders = null; + + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync((HttpRequestMessage req, CancellationToken ct) => { + sentHeaders = req.Headers.ToDictionary(h => h.Key, h => h.Value); + return new HttpResponseMessage() { + StatusCode = HttpStatusCode.OK, + Content = Utils.CreateJsonStringContent(expectedResponse), + }; + }); + + var httpClient = new HttpClient(mockHandler.Object); + var client = new {{appShortName}}Client(configWithDefaults, httpClient); + + // Per-request headers (highest priority) + var options = new ClientCheckOptions { + Headers = new Dictionary { + { "X-Layer", "per-request" }, + { "X-Per-Request-Only", "per-request-only" } + } + }; + + await client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + options + ); + + Assert.NotNull(sentHeaders); + + // Verify per-request header wins the precedence battle + Assert.Equal("per-request", sentHeaders["X-Layer"].First()); + + // Verify per-request-only header is present + Assert.Equal("per-request-only", sentHeaders["X-Per-Request-Only"].First()); + + // Verify default-only header is present (not overridden) + Assert.Equal("default-only", sentHeaders["X-Default-Only"].First()); + + // Verify exactly the right number of custom headers (not counting standard HTTP headers) + var customHeaders = sentHeaders.Where(h => h.Key.StartsWith("X-")).ToList(); + Assert.Equal(3, customHeaders.Count); + } + + /// + /// Test per-request Content-Type header should be blocked before HTTP request + /// + [Fact] + public async Task PerRequestHeaders_ContentType_ShouldFailBeforeHttpRequest() { + var (client, _) = CreateTestClientForHeaders(new CheckResponse()); + + var options = new ClientCheckOptions { + Headers = new Dictionary { + { "Content-Type", "application/xml" } + } + }; + + var exception = await Assert.ThrowsAsync(async () => + await client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + options + ) + ); + + Assert.Contains("Content-Type", exception.Message); + Assert.Contains("reserved HTTP header", exception.Message); + } + + /// + /// Comprehensive test: Content-Type cannot be overridden through ANY path + /// + [Fact] + public async Task IntegrationTest_ContentTypeCannotBeOverriddenAnyPath() { + // This comprehensive test verifies Content-Type cannot be overridden through ANY path: + // 1. Via DefaultHeaders - validated at configuration time + // 2. Via per-request Headers - validated at request time + + // Test Path 1: DefaultHeaders + var configWithContentType = new ClientConfiguration() { + StoreId = _storeId, + ApiUrl = _apiUrl + }; + configWithContentType.DefaultHeaders["Content-Type"] = "text/plain"; + + var configException = Assert.Throws(() => configWithContentType.EnsureValid()); + Assert.Contains("Content-Type", configException.Message); + Assert.Contains("reserved", configException.Message); + + // Test Path 2: Per-request headers + var (client, _) = CreateTestClientForHeaders(new CheckResponse()); + + var optionsWithContentType = new ClientCheckOptions { + Headers = new Dictionary { + { "Content-Type", "application/xml" } + } + }; + + var requestException = await Assert.ThrowsAsync(async () => + await client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + optionsWithContentType + ) + ); + + Assert.Contains("Content-Type", requestException.Message); + Assert.Contains("reserved", requestException.Message); + } + + /// + /// Comprehensive test: all reserved headers are protected in both config and per-request paths + /// + [Fact] + public async Task IntegrationTest_AllReservedHeadersProtected() { + // Comprehensive test: ensure ALL reserved headers are protected in both paths + // Note: User-Agent is intentionally excluded as the SDK allows customization + + var reservedHeaders = new[] { + "Authorization", + "Content-Type", + "Content-Length", + "Host", + "Accept", + "Accept-Encoding" + }; + + foreach (var reservedHeader in reservedHeaders) { + // Test DefaultHeaders path + var config = new ClientConfiguration() { + StoreId = _storeId, + ApiUrl = _apiUrl + }; + config.DefaultHeaders[reservedHeader] = "test-value"; + + var configException = Assert.Throws(() => config.EnsureValid()); + Assert.Contains(reservedHeader, configException.Message, StringComparison.OrdinalIgnoreCase); + + // Test per-request headers path + var (client, _) = CreateTestClientForHeaders(new CheckResponse()); + + var options = new ClientCheckOptions { + Headers = new Dictionary { + { reservedHeader, "test-value" } + } + }; + + var requestException = await Assert.ThrowsAsync(async () => + await client.Check( + new ClientCheckRequest { + User = "user:anne", + Relation = "reader", + Object = "document:budget" + }, + options + ) + ); + + Assert.Contains(reservedHeader, requestException.Message, StringComparison.OrdinalIgnoreCase); + } + } + + #endregion +} diff --git a/config/clients/dotnet/template/README_initializing.mustache b/config/clients/dotnet/template/README_initializing.mustache index a78815776..ef6241f52 100644 --- a/config/clients/dotnet/template/README_initializing.mustache +++ b/config/clients/dotnet/template/README_initializing.mustache @@ -94,3 +94,43 @@ namespace Example { } } ``` + +### Custom Headers + +#### Default Headers +You can set default headers that will be sent with every request during client initialization: + +```csharp +var configuration = new ClientConfiguration() { + ApiUrl = Environment.GetEnvironmentVariable("FGA_API_URL") ?? "http://localhost:8080", + StoreId = Environment.GetEnvironmentVariable("FGA_STORE_ID"), + AuthorizationModelId = Environment.GetEnvironmentVariable("FGA_MODEL_ID"), + DefaultHeaders = new Dictionary { + { "X-Custom-Header", "default-value" }, + { "X-Request-Source", "my-app" } + } +}; +var fgaClient = new OpenFgaClient(configuration); +``` + +#### Per-Request Headers + +You can also send custom headers on a per-request basis by using the options parameter. Per-request headers will override any default headers set in the client configuration. + +```csharp +// Add custom headers to a specific request +var body = new ClientCheckRequest { + User = "user:anne", + Relation = "viewer", + Object = "document:roadmap" +}; + +var options = new ClientCheckOptions { + Headers = new Dictionary { + { "X-Request-ID", "123e4567-e89b-12d3-a456-426614174000" }, + { "X-Custom-Header", "custom-value" } // These override any default headers + } +}; + +var response = await fgaClient.Check(body, options); +``` diff --git a/config/clients/dotnet/template/RequestOptions.mustache b/config/clients/dotnet/template/RequestOptions.mustache deleted file mode 100644 index e69de29bb..000000000 diff --git a/config/clients/dotnet/template/api.mustache b/config/clients/dotnet/template/api.mustache index 0d2e66579..6b8f1f7d3 100644 --- a/config/clients/dotnet/template/api.mustache +++ b/config/clients/dotnet/template/api.mustache @@ -1,13 +1,13 @@ {{>partial_header}} +using {{packageName}}.ApiClient; +using {{packageName}}.Exceptions; +using {{packageName}}.{{modelPackage}}; using System; using System.Collections.Generic; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using {{packageName}}.ApiClient; -using {{packageName}}.Exceptions; -using {{packageName}}.{{modelPackage}}; namespace {{packageName}}.{{apiPackage}}; @@ -34,17 +34,18 @@ public class {{classname}} : IDisposable { /// /// {{summary}} {{notes}} /// - /// Thrown when fails to make API call + /// Thrown when fails to make API call {{#allParams}} /// {{description}}{{^required}} (optional{{#defaultValue}}, default to {{.}}{{/defaultValue}}){{/required}}{{#isDeprecated}} (deprecated){{/isDeprecated}} {{/allParams}} + /// Request options. /// Cancellation Token to cancel the request. /// Task of {{returnType}}{{^returnType}}void{{/returnType}} {{#isDeprecated}} [Obsolete] {{/isDeprecated}} - {{#returnType}}public async Task<{{{.}}}>{{/returnType}}{{^returnType}}public async Task{{/returnType}} {{operationId}}({{#allParams}}{{{dataType}}} {{paramName}}{{^required}}{{#optionalMethodArgument}} = default({{{dataType}}}){{/optionalMethodArgument}}{{/required}}{{^-last}}, {{/-last}}{{/allParams}}{{#allParams.0}}, {{/allParams.0}}CancellationToken cancellationToken = default) { - var pathParams = new Dictionary {}; + {{#returnType}}public async Task<{{{.}}}>{{/returnType}}{{^returnType}}public async Task{{/returnType}} {{operationId}}({{#allParams}}{{{dataType}}} {{paramName}}{{^required}}{{#optionalMethodArgument}} = default({{{dataType}}}){{/optionalMethodArgument}}{{/required}}{{^-last}}, {{/-last}}{{/allParams}}{{#allParams.0}}, {{/allParams.0}}IRequestOptions? options = null, CancellationToken cancellationToken = default) { + var pathParams = new Dictionary { }; {{#pathParams.0}} if (string.IsNullOrWhiteSpace(storeId)) { throw new FgaRequiredParamError("{{operationId}}", "StoreId"); @@ -87,7 +88,7 @@ public class {{classname}} : IDisposable { }; {{#returnType}}return {{/returnType}}await _apiClient.SendRequestAsync{{#returnType}}<{{#bodyParam}}{{{dataType}}}{{/bodyParam}}{{^bodyParam}}Any{{/bodyParam}}, {{{.}}}>{{/returnType}}(requestBuilder, - "{{operationId}}", cancellationToken); + "{{operationId}}", options, cancellationToken); } {{/operation}} diff --git a/config/clients/dotnet/template/modelRequestOptions.mustache b/config/clients/dotnet/template/modelRequestOptions.mustache new file mode 100644 index 000000000..85b7e96c9 --- /dev/null +++ b/config/clients/dotnet/template/modelRequestOptions.mustache @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace {{packageName}}.Model; + +/// +/// Request Options +/// +public interface IRequestOptions { + /// + /// Custom headers to include with this specific request. + /// + IDictionary? Headers { get; set; } +} + +/// +/// RequestOptions +/// +public partial class RequestOptions : IRequestOptions { + /// + /// Initializes a new instance of the class. + /// + public RequestOptions() { + } + + /// + /// Initializes a new instance of the class. + /// + /// Custom headers to include with this specific request. + public RequestOptions(IDictionary? headers = default) { + this.Headers = headers; + } + + /// + public IDictionary? Headers { get; set; } +}