From c692ec2a26eb4007ff428e54eaa67ea22fd20728 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 15 Apr 2025 07:05:07 +0100 Subject: [PATCH 001/126] chore(deps): update codecov/codecov-action action to v5.4.2 (#432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [codecov/codecov-action](https://github.com/codecov/codecov-action) | action | patch | `v5.4.0` -> `v5.4.2` | --- ### Release Notes
codecov/codecov-action (codecov/codecov-action) ### [`v5.4.2`](https://github.com/codecov/codecov-action/blob/HEAD/CHANGELOG.md#v542) [Compare Source](https://github.com/codecov/codecov-action/compare/v5.4.1...v5.4.2) ##### What's Changed **Full Changelog**: https://github.com/codecov/codecov-action/compare/v5.4.1..v5.4.2 ### [`v5.4.1`](https://github.com/codecov/codecov-action/blob/HEAD/CHANGELOG.md#v541) [Compare Source](https://github.com/codecov/codecov-action/compare/v5.4.0...v5.4.1) ##### What's Changed - fix: use the github core methods by [@​thomasrockhu-codecov](https://github.com/thomasrockhu-codecov) in [https://github.com/codecov/codecov-action/pull/1807](https://github.com/codecov/codecov-action/pull/1807) - build(deps): bump github/codeql-action from 3.28.12 to 3.28.13 by [@​app/dependabot](https://github.com/app/dependabot) in [https://github.com/codecov/codecov-action/pull/1803](https://github.com/codecov/codecov-action/pull/1803) - build(deps): bump github/codeql-action from 3.28.11 to 3.28.12 by [@​app/dependabot](https://github.com/app/dependabot) in [https://github.com/codecov/codecov-action/pull/1797](https://github.com/codecov/codecov-action/pull/1797) - build(deps): bump actions/upload-artifact from 4.6.1 to 4.6.2 by [@​app/dependabot](https://github.com/app/dependabot) in [https://github.com/codecov/codecov-action/pull/1798](https://github.com/codecov/codecov-action/pull/1798) - chore(release): wrapper -0.2.1 by [@​app/codecov-releaser-app](https://github.com/app/codecov-releaser-app) in [https://github.com/codecov/codecov-action/pull/1788](https://github.com/codecov/codecov-action/pull/1788) - build(deps): bump github/codeql-action from 3.28.10 to 3.28.11 by [@​app/dependabot](https://github.com/app/dependabot) in [https://github.com/codecov/codecov-action/pull/1786](https://github.com/codecov/codecov-action/pull/1786) **Full Changelog**: https://github.com/codecov/codecov-action/compare/v5.4.0..v5.4.1
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/code-coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 383136286..be0a5412b 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -34,7 +34,7 @@ jobs: - name: Run Test run: dotnet test --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover - - uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0 + - uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2 with: name: Code Coverage for ${{ matrix.os }} fail_ci_if_error: true From 2de3a971600242d795431caf54fd956df9cbcb83 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 16 Apr 2025 09:18:07 +0100 Subject: [PATCH 002/126] test: Add E2E Steps for contextMerging.feature tests (#422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR - Add new Reqnroll steps for contextMerging.feature ahead of merging #395 ### Related Issues Fixes #399 ### Notes I was following the [requirement 3.2.3](https://openfeature.dev/specification/sections/evaluation-context#requirement-323) I'm assuming with these additions that "Before Hook" is the same as "Invocation" in the dotnet-sdk. If this is not the case then please let me know! In order to fetch the merged context I use a test hook with a function that can set the EvaluationContext on the state. There are probably better abstractions to use I haven't updated the spec submodule in these changes either, although I can add that if needed Let me know if you have any concerns or feedback 🏗️ ### Follow-up Tasks ### How to test --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: André Silva <2493377+askpt@users.noreply.github.com> --- spec | 2 +- .../Steps/BaseStepDefinitions.cs | 117 +++++++++++++++++- ...ContextMergingPrecedenceStepDefinitions.cs | 35 ++++++ .../Utils/ContextStoringProvider.cs | 46 +++++++ test/OpenFeature.E2ETests/Utils/State.cs | 3 + 5 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs create mode 100644 test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs diff --git a/spec b/spec index 0cd553d85..27e4461b4 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 0cd553d85f03b0b7ab983a988ce1720a3190bc88 +Subproject commit 27e4461b452429ec64bcb89af0301f7664cb702a diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs index 1e8311aef..6b2bfebfc 100644 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs @@ -1,4 +1,6 @@ using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using OpenFeature.E2ETests.Utils; using OpenFeature.Model; @@ -18,10 +20,10 @@ public BaseStepDefinitions(State state) } [Given(@"a stable provider")] - public void GivenAStableProvider() + public async Task GivenAStableProvider() { var memProvider = new InMemoryProvider(E2EFlagConfig); - Api.Instance.SetProviderAsync(memProvider).Wait(); + await Api.Instance.SetProviderAsync(memProvider).ConfigureAwait(false); this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); } @@ -57,6 +59,54 @@ public void GivenAString_FlagWithKeyAndADefaultValue(string key, string defaultT this.State.Flag = flagState; } + [Given("a stable provider with retrievable context is registered")] + public async Task GivenAStableProviderWithRetrievableContextIsRegistered() + { + this.State.ContextStoringProvider = new ContextStoringProvider(); + + await Api.Instance.SetProviderAsync(this.State.ContextStoringProvider).ConfigureAwait(false); + + Api.Instance.SetTransactionContextPropagator(new AsyncLocalTransactionContextPropagator()); + + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Given(@"A context entry with key ""(.*)"" and value ""(.*)"" is added to the ""(.*)"" level")] + public void GivenAContextEntryWithKeyAndValueIsAddedToTheLevel(string key, string value, string level) + { + var context = EvaluationContext.Builder() + .Set(key, value) + .Build(); + + this.InitializeContext(level, context); + } + + [Given("A table with levels of increasing precedence")] + public void GivenATableWithLevelsOfIncreasingPrecedence(DataTable dataTable) + { + var items = dataTable.Rows.ToList(); + + var levels = items.Select(r => r.Values.First()); + + this.State.ContextPrecedenceLevels = levels.ToArray(); + } + + [Given(@"Context entries for each level from API level down to the ""(.*)"" level, with key ""(.*)"" and value ""(.*)""")] + public void GivenContextEntriesForEachLevelFromAPILevelDownToTheLevelWithKeyAndValue(string currentLevel, string key, string value) + { + if (this.State.ContextPrecedenceLevels == null) + this.State.ContextPrecedenceLevels = new string[0]; + + foreach (var level in this.State.ContextPrecedenceLevels) + { + var context = EvaluationContext.Builder() + .Set(key, value) + .Build(); + + this.InitializeContext(level, context); + } + } + [When(@"the flag was evaluated with details")] public async Task WhenTheFlagWasEvaluatedWithDetails() { @@ -82,6 +132,54 @@ public async Task WhenTheFlagWasEvaluatedWithDetails() break; } } + private void InitializeContext(string level, EvaluationContext context) + { + switch (level) + { + case "API": + { + Api.Instance.SetContext(context); + break; + } + case "Transaction": + { + Api.Instance.SetTransactionContext(context); + break; + } + case "Client": + { + if (this.State.Client != null) + { + this.State.Client.SetContext(context); + } + else + { + throw new PendingStepException("You must initialise a FeatureClient before adding some EvaluationContext"); + } + break; + } + case "Invocation": + { + this.State.InvocationEvaluationContext = context; + break; + } + case "Before Hooks": // Assumed before hooks is the same as Invocation + { + if (this.State.Client != null) + { + this.State.Client.AddHooks(new BeforeHook(context)); + } + else + { + throw new PendingStepException("You must initialise a FeatureClient before adding some EvaluationContext"); + } + + break; + } + default: + throw new PendingStepException("Context level not defined"); + } + } private static readonly IDictionary E2EFlagConfig = new Dictionary { @@ -159,4 +257,19 @@ public async Task WhenTheFlagWasEvaluatedWithDetails() ) } }; + + public class BeforeHook : Hook + { + private readonly EvaluationContext context; + + public BeforeHook(EvaluationContext context) + { + this.context = context; + } + + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return new ValueTask(this.context); + } + } } diff --git a/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs new file mode 100644 index 000000000..c9f454ac9 --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using OpenFeature.E2ETests.Utils; +using Reqnroll; +using Xunit; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +[Scope(Feature = "Context merging precedence")] +public class ContextMergingPrecedenceStepDefinitions : BaseStepDefinitions +{ + public ContextMergingPrecedenceStepDefinitions(State state) : base(state) + { + } + + [When("Some flag was evaluated")] + public async Task WhenSomeFlagWasEvaluated() + { + this.State.Flag = new FlagState("boolean-flag", "true", FlagType.Boolean); + this.State.FlagResult = await this.State.Client!.GetBooleanValueAsync("boolean-flag", true, this.State.InvocationEvaluationContext).ConfigureAwait(false); + } + + [Then(@"The merged context contains an entry with key ""(.*)"" and value ""(.*)""")] + public void ThenTheMergedContextContainsAnEntryWithKeyAndValue(string key, string value) + { + var provider = this.State.ContextStoringProvider; + + var mergedContext = provider!.EvaluationContext!; + + Assert.NotNull(mergedContext); + + var actualValue = mergedContext.GetValue(key); + Assert.Contains(value, actualValue.AsString); + } +} diff --git a/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs b/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs new file mode 100644 index 000000000..40141e791 --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs @@ -0,0 +1,46 @@ +using System.Threading; +using System.Threading.Tasks; +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Utils; + +public class ContextStoringProvider : FeatureProvider +{ + private EvaluationContext? evaluationContext; + public EvaluationContext? EvaluationContext { get => this.evaluationContext; } + + public override Metadata? GetMetadata() + { + return new Metadata("ContextStoringProvider"); + } + + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + this.evaluationContext = context; + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } +} diff --git a/test/OpenFeature.E2ETests/Utils/State.cs b/test/OpenFeature.E2ETests/Utils/State.cs index b3380132f..13a4e5a39 100644 --- a/test/OpenFeature.E2ETests/Utils/State.cs +++ b/test/OpenFeature.E2ETests/Utils/State.cs @@ -10,4 +10,7 @@ public class State public TestHook? TestHook; public object? FlagResult; public EvaluationContext? EvaluationContext; + public ContextStoringProvider? ContextStoringProvider; + public EvaluationContext? InvocationEvaluationContext; + public string[]? ContextPrecedenceLevels; } From 5608dfbd441b99531add8e89ad842ea9d613f707 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 16 Apr 2025 09:38:34 +0100 Subject: [PATCH 003/126] chore(deps): update spec digest to 18cde17 (#395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | spec | digest | `27e4461` -> `18cde17` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index 27e4461b4..18cde1708 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 27e4461b452429ec64bcb89af0301f7664cb702a +Subproject commit 18cde1708e08d4f380ba30b4b1649e06683edfd2 From 73207d037c68f91dc400f7962f60d51717d52beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Wed, 16 Apr 2025 16:30:22 +0100 Subject: [PATCH 004/126] ci: Add GH Actions as a scanned artifact for CodeQL (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2e3a1e149..57ca46418 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,7 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'csharp' ] + language: [ 'csharp', 'actions' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support From 568722a4ab1f863d8509dc4a172ac9c29f267825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 17 Apr 2025 16:38:06 +0100 Subject: [PATCH 005/126] chore(workflows): Add permissions for contents and pull-requests (#439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR This pull request includes updates to several GitHub Actions workflows to add permissions for reading contents and writing pull requests. Additionally, there is a minor change to the `lint-pr.yml` workflow file to standardize the quotation marks used in the `name` field. Workflow permissions updates: * [`.github/workflows/ci.yml`](diffhunk://#diff-b803fcb7f17ed9235f1e5cb1fcd2f5d3b2838429d4368ae4c57ce4436577f03fR15-R17): Added permissions for reading contents and writing pull requests to the `build` job. * [`.github/workflows/code-coverage.yml`](diffhunk://#diff-49708f979e226a1e7bd7a68d71b2e91aae8114dd3e9254d9830cd3b4d62d4303R15-R17): Added permissions for reading contents and writing pull requests to the `build-test-report` job. * [`.github/workflows/dco-merge-group.yml`](diffhunk://#diff-cbf8f01aa06b4aa3d0729c5bce44e4f919c801b55f19a781b15f62aa10e68e90R10-R12): Added permissions for reading contents and writing pull requests to the `DCO` job. * [`.github/workflows/dotnet-format.yml`](diffhunk://#diff-ca8c2611c79b991c0fbe04fec3c97c14dc83419f5efb1e8a7a96dd51e7df3e2aR12-R14): Added permissions for reading contents and writing pull requests to the `check-format` job. * [`.github/workflows/e2e.yml`](diffhunk://#diff-3e103440521ada06efd263ae09b259e5507e4b8f7408308dc227621ad9efa31eR16-R18): Added permissions for reading contents and writing pull requests to the `e2e-tests` job. * [`.github/workflows/lint-pr.yml`](diffhunk://#diff-70c3a017bfdb629fd50281fe5f7ad22e29c0ddac36e7065e9dc6d4f0924104f4R14-R16): Added permissions for reading contents and writing pull requests to the `main` job. Standardization: * [`.github/workflows/lint-pr.yml`](diffhunk://#diff-70c3a017bfdb629fd50281fe5f7ad22e29c0ddac36e7065e9dc6d4f0924104f4L1-R1): Changed single quotes to double quotes in the `name` field. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/ci.yml | 3 +++ .github/workflows/code-coverage.yml | 3 +++ .github/workflows/dco-merge-group.yml | 3 +++ .github/workflows/dotnet-format.yml | 3 +++ .github/workflows/e2e.yml | 3 +++ .github/workflows/lint-pr.yml | 5 ++++- 6 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a26e59c6..bb1c72273 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,9 @@ on: jobs: build: + permissions: + contents: read + pull-requests: write strategy: matrix: os: [ubuntu-latest, windows-latest] diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index be0a5412b..a33413d84 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -12,6 +12,9 @@ on: jobs: build-test-report: + permissions: + contents: read + pull-requests: write strategy: matrix: os: [ubuntu-latest, windows-latest] diff --git a/.github/workflows/dco-merge-group.yml b/.github/workflows/dco-merge-group.yml index 0241f80a8..018589ead 100644 --- a/.github/workflows/dco-merge-group.yml +++ b/.github/workflows/dco-merge-group.yml @@ -7,6 +7,9 @@ on: jobs: DCO: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write if: ${{ github.actor != 'renovate[bot]' }} steps: - run: echo "dummy DCO workflow (it won't run any check actually) to trigger by merge_group in order to enable merge queue" diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index 63259de07..16799cf11 100644 --- a/.github/workflows/dotnet-format.yml +++ b/.github/workflows/dotnet-format.yml @@ -9,6 +9,9 @@ on: jobs: check-format: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - name: Check out code diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ce4bb634e..ae0ca8391 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -13,6 +13,9 @@ on: jobs: e2e-tests: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index 5dbb56887..f23079276 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -1,4 +1,4 @@ -name: 'Lint PR' +name: "Lint PR" on: pull_request_target: @@ -11,6 +11,9 @@ jobs: main: name: Validate PR title runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5 env: From 456351216ce9113d84b56d0bce1dad39430a26cd Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 18 Apr 2025 11:16:14 -0700 Subject: [PATCH 006/126] feat: Add support for hook data. (#387) ## This PR Adds support for hook data. https://github.com/open-feature/spec/pull/273 ### Related Issues ### Notes I realized that the 4.6.1 section of the spec wasn't consistent with the expected usage. Basically it over-specifies the typing of the hook data matching that of the evaluation context. That is one possible approach, it would just mean a bit more work on the part of the hook implementers. In the earlier example in the spec I put a `Span` in the hook data: ``` public Optional before(HookContext context, HookHints hints) { SpanBuilder builder = tracer.spanBuilder('sample') .setParent(Context.current().with(Span.current())); Span span = builder.startSpan() context.hookData.set("span", span); } public void after(HookContext context, FlagEvaluationDetails details, HookHints hints) { // Only accessible by this hook for this specific evaluation. Object value = context.hookData.get("span"); if (value instanceof Span) { Span span = (Span) value; span.end(); } } ``` This is only possible if the hook data allows specification of any `object` instead of being limited to the immutable types of a context. For hooks hook data this is safe because only the hook mutating the data will have access to that data. Additionally the execution of the hook will be in sequence with the evaluation (likely in a single thread). The alternative would be to store data in the hook, and use the hook data to know when to remove it. Something like this: ``` public Optional before(HookContext context, HookHints hints) { SpanBuilder builder = tracer.spanBuilder('sample') .setParent(Context.current().with(Span.current())); Span span = builder.startSpan() String storageId = Uuid(); this.tmpData.set(storageId, span); context.hookData.set("span", storageId); } public void after(HookContext context, FlagEvaluationDetails details, HookHints hints) { // Only accessible by this hook for this specific evaluation. Object value = context.hookData.get("span"); if (value) { String id = value.AsString(); Span span= this.tmpData.get(id); span.end(); } } ``` ### Follow-up Tasks ### How to test --------- Signed-off-by: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Co-authored-by: chrfwow --- README.md | 27 ++- src/OpenFeature/HookData.cs | 104 +++++++++++ src/OpenFeature/HookRunner.cs | 173 ++++++++++++++++++ src/OpenFeature/Model/HookContext.cs | 43 +++-- src/OpenFeature/OpenFeatureClient.cs | 127 ++++--------- src/OpenFeature/SharedHookContext.cs | 60 ++++++ test/OpenFeature.Tests/HookDataTests.cs | 158 ++++++++++++++++ .../OpenFeature.Tests/OpenFeatureHookTests.cs | 106 ++++++++++- 8 files changed, 683 insertions(+), 115 deletions(-) create mode 100644 src/OpenFeature/HookData.cs create mode 100644 src/OpenFeature/HookRunner.cs create mode 100644 src/OpenFeature/SharedHookContext.cs create mode 100644 test/OpenFeature.Tests/HookDataTests.cs diff --git a/README.md b/README.md index cf8101ab2..7a2261772 100644 --- a/README.md +++ b/README.md @@ -336,13 +336,13 @@ public class MyHook : Hook } public ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, - IReadOnlyDictionary hints = null) + IReadOnlyDictionary? hints = null) { // code to run after successful flag evaluation } public ValueTask ErrorAsync(HookContext context, Exception error, - IReadOnlyDictionary hints = null) + IReadOnlyDictionary? hints = null) { // code to run if there's an error during before hooks or during flag evaluation } @@ -354,6 +354,29 @@ public class MyHook : Hook } ``` +Hooks support passing per-evaluation data between that stages using `hook data`. The below example hook uses `hook data` to measure the duration between the execution of the `before` and `after` stage. + +```csharp + class TimingHook : Hook + { + public ValueTask BeforeAsync(HookContext context, + IReadOnlyDictionary? hints = null) + { + context.Data.Set("beforeTime", DateTime.Now); + return ValueTask.FromResult(context.EvaluationContext); + } + + public ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, + IReadOnlyDictionary? hints = null) + { + var beforeTime = context.Data.Get("beforeTime") as DateTime?; + var duration = DateTime.Now - beforeTime; + Console.WriteLine($"Duration: {duration}"); + return ValueTask.CompletedTask; + } + } +``` + Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs! ### DependencyInjection diff --git a/src/OpenFeature/HookData.cs b/src/OpenFeature/HookData.cs new file mode 100644 index 000000000..5d56eb870 --- /dev/null +++ b/src/OpenFeature/HookData.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using OpenFeature.Model; + +namespace OpenFeature +{ + /// + /// A key-value collection of strings to objects used for passing data between hook stages. + /// + /// This collection is scoped to a single evaluation for a single hook. Each hook stage for the evaluation + /// will share the same . + /// + /// + /// This collection is intended for use only during the execution of individual hook stages, a reference + /// to the collection should not be retained. + /// + /// + /// This collection is not thread-safe. + /// + /// + /// + public sealed class HookData + { + private readonly Dictionary _data = []; + + /// + /// Set the key to the given value. + /// + /// The key for the value + /// The value to set + /// This hook data instance + public HookData Set(string key, object value) + { + this._data[key] = value; + return this; + } + + /// + /// Gets the value at the specified key as an object. + /// + /// For types use instead. + /// + /// + /// The key of the value to be retrieved + /// The object associated with the key + /// + /// Thrown when the context does not contain the specified key + /// + public object Get(string key) + { + return this._data[key]; + } + + /// + /// Return a count of all values. + /// + public int Count => this._data.Count; + + /// + /// Return an enumerator for all values. + /// + /// An enumerator for all values + public IEnumerator> GetEnumerator() + { + return this._data.GetEnumerator(); + } + + /// + /// Return a list containing all the keys in the hook data + /// + public IImmutableList Keys => this._data.Keys.ToImmutableList(); + + /// + /// Return an enumerable containing all the values of the hook data + /// + public IImmutableList Values => this._data.Values.ToImmutableList(); + + /// + /// Gets all values as a read only dictionary. + /// + /// The dictionary references the original values and is not a thread-safe copy. + /// + /// + /// A representation of the hook data + public IReadOnlyDictionary AsDictionary() + { + return this._data; + } + + /// + /// Gets or sets the value associated with the specified key. + /// + /// The key of the value to get or set + /// The value associated with the specified key + /// + /// Thrown when getting a value and the context does not contain the specified key + /// + public object this[string key] + { + get => this.Get(key); + set => this.Set(key, value); + } + } +} diff --git a/src/OpenFeature/HookRunner.cs b/src/OpenFeature/HookRunner.cs new file mode 100644 index 000000000..8c1dbb510 --- /dev/null +++ b/src/OpenFeature/HookRunner.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OpenFeature.Model; + +namespace OpenFeature +{ + /// + /// This class manages the execution of hooks. + /// + /// type of the evaluation detail provided to the hooks + internal partial class HookRunner + { + private readonly ImmutableList _hooks; + + private readonly List> _hookContexts; + + private EvaluationContext _evaluationContext; + + private readonly ILogger _logger; + + /// + /// Construct a hook runner instance. Each instance should be used for the execution of a single evaluation. + /// + /// + /// The hooks for the evaluation, these should be in the correct order for the before evaluation stage + /// + /// + /// The initial evaluation context, this can be updated as the hooks execute + /// + /// + /// Contents of the initial hook context excluding the evaluation context and hook data + /// + /// Client logger instance + public HookRunner(ImmutableList hooks, EvaluationContext evaluationContext, + SharedHookContext sharedHookContext, + ILogger logger) + { + this._evaluationContext = evaluationContext; + this._logger = logger; + this._hooks = hooks; + this._hookContexts = new List>(hooks.Count); + for (var i = 0; i < hooks.Count; i++) + { + // Create hook instance specific hook context. + // Hook contexts are instance specific so that the mutable hook data is scoped to each hook. + this._hookContexts.Add(sharedHookContext.ToHookContext(evaluationContext)); + } + } + + /// + /// Execute before hooks. + /// + /// Optional hook hints + /// Cancellation token which can cancel hook operations + /// Context with any modifications from the before hooks + public async Task TriggerBeforeHooksAsync(IImmutableDictionary? hints, + CancellationToken cancellationToken = default) + { + var evalContextBuilder = EvaluationContext.Builder(); + evalContextBuilder.Merge(this._evaluationContext); + + for (var i = 0; i < this._hooks.Count; i++) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + + var resp = await hook.BeforeAsync(hookContext, hints, cancellationToken) + .ConfigureAwait(false); + if (resp != null) + { + evalContextBuilder.Merge(resp); + this._evaluationContext = evalContextBuilder.Build(); + for (var j = 0; j < this._hookContexts.Count; j++) + { + this._hookContexts[j] = this._hookContexts[j].WithNewEvaluationContext(this._evaluationContext); + } + } + else + { + this.HookReturnedNull(hook.GetType().Name); + } + } + + return this._evaluationContext; + } + + /// + /// Execute the after hooks. These are executed in opposite order of the before hooks. + /// + /// The evaluation details which will be provided to the hook + /// Optional hook hints + /// Cancellation token which can cancel hook operations + public async Task TriggerAfterHooksAsync(FlagEvaluationDetails evaluationDetails, + IImmutableDictionary? hints, + CancellationToken cancellationToken = default) + { + // After hooks run in reverse. + for (var i = this._hooks.Count - 1; i >= 0; i--) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + await hook.AfterAsync(hookContext, evaluationDetails, hints, cancellationToken) + .ConfigureAwait(false); + } + } + + /// + /// Execute the error hooks. These are executed in opposite order of the before hooks. + /// + /// Exception which triggered the error + /// Optional hook hints + /// Cancellation token which can cancel hook operations + public async Task TriggerErrorHooksAsync(Exception exception, + IImmutableDictionary? hints, CancellationToken cancellationToken = default) + { + // Error hooks run in reverse. + for (var i = this._hooks.Count - 1; i >= 0; i--) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + try + { + await hook.ErrorAsync(hookContext, exception, hints, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) + { + this.ErrorHookError(hook.GetType().Name, e); + } + } + } + + /// + /// Execute the finally hooks. These are executed in opposite order of the before hooks. + /// + /// The evaluation details which will be provided to the hook + /// Optional hook hints + /// Cancellation token which can cancel hook operations + public async Task TriggerFinallyHooksAsync(FlagEvaluationDetails evaluationDetails, + IImmutableDictionary? hints, + CancellationToken cancellationToken = default) + { + // Finally hooks run in reverse + for (var i = this._hooks.Count - 1; i >= 0; i--) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + try + { + await hook.FinallyAsync(hookContext, evaluationDetails, hints, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) + { + this.FinallyHookError(hook.GetType().Name, e); + } + } + } + + [LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")] + partial void HookReturnedNull(string hookName); + + [LoggerMessage(103, LogLevel.Error, "Error while executing Error hook {HookName}")] + partial void ErrorHookError(string hookName, Exception exception); + + [LoggerMessage(104, LogLevel.Error, "Error while executing Finally hook {HookName}")] + partial void FinallyHookError(string hookName, Exception exception); + } +} diff --git a/src/OpenFeature/Model/HookContext.cs b/src/OpenFeature/Model/HookContext.cs index 69f58bde8..8d99a2836 100644 --- a/src/OpenFeature/Model/HookContext.cs +++ b/src/OpenFeature/Model/HookContext.cs @@ -10,20 +10,22 @@ namespace OpenFeature.Model /// public sealed class HookContext { + private readonly SharedHookContext _shared; + /// /// Feature flag being evaluated /// - public string FlagKey { get; } + public string FlagKey => this._shared.FlagKey; /// /// Default value if flag fails to be evaluated /// - public T DefaultValue { get; } + public T DefaultValue => this._shared.DefaultValue; /// /// The value type of the flag /// - public FlagValueType FlagValueType { get; } + public FlagValueType FlagValueType => this._shared.FlagValueType; /// /// User defined evaluation context used in the evaluation process @@ -34,12 +36,17 @@ public sealed class HookContext /// /// Client metadata /// - public ClientMetadata ClientMetadata { get; } + public ClientMetadata ClientMetadata => this._shared.ClientMetadata; /// /// Provider metadata /// - public Metadata ProviderMetadata { get; } + public Metadata ProviderMetadata => this._shared.ProviderMetadata; + + /// + /// Hook data + /// + public HookData Data { get; } /// /// Initialize a new instance of @@ -58,23 +65,27 @@ public HookContext(string? flagKey, Metadata? providerMetadata, EvaluationContext? evaluationContext) { - this.FlagKey = flagKey ?? throw new ArgumentNullException(nameof(flagKey)); - this.DefaultValue = defaultValue; - this.FlagValueType = flagValueType; - this.ClientMetadata = clientMetadata ?? throw new ArgumentNullException(nameof(clientMetadata)); - this.ProviderMetadata = providerMetadata ?? throw new ArgumentNullException(nameof(providerMetadata)); + this._shared = new SharedHookContext( + flagKey, defaultValue, flagValueType, clientMetadata, providerMetadata); + + this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext)); + this.Data = new HookData(); + } + + internal HookContext(SharedHookContext? sharedHookContext, EvaluationContext? evaluationContext, + HookData? hookData) + { + this._shared = sharedHookContext ?? throw new ArgumentNullException(nameof(sharedHookContext)); this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext)); + this.Data = hookData ?? throw new ArgumentNullException(nameof(hookData)); } internal HookContext WithNewEvaluationContext(EvaluationContext context) { return new HookContext( - this.FlagKey, - this.DefaultValue, - this.FlagValueType, - this.ClientMetadata, - this.ProviderMetadata, - context + this._shared, + context, + this.Data ); } } diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index 03420a2a4..4a00aa440 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -222,32 +223,29 @@ private async Task> EvaluateFlagAsync( evaluationContextBuilder.Merge(Api.Instance.GetTransactionContext()); // Transaction context evaluationContextBuilder.Merge(context); // Invocation context - var allHooks = new List() + var allHooks = ImmutableList.CreateBuilder() .Concat(Api.Instance.GetHooks()) .Concat(this.GetHooks()) .Concat(options?.Hooks ?? Enumerable.Empty()) .Concat(provider.GetProviderHooks()) - .ToList() - .AsReadOnly(); + .ToImmutableList(); - var allHooksReversed = allHooks - .AsEnumerable() - .Reverse() - .ToList() - .AsReadOnly(); - - var hookContext = new HookContext( + var sharedHookContext = new SharedHookContext( flagKey, defaultValue, - flagValueType, this._metadata, - provider.GetMetadata(), - evaluationContextBuilder.Build() + flagValueType, + this._metadata, + provider.GetMetadata() ); FlagEvaluationDetails? evaluation = null; + var hookRunner = new HookRunner(allHooks, evaluationContextBuilder.Build(), sharedHookContext, + this._logger); + try { - var contextFromHooks = await this.TriggerBeforeHooksAsync(allHooks, hookContext, options, cancellationToken).ConfigureAwait(false); + var evaluationContextFromHooks = await hookRunner.TriggerBeforeHooksAsync(options?.HookHints, cancellationToken) + .ConfigureAwait(false); // short circuit evaluation entirely if provider is in a bad state if (provider.Status == ProviderStatus.NotReady) @@ -260,23 +258,24 @@ private async Task> EvaluateFlagAsync( } evaluation = - (await resolveValueDelegate.Invoke(flagKey, defaultValue, contextFromHooks.EvaluationContext, cancellationToken).ConfigureAwait(false)) + (await resolveValueDelegate + .Invoke(flagKey, defaultValue, evaluationContextFromHooks, cancellationToken) + .ConfigureAwait(false)) .ToFlagEvaluationDetails(); if (evaluation.ErrorType == ErrorType.None) { - await this.TriggerAfterHooksAsync( - allHooksReversed, - hookContext, + await hookRunner.TriggerAfterHooksAsync( evaluation, - options, + options?.HookHints, cancellationToken ).ConfigureAwait(false); } else { var exception = new FeatureProviderException(evaluation.ErrorType, evaluation.ErrorMessage); - await this.TriggerErrorHooksAsync(allHooksReversed, hookContext, exception, options, cancellationToken) + this.FlagEvaluationErrorWithDescription(flagKey, evaluation.ErrorType.GetDescription(), exception); + await hookRunner.TriggerErrorHooksAsync(exception, options?.HookHints, cancellationToken) .ConfigureAwait(false); } } @@ -285,88 +284,29 @@ await this.TriggerErrorHooksAsync(allHooksReversed, hookContext, exception, opti this.FlagEvaluationErrorWithDescription(flagKey, ex.ErrorType.GetDescription(), ex); evaluation = new FlagEvaluationDetails(flagKey, defaultValue, ex.ErrorType, Reason.Error, string.Empty, ex.Message); - await this.TriggerErrorHooksAsync(allHooksReversed, hookContext, ex, options, cancellationToken).ConfigureAwait(false); + await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken) + .ConfigureAwait(false); } catch (Exception ex) { var errorCode = ex is InvalidCastException ? ErrorType.TypeMismatch : ErrorType.General; - evaluation = new FlagEvaluationDetails(flagKey, defaultValue, errorCode, Reason.Error, string.Empty, ex.Message); - await this.TriggerErrorHooksAsync(allHooksReversed, hookContext, ex, options, cancellationToken).ConfigureAwait(false); + evaluation = new FlagEvaluationDetails(flagKey, defaultValue, errorCode, Reason.Error, string.Empty, + ex.Message); + await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken) + .ConfigureAwait(false); } finally { - evaluation ??= new FlagEvaluationDetails(flagKey, defaultValue, ErrorType.General, Reason.Error, string.Empty, + evaluation ??= new FlagEvaluationDetails(flagKey, defaultValue, ErrorType.General, Reason.Error, + string.Empty, "Evaluation failed to return a result."); - await this.TriggerFinallyHooksAsync(allHooksReversed, evaluation, hookContext, options, cancellationToken).ConfigureAwait(false); + await hookRunner.TriggerFinallyHooksAsync(evaluation, options?.HookHints, cancellationToken) + .ConfigureAwait(false); } return evaluation; } - private async Task> TriggerBeforeHooksAsync(IReadOnlyList hooks, HookContext context, - FlagEvaluationOptions? options, CancellationToken cancellationToken = default) - { - var evalContextBuilder = EvaluationContext.Builder(); - evalContextBuilder.Merge(context.EvaluationContext); - - foreach (var hook in hooks) - { - var resp = await hook.BeforeAsync(context, options?.HookHints, cancellationToken).ConfigureAwait(false); - if (resp != null) - { - evalContextBuilder.Merge(resp); - context = context.WithNewEvaluationContext(evalContextBuilder.Build()); - } - else - { - this.HookReturnedNull(hook.GetType().Name); - } - } - - return context.WithNewEvaluationContext(evalContextBuilder.Build()); - } - - private async Task TriggerAfterHooksAsync(IReadOnlyList hooks, HookContext context, - FlagEvaluationDetails evaluationDetails, FlagEvaluationOptions? options, CancellationToken cancellationToken = default) - { - foreach (var hook in hooks) - { - await hook.AfterAsync(context, evaluationDetails, options?.HookHints, cancellationToken).ConfigureAwait(false); - } - } - - private async Task TriggerErrorHooksAsync(IReadOnlyList hooks, HookContext context, Exception exception, - FlagEvaluationOptions? options, CancellationToken cancellationToken = default) - { - foreach (var hook in hooks) - { - try - { - await hook.ErrorAsync(context, exception, options?.HookHints, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - this.ErrorHookError(hook.GetType().Name, e); - } - } - } - - private async Task TriggerFinallyHooksAsync(IReadOnlyList hooks, FlagEvaluationDetails evaluation, - HookContext context, FlagEvaluationOptions? options, CancellationToken cancellationToken = default) - { - foreach (var hook in hooks) - { - try - { - await hook.FinallyAsync(context, evaluation, options?.HookHints, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - this.FinallyHookError(hook.GetType().Name, e); - } - } - } - /// /// Use this method to track user interactions and the application state. /// @@ -392,16 +332,13 @@ public void Track(string trackingEventName, EvaluationContext? evaluationContext this._providerAccessor.Invoke().Track(trackingEventName, evaluationContextBuilder.Build(), trackingEventDetails); } + [LoggerMessage(101, LogLevel.Error, "Error while evaluating flag {FlagKey}")] + partial void FlagEvaluationError(string flagKey, Exception exception); + [LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")] partial void HookReturnedNull(string hookName); [LoggerMessage(102, LogLevel.Error, "Error while evaluating flag {FlagKey}: {ErrorType}")] partial void FlagEvaluationErrorWithDescription(string flagKey, string errorType, Exception exception); - - [LoggerMessage(103, LogLevel.Error, "Error while executing Error hook {HookName}")] - partial void ErrorHookError(string hookName, Exception exception); - - [LoggerMessage(104, LogLevel.Error, "Error while executing Finally hook {HookName}")] - partial void FinallyHookError(string hookName, Exception exception); } } diff --git a/src/OpenFeature/SharedHookContext.cs b/src/OpenFeature/SharedHookContext.cs new file mode 100644 index 000000000..3d6b787c6 --- /dev/null +++ b/src/OpenFeature/SharedHookContext.cs @@ -0,0 +1,60 @@ +using System; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature +{ + /// + /// Component of the hook context which shared between all hook instances + /// + /// Feature flag key + /// Default value + /// Flag value type + /// Client metadata + /// Provider metadata + /// Flag value type + internal class SharedHookContext( + string? flagKey, + T defaultValue, + FlagValueType flagValueType, + ClientMetadata? clientMetadata, + Metadata? providerMetadata) + { + /// + /// Feature flag being evaluated + /// + public string FlagKey { get; } = flagKey ?? throw new ArgumentNullException(nameof(flagKey)); + + /// + /// Default value if flag fails to be evaluated + /// + public T DefaultValue { get; } = defaultValue; + + /// + /// The value type of the flag + /// + public FlagValueType FlagValueType { get; } = flagValueType; + + /// + /// Client metadata + /// + public ClientMetadata ClientMetadata { get; } = + clientMetadata ?? throw new ArgumentNullException(nameof(clientMetadata)); + + /// + /// Provider metadata + /// + public Metadata ProviderMetadata { get; } = + providerMetadata ?? throw new ArgumentNullException(nameof(providerMetadata)); + + /// + /// Create a hook context from this shared context. + /// + /// Evaluation context + /// A hook context + public HookContext ToHookContext(EvaluationContext? evaluationContext) + { + return new HookContext(this, evaluationContext, new HookData()); + } + } +} diff --git a/test/OpenFeature.Tests/HookDataTests.cs b/test/OpenFeature.Tests/HookDataTests.cs new file mode 100644 index 000000000..96cbaf723 --- /dev/null +++ b/test/OpenFeature.Tests/HookDataTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.Tests; + +public class HookDataTests +{ + private readonly HookData _commonHookData = new(); + + public HookDataTests() + { + this._commonHookData.Set("bool", true); + this._commonHookData.Set("string", "string"); + this._commonHookData.Set("int", 1); + this._commonHookData.Set("double", 1.2); + this._commonHookData.Set("float", 1.2f); + } + + [Fact] + public void HookData_Can_Set_And_Get_Data() + { + var hookData = new HookData(); + hookData.Set("bool", true); + hookData.Set("string", "string"); + hookData.Set("int", 1); + hookData.Set("double", 1.2); + hookData.Set("float", 1.2f); + var structure = Structure.Builder().Build(); + hookData.Set("structure", structure); + + Assert.True((bool)hookData.Get("bool")); + Assert.Equal("string", hookData.Get("string")); + Assert.Equal(1, hookData.Get("int")); + Assert.Equal(1.2, hookData.Get("double")); + Assert.Equal(1.2f, hookData.Get("float")); + Assert.Same(structure, hookData.Get("structure")); + } + + [Fact] + public void HookData_Can_Chain_Set() + { + var structure = Structure.Builder().Build(); + + var hookData = new HookData(); + hookData.Set("bool", true) + .Set("string", "string") + .Set("int", 1) + .Set("double", 1.2) + .Set("float", 1.2f) + .Set("structure", structure); + + Assert.True((bool)hookData.Get("bool")); + Assert.Equal("string", hookData.Get("string")); + Assert.Equal(1, hookData.Get("int")); + Assert.Equal(1.2, hookData.Get("double")); + Assert.Equal(1.2f, hookData.Get("float")); + Assert.Same(structure, hookData.Get("structure")); + } + + [Fact] + public void HookData_Can_Set_And_Get_Data_Using_Indexer() + { + var hookData = new HookData(); + hookData["bool"] = true; + hookData["string"] = "string"; + hookData["int"] = 1; + hookData["double"] = 1.2; + hookData["float"] = 1.2f; + var structure = Structure.Builder().Build(); + hookData["structure"] = structure; + + Assert.True((bool)hookData["bool"]); + Assert.Equal("string", hookData["string"]); + Assert.Equal(1, hookData["int"]); + Assert.Equal(1.2, hookData["double"]); + Assert.Equal(1.2f, hookData["float"]); + Assert.Same(structure, hookData["structure"]); + } + + [Fact] + public void HookData_Can_Be_Enumerated() + { + var asList = new List>(); + foreach (var kvp in this._commonHookData) + { + asList.Add(kvp); + } + + asList.Sort((a, b) => + string.Compare(a.Key, b.Key, StringComparison.Ordinal)); + + Assert.Equal([ + new KeyValuePair("bool", true), + new KeyValuePair("double", 1.2), + new KeyValuePair("float", 1.2f), + new KeyValuePair("int", 1), + new KeyValuePair("string", "string") + ], asList); + } + + [Fact] + public void HookData_Has_Count() + { + Assert.Equal(5, this._commonHookData.Count); + } + + [Fact] + public void HookData_Has_Keys() + { + Assert.Equal(5, this._commonHookData.Keys.Count); + Assert.Contains("bool", this._commonHookData.Keys); + Assert.Contains("double", this._commonHookData.Keys); + Assert.Contains("float", this._commonHookData.Keys); + Assert.Contains("int", this._commonHookData.Keys); + Assert.Contains("string", this._commonHookData.Keys); + } + + [Fact] + public void HookData_Has_Values() + { + Assert.Equal(5, this._commonHookData.Values.Count); + Assert.Contains(true, this._commonHookData.Values); + Assert.Contains(1, this._commonHookData.Values); + Assert.Contains(1.2f, this._commonHookData.Values); + Assert.Contains(1.2, this._commonHookData.Values); + Assert.Contains("string", this._commonHookData.Values); + } + + [Fact] + public void HookData_Can_Be_Converted_To_Dictionary() + { + var asDictionary = this._commonHookData.AsDictionary(); + Assert.Equal(5, asDictionary.Count); + Assert.Equal(true, asDictionary["bool"]); + Assert.Equal(1.2, asDictionary["double"]); + Assert.Equal(1.2f, asDictionary["float"]); + Assert.Equal(1, asDictionary["int"]); + Assert.Equal("string", asDictionary["string"]); + } + + [Fact] + public void HookData_Get_Should_Throw_When_Key_Not_Found() + { + var hookData = new HookData(); + + Assert.Throws(() => hookData.Get("nonexistent")); + } + + [Fact] + public void HookData_Indexer_Should_Throw_When_Key_Not_Found() + { + var hookData = new HookData(); + + Assert.Throws(() => _ = hookData["nonexistent"]); + } +} diff --git a/test/OpenFeature.Tests/OpenFeatureHookTests.cs b/test/OpenFeature.Tests/OpenFeatureHookTests.cs index bbb4da3fd..ae53f6db4 100644 --- a/test/OpenFeature.Tests/OpenFeatureHookTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureHookTests.cs @@ -90,7 +90,7 @@ await client.GetBooleanValueAsync(flagName, defaultValue, EvaluationContext.Empt } [Fact] - [Specification("4.1.1", "Hook context MUST provide: the `flag key`, `flag value type`, `evaluation context`, and the `default value`.")] + [Specification("4.1.1", "Hook context MUST provide: the `flag key`, `flag value type`, `evaluation context`, `default value`, and `hook data`.")] public void Hook_Context_Should_Not_Allow_Nulls() { Assert.Throws(() => @@ -108,6 +108,19 @@ public void Hook_Context_Should_Not_Allow_Nulls() Assert.Throws(() => new HookContext("test", Structure.Empty, FlagValueType.Object, new ClientMetadata(null, null), new Metadata(null), null)); + + Assert.Throws(() => new SharedHookContext("test", Structure.Empty, FlagValueType.Object, + new ClientMetadata(null, null), new Metadata(null)).ToHookContext(null)); + + Assert.Throws(() => + new HookContext(null, EvaluationContext.Empty, + new HookData())); + + Assert.Throws(() => + new HookContext( + new SharedHookContext("test", Structure.Empty, FlagValueType.Object, + new ClientMetadata(null, null), new Metadata(null)), EvaluationContext.Empty, + null)); } [Fact] @@ -151,6 +164,95 @@ await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, _ = hook2.Received(1).BeforeAsync(Arg.Is>(a => a.EvaluationContext.GetValue("test").AsString == "test"), Arg.Any>()); } + [Fact] + [Specification("4.1.5", "The `hook data` MUST be mutable.")] + public async Task HookData_Must_Be_Mutable() + { + var hook = Substitute.For(); + + hook.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty).AndDoes(info => + { + info.Arg>().Data.Set("test-a", true); + }); + hook.AfterAsync(Arg.Any>(), Arg.Any>(), + Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + { + info.Arg>().Data.Set("test-b", "test-value"); + }); + + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var client = Api.Instance.GetClient("test", "1.0.0"); + + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, + new FlagEvaluationOptions(ImmutableList.Create(hook), ImmutableDictionary.Empty)); + + _ = hook.Received(1).AfterAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("test-a") == true + ), Arg.Any>(), Arg.Any>()); + _ = hook.Received(1).FinallyAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("test-a") == true && (string)hookContext.Data.Get("test-b") == "test-value" + ), Arg.Any>(), Arg.Any>()); + } + + [Fact] + [Specification("4.3.2", + "`Hook data` **MUST** must be created before the first `stage` invoked in a hook for a specific evaluation and propagated between each `stage` of the hook. The hook data is not shared between different hooks.")] + public async Task HookData_Must_Be_Unique_Per_Hook() + { + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + + hook1.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty).AndDoes(info => + { + info.Arg>().Data.Set("hook-1-value-a", true); + info.Arg>().Data.Set("same", true); + }); + hook1.AfterAsync(Arg.Any>(), Arg.Any>(), + Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + { + info.Arg>().Data.Set("hook-1-value-b", "test-value-hook-1"); + }); + + hook2.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty).AndDoes(info => + { + info.Arg>().Data.Set("hook-2-value-a", false); + info.Arg>().Data.Set("same", false); + }); + hook2.AfterAsync(Arg.Any>(), Arg.Any>(), + Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + { + info.Arg>().Data.Set("hook-2-value-b", "test-value-hook-2"); + }); + + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var client = Api.Instance.GetClient("test", "1.0.0"); + + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, + new FlagEvaluationOptions(ImmutableList.Create(hook1, hook2), + ImmutableDictionary.Empty)); + + _ = hook1.Received(1).AfterAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-1-value-a") == true && (bool)hookContext.Data.Get("same") == true + ), Arg.Any>(), Arg.Any>()); + _ = hook1.Received(1).FinallyAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-1-value-a") == true && + (bool)hookContext.Data.Get("same") == true && + (string)hookContext.Data.Get("hook-1-value-b") == "test-value-hook-1" && hookContext.Data.Count == 3 + ), Arg.Any>(), Arg.Any>()); + + _ = hook2.Received(1).AfterAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-2-value-a") == false && (bool)hookContext.Data.Get("same") == false + ), Arg.Any>(), Arg.Any>()); + _ = hook2.Received(1).FinallyAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-2-value-a") == false && + (bool)hookContext.Data.Get("same") == false && + (string)hookContext.Data.Get("hook-2-value-b") == "test-value-hook-2" && hookContext.Data.Count == 3 + ), Arg.Any>(), Arg.Any>()); + } + [Fact] [Specification("3.2.2", "Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.")] [Specification("4.3.4", "When `before` hooks have finished executing, any resulting `evaluation context` MUST be merged with the existing `evaluation context`.")] @@ -394,7 +496,7 @@ public async Task Error_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() hook1.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); hook2.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); featureProvider1.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Throws(new Exception()); - hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null).Returns(new ValueTask()); + hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null).Throws(new Exception()); hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null).Returns(new ValueTask()); await Api.Instance.SetProviderAsync(featureProvider1); From d0bf40b9b40adc57a2a008a9497098b3cd1a05a7 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 22 Apr 2025 14:24:23 -0400 Subject: [PATCH 007/126] chore: update release permissions Signed-off-by: Todd Baert --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0fbad85c..7a17569f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,8 @@ on: - main permissions: - contents: read + contents: write + pull-requests: write jobs: release-please: From 8ecf50db2cab3a266de5c6c5216714570cfc6a52 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 23 Apr 2025 02:11:50 +0100 Subject: [PATCH 008/126] refactor: InMemoryProvider throwing when types mismatched (#442) ## This PR - Update InMemoryProvider to return an ErrorType with a default value instead of throwing exceptions - Add unit test to cover the new behavior ### Related Issues Fixes #441 ### Notes ### Follow-up Tasks ### How to test Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- src/OpenFeature/Providers/Memory/InMemoryProvider.cs | 3 +-- .../Providers/Memory/InMemoryProviderTests.cs | 9 +++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/OpenFeature/Providers/Memory/InMemoryProvider.cs b/src/OpenFeature/Providers/Memory/InMemoryProvider.cs index f95a0a06a..2eec879d0 100644 --- a/src/OpenFeature/Providers/Memory/InMemoryProvider.cs +++ b/src/OpenFeature/Providers/Memory/InMemoryProvider.cs @@ -3,7 +3,6 @@ using System.Threading; using System.Threading.Tasks; using OpenFeature.Constant; -using OpenFeature.Error; using OpenFeature.Model; namespace OpenFeature.Providers.Memory @@ -112,7 +111,7 @@ private ResolutionDetails Resolve(string flagKey, T defaultValue, Evaluati return value.Evaluate(flagKey, defaultValue, context); } - throw new TypeMismatchException($"flag {flagKey} is not of type {typeof(T)}"); + return new ResolutionDetails(flagKey, defaultValue, ErrorType.TypeMismatch, Reason.Error); } } } diff --git a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs index 7a174fc51..c575dc56c 100644 --- a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs @@ -186,9 +186,14 @@ public async Task MissingFlag_ShouldReturnFlagNotFoundEvaluationFlag() } [Fact] - public async Task MismatchedFlag_ShouldThrow() + public async Task MismatchedFlag_ShouldReturnTypeMismatchError() { - await Assert.ThrowsAsync(() => this.commonProvider.ResolveStringValueAsync("boolean-flag", "nope", EvaluationContext.Empty)); + // Act + var result = await this.commonProvider.ResolveStringValueAsync("boolean-flag", "nope", EvaluationContext.Empty); + + // Assert + Assert.Equal(Reason.Error, result.Reason); + Assert.Equal(ErrorType.TypeMismatch, result.ErrorType); } [Fact] From 1acc00fa7a6a38152d97fd7efc9f7e8befb1c3ed Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 23 Apr 2025 10:15:20 -0400 Subject: [PATCH 009/126] chore: packages read in release please Signed-off-by: Todd Baert --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a17569f9..3702d88bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,8 @@ jobs: permissions: contents: write # for googleapis/release-please-action to create release commit pull-requests: write # for googleapis/release-please-action to create release PR + packages: read # for internal nuget reading + runs-on: ubuntu-latest steps: From dfecd0c6a4467e5c1afe481e785e3e0f179beb25 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 23 Apr 2025 17:33:01 +0100 Subject: [PATCH 010/126] chore(deps): update github/codeql-action digest to 28deaed (#446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [github/codeql-action](https://github.com/github/codeql-action) | action | digest | `45775bd` -> `28deaed` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 57ca46418..d943ef016 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3 + uses: github/codeql-action/init@28deaeda66b76a05916b6923827895f2b14ab387 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3 + uses: github/codeql-action/autobuild@28deaeda66b76a05916b6923827895f2b14ab387 # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3 + uses: github/codeql-action/analyze@28deaeda66b76a05916b6923827895f2b14ab387 # v3 From 858b286dba2313239141c20ec6770504d340fbe0 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 23 Apr 2025 21:32:24 +0100 Subject: [PATCH 011/126] docs: update documentation on SetProviderAsync (#449) ## This PR - Clarifies the behaviour of SetProviderAsync. Exceptions can be thrown by a provider during initialization. ### Related Issues Fixes #445 ### Notes ### Follow-up Tasks ### How to test Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- README.md | 48 ++++++++++++++++++++++++++++++++++-------- src/OpenFeature/Api.cs | 4 +++- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7a2261772..b3d6ae985 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,14 @@ dotnet add package OpenFeature public async Task Example() { // Register your feature flag provider - await Api.Instance.SetProviderAsync(new InMemoryProvider()); + try + { + await Api.Instance.SetProviderAsync(new InMemoryProvider()); + } + catch (Exception ex) + { + // Log error + } // Create a new client FeatureClient client = Api.Instance.GetClient(); @@ -63,7 +70,7 @@ public async Task Example() if ( v2Enabled ) { - //Do some work + // Do some work } } ``` @@ -96,9 +103,18 @@ If the provider you're looking for hasn't been created yet, see the [develop a p Once you've added a provider as a dependency, it can be registered with OpenFeature like this: ```csharp -await Api.Instance.SetProviderAsync(new MyProvider()); +try +{ + await Api.Instance.SetProviderAsync(new MyProvider()); +} +catch (Exception ex) +{ + // Log error +} ``` +When calling `SetProviderAsync` an exception may be thrown if the provider cannot be initialized. This may occur if the provider has not been configured correctly. See the documentation for the provider you are using for more information on how to configure the provider correctly. + In some situations, it may be beneficial to register multiple providers in the same application. This is possible using [domains](#domains), which is covered in more detail below. @@ -177,11 +193,18 @@ A domain is a logical identifier which can be used to associate clients with a p If a domain has no associated provider, the default provider is used. ```csharp -// registering the default provider -await Api.Instance.SetProviderAsync(new LocalProvider()); +try +{ + // registering the default provider + await Api.Instance.SetProviderAsync(new LocalProvider()); -// registering a provider to a domain -await Api.Instance.SetProviderAsync("clientForCache", new CachedProvider()); + // registering a provider to a domain + await Api.Instance.SetProviderAsync("clientForCache", new CachedProvider()); +} +catch (Exception ex) +{ + // Log error +} // a client backed by default provider FeatureClient clientDefault = Api.Instance.GetClient(); @@ -224,8 +247,15 @@ EventHandlerDelegate callback = EventHandler; var myClient = Api.Instance.GetClient("my-client"); -var provider = new ExampleProvider(); -await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name, provider); +try +{ + var provider = new ExampleProvider(); + await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name, provider); +} +catch (Exception ex) +{ + // Log error +} myClient.AddHandler(ProviderEventTypes.ProviderReady, callback); ``` diff --git a/src/OpenFeature/Api.cs b/src/OpenFeature/Api.cs index 703218835..1f52a2a1f 100644 --- a/src/OpenFeature/Api.cs +++ b/src/OpenFeature/Api.cs @@ -43,7 +43,7 @@ private Api() { } /// Sets the default feature provider. In order to wait for the provider to be set, and initialization to complete, /// await the returned task. /// - /// The provider cannot be set to null. Attempting to set the provider to null has no effect. + /// The provider cannot be set to null. Attempting to set the provider to null has no effect. May throw an exception if cannot be initialized. /// Implementation of public async Task SetProviderAsync(FeatureProvider featureProvider) { @@ -56,8 +56,10 @@ public async Task SetProviderAsync(FeatureProvider featureProvider) /// Binds the feature provider to the given domain. In order to wait for the provider to be set, and /// initialization to complete, await the returned task. /// + /// The provider cannot be set to null. Attempting to set the provider to null has no effect. May throw an exception if cannot be initialized. /// An identifier which logically binds clients with providers /// Implementation of + /// domain cannot be null or empty public async Task SetProviderAsync(string domain, FeatureProvider featureProvider) { if (string.IsNullOrWhiteSpace(domain)) From e162169af0b5518f12527a8601d6dfcdf379b4f7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 23 Apr 2025 17:20:02 -0400 Subject: [PATCH 012/126] chore(deps): update spec digest to 36944c6 (#450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | spec | digest | `18cde17` -> `36944c6` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index 18cde1708..36944c68d 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 18cde1708e08d4f380ba30b4b1649e06683edfd2 +Subproject commit 36944c68dd60e874661f5efd022ccafb9af76535 From 4795685bc8c557cedac551c3f5e9f7fd1da0d55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 24 Apr 2025 17:45:55 +0100 Subject: [PATCH 013/126] ci: update renovate configuration to fix immortal PRs (#451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> ## This PR This pull request updates the Renovate configuration to enhance dependency management workflows. The most notable changes include enabling dependency dashboard approval and specifying that dependencies should not be recreated automatically. Changes to Renovate configuration: * [`renovate.json`](diffhunk://#diff-7b5c8955fc544a11b4b74eddb4115f9cc51c9cf162dbffa60d37eeed82a55a57L5-R7): Enabled the `dependencyDashboardApproval` setting to require manual approval for dependency updates. * [`renovate.json`](diffhunk://#diff-7b5c8955fc544a11b4b74eddb4115f9cc51c9cf162dbffa60d37eeed82a55a57L5-R7): Added the `recreateWhen` setting with a value of `"never"` to prevent automatic recreation of dependency updates. ### Related Issues Fixes #448 ### Notes I followed Renovate's general recommendation. I also changed to require dashboard manual approval because we don't want the PRs to pop up for dotnet extensions (they should be closed since we need to publish the lowest version compatible; see #426). So we should look into the dashboard issue #92 to create the PRs for dependencies. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- renovate.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index daaea0b51..151c402c1 100644 --- a/renovate.json +++ b/renovate.json @@ -2,5 +2,7 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "github>open-feature/community-tooling" - ] + ], + "dependencyDashboardApproval": true, + "recreateWhen": "never" } From 1e74a04f2b76c128a09c95dfd0b06803f2ef77bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 24 Apr 2025 20:03:03 +0100 Subject: [PATCH 014/126] chore: Change file scoped namespaces and cleanup job (#453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR This pull request introduces a significant refactor to the codebase by converting all namespace declarations to file-scoped namespaces and updating the `.editorconfig` to enforce this style. Additionally, it simplifies the structure of multiple files by removing unnecessary closing braces for namespace blocks. ### Namespace Refactoring: * Converted all namespace declarations in the `src/OpenFeature` directory to file-scoped namespaces for consistency and improved readability. This change affects multiple files, including `Api.cs`, `Constants.cs`, `Error/*.cs`, `EventExecutor.cs`, and `Extension/*.cs`. [[1]](diffhunk://#diff-dc150fbd3b7be797470374ee7e38c7ab8a31eb9b6b7a52345e05114c2d32a15eL12-R13) [[2]](diffhunk://#diff-b0112a803c4d4783b4b829626b4970801bda0de261d9887d3d5f5de30eb49d4fL1-L9) [[3]](diffhunk://#diff-08c95bef1c40daf926c6da5657e9333d4481a1e76b03c8749b87e58d9c711af3L3-R4) [[4]](diffhunk://#diff-95f94f0d86864094b6a4ed191b7a08bc306c6b2f21eb061b7de319aa9fa19e3fL1-R2) [[5]](diffhunk://#diff-591e2f16844f704f064cbe31b7427e66c6f816f421a970fc7ad6fe1ad3c4c220L1-R2)R1, [[6]](diffhunk://#diff-04e7c0ae5d31a3d10742b06dd413c512ee002ce27ce7b3b39cca79598c381a68L3-R4) [[7]](diffhunk://#diff-0c85dcfcfa3c97d37ee0ba4394ca6958a14c013ee0940aaea09cefd2686bf41fL1-R2) [[8]](diffhunk://#diff-a7a382f3d0da52015d1b9e03802692a86c61e7b57580747f31fae37d8dcc5cd6L4-R5) [[9]](diffhunk://#diff-b9fdde9b61e62d7c474069ea5fc2a43d123ab86d93cb9a6d0673254a64536722L5-R6) [[10]](diffhunk://#diff-bb4aadb03ea44e3b6b74b83f12b2b1a258e13836bcf815f996addcd5537a478fL5-R6) [[11]](diffhunk://#diff-89efe60a8b640cc303a38e5b01bc27ad40599e364ff2654a57b7e42138056cdaL5-R6) [[12]](diffhunk://#diff-7a8cbfba7673f1b69d3d927f60eb4ab0aa548403a118fad9eb43b9a22875dc02L5-R6) [[13]](diffhunk://#diff-48ae8447bc31a75ecf4bba768a7b68244fbbc9dd5480db9114d9c74fb10fe2efL5-R6) [[14]](diffhunk://#diff-9a3b5bf7bf3351a6161fc6dd75830bfbaab7aca730cf3f0ae6cd120a76b2f1b1L5-R6) [[15]](diffhunk://#diff-c4388b2e7252e2e3ac0967dbfdd4647a924cdfc54da229667a0db3613b243a7eL5-R6) [[16]](diffhunk://#diff-44c88ae43caf99ee733ec911fa85454a96c57d07fc57d2fadd44e12cd7d31cd4L5-R6) [[17]](diffhunk://#diff-f563baadcf750bb66efaea76d7ec4783320e6efdc45c468effb531c948a2292cL10-R11) [[18]](diffhunk://#diff-f94bf65f426e13a14f798a8db21a5c9dd3306f5941bde1aba79af3e41421bfc0L5-R6) [[19]](diffhunk://#diff-484832cfacaa02b6872a26cf5202843e96e0125281deca5798974879d9d609a0L3-R4) ### Code Style Enforcement: * Updated `.editorconfig` to include a new rule (`csharp_style_namespace_declarations = file_scoped:warning`) to enforce the use of file-scoped namespaces. ### Simplification of Code Structure: * Removed unnecessary closing braces for namespace blocks in all affected files, as they are no longer required with file-scoped namespaces. This change simplifies the code and reduces visual clutter. [[1]](diffhunk://#diff-dc150fbd3b7be797470374ee7e38c7ab8a31eb9b6b7a52345e05114c2d32a15eL369) [[2]](diffhunk://#diff-b0112a803c4d4783b4b829626b4970801bda0de261d9887d3d5f5de30eb49d4fL1-L9) [[3]](diffhunk://#diff-08c95bef1c40daf926c6da5657e9333d4481a1e76b03c8749b87e58d9c711af3L56) [[4]](diffhunk://#diff-95f94f0d86864094b6a4ed191b7a08bc306c6b2f21eb061b7de319aa9fa19e3fL25) [[5]](diffhunk://#diff-591e2f16844f704f064cbe31b7427e66c6f816f421a970fc7ad6fe1ad3c4c220L28) [[6]](diffhunk://#diff-04e7c0ae5d31a3d10742b06dd413c512ee002ce27ce7b3b39cca79598c381a68L36) [[7]](diffhunk://#diff-0c85dcfcfa3c97d37ee0ba4394ca6958a14c013ee0940aaea09cefd2686bf41fL50) [[8]](diffhunk://#diff-a7a382f3d0da52015d1b9e03802692a86c61e7b57580747f31fae37d8dcc5cd6L29) [[9]](diffhunk://#diff-b9fdde9b61e62d7c474069ea5fc2a43d123ab86d93cb9a6d0673254a64536722L23) [[10]](diffhunk://#diff-bb4aadb03ea44e3b6b74b83f12b2b1a258e13836bcf815f996addcd5537a478fL23) [[11]](diffhunk://#diff-89efe60a8b640cc303a38e5b01bc27ad40599e364ff2654a57b7e42138056cdaL23) [[12]](diffhunk://#diff-7a8cbfba7673f1b69d3d927f60eb4ab0aa548403a118fad9eb43b9a22875dc02L23) [[13]](diffhunk://#diff-48ae8447bc31a75ecf4bba768a7b68244fbbc9dd5480db9114d9c74fb10fe2efL23) [[14]](diffhunk://#diff-9a3b5bf7bf3351a6161fc6dd75830bfbaab7aca730cf3f0ae6cd120a76b2f1b1L23) [[15]](diffhunk://#diff-c4388b2e7252e2e3ac0967dbfdd4647a924cdfc54da229667a0db3613b243a7eL23) [[16]](diffhunk://#diff-44c88ae43caf99ee733ec911fa85454a96c57d07fc57d2fadd44e12cd7d31cd4L23) [[17]](diffhunk://#diff-f563baadcf750bb66efaea76d7ec4783320e6efdc45c468effb531c948a2292cL356) [[18]](diffhunk://#diff-f94bf65f426e13a14f798a8db21a5c9dd3306f5941bde1aba79af3e41421bfc0L16) [[19]](diffhunk://#diff-484832cfacaa02b6872a26cf5202843e96e0125281deca5798974879d9d609a0L13) ### Related Issues Fixes #447 ### Notes I ran the `dotnet format OpenFeature.sln` to clean up the code. As you can imagine, this generates a big diff. I would advise enabling `Hide Whitespace` to review this PR, since it will show the conversion from nested to file namespaces. --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .editorconfig | 3 + src/OpenFeature/Api.cs | 613 ++++---- src/OpenFeature/Constant/Constants.cs | 13 +- src/OpenFeature/Constant/ErrorType.cs | 101 +- src/OpenFeature/Constant/EventType.cs | 41 +- src/OpenFeature/Constant/FlagValueType.cs | 41 +- src/OpenFeature/Constant/ProviderStatus.cs | 51 +- src/OpenFeature/Constant/Reason.cs | 93 +- .../Error/FeatureProviderException.cs | 39 +- .../Error/FlagNotFoundException.cs | 25 +- src/OpenFeature/Error/GeneralException.cs | 25 +- .../Error/InvalidContextException.cs | 25 +- src/OpenFeature/Error/ParseErrorException.cs | 25 +- .../Error/ProviderFatalException.cs | 25 +- .../Error/ProviderNotReadyException.cs | 25 +- .../Error/TargetingKeyMissingException.cs | 25 +- .../Error/TypeMismatchException.cs | 25 +- src/OpenFeature/EventExecutor.cs | 465 +++--- src/OpenFeature/Extension/EnumExtensions.cs | 15 +- .../Extension/ResolutionDetailsExtensions.cs | 13 +- src/OpenFeature/FeatureProvider.cs | 257 ++-- src/OpenFeature/Hook.cs | 145 +- src/OpenFeature/HookData.cs | 173 ++- src/OpenFeature/HookRunner.cs | 265 ++-- src/OpenFeature/Hooks/LoggingHook.cs | 259 ++-- src/OpenFeature/IEventBus.cs | 33 +- src/OpenFeature/IFeatureClient.cs | 325 +++-- src/OpenFeature/Model/ClientMetadata.cs | 33 +- src/OpenFeature/Model/EvaluationContext.cs | 201 ++- .../Model/EvaluationContextBuilder.cs | 269 ++-- .../Model/FlagEvaluationDetails.cs | 123 +- .../Model/FlagEvaluationOptions.cs | 67 +- src/OpenFeature/Model/HookContext.cs | 147 +- src/OpenFeature/Model/Metadata.cs | 31 +- src/OpenFeature/Model/ProviderEvents.cs | 71 +- src/OpenFeature/Model/ResolutionDetails.cs | 121 +- src/OpenFeature/Model/Structure.cs | 225 ++- src/OpenFeature/Model/StructureBuilder.cs | 249 ++-- .../Model/TrackingEventDetailsBuilder.cs | 275 ++-- src/OpenFeature/Model/Value.cs | 353 +++-- src/OpenFeature/NoOpProvider.cs | 85 +- src/OpenFeature/OpenFeatureClient.cs | 571 ++++---- src/OpenFeature/ProviderRepository.cs | 439 +++--- src/OpenFeature/Providers/Memory/Flag.cs | 109 +- .../Providers/Memory/InMemoryProvider.cs | 171 ++- src/OpenFeature/SharedHookContext.cs | 91 +- .../OpenFeatureClientBenchmarks.cs | 185 ++- test/OpenFeature.Benchmarks/Program.cs | 11 +- .../FeatureProviderExceptionTests.cs | 95 +- .../OpenFeature.Tests/FeatureProviderTests.cs | 249 ++-- .../Hooks/LoggingHookTests.cs | 1247 ++++++++--------- .../Internal/SpecificationAttribute.cs | 21 +- .../OpenFeatureClientTests.cs | 1155 ++++++++------- .../OpenFeatureEvaluationContextTests.cs | 379 +++-- .../OpenFeatureEventTests.cs | 915 ++++++------ .../OpenFeature.Tests/OpenFeatureHookTests.cs | 1247 ++++++++--------- test/OpenFeature.Tests/OpenFeatureTests.cs | 571 ++++---- .../ProviderRepositoryTests.cs | 695 +++++---- .../Providers/Memory/InMemoryProviderTests.cs | 447 +++--- test/OpenFeature.Tests/StructureTests.cs | 193 ++- test/OpenFeature.Tests/TestImplementations.cs | 231 ++- test/OpenFeature.Tests/TestUtilsTest.cs | 25 +- test/OpenFeature.Tests/ValueTests.cs | 373 +++-- 63 files changed, 7378 insertions(+), 7437 deletions(-) diff --git a/.editorconfig b/.editorconfig index 7297b04dc..f6763f4da 100644 --- a/.editorconfig +++ b/.editorconfig @@ -148,6 +148,9 @@ dotnet_diagnostic.RS0041.severity = suggestion # CA2007: Do not directly await a Task dotnet_diagnostic.CA2007.severity = error +# IDE0161: Convert to file-scoped namespace +csharp_style_namespace_declarations = file_scoped:warning + [obj/**.cs] generated_code = true diff --git a/src/OpenFeature/Api.cs b/src/OpenFeature/Api.cs index 1f52a2a1f..cc0161c10 100644 --- a/src/OpenFeature/Api.cs +++ b/src/OpenFeature/Api.cs @@ -9,361 +9,360 @@ using OpenFeature.Error; using OpenFeature.Model; -namespace OpenFeature +namespace OpenFeature; + +/// +/// The evaluation API allows for the evaluation of feature flag values, independent of any flag control plane or vendor. +/// In the absence of a provider the evaluation API uses the "No-op provider", which simply returns the supplied default flag value. +/// +/// +public sealed class Api : IEventBus { + private EvaluationContext _evaluationContext = EvaluationContext.Empty; + private EventExecutor _eventExecutor = new EventExecutor(); + private ProviderRepository _repository = new ProviderRepository(); + private readonly ConcurrentStack _hooks = new ConcurrentStack(); + private ITransactionContextPropagator _transactionContextPropagator = new NoOpTransactionContextPropagator(); + private readonly object _transactionContextPropagatorLock = new(); + + /// The reader/writer locks are not disposed because the singleton instance should never be disposed. + private readonly ReaderWriterLockSlim _evaluationContextLock = new ReaderWriterLockSlim(); + + /// + /// Singleton instance of Api + /// + public static Api Instance { get; private set; } = new Api(); + + // Explicit static constructor to tell C# compiler + // not to mark type as beforeFieldInit + // IE Lazy way of ensuring this is thread safe without using locks + static Api() { } + private Api() { } + /// - /// The evaluation API allows for the evaluation of feature flag values, independent of any flag control plane or vendor. - /// In the absence of a provider the evaluation API uses the "No-op provider", which simply returns the supplied default flag value. + /// Sets the default feature provider. In order to wait for the provider to be set, and initialization to complete, + /// await the returned task. /// - /// - public sealed class Api : IEventBus + /// The provider cannot be set to null. Attempting to set the provider to null has no effect. May throw an exception if cannot be initialized. + /// Implementation of + public async Task SetProviderAsync(FeatureProvider featureProvider) { - private EvaluationContext _evaluationContext = EvaluationContext.Empty; - private EventExecutor _eventExecutor = new EventExecutor(); - private ProviderRepository _repository = new ProviderRepository(); - private readonly ConcurrentStack _hooks = new ConcurrentStack(); - private ITransactionContextPropagator _transactionContextPropagator = new NoOpTransactionContextPropagator(); - private readonly object _transactionContextPropagatorLock = new(); - - /// The reader/writer locks are not disposed because the singleton instance should never be disposed. - private readonly ReaderWriterLockSlim _evaluationContextLock = new ReaderWriterLockSlim(); - - /// - /// Singleton instance of Api - /// - public static Api Instance { get; private set; } = new Api(); - - // Explicit static constructor to tell C# compiler - // not to mark type as beforeFieldInit - // IE Lazy way of ensuring this is thread safe without using locks - static Api() { } - private Api() { } - - /// - /// Sets the default feature provider. In order to wait for the provider to be set, and initialization to complete, - /// await the returned task. - /// - /// The provider cannot be set to null. Attempting to set the provider to null has no effect. May throw an exception if cannot be initialized. - /// Implementation of - public async Task SetProviderAsync(FeatureProvider featureProvider) - { - this._eventExecutor.RegisterDefaultFeatureProvider(featureProvider); - await this._repository.SetProviderAsync(featureProvider, this.GetContext(), this.AfterInitialization, this.AfterError).ConfigureAwait(false); + this._eventExecutor.RegisterDefaultFeatureProvider(featureProvider); + await this._repository.SetProviderAsync(featureProvider, this.GetContext(), this.AfterInitialization, this.AfterError).ConfigureAwait(false); - } + } - /// - /// Binds the feature provider to the given domain. In order to wait for the provider to be set, and - /// initialization to complete, await the returned task. - /// - /// The provider cannot be set to null. Attempting to set the provider to null has no effect. May throw an exception if cannot be initialized. - /// An identifier which logically binds clients with providers - /// Implementation of - /// domain cannot be null or empty - public async Task SetProviderAsync(string domain, FeatureProvider featureProvider) + /// + /// Binds the feature provider to the given domain. In order to wait for the provider to be set, and + /// initialization to complete, await the returned task. + /// + /// The provider cannot be set to null. Attempting to set the provider to null has no effect. May throw an exception if cannot be initialized. + /// An identifier which logically binds clients with providers + /// Implementation of + /// domain cannot be null or empty + public async Task SetProviderAsync(string domain, FeatureProvider featureProvider) + { + if (string.IsNullOrWhiteSpace(domain)) { - if (string.IsNullOrWhiteSpace(domain)) - { - throw new ArgumentNullException(nameof(domain)); - } - this._eventExecutor.RegisterClientFeatureProvider(domain, featureProvider); - await this._repository.SetProviderAsync(domain, featureProvider, this.GetContext(), this.AfterInitialization, this.AfterError).ConfigureAwait(false); + throw new ArgumentNullException(nameof(domain)); } + this._eventExecutor.RegisterClientFeatureProvider(domain, featureProvider); + await this._repository.SetProviderAsync(domain, featureProvider, this.GetContext(), this.AfterInitialization, this.AfterError).ConfigureAwait(false); + } - /// - /// Gets the feature provider - /// - /// The feature provider may be set from multiple threads, when accessing the global feature provider - /// it should be accessed once for an operation, and then that reference should be used for all dependent - /// operations. For instance, during an evaluation the flag resolution method, and the provider hooks - /// should be accessed from the same reference, not two independent calls to - /// . - /// - /// - /// - public FeatureProvider GetProvider() - { - return this._repository.GetProvider(); - } + /// + /// Gets the feature provider + /// + /// The feature provider may be set from multiple threads, when accessing the global feature provider + /// it should be accessed once for an operation, and then that reference should be used for all dependent + /// operations. For instance, during an evaluation the flag resolution method, and the provider hooks + /// should be accessed from the same reference, not two independent calls to + /// . + /// + /// + /// + public FeatureProvider GetProvider() + { + return this._repository.GetProvider(); + } - /// - /// Gets the feature provider with given domain - /// - /// An identifier which logically binds clients with providers - /// A provider associated with the given domain, if domain is empty or doesn't - /// have a corresponding provider the default provider will be returned - public FeatureProvider GetProvider(string domain) - { - return this._repository.GetProvider(domain); - } + /// + /// Gets the feature provider with given domain + /// + /// An identifier which logically binds clients with providers + /// A provider associated with the given domain, if domain is empty or doesn't + /// have a corresponding provider the default provider will be returned + public FeatureProvider GetProvider(string domain) + { + return this._repository.GetProvider(domain); + } - /// - /// Gets providers metadata - /// - /// This method is not guaranteed to return the same provider instance that may be used during an evaluation - /// in the case where the provider may be changed from another thread. - /// For multiple dependent provider operations see . - /// - /// - /// - public Metadata? GetProviderMetadata() => this.GetProvider().GetMetadata(); - - /// - /// Gets providers metadata assigned to the given domain. If the domain has no provider - /// assigned to it the default provider will be returned - /// - /// An identifier which logically binds clients with providers - /// Metadata assigned to provider - public Metadata? GetProviderMetadata(string domain) => this.GetProvider(domain).GetMetadata(); - - /// - /// Create a new instance of using the current provider - /// - /// Name of client - /// Version of client - /// Logger instance used by client - /// Context given to this client - /// - public FeatureClient GetClient(string? name = null, string? version = null, ILogger? logger = null, - EvaluationContext? context = null) => - new FeatureClient(() => this._repository.GetProvider(name), name, version, logger, context); - - /// - /// Appends list of hooks to global hooks list - /// - /// The appending operation will be atomic. - /// - /// - /// A list of - public void AddHooks(IEnumerable hooks) -#if NET7_0_OR_GREATER - => this._hooks.PushRange(hooks as Hook[] ?? hooks.ToArray()); -#else - { - // See: https://github.com/dotnet/runtime/issues/62121 - if (hooks is Hook[] array) - { - if (array.Length > 0) - this._hooks.PushRange(array); + /// + /// Gets providers metadata + /// + /// This method is not guaranteed to return the same provider instance that may be used during an evaluation + /// in the case where the provider may be changed from another thread. + /// For multiple dependent provider operations see . + /// + /// + /// + public Metadata? GetProviderMetadata() => this.GetProvider().GetMetadata(); - return; - } + /// + /// Gets providers metadata assigned to the given domain. If the domain has no provider + /// assigned to it the default provider will be returned + /// + /// An identifier which logically binds clients with providers + /// Metadata assigned to provider + public Metadata? GetProviderMetadata(string domain) => this.GetProvider(domain).GetMetadata(); - array = hooks.ToArray(); + /// + /// Create a new instance of using the current provider + /// + /// Name of client + /// Version of client + /// Logger instance used by client + /// Context given to this client + /// + public FeatureClient GetClient(string? name = null, string? version = null, ILogger? logger = null, + EvaluationContext? context = null) => + new FeatureClient(() => this._repository.GetProvider(name), name, version, logger, context); + /// + /// Appends list of hooks to global hooks list + /// + /// The appending operation will be atomic. + /// + /// + /// A list of + public void AddHooks(IEnumerable hooks) +#if NET7_0_OR_GREATER + => this._hooks.PushRange(hooks as Hook[] ?? hooks.ToArray()); +#else + { + // See: https://github.com/dotnet/runtime/issues/62121 + if (hooks is Hook[] array) + { if (array.Length > 0) this._hooks.PushRange(array); + + return; } + + array = hooks.ToArray(); + + if (array.Length > 0) + this._hooks.PushRange(array); + } #endif - /// - /// Adds a hook to global hooks list - /// - /// Hooks which are dependent on each other should be provided in a collection - /// using the . - /// - /// - /// Hook that implements the interface - public void AddHooks(Hook hook) => this._hooks.Push(hook); - - /// - /// Enumerates the global hooks. - /// - /// The items enumerated will reflect the registered hooks - /// at the start of enumeration. Hooks added during enumeration - /// will not be included. - /// - /// - /// Enumeration of - public IEnumerable GetHooks() => this._hooks.Reverse(); - - /// - /// Removes all hooks from global hooks list - /// - public void ClearHooks() => this._hooks.Clear(); - - /// - /// Sets the global - /// - /// The to set - public void SetContext(EvaluationContext? context) - { - this._evaluationContextLock.EnterWriteLock(); - try - { - this._evaluationContext = context ?? EvaluationContext.Empty; - } - finally - { - this._evaluationContextLock.ExitWriteLock(); - } - } + /// + /// Adds a hook to global hooks list + /// + /// Hooks which are dependent on each other should be provided in a collection + /// using the . + /// + /// + /// Hook that implements the interface + public void AddHooks(Hook hook) => this._hooks.Push(hook); - /// - /// Gets the global - /// - /// The evaluation context may be set from multiple threads, when accessing the global evaluation context - /// it should be accessed once for an operation, and then that reference should be used for all dependent - /// operations. - /// - /// - /// An - public EvaluationContext GetContext() - { - this._evaluationContextLock.EnterReadLock(); - try - { - return this._evaluationContext; - } - finally - { - this._evaluationContextLock.ExitReadLock(); - } - } + /// + /// Enumerates the global hooks. + /// + /// The items enumerated will reflect the registered hooks + /// at the start of enumeration. Hooks added during enumeration + /// will not be included. + /// + /// + /// Enumeration of + public IEnumerable GetHooks() => this._hooks.Reverse(); - /// - /// Return the transaction context propagator. - /// - /// the registered transaction context propagator - internal ITransactionContextPropagator GetTransactionContextPropagator() + /// + /// Removes all hooks from global hooks list + /// + public void ClearHooks() => this._hooks.Clear(); + + /// + /// Sets the global + /// + /// The to set + public void SetContext(EvaluationContext? context) + { + this._evaluationContextLock.EnterWriteLock(); + try { - return this._transactionContextPropagator; + this._evaluationContext = context ?? EvaluationContext.Empty; } - - /// - /// Sets the transaction context propagator. - /// - /// the transaction context propagator to be registered - /// Transaction context propagator cannot be null - public void SetTransactionContextPropagator(ITransactionContextPropagator transactionContextPropagator) + finally { - if (transactionContextPropagator == null) - { - throw new ArgumentNullException(nameof(transactionContextPropagator), - "Transaction context propagator cannot be null"); - } - - lock (this._transactionContextPropagatorLock) - { - this._transactionContextPropagator = transactionContextPropagator; - } + this._evaluationContextLock.ExitWriteLock(); } + } - /// - /// Returns the currently defined transaction context using the registered transaction context propagator. - /// - /// The current transaction context - public EvaluationContext GetTransactionContext() + /// + /// Gets the global + /// + /// The evaluation context may be set from multiple threads, when accessing the global evaluation context + /// it should be accessed once for an operation, and then that reference should be used for all dependent + /// operations. + /// + /// + /// An + public EvaluationContext GetContext() + { + this._evaluationContextLock.EnterReadLock(); + try { - return this._transactionContextPropagator.GetTransactionContext(); + return this._evaluationContext; } - - /// - /// Sets the transaction context using the registered transaction context propagator. - /// - /// The to set - /// Transaction context propagator is not set. - /// Evaluation context cannot be null - public void SetTransactionContext(EvaluationContext evaluationContext) + finally { - if (evaluationContext == null) - { - throw new ArgumentNullException(nameof(evaluationContext), "Evaluation context cannot be null"); - } - - this._transactionContextPropagator.SetTransactionContext(evaluationContext); + this._evaluationContextLock.ExitReadLock(); } + } - /// - /// - /// Shut down and reset the current status of OpenFeature API. - /// - /// - /// This call cleans up all active providers and attempts to shut down internal event handling mechanisms. - /// Once shut down is complete, API is reset and ready to use again. - /// - /// - public async Task ShutdownAsync() + /// + /// Return the transaction context propagator. + /// + /// the registered transaction context propagator + internal ITransactionContextPropagator GetTransactionContextPropagator() + { + return this._transactionContextPropagator; + } + + /// + /// Sets the transaction context propagator. + /// + /// the transaction context propagator to be registered + /// Transaction context propagator cannot be null + public void SetTransactionContextPropagator(ITransactionContextPropagator transactionContextPropagator) + { + if (transactionContextPropagator == null) { - await using (this._eventExecutor.ConfigureAwait(false)) - await using (this._repository.ConfigureAwait(false)) - { - this._evaluationContext = EvaluationContext.Empty; - this._hooks.Clear(); - this._transactionContextPropagator = new NoOpTransactionContextPropagator(); - - // TODO: make these lazy to avoid extra allocations on the common cleanup path? - this._eventExecutor = new EventExecutor(); - this._repository = new ProviderRepository(); - } + throw new ArgumentNullException(nameof(transactionContextPropagator), + "Transaction context propagator cannot be null"); } - /// - public void AddHandler(ProviderEventTypes type, EventHandlerDelegate handler) + lock (this._transactionContextPropagatorLock) { - this._eventExecutor.AddApiLevelHandler(type, handler); + this._transactionContextPropagator = transactionContextPropagator; } + } + + /// + /// Returns the currently defined transaction context using the registered transaction context propagator. + /// + /// The current transaction context + public EvaluationContext GetTransactionContext() + { + return this._transactionContextPropagator.GetTransactionContext(); + } - /// - public void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler) + /// + /// Sets the transaction context using the registered transaction context propagator. + /// + /// The to set + /// Transaction context propagator is not set. + /// Evaluation context cannot be null + public void SetTransactionContext(EvaluationContext evaluationContext) + { + if (evaluationContext == null) { - this._eventExecutor.RemoveApiLevelHandler(type, handler); + throw new ArgumentNullException(nameof(evaluationContext), "Evaluation context cannot be null"); } - /// - /// Sets the logger for the API - /// - /// The logger to be used - public void SetLogger(ILogger logger) + this._transactionContextPropagator.SetTransactionContext(evaluationContext); + } + + /// + /// + /// Shut down and reset the current status of OpenFeature API. + /// + /// + /// This call cleans up all active providers and attempts to shut down internal event handling mechanisms. + /// Once shut down is complete, API is reset and ready to use again. + /// + /// + public async Task ShutdownAsync() + { + await using (this._eventExecutor.ConfigureAwait(false)) + await using (this._repository.ConfigureAwait(false)) { - this._eventExecutor.SetLogger(logger); - this._repository.SetLogger(logger); + this._evaluationContext = EvaluationContext.Empty; + this._hooks.Clear(); + this._transactionContextPropagator = new NoOpTransactionContextPropagator(); + + // TODO: make these lazy to avoid extra allocations on the common cleanup path? + this._eventExecutor = new EventExecutor(); + this._repository = new ProviderRepository(); } + } - internal void AddClientHandler(string client, ProviderEventTypes eventType, EventHandlerDelegate handler) - => this._eventExecutor.AddClientHandler(client, eventType, handler); + /// + public void AddHandler(ProviderEventTypes type, EventHandlerDelegate handler) + { + this._eventExecutor.AddApiLevelHandler(type, handler); + } - internal void RemoveClientHandler(string client, ProviderEventTypes eventType, EventHandlerDelegate handler) - => this._eventExecutor.RemoveClientHandler(client, eventType, handler); + /// + public void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler) + { + this._eventExecutor.RemoveApiLevelHandler(type, handler); + } - /// - /// Update the provider state to READY and emit a READY event after successful init. - /// - private async Task AfterInitialization(FeatureProvider provider) - { - provider.Status = ProviderStatus.Ready; - var eventPayload = new ProviderEventPayload - { - Type = ProviderEventTypes.ProviderReady, - Message = "Provider initialization complete", - ProviderName = provider.GetMetadata()?.Name, - }; - - await this._eventExecutor.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }).ConfigureAwait(false); - } + /// + /// Sets the logger for the API + /// + /// The logger to be used + public void SetLogger(ILogger logger) + { + this._eventExecutor.SetLogger(logger); + this._repository.SetLogger(logger); + } + + internal void AddClientHandler(string client, ProviderEventTypes eventType, EventHandlerDelegate handler) + => this._eventExecutor.AddClientHandler(client, eventType, handler); - /// - /// Update the provider state to ERROR and emit an ERROR after failed init. - /// - private async Task AfterError(FeatureProvider provider, Exception? ex) + internal void RemoveClientHandler(string client, ProviderEventTypes eventType, EventHandlerDelegate handler) + => this._eventExecutor.RemoveClientHandler(client, eventType, handler); + + /// + /// Update the provider state to READY and emit a READY event after successful init. + /// + private async Task AfterInitialization(FeatureProvider provider) + { + provider.Status = ProviderStatus.Ready; + var eventPayload = new ProviderEventPayload { - provider.Status = typeof(ProviderFatalException) == ex?.GetType() ? ProviderStatus.Fatal : ProviderStatus.Error; - var eventPayload = new ProviderEventPayload - { - Type = ProviderEventTypes.ProviderError, - Message = $"Provider initialization error: {ex?.Message}", - ProviderName = provider.GetMetadata()?.Name, - }; - - await this._eventExecutor.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }).ConfigureAwait(false); - } + Type = ProviderEventTypes.ProviderReady, + Message = "Provider initialization complete", + ProviderName = provider.GetMetadata()?.Name, + }; + + await this._eventExecutor.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }).ConfigureAwait(false); + } - /// - /// This method should only be using for testing purposes. It will reset the singleton instance of the API. - /// - internal static void ResetApi() + /// + /// Update the provider state to ERROR and emit an ERROR after failed init. + /// + private async Task AfterError(FeatureProvider provider, Exception? ex) + { + provider.Status = typeof(ProviderFatalException) == ex?.GetType() ? ProviderStatus.Fatal : ProviderStatus.Error; + var eventPayload = new ProviderEventPayload { - Instance = new Api(); - } + Type = ProviderEventTypes.ProviderError, + Message = $"Provider initialization error: {ex?.Message}", + ProviderName = provider.GetMetadata()?.Name, + }; + + await this._eventExecutor.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }).ConfigureAwait(false); + } + + /// + /// This method should only be using for testing purposes. It will reset the singleton instance of the API. + /// + internal static void ResetApi() + { + Instance = new Api(); } } diff --git a/src/OpenFeature/Constant/Constants.cs b/src/OpenFeature/Constant/Constants.cs index 0c58ec4d7..319844b88 100644 --- a/src/OpenFeature/Constant/Constants.cs +++ b/src/OpenFeature/Constant/Constants.cs @@ -1,9 +1,8 @@ -namespace OpenFeature.Constant +namespace OpenFeature.Constant; + +internal static class NoOpProvider { - internal static class NoOpProvider - { - public const string NoOpProviderName = "No-op Provider"; - public const string ReasonNoOp = "No-op"; - public const string Variant = "No-op"; - } + public const string NoOpProviderName = "No-op Provider"; + public const string ReasonNoOp = "No-op"; + public const string Variant = "No-op"; } diff --git a/src/OpenFeature/Constant/ErrorType.cs b/src/OpenFeature/Constant/ErrorType.cs index 4660e41a3..d36f3d963 100644 --- a/src/OpenFeature/Constant/ErrorType.cs +++ b/src/OpenFeature/Constant/ErrorType.cs @@ -1,56 +1,55 @@ using System.ComponentModel; -namespace OpenFeature.Constant +namespace OpenFeature.Constant; + +/// +/// These errors are used to indicate abnormal execution when evaluation a flag +/// +/// +public enum ErrorType { /// - /// These errors are used to indicate abnormal execution when evaluation a flag - /// - /// - public enum ErrorType - { - /// - /// Default value, no error occured - /// - None, - - /// - /// Provider has yet been initialized - /// - [Description("PROVIDER_NOT_READY")] ProviderNotReady, - - /// - /// Provider was unable to find the flag - /// - [Description("FLAG_NOT_FOUND")] FlagNotFound, - - /// - /// Provider failed to parse the flag response - /// - [Description("PARSE_ERROR")] ParseError, - - /// - /// Request type does not match the expected type - /// - [Description("TYPE_MISMATCH")] TypeMismatch, - - /// - /// Abnormal execution of the provider - /// - [Description("GENERAL")] General, - - /// - /// Context does not satisfy provider requirements. - /// - [Description("INVALID_CONTEXT")] InvalidContext, - - /// - /// Context does not contain a targeting key and the provider requires one. - /// - [Description("TARGETING_KEY_MISSING")] TargetingKeyMissing, - - /// - /// The provider has entered an irrecoverable error state. - /// - [Description("PROVIDER_FATAL")] ProviderFatal, - } + /// Default value, no error occured + /// + None, + + /// + /// Provider has yet been initialized + /// + [Description("PROVIDER_NOT_READY")] ProviderNotReady, + + /// + /// Provider was unable to find the flag + /// + [Description("FLAG_NOT_FOUND")] FlagNotFound, + + /// + /// Provider failed to parse the flag response + /// + [Description("PARSE_ERROR")] ParseError, + + /// + /// Request type does not match the expected type + /// + [Description("TYPE_MISMATCH")] TypeMismatch, + + /// + /// Abnormal execution of the provider + /// + [Description("GENERAL")] General, + + /// + /// Context does not satisfy provider requirements. + /// + [Description("INVALID_CONTEXT")] InvalidContext, + + /// + /// Context does not contain a targeting key and the provider requires one. + /// + [Description("TARGETING_KEY_MISSING")] TargetingKeyMissing, + + /// + /// The provider has entered an irrecoverable error state. + /// + [Description("PROVIDER_FATAL")] ProviderFatal, } diff --git a/src/OpenFeature/Constant/EventType.cs b/src/OpenFeature/Constant/EventType.cs index 3d3c9dc89..369c10b2f 100644 --- a/src/OpenFeature/Constant/EventType.cs +++ b/src/OpenFeature/Constant/EventType.cs @@ -1,25 +1,24 @@ -namespace OpenFeature.Constant +namespace OpenFeature.Constant; + +/// +/// The ProviderEventTypes enum represents the available event types of a provider. +/// +public enum ProviderEventTypes { /// - /// The ProviderEventTypes enum represents the available event types of a provider. + /// ProviderReady should be emitted by a provider upon completing its initialisation. /// - public enum ProviderEventTypes - { - /// - /// ProviderReady should be emitted by a provider upon completing its initialisation. - /// - ProviderReady, - /// - /// ProviderError should be emitted by a provider upon encountering an error. - /// - ProviderError, - /// - /// ProviderConfigurationChanged should be emitted by a provider when a flag configuration has been changed. - /// - ProviderConfigurationChanged, - /// - /// ProviderStale should be emitted by a provider when it goes into the stale state. - /// - ProviderStale - } + ProviderReady, + /// + /// ProviderError should be emitted by a provider upon encountering an error. + /// + ProviderError, + /// + /// ProviderConfigurationChanged should be emitted by a provider when a flag configuration has been changed. + /// + ProviderConfigurationChanged, + /// + /// ProviderStale should be emitted by a provider when it goes into the stale state. + /// + ProviderStale } diff --git a/src/OpenFeature/Constant/FlagValueType.cs b/src/OpenFeature/Constant/FlagValueType.cs index 94a35d5b9..d63db7122 100644 --- a/src/OpenFeature/Constant/FlagValueType.cs +++ b/src/OpenFeature/Constant/FlagValueType.cs @@ -1,28 +1,27 @@ -namespace OpenFeature.Constant +namespace OpenFeature.Constant; + +/// +/// Used to identity what object type of flag being evaluated +/// +public enum FlagValueType { /// - /// Used to identity what object type of flag being evaluated + /// Flag is a boolean value /// - public enum FlagValueType - { - /// - /// Flag is a boolean value - /// - Boolean, + Boolean, - /// - /// Flag is a string value - /// - String, + /// + /// Flag is a string value + /// + String, - /// - /// Flag is a numeric value - /// - Number, + /// + /// Flag is a numeric value + /// + Number, - /// - /// Flag is a structured value - /// - Object - } + /// + /// Flag is a structured value + /// + Object } diff --git a/src/OpenFeature/Constant/ProviderStatus.cs b/src/OpenFeature/Constant/ProviderStatus.cs index 16dbd0247..760337463 100644 --- a/src/OpenFeature/Constant/ProviderStatus.cs +++ b/src/OpenFeature/Constant/ProviderStatus.cs @@ -1,36 +1,35 @@ using System.ComponentModel; -namespace OpenFeature.Constant +namespace OpenFeature.Constant; + +/// +/// The state of the provider. +/// +/// +public enum ProviderStatus { /// - /// The state of the provider. + /// The provider has not been initialized and cannot yet evaluate flags. /// - /// - public enum ProviderStatus - { - /// - /// The provider has not been initialized and cannot yet evaluate flags. - /// - [Description("NOT_READY")] NotReady, + [Description("NOT_READY")] NotReady, - /// - /// The provider is ready to resolve flags. - /// - [Description("READY")] Ready, + /// + /// The provider is ready to resolve flags. + /// + [Description("READY")] Ready, - /// - /// The provider's cached state is no longer valid and may not be up-to-date with the source of truth. - /// - [Description("STALE")] Stale, + /// + /// The provider's cached state is no longer valid and may not be up-to-date with the source of truth. + /// + [Description("STALE")] Stale, - /// - /// The provider is in an error state and unable to evaluate flags. - /// - [Description("ERROR")] Error, + /// + /// The provider is in an error state and unable to evaluate flags. + /// + [Description("ERROR")] Error, - /// - /// The provider has entered an irrecoverable error state. - /// - [Description("FATAL")] Fatal, - } + /// + /// The provider has entered an irrecoverable error state. + /// + [Description("FATAL")] Fatal, } diff --git a/src/OpenFeature/Constant/Reason.cs b/src/OpenFeature/Constant/Reason.cs index eac06c1e9..bd0653b50 100644 --- a/src/OpenFeature/Constant/Reason.cs +++ b/src/OpenFeature/Constant/Reason.cs @@ -1,50 +1,49 @@ -namespace OpenFeature.Constant +namespace OpenFeature.Constant; + +/// +/// Common reasons used during flag resolution +/// +/// Reason Specification +public static class Reason { /// - /// Common reasons used during flag resolution - /// - /// Reason Specification - public static class Reason - { - /// - /// Use when the flag is matched based on the evaluation context user data - /// - public const string TargetingMatch = "TARGETING_MATCH"; - - /// - /// Use when the flag is matched based on a split rule in the feature flag provider - /// - public const string Split = "SPLIT"; - - /// - /// Use when the flag is disabled in the feature flag provider - /// - public const string Disabled = "DISABLED"; - - /// - /// Default reason when evaluating flag - /// - public const string Default = "DEFAULT"; - - /// - /// The resolved value is static (no dynamic evaluation) - /// - public const string Static = "STATIC"; - - /// - /// The resolved value was retrieved from cache - /// - public const string Cached = "CACHED"; - - /// - /// Use when an unknown reason is encountered when evaluating flag. - /// An example of this is if the feature provider returns a reason that is not defined in the spec - /// - public const string Unknown = "UNKNOWN"; - - /// - /// Use this flag when abnormal execution is encountered. - /// - public const string Error = "ERROR"; - } + /// Use when the flag is matched based on the evaluation context user data + /// + public const string TargetingMatch = "TARGETING_MATCH"; + + /// + /// Use when the flag is matched based on a split rule in the feature flag provider + /// + public const string Split = "SPLIT"; + + /// + /// Use when the flag is disabled in the feature flag provider + /// + public const string Disabled = "DISABLED"; + + /// + /// Default reason when evaluating flag + /// + public const string Default = "DEFAULT"; + + /// + /// The resolved value is static (no dynamic evaluation) + /// + public const string Static = "STATIC"; + + /// + /// The resolved value was retrieved from cache + /// + public const string Cached = "CACHED"; + + /// + /// Use when an unknown reason is encountered when evaluating flag. + /// An example of this is if the feature provider returns a reason that is not defined in the spec + /// + public const string Unknown = "UNKNOWN"; + + /// + /// Use this flag when abnormal execution is encountered. + /// + public const string Error = "ERROR"; } diff --git a/src/OpenFeature/Error/FeatureProviderException.cs b/src/OpenFeature/Error/FeatureProviderException.cs index b2c43dc7a..b0431ab7b 100644 --- a/src/OpenFeature/Error/FeatureProviderException.cs +++ b/src/OpenFeature/Error/FeatureProviderException.cs @@ -1,29 +1,28 @@ using System; using OpenFeature.Constant; -namespace OpenFeature.Error +namespace OpenFeature.Error; + +/// +/// Used to represent an abnormal error when evaluating a flag. +/// This exception should be thrown when evaluating a flag inside a IFeatureFlag provider +/// +public class FeatureProviderException : Exception { /// - /// Used to represent an abnormal error when evaluating a flag. - /// This exception should be thrown when evaluating a flag inside a IFeatureFlag provider + /// Error that occurred during evaluation /// - public class FeatureProviderException : Exception - { - /// - /// Error that occurred during evaluation - /// - public ErrorType ErrorType { get; } + public ErrorType ErrorType { get; } - /// - /// Initialize a new instance of the class - /// - /// Common error types - /// Exception message - /// Optional inner exception - public FeatureProviderException(ErrorType errorType, string? message = null, Exception? innerException = null) - : base(message, innerException) - { - this.ErrorType = errorType; - } + /// + /// Initialize a new instance of the class + /// + /// Common error types + /// Exception message + /// Optional inner exception + public FeatureProviderException(ErrorType errorType, string? message = null, Exception? innerException = null) + : base(message, innerException) + { + this.ErrorType = errorType; } } diff --git a/src/OpenFeature/Error/FlagNotFoundException.cs b/src/OpenFeature/Error/FlagNotFoundException.cs index b1a5b64a8..d685bb4a4 100644 --- a/src/OpenFeature/Error/FlagNotFoundException.cs +++ b/src/OpenFeature/Error/FlagNotFoundException.cs @@ -2,22 +2,21 @@ using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; -namespace OpenFeature.Error +namespace OpenFeature.Error; + +/// +/// Provider was unable to find the flag error when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class FlagNotFoundException : FeatureProviderException { /// - /// Provider was unable to find the flag error when evaluating a flag. + /// Initialize a new instance of the class /// - [ExcludeFromCodeCoverage] - public class FlagNotFoundException : FeatureProviderException + /// Exception message + /// Optional inner exception + public FlagNotFoundException(string? message = null, Exception? innerException = null) + : base(ErrorType.FlagNotFound, message, innerException) { - /// - /// Initialize a new instance of the class - /// - /// Exception message - /// Optional inner exception - public FlagNotFoundException(string? message = null, Exception? innerException = null) - : base(ErrorType.FlagNotFound, message, innerException) - { - } } } diff --git a/src/OpenFeature/Error/GeneralException.cs b/src/OpenFeature/Error/GeneralException.cs index 4580ff319..0f9da24ca 100644 --- a/src/OpenFeature/Error/GeneralException.cs +++ b/src/OpenFeature/Error/GeneralException.cs @@ -2,22 +2,21 @@ using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; -namespace OpenFeature.Error +namespace OpenFeature.Error; + +/// +/// Abnormal execution of the provider when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class GeneralException : FeatureProviderException { /// - /// Abnormal execution of the provider when evaluating a flag. + /// Initialize a new instance of the class /// - [ExcludeFromCodeCoverage] - public class GeneralException : FeatureProviderException + /// Exception message + /// Optional inner exception + public GeneralException(string? message = null, Exception? innerException = null) + : base(ErrorType.General, message, innerException) { - /// - /// Initialize a new instance of the class - /// - /// Exception message - /// Optional inner exception - public GeneralException(string? message = null, Exception? innerException = null) - : base(ErrorType.General, message, innerException) - { - } } } diff --git a/src/OpenFeature/Error/InvalidContextException.cs b/src/OpenFeature/Error/InvalidContextException.cs index ffea8ab17..881d0464f 100644 --- a/src/OpenFeature/Error/InvalidContextException.cs +++ b/src/OpenFeature/Error/InvalidContextException.cs @@ -2,22 +2,21 @@ using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; -namespace OpenFeature.Error +namespace OpenFeature.Error; + +/// +/// Context does not satisfy provider requirements when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class InvalidContextException : FeatureProviderException { /// - /// Context does not satisfy provider requirements when evaluating a flag. + /// Initialize a new instance of the class /// - [ExcludeFromCodeCoverage] - public class InvalidContextException : FeatureProviderException + /// Exception message + /// Optional inner exception + public InvalidContextException(string? message = null, Exception? innerException = null) + : base(ErrorType.InvalidContext, message, innerException) { - /// - /// Initialize a new instance of the class - /// - /// Exception message - /// Optional inner exception - public InvalidContextException(string? message = null, Exception? innerException = null) - : base(ErrorType.InvalidContext, message, innerException) - { - } } } diff --git a/src/OpenFeature/Error/ParseErrorException.cs b/src/OpenFeature/Error/ParseErrorException.cs index 81ded4562..57bcf2719 100644 --- a/src/OpenFeature/Error/ParseErrorException.cs +++ b/src/OpenFeature/Error/ParseErrorException.cs @@ -2,22 +2,21 @@ using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; -namespace OpenFeature.Error +namespace OpenFeature.Error; + +/// +/// Provider failed to parse the flag response when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class ParseErrorException : FeatureProviderException { /// - /// Provider failed to parse the flag response when evaluating a flag. + /// Initialize a new instance of the class /// - [ExcludeFromCodeCoverage] - public class ParseErrorException : FeatureProviderException + /// Exception message + /// Optional inner exception + public ParseErrorException(string? message = null, Exception? innerException = null) + : base(ErrorType.ParseError, message, innerException) { - /// - /// Initialize a new instance of the class - /// - /// Exception message - /// Optional inner exception - public ParseErrorException(string? message = null, Exception? innerException = null) - : base(ErrorType.ParseError, message, innerException) - { - } } } diff --git a/src/OpenFeature/Error/ProviderFatalException.cs b/src/OpenFeature/Error/ProviderFatalException.cs index 894a583dc..60ba5f251 100644 --- a/src/OpenFeature/Error/ProviderFatalException.cs +++ b/src/OpenFeature/Error/ProviderFatalException.cs @@ -2,22 +2,21 @@ using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; -namespace OpenFeature.Error +namespace OpenFeature.Error; + +/// +/// An exception that signals the provider has entered an irrecoverable error state. +/// +[ExcludeFromCodeCoverage] +public class ProviderFatalException : FeatureProviderException { /// - /// An exception that signals the provider has entered an irrecoverable error state. + /// Initialize a new instance of the class /// - [ExcludeFromCodeCoverage] - public class ProviderFatalException : FeatureProviderException + /// Exception message + /// Optional inner exception + public ProviderFatalException(string? message = null, Exception? innerException = null) + : base(ErrorType.ProviderFatal, message, innerException) { - /// - /// Initialize a new instance of the class - /// - /// Exception message - /// Optional inner exception - public ProviderFatalException(string? message = null, Exception? innerException = null) - : base(ErrorType.ProviderFatal, message, innerException) - { - } } } diff --git a/src/OpenFeature/Error/ProviderNotReadyException.cs b/src/OpenFeature/Error/ProviderNotReadyException.cs index b66201d7f..5d2e3af18 100644 --- a/src/OpenFeature/Error/ProviderNotReadyException.cs +++ b/src/OpenFeature/Error/ProviderNotReadyException.cs @@ -2,22 +2,21 @@ using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; -namespace OpenFeature.Error +namespace OpenFeature.Error; + +/// +/// Provider has not yet been initialized when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class ProviderNotReadyException : FeatureProviderException { /// - /// Provider has not yet been initialized when evaluating a flag. + /// Initialize a new instance of the class /// - [ExcludeFromCodeCoverage] - public class ProviderNotReadyException : FeatureProviderException + /// Exception message + /// Optional inner exception + public ProviderNotReadyException(string? message = null, Exception? innerException = null) + : base(ErrorType.ProviderNotReady, message, innerException) { - /// - /// Initialize a new instance of the class - /// - /// Exception message - /// Optional inner exception - public ProviderNotReadyException(string? message = null, Exception? innerException = null) - : base(ErrorType.ProviderNotReady, message, innerException) - { - } } } diff --git a/src/OpenFeature/Error/TargetingKeyMissingException.cs b/src/OpenFeature/Error/TargetingKeyMissingException.cs index 717424134..488009f41 100644 --- a/src/OpenFeature/Error/TargetingKeyMissingException.cs +++ b/src/OpenFeature/Error/TargetingKeyMissingException.cs @@ -2,22 +2,21 @@ using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; -namespace OpenFeature.Error +namespace OpenFeature.Error; + +/// +/// Context does not contain a targeting key and the provider requires one when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class TargetingKeyMissingException : FeatureProviderException { /// - /// Context does not contain a targeting key and the provider requires one when evaluating a flag. + /// Initialize a new instance of the class /// - [ExcludeFromCodeCoverage] - public class TargetingKeyMissingException : FeatureProviderException + /// Exception message + /// Optional inner exception + public TargetingKeyMissingException(string? message = null, Exception? innerException = null) + : base(ErrorType.TargetingKeyMissing, message, innerException) { - /// - /// Initialize a new instance of the class - /// - /// Exception message - /// Optional inner exception - public TargetingKeyMissingException(string? message = null, Exception? innerException = null) - : base(ErrorType.TargetingKeyMissing, message, innerException) - { - } } } diff --git a/src/OpenFeature/Error/TypeMismatchException.cs b/src/OpenFeature/Error/TypeMismatchException.cs index 83ff0cf39..2df3b29f0 100644 --- a/src/OpenFeature/Error/TypeMismatchException.cs +++ b/src/OpenFeature/Error/TypeMismatchException.cs @@ -2,22 +2,21 @@ using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; -namespace OpenFeature.Error +namespace OpenFeature.Error; + +/// +/// Request type does not match the expected type when evaluating a flag. +/// +[ExcludeFromCodeCoverage] +public class TypeMismatchException : FeatureProviderException { /// - /// Request type does not match the expected type when evaluating a flag. + /// Initialize a new instance of the class /// - [ExcludeFromCodeCoverage] - public class TypeMismatchException : FeatureProviderException + /// Exception message + /// Optional inner exception + public TypeMismatchException(string? message = null, Exception? innerException = null) + : base(ErrorType.TypeMismatch, message, innerException) { - /// - /// Initialize a new instance of the class - /// - /// Exception message - /// Optional inner exception - public TypeMismatchException(string? message = null, Exception? innerException = null) - : base(ErrorType.TypeMismatch, message, innerException) - { - } } } diff --git a/src/OpenFeature/EventExecutor.cs b/src/OpenFeature/EventExecutor.cs index a1c1ddbdc..edb75780a 100644 --- a/src/OpenFeature/EventExecutor.cs +++ b/src/OpenFeature/EventExecutor.cs @@ -7,350 +7,349 @@ using OpenFeature.Constant; using OpenFeature.Model; -namespace OpenFeature +namespace OpenFeature; + +internal sealed partial class EventExecutor : IAsyncDisposable { - internal sealed partial class EventExecutor : IAsyncDisposable - { - private readonly object _lockObj = new(); - public readonly Channel EventChannel = Channel.CreateBounded(1); - private FeatureProvider? _defaultProvider; - private readonly Dictionary _namedProviderReferences = []; - private readonly List _activeSubscriptions = []; + private readonly object _lockObj = new(); + public readonly Channel EventChannel = Channel.CreateBounded(1); + private FeatureProvider? _defaultProvider; + private readonly Dictionary _namedProviderReferences = []; + private readonly List _activeSubscriptions = []; - private readonly Dictionary> _apiHandlers = []; - private readonly Dictionary>> _clientHandlers = []; + private readonly Dictionary> _apiHandlers = []; + private readonly Dictionary>> _clientHandlers = []; - private ILogger _logger; + private ILogger _logger; - public EventExecutor() - { - this._logger = NullLogger.Instance; - Task.Run(this.ProcessEventAsync); - } + public EventExecutor() + { + this._logger = NullLogger.Instance; + Task.Run(this.ProcessEventAsync); + } - public ValueTask DisposeAsync() => new(this.ShutdownAsync()); + public ValueTask DisposeAsync() => new(this.ShutdownAsync()); - internal void SetLogger(ILogger logger) => this._logger = logger; + internal void SetLogger(ILogger logger) => this._logger = logger; - internal void AddApiLevelHandler(ProviderEventTypes eventType, EventHandlerDelegate handler) + internal void AddApiLevelHandler(ProviderEventTypes eventType, EventHandlerDelegate handler) + { + lock (this._lockObj) { - lock (this._lockObj) + if (!this._apiHandlers.TryGetValue(eventType, out var eventHandlers)) { - if (!this._apiHandlers.TryGetValue(eventType, out var eventHandlers)) - { - eventHandlers = []; - this._apiHandlers[eventType] = eventHandlers; - } + eventHandlers = []; + this._apiHandlers[eventType] = eventHandlers; + } - eventHandlers.Add(handler); + eventHandlers.Add(handler); - this.EmitOnRegistration(this._defaultProvider, eventType, handler); - } + this.EmitOnRegistration(this._defaultProvider, eventType, handler); } + } - internal void RemoveApiLevelHandler(ProviderEventTypes type, EventHandlerDelegate handler) + internal void RemoveApiLevelHandler(ProviderEventTypes type, EventHandlerDelegate handler) + { + lock (this._lockObj) { - lock (this._lockObj) + if (this._apiHandlers.TryGetValue(type, out var eventHandlers)) { - if (this._apiHandlers.TryGetValue(type, out var eventHandlers)) - { - eventHandlers.Remove(handler); - } + eventHandlers.Remove(handler); } } + } - internal void AddClientHandler(string client, ProviderEventTypes eventType, EventHandlerDelegate handler) + internal void AddClientHandler(string client, ProviderEventTypes eventType, EventHandlerDelegate handler) + { + lock (this._lockObj) { - lock (this._lockObj) + // check if there is already a list of handlers for the given client and event type + if (!this._clientHandlers.TryGetValue(client, out var registry)) { - // check if there is already a list of handlers for the given client and event type - if (!this._clientHandlers.TryGetValue(client, out var registry)) - { - registry = []; - this._clientHandlers[client] = registry; - } + registry = []; + this._clientHandlers[client] = registry; + } - if (!this._clientHandlers[client].TryGetValue(eventType, out var eventHandlers)) - { - eventHandlers = []; - this._clientHandlers[client][eventType] = eventHandlers; - } + if (!this._clientHandlers[client].TryGetValue(eventType, out var eventHandlers)) + { + eventHandlers = []; + this._clientHandlers[client][eventType] = eventHandlers; + } - this._clientHandlers[client][eventType].Add(handler); + this._clientHandlers[client][eventType].Add(handler); - this.EmitOnRegistration( - this._namedProviderReferences.TryGetValue(client, out var clientProviderReference) - ? clientProviderReference - : this._defaultProvider, eventType, handler); - } + this.EmitOnRegistration( + this._namedProviderReferences.TryGetValue(client, out var clientProviderReference) + ? clientProviderReference + : this._defaultProvider, eventType, handler); } + } - internal void RemoveClientHandler(string client, ProviderEventTypes type, EventHandlerDelegate handler) + internal void RemoveClientHandler(string client, ProviderEventTypes type, EventHandlerDelegate handler) + { + lock (this._lockObj) { - lock (this._lockObj) + if (this._clientHandlers.TryGetValue(client, out var clientEventHandlers)) { - if (this._clientHandlers.TryGetValue(client, out var clientEventHandlers)) + if (clientEventHandlers.TryGetValue(type, out var eventHandlers)) { - if (clientEventHandlers.TryGetValue(type, out var eventHandlers)) - { - eventHandlers.Remove(handler); - } + eventHandlers.Remove(handler); } } } + } - internal void RegisterDefaultFeatureProvider(FeatureProvider? provider) + internal void RegisterDefaultFeatureProvider(FeatureProvider? provider) + { + if (provider == null) { - if (provider == null) - { - return; - } - lock (this._lockObj) - { - var oldProvider = this._defaultProvider; + return; + } + lock (this._lockObj) + { + var oldProvider = this._defaultProvider; - this._defaultProvider = provider; + this._defaultProvider = provider; - this.StartListeningAndShutdownOld(this._defaultProvider, oldProvider); - } + this.StartListeningAndShutdownOld(this._defaultProvider, oldProvider); } + } - internal void RegisterClientFeatureProvider(string client, FeatureProvider? provider) + internal void RegisterClientFeatureProvider(string client, FeatureProvider? provider) + { + if (provider == null) + { + return; + } + lock (this._lockObj) { - if (provider == null) + FeatureProvider? oldProvider = null; + if (this._namedProviderReferences.TryGetValue(client, out var foundOldProvider)) { - return; + oldProvider = foundOldProvider; } - lock (this._lockObj) - { - FeatureProvider? oldProvider = null; - if (this._namedProviderReferences.TryGetValue(client, out var foundOldProvider)) - { - oldProvider = foundOldProvider; - } - this._namedProviderReferences[client] = provider; + this._namedProviderReferences[client] = provider; - this.StartListeningAndShutdownOld(provider, oldProvider); - } + this.StartListeningAndShutdownOld(provider, oldProvider); } + } - private void StartListeningAndShutdownOld(FeatureProvider newProvider, FeatureProvider? oldProvider) + private void StartListeningAndShutdownOld(FeatureProvider newProvider, FeatureProvider? oldProvider) + { + // check if the provider is already active - if not, we need to start listening for its emitted events + if (!this.IsProviderActive(newProvider)) { - // check if the provider is already active - if not, we need to start listening for its emitted events - if (!this.IsProviderActive(newProvider)) - { - this._activeSubscriptions.Add(newProvider); - Task.Run(() => this.ProcessFeatureProviderEventsAsync(newProvider)); - } + this._activeSubscriptions.Add(newProvider); + Task.Run(() => this.ProcessFeatureProviderEventsAsync(newProvider)); + } - if (oldProvider != null && !this.IsProviderBound(oldProvider)) - { - this._activeSubscriptions.Remove(oldProvider); - oldProvider.GetEventChannel().Writer.Complete(); - } + if (oldProvider != null && !this.IsProviderBound(oldProvider)) + { + this._activeSubscriptions.Remove(oldProvider); + oldProvider.GetEventChannel().Writer.Complete(); } + } - private bool IsProviderBound(FeatureProvider provider) + private bool IsProviderBound(FeatureProvider provider) + { + if (this._defaultProvider == provider) + { + return true; + } + foreach (var providerReference in this._namedProviderReferences.Values) { - if (this._defaultProvider == provider) + if (providerReference == provider) { return true; } - foreach (var providerReference in this._namedProviderReferences.Values) - { - if (providerReference == provider) - { - return true; - } - } - return false; } + return false; + } + + private bool IsProviderActive(FeatureProvider providerRef) + { + return this._activeSubscriptions.Contains(providerRef); + } - private bool IsProviderActive(FeatureProvider providerRef) + private void EmitOnRegistration(FeatureProvider? provider, ProviderEventTypes eventType, EventHandlerDelegate handler) + { + if (provider == null) { - return this._activeSubscriptions.Contains(providerRef); + return; } + var status = provider.Status; - private void EmitOnRegistration(FeatureProvider? provider, ProviderEventTypes eventType, EventHandlerDelegate handler) + var message = status switch { - if (provider == null) - { - return; - } - var status = provider.Status; + ProviderStatus.Ready when eventType == ProviderEventTypes.ProviderReady => "Provider is ready", + ProviderStatus.Error when eventType == ProviderEventTypes.ProviderError => "Provider is in error state", + ProviderStatus.Stale when eventType == ProviderEventTypes.ProviderStale => "Provider is in stale state", + _ => string.Empty + }; - var message = status switch - { - ProviderStatus.Ready when eventType == ProviderEventTypes.ProviderReady => "Provider is ready", - ProviderStatus.Error when eventType == ProviderEventTypes.ProviderError => "Provider is in error state", - ProviderStatus.Stale when eventType == ProviderEventTypes.ProviderStale => "Provider is in stale state", - _ => string.Empty - }; + if (string.IsNullOrWhiteSpace(message)) + { + return; + } - if (string.IsNullOrWhiteSpace(message)) + try + { + handler.Invoke(new ProviderEventPayload { - return; - } + ProviderName = provider.GetMetadata()?.Name, + Type = eventType, + Message = message + }); + } + catch (Exception exc) + { + this.ErrorRunningHandler(exc); + } + } - try - { - handler.Invoke(new ProviderEventPayload - { - ProviderName = provider.GetMetadata()?.Name, - Type = eventType, - Message = message - }); - } - catch (Exception exc) + private async Task ProcessFeatureProviderEventsAsync(FeatureProvider provider) + { + if (provider.GetEventChannel() is not { Reader: { } reader }) + { + return; + } + + while (await reader.WaitToReadAsync().ConfigureAwait(false)) + { + if (!reader.TryRead(out var item)) + continue; + + switch (item) { - this.ErrorRunningHandler(exc); + case ProviderEventPayload eventPayload: + UpdateProviderStatus(provider, eventPayload); + await this.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }).ConfigureAwait(false); + break; } } + } - private async Task ProcessFeatureProviderEventsAsync(FeatureProvider provider) + // Method to process events + private async Task ProcessEventAsync() + { + while (await this.EventChannel.Reader.WaitToReadAsync().ConfigureAwait(false)) { - if (provider.GetEventChannel() is not { Reader: { } reader }) + if (!this.EventChannel.Reader.TryRead(out var item)) { - return; + continue; } - while (await reader.WaitToReadAsync().ConfigureAwait(false)) + if (item is not Event e) { - if (!reader.TryRead(out var item)) - continue; + continue; + } - switch (item) - { - case ProviderEventPayload eventPayload: - UpdateProviderStatus(provider, eventPayload); - await this.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }).ConfigureAwait(false); - break; - } + lock (this._lockObj) + { + this.ProcessApiHandlers(e); + this.ProcessClientHandlers(e); + this.ProcessDefaultProviderHandlers(e); } } + } - // Method to process events - private async Task ProcessEventAsync() + private void ProcessApiHandlers(Event e) + { + if (e.EventPayload?.Type != null && this._apiHandlers.TryGetValue(e.EventPayload.Type, out var eventHandlers)) { - while (await this.EventChannel.Reader.WaitToReadAsync().ConfigureAwait(false)) + foreach (var eventHandler in eventHandlers) { - if (!this.EventChannel.Reader.TryRead(out var item)) - { - continue; - } - - if (item is not Event e) - { - continue; - } - - lock (this._lockObj) - { - this.ProcessApiHandlers(e); - this.ProcessClientHandlers(e); - this.ProcessDefaultProviderHandlers(e); - } + this.InvokeEventHandler(eventHandler, e); } } + } - private void ProcessApiHandlers(Event e) + private void ProcessClientHandlers(Event e) + { + foreach (var keyAndValue in this._namedProviderReferences) { - if (e.EventPayload?.Type != null && this._apiHandlers.TryGetValue(e.EventPayload.Type, out var eventHandlers)) + if (keyAndValue.Value == e.Provider + && this._clientHandlers.TryGetValue(keyAndValue.Key, out var clientRegistry) + && e.EventPayload?.Type != null + && clientRegistry.TryGetValue(e.EventPayload.Type, out var clientEventHandlers)) { - foreach (var eventHandler in eventHandlers) + foreach (var eventHandler in clientEventHandlers) { this.InvokeEventHandler(eventHandler, e); } } } + } - private void ProcessClientHandlers(Event e) + private void ProcessDefaultProviderHandlers(Event e) + { + if (e.Provider != this._defaultProvider) { - foreach (var keyAndValue in this._namedProviderReferences) - { - if (keyAndValue.Value == e.Provider - && this._clientHandlers.TryGetValue(keyAndValue.Key, out var clientRegistry) - && e.EventPayload?.Type != null - && clientRegistry.TryGetValue(e.EventPayload.Type, out var clientEventHandlers)) - { - foreach (var eventHandler in clientEventHandlers) - { - this.InvokeEventHandler(eventHandler, e); - } - } - } + return; } - private void ProcessDefaultProviderHandlers(Event e) + foreach (var keyAndValues in this._clientHandlers) { - if (e.Provider != this._defaultProvider) + if (this._namedProviderReferences.ContainsKey(keyAndValues.Key)) { - return; + continue; } - foreach (var keyAndValues in this._clientHandlers) + if (e.EventPayload?.Type != null && keyAndValues.Value.TryGetValue(e.EventPayload.Type, out var clientEventHandlers)) { - if (this._namedProviderReferences.ContainsKey(keyAndValues.Key)) + foreach (var eventHandler in clientEventHandlers) { - continue; - } - - if (e.EventPayload?.Type != null && keyAndValues.Value.TryGetValue(e.EventPayload.Type, out var clientEventHandlers)) - { - foreach (var eventHandler in clientEventHandlers) - { - this.InvokeEventHandler(eventHandler, e); - } + this.InvokeEventHandler(eventHandler, e); } } } + } - // map events to provider status as per spec: https://openfeature.dev/specification/sections/events/#requirement-535 - private static void UpdateProviderStatus(FeatureProvider provider, ProviderEventPayload eventPayload) + // map events to provider status as per spec: https://openfeature.dev/specification/sections/events/#requirement-535 + private static void UpdateProviderStatus(FeatureProvider provider, ProviderEventPayload eventPayload) + { + switch (eventPayload.Type) { - switch (eventPayload.Type) - { - case ProviderEventTypes.ProviderReady: - provider.Status = ProviderStatus.Ready; - break; - case ProviderEventTypes.ProviderStale: - provider.Status = ProviderStatus.Stale; - break; - case ProviderEventTypes.ProviderError: - provider.Status = eventPayload.ErrorType == ErrorType.ProviderFatal ? ProviderStatus.Fatal : ProviderStatus.Error; - break; - case ProviderEventTypes.ProviderConfigurationChanged: - default: break; - } + case ProviderEventTypes.ProviderReady: + provider.Status = ProviderStatus.Ready; + break; + case ProviderEventTypes.ProviderStale: + provider.Status = ProviderStatus.Stale; + break; + case ProviderEventTypes.ProviderError: + provider.Status = eventPayload.ErrorType == ErrorType.ProviderFatal ? ProviderStatus.Fatal : ProviderStatus.Error; + break; + case ProviderEventTypes.ProviderConfigurationChanged: + default: break; } + } - private void InvokeEventHandler(EventHandlerDelegate eventHandler, Event e) + private void InvokeEventHandler(EventHandlerDelegate eventHandler, Event e) + { + try { - try - { - eventHandler.Invoke(e.EventPayload); - } - catch (Exception exc) - { - this.ErrorRunningHandler(exc); - } + eventHandler.Invoke(e.EventPayload); } - - public async Task ShutdownAsync() + catch (Exception exc) { - this.EventChannel.Writer.Complete(); - await this.EventChannel.Reader.Completion.ConfigureAwait(false); + this.ErrorRunningHandler(exc); } - - [LoggerMessage(100, LogLevel.Error, "Error running handler")] - partial void ErrorRunningHandler(Exception exception); } - internal class Event + public async Task ShutdownAsync() { - internal FeatureProvider? Provider { get; set; } - internal ProviderEventPayload? EventPayload { get; set; } + this.EventChannel.Writer.Complete(); + await this.EventChannel.Reader.Completion.ConfigureAwait(false); } + + [LoggerMessage(100, LogLevel.Error, "Error running handler")] + partial void ErrorRunningHandler(Exception exception); +} + +internal class Event +{ + internal FeatureProvider? Provider { get; set; } + internal ProviderEventPayload? EventPayload { get; set; } } diff --git a/src/OpenFeature/Extension/EnumExtensions.cs b/src/OpenFeature/Extension/EnumExtensions.cs index fe10afb5f..d5d7e72b9 100644 --- a/src/OpenFeature/Extension/EnumExtensions.cs +++ b/src/OpenFeature/Extension/EnumExtensions.cs @@ -2,15 +2,14 @@ using System.ComponentModel; using System.Linq; -namespace OpenFeature.Extension +namespace OpenFeature.Extension; + +internal static class EnumExtensions { - internal static class EnumExtensions + public static string GetDescription(this Enum value) { - public static string GetDescription(this Enum value) - { - var field = value.GetType().GetField(value.ToString()); - var attribute = field?.GetCustomAttributes(typeof(DescriptionAttribute), false).FirstOrDefault() as DescriptionAttribute; - return attribute?.Description ?? value.ToString(); - } + var field = value.GetType().GetField(value.ToString()); + var attribute = field?.GetCustomAttributes(typeof(DescriptionAttribute), false).FirstOrDefault() as DescriptionAttribute; + return attribute?.Description ?? value.ToString(); } } diff --git a/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs b/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs index f38356ad3..cf0d4f4aa 100644 --- a/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs +++ b/src/OpenFeature/Extension/ResolutionDetailsExtensions.cs @@ -1,13 +1,12 @@ using OpenFeature.Model; -namespace OpenFeature.Extension +namespace OpenFeature.Extension; + +internal static class ResolutionDetailsExtensions { - internal static class ResolutionDetailsExtensions + public static FlagEvaluationDetails ToFlagEvaluationDetails(this ResolutionDetails details) { - public static FlagEvaluationDetails ToFlagEvaluationDetails(this ResolutionDetails details) - { - return new FlagEvaluationDetails(details.FlagKey, details.Value, details.ErrorType, details.Reason, - details.Variant, details.ErrorMessage, details.FlagMetadata); - } + return new FlagEvaluationDetails(details.FlagKey, details.Value, details.ErrorType, details.Reason, + details.Variant, details.ErrorMessage, details.FlagMetadata); } } diff --git a/src/OpenFeature/FeatureProvider.cs b/src/OpenFeature/FeatureProvider.cs index b5b9a30f7..9c9d93277 100644 --- a/src/OpenFeature/FeatureProvider.cs +++ b/src/OpenFeature/FeatureProvider.cs @@ -5,149 +5,148 @@ using OpenFeature.Constant; using OpenFeature.Model; -namespace OpenFeature +namespace OpenFeature; + +/// +/// The provider interface describes the abstraction layer for a feature flag provider. +/// A provider acts as it translates layer between the generic feature flag structure to a target feature flag system. +/// +/// Provider specification +public abstract class FeatureProvider { /// - /// The provider interface describes the abstraction layer for a feature flag provider. - /// A provider acts as it translates layer between the generic feature flag structure to a target feature flag system. + /// Gets an immutable list of hooks that belong to the provider. + /// By default, return an empty list + /// + /// Executed in the order of hooks + /// before: API, Client, Invocation, Provider + /// after: Provider, Invocation, Client, API + /// error (if applicable): Provider, Invocation, Client, API + /// finally: Provider, Invocation, Client, API /// - /// Provider specification - public abstract class FeatureProvider - { - /// - /// Gets an immutable list of hooks that belong to the provider. - /// By default, return an empty list - /// - /// Executed in the order of hooks - /// before: API, Client, Invocation, Provider - /// after: Provider, Invocation, Client, API - /// error (if applicable): Provider, Invocation, Client, API - /// finally: Provider, Invocation, Client, API - /// - /// Immutable list of hooks - public virtual IImmutableList GetProviderHooks() => ImmutableList.Empty; + /// Immutable list of hooks + public virtual IImmutableList GetProviderHooks() => ImmutableList.Empty; - /// - /// The event channel of the provider. - /// - protected readonly Channel EventChannel = Channel.CreateBounded(1); + /// + /// The event channel of the provider. + /// + protected readonly Channel EventChannel = Channel.CreateBounded(1); - /// - /// Metadata describing the provider. - /// - /// - public abstract Metadata? GetMetadata(); + /// + /// Metadata describing the provider. + /// + /// + public abstract Metadata? GetMetadata(); - /// - /// Resolves a boolean feature flag - /// - /// Feature flag key - /// Default value - /// - /// The . - /// - public abstract Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, - EvaluationContext? context = null, CancellationToken cancellationToken = default); + /// + /// Resolves a boolean feature flag + /// + /// Feature flag key + /// Default value + /// + /// The . + /// + public abstract Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default); - /// - /// Resolves a string feature flag - /// - /// Feature flag key - /// Default value - /// - /// The . - /// - public abstract Task> ResolveStringValueAsync(string flagKey, string defaultValue, - EvaluationContext? context = null, CancellationToken cancellationToken = default); + /// + /// Resolves a string feature flag + /// + /// Feature flag key + /// Default value + /// + /// The . + /// + public abstract Task> ResolveStringValueAsync(string flagKey, string defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default); - /// - /// Resolves a integer feature flag - /// - /// Feature flag key - /// Default value - /// - /// The . - /// - public abstract Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, - EvaluationContext? context = null, CancellationToken cancellationToken = default); + /// + /// Resolves a integer feature flag + /// + /// Feature flag key + /// Default value + /// + /// The . + /// + public abstract Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default); - /// - /// Resolves a double feature flag - /// - /// Feature flag key - /// Default value - /// - /// The . - /// - public abstract Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, - EvaluationContext? context = null, CancellationToken cancellationToken = default); + /// + /// Resolves a double feature flag + /// + /// Feature flag key + /// Default value + /// + /// The . + /// + public abstract Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default); - /// - /// Resolves a structured feature flag - /// - /// Feature flag key - /// Default value - /// - /// The . - /// - public abstract Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, - EvaluationContext? context = null, CancellationToken cancellationToken = default); + /// + /// Resolves a structured feature flag + /// + /// Feature flag key + /// Default value + /// + /// The . + /// + public abstract Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default); - /// - /// Internally-managed provider status. - /// The SDK uses this field to track the status of the provider. - /// Not visible outside OpenFeature assembly - /// - internal virtual ProviderStatus Status { get; set; } = ProviderStatus.NotReady; + /// + /// Internally-managed provider status. + /// The SDK uses this field to track the status of the provider. + /// Not visible outside OpenFeature assembly + /// + internal virtual ProviderStatus Status { get; set; } = ProviderStatus.NotReady; - /// - /// - /// This method is called before a provider is used to evaluate flags. Providers can overwrite this method, - /// if they have special initialization needed prior being called for flag evaluation. - /// When this method completes, the provider will be considered ready for use. - /// - /// - /// - /// The to cancel any async side effects. - /// A task that completes when the initialization process is complete. - /// - /// - /// Providers not implementing this method will be considered ready immediately. - /// - /// - public virtual Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) - { - // Intentionally left blank. - return Task.CompletedTask; - } + /// + /// + /// This method is called before a provider is used to evaluate flags. Providers can overwrite this method, + /// if they have special initialization needed prior being called for flag evaluation. + /// When this method completes, the provider will be considered ready for use. + /// + /// + /// + /// The to cancel any async side effects. + /// A task that completes when the initialization process is complete. + /// + /// + /// Providers not implementing this method will be considered ready immediately. + /// + /// + public virtual Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) + { + // Intentionally left blank. + return Task.CompletedTask; + } - /// - /// This method is called when a new provider is about to be used to evaluate flags, or the SDK is shut down. - /// Providers can overwrite this method, if they have special shutdown actions needed. - /// - /// A task that completes when the shutdown process is complete. - /// The to cancel any async side effects. - public virtual Task ShutdownAsync(CancellationToken cancellationToken = default) - { - // Intentionally left blank. - return Task.CompletedTask; - } + /// + /// This method is called when a new provider is about to be used to evaluate flags, or the SDK is shut down. + /// Providers can overwrite this method, if they have special shutdown actions needed. + /// + /// A task that completes when the shutdown process is complete. + /// The to cancel any async side effects. + public virtual Task ShutdownAsync(CancellationToken cancellationToken = default) + { + // Intentionally left blank. + return Task.CompletedTask; + } - /// - /// Returns the event channel of the provider. - /// - /// The event channel of the provider - public Channel GetEventChannel() => this.EventChannel; + /// + /// Returns the event channel of the provider. + /// + /// The event channel of the provider + public Channel GetEventChannel() => this.EventChannel; - /// - /// Track a user action or application state, usually representing a business objective or outcome. The implementation of this method is optional. - /// - /// The name associated with this tracking event - /// The evaluation context used in the evaluation of the flag (optional) - /// Data pertinent to the tracking event (Optional) - public virtual void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) - { - // Intentionally left blank. - } + /// + /// Track a user action or application state, usually representing a business objective or outcome. The implementation of this method is optional. + /// + /// The name associated with this tracking event + /// The evaluation context used in the evaluation of the flag (optional) + /// Data pertinent to the tracking event (Optional) + public virtual void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + // Intentionally left blank. } } diff --git a/src/OpenFeature/Hook.cs b/src/OpenFeature/Hook.cs index c1dbbe382..d38550ffd 100644 --- a/src/OpenFeature/Hook.cs +++ b/src/OpenFeature/Hook.cs @@ -4,85 +4,84 @@ using System.Threading.Tasks; using OpenFeature.Model; -namespace OpenFeature +namespace OpenFeature; + +/// +/// The Hook abstract class describes the default implementation for a hook. +/// A hook has multiple lifecycles, and is called in the following order when normal execution Before, After, Finally. +/// When an abnormal execution occurs, the hook is called in the following order: Error, Finally. +/// +/// Before: immediately before flag evaluation +/// After: immediately after successful flag evaluation +/// Error: immediately after an unsuccessful during flag evaluation +/// Finally: unconditionally after flag evaluation +/// +/// Hooks can be configured to run globally (impacting all flag evaluations), per client, or per flag evaluation invocation. +/// +/// +/// Hook Specification +public abstract class Hook { /// - /// The Hook abstract class describes the default implementation for a hook. - /// A hook has multiple lifecycles, and is called in the following order when normal execution Before, After, Finally. - /// When an abnormal execution occurs, the hook is called in the following order: Error, Finally. - /// - /// Before: immediately before flag evaluation - /// After: immediately after successful flag evaluation - /// Error: immediately after an unsuccessful during flag evaluation - /// Finally: unconditionally after flag evaluation - /// - /// Hooks can be configured to run globally (impacting all flag evaluations), per client, or per flag evaluation invocation. - /// + /// Called immediately before flag evaluation. /// - /// Hook Specification - public abstract class Hook + /// Provides context of innovation + /// Caller provided data + /// The . + /// Flag value type (bool|number|string|object) + /// Modified EvaluationContext that is used for the flag evaluation + public virtual ValueTask BeforeAsync(HookContext context, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) { - /// - /// Called immediately before flag evaluation. - /// - /// Provides context of innovation - /// Caller provided data - /// The . - /// Flag value type (bool|number|string|object) - /// Modified EvaluationContext that is used for the flag evaluation - public virtual ValueTask BeforeAsync(HookContext context, - IReadOnlyDictionary? hints = null, - CancellationToken cancellationToken = default) - { - return new ValueTask(EvaluationContext.Empty); - } + return new ValueTask(EvaluationContext.Empty); + } - /// - /// Called immediately after successful flag evaluation. - /// - /// Provides context of innovation - /// Flag evaluation information - /// Caller provided data - /// The . - /// Flag value type (bool|number|string|object) - public virtual ValueTask AfterAsync(HookContext context, - FlagEvaluationDetails details, - IReadOnlyDictionary? hints = null, - CancellationToken cancellationToken = default) - { - return new ValueTask(); - } + /// + /// Called immediately after successful flag evaluation. + /// + /// Provides context of innovation + /// Flag evaluation information + /// Caller provided data + /// The . + /// Flag value type (bool|number|string|object) + public virtual ValueTask AfterAsync(HookContext context, + FlagEvaluationDetails details, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) + { + return new ValueTask(); + } - /// - /// Called immediately after an unsuccessful flag evaluation. - /// - /// Provides context of innovation - /// Exception representing what went wrong - /// Caller provided data - /// The . - /// Flag value type (bool|number|string|object) - public virtual ValueTask ErrorAsync(HookContext context, - Exception error, - IReadOnlyDictionary? hints = null, - CancellationToken cancellationToken = default) - { - return new ValueTask(); - } + /// + /// Called immediately after an unsuccessful flag evaluation. + /// + /// Provides context of innovation + /// Exception representing what went wrong + /// Caller provided data + /// The . + /// Flag value type (bool|number|string|object) + public virtual ValueTask ErrorAsync(HookContext context, + Exception error, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) + { + return new ValueTask(); + } - /// - /// Called unconditionally after flag evaluation. - /// - /// Provides context of innovation - /// Flag evaluation information - /// Caller provided data - /// The . - /// Flag value type (bool|number|string|object) - public virtual ValueTask FinallyAsync(HookContext context, - FlagEvaluationDetails evaluationDetails, - IReadOnlyDictionary? hints = null, - CancellationToken cancellationToken = default) - { - return new ValueTask(); - } + /// + /// Called unconditionally after flag evaluation. + /// + /// Provides context of innovation + /// Flag evaluation information + /// Caller provided data + /// The . + /// Flag value type (bool|number|string|object) + public virtual ValueTask FinallyAsync(HookContext context, + FlagEvaluationDetails evaluationDetails, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) + { + return new ValueTask(); } } diff --git a/src/OpenFeature/HookData.cs b/src/OpenFeature/HookData.cs index 5d56eb870..ecfdfabd4 100644 --- a/src/OpenFeature/HookData.cs +++ b/src/OpenFeature/HookData.cs @@ -2,103 +2,102 @@ using System.Collections.Immutable; using OpenFeature.Model; -namespace OpenFeature +namespace OpenFeature; + +/// +/// A key-value collection of strings to objects used for passing data between hook stages. +/// +/// This collection is scoped to a single evaluation for a single hook. Each hook stage for the evaluation +/// will share the same . +/// +/// +/// This collection is intended for use only during the execution of individual hook stages, a reference +/// to the collection should not be retained. +/// +/// +/// This collection is not thread-safe. +/// +/// +/// +public sealed class HookData { + private readonly Dictionary _data = []; + /// - /// A key-value collection of strings to objects used for passing data between hook stages. - /// - /// This collection is scoped to a single evaluation for a single hook. Each hook stage for the evaluation - /// will share the same . - /// - /// - /// This collection is intended for use only during the execution of individual hook stages, a reference - /// to the collection should not be retained. - /// - /// - /// This collection is not thread-safe. - /// + /// Set the key to the given value. /// - /// - public sealed class HookData + /// The key for the value + /// The value to set + /// This hook data instance + public HookData Set(string key, object value) { - private readonly Dictionary _data = []; - - /// - /// Set the key to the given value. - /// - /// The key for the value - /// The value to set - /// This hook data instance - public HookData Set(string key, object value) - { - this._data[key] = value; - return this; - } + this._data[key] = value; + return this; + } - /// - /// Gets the value at the specified key as an object. - /// - /// For types use instead. - /// - /// - /// The key of the value to be retrieved - /// The object associated with the key - /// - /// Thrown when the context does not contain the specified key - /// - public object Get(string key) - { - return this._data[key]; - } + /// + /// Gets the value at the specified key as an object. + /// + /// For types use instead. + /// + /// + /// The key of the value to be retrieved + /// The object associated with the key + /// + /// Thrown when the context does not contain the specified key + /// + public object Get(string key) + { + return this._data[key]; + } - /// - /// Return a count of all values. - /// - public int Count => this._data.Count; + /// + /// Return a count of all values. + /// + public int Count => this._data.Count; - /// - /// Return an enumerator for all values. - /// - /// An enumerator for all values - public IEnumerator> GetEnumerator() - { - return this._data.GetEnumerator(); - } + /// + /// Return an enumerator for all values. + /// + /// An enumerator for all values + public IEnumerator> GetEnumerator() + { + return this._data.GetEnumerator(); + } - /// - /// Return a list containing all the keys in the hook data - /// - public IImmutableList Keys => this._data.Keys.ToImmutableList(); + /// + /// Return a list containing all the keys in the hook data + /// + public IImmutableList Keys => this._data.Keys.ToImmutableList(); - /// - /// Return an enumerable containing all the values of the hook data - /// - public IImmutableList Values => this._data.Values.ToImmutableList(); + /// + /// Return an enumerable containing all the values of the hook data + /// + public IImmutableList Values => this._data.Values.ToImmutableList(); - /// - /// Gets all values as a read only dictionary. - /// - /// The dictionary references the original values and is not a thread-safe copy. - /// - /// - /// A representation of the hook data - public IReadOnlyDictionary AsDictionary() - { - return this._data; - } + /// + /// Gets all values as a read only dictionary. + /// + /// The dictionary references the original values and is not a thread-safe copy. + /// + /// + /// A representation of the hook data + public IReadOnlyDictionary AsDictionary() + { + return this._data; + } - /// - /// Gets or sets the value associated with the specified key. - /// - /// The key of the value to get or set - /// The value associated with the specified key - /// - /// Thrown when getting a value and the context does not contain the specified key - /// - public object this[string key] - { - get => this.Get(key); - set => this.Set(key, value); - } + /// + /// Gets or sets the value associated with the specified key. + /// + /// The key of the value to get or set + /// The value associated with the specified key + /// + /// Thrown when getting a value and the context does not contain the specified key + /// + public object this[string key] + { + get => this.Get(key); + set => this.Set(key, value); } } diff --git a/src/OpenFeature/HookRunner.cs b/src/OpenFeature/HookRunner.cs index 8c1dbb510..c80b86131 100644 --- a/src/OpenFeature/HookRunner.cs +++ b/src/OpenFeature/HookRunner.cs @@ -6,168 +6,167 @@ using Microsoft.Extensions.Logging; using OpenFeature.Model; -namespace OpenFeature +namespace OpenFeature; + +/// +/// This class manages the execution of hooks. +/// +/// type of the evaluation detail provided to the hooks +internal partial class HookRunner { + private readonly ImmutableList _hooks; + + private readonly List> _hookContexts; + + private EvaluationContext _evaluationContext; + + private readonly ILogger _logger; + /// - /// This class manages the execution of hooks. + /// Construct a hook runner instance. Each instance should be used for the execution of a single evaluation. /// - /// type of the evaluation detail provided to the hooks - internal partial class HookRunner + /// + /// The hooks for the evaluation, these should be in the correct order for the before evaluation stage + /// + /// + /// The initial evaluation context, this can be updated as the hooks execute + /// + /// + /// Contents of the initial hook context excluding the evaluation context and hook data + /// + /// Client logger instance + public HookRunner(ImmutableList hooks, EvaluationContext evaluationContext, + SharedHookContext sharedHookContext, + ILogger logger) { - private readonly ImmutableList _hooks; - - private readonly List> _hookContexts; - - private EvaluationContext _evaluationContext; - - private readonly ILogger _logger; - - /// - /// Construct a hook runner instance. Each instance should be used for the execution of a single evaluation. - /// - /// - /// The hooks for the evaluation, these should be in the correct order for the before evaluation stage - /// - /// - /// The initial evaluation context, this can be updated as the hooks execute - /// - /// - /// Contents of the initial hook context excluding the evaluation context and hook data - /// - /// Client logger instance - public HookRunner(ImmutableList hooks, EvaluationContext evaluationContext, - SharedHookContext sharedHookContext, - ILogger logger) + this._evaluationContext = evaluationContext; + this._logger = logger; + this._hooks = hooks; + this._hookContexts = new List>(hooks.Count); + for (var i = 0; i < hooks.Count; i++) { - this._evaluationContext = evaluationContext; - this._logger = logger; - this._hooks = hooks; - this._hookContexts = new List>(hooks.Count); - for (var i = 0; i < hooks.Count; i++) - { - // Create hook instance specific hook context. - // Hook contexts are instance specific so that the mutable hook data is scoped to each hook. - this._hookContexts.Add(sharedHookContext.ToHookContext(evaluationContext)); - } + // Create hook instance specific hook context. + // Hook contexts are instance specific so that the mutable hook data is scoped to each hook. + this._hookContexts.Add(sharedHookContext.ToHookContext(evaluationContext)); } + } + + /// + /// Execute before hooks. + /// + /// Optional hook hints + /// Cancellation token which can cancel hook operations + /// Context with any modifications from the before hooks + public async Task TriggerBeforeHooksAsync(IImmutableDictionary? hints, + CancellationToken cancellationToken = default) + { + var evalContextBuilder = EvaluationContext.Builder(); + evalContextBuilder.Merge(this._evaluationContext); - /// - /// Execute before hooks. - /// - /// Optional hook hints - /// Cancellation token which can cancel hook operations - /// Context with any modifications from the before hooks - public async Task TriggerBeforeHooksAsync(IImmutableDictionary? hints, - CancellationToken cancellationToken = default) + for (var i = 0; i < this._hooks.Count; i++) { - var evalContextBuilder = EvaluationContext.Builder(); - evalContextBuilder.Merge(this._evaluationContext); + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; - for (var i = 0; i < this._hooks.Count; i++) + var resp = await hook.BeforeAsync(hookContext, hints, cancellationToken) + .ConfigureAwait(false); + if (resp != null) { - var hook = this._hooks[i]; - var hookContext = this._hookContexts[i]; - - var resp = await hook.BeforeAsync(hookContext, hints, cancellationToken) - .ConfigureAwait(false); - if (resp != null) - { - evalContextBuilder.Merge(resp); - this._evaluationContext = evalContextBuilder.Build(); - for (var j = 0; j < this._hookContexts.Count; j++) - { - this._hookContexts[j] = this._hookContexts[j].WithNewEvaluationContext(this._evaluationContext); - } - } - else + evalContextBuilder.Merge(resp); + this._evaluationContext = evalContextBuilder.Build(); + for (var j = 0; j < this._hookContexts.Count; j++) { - this.HookReturnedNull(hook.GetType().Name); + this._hookContexts[j] = this._hookContexts[j].WithNewEvaluationContext(this._evaluationContext); } } + else + { + this.HookReturnedNull(hook.GetType().Name); + } + } - return this._evaluationContext; + return this._evaluationContext; + } + + /// + /// Execute the after hooks. These are executed in opposite order of the before hooks. + /// + /// The evaluation details which will be provided to the hook + /// Optional hook hints + /// Cancellation token which can cancel hook operations + public async Task TriggerAfterHooksAsync(FlagEvaluationDetails evaluationDetails, + IImmutableDictionary? hints, + CancellationToken cancellationToken = default) + { + // After hooks run in reverse. + for (var i = this._hooks.Count - 1; i >= 0; i--) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + await hook.AfterAsync(hookContext, evaluationDetails, hints, cancellationToken) + .ConfigureAwait(false); } + } - /// - /// Execute the after hooks. These are executed in opposite order of the before hooks. - /// - /// The evaluation details which will be provided to the hook - /// Optional hook hints - /// Cancellation token which can cancel hook operations - public async Task TriggerAfterHooksAsync(FlagEvaluationDetails evaluationDetails, - IImmutableDictionary? hints, - CancellationToken cancellationToken = default) + /// + /// Execute the error hooks. These are executed in opposite order of the before hooks. + /// + /// Exception which triggered the error + /// Optional hook hints + /// Cancellation token which can cancel hook operations + public async Task TriggerErrorHooksAsync(Exception exception, + IImmutableDictionary? hints, CancellationToken cancellationToken = default) + { + // Error hooks run in reverse. + for (var i = this._hooks.Count - 1; i >= 0; i--) { - // After hooks run in reverse. - for (var i = this._hooks.Count - 1; i >= 0; i--) + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + try { - var hook = this._hooks[i]; - var hookContext = this._hookContexts[i]; - await hook.AfterAsync(hookContext, evaluationDetails, hints, cancellationToken) + await hook.ErrorAsync(hookContext, exception, hints, cancellationToken) .ConfigureAwait(false); } - } - - /// - /// Execute the error hooks. These are executed in opposite order of the before hooks. - /// - /// Exception which triggered the error - /// Optional hook hints - /// Cancellation token which can cancel hook operations - public async Task TriggerErrorHooksAsync(Exception exception, - IImmutableDictionary? hints, CancellationToken cancellationToken = default) - { - // Error hooks run in reverse. - for (var i = this._hooks.Count - 1; i >= 0; i--) + catch (Exception e) { - var hook = this._hooks[i]; - var hookContext = this._hookContexts[i]; - try - { - await hook.ErrorAsync(hookContext, exception, hints, cancellationToken) - .ConfigureAwait(false); - } - catch (Exception e) - { - this.ErrorHookError(hook.GetType().Name, e); - } + this.ErrorHookError(hook.GetType().Name, e); } } + } - /// - /// Execute the finally hooks. These are executed in opposite order of the before hooks. - /// - /// The evaluation details which will be provided to the hook - /// Optional hook hints - /// Cancellation token which can cancel hook operations - public async Task TriggerFinallyHooksAsync(FlagEvaluationDetails evaluationDetails, - IImmutableDictionary? hints, - CancellationToken cancellationToken = default) + /// + /// Execute the finally hooks. These are executed in opposite order of the before hooks. + /// + /// The evaluation details which will be provided to the hook + /// Optional hook hints + /// Cancellation token which can cancel hook operations + public async Task TriggerFinallyHooksAsync(FlagEvaluationDetails evaluationDetails, + IImmutableDictionary? hints, + CancellationToken cancellationToken = default) + { + // Finally hooks run in reverse + for (var i = this._hooks.Count - 1; i >= 0; i--) { - // Finally hooks run in reverse - for (var i = this._hooks.Count - 1; i >= 0; i--) + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + try { - var hook = this._hooks[i]; - var hookContext = this._hookContexts[i]; - try - { - await hook.FinallyAsync(hookContext, evaluationDetails, hints, cancellationToken) - .ConfigureAwait(false); - } - catch (Exception e) - { - this.FinallyHookError(hook.GetType().Name, e); - } + await hook.FinallyAsync(hookContext, evaluationDetails, hints, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) + { + this.FinallyHookError(hook.GetType().Name, e); } } + } - [LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")] - partial void HookReturnedNull(string hookName); + [LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")] + partial void HookReturnedNull(string hookName); - [LoggerMessage(103, LogLevel.Error, "Error while executing Error hook {HookName}")] - partial void ErrorHookError(string hookName, Exception exception); + [LoggerMessage(103, LogLevel.Error, "Error while executing Error hook {HookName}")] + partial void ErrorHookError(string hookName, Exception exception); - [LoggerMessage(104, LogLevel.Error, "Error while executing Finally hook {HookName}")] - partial void FinallyHookError(string hookName, Exception exception); - } + [LoggerMessage(104, LogLevel.Error, "Error while executing Finally hook {HookName}")] + partial void FinallyHookError(string hookName, Exception exception); } diff --git a/src/OpenFeature/Hooks/LoggingHook.cs b/src/OpenFeature/Hooks/LoggingHook.cs index a8d318b00..b83081678 100644 --- a/src/OpenFeature/Hooks/LoggingHook.cs +++ b/src/OpenFeature/Hooks/LoggingHook.cs @@ -6,169 +6,168 @@ using Microsoft.Extensions.Logging; using OpenFeature.Model; -namespace OpenFeature.Hooks +namespace OpenFeature.Hooks; + +/// +/// The logging hook is a hook which logs messages during the flag evaluation life-cycle. +/// +public sealed partial class LoggingHook : Hook { + private readonly ILogger _logger; + private readonly bool _includeContext; + /// - /// The logging hook is a hook which logs messages during the flag evaluation life-cycle. + /// Initialise a with a and optional Evaluation Context. will + /// include properties in the to the generated logs. /// - public sealed partial class LoggingHook : Hook + public LoggingHook(ILogger logger, bool includeContext = false) { - private readonly ILogger _logger; - private readonly bool _includeContext; - - /// - /// Initialise a with a and optional Evaluation Context. will - /// include properties in the to the generated logs. - /// - public LoggingHook(ILogger logger, bool includeContext = false) - { - this._logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this._includeContext = includeContext; - } + this._logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this._includeContext = includeContext; + } - /// - public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) - { - var evaluationContext = this._includeContext ? context.EvaluationContext : null; + /// + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var evaluationContext = this._includeContext ? context.EvaluationContext : null; - var content = new LoggingHookContent( - context.ClientMetadata.Name, - context.ProviderMetadata.Name, - context.FlagKey, - context.DefaultValue?.ToString(), - evaluationContext); + var content = new LoggingHookContent( + context.ClientMetadata.Name, + context.ProviderMetadata.Name, + context.FlagKey, + context.DefaultValue?.ToString(), + evaluationContext); - this.HookBeforeStageExecuted(content); + this.HookBeforeStageExecuted(content); - return base.BeforeAsync(context, hints, cancellationToken); - } + return base.BeforeAsync(context, hints, cancellationToken); + } - /// - public override ValueTask ErrorAsync(HookContext context, Exception error, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) - { - var evaluationContext = this._includeContext ? context.EvaluationContext : null; + /// + public override ValueTask ErrorAsync(HookContext context, Exception error, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var evaluationContext = this._includeContext ? context.EvaluationContext : null; - var content = new LoggingHookContent( - context.ClientMetadata.Name, - context.ProviderMetadata.Name, - context.FlagKey, - context.DefaultValue?.ToString(), - evaluationContext); + var content = new LoggingHookContent( + context.ClientMetadata.Name, + context.ProviderMetadata.Name, + context.FlagKey, + context.DefaultValue?.ToString(), + evaluationContext); - this.HookErrorStageExecuted(content); + this.HookErrorStageExecuted(content); - return base.ErrorAsync(context, error, hints, cancellationToken); - } + return base.ErrorAsync(context, error, hints, cancellationToken); + } - /// - public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) - { - var evaluationContext = this._includeContext ? context.EvaluationContext : null; + /// + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var evaluationContext = this._includeContext ? context.EvaluationContext : null; - var content = new LoggingHookContent( - context.ClientMetadata.Name, - context.ProviderMetadata.Name, - context.FlagKey, - context.DefaultValue?.ToString(), - evaluationContext); + var content = new LoggingHookContent( + context.ClientMetadata.Name, + context.ProviderMetadata.Name, + context.FlagKey, + context.DefaultValue?.ToString(), + evaluationContext); - this.HookAfterStageExecuted(content); + this.HookAfterStageExecuted(content); - return base.AfterAsync(context, details, hints, cancellationToken); - } + return base.AfterAsync(context, details, hints, cancellationToken); + } - [LoggerMessage( - Level = LogLevel.Debug, - Message = "Before Flag Evaluation {Content}")] - partial void HookBeforeStageExecuted(LoggingHookContent content); - - [LoggerMessage( - Level = LogLevel.Error, - Message = "Error during Flag Evaluation {Content}")] - partial void HookErrorStageExecuted(LoggingHookContent content); - - [LoggerMessage( - Level = LogLevel.Debug, - Message = "After Flag Evaluation {Content}")] - partial void HookAfterStageExecuted(LoggingHookContent content); - - /// - /// Generates a log string with contents provided by the . - /// - /// Specification for log contents found at https://github.com/open-feature/spec/blob/d261f68331b94fd8ed10bc72bc0485cfc72a51a8/specification/appendix-a-included-utilities.md#logging-hook - /// - /// - internal class LoggingHookContent - { - private readonly string _domain; - private readonly string _providerName; - private readonly string _flagKey; - private readonly string _defaultValue; - private readonly EvaluationContext? _evaluationContext; + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Before Flag Evaluation {Content}")] + partial void HookBeforeStageExecuted(LoggingHookContent content); - public LoggingHookContent(string? domain, string? providerName, string flagKey, string? defaultValue, EvaluationContext? evaluationContext = null) - { - this._domain = string.IsNullOrEmpty(domain) ? "missing" : domain!; - this._providerName = string.IsNullOrEmpty(providerName) ? "missing" : providerName!; - this._flagKey = flagKey; - this._defaultValue = string.IsNullOrEmpty(defaultValue) ? "missing" : defaultValue!; - this._evaluationContext = evaluationContext; - } + [LoggerMessage( + Level = LogLevel.Error, + Message = "Error during Flag Evaluation {Content}")] + partial void HookErrorStageExecuted(LoggingHookContent content); - public override string ToString() - { - var stringBuilder = new StringBuilder(); + [LoggerMessage( + Level = LogLevel.Debug, + Message = "After Flag Evaluation {Content}")] + partial void HookAfterStageExecuted(LoggingHookContent content); - stringBuilder.Append("Domain:"); - stringBuilder.AppendLine(this._domain); + /// + /// Generates a log string with contents provided by the . + /// + /// Specification for log contents found at https://github.com/open-feature/spec/blob/d261f68331b94fd8ed10bc72bc0485cfc72a51a8/specification/appendix-a-included-utilities.md#logging-hook + /// + /// + internal class LoggingHookContent + { + private readonly string _domain; + private readonly string _providerName; + private readonly string _flagKey; + private readonly string _defaultValue; + private readonly EvaluationContext? _evaluationContext; + + public LoggingHookContent(string? domain, string? providerName, string flagKey, string? defaultValue, EvaluationContext? evaluationContext = null) + { + this._domain = string.IsNullOrEmpty(domain) ? "missing" : domain!; + this._providerName = string.IsNullOrEmpty(providerName) ? "missing" : providerName!; + this._flagKey = flagKey; + this._defaultValue = string.IsNullOrEmpty(defaultValue) ? "missing" : defaultValue!; + this._evaluationContext = evaluationContext; + } - stringBuilder.Append("ProviderName:"); - stringBuilder.AppendLine(this._providerName); + public override string ToString() + { + var stringBuilder = new StringBuilder(); - stringBuilder.Append("FlagKey:"); - stringBuilder.AppendLine(this._flagKey); + stringBuilder.Append("Domain:"); + stringBuilder.AppendLine(this._domain); - stringBuilder.Append("DefaultValue:"); - stringBuilder.AppendLine(this._defaultValue); + stringBuilder.Append("ProviderName:"); + stringBuilder.AppendLine(this._providerName); - if (this._evaluationContext != null) - { - stringBuilder.AppendLine("Context:"); - foreach (var kvp in this._evaluationContext.AsDictionary()) - { - stringBuilder.Append('\t'); - stringBuilder.Append(kvp.Key); - stringBuilder.Append(':'); - stringBuilder.AppendLine(GetValueString(kvp.Value)); - } - } + stringBuilder.Append("FlagKey:"); + stringBuilder.AppendLine(this._flagKey); - return stringBuilder.ToString(); - } + stringBuilder.Append("DefaultValue:"); + stringBuilder.AppendLine(this._defaultValue); - static string? GetValueString(Value value) + if (this._evaluationContext != null) { - if (value.IsNull) - return string.Empty; + stringBuilder.AppendLine("Context:"); + foreach (var kvp in this._evaluationContext.AsDictionary()) + { + stringBuilder.Append('\t'); + stringBuilder.Append(kvp.Key); + stringBuilder.Append(':'); + stringBuilder.AppendLine(GetValueString(kvp.Value)); + } + } - if (value.IsString) - return value.AsString; + return stringBuilder.ToString(); + } - if (value.IsBoolean) - return value.AsBoolean.ToString(); + static string? GetValueString(Value value) + { + if (value.IsNull) + return string.Empty; - if (value.IsNumber) - { - // Value.AsDouble will attempt to cast other numbers to double - // There is an implicit conversation for int/long to double - if (value.AsDouble != null) return value.AsDouble.ToString(); - } + if (value.IsString) + return value.AsString; - if (value.IsDateTime) - return value.AsDateTime?.ToString("O"); + if (value.IsBoolean) + return value.AsBoolean.ToString(); - return value.ToString(); + if (value.IsNumber) + { + // Value.AsDouble will attempt to cast other numbers to double + // There is an implicit conversation for int/long to double + if (value.AsDouble != null) return value.AsDouble.ToString(); } + + if (value.IsDateTime) + return value.AsDateTime?.ToString("O"); + + return value.ToString(); } } } diff --git a/src/OpenFeature/IEventBus.cs b/src/OpenFeature/IEventBus.cs index 114b66b3d..bb1cd91e2 100644 --- a/src/OpenFeature/IEventBus.cs +++ b/src/OpenFeature/IEventBus.cs @@ -1,24 +1,23 @@ using OpenFeature.Constant; using OpenFeature.Model; -namespace OpenFeature +namespace OpenFeature; + +/// +/// Defines the methods required for handling events. +/// +public interface IEventBus { /// - /// Defines the methods required for handling events. + /// Adds an Event Handler for the given event type. + /// + /// The type of the event + /// Implementation of the + void AddHandler(ProviderEventTypes type, EventHandlerDelegate handler); + /// + /// Removes an Event Handler for the given event type. /// - public interface IEventBus - { - /// - /// Adds an Event Handler for the given event type. - /// - /// The type of the event - /// Implementation of the - void AddHandler(ProviderEventTypes type, EventHandlerDelegate handler); - /// - /// Removes an Event Handler for the given event type. - /// - /// The type of the event - /// Implementation of the - void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler); - } + /// The type of the event + /// Implementation of the + void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler); } diff --git a/src/OpenFeature/IFeatureClient.cs b/src/OpenFeature/IFeatureClient.cs index f39b7f527..c14e6e4bf 100644 --- a/src/OpenFeature/IFeatureClient.cs +++ b/src/OpenFeature/IFeatureClient.cs @@ -4,170 +4,169 @@ using OpenFeature.Constant; using OpenFeature.Model; -namespace OpenFeature +namespace OpenFeature; + +/// +/// Interface used to resolve flags of varying types. +/// +public interface IFeatureClient : IEventBus { /// - /// Interface used to resolve flags of varying types. - /// - public interface IFeatureClient : IEventBus - { - /// - /// Appends hooks to client - /// - /// The appending operation will be atomic. - /// - /// - /// A list of Hooks that implement the interface - void AddHooks(IEnumerable hooks); - - /// - /// Enumerates the global hooks. - /// - /// The items enumerated will reflect the registered hooks - /// at the start of enumeration. Hooks added during enumeration - /// will not be included. - /// - /// - /// Enumeration of - IEnumerable GetHooks(); - - /// - /// Gets the of this client - /// - /// The evaluation context may be set from multiple threads, when accessing the client evaluation context - /// it should be accessed once for an operation, and then that reference should be used for all dependent - /// operations. - /// - /// - /// of this client - EvaluationContext GetContext(); - - /// - /// Sets the of the client - /// - /// The to set - void SetContext(EvaluationContext context); - - /// - /// Gets client metadata - /// - /// Client metadata - ClientMetadata GetMetadata(); - - /// - /// Returns the current status of the associated provider. - /// - /// - ProviderStatus ProviderStatus { get; } + /// Appends hooks to client + /// + /// The appending operation will be atomic. + /// + /// + /// A list of Hooks that implement the interface + void AddHooks(IEnumerable hooks); + + /// + /// Enumerates the global hooks. + /// + /// The items enumerated will reflect the registered hooks + /// at the start of enumeration. Hooks added during enumeration + /// will not be included. + /// + /// + /// Enumeration of + IEnumerable GetHooks(); + + /// + /// Gets the of this client + /// + /// The evaluation context may be set from multiple threads, when accessing the client evaluation context + /// it should be accessed once for an operation, and then that reference should be used for all dependent + /// operations. + /// + /// + /// of this client + EvaluationContext GetContext(); + + /// + /// Sets the of the client + /// + /// The to set + void SetContext(EvaluationContext context); + + /// + /// Gets client metadata + /// + /// Client metadata + ClientMetadata GetMetadata(); + + /// + /// Returns the current status of the associated provider. + /// + /// + ProviderStatus ProviderStatus { get; } - /// - /// Resolves a boolean feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// The . - /// Resolved flag value. - Task GetBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - - /// - /// Resolves a boolean feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// The . - /// Resolved flag details - Task> GetBooleanDetailsAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - - /// - /// Resolves a string feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// The . - /// Resolved flag value. - Task GetStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - - /// - /// Resolves a string feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// The . - /// Resolved flag details - Task> GetStringDetailsAsync(string flagKey, string defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - - /// - /// Resolves a integer feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// The . - /// Resolved flag value. - Task GetIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - - /// - /// Resolves a integer feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// The . - /// Resolved flag details - Task> GetIntegerDetailsAsync(string flagKey, int defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - - /// - /// Resolves a double feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// The . - /// Resolved flag value. - Task GetDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - - /// - /// Resolves a double feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// The . - /// Resolved flag details - Task> GetDoubleDetailsAsync(string flagKey, double defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - - /// - /// Resolves a structure object feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// The . - /// Resolved flag value. - Task GetObjectValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - - /// - /// Resolves a structure object feature flag - /// - /// Feature flag key - /// Default value - /// Evaluation Context - /// Flag Evaluation Options - /// The . - /// Resolved flag details - Task> GetObjectDetailsAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); - } + /// + /// Resolves a boolean feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag value. + Task GetBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a boolean feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag details + Task> GetBooleanDetailsAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a string feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag value. + Task GetStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a string feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag details + Task> GetStringDetailsAsync(string flagKey, string defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a integer feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag value. + Task GetIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a integer feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag details + Task> GetIntegerDetailsAsync(string flagKey, int defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a double feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag value. + Task GetDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a double feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag details + Task> GetDoubleDetailsAsync(string flagKey, double defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a structure object feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag value. + Task GetObjectValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Resolves a structure object feature flag + /// + /// Feature flag key + /// Default value + /// Evaluation Context + /// Flag Evaluation Options + /// The . + /// Resolved flag details + Task> GetObjectDetailsAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); } diff --git a/src/OpenFeature/Model/ClientMetadata.cs b/src/OpenFeature/Model/ClientMetadata.cs index b98e6e9da..ffdc4eebe 100644 --- a/src/OpenFeature/Model/ClientMetadata.cs +++ b/src/OpenFeature/Model/ClientMetadata.cs @@ -1,23 +1,22 @@ -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// Represents the client metadata +/// +public sealed class ClientMetadata : Metadata { /// - /// Represents the client metadata + /// Version of the client /// - public sealed class ClientMetadata : Metadata - { - /// - /// Version of the client - /// - public string? Version { get; } + public string? Version { get; } - /// - /// Initializes a new instance of the class - /// - /// Name of client - /// Version of client - public ClientMetadata(string? name, string? version) : base(name) - { - this.Version = version; - } + /// + /// Initializes a new instance of the class + /// + /// Name of client + /// Version of client + public ClientMetadata(string? name, string? version) : base(name) + { + this.Version = version; } } diff --git a/src/OpenFeature/Model/EvaluationContext.cs b/src/OpenFeature/Model/EvaluationContext.cs index 304e4cd9d..ed4f989a8 100644 --- a/src/OpenFeature/Model/EvaluationContext.cs +++ b/src/OpenFeature/Model/EvaluationContext.cs @@ -2,122 +2,121 @@ using System.Collections.Generic; using System.Collections.Immutable; -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// A KeyValuePair with a string key and object value that is used to apply user defined properties +/// to the feature flag evaluation context. +/// +/// Evaluation context +public sealed class EvaluationContext { /// - /// A KeyValuePair with a string key and object value that is used to apply user defined properties - /// to the feature flag evaluation context. + /// The index for the "targeting key" property when the EvaluationContext is serialized or expressed as a dictionary. + /// + internal const string TargetingKeyIndex = "targetingKey"; + + + private readonly Structure _structure; + + /// + /// Internal constructor used by the builder. /// - /// Evaluation context - public sealed class EvaluationContext + /// + internal EvaluationContext(Structure content) { - /// - /// The index for the "targeting key" property when the EvaluationContext is serialized or expressed as a dictionary. - /// - internal const string TargetingKeyIndex = "targetingKey"; + this._structure = content; + } - private readonly Structure _structure; + /// + /// Private constructor for making an empty . + /// + private EvaluationContext() + { + this._structure = Structure.Empty; + } - /// - /// Internal constructor used by the builder. - /// - /// - internal EvaluationContext(Structure content) - { - this._structure = content; - } + /// + /// An empty evaluation context. + /// + public static EvaluationContext Empty { get; } = new EvaluationContext(); + /// + /// Gets the Value at the specified key + /// + /// The key of the value to be retrieved + /// The associated with the key + /// + /// Thrown when the context does not contain the specified key + /// + /// + /// Thrown when the key is + /// + public Value GetValue(string key) => this._structure.GetValue(key); - /// - /// Private constructor for making an empty . - /// - private EvaluationContext() - { - this._structure = Structure.Empty; - } + /// + /// Bool indicating if the specified key exists in the evaluation context + /// + /// The key of the value to be checked + /// indicating the presence of the key + /// + /// Thrown when the key is + /// + public bool ContainsKey(string key) => this._structure.ContainsKey(key); - /// - /// An empty evaluation context. - /// - public static EvaluationContext Empty { get; } = new EvaluationContext(); - - /// - /// Gets the Value at the specified key - /// - /// The key of the value to be retrieved - /// The associated with the key - /// - /// Thrown when the context does not contain the specified key - /// - /// - /// Thrown when the key is - /// - public Value GetValue(string key) => this._structure.GetValue(key); - - /// - /// Bool indicating if the specified key exists in the evaluation context - /// - /// The key of the value to be checked - /// indicating the presence of the key - /// - /// Thrown when the key is - /// - public bool ContainsKey(string key) => this._structure.ContainsKey(key); - - /// - /// Gets the value associated with the specified key - /// - /// The or if the key was not present - /// The key of the value to be retrieved - /// indicating the presence of the key - /// - /// Thrown when the key is - /// - public bool TryGetValue(string key, out Value? value) => this._structure.TryGetValue(key, out value); - - /// - /// Gets all values as a Dictionary - /// - /// New representation of this Structure - public IImmutableDictionary AsDictionary() - { - return this._structure.AsDictionary(); - } + /// + /// Gets the value associated with the specified key + /// + /// The or if the key was not present + /// The key of the value to be retrieved + /// indicating the presence of the key + /// + /// Thrown when the key is + /// + public bool TryGetValue(string key, out Value? value) => this._structure.TryGetValue(key, out value); - /// - /// Return a count of all values - /// - public int Count => this._structure.Count; + /// + /// Gets all values as a Dictionary + /// + /// New representation of this Structure + public IImmutableDictionary AsDictionary() + { + return this._structure.AsDictionary(); + } - /// - /// Returns the targeting key for the context. - /// - public string? TargetingKey - { - get - { - this._structure.TryGetValue(TargetingKeyIndex, out Value? targetingKey); - return targetingKey?.AsString; - } - } + /// + /// Return a count of all values + /// + public int Count => this._structure.Count; - /// - /// Return an enumerator for all values - /// - /// An enumerator for all values - public IEnumerator> GetEnumerator() + /// + /// Returns the targeting key for the context. + /// + public string? TargetingKey + { + get { - return this._structure.GetEnumerator(); + this._structure.TryGetValue(TargetingKeyIndex, out Value? targetingKey); + return targetingKey?.AsString; } + } - /// - /// Get a builder which can build an . - /// - /// The builder - public static EvaluationContextBuilder Builder() - { - return new EvaluationContextBuilder(); - } + /// + /// Return an enumerator for all values + /// + /// An enumerator for all values + public IEnumerator> GetEnumerator() + { + return this._structure.GetEnumerator(); + } + + /// + /// Get a builder which can build an . + /// + /// The builder + public static EvaluationContextBuilder Builder() + { + return new EvaluationContextBuilder(); } } diff --git a/src/OpenFeature/Model/EvaluationContextBuilder.cs b/src/OpenFeature/Model/EvaluationContextBuilder.cs index 30e2ffe02..3d85ba984 100644 --- a/src/OpenFeature/Model/EvaluationContextBuilder.cs +++ b/src/OpenFeature/Model/EvaluationContextBuilder.cs @@ -1,156 +1,155 @@ using System; -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// A builder which allows the specification of attributes for an . +/// +/// A object is intended for use by a single thread and should not be used +/// from multiple threads. Once an has been created it is immutable and safe for use +/// from multiple threads. +/// +/// +public sealed class EvaluationContextBuilder { + private readonly StructureBuilder _attributes = Structure.Builder(); + /// - /// A builder which allows the specification of attributes for an . - /// - /// A object is intended for use by a single thread and should not be used - /// from multiple threads. Once an has been created it is immutable and safe for use - /// from multiple threads. - /// + /// Internal to only allow direct creation by . /// - public sealed class EvaluationContextBuilder - { - private readonly StructureBuilder _attributes = Structure.Builder(); + internal EvaluationContextBuilder() { } - /// - /// Internal to only allow direct creation by . - /// - internal EvaluationContextBuilder() { } + /// + /// Set the targeting key for the context. + /// + /// The targeting key + /// This builder + public EvaluationContextBuilder SetTargetingKey(string targetingKey) + { + this._attributes.Set(EvaluationContext.TargetingKeyIndex, targetingKey); + return this; + } - /// - /// Set the targeting key for the context. - /// - /// The targeting key - /// This builder - public EvaluationContextBuilder SetTargetingKey(string targetingKey) - { - this._attributes.Set(EvaluationContext.TargetingKeyIndex, targetingKey); - return this; - } + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, Value value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given . - /// - /// The key for the value - /// The value to set - /// This builder - public EvaluationContextBuilder Set(string key, Value value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given string. + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, string value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given string. - /// - /// The key for the value - /// The value to set - /// This builder - public EvaluationContextBuilder Set(string key, string value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given int. + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, int value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given int. - /// - /// The key for the value - /// The value to set - /// This builder - public EvaluationContextBuilder Set(string key, int value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given double. + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, double value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given double. - /// - /// The key for the value - /// The value to set - /// This builder - public EvaluationContextBuilder Set(string key, double value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given long. + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, long value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given long. - /// - /// The key for the value - /// The value to set - /// This builder - public EvaluationContextBuilder Set(string key, long value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given bool. + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, bool value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given bool. - /// - /// The key for the value - /// The value to set - /// This builder - public EvaluationContextBuilder Set(string key, bool value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, Structure value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given . - /// - /// The key for the value - /// The value to set - /// This builder - public EvaluationContextBuilder Set(string key, Structure value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given DateTime. + /// + /// The key for the value + /// The value to set + /// This builder + public EvaluationContextBuilder Set(string key, DateTime value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given DateTime. - /// - /// The key for the value - /// The value to set - /// This builder - public EvaluationContextBuilder Set(string key, DateTime value) + /// + /// Incorporate an existing context into the builder. + /// + /// Any existing keys in the builder will be replaced by keys in the context. + /// + /// + /// The context to add merge + /// This builder + public EvaluationContextBuilder Merge(EvaluationContext context) + { + foreach (var kvp in context) { - this._attributes.Set(key, value); - return this; + this.Set(kvp.Key, kvp.Value); } - /// - /// Incorporate an existing context into the builder. - /// - /// Any existing keys in the builder will be replaced by keys in the context. - /// - /// - /// The context to add merge - /// This builder - public EvaluationContextBuilder Merge(EvaluationContext context) - { - foreach (var kvp in context) - { - this.Set(kvp.Key, kvp.Value); - } - - return this; - } + return this; + } - /// - /// Build an immutable . - /// - /// An immutable - public EvaluationContext Build() - { - return new EvaluationContext(this._attributes.Build()); - } + /// + /// Build an immutable . + /// + /// An immutable + public EvaluationContext Build() + { + return new EvaluationContext(this._attributes.Build()); } } diff --git a/src/OpenFeature/Model/FlagEvaluationDetails.cs b/src/OpenFeature/Model/FlagEvaluationDetails.cs index 11283b4f6..a08e2041e 100644 --- a/src/OpenFeature/Model/FlagEvaluationDetails.cs +++ b/src/OpenFeature/Model/FlagEvaluationDetails.cs @@ -1,75 +1,74 @@ using OpenFeature.Constant; -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// The contract returned to the caller that describes the result of the flag evaluation process. +/// +/// Flag value type +/// +public sealed class FlagEvaluationDetails { /// - /// The contract returned to the caller that describes the result of the flag evaluation process. + /// Feature flag evaluated value /// - /// Flag value type - /// - public sealed class FlagEvaluationDetails - { - /// - /// Feature flag evaluated value - /// - public T Value { get; } + public T Value { get; } - /// - /// Feature flag key - /// - public string FlagKey { get; } + /// + /// Feature flag key + /// + public string FlagKey { get; } - /// - /// Error that occurred during evaluation - /// - public ErrorType ErrorType { get; } + /// + /// Error that occurred during evaluation + /// + public ErrorType ErrorType { get; } - /// - /// Message containing additional details about an error. - /// - /// Will be if there is no error or if the provider didn't provide any additional error - /// details. - /// - /// - public string? ErrorMessage { get; } + /// + /// Message containing additional details about an error. + /// + /// Will be if there is no error or if the provider didn't provide any additional error + /// details. + /// + /// + public string? ErrorMessage { get; } - /// - /// Describes the reason for the outcome of the evaluation process - /// - public string? Reason { get; } + /// + /// Describes the reason for the outcome of the evaluation process + /// + public string? Reason { get; } - /// - /// A variant is a semantic identifier for a value. This allows for referral to particular values without - /// necessarily including the value itself, which may be quite prohibitively large or otherwise unsuitable - /// in some cases. - /// - public string? Variant { get; } + /// + /// A variant is a semantic identifier for a value. This allows for referral to particular values without + /// necessarily including the value itself, which may be quite prohibitively large or otherwise unsuitable + /// in some cases. + /// + public string? Variant { get; } - /// - /// A structure which supports definition of arbitrary properties, with keys of type string, and values of type boolean, string, or number. - /// - public ImmutableMetadata? FlagMetadata { get; } + /// + /// A structure which supports definition of arbitrary properties, with keys of type string, and values of type boolean, string, or number. + /// + public ImmutableMetadata? FlagMetadata { get; } - /// - /// Initializes a new instance of the class. - /// - /// Feature flag key - /// Evaluated value - /// Error - /// Reason - /// Variant - /// Error message - /// Flag metadata - public FlagEvaluationDetails(string flagKey, T value, ErrorType errorType, string? reason, string? variant, - string? errorMessage = null, ImmutableMetadata? flagMetadata = null) - { - this.Value = value; - this.FlagKey = flagKey; - this.ErrorType = errorType; - this.Reason = reason; - this.Variant = variant; - this.ErrorMessage = errorMessage; - this.FlagMetadata = flagMetadata; - } - } + /// + /// Initializes a new instance of the class. + /// + /// Feature flag key + /// Evaluated value + /// Error + /// Reason + /// Variant + /// Error message + /// Flag metadata + public FlagEvaluationDetails(string flagKey, T value, ErrorType errorType, string? reason, string? variant, + string? errorMessage = null, ImmutableMetadata? flagMetadata = null) + { + this.Value = value; + this.FlagKey = flagKey; + this.ErrorType = errorType; + this.Reason = reason; + this.Variant = variant; + this.ErrorMessage = errorMessage; + this.FlagMetadata = flagMetadata; + } } diff --git a/src/OpenFeature/Model/FlagEvaluationOptions.cs b/src/OpenFeature/Model/FlagEvaluationOptions.cs index 8bba0aefa..a261a6b36 100644 --- a/src/OpenFeature/Model/FlagEvaluationOptions.cs +++ b/src/OpenFeature/Model/FlagEvaluationOptions.cs @@ -1,44 +1,43 @@ using System.Collections.Immutable; -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// A structure containing the one or more hooks and hook hints +/// The hook and hook hints are added to the list of hooks called during the evaluation process +/// +/// Flag Evaluation Options +public sealed class FlagEvaluationOptions { /// - /// A structure containing the one or more hooks and hook hints - /// The hook and hook hints are added to the list of hooks called during the evaluation process + /// An immutable list of /// - /// Flag Evaluation Options - public sealed class FlagEvaluationOptions - { - /// - /// An immutable list of - /// - public IImmutableList Hooks { get; } + public IImmutableList Hooks { get; } - /// - /// An immutable dictionary of hook hints - /// - public IImmutableDictionary HookHints { get; } + /// + /// An immutable dictionary of hook hints + /// + public IImmutableDictionary HookHints { get; } - /// - /// Initializes a new instance of the class. - /// - /// An immutable list of hooks to use during evaluation - /// Optional - a list of hints that are passed through the hook lifecycle - public FlagEvaluationOptions(IImmutableList hooks, IImmutableDictionary? hookHints = null) - { - this.Hooks = hooks; - this.HookHints = hookHints ?? ImmutableDictionary.Empty; - } + /// + /// Initializes a new instance of the class. + /// + /// An immutable list of hooks to use during evaluation + /// Optional - a list of hints that are passed through the hook lifecycle + public FlagEvaluationOptions(IImmutableList hooks, IImmutableDictionary? hookHints = null) + { + this.Hooks = hooks; + this.HookHints = hookHints ?? ImmutableDictionary.Empty; + } - /// - /// Initializes a new instance of the class. - /// - /// A hook to use during the evaluation - /// Optional - a list of hints that are passed through the hook lifecycle - public FlagEvaluationOptions(Hook hook, ImmutableDictionary? hookHints = null) - { - this.Hooks = ImmutableList.Create(hook); - this.HookHints = hookHints ?? ImmutableDictionary.Empty; - } + /// + /// Initializes a new instance of the class. + /// + /// A hook to use during the evaluation + /// Optional - a list of hints that are passed through the hook lifecycle + public FlagEvaluationOptions(Hook hook, ImmutableDictionary? hookHints = null) + { + this.Hooks = ImmutableList.Create(hook); + this.HookHints = hookHints ?? ImmutableDictionary.Empty; } } diff --git a/src/OpenFeature/Model/HookContext.cs b/src/OpenFeature/Model/HookContext.cs index 8d99a2836..4abc773cb 100644 --- a/src/OpenFeature/Model/HookContext.cs +++ b/src/OpenFeature/Model/HookContext.cs @@ -1,92 +1,91 @@ using System; using OpenFeature.Constant; -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// Context provided to hook execution +/// +/// Flag value type +/// +public sealed class HookContext { + private readonly SharedHookContext _shared; + /// - /// Context provided to hook execution + /// Feature flag being evaluated /// - /// Flag value type - /// - public sealed class HookContext - { - private readonly SharedHookContext _shared; + public string FlagKey => this._shared.FlagKey; - /// - /// Feature flag being evaluated - /// - public string FlagKey => this._shared.FlagKey; - - /// - /// Default value if flag fails to be evaluated - /// - public T DefaultValue => this._shared.DefaultValue; + /// + /// Default value if flag fails to be evaluated + /// + public T DefaultValue => this._shared.DefaultValue; - /// - /// The value type of the flag - /// - public FlagValueType FlagValueType => this._shared.FlagValueType; + /// + /// The value type of the flag + /// + public FlagValueType FlagValueType => this._shared.FlagValueType; - /// - /// User defined evaluation context used in the evaluation process - /// - /// - public EvaluationContext EvaluationContext { get; } + /// + /// User defined evaluation context used in the evaluation process + /// + /// + public EvaluationContext EvaluationContext { get; } - /// - /// Client metadata - /// - public ClientMetadata ClientMetadata => this._shared.ClientMetadata; + /// + /// Client metadata + /// + public ClientMetadata ClientMetadata => this._shared.ClientMetadata; - /// - /// Provider metadata - /// - public Metadata ProviderMetadata => this._shared.ProviderMetadata; + /// + /// Provider metadata + /// + public Metadata ProviderMetadata => this._shared.ProviderMetadata; - /// - /// Hook data - /// - public HookData Data { get; } + /// + /// Hook data + /// + public HookData Data { get; } - /// - /// Initialize a new instance of - /// - /// Feature flag key - /// Default value - /// Flag value type - /// Client metadata - /// Provider metadata - /// Evaluation context - /// When any of arguments are null - public HookContext(string? flagKey, - T defaultValue, - FlagValueType flagValueType, - ClientMetadata? clientMetadata, - Metadata? providerMetadata, - EvaluationContext? evaluationContext) - { - this._shared = new SharedHookContext( - flagKey, defaultValue, flagValueType, clientMetadata, providerMetadata); + /// + /// Initialize a new instance of + /// + /// Feature flag key + /// Default value + /// Flag value type + /// Client metadata + /// Provider metadata + /// Evaluation context + /// When any of arguments are null + public HookContext(string? flagKey, + T defaultValue, + FlagValueType flagValueType, + ClientMetadata? clientMetadata, + Metadata? providerMetadata, + EvaluationContext? evaluationContext) + { + this._shared = new SharedHookContext( + flagKey, defaultValue, flagValueType, clientMetadata, providerMetadata); - this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext)); - this.Data = new HookData(); - } + this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext)); + this.Data = new HookData(); + } - internal HookContext(SharedHookContext? sharedHookContext, EvaluationContext? evaluationContext, - HookData? hookData) - { - this._shared = sharedHookContext ?? throw new ArgumentNullException(nameof(sharedHookContext)); - this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext)); - this.Data = hookData ?? throw new ArgumentNullException(nameof(hookData)); - } + internal HookContext(SharedHookContext? sharedHookContext, EvaluationContext? evaluationContext, + HookData? hookData) + { + this._shared = sharedHookContext ?? throw new ArgumentNullException(nameof(sharedHookContext)); + this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext)); + this.Data = hookData ?? throw new ArgumentNullException(nameof(hookData)); + } - internal HookContext WithNewEvaluationContext(EvaluationContext context) - { - return new HookContext( - this._shared, - context, - this.Data - ); - } + internal HookContext WithNewEvaluationContext(EvaluationContext context) + { + return new HookContext( + this._shared, + context, + this.Data + ); } } diff --git a/src/OpenFeature/Model/Metadata.cs b/src/OpenFeature/Model/Metadata.cs index d7c972d7e..44a059ef2 100644 --- a/src/OpenFeature/Model/Metadata.cs +++ b/src/OpenFeature/Model/Metadata.cs @@ -1,22 +1,21 @@ -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// metadata +/// +public class Metadata { /// - /// metadata + /// Gets name of instance /// - public class Metadata - { - /// - /// Gets name of instance - /// - public string? Name { get; } + public string? Name { get; } - /// - /// Initializes a new instance of the class. - /// - /// Name of instance - public Metadata(string? name) - { - this.Name = name; - } + /// + /// Initializes a new instance of the class. + /// + /// Name of instance + public Metadata(string? name) + { + this.Name = name; } } diff --git a/src/OpenFeature/Model/ProviderEvents.cs b/src/OpenFeature/Model/ProviderEvents.cs index bdae057e2..1977edb69 100644 --- a/src/OpenFeature/Model/ProviderEvents.cs +++ b/src/OpenFeature/Model/ProviderEvents.cs @@ -1,46 +1,45 @@ using System.Collections.Generic; using OpenFeature.Constant; -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// The EventHandlerDelegate is an implementation of an Event Handler +/// +public delegate void EventHandlerDelegate(ProviderEventPayload? eventDetails); + +/// +/// Contains the payload of an OpenFeature Event. +/// +public class ProviderEventPayload { /// - /// The EventHandlerDelegate is an implementation of an Event Handler + /// Name of the provider. + /// + public string? ProviderName { get; set; } + + /// + /// Type of the event + /// + public ProviderEventTypes Type { get; set; } + + /// + /// A message providing more information about the event. + /// + public string? Message { get; set; } + + /// + /// Optional error associated with the event. + /// + public ErrorType? ErrorType { get; set; } + + /// + /// A List of flags that have been changed. /// - public delegate void EventHandlerDelegate(ProviderEventPayload? eventDetails); + public List? FlagsChanged { get; set; } /// - /// Contains the payload of an OpenFeature Event. + /// Metadata information for the event. /// - public class ProviderEventPayload - { - /// - /// Name of the provider. - /// - public string? ProviderName { get; set; } - - /// - /// Type of the event - /// - public ProviderEventTypes Type { get; set; } - - /// - /// A message providing more information about the event. - /// - public string? Message { get; set; } - - /// - /// Optional error associated with the event. - /// - public ErrorType? ErrorType { get; set; } - - /// - /// A List of flags that have been changed. - /// - public List? FlagsChanged { get; set; } - - /// - /// Metadata information for the event. - /// - public ImmutableMetadata? EventMetadata { get; set; } - } + public ImmutableMetadata? EventMetadata { get; set; } } diff --git a/src/OpenFeature/Model/ResolutionDetails.cs b/src/OpenFeature/Model/ResolutionDetails.cs index 78b907d20..a5c43aedb 100644 --- a/src/OpenFeature/Model/ResolutionDetails.cs +++ b/src/OpenFeature/Model/ResolutionDetails.cs @@ -1,74 +1,73 @@ using OpenFeature.Constant; -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// Defines the contract that the is required to return +/// Describes the details of the feature flag being evaluated +/// +/// Flag value type +/// +public sealed class ResolutionDetails { /// - /// Defines the contract that the is required to return - /// Describes the details of the feature flag being evaluated + /// Feature flag evaluated value /// - /// Flag value type - /// - public sealed class ResolutionDetails - { - /// - /// Feature flag evaluated value - /// - public T Value { get; } + public T Value { get; } - /// - /// Feature flag key - /// - public string FlagKey { get; } + /// + /// Feature flag key + /// + public string FlagKey { get; } - /// - /// Error that occurred during evaluation - /// - /// - public ErrorType ErrorType { get; } + /// + /// Error that occurred during evaluation + /// + /// + public ErrorType ErrorType { get; } - /// - /// Message containing additional details about an error. - /// - public string? ErrorMessage { get; } + /// + /// Message containing additional details about an error. + /// + public string? ErrorMessage { get; } - /// - /// Describes the reason for the outcome of the evaluation process - /// - /// - public string? Reason { get; } + /// + /// Describes the reason for the outcome of the evaluation process + /// + /// + public string? Reason { get; } - /// - /// A variant is a semantic identifier for a value. This allows for referral to particular values without - /// necessarily including the value itself, which may be quite prohibitively large or otherwise unsuitable - /// in some cases. - /// - public string? Variant { get; } + /// + /// A variant is a semantic identifier for a value. This allows for referral to particular values without + /// necessarily including the value itself, which may be quite prohibitively large or otherwise unsuitable + /// in some cases. + /// + public string? Variant { get; } - /// - /// A structure which supports definition of arbitrary properties, with keys of type string, and values of type boolean, string, or number. - /// - public ImmutableMetadata? FlagMetadata { get; } + /// + /// A structure which supports definition of arbitrary properties, with keys of type string, and values of type boolean, string, or number. + /// + public ImmutableMetadata? FlagMetadata { get; } - /// - /// Initializes a new instance of the class. - /// - /// Feature flag key - /// Evaluated value - /// Error - /// Reason - /// Variant - /// Error message - /// Flag metadata - public ResolutionDetails(string flagKey, T value, ErrorType errorType = ErrorType.None, string? reason = null, - string? variant = null, string? errorMessage = null, ImmutableMetadata? flagMetadata = null) - { - this.Value = value; - this.FlagKey = flagKey; - this.ErrorType = errorType; - this.Reason = reason; - this.Variant = variant; - this.ErrorMessage = errorMessage; - this.FlagMetadata = flagMetadata; - } - } + /// + /// Initializes a new instance of the class. + /// + /// Feature flag key + /// Evaluated value + /// Error + /// Reason + /// Variant + /// Error message + /// Flag metadata + public ResolutionDetails(string flagKey, T value, ErrorType errorType = ErrorType.None, string? reason = null, + string? variant = null, string? errorMessage = null, ImmutableMetadata? flagMetadata = null) + { + this.Value = value; + this.FlagKey = flagKey; + this.ErrorType = errorType; + this.Reason = reason; + this.Variant = variant; + this.ErrorMessage = errorMessage; + this.FlagMetadata = flagMetadata; + } } diff --git a/src/OpenFeature/Model/Structure.cs b/src/OpenFeature/Model/Structure.cs index 47c669234..9807ec45d 100644 --- a/src/OpenFeature/Model/Structure.cs +++ b/src/OpenFeature/Model/Structure.cs @@ -3,122 +3,121 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// Structure represents a map of Values +/// +public sealed class Structure : IEnumerable> { + private readonly ImmutableDictionary _attributes; + + /// + /// Internal constructor for use by the builder. + /// + internal Structure(ImmutableDictionary attributes) + { + this._attributes = attributes; + } + + /// + /// Private constructor for creating an empty . + /// + private Structure() + { + this._attributes = ImmutableDictionary.Empty; + } + + /// + /// An empty structure. + /// + public static Structure Empty { get; } = new Structure(); + + /// + /// Creates a new structure with the supplied attributes + /// + /// + public Structure(IDictionary attributes) + { + this._attributes = ImmutableDictionary.CreateRange(attributes); + } + + /// + /// Gets the Value at the specified key + /// + /// The key of the value to be retrieved + /// + public Value GetValue(string key) => this._attributes[key]; + + /// + /// Bool indicating if the specified key exists in the structure + /// + /// The key of the value to be retrieved + /// indicating the presence of the key. + public bool ContainsKey(string key) => this._attributes.ContainsKey(key); + + /// + /// Gets the value associated with the specified key by mutating the supplied value. + /// + /// The key of the value to be retrieved + /// value to be mutated + /// indicating the presence of the key. + public bool TryGetValue(string key, out Value? value) => this._attributes.TryGetValue(key, out value); + + /// + /// Gets all values as a Dictionary + /// + /// New representation of this Structure + public IImmutableDictionary AsDictionary() + { + return this._attributes; + } + + /// + /// Return the value at the supplied index + /// + /// The key of the value to be retrieved + public Value this[string key] + { + get => this._attributes[key]; + } + + /// + /// Return a list containing all the keys in this structure + /// + public IImmutableList Keys => this._attributes.Keys.ToImmutableList(); + /// - /// Structure represents a map of Values + /// Return an enumerable containing all the values in this structure /// - public sealed class Structure : IEnumerable> + public IImmutableList Values => this._attributes.Values.ToImmutableList(); + + /// + /// Return a count of all values + /// + public int Count => this._attributes.Count; + + /// + /// Return an enumerator for all values + /// + /// + public IEnumerator> GetEnumerator() + { + return this._attributes.GetEnumerator(); + } + + /// + /// Get a builder which can build a . + /// + /// The builder + public static StructureBuilder Builder() + { + return new StructureBuilder(); + } + + [ExcludeFromCodeCoverage] + IEnumerator IEnumerable.GetEnumerator() { - private readonly ImmutableDictionary _attributes; - - /// - /// Internal constructor for use by the builder. - /// - internal Structure(ImmutableDictionary attributes) - { - this._attributes = attributes; - } - - /// - /// Private constructor for creating an empty . - /// - private Structure() - { - this._attributes = ImmutableDictionary.Empty; - } - - /// - /// An empty structure. - /// - public static Structure Empty { get; } = new Structure(); - - /// - /// Creates a new structure with the supplied attributes - /// - /// - public Structure(IDictionary attributes) - { - this._attributes = ImmutableDictionary.CreateRange(attributes); - } - - /// - /// Gets the Value at the specified key - /// - /// The key of the value to be retrieved - /// - public Value GetValue(string key) => this._attributes[key]; - - /// - /// Bool indicating if the specified key exists in the structure - /// - /// The key of the value to be retrieved - /// indicating the presence of the key. - public bool ContainsKey(string key) => this._attributes.ContainsKey(key); - - /// - /// Gets the value associated with the specified key by mutating the supplied value. - /// - /// The key of the value to be retrieved - /// value to be mutated - /// indicating the presence of the key. - public bool TryGetValue(string key, out Value? value) => this._attributes.TryGetValue(key, out value); - - /// - /// Gets all values as a Dictionary - /// - /// New representation of this Structure - public IImmutableDictionary AsDictionary() - { - return this._attributes; - } - - /// - /// Return the value at the supplied index - /// - /// The key of the value to be retrieved - public Value this[string key] - { - get => this._attributes[key]; - } - - /// - /// Return a list containing all the keys in this structure - /// - public IImmutableList Keys => this._attributes.Keys.ToImmutableList(); - - /// - /// Return an enumerable containing all the values in this structure - /// - public IImmutableList Values => this._attributes.Values.ToImmutableList(); - - /// - /// Return a count of all values - /// - public int Count => this._attributes.Count; - - /// - /// Return an enumerator for all values - /// - /// - public IEnumerator> GetEnumerator() - { - return this._attributes.GetEnumerator(); - } - - /// - /// Get a builder which can build a . - /// - /// The builder - public static StructureBuilder Builder() - { - return new StructureBuilder(); - } - - [ExcludeFromCodeCoverage] - IEnumerator IEnumerable.GetEnumerator() - { - return this.GetEnumerator(); - } + return this.GetEnumerator(); } } diff --git a/src/OpenFeature/Model/StructureBuilder.cs b/src/OpenFeature/Model/StructureBuilder.cs index 4c44813dc..0cc922aca 100644 --- a/src/OpenFeature/Model/StructureBuilder.cs +++ b/src/OpenFeature/Model/StructureBuilder.cs @@ -2,143 +2,142 @@ using System.Collections.Generic; using System.Collections.Immutable; -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// A builder which allows the specification of attributes for a . +/// +/// A object is intended for use by a single thread and should not be used from +/// multiple threads. Once a has been created it is immutable and safe for use from +/// multiple threads. +/// +/// +public sealed class StructureBuilder { + private readonly ImmutableDictionary.Builder _attributes = + ImmutableDictionary.CreateBuilder(); + /// - /// A builder which allows the specification of attributes for a . - /// - /// A object is intended for use by a single thread and should not be used from - /// multiple threads. Once a has been created it is immutable and safe for use from - /// multiple threads. - /// + /// Internal to only allow direct creation by . /// - public sealed class StructureBuilder - { - private readonly ImmutableDictionary.Builder _attributes = - ImmutableDictionary.CreateBuilder(); + internal StructureBuilder() { } - /// - /// Internal to only allow direct creation by . - /// - internal StructureBuilder() { } - - /// - /// Set the key to the given . - /// - /// The key for the value - /// The value to set - /// This builder - public StructureBuilder Set(string key, Value value) - { - // Remove the attribute. Will not throw an exception if not present. - this._attributes.Remove(key); - this._attributes.Add(key, value); - return this; - } + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, Value value) + { + // Remove the attribute. Will not throw an exception if not present. + this._attributes.Remove(key); + this._attributes.Add(key, value); + return this; + } - /// - /// Set the key to the given string. - /// - /// The key for the value - /// The value to set - /// This builder - public StructureBuilder Set(string key, string value) - { - this.Set(key, new Value(value)); - return this; - } + /// + /// Set the key to the given string. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, string value) + { + this.Set(key, new Value(value)); + return this; + } - /// - /// Set the key to the given int. - /// - /// The key for the value - /// The value to set - /// This builder - public StructureBuilder Set(string key, int value) - { - this.Set(key, new Value(value)); - return this; - } + /// + /// Set the key to the given int. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, int value) + { + this.Set(key, new Value(value)); + return this; + } - /// - /// Set the key to the given double. - /// - /// The key for the value - /// The value to set - /// This builder - public StructureBuilder Set(string key, double value) - { - this.Set(key, new Value(value)); - return this; - } + /// + /// Set the key to the given double. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, double value) + { + this.Set(key, new Value(value)); + return this; + } - /// - /// Set the key to the given long. - /// - /// The key for the value - /// The value to set - /// This builder - public StructureBuilder Set(string key, long value) - { - this.Set(key, new Value(value)); - return this; - } + /// + /// Set the key to the given long. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, long value) + { + this.Set(key, new Value(value)); + return this; + } - /// - /// Set the key to the given bool. - /// - /// The key for the value - /// The value to set - /// This builder - public StructureBuilder Set(string key, bool value) - { - this.Set(key, new Value(value)); - return this; - } + /// + /// Set the key to the given bool. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, bool value) + { + this.Set(key, new Value(value)); + return this; + } - /// - /// Set the key to the given . - /// - /// The key for the value - /// The value to set - /// This builder - public StructureBuilder Set(string key, Structure value) - { - this.Set(key, new Value(value)); - return this; - } + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, Structure value) + { + this.Set(key, new Value(value)); + return this; + } - /// - /// Set the key to the given DateTime. - /// - /// The key for the value - /// The value to set - /// This builder - public StructureBuilder Set(string key, DateTime value) - { - this.Set(key, new Value(value)); - return this; - } + /// + /// Set the key to the given DateTime. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, DateTime value) + { + this.Set(key, new Value(value)); + return this; + } - /// - /// Set the key to the given list. - /// - /// The key for the value - /// The value to set - /// This builder - public StructureBuilder Set(string key, IList value) - { - this.Set(key, new Value(value)); - return this; - } + /// + /// Set the key to the given list. + /// + /// The key for the value + /// The value to set + /// This builder + public StructureBuilder Set(string key, IList value) + { + this.Set(key, new Value(value)); + return this; + } - /// - /// Build an immutable / - /// - /// The built - public Structure Build() - { - return new Structure(this._attributes.ToImmutable()); - } + /// + /// Build an immutable / + /// + /// The built + public Structure Build() + { + return new Structure(this._attributes.ToImmutable()); } } diff --git a/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs b/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs index 99a9d677a..6520ab3e5 100644 --- a/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs +++ b/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs @@ -1,159 +1,158 @@ using System; -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// A builder which allows the specification of attributes for an . +/// +/// A object is intended for use by a single thread and should not be used +/// from multiple threads. Once an has been created it is immutable and safe for use +/// from multiple threads. +/// +/// +public sealed class TrackingEventDetailsBuilder { + private readonly StructureBuilder _attributes = Structure.Builder(); + private double? _value; + /// - /// A builder which allows the specification of attributes for an . - /// - /// A object is intended for use by a single thread and should not be used - /// from multiple threads. Once an has been created it is immutable and safe for use - /// from multiple threads. - /// + /// Internal to only allow direct creation by . /// - public sealed class TrackingEventDetailsBuilder - { - private readonly StructureBuilder _attributes = Structure.Builder(); - private double? _value; + internal TrackingEventDetailsBuilder() { } - /// - /// Internal to only allow direct creation by . - /// - internal TrackingEventDetailsBuilder() { } + /// + /// Set the predefined value field for the tracking details. + /// + /// + /// + public TrackingEventDetailsBuilder SetValue(double? value) + { + this._value = value; + return this; + } - /// - /// Set the predefined value field for the tracking details. - /// - /// - /// - public TrackingEventDetailsBuilder SetValue(double? value) - { - this._value = value; - return this; - } + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, Value value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given . - /// - /// The key for the value - /// The value to set - /// This builder - public TrackingEventDetailsBuilder Set(string key, Value value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given string. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, string value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given string. - /// - /// The key for the value - /// The value to set - /// This builder - public TrackingEventDetailsBuilder Set(string key, string value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given int. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, int value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given int. - /// - /// The key for the value - /// The value to set - /// This builder - public TrackingEventDetailsBuilder Set(string key, int value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given double. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, double value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given double. - /// - /// The key for the value - /// The value to set - /// This builder - public TrackingEventDetailsBuilder Set(string key, double value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given long. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, long value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given long. - /// - /// The key for the value - /// The value to set - /// This builder - public TrackingEventDetailsBuilder Set(string key, long value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given bool. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, bool value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given bool. - /// - /// The key for the value - /// The value to set - /// This builder - public TrackingEventDetailsBuilder Set(string key, bool value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given . + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, Structure value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given . - /// - /// The key for the value - /// The value to set - /// This builder - public TrackingEventDetailsBuilder Set(string key, Structure value) - { - this._attributes.Set(key, value); - return this; - } + /// + /// Set the key to the given DateTime. + /// + /// The key for the value + /// The value to set + /// This builder + public TrackingEventDetailsBuilder Set(string key, DateTime value) + { + this._attributes.Set(key, value); + return this; + } - /// - /// Set the key to the given DateTime. - /// - /// The key for the value - /// The value to set - /// This builder - public TrackingEventDetailsBuilder Set(string key, DateTime value) + /// + /// Incorporate existing tracking details into the builder. + /// + /// Any existing keys in the builder will be replaced by keys in the tracking details, including the Value set + /// through . + /// + /// + /// The tracking details to add merge + /// This builder + public TrackingEventDetailsBuilder Merge(TrackingEventDetails trackingDetails) + { + this._value = trackingDetails.Value; + foreach (var kvp in trackingDetails) { - this._attributes.Set(key, value); - return this; + this.Set(kvp.Key, kvp.Value); } - /// - /// Incorporate existing tracking details into the builder. - /// - /// Any existing keys in the builder will be replaced by keys in the tracking details, including the Value set - /// through . - /// - /// - /// The tracking details to add merge - /// This builder - public TrackingEventDetailsBuilder Merge(TrackingEventDetails trackingDetails) - { - this._value = trackingDetails.Value; - foreach (var kvp in trackingDetails) - { - this.Set(kvp.Key, kvp.Value); - } - - return this; - } + return this; + } - /// - /// Build an immutable . - /// - /// An immutable - public TrackingEventDetails Build() - { - return new TrackingEventDetails(this._attributes.Build(), this._value); - } + /// + /// Build an immutable . + /// + /// An immutable + public TrackingEventDetails Build() + { + return new TrackingEventDetails(this._attributes.Build(), this._value); } } diff --git a/src/OpenFeature/Model/Value.cs b/src/OpenFeature/Model/Value.cs index 88fb07340..2f75eca36 100644 --- a/src/OpenFeature/Model/Value.cs +++ b/src/OpenFeature/Model/Value.cs @@ -2,189 +2,188 @@ using System.Collections.Generic; using System.Collections.Immutable; -namespace OpenFeature.Model +namespace OpenFeature.Model; + +/// +/// Values serve as a return type for provider objects. Providers may deal in JSON, protobuf, XML or some other data-interchange format. +/// This intermediate representation provides a good medium of exchange. +/// +public sealed class Value { + private readonly object? _innerValue; + + /// + /// Creates a Value with the inner value set to null + /// + public Value() => this._innerValue = null; + /// - /// Values serve as a return type for provider objects. Providers may deal in JSON, protobuf, XML or some other data-interchange format. - /// This intermediate representation provides a good medium of exchange. + /// Creates a Value with the inner set to the object /// - public sealed class Value + /// The object to set as the inner value + public Value(Object value) { - private readonly object? _innerValue; - - /// - /// Creates a Value with the inner value set to null - /// - public Value() => this._innerValue = null; - - /// - /// Creates a Value with the inner set to the object - /// - /// The object to set as the inner value - public Value(Object value) + if (value is IList list) { - if (value is IList list) - { - value = list.ToImmutableList(); - } - // integer is a special case, convert those. - this._innerValue = value is int ? Convert.ToDouble(value) : value; - if (!(this.IsNull - || this.IsBoolean - || this.IsString - || this.IsNumber - || this.IsStructure - || this.IsList - || this.IsDateTime)) - { - throw new ArgumentException("Invalid value type: " + value.GetType()); - } + value = list.ToImmutableList(); } + // integer is a special case, convert those. + this._innerValue = value is int ? Convert.ToDouble(value) : value; + if (!(this.IsNull + || this.IsBoolean + || this.IsString + || this.IsNumber + || this.IsStructure + || this.IsList + || this.IsDateTime)) + { + throw new ArgumentException("Invalid value type: " + value.GetType()); + } + } - /// - /// Creates a Value with the inner value to the inner value of the value param - /// - /// Value type - public Value(Value value) => this._innerValue = value._innerValue; - - /// - /// Creates a Value with the inner set to bool type - /// - /// Bool type - public Value(bool value) => this._innerValue = value; - - /// - /// Creates a Value by converting value to a double - /// - /// Int type - public Value(int value) => this._innerValue = Convert.ToDouble(value); - - /// - /// Creates a Value with the inner set to double type - /// - /// Double type - public Value(double value) => this._innerValue = value; - - /// - /// Creates a Value with the inner set to string type - /// - /// String type - public Value(string value) => this._innerValue = value; - - /// - /// Creates a Value with the inner set to structure type - /// - /// Structure type - public Value(Structure value) => this._innerValue = value; - - /// - /// Creates a Value with the inner set to list type - /// - /// List type - public Value(IList value) => this._innerValue = value.ToImmutableList(); - - /// - /// Creates a Value with the inner set to DateTime type - /// - /// DateTime type - public Value(DateTime value) => this._innerValue = value; - - /// - /// Determines if inner value is null - /// - /// True if value is null - public bool IsNull => this._innerValue is null; - - /// - /// Determines if inner value is bool - /// - /// True if value is bool - public bool IsBoolean => this._innerValue is bool; - - /// - /// Determines if inner value is numeric - /// - /// True if value is double - public bool IsNumber => this._innerValue is double; - - /// - /// Determines if inner value is string - /// - /// True if value is string - public bool IsString => this._innerValue is string; - - /// - /// Determines if inner value is Structure - /// - /// True if value is Structure - public bool IsStructure => this._innerValue is Structure; - - /// - /// Determines if inner value is list - /// - /// True if value is list - public bool IsList => this._innerValue is IImmutableList; - - /// - /// Determines if inner value is DateTime - /// - /// True if value is DateTime - public bool IsDateTime => this._innerValue is DateTime; - - /// - /// Returns the underlying inner value as an object. Returns null if the inner value is null. - /// - /// Value as object - public object? AsObject => this._innerValue; - - /// - /// Returns the underlying int value. - /// Value will be null if it isn't an integer - /// - /// Value as int - public int? AsInteger => this.IsNumber ? Convert.ToInt32((double?)this._innerValue) : null; - - /// - /// Returns the underlying bool value. - /// Value will be null if it isn't a bool - /// - /// Value as bool - public bool? AsBoolean => this.IsBoolean ? (bool?)this._innerValue : null; - - /// - /// Returns the underlying double value. - /// Value will be null if it isn't a double - /// - /// Value as int - public double? AsDouble => this.IsNumber ? (double?)this._innerValue : null; - - /// - /// Returns the underlying string value. - /// Value will be null if it isn't a string - /// - /// Value as string - public string? AsString => this.IsString ? (string?)this._innerValue : null; - - /// - /// Returns the underlying Structure value. - /// Value will be null if it isn't a Structure - /// - /// Value as Structure - public Structure? AsStructure => this.IsStructure ? (Structure?)this._innerValue : null; - - /// - /// Returns the underlying List value. - /// Value will be null if it isn't a List - /// - /// Value as List - public IImmutableList? AsList => this.IsList ? (IImmutableList?)this._innerValue : null; - - /// - /// Returns the underlying DateTime value. - /// Value will be null if it isn't a DateTime - /// - /// Value as DateTime - public DateTime? AsDateTime => this.IsDateTime ? (DateTime?)this._innerValue : null; - } + /// + /// Creates a Value with the inner value to the inner value of the value param + /// + /// Value type + public Value(Value value) => this._innerValue = value._innerValue; + + /// + /// Creates a Value with the inner set to bool type + /// + /// Bool type + public Value(bool value) => this._innerValue = value; + + /// + /// Creates a Value by converting value to a double + /// + /// Int type + public Value(int value) => this._innerValue = Convert.ToDouble(value); + + /// + /// Creates a Value with the inner set to double type + /// + /// Double type + public Value(double value) => this._innerValue = value; + + /// + /// Creates a Value with the inner set to string type + /// + /// String type + public Value(string value) => this._innerValue = value; + + /// + /// Creates a Value with the inner set to structure type + /// + /// Structure type + public Value(Structure value) => this._innerValue = value; + + /// + /// Creates a Value with the inner set to list type + /// + /// List type + public Value(IList value) => this._innerValue = value.ToImmutableList(); + + /// + /// Creates a Value with the inner set to DateTime type + /// + /// DateTime type + public Value(DateTime value) => this._innerValue = value; + + /// + /// Determines if inner value is null + /// + /// True if value is null + public bool IsNull => this._innerValue is null; + + /// + /// Determines if inner value is bool + /// + /// True if value is bool + public bool IsBoolean => this._innerValue is bool; + + /// + /// Determines if inner value is numeric + /// + /// True if value is double + public bool IsNumber => this._innerValue is double; + + /// + /// Determines if inner value is string + /// + /// True if value is string + public bool IsString => this._innerValue is string; + + /// + /// Determines if inner value is Structure + /// + /// True if value is Structure + public bool IsStructure => this._innerValue is Structure; + + /// + /// Determines if inner value is list + /// + /// True if value is list + public bool IsList => this._innerValue is IImmutableList; + + /// + /// Determines if inner value is DateTime + /// + /// True if value is DateTime + public bool IsDateTime => this._innerValue is DateTime; + + /// + /// Returns the underlying inner value as an object. Returns null if the inner value is null. + /// + /// Value as object + public object? AsObject => this._innerValue; + + /// + /// Returns the underlying int value. + /// Value will be null if it isn't an integer + /// + /// Value as int + public int? AsInteger => this.IsNumber ? Convert.ToInt32((double?)this._innerValue) : null; + + /// + /// Returns the underlying bool value. + /// Value will be null if it isn't a bool + /// + /// Value as bool + public bool? AsBoolean => this.IsBoolean ? (bool?)this._innerValue : null; + + /// + /// Returns the underlying double value. + /// Value will be null if it isn't a double + /// + /// Value as int + public double? AsDouble => this.IsNumber ? (double?)this._innerValue : null; + + /// + /// Returns the underlying string value. + /// Value will be null if it isn't a string + /// + /// Value as string + public string? AsString => this.IsString ? (string?)this._innerValue : null; + + /// + /// Returns the underlying Structure value. + /// Value will be null if it isn't a Structure + /// + /// Value as Structure + public Structure? AsStructure => this.IsStructure ? (Structure?)this._innerValue : null; + + /// + /// Returns the underlying List value. + /// Value will be null if it isn't a List + /// + /// Value as List + public IImmutableList? AsList => this.IsList ? (IImmutableList?)this._innerValue : null; + + /// + /// Returns the underlying DateTime value. + /// Value will be null if it isn't a DateTime + /// + /// Value as DateTime + public DateTime? AsDateTime => this.IsDateTime ? (DateTime?)this._innerValue : null; } diff --git a/src/OpenFeature/NoOpProvider.cs b/src/OpenFeature/NoOpProvider.cs index 5d7b9caa2..20973365d 100644 --- a/src/OpenFeature/NoOpProvider.cs +++ b/src/OpenFeature/NoOpProvider.cs @@ -3,50 +3,49 @@ using OpenFeature.Constant; using OpenFeature.Model; -namespace OpenFeature +namespace OpenFeature; + +internal sealed class NoOpFeatureProvider : FeatureProvider { - internal sealed class NoOpFeatureProvider : FeatureProvider + private readonly Metadata _metadata = new Metadata(NoOpProvider.NoOpProviderName); + + public override Metadata GetMetadata() + { + return this._metadata; + } + + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + private static ResolutionDetails NoOpResponse(string flagKey, T defaultValue) { - private readonly Metadata _metadata = new Metadata(NoOpProvider.NoOpProviderName); - - public override Metadata GetMetadata() - { - return this._metadata; - } - - public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(NoOpResponse(flagKey, defaultValue)); - } - - public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(NoOpResponse(flagKey, defaultValue)); - } - - public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(NoOpResponse(flagKey, defaultValue)); - } - - public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(NoOpResponse(flagKey, defaultValue)); - } - - public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(NoOpResponse(flagKey, defaultValue)); - } - - private static ResolutionDetails NoOpResponse(string flagKey, T defaultValue) - { - return new ResolutionDetails( - flagKey, - defaultValue, - reason: NoOpProvider.ReasonNoOp, - variant: NoOpProvider.Variant - ); - } + return new ResolutionDetails( + flagKey, + defaultValue, + reason: NoOpProvider.ReasonNoOp, + variant: NoOpProvider.Variant + ); } } diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index 4a00aa440..98aae19fb 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -12,333 +12,332 @@ using OpenFeature.Extension; using OpenFeature.Model; -namespace OpenFeature +namespace OpenFeature; + +/// +/// +/// +public sealed partial class FeatureClient : IFeatureClient { + private readonly ClientMetadata _metadata; + private readonly ConcurrentStack _hooks = new ConcurrentStack(); + private readonly ILogger _logger; + private readonly Func _providerAccessor; + private EvaluationContext _evaluationContext; + + private readonly object _evaluationContextLock = new object(); + /// - /// + /// Get a provider and an associated typed flag resolution method. + /// + /// The global provider could change between two accesses, so in order to safely get provider information we + /// must first alias it and then use that alias to access everything we need. + /// /// - public sealed partial class FeatureClient : IFeatureClient + /// + /// This method should return the desired flag resolution method from the given provider reference. + /// + /// The type of the resolution method + /// A tuple containing a resolution method and the provider it came from. + private (Func>>, FeatureProvider) + ExtractProvider( + Func>>> method) { - private readonly ClientMetadata _metadata; - private readonly ConcurrentStack _hooks = new ConcurrentStack(); - private readonly ILogger _logger; - private readonly Func _providerAccessor; - private EvaluationContext _evaluationContext; - - private readonly object _evaluationContextLock = new object(); - - /// - /// Get a provider and an associated typed flag resolution method. - /// - /// The global provider could change between two accesses, so in order to safely get provider information we - /// must first alias it and then use that alias to access everything we need. - /// - /// - /// - /// This method should return the desired flag resolution method from the given provider reference. - /// - /// The type of the resolution method - /// A tuple containing a resolution method and the provider it came from. - private (Func>>, FeatureProvider) - ExtractProvider( - Func>>> method) - { - // Alias the provider reference so getting the method and returning the provider are - // guaranteed to be the same object. - var provider = Api.Instance.GetProvider(this._metadata.Name!); + // Alias the provider reference so getting the method and returning the provider are + // guaranteed to be the same object. + var provider = Api.Instance.GetProvider(this._metadata.Name!); - return (method(provider), provider); - } + return (method(provider), provider); + } - /// - public ProviderStatus ProviderStatus => this._providerAccessor.Invoke().Status; + /// + public ProviderStatus ProviderStatus => this._providerAccessor.Invoke().Status; - /// - public EvaluationContext GetContext() - { - lock (this._evaluationContextLock) - { - return this._evaluationContext; - } - } - - /// - public void SetContext(EvaluationContext? context) + /// + public EvaluationContext GetContext() + { + lock (this._evaluationContextLock) { - lock (this._evaluationContextLock) - { - this._evaluationContext = context ?? EvaluationContext.Empty; - } + return this._evaluationContext; } + } - /// - /// Initializes a new instance of the class. - /// - /// Function to retrieve current provider - /// Name of client - /// Version of client - /// Logger used by client - /// Context given to this client - /// Throws if any of the required parameters are null - internal FeatureClient(Func providerAccessor, string? name, string? version, ILogger? logger = null, EvaluationContext? context = null) + /// + public void SetContext(EvaluationContext? context) + { + lock (this._evaluationContextLock) { - this._metadata = new ClientMetadata(name, version); - this._logger = logger ?? NullLogger.Instance; this._evaluationContext = context ?? EvaluationContext.Empty; - this._providerAccessor = providerAccessor; } + } - /// - public ClientMetadata GetMetadata() => this._metadata; - - /// - /// Add hook to client - /// - /// Hooks which are dependent on each other should be provided in a collection - /// using the . - /// - /// - /// Hook that implements the interface - public void AddHooks(Hook hook) => this._hooks.Push(hook); - - /// - public void AddHandler(ProviderEventTypes eventType, EventHandlerDelegate handler) - { - Api.Instance.AddClientHandler(this._metadata.Name!, eventType, handler); - } + /// + /// Initializes a new instance of the class. + /// + /// Function to retrieve current provider + /// Name of client + /// Version of client + /// Logger used by client + /// Context given to this client + /// Throws if any of the required parameters are null + internal FeatureClient(Func providerAccessor, string? name, string? version, ILogger? logger = null, EvaluationContext? context = null) + { + this._metadata = new ClientMetadata(name, version); + this._logger = logger ?? NullLogger.Instance; + this._evaluationContext = context ?? EvaluationContext.Empty; + this._providerAccessor = providerAccessor; + } - /// - public void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler) - { - Api.Instance.RemoveClientHandler(this._metadata.Name!, type, handler); - } + /// + public ClientMetadata GetMetadata() => this._metadata; - /// - public void AddHooks(IEnumerable hooks) -#if NET7_0_OR_GREATER - => this._hooks.PushRange(hooks as Hook[] ?? hooks.ToArray()); -#else - { - // See: https://github.com/dotnet/runtime/issues/62121 - if (hooks is Hook[] array) - { - if (array.Length > 0) - this._hooks.PushRange(array); + /// + /// Add hook to client + /// + /// Hooks which are dependent on each other should be provided in a collection + /// using the . + /// + /// + /// Hook that implements the interface + public void AddHooks(Hook hook) => this._hooks.Push(hook); - return; - } + /// + public void AddHandler(ProviderEventTypes eventType, EventHandlerDelegate handler) + { + Api.Instance.AddClientHandler(this._metadata.Name!, eventType, handler); + } - array = hooks.ToArray(); + /// + public void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler) + { + Api.Instance.RemoveClientHandler(this._metadata.Name!, type, handler); + } + /// + public void AddHooks(IEnumerable hooks) +#if NET7_0_OR_GREATER + => this._hooks.PushRange(hooks as Hook[] ?? hooks.ToArray()); +#else + { + // See: https://github.com/dotnet/runtime/issues/62121 + if (hooks is Hook[] array) + { if (array.Length > 0) this._hooks.PushRange(array); + + return; } + + array = hooks.ToArray(); + + if (array.Length > 0) + this._hooks.PushRange(array); + } #endif - /// - public IEnumerable GetHooks() => this._hooks.Reverse(); - - /// - /// Removes all hooks from the client - /// - public void ClearHooks() => this._hooks.Clear(); - - /// - public async Task GetBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, - FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => - (await this.GetBooleanDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; - - /// - public async Task> GetBooleanDetailsAsync(string flagKey, bool defaultValue, - EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => - await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveBooleanValueAsync), - FlagValueType.Boolean, flagKey, - defaultValue, context, config, cancellationToken).ConfigureAwait(false); - - /// - public async Task GetStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, - FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => - (await this.GetStringDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; - - /// - public async Task> GetStringDetailsAsync(string flagKey, string defaultValue, - EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => - await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveStringValueAsync), - FlagValueType.String, flagKey, - defaultValue, context, config, cancellationToken).ConfigureAwait(false); - - /// - public async Task GetIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, - FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => - (await this.GetIntegerDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; - - /// - public async Task> GetIntegerDetailsAsync(string flagKey, int defaultValue, - EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => - await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveIntegerValueAsync), - FlagValueType.Number, flagKey, - defaultValue, context, config, cancellationToken).ConfigureAwait(false); - - /// - public async Task GetDoubleValueAsync(string flagKey, double defaultValue, - EvaluationContext? context = null, - FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => - (await this.GetDoubleDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; - - /// - public async Task> GetDoubleDetailsAsync(string flagKey, double defaultValue, - EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => - await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveDoubleValueAsync), - FlagValueType.Number, flagKey, - defaultValue, context, config, cancellationToken).ConfigureAwait(false); - - /// - public async Task GetObjectValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, - FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => - (await this.GetObjectDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; - - /// - public async Task> GetObjectDetailsAsync(string flagKey, Value defaultValue, - EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => - await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveStructureValueAsync), - FlagValueType.Object, flagKey, - defaultValue, context, config, cancellationToken).ConfigureAwait(false); - - private async Task> EvaluateFlagAsync( - (Func>>, FeatureProvider) providerInfo, - FlagValueType flagValueType, string flagKey, T defaultValue, EvaluationContext? context = null, - FlagEvaluationOptions? options = null, - CancellationToken cancellationToken = default) + /// + public IEnumerable GetHooks() => this._hooks.Reverse(); + + /// + /// Removes all hooks from the client + /// + public void ClearHooks() => this._hooks.Clear(); + + /// + public async Task GetBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, + FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + (await this.GetBooleanDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; + + /// + public async Task> GetBooleanDetailsAsync(string flagKey, bool defaultValue, + EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveBooleanValueAsync), + FlagValueType.Boolean, flagKey, + defaultValue, context, config, cancellationToken).ConfigureAwait(false); + + /// + public async Task GetStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, + FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + (await this.GetStringDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; + + /// + public async Task> GetStringDetailsAsync(string flagKey, string defaultValue, + EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveStringValueAsync), + FlagValueType.String, flagKey, + defaultValue, context, config, cancellationToken).ConfigureAwait(false); + + /// + public async Task GetIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, + FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + (await this.GetIntegerDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; + + /// + public async Task> GetIntegerDetailsAsync(string flagKey, int defaultValue, + EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveIntegerValueAsync), + FlagValueType.Number, flagKey, + defaultValue, context, config, cancellationToken).ConfigureAwait(false); + + /// + public async Task GetDoubleValueAsync(string flagKey, double defaultValue, + EvaluationContext? context = null, + FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + (await this.GetDoubleDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; + + /// + public async Task> GetDoubleDetailsAsync(string flagKey, double defaultValue, + EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveDoubleValueAsync), + FlagValueType.Number, flagKey, + defaultValue, context, config, cancellationToken).ConfigureAwait(false); + + /// + public async Task GetObjectValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, + FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + (await this.GetObjectDetailsAsync(flagKey, defaultValue, context, config, cancellationToken).ConfigureAwait(false)).Value; + + /// + public async Task> GetObjectDetailsAsync(string flagKey, Value defaultValue, + EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default) => + await this.EvaluateFlagAsync(this.ExtractProvider(provider => provider.ResolveStructureValueAsync), + FlagValueType.Object, flagKey, + defaultValue, context, config, cancellationToken).ConfigureAwait(false); + + private async Task> EvaluateFlagAsync( + (Func>>, FeatureProvider) providerInfo, + FlagValueType flagValueType, string flagKey, T defaultValue, EvaluationContext? context = null, + FlagEvaluationOptions? options = null, + CancellationToken cancellationToken = default) + { + var resolveValueDelegate = providerInfo.Item1; + var provider = providerInfo.Item2; + + // New up an evaluation context if one was not provided. + context ??= EvaluationContext.Empty; + + // merge api, client, transaction and invocation context + var evaluationContextBuilder = EvaluationContext.Builder(); + evaluationContextBuilder.Merge(Api.Instance.GetContext()); // API context + evaluationContextBuilder.Merge(this.GetContext()); // Client context + evaluationContextBuilder.Merge(Api.Instance.GetTransactionContext()); // Transaction context + evaluationContextBuilder.Merge(context); // Invocation context + + var allHooks = ImmutableList.CreateBuilder() + .Concat(Api.Instance.GetHooks()) + .Concat(this.GetHooks()) + .Concat(options?.Hooks ?? Enumerable.Empty()) + .Concat(provider.GetProviderHooks()) + .ToImmutableList(); + + var sharedHookContext = new SharedHookContext( + flagKey, + defaultValue, + flagValueType, + this._metadata, + provider.GetMetadata() + ); + + FlagEvaluationDetails? evaluation = null; + var hookRunner = new HookRunner(allHooks, evaluationContextBuilder.Build(), sharedHookContext, + this._logger); + + try { - var resolveValueDelegate = providerInfo.Item1; - var provider = providerInfo.Item2; - - // New up an evaluation context if one was not provided. - context ??= EvaluationContext.Empty; - - // merge api, client, transaction and invocation context - var evaluationContextBuilder = EvaluationContext.Builder(); - evaluationContextBuilder.Merge(Api.Instance.GetContext()); // API context - evaluationContextBuilder.Merge(this.GetContext()); // Client context - evaluationContextBuilder.Merge(Api.Instance.GetTransactionContext()); // Transaction context - evaluationContextBuilder.Merge(context); // Invocation context - - var allHooks = ImmutableList.CreateBuilder() - .Concat(Api.Instance.GetHooks()) - .Concat(this.GetHooks()) - .Concat(options?.Hooks ?? Enumerable.Empty()) - .Concat(provider.GetProviderHooks()) - .ToImmutableList(); - - var sharedHookContext = new SharedHookContext( - flagKey, - defaultValue, - flagValueType, - this._metadata, - provider.GetMetadata() - ); - - FlagEvaluationDetails? evaluation = null; - var hookRunner = new HookRunner(allHooks, evaluationContextBuilder.Build(), sharedHookContext, - this._logger); - - try - { - var evaluationContextFromHooks = await hookRunner.TriggerBeforeHooksAsync(options?.HookHints, cancellationToken) - .ConfigureAwait(false); + var evaluationContextFromHooks = await hookRunner.TriggerBeforeHooksAsync(options?.HookHints, cancellationToken) + .ConfigureAwait(false); - // short circuit evaluation entirely if provider is in a bad state - if (provider.Status == ProviderStatus.NotReady) - { - throw new ProviderNotReadyException("Provider has not yet completed initialization."); - } - else if (provider.Status == ProviderStatus.Fatal) - { - throw new ProviderFatalException("Provider is in an irrecoverable error state."); - } - - evaluation = - (await resolveValueDelegate - .Invoke(flagKey, defaultValue, evaluationContextFromHooks, cancellationToken) - .ConfigureAwait(false)) - .ToFlagEvaluationDetails(); - - if (evaluation.ErrorType == ErrorType.None) - { - await hookRunner.TriggerAfterHooksAsync( - evaluation, - options?.HookHints, - cancellationToken - ).ConfigureAwait(false); - } - else - { - var exception = new FeatureProviderException(evaluation.ErrorType, evaluation.ErrorMessage); - this.FlagEvaluationErrorWithDescription(flagKey, evaluation.ErrorType.GetDescription(), exception); - await hookRunner.TriggerErrorHooksAsync(exception, options?.HookHints, cancellationToken) - .ConfigureAwait(false); - } + // short circuit evaluation entirely if provider is in a bad state + if (provider.Status == ProviderStatus.NotReady) + { + throw new ProviderNotReadyException("Provider has not yet completed initialization."); } - catch (FeatureProviderException ex) + else if (provider.Status == ProviderStatus.Fatal) { - this.FlagEvaluationErrorWithDescription(flagKey, ex.ErrorType.GetDescription(), ex); - evaluation = new FlagEvaluationDetails(flagKey, defaultValue, ex.ErrorType, Reason.Error, - string.Empty, ex.Message); - await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken) - .ConfigureAwait(false); + throw new ProviderFatalException("Provider is in an irrecoverable error state."); } - catch (Exception ex) + + evaluation = + (await resolveValueDelegate + .Invoke(flagKey, defaultValue, evaluationContextFromHooks, cancellationToken) + .ConfigureAwait(false)) + .ToFlagEvaluationDetails(); + + if (evaluation.ErrorType == ErrorType.None) { - var errorCode = ex is InvalidCastException ? ErrorType.TypeMismatch : ErrorType.General; - evaluation = new FlagEvaluationDetails(flagKey, defaultValue, errorCode, Reason.Error, string.Empty, - ex.Message); - await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken) - .ConfigureAwait(false); + await hookRunner.TriggerAfterHooksAsync( + evaluation, + options?.HookHints, + cancellationToken + ).ConfigureAwait(false); } - finally + else { - evaluation ??= new FlagEvaluationDetails(flagKey, defaultValue, ErrorType.General, Reason.Error, - string.Empty, - "Evaluation failed to return a result."); - await hookRunner.TriggerFinallyHooksAsync(evaluation, options?.HookHints, cancellationToken) + var exception = new FeatureProviderException(evaluation.ErrorType, evaluation.ErrorMessage); + this.FlagEvaluationErrorWithDescription(flagKey, evaluation.ErrorType.GetDescription(), exception); + await hookRunner.TriggerErrorHooksAsync(exception, options?.HookHints, cancellationToken) .ConfigureAwait(false); } - - return evaluation; } - - /// - /// Use this method to track user interactions and the application state. - /// - /// The name associated with this tracking event - /// The evaluation context used in the evaluation of the flag (optional) - /// Data pertinent to the tracking event (Optional) - /// When trackingEventName is null or empty - public void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + catch (FeatureProviderException ex) { - if (string.IsNullOrWhiteSpace(trackingEventName)) - { - throw new ArgumentException("Tracking event cannot be null or empty.", nameof(trackingEventName)); - } - - var globalContext = Api.Instance.GetContext(); - var clientContext = this.GetContext(); + this.FlagEvaluationErrorWithDescription(flagKey, ex.ErrorType.GetDescription(), ex); + evaluation = new FlagEvaluationDetails(flagKey, defaultValue, ex.ErrorType, Reason.Error, + string.Empty, ex.Message); + await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + var errorCode = ex is InvalidCastException ? ErrorType.TypeMismatch : ErrorType.General; + evaluation = new FlagEvaluationDetails(flagKey, defaultValue, errorCode, Reason.Error, string.Empty, + ex.Message); + await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken) + .ConfigureAwait(false); + } + finally + { + evaluation ??= new FlagEvaluationDetails(flagKey, defaultValue, ErrorType.General, Reason.Error, + string.Empty, + "Evaluation failed to return a result."); + await hookRunner.TriggerFinallyHooksAsync(evaluation, options?.HookHints, cancellationToken) + .ConfigureAwait(false); + } - var evaluationContextBuilder = EvaluationContext.Builder() - .Merge(globalContext) - .Merge(clientContext); - if (evaluationContext != null) evaluationContextBuilder.Merge(evaluationContext); + return evaluation; + } - this._providerAccessor.Invoke().Track(trackingEventName, evaluationContextBuilder.Build(), trackingEventDetails); + /// + /// Use this method to track user interactions and the application state. + /// + /// The name associated with this tracking event + /// The evaluation context used in the evaluation of the flag (optional) + /// Data pertinent to the tracking event (Optional) + /// When trackingEventName is null or empty + public void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + if (string.IsNullOrWhiteSpace(trackingEventName)) + { + throw new ArgumentException("Tracking event cannot be null or empty.", nameof(trackingEventName)); } - [LoggerMessage(101, LogLevel.Error, "Error while evaluating flag {FlagKey}")] - partial void FlagEvaluationError(string flagKey, Exception exception); + var globalContext = Api.Instance.GetContext(); + var clientContext = this.GetContext(); - [LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")] - partial void HookReturnedNull(string hookName); + var evaluationContextBuilder = EvaluationContext.Builder() + .Merge(globalContext) + .Merge(clientContext); + if (evaluationContext != null) evaluationContextBuilder.Merge(evaluationContext); - [LoggerMessage(102, LogLevel.Error, "Error while evaluating flag {FlagKey}: {ErrorType}")] - partial void FlagEvaluationErrorWithDescription(string flagKey, string errorType, Exception exception); + this._providerAccessor.Invoke().Track(trackingEventName, evaluationContextBuilder.Build(), trackingEventDetails); } + + [LoggerMessage(101, LogLevel.Error, "Error while evaluating flag {FlagKey}")] + partial void FlagEvaluationError(string flagKey, Exception exception); + + [LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")] + partial void HookReturnedNull(string hookName); + + [LoggerMessage(102, LogLevel.Error, "Error while evaluating flag {FlagKey}: {ErrorType}")] + partial void FlagEvaluationErrorWithDescription(string flagKey, string errorType, Exception exception); } diff --git a/src/OpenFeature/ProviderRepository.cs b/src/OpenFeature/ProviderRepository.cs index 49f1de43f..54e797db3 100644 --- a/src/OpenFeature/ProviderRepository.cs +++ b/src/OpenFeature/ProviderRepository.cs @@ -9,284 +9,283 @@ using OpenFeature.Model; -namespace OpenFeature +namespace OpenFeature; + +/// +/// This class manages the collection of providers, both default and named, contained by the API. +/// +internal sealed partial class ProviderRepository : IAsyncDisposable { - /// - /// This class manages the collection of providers, both default and named, contained by the API. - /// - internal sealed partial class ProviderRepository : IAsyncDisposable - { - private ILogger _logger = NullLogger.Instance; + private ILogger _logger = NullLogger.Instance; - private FeatureProvider _defaultProvider = new NoOpFeatureProvider(); + private FeatureProvider _defaultProvider = new NoOpFeatureProvider(); - private readonly ConcurrentDictionary _featureProviders = - new ConcurrentDictionary(); + private readonly ConcurrentDictionary _featureProviders = + new ConcurrentDictionary(); - /// The reader/writer locks is not disposed because the singleton instance should never be disposed. - /// - /// Mutations of the _defaultProvider or _featureProviders are done within this lock even though - /// _featureProvider is a concurrent collection. This is for a couple of reasons, the first is that - /// a provider should only be shutdown if it is not in use, and it could be in use as either a named or - /// default provider. - /// - /// The second is that a concurrent collection doesn't provide any ordering, so we could check a provider - /// as it was being added or removed such as two concurrent calls to SetProvider replacing multiple instances - /// of that provider under different names. - private readonly ReaderWriterLockSlim _providersLock = new ReaderWriterLockSlim(); + /// The reader/writer locks is not disposed because the singleton instance should never be disposed. + /// + /// Mutations of the _defaultProvider or _featureProviders are done within this lock even though + /// _featureProvider is a concurrent collection. This is for a couple of reasons, the first is that + /// a provider should only be shutdown if it is not in use, and it could be in use as either a named or + /// default provider. + /// + /// The second is that a concurrent collection doesn't provide any ordering, so we could check a provider + /// as it was being added or removed such as two concurrent calls to SetProvider replacing multiple instances + /// of that provider under different names. + private readonly ReaderWriterLockSlim _providersLock = new ReaderWriterLockSlim(); - public async ValueTask DisposeAsync() + public async ValueTask DisposeAsync() + { + using (this._providersLock) { - using (this._providersLock) - { - await this.ShutdownAsync().ConfigureAwait(false); - } + await this.ShutdownAsync().ConfigureAwait(false); } + } - internal void SetLogger(ILogger logger) => this._logger = logger; + internal void SetLogger(ILogger logger) => this._logger = logger; - /// - /// Set the default provider - /// - /// the provider to set as the default, passing null has no effect - /// the context to initialize the provider with - /// - /// called after the provider has initialized successfully, only called if the provider needed initialization - /// - /// - /// called if an error happens during the initialization of the provider, only called if the provider needed - /// initialization - /// - public async Task SetProviderAsync( - FeatureProvider? featureProvider, - EvaluationContext context, - Func? afterInitSuccess = null, - Func? afterInitError = null) + /// + /// Set the default provider + /// + /// the provider to set as the default, passing null has no effect + /// the context to initialize the provider with + /// + /// called after the provider has initialized successfully, only called if the provider needed initialization + /// + /// + /// called if an error happens during the initialization of the provider, only called if the provider needed + /// initialization + /// + public async Task SetProviderAsync( + FeatureProvider? featureProvider, + EvaluationContext context, + Func? afterInitSuccess = null, + Func? afterInitError = null) + { + // Cannot unset the feature provider. + if (featureProvider == null) { - // Cannot unset the feature provider. - if (featureProvider == null) + return; + } + + this._providersLock.EnterWriteLock(); + // Default provider is swapped synchronously, initialization and shutdown may happen asynchronously. + try + { + // Setting the provider to the same provider should not have an effect. + if (ReferenceEquals(featureProvider, this._defaultProvider)) { return; } - this._providersLock.EnterWriteLock(); - // Default provider is swapped synchronously, initialization and shutdown may happen asynchronously. - try - { - // Setting the provider to the same provider should not have an effect. - if (ReferenceEquals(featureProvider, this._defaultProvider)) - { - return; - } + var oldProvider = this._defaultProvider; + this._defaultProvider = featureProvider; + // We want to allow shutdown to happen concurrently with initialization, and the caller to not + // wait for it. + _ = this.ShutdownIfUnusedAsync(oldProvider); + } + finally + { + this._providersLock.ExitWriteLock(); + } - var oldProvider = this._defaultProvider; - this._defaultProvider = featureProvider; - // We want to allow shutdown to happen concurrently with initialization, and the caller to not - // wait for it. - _ = this.ShutdownIfUnusedAsync(oldProvider); - } - finally - { - this._providersLock.ExitWriteLock(); - } + await InitProviderAsync(this._defaultProvider, context, afterInitSuccess, afterInitError) + .ConfigureAwait(false); + } - await InitProviderAsync(this._defaultProvider, context, afterInitSuccess, afterInitError) - .ConfigureAwait(false); + private static async Task InitProviderAsync( + FeatureProvider? newProvider, + EvaluationContext context, + Func? afterInitialization, + Func? afterError) + { + if (newProvider == null) + { + return; } - - private static async Task InitProviderAsync( - FeatureProvider? newProvider, - EvaluationContext context, - Func? afterInitialization, - Func? afterError) + if (newProvider.Status == ProviderStatus.NotReady) { - if (newProvider == null) - { - return; - } - if (newProvider.Status == ProviderStatus.NotReady) + try { - try + await newProvider.InitializeAsync(context).ConfigureAwait(false); + if (afterInitialization != null) { - await newProvider.InitializeAsync(context).ConfigureAwait(false); - if (afterInitialization != null) - { - await afterInitialization.Invoke(newProvider).ConfigureAwait(false); - } + await afterInitialization.Invoke(newProvider).ConfigureAwait(false); } - catch (Exception ex) + } + catch (Exception ex) + { + if (afterError != null) { - if (afterError != null) - { - await afterError.Invoke(newProvider, ex).ConfigureAwait(false); - } + await afterError.Invoke(newProvider, ex).ConfigureAwait(false); } } } + } - /// - /// Set a named provider - /// - /// an identifier which logically binds clients with providers - /// the provider to set as the default, passing null has no effect - /// the context to initialize the provider with - /// - /// called after the provider has initialized successfully, only called if the provider needed initialization - /// - /// - /// called if an error happens during the initialization of the provider, only called if the provider needed - /// initialization - /// - /// The to cancel any async side effects. - public async Task SetProviderAsync(string? domain, - FeatureProvider? featureProvider, - EvaluationContext context, - Func? afterInitSuccess = null, - Func? afterInitError = null, - CancellationToken cancellationToken = default) + /// + /// Set a named provider + /// + /// an identifier which logically binds clients with providers + /// the provider to set as the default, passing null has no effect + /// the context to initialize the provider with + /// + /// called after the provider has initialized successfully, only called if the provider needed initialization + /// + /// + /// called if an error happens during the initialization of the provider, only called if the provider needed + /// initialization + /// + /// The to cancel any async side effects. + public async Task SetProviderAsync(string? domain, + FeatureProvider? featureProvider, + EvaluationContext context, + Func? afterInitSuccess = null, + Func? afterInitError = null, + CancellationToken cancellationToken = default) + { + // Cannot set a provider for a null domain. + if (domain == null) { - // Cannot set a provider for a null domain. - if (domain == null) - { - return; - } + return; + } - this._providersLock.EnterWriteLock(); + this._providersLock.EnterWriteLock(); - try + try + { + this._featureProviders.TryGetValue(domain, out var oldProvider); + if (featureProvider != null) { - this._featureProviders.TryGetValue(domain, out var oldProvider); - if (featureProvider != null) - { - this._featureProviders.AddOrUpdate(domain, featureProvider, - (key, current) => featureProvider); - } - else - { - // If names of clients are programmatic, then setting the provider to null could result - // in unbounded growth of the collection. - this._featureProviders.TryRemove(domain, out _); - } - - // We want to allow shutdown to happen concurrently with initialization, and the caller to not - // wait for it. - _ = this.ShutdownIfUnusedAsync(oldProvider); + this._featureProviders.AddOrUpdate(domain, featureProvider, + (key, current) => featureProvider); } - finally + else { - this._providersLock.ExitWriteLock(); + // If names of clients are programmatic, then setting the provider to null could result + // in unbounded growth of the collection. + this._featureProviders.TryRemove(domain, out _); } - await InitProviderAsync(featureProvider, context, afterInitSuccess, afterInitError).ConfigureAwait(false); + // We want to allow shutdown to happen concurrently with initialization, and the caller to not + // wait for it. + _ = this.ShutdownIfUnusedAsync(oldProvider); } - - /// - /// Shutdown the feature provider if it is unused. This must be called within a write lock of the _providersLock. - /// - private async Task ShutdownIfUnusedAsync( - FeatureProvider? targetProvider) + finally { - if (ReferenceEquals(this._defaultProvider, targetProvider)) - { - return; - } + this._providersLock.ExitWriteLock(); + } - if (targetProvider != null && this._featureProviders.Values.Contains(targetProvider)) - { - return; - } + await InitProviderAsync(featureProvider, context, afterInitSuccess, afterInitError).ConfigureAwait(false); + } - await this.SafeShutdownProviderAsync(targetProvider).ConfigureAwait(false); + /// + /// Shutdown the feature provider if it is unused. This must be called within a write lock of the _providersLock. + /// + private async Task ShutdownIfUnusedAsync( + FeatureProvider? targetProvider) + { + if (ReferenceEquals(this._defaultProvider, targetProvider)) + { + return; } - /// - /// - /// Shut down the provider and capture any exceptions thrown. - /// - /// - /// The provider is set either to a name or default before the old provider it shut down, so - /// it would not be meaningful to emit an error. - /// - /// - private async Task SafeShutdownProviderAsync(FeatureProvider? targetProvider) + if (targetProvider != null && this._featureProviders.Values.Contains(targetProvider)) { - if (targetProvider == null) - { - return; - } + return; + } - try - { - await targetProvider.ShutdownAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - this.ErrorShuttingDownProvider(targetProvider.GetMetadata()?.Name, ex); - } + await this.SafeShutdownProviderAsync(targetProvider).ConfigureAwait(false); + } + + /// + /// + /// Shut down the provider and capture any exceptions thrown. + /// + /// + /// The provider is set either to a name or default before the old provider it shut down, so + /// it would not be meaningful to emit an error. + /// + /// + private async Task SafeShutdownProviderAsync(FeatureProvider? targetProvider) + { + if (targetProvider == null) + { + return; } - public FeatureProvider GetProvider() + try { - this._providersLock.EnterReadLock(); - try - { - return this._defaultProvider; - } - finally - { - this._providersLock.ExitReadLock(); - } + await targetProvider.ShutdownAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this.ErrorShuttingDownProvider(targetProvider.GetMetadata()?.Name, ex); } + } - public FeatureProvider GetProvider(string? domain) + public FeatureProvider GetProvider() + { + this._providersLock.EnterReadLock(); + try + { + return this._defaultProvider; + } + finally { + this._providersLock.ExitReadLock(); + } + } + + public FeatureProvider GetProvider(string? domain) + { #if NET6_0_OR_GREATER - if (string.IsNullOrEmpty(domain)) - { - return this.GetProvider(); - } + if (string.IsNullOrEmpty(domain)) + { + return this.GetProvider(); + } #else - // This is a workaround for the issue in .NET Framework where string.IsNullOrEmpty is not nullable compatible. - if (domain == null || string.IsNullOrEmpty(domain)) - { - return this.GetProvider(); - } + // This is a workaround for the issue in .NET Framework where string.IsNullOrEmpty is not nullable compatible. + if (domain == null || string.IsNullOrEmpty(domain)) + { + return this.GetProvider(); + } #endif - return this._featureProviders.TryGetValue(domain, out var featureProvider) - ? featureProvider - : this.GetProvider(); - } + return this._featureProviders.TryGetValue(domain, out var featureProvider) + ? featureProvider + : this.GetProvider(); + } - public async Task ShutdownAsync(Action? afterError = null, CancellationToken cancellationToken = default) + public async Task ShutdownAsync(Action? afterError = null, CancellationToken cancellationToken = default) + { + var providers = new HashSet(); + this._providersLock.EnterWriteLock(); + try { - var providers = new HashSet(); - this._providersLock.EnterWriteLock(); - try + providers.Add(this._defaultProvider); + foreach (var featureProvidersValue in this._featureProviders.Values) { - providers.Add(this._defaultProvider); - foreach (var featureProvidersValue in this._featureProviders.Values) - { - providers.Add(featureProvidersValue); - } - - // Set a default provider so the Api is ready to be used again. - this._defaultProvider = new NoOpFeatureProvider(); - this._featureProviders.Clear(); - } - finally - { - this._providersLock.ExitWriteLock(); + providers.Add(featureProvidersValue); } - foreach (var targetProvider in providers) - { - // We don't need to take any actions after shutdown. - await this.SafeShutdownProviderAsync(targetProvider).ConfigureAwait(false); - } + // Set a default provider so the Api is ready to be used again. + this._defaultProvider = new NoOpFeatureProvider(); + this._featureProviders.Clear(); + } + finally + { + this._providersLock.ExitWriteLock(); } - [LoggerMessage(EventId = 105, Level = LogLevel.Error, Message = "Error shutting down provider: {TargetProviderName}`")] - partial void ErrorShuttingDownProvider(string? targetProviderName, Exception exception); + foreach (var targetProvider in providers) + { + // We don't need to take any actions after shutdown. + await this.SafeShutdownProviderAsync(targetProvider).ConfigureAwait(false); + } } + + [LoggerMessage(EventId = 105, Level = LogLevel.Error, Message = "Error shutting down provider: {TargetProviderName}`")] + partial void ErrorShuttingDownProvider(string? targetProviderName, Exception exception); } diff --git a/src/OpenFeature/Providers/Memory/Flag.cs b/src/OpenFeature/Providers/Memory/Flag.cs index 7e125a89a..fd8cf19f9 100644 --- a/src/OpenFeature/Providers/Memory/Flag.cs +++ b/src/OpenFeature/Providers/Memory/Flag.cs @@ -4,75 +4,74 @@ using OpenFeature.Error; using OpenFeature.Model; -namespace OpenFeature.Providers.Memory +namespace OpenFeature.Providers.Memory; + +/// +/// Flag representation for the in-memory provider. +/// +public interface Flag; + +/// +/// Flag representation for the in-memory provider. +/// +public sealed class Flag : Flag { - /// - /// Flag representation for the in-memory provider. - /// - public interface Flag; + private readonly Dictionary _variants; + private readonly string _defaultVariant; + private readonly Func? _contextEvaluator; + private readonly ImmutableMetadata? _flagMetadata; /// /// Flag representation for the in-memory provider. /// - public sealed class Flag : Flag + /// dictionary of variants and their corresponding values + /// default variant (should match 1 key in variants dictionary) + /// optional context-sensitive evaluation function + /// optional metadata for the flag + public Flag(Dictionary variants, string defaultVariant, Func? contextEvaluator = null, ImmutableMetadata? flagMetadata = null) { - private readonly Dictionary _variants; - private readonly string _defaultVariant; - private readonly Func? _contextEvaluator; - private readonly ImmutableMetadata? _flagMetadata; + this._variants = variants; + this._defaultVariant = defaultVariant; + this._contextEvaluator = contextEvaluator; + this._flagMetadata = flagMetadata; + } - /// - /// Flag representation for the in-memory provider. - /// - /// dictionary of variants and their corresponding values - /// default variant (should match 1 key in variants dictionary) - /// optional context-sensitive evaluation function - /// optional metadata for the flag - public Flag(Dictionary variants, string defaultVariant, Func? contextEvaluator = null, ImmutableMetadata? flagMetadata = null) + internal ResolutionDetails Evaluate(string flagKey, T _, EvaluationContext? evaluationContext) + { + T? value; + if (this._contextEvaluator == null) { - this._variants = variants; - this._defaultVariant = defaultVariant; - this._contextEvaluator = contextEvaluator; - this._flagMetadata = flagMetadata; + if (this._variants.TryGetValue(this._defaultVariant, out value)) + { + return new ResolutionDetails( + flagKey, + value, + variant: this._defaultVariant, + reason: Reason.Static, + flagMetadata: this._flagMetadata + ); + } + else + { + throw new GeneralException($"variant {this._defaultVariant} not found"); + } } - - internal ResolutionDetails Evaluate(string flagKey, T _, EvaluationContext? evaluationContext) + else { - T? value; - if (this._contextEvaluator == null) + var variant = this._contextEvaluator.Invoke(evaluationContext ?? EvaluationContext.Empty); + if (!this._variants.TryGetValue(variant, out value)) { - if (this._variants.TryGetValue(this._defaultVariant, out value)) - { - return new ResolutionDetails( - flagKey, - value, - variant: this._defaultVariant, - reason: Reason.Static, - flagMetadata: this._flagMetadata - ); - } - else - { - throw new GeneralException($"variant {this._defaultVariant} not found"); - } + throw new GeneralException($"variant {variant} not found"); } else { - var variant = this._contextEvaluator.Invoke(evaluationContext ?? EvaluationContext.Empty); - if (!this._variants.TryGetValue(variant, out value)) - { - throw new GeneralException($"variant {variant} not found"); - } - else - { - return new ResolutionDetails( - flagKey, - value, - variant: variant, - reason: Reason.TargetingMatch, - flagMetadata: this._flagMetadata - ); - } + return new ResolutionDetails( + flagKey, + value, + variant: variant, + reason: Reason.TargetingMatch, + flagMetadata: this._flagMetadata + ); } } } diff --git a/src/OpenFeature/Providers/Memory/InMemoryProvider.cs b/src/OpenFeature/Providers/Memory/InMemoryProvider.cs index 2eec879d0..fce7afe1f 100644 --- a/src/OpenFeature/Providers/Memory/InMemoryProvider.cs +++ b/src/OpenFeature/Providers/Memory/InMemoryProvider.cs @@ -5,113 +5,112 @@ using OpenFeature.Constant; using OpenFeature.Model; -namespace OpenFeature.Providers.Memory +namespace OpenFeature.Providers.Memory; + +/// +/// The in memory provider. +/// Useful for testing and demonstration purposes. +/// +/// In Memory Provider specification +public class InMemoryProvider : FeatureProvider { + private readonly Metadata _metadata = new Metadata("InMemory"); + + private Dictionary _flags; + + /// + public override Metadata GetMetadata() + { + return this._metadata; + } + /// - /// The in memory provider. - /// Useful for testing and demonstration purposes. + /// Construct a new InMemoryProvider. /// - /// In Memory Provider specification - public class InMemoryProvider : FeatureProvider + /// dictionary of Flags + public InMemoryProvider(IDictionary? flags = null) { - private readonly Metadata _metadata = new Metadata("InMemory"); - - private Dictionary _flags; - - /// - public override Metadata GetMetadata() + if (flags == null) { - return this._metadata; + this._flags = new Dictionary(); } - - /// - /// Construct a new InMemoryProvider. - /// - /// dictionary of Flags - public InMemoryProvider(IDictionary? flags = null) + else { - if (flags == null) - { - this._flags = new Dictionary(); - } - else - { - this._flags = new Dictionary(flags); // shallow copy - } + this._flags = new Dictionary(flags); // shallow copy } + } - /// - /// Update provider flag configuration, replacing all flags. - /// - /// the flags to use instead of the previous flags. - public async Task UpdateFlagsAsync(IDictionary? flags = null) + /// + /// Update provider flag configuration, replacing all flags. + /// + /// the flags to use instead of the previous flags. + public async Task UpdateFlagsAsync(IDictionary? flags = null) + { + var changed = this._flags.Keys.ToList(); + if (flags == null) { - var changed = this._flags.Keys.ToList(); - if (flags == null) - { - this._flags = new Dictionary(); - } - else - { - this._flags = new Dictionary(flags); // shallow copy - } - changed.AddRange(this._flags.Keys.ToList()); - var @event = new ProviderEventPayload - { - Type = ProviderEventTypes.ProviderConfigurationChanged, - ProviderName = this._metadata.Name, - FlagsChanged = changed, // emit all - Message = "flags changed", - }; - - await this.EventChannel.Writer.WriteAsync(@event).ConfigureAwait(false); + this._flags = new Dictionary(); } - - /// - public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + else { - return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); + this._flags = new Dictionary(flags); // shallow copy } - - /// - public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + changed.AddRange(this._flags.Keys.ToList()); + var @event = new ProviderEventPayload { - return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); - } + Type = ProviderEventTypes.ProviderConfigurationChanged, + ProviderName = this._metadata.Name, + FlagsChanged = changed, // emit all + Message = "flags changed", + }; - /// - public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); - } + await this.EventChannel.Writer.WriteAsync(@event).ConfigureAwait(false); + } - /// - public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); - } + /// + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); + } + + /// + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); + } + + /// + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); + } - /// - public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + /// + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); + } + + /// + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); + } + + private ResolutionDetails Resolve(string flagKey, T defaultValue, EvaluationContext? context) + { + if (!this._flags.TryGetValue(flagKey, out var flag)) { - return Task.FromResult(this.Resolve(flagKey, defaultValue, context)); + return new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, Reason.Error); } - private ResolutionDetails Resolve(string flagKey, T defaultValue, EvaluationContext? context) + // This check returns False if a floating point flag is evaluated as an integer flag, and vice-versa. + // In a production provider, such behavior is probably not desirable; consider supporting conversion. + if (flag is Flag value) { - if (!this._flags.TryGetValue(flagKey, out var flag)) - { - return new ResolutionDetails(flagKey, defaultValue, ErrorType.FlagNotFound, Reason.Error); - } - - // This check returns False if a floating point flag is evaluated as an integer flag, and vice-versa. - // In a production provider, such behavior is probably not desirable; consider supporting conversion. - if (flag is Flag value) - { - return value.Evaluate(flagKey, defaultValue, context); - } - - return new ResolutionDetails(flagKey, defaultValue, ErrorType.TypeMismatch, Reason.Error); + return value.Evaluate(flagKey, defaultValue, context); } + + return new ResolutionDetails(flagKey, defaultValue, ErrorType.TypeMismatch, Reason.Error); } } diff --git a/src/OpenFeature/SharedHookContext.cs b/src/OpenFeature/SharedHookContext.cs index 3d6b787c6..c364e40ca 100644 --- a/src/OpenFeature/SharedHookContext.cs +++ b/src/OpenFeature/SharedHookContext.cs @@ -2,59 +2,58 @@ using OpenFeature.Constant; using OpenFeature.Model; -namespace OpenFeature +namespace OpenFeature; + +/// +/// Component of the hook context which shared between all hook instances +/// +/// Feature flag key +/// Default value +/// Flag value type +/// Client metadata +/// Provider metadata +/// Flag value type +internal class SharedHookContext( + string? flagKey, + T defaultValue, + FlagValueType flagValueType, + ClientMetadata? clientMetadata, + Metadata? providerMetadata) { /// - /// Component of the hook context which shared between all hook instances + /// Feature flag being evaluated /// - /// Feature flag key - /// Default value - /// Flag value type - /// Client metadata - /// Provider metadata - /// Flag value type - internal class SharedHookContext( - string? flagKey, - T defaultValue, - FlagValueType flagValueType, - ClientMetadata? clientMetadata, - Metadata? providerMetadata) - { - /// - /// Feature flag being evaluated - /// - public string FlagKey { get; } = flagKey ?? throw new ArgumentNullException(nameof(flagKey)); + public string FlagKey { get; } = flagKey ?? throw new ArgumentNullException(nameof(flagKey)); - /// - /// Default value if flag fails to be evaluated - /// - public T DefaultValue { get; } = defaultValue; + /// + /// Default value if flag fails to be evaluated + /// + public T DefaultValue { get; } = defaultValue; - /// - /// The value type of the flag - /// - public FlagValueType FlagValueType { get; } = flagValueType; + /// + /// The value type of the flag + /// + public FlagValueType FlagValueType { get; } = flagValueType; - /// - /// Client metadata - /// - public ClientMetadata ClientMetadata { get; } = - clientMetadata ?? throw new ArgumentNullException(nameof(clientMetadata)); + /// + /// Client metadata + /// + public ClientMetadata ClientMetadata { get; } = + clientMetadata ?? throw new ArgumentNullException(nameof(clientMetadata)); - /// - /// Provider metadata - /// - public Metadata ProviderMetadata { get; } = - providerMetadata ?? throw new ArgumentNullException(nameof(providerMetadata)); + /// + /// Provider metadata + /// + public Metadata ProviderMetadata { get; } = + providerMetadata ?? throw new ArgumentNullException(nameof(providerMetadata)); - /// - /// Create a hook context from this shared context. - /// - /// Evaluation context - /// A hook context - public HookContext ToHookContext(EvaluationContext? evaluationContext) - { - return new HookContext(this, evaluationContext, new HookData()); - } + /// + /// Create a hook context from this shared context. + /// + /// Evaluation context + /// A hook context + public HookContext ToHookContext(EvaluationContext? evaluationContext) + { + return new HookContext(this, evaluationContext, new HookData()); } } diff --git a/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs b/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs index 03650144c..c2779a31d 100644 --- a/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs +++ b/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs @@ -7,100 +7,99 @@ using BenchmarkDotNet.Jobs; using OpenFeature.Model; -namespace OpenFeature.Benchmark +namespace OpenFeature.Benchmark; + +[MemoryDiagnoser] +[SimpleJob(RuntimeMoniker.Net60, baseline: true)] +[JsonExporterAttribute.Full] +[JsonExporterAttribute.FullCompressed] +[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] +public class OpenFeatureClientBenchmarks { - [MemoryDiagnoser] - [SimpleJob(RuntimeMoniker.Net60, baseline: true)] - [JsonExporterAttribute.Full] - [JsonExporterAttribute.FullCompressed] - [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] - public class OpenFeatureClientBenchmarks + private readonly string _domain; + private readonly string _clientVersion; + private readonly string _flagName; + private readonly bool _defaultBoolValue; + private readonly string _defaultStringValue; + private readonly int _defaultIntegerValue; + private readonly double _defaultDoubleValue; + private readonly Value _defaultStructureValue; + private readonly FlagEvaluationOptions _emptyFlagOptions; + private readonly FeatureClient _client; + + public OpenFeatureClientBenchmarks() { - private readonly string _domain; - private readonly string _clientVersion; - private readonly string _flagName; - private readonly bool _defaultBoolValue; - private readonly string _defaultStringValue; - private readonly int _defaultIntegerValue; - private readonly double _defaultDoubleValue; - private readonly Value _defaultStructureValue; - private readonly FlagEvaluationOptions _emptyFlagOptions; - private readonly FeatureClient _client; - - public OpenFeatureClientBenchmarks() - { - var fixture = new Fixture(); - this._domain = fixture.Create(); - this._clientVersion = fixture.Create(); - this._flagName = fixture.Create(); - this._defaultBoolValue = fixture.Create(); - this._defaultStringValue = fixture.Create(); - this._defaultIntegerValue = fixture.Create(); - this._defaultDoubleValue = fixture.Create(); - this._defaultStructureValue = fixture.Create(); - this._emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); - - this._client = Api.Instance.GetClient(this._domain, this._clientVersion); - } - - [Benchmark] - public async Task OpenFeatureClient_GetBooleanValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => - await this._client.GetBooleanValueAsync(this._flagName, this._defaultBoolValue); - - [Benchmark] - public async Task OpenFeatureClient_GetBooleanValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => - await this._client.GetBooleanValueAsync(this._flagName, this._defaultBoolValue, EvaluationContext.Empty); - - [Benchmark] - public async Task OpenFeatureClient_GetBooleanValue_WithEmptyEvaluationContext_WithEmptyFlagEvaluationOptions() => - await this._client.GetBooleanValueAsync(this._flagName, this._defaultBoolValue, EvaluationContext.Empty, this._emptyFlagOptions); - - [Benchmark] - public async Task OpenFeatureClient_GetStringValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => - await this._client.GetStringValueAsync(this._flagName, this._defaultStringValue); - - [Benchmark] - public async Task OpenFeatureClient_GetStringValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => - await this._client.GetStringValueAsync(this._flagName, this._defaultStringValue, EvaluationContext.Empty); - - [Benchmark] - public async Task OpenFeatureClient_GetStringValue_WithoutEvaluationContext_WithEmptyFlagEvaluationOptions() => - await this._client.GetStringValueAsync(this._flagName, this._defaultStringValue, EvaluationContext.Empty, this._emptyFlagOptions); - - [Benchmark] - public async Task OpenFeatureClient_GetIntegerValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => - await this._client.GetIntegerValueAsync(this._flagName, this._defaultIntegerValue); - - [Benchmark] - public async Task OpenFeatureClient_GetIntegerValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => - await this._client.GetIntegerValueAsync(this._flagName, this._defaultIntegerValue, EvaluationContext.Empty); - - [Benchmark] - public async Task OpenFeatureClient_GetIntegerValue_WithEmptyEvaluationContext_WithEmptyFlagEvaluationOptions() => - await this._client.GetIntegerValueAsync(this._flagName, this._defaultIntegerValue, EvaluationContext.Empty, this._emptyFlagOptions); - - [Benchmark] - public async Task OpenFeatureClient_GetDoubleValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => - await this._client.GetDoubleValueAsync(this._flagName, this._defaultDoubleValue); - - [Benchmark] - public async Task OpenFeatureClient_GetDoubleValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => - await this._client.GetDoubleValueAsync(this._flagName, this._defaultDoubleValue, EvaluationContext.Empty); - - [Benchmark] - public async Task OpenFeatureClient_GetDoubleValue_WithEmptyEvaluationContext_WithEmptyFlagEvaluationOptions() => - await this._client.GetDoubleValueAsync(this._flagName, this._defaultDoubleValue, EvaluationContext.Empty, this._emptyFlagOptions); - - [Benchmark] - public async Task OpenFeatureClient_GetObjectValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => - await this._client.GetObjectValueAsync(this._flagName, this._defaultStructureValue); - - [Benchmark] - public async Task OpenFeatureClient_GetObjectValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => - await this._client.GetObjectValueAsync(this._flagName, this._defaultStructureValue, EvaluationContext.Empty); - - [Benchmark] - public async Task OpenFeatureClient_GetObjectValue_WithEmptyEvaluationContext_WithEmptyFlagEvaluationOptions() => - await this._client.GetObjectValueAsync(this._flagName, this._defaultStructureValue, EvaluationContext.Empty, this._emptyFlagOptions); + var fixture = new Fixture(); + this._domain = fixture.Create(); + this._clientVersion = fixture.Create(); + this._flagName = fixture.Create(); + this._defaultBoolValue = fixture.Create(); + this._defaultStringValue = fixture.Create(); + this._defaultIntegerValue = fixture.Create(); + this._defaultDoubleValue = fixture.Create(); + this._defaultStructureValue = fixture.Create(); + this._emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); + + this._client = Api.Instance.GetClient(this._domain, this._clientVersion); } + + [Benchmark] + public async Task OpenFeatureClient_GetBooleanValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetBooleanValueAsync(this._flagName, this._defaultBoolValue); + + [Benchmark] + public async Task OpenFeatureClient_GetBooleanValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetBooleanValueAsync(this._flagName, this._defaultBoolValue, EvaluationContext.Empty); + + [Benchmark] + public async Task OpenFeatureClient_GetBooleanValue_WithEmptyEvaluationContext_WithEmptyFlagEvaluationOptions() => + await this._client.GetBooleanValueAsync(this._flagName, this._defaultBoolValue, EvaluationContext.Empty, this._emptyFlagOptions); + + [Benchmark] + public async Task OpenFeatureClient_GetStringValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetStringValueAsync(this._flagName, this._defaultStringValue); + + [Benchmark] + public async Task OpenFeatureClient_GetStringValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetStringValueAsync(this._flagName, this._defaultStringValue, EvaluationContext.Empty); + + [Benchmark] + public async Task OpenFeatureClient_GetStringValue_WithoutEvaluationContext_WithEmptyFlagEvaluationOptions() => + await this._client.GetStringValueAsync(this._flagName, this._defaultStringValue, EvaluationContext.Empty, this._emptyFlagOptions); + + [Benchmark] + public async Task OpenFeatureClient_GetIntegerValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetIntegerValueAsync(this._flagName, this._defaultIntegerValue); + + [Benchmark] + public async Task OpenFeatureClient_GetIntegerValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetIntegerValueAsync(this._flagName, this._defaultIntegerValue, EvaluationContext.Empty); + + [Benchmark] + public async Task OpenFeatureClient_GetIntegerValue_WithEmptyEvaluationContext_WithEmptyFlagEvaluationOptions() => + await this._client.GetIntegerValueAsync(this._flagName, this._defaultIntegerValue, EvaluationContext.Empty, this._emptyFlagOptions); + + [Benchmark] + public async Task OpenFeatureClient_GetDoubleValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetDoubleValueAsync(this._flagName, this._defaultDoubleValue); + + [Benchmark] + public async Task OpenFeatureClient_GetDoubleValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetDoubleValueAsync(this._flagName, this._defaultDoubleValue, EvaluationContext.Empty); + + [Benchmark] + public async Task OpenFeatureClient_GetDoubleValue_WithEmptyEvaluationContext_WithEmptyFlagEvaluationOptions() => + await this._client.GetDoubleValueAsync(this._flagName, this._defaultDoubleValue, EvaluationContext.Empty, this._emptyFlagOptions); + + [Benchmark] + public async Task OpenFeatureClient_GetObjectValue_WithoutEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetObjectValueAsync(this._flagName, this._defaultStructureValue); + + [Benchmark] + public async Task OpenFeatureClient_GetObjectValue_WithEmptyEvaluationContext_WithoutFlagEvaluationOptions() => + await this._client.GetObjectValueAsync(this._flagName, this._defaultStructureValue, EvaluationContext.Empty); + + [Benchmark] + public async Task OpenFeatureClient_GetObjectValue_WithEmptyEvaluationContext_WithEmptyFlagEvaluationOptions() => + await this._client.GetObjectValueAsync(this._flagName, this._defaultStructureValue, EvaluationContext.Empty, this._emptyFlagOptions); } diff --git a/test/OpenFeature.Benchmarks/Program.cs b/test/OpenFeature.Benchmarks/Program.cs index 0738b2725..00be344ad 100644 --- a/test/OpenFeature.Benchmarks/Program.cs +++ b/test/OpenFeature.Benchmarks/Program.cs @@ -1,12 +1,11 @@ using BenchmarkDotNet.Running; -namespace OpenFeature.Benchmark +namespace OpenFeature.Benchmark; + +internal class Program { - internal class Program + static void Main(string[] args) { - static void Main(string[] args) - { - BenchmarkRunner.Run(); - } + BenchmarkRunner.Run(); } } diff --git a/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs b/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs index 1498056ff..334f664e4 100644 --- a/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs +++ b/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs @@ -4,65 +4,64 @@ using OpenFeature.Extension; using Xunit; -namespace OpenFeature.Tests +namespace OpenFeature.Tests; + +public class FeatureProviderExceptionTests { - public class FeatureProviderExceptionTests + [Theory] + [InlineData(ErrorType.General, "GENERAL")] + [InlineData(ErrorType.ParseError, "PARSE_ERROR")] + [InlineData(ErrorType.TypeMismatch, "TYPE_MISMATCH")] + [InlineData(ErrorType.FlagNotFound, "FLAG_NOT_FOUND")] + [InlineData(ErrorType.ProviderNotReady, "PROVIDER_NOT_READY")] + public void FeatureProviderException_Should_Resolve_Description(ErrorType errorType, string errorDescription) { - [Theory] - [InlineData(ErrorType.General, "GENERAL")] - [InlineData(ErrorType.ParseError, "PARSE_ERROR")] - [InlineData(ErrorType.TypeMismatch, "TYPE_MISMATCH")] - [InlineData(ErrorType.FlagNotFound, "FLAG_NOT_FOUND")] - [InlineData(ErrorType.ProviderNotReady, "PROVIDER_NOT_READY")] - public void FeatureProviderException_Should_Resolve_Description(ErrorType errorType, string errorDescription) - { - var ex = new FeatureProviderException(errorType); + var ex = new FeatureProviderException(errorType); - Assert.Equal(errorDescription, ex.ErrorType.GetDescription()); - } + Assert.Equal(errorDescription, ex.ErrorType.GetDescription()); + } - [Theory] - [InlineData(ErrorType.General, "Subscription has expired, please renew your subscription.")] - [InlineData(ErrorType.ProviderNotReady, "User has exceeded the quota for this feature.")] - public void FeatureProviderException_Should_Allow_Custom_ErrorCode_Messages(ErrorType errorCode, string message) - { - var ex = new FeatureProviderException(errorCode, message, new ArgumentOutOfRangeException("flag")); + [Theory] + [InlineData(ErrorType.General, "Subscription has expired, please renew your subscription.")] + [InlineData(ErrorType.ProviderNotReady, "User has exceeded the quota for this feature.")] + public void FeatureProviderException_Should_Allow_Custom_ErrorCode_Messages(ErrorType errorCode, string message) + { + var ex = new FeatureProviderException(errorCode, message, new ArgumentOutOfRangeException("flag")); - Assert.Equal(errorCode, ex.ErrorType); - Assert.Equal(message, ex.Message); - Assert.IsType(ex.InnerException); - } + Assert.Equal(errorCode, ex.ErrorType); + Assert.Equal(message, ex.Message); + Assert.IsType(ex.InnerException); + } - private enum TestEnum - { - TestValueWithoutDescription - } + private enum TestEnum + { + TestValueWithoutDescription + } - [Fact] - public void GetDescription_WhenCalledWithEnumWithoutDescription_ReturnsEnumName() - { - // Arrange - var testEnum = TestEnum.TestValueWithoutDescription; - var expectedDescription = "TestValueWithoutDescription"; + [Fact] + public void GetDescription_WhenCalledWithEnumWithoutDescription_ReturnsEnumName() + { + // Arrange + var testEnum = TestEnum.TestValueWithoutDescription; + var expectedDescription = "TestValueWithoutDescription"; - // Act - var actualDescription = testEnum.GetDescription(); + // Act + var actualDescription = testEnum.GetDescription(); - // Assert - Assert.Equal(expectedDescription, actualDescription); - } + // Assert + Assert.Equal(expectedDescription, actualDescription); + } - [Fact] - public void GetDescription_WhenFieldIsNull_ReturnsEnumValueAsString() - { - // Arrange - var testEnum = (TestEnum)999;// This value should not exist in the TestEnum + [Fact] + public void GetDescription_WhenFieldIsNull_ReturnsEnumValueAsString() + { + // Arrange + var testEnum = (TestEnum)999;// This value should not exist in the TestEnum - // Act - var description = testEnum.GetDescription(); + // Act + var description = testEnum.GetDescription(); - // Assert - Assert.Equal(testEnum.ToString(), description); - } + // Assert + Assert.Equal(testEnum.ToString(), description); } } diff --git a/test/OpenFeature.Tests/FeatureProviderTests.cs b/test/OpenFeature.Tests/FeatureProviderTests.cs index ce1de36b9..79ab2f00e 100644 --- a/test/OpenFeature.Tests/FeatureProviderTests.cs +++ b/test/OpenFeature.Tests/FeatureProviderTests.cs @@ -7,132 +7,131 @@ using OpenFeature.Tests.Internal; using Xunit; -namespace OpenFeature.Tests +namespace OpenFeature.Tests; + +[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] +public class FeatureProviderTests : ClearOpenFeatureInstanceFixture { - [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] - public class FeatureProviderTests : ClearOpenFeatureInstanceFixture + [Fact] + [Specification("2.1.1", "The provider interface MUST define a `metadata` member or accessor, containing a `name` field or accessor of type string, which identifies the provider implementation.")] + public void Provider_Must_Have_Metadata() + { + var provider = new TestProvider(); + + Assert.Equal(TestProvider.DefaultName, provider.GetMetadata().Name); + } + + [Fact] + [Specification("2.2.1", "The `feature provider` interface MUST define methods to resolve flag values, with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required) and `evaluation context` (optional), which returns a `resolution details` structure.")] + [Specification("2.2.2.1", "The `feature provider` interface MUST define methods for typed flag resolution, including boolean, numeric, string, and structure.")] + [Specification("2.2.3", "In cases of normal execution, the `provider` MUST populate the `resolution details` structure's `value` field with the resolved flag value.")] + [Specification("2.2.4", "In cases of normal execution, the `provider` SHOULD populate the `resolution details` structure's `variant` field with a string identifier corresponding to the returned flag value.")] + [Specification("2.2.5", "The `provider` SHOULD populate the `resolution details` structure's `reason` field with `\"STATIC\"`, `\"DEFAULT\",` `\"TARGETING_MATCH\"`, `\"SPLIT\"`, `\"CACHED\"`, `\"DISABLED\"`, `\"UNKNOWN\"`, `\"ERROR\"` or some other string indicating the semantic reason for the returned flag value.")] + [Specification("2.2.6", "In cases of normal execution, the `provider` MUST NOT populate the `resolution details` structure's `error code` field, or otherwise must populate it with a null or falsy value.")] + [Specification("2.2.8.1", "The `resolution details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] + [Specification("2.3.2", "In cases of normal execution, the `provider` MUST NOT populate the `resolution details` structure's `error message` field, or otherwise must populate it with a null or falsy value.")] + public async Task Provider_Must_Resolve_Flag_Values() + { + var fixture = new Fixture(); + var flagName = fixture.Create(); + var defaultBoolValue = fixture.Create(); + var defaultStringValue = fixture.Create(); + var defaultIntegerValue = fixture.Create(); + var defaultDoubleValue = fixture.Create(); + var defaultStructureValue = fixture.Create(); + var provider = new NoOpFeatureProvider(); + + var boolResolutionDetails = new ResolutionDetails(flagName, defaultBoolValue, ErrorType.None, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(boolResolutionDetails, await provider.ResolveBooleanValueAsync(flagName, defaultBoolValue)); + + var integerResolutionDetails = new ResolutionDetails(flagName, defaultIntegerValue, ErrorType.None, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(integerResolutionDetails, await provider.ResolveIntegerValueAsync(flagName, defaultIntegerValue)); + + var doubleResolutionDetails = new ResolutionDetails(flagName, defaultDoubleValue, ErrorType.None, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(doubleResolutionDetails, await provider.ResolveDoubleValueAsync(flagName, defaultDoubleValue)); + + var stringResolutionDetails = new ResolutionDetails(flagName, defaultStringValue, ErrorType.None, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(stringResolutionDetails, await provider.ResolveStringValueAsync(flagName, defaultStringValue)); + + var structureResolutionDetails = new ResolutionDetails(flagName, defaultStructureValue, + ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(structureResolutionDetails, await provider.ResolveStructureValueAsync(flagName, defaultStructureValue)); + } + + [Fact] + [Specification("2.2.7", "In cases of abnormal execution, the `provider` MUST indicate an error using the idioms of the implementation language, with an associated `error code` and optional associated `error message`.")] + [Specification("2.3.3", "In cases of abnormal execution, the `resolution details` structure's `error message` field MAY contain a string containing additional detail about the nature of the error.")] + public async Task Provider_Must_ErrorType() { - [Fact] - [Specification("2.1.1", "The provider interface MUST define a `metadata` member or accessor, containing a `name` field or accessor of type string, which identifies the provider implementation.")] - public void Provider_Must_Have_Metadata() - { - var provider = new TestProvider(); - - Assert.Equal(TestProvider.DefaultName, provider.GetMetadata().Name); - } - - [Fact] - [Specification("2.2.1", "The `feature provider` interface MUST define methods to resolve flag values, with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required) and `evaluation context` (optional), which returns a `resolution details` structure.")] - [Specification("2.2.2.1", "The `feature provider` interface MUST define methods for typed flag resolution, including boolean, numeric, string, and structure.")] - [Specification("2.2.3", "In cases of normal execution, the `provider` MUST populate the `resolution details` structure's `value` field with the resolved flag value.")] - [Specification("2.2.4", "In cases of normal execution, the `provider` SHOULD populate the `resolution details` structure's `variant` field with a string identifier corresponding to the returned flag value.")] - [Specification("2.2.5", "The `provider` SHOULD populate the `resolution details` structure's `reason` field with `\"STATIC\"`, `\"DEFAULT\",` `\"TARGETING_MATCH\"`, `\"SPLIT\"`, `\"CACHED\"`, `\"DISABLED\"`, `\"UNKNOWN\"`, `\"ERROR\"` or some other string indicating the semantic reason for the returned flag value.")] - [Specification("2.2.6", "In cases of normal execution, the `provider` MUST NOT populate the `resolution details` structure's `error code` field, or otherwise must populate it with a null or falsy value.")] - [Specification("2.2.8.1", "The `resolution details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] - [Specification("2.3.2", "In cases of normal execution, the `provider` MUST NOT populate the `resolution details` structure's `error message` field, or otherwise must populate it with a null or falsy value.")] - public async Task Provider_Must_Resolve_Flag_Values() - { - var fixture = new Fixture(); - var flagName = fixture.Create(); - var defaultBoolValue = fixture.Create(); - var defaultStringValue = fixture.Create(); - var defaultIntegerValue = fixture.Create(); - var defaultDoubleValue = fixture.Create(); - var defaultStructureValue = fixture.Create(); - var provider = new NoOpFeatureProvider(); - - var boolResolutionDetails = new ResolutionDetails(flagName, defaultBoolValue, ErrorType.None, - NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - Assert.Equivalent(boolResolutionDetails, await provider.ResolveBooleanValueAsync(flagName, defaultBoolValue)); - - var integerResolutionDetails = new ResolutionDetails(flagName, defaultIntegerValue, ErrorType.None, - NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - Assert.Equivalent(integerResolutionDetails, await provider.ResolveIntegerValueAsync(flagName, defaultIntegerValue)); - - var doubleResolutionDetails = new ResolutionDetails(flagName, defaultDoubleValue, ErrorType.None, - NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - Assert.Equivalent(doubleResolutionDetails, await provider.ResolveDoubleValueAsync(flagName, defaultDoubleValue)); - - var stringResolutionDetails = new ResolutionDetails(flagName, defaultStringValue, ErrorType.None, - NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - Assert.Equivalent(stringResolutionDetails, await provider.ResolveStringValueAsync(flagName, defaultStringValue)); - - var structureResolutionDetails = new ResolutionDetails(flagName, defaultStructureValue, - ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - Assert.Equivalent(structureResolutionDetails, await provider.ResolveStructureValueAsync(flagName, defaultStructureValue)); - } - - [Fact] - [Specification("2.2.7", "In cases of abnormal execution, the `provider` MUST indicate an error using the idioms of the implementation language, with an associated `error code` and optional associated `error message`.")] - [Specification("2.3.3", "In cases of abnormal execution, the `resolution details` structure's `error message` field MAY contain a string containing additional detail about the nature of the error.")] - public async Task Provider_Must_ErrorType() - { - var fixture = new Fixture(); - var flagName = fixture.Create(); - var flagName2 = fixture.Create(); - var defaultBoolValue = fixture.Create(); - var defaultStringValue = fixture.Create(); - var defaultIntegerValue = fixture.Create(); - var defaultDoubleValue = fixture.Create(); - var defaultStructureValue = fixture.Create(); - var providerMock = Substitute.For(); - const string testMessage = "An error message"; - - providerMock.ResolveBooleanValueAsync(flagName, defaultBoolValue, Arg.Any()) - .Returns(new ResolutionDetails(flagName, defaultBoolValue, ErrorType.General, - NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); - - providerMock.ResolveIntegerValueAsync(flagName, defaultIntegerValue, Arg.Any()) - .Returns(new ResolutionDetails(flagName, defaultIntegerValue, ErrorType.ParseError, - NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); - - providerMock.ResolveDoubleValueAsync(flagName, defaultDoubleValue, Arg.Any()) - .Returns(new ResolutionDetails(flagName, defaultDoubleValue, ErrorType.InvalidContext, - NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); - - providerMock.ResolveStringValueAsync(flagName, defaultStringValue, Arg.Any()) - .Returns(new ResolutionDetails(flagName, defaultStringValue, ErrorType.TypeMismatch, - NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); - - providerMock.ResolveStructureValueAsync(flagName, defaultStructureValue, Arg.Any()) - .Returns(new ResolutionDetails(flagName, defaultStructureValue, ErrorType.FlagNotFound, - NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); - - providerMock.ResolveStructureValueAsync(flagName2, defaultStructureValue, Arg.Any()) - .Returns(new ResolutionDetails(flagName2, defaultStructureValue, ErrorType.ProviderNotReady, - NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); - - providerMock.ResolveBooleanValueAsync(flagName2, defaultBoolValue, Arg.Any()) - .Returns(new ResolutionDetails(flagName2, defaultBoolValue, ErrorType.TargetingKeyMissing, - NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); - - var boolRes = await providerMock.ResolveBooleanValueAsync(flagName, defaultBoolValue); - Assert.Equal(ErrorType.General, boolRes.ErrorType); - Assert.Equal(testMessage, boolRes.ErrorMessage); - - var intRes = await providerMock.ResolveIntegerValueAsync(flagName, defaultIntegerValue); - Assert.Equal(ErrorType.ParseError, intRes.ErrorType); - Assert.Equal(testMessage, intRes.ErrorMessage); - - var doubleRes = await providerMock.ResolveDoubleValueAsync(flagName, defaultDoubleValue); - Assert.Equal(ErrorType.InvalidContext, doubleRes.ErrorType); - Assert.Equal(testMessage, doubleRes.ErrorMessage); - - var stringRes = await providerMock.ResolveStringValueAsync(flagName, defaultStringValue); - Assert.Equal(ErrorType.TypeMismatch, stringRes.ErrorType); - Assert.Equal(testMessage, stringRes.ErrorMessage); - - var structRes1 = await providerMock.ResolveStructureValueAsync(flagName, defaultStructureValue); - Assert.Equal(ErrorType.FlagNotFound, structRes1.ErrorType); - Assert.Equal(testMessage, structRes1.ErrorMessage); - - var structRes2 = await providerMock.ResolveStructureValueAsync(flagName2, defaultStructureValue); - Assert.Equal(ErrorType.ProviderNotReady, structRes2.ErrorType); - Assert.Equal(testMessage, structRes2.ErrorMessage); - - var boolRes2 = await providerMock.ResolveBooleanValueAsync(flagName2, defaultBoolValue); - Assert.Equal(ErrorType.TargetingKeyMissing, boolRes2.ErrorType); - Assert.Null(boolRes2.ErrorMessage); - } + var fixture = new Fixture(); + var flagName = fixture.Create(); + var flagName2 = fixture.Create(); + var defaultBoolValue = fixture.Create(); + var defaultStringValue = fixture.Create(); + var defaultIntegerValue = fixture.Create(); + var defaultDoubleValue = fixture.Create(); + var defaultStructureValue = fixture.Create(); + var providerMock = Substitute.For(); + const string testMessage = "An error message"; + + providerMock.ResolveBooleanValueAsync(flagName, defaultBoolValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName, defaultBoolValue, ErrorType.General, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); + + providerMock.ResolveIntegerValueAsync(flagName, defaultIntegerValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName, defaultIntegerValue, ErrorType.ParseError, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); + + providerMock.ResolveDoubleValueAsync(flagName, defaultDoubleValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName, defaultDoubleValue, ErrorType.InvalidContext, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); + + providerMock.ResolveStringValueAsync(flagName, defaultStringValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName, defaultStringValue, ErrorType.TypeMismatch, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); + + providerMock.ResolveStructureValueAsync(flagName, defaultStructureValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName, defaultStructureValue, ErrorType.FlagNotFound, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); + + providerMock.ResolveStructureValueAsync(flagName2, defaultStructureValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName2, defaultStructureValue, ErrorType.ProviderNotReady, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant, testMessage)); + + providerMock.ResolveBooleanValueAsync(flagName2, defaultBoolValue, Arg.Any()) + .Returns(new ResolutionDetails(flagName2, defaultBoolValue, ErrorType.TargetingKeyMissing, + NoOpProvider.ReasonNoOp, NoOpProvider.Variant)); + + var boolRes = await providerMock.ResolveBooleanValueAsync(flagName, defaultBoolValue); + Assert.Equal(ErrorType.General, boolRes.ErrorType); + Assert.Equal(testMessage, boolRes.ErrorMessage); + + var intRes = await providerMock.ResolveIntegerValueAsync(flagName, defaultIntegerValue); + Assert.Equal(ErrorType.ParseError, intRes.ErrorType); + Assert.Equal(testMessage, intRes.ErrorMessage); + + var doubleRes = await providerMock.ResolveDoubleValueAsync(flagName, defaultDoubleValue); + Assert.Equal(ErrorType.InvalidContext, doubleRes.ErrorType); + Assert.Equal(testMessage, doubleRes.ErrorMessage); + + var stringRes = await providerMock.ResolveStringValueAsync(flagName, defaultStringValue); + Assert.Equal(ErrorType.TypeMismatch, stringRes.ErrorType); + Assert.Equal(testMessage, stringRes.ErrorMessage); + + var structRes1 = await providerMock.ResolveStructureValueAsync(flagName, defaultStructureValue); + Assert.Equal(ErrorType.FlagNotFound, structRes1.ErrorType); + Assert.Equal(testMessage, structRes1.ErrorMessage); + + var structRes2 = await providerMock.ResolveStructureValueAsync(flagName2, defaultStructureValue); + Assert.Equal(ErrorType.ProviderNotReady, structRes2.ErrorType); + Assert.Equal(testMessage, structRes2.ErrorMessage); + + var boolRes2 = await providerMock.ResolveBooleanValueAsync(flagName2, defaultBoolValue); + Assert.Equal(ErrorType.TargetingKeyMissing, boolRes2.ErrorType); + Assert.Null(boolRes2.ErrorMessage); } } diff --git a/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs b/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs index 7f2995043..461455eb1 100644 --- a/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs +++ b/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs @@ -8,661 +8,660 @@ using OpenFeature.Model; using Xunit; -namespace OpenFeature.Tests.Hooks +namespace OpenFeature.Tests.Hooks; + +[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] + +public class LoggingHookTests { - [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] + [Fact] + public async Task BeforeAsync_Without_EvaluationContext_Generates_Debug_Log() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var hook = new LoggingHook(logger, includeContext: false); + + // Act + await hook.BeforeAsync(context); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + + Assert.Equal(LogLevel.Debug, record.Level); + Assert.Equal( + """ + Before Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task BeforeAsync_Without_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var hook = new LoggingHook(logger, includeContext: false); + + // Act + await hook.BeforeAsync(context); + + // Assert + var record = logger.LatestRecord; + + Assert.Equal( + """ + Before Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task BeforeAsync_With_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var timestamp = DateTime.Parse("2025-01-01T11:00:00.0000000Z"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", "value") + .Set("key_2", false) + .Set("key_3", 1.531) + .Set("key_4", 42) + .Set("key_5", timestamp) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.BeforeAsync(context); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Debug, record.Level); + + Assert.Multiple( + () => Assert.Contains("key_1:value", record.Message), + () => Assert.Contains("key_2:False", record.Message), + () => Assert.Contains("key_3:1.531", record.Message), + () => Assert.Contains("key_4:42", record.Message), + () => Assert.Contains($"key_5:{timestamp:O}", record.Message) + ); + } + + [Fact] + public async Task BeforeAsync_With_No_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + // Act + var hook = new LoggingHook(logger, includeContext: true); + + await hook.BeforeAsync(context); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Debug, record.Level); + + Assert.Equal( + """ + Before Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + Context: + + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task ErrorAsync_Without_EvaluationContext_Generates_Error_Log() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var hook = new LoggingHook(logger, includeContext: false); + + var exception = new Exception("Error within hook!"); + + // Act + await hook.ErrorAsync(context, exception); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + + Assert.Equal(LogLevel.Error, record.Level); + } + + [Fact] + public async Task ErrorAsync_Without_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var hook = new LoggingHook(logger, includeContext: false); + + var exception = new Exception("Error within hook!"); + + // Act + await hook.ErrorAsync(context, exception); + + // Assert + var record = logger.LatestRecord; + + Assert.Equal( + """ + Error during Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task ErrorAsync_With_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + + var timestamp = DateTime.Parse("2099-01-01T01:00:00.0000000Z"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", " ") + .Set("key_2", true) + .Set("key_3", 0.002154) + .Set("key_4", -15) + .Set("key_5", timestamp) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var hook = new LoggingHook(logger, includeContext: true); - public class LoggingHookTests + var exception = new Exception("Error within hook!"); + + // Act + await hook.ErrorAsync(context, exception); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Error, record.Level); + + Assert.Multiple( + () => Assert.Contains("key_1: ", record.Message), + () => Assert.Contains("key_2:True", record.Message), + () => Assert.Contains("key_3:0.002154", record.Message), + () => Assert.Contains("key_4:-15", record.Message), + () => Assert.Contains($"key_5:{timestamp:O}", record.Message) + ); + } + + [Fact] + public async Task ErrorAsync_With_No_EvaluationContext_Generates_Correct_Log_Message() { - [Fact] - public async Task BeforeAsync_Without_EvaluationContext_Generates_Debug_Log() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, EvaluationContext.Empty); - - var hook = new LoggingHook(logger, includeContext: false); - - // Act - await hook.BeforeAsync(context); - - // Assert - Assert.Equal(1, logger.Collector.Count); - - var record = logger.LatestRecord; - - Assert.Equal(LogLevel.Debug, record.Level); - Assert.Equal( - """ - Before Flag Evaluation Domain:client - ProviderName:provider - FlagKey:test - DefaultValue:False - - """, - record.Message, - ignoreLineEndingDifferences: true - ); - } - - [Fact] - public async Task BeforeAsync_Without_EvaluationContext_Generates_Correct_Log_Message() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, EvaluationContext.Empty); - - var hook = new LoggingHook(logger, includeContext: false); - - // Act - await hook.BeforeAsync(context); - - // Assert - var record = logger.LatestRecord; - - Assert.Equal( - """ - Before Flag Evaluation Domain:client - ProviderName:provider - FlagKey:test - DefaultValue:False - - """, - record.Message, - ignoreLineEndingDifferences: true - ); - } - - [Fact] - public async Task BeforeAsync_With_EvaluationContext_Generates_Correct_Log_Message() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var timestamp = DateTime.Parse("2025-01-01T11:00:00.0000000Z"); - var evaluationContext = EvaluationContext.Builder() - .Set("key_1", "value") - .Set("key_2", false) - .Set("key_3", 1.531) - .Set("key_4", 42) - .Set("key_5", timestamp) - .Build(); - - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, evaluationContext); - - var hook = new LoggingHook(logger, includeContext: true); - - // Act - await hook.BeforeAsync(context); - - // Assert - Assert.Equal(1, logger.Collector.Count); - - var record = logger.LatestRecord; - Assert.Equal(LogLevel.Debug, record.Level); - - Assert.Multiple( - () => Assert.Contains("key_1:value", record.Message), - () => Assert.Contains("key_2:False", record.Message), - () => Assert.Contains("key_3:1.531", record.Message), - () => Assert.Contains("key_4:42", record.Message), - () => Assert.Contains($"key_5:{timestamp:O}", record.Message) - ); - } - - [Fact] - public async Task BeforeAsync_With_No_EvaluationContext_Generates_Correct_Log_Message() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, EvaluationContext.Empty); - - // Act - var hook = new LoggingHook(logger, includeContext: true); - - await hook.BeforeAsync(context); - - // Assert - Assert.Equal(1, logger.Collector.Count); - - var record = logger.LatestRecord; - Assert.Equal(LogLevel.Debug, record.Level); - - Assert.Equal( - """ - Before Flag Evaluation Domain:client - ProviderName:provider - FlagKey:test - DefaultValue:False - Context: - - """, - record.Message, - ignoreLineEndingDifferences: true - ); - } - - [Fact] - public async Task ErrorAsync_Without_EvaluationContext_Generates_Error_Log() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, EvaluationContext.Empty); - - var hook = new LoggingHook(logger, includeContext: false); - - var exception = new Exception("Error within hook!"); - - // Act - await hook.ErrorAsync(context, exception); - - // Assert - Assert.Equal(1, logger.Collector.Count); - - var record = logger.LatestRecord; - - Assert.Equal(LogLevel.Error, record.Level); - } - - [Fact] - public async Task ErrorAsync_Without_EvaluationContext_Generates_Correct_Log_Message() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, EvaluationContext.Empty); + // Arrange + var logger = new FakeLogger(); - var hook = new LoggingHook(logger, includeContext: false); + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); - var exception = new Exception("Error within hook!"); + var hook = new LoggingHook(logger, includeContext: true); - // Act - await hook.ErrorAsync(context, exception); - - // Assert - var record = logger.LatestRecord; - - Assert.Equal( - """ - Error during Flag Evaluation Domain:client - ProviderName:provider - FlagKey:test - DefaultValue:False - - """, - record.Message, - ignoreLineEndingDifferences: true - ); - } + var exception = new Exception("Error within hook!"); - [Fact] - public async Task ErrorAsync_With_EvaluationContext_Generates_Correct_Log_Message() - { - // Arrange - var logger = new FakeLogger(); + // Act + await hook.ErrorAsync(context, exception); - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); + // Assert + Assert.Equal(1, logger.Collector.Count); - var timestamp = DateTime.Parse("2099-01-01T01:00:00.0000000Z"); - var evaluationContext = EvaluationContext.Builder() - .Set("key_1", " ") - .Set("key_2", true) - .Set("key_3", 0.002154) - .Set("key_4", -15) - .Set("key_5", timestamp) - .Build(); + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Error, record.Level); - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, evaluationContext); + Assert.Equal( + """ + Error during Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + Context: - var hook = new LoggingHook(logger, includeContext: true); + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } - var exception = new Exception("Error within hook!"); + [Fact] + public async Task AfterAsync_Without_EvaluationContext_Generates_Debug_Log() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: false); + + // Act + await hook.AfterAsync(context, details); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Debug, record.Level); + } - // Act - await hook.ErrorAsync(context, exception); + [Fact] + public async Task AfterAsync_Without_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); - // Assert - Assert.Equal(1, logger.Collector.Count); + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); - var record = logger.LatestRecord; - Assert.Equal(LogLevel.Error, record.Level); + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); - Assert.Multiple( - () => Assert.Contains("key_1: ", record.Message), - () => Assert.Contains("key_2:True", record.Message), - () => Assert.Contains("key_3:0.002154", record.Message), - () => Assert.Contains("key_4:-15", record.Message), - () => Assert.Contains($"key_5:{timestamp:O}", record.Message) - ); - } + var hook = new LoggingHook(logger, includeContext: false); - [Fact] - public async Task ErrorAsync_With_No_EvaluationContext_Generates_Correct_Log_Message() - { - // Arrange - var logger = new FakeLogger(); + // Act + await hook.AfterAsync(context, details); - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, EvaluationContext.Empty); - - var hook = new LoggingHook(logger, includeContext: true); - - var exception = new Exception("Error within hook!"); - - // Act - await hook.ErrorAsync(context, exception); - - // Assert - Assert.Equal(1, logger.Collector.Count); - - var record = logger.LatestRecord; - Assert.Equal(LogLevel.Error, record.Level); - - Assert.Equal( - """ - Error during Flag Evaluation Domain:client - ProviderName:provider - FlagKey:test - DefaultValue:False - Context: - - """, - record.Message, - ignoreLineEndingDifferences: true - ); - } - - [Fact] - public async Task AfterAsync_Without_EvaluationContext_Generates_Debug_Log() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, EvaluationContext.Empty); - - var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); - - var hook = new LoggingHook(logger, includeContext: false); - - // Act - await hook.AfterAsync(context, details); - - // Assert - Assert.Equal(1, logger.Collector.Count); - - var record = logger.LatestRecord; - Assert.Equal(LogLevel.Debug, record.Level); - } + // Assert + var record = logger.LatestRecord; - [Fact] - public async Task AfterAsync_Without_EvaluationContext_Generates_Correct_Log_Message() - { - // Arrange - var logger = new FakeLogger(); + Assert.Equal( + """ + After Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, EvaluationContext.Empty); + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } - var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + [Fact] + public async Task AfterAsync_With_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); - var hook = new LoggingHook(logger, includeContext: false); + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", "") + .Set("key_2", false) + .Set("key_3", double.MinValue) + .Set("key_4", int.MaxValue) + .Set("key_5", DateTime.MinValue) + .Build(); - // Act - await hook.AfterAsync(context, details); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); - // Assert - var record = logger.LatestRecord; - - Assert.Equal( - """ - After Flag Evaluation Domain:client - ProviderName:provider - FlagKey:test - DefaultValue:False - - """, - record.Message, - ignoreLineEndingDifferences: true - ); - } + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); - [Fact] - public async Task AfterAsync_With_EvaluationContext_Generates_Correct_Log_Message() - { - // Arrange - var logger = new FakeLogger(); + var hook = new LoggingHook(logger, includeContext: true); - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var evaluationContext = EvaluationContext.Builder() - .Set("key_1", "") - .Set("key_2", false) - .Set("key_3", double.MinValue) - .Set("key_4", int.MaxValue) - .Set("key_5", DateTime.MinValue) - .Build(); + // Act + await hook.AfterAsync(context, details); - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, evaluationContext); - - var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + // Assert + Assert.Equal(1, logger.Collector.Count); - var hook = new LoggingHook(logger, includeContext: true); - - // Act - await hook.AfterAsync(context, details); - - // Assert - Assert.Equal(1, logger.Collector.Count); - - var record = logger.LatestRecord; - Assert.Equal(LogLevel.Debug, record.Level); + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Debug, record.Level); - // .NET Framework uses G15 formatter on double.ToString - // .NET uses G17 formatter on double.ToString + // .NET Framework uses G15 formatter on double.ToString + // .NET uses G17 formatter on double.ToString #if NET462 - var expectedMaxDoubleString = "-1.79769313486232E+308"; + var expectedMaxDoubleString = "-1.79769313486232E+308"; #else - var expectedMaxDoubleString = "-1.7976931348623157E+308"; + var expectedMaxDoubleString = "-1.7976931348623157E+308"; #endif - Assert.Multiple( - () => Assert.Contains("key_1:", record.Message), - () => Assert.Contains("key_2:False", record.Message), - () => Assert.Contains($"key_3:{expectedMaxDoubleString}", record.Message), - () => Assert.Contains("key_4:2147483647", record.Message), - () => Assert.Contains("key_5:0001-01-01T00:00:00.0000000", record.Message) - ); - } - - [Fact] - public async Task AfterAsync_With_No_EvaluationContext_Generates_Correct_Log_Message() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, EvaluationContext.Empty); - - var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); - - var hook = new LoggingHook(logger, includeContext: true); - - // Act - await hook.AfterAsync(context, details); - - // Assert - Assert.Equal(1, logger.Collector.Count); - - var record = logger.LatestRecord; - Assert.Equal(LogLevel.Debug, record.Level); - - Assert.Equal( - """ - After Flag Evaluation Domain:client - ProviderName:provider - FlagKey:test - DefaultValue:False - Context: - - """, - record.Message, - ignoreLineEndingDifferences: true - ); - } - - [Fact] - public void Create_LoggingHook_Without_Logger() - { - Assert.Throws(() => new LoggingHook(null!, includeContext: true)); - } - - [Fact] - public async Task With_Structure_Type_In_Context_Returns_Qualified_Name_Of_Value() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var evaluationContext = EvaluationContext.Builder() - .Set("key_1", Structure.Builder().Set("inner_key_1", false).Build()) - .Build(); - - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, evaluationContext); - - var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); - - var hook = new LoggingHook(logger, includeContext: true); - - // Act - await hook.AfterAsync(context, details); - - // Assert - var record = logger.LatestRecord; - - // Raw string literals will convert tab to spaces (the File index style) - var message = NormalizeLogRecord(record); - - Assert.Equal( - """ - After Flag Evaluation Domain:client - ProviderName:provider - FlagKey:test - DefaultValue:False - Context: - key_1:OpenFeature.Model.Value - - """, - message, - ignoreLineEndingDifferences: true - ); - } - - [Fact] - public async Task Without_Domain_Returns_Missing() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata(null, "1.0.0"); - var providerMetadata = new Metadata("provider"); - var evaluationContext = EvaluationContext.Builder() - .Set("key_1", true) - .Build(); - - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, evaluationContext); - - var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); - - var hook = new LoggingHook(logger, includeContext: true); - - // Act - await hook.AfterAsync(context, details); - - // Assert - var record = logger.LatestRecord; - var message = NormalizeLogRecord(record); - - Assert.Equal( - """ - After Flag Evaluation Domain:missing - ProviderName:provider - FlagKey:test - DefaultValue:False - Context: - key_1:True - - """, - message, - ignoreLineEndingDifferences: true - ); - } - - [Fact] - public async Task Without_Provider_Returns_Missing() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata(null); - var evaluationContext = EvaluationContext.Builder() - .Set("key_1", true) - .Build(); - - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, evaluationContext); - - var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); - - var hook = new LoggingHook(logger, includeContext: true); - - // Act - await hook.AfterAsync(context, details); - - // Assert - var record = logger.LatestRecord; - var message = NormalizeLogRecord(record); - - Assert.Equal( - """ - After Flag Evaluation Domain:client - ProviderName:missing - FlagKey:test - DefaultValue:False - Context: - key_1:True - - """, - message, - ignoreLineEndingDifferences: true - ); - } - - [Fact] - public async Task Without_DefaultValue_Returns_Missing() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var evaluationContext = EvaluationContext.Builder() - .Set("key_1", true) - .Build(); - - var context = new HookContext("test", null!, FlagValueType.Object, clientMetadata, - providerMetadata, evaluationContext); - - var details = new FlagEvaluationDetails("test", "true", ErrorType.None, reason: null, variant: null); - - var hook = new LoggingHook(logger, includeContext: true); - - // Act - await hook.AfterAsync(context, details); - - // Assert - var record = logger.LatestRecord; - var message = NormalizeLogRecord(record); - - Assert.Equal( - """ - After Flag Evaluation Domain:client - ProviderName:provider - FlagKey:test - DefaultValue:missing - Context: - key_1:True - - """, - message, - ignoreLineEndingDifferences: true - ); - } - - [Fact] - public async Task Without_EvaluationContextValue_Returns_Nothing() - { - // Arrange - var logger = new FakeLogger(); - - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var evaluationContext = EvaluationContext.Builder() - .Set("key_1", (string)null!) - .Build(); - - var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, - providerMetadata, evaluationContext); - - var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); - - var hook = new LoggingHook(logger, includeContext: true); - - // Act - await hook.AfterAsync(context, details); - - // Assert - var record = logger.LatestRecord; - var message = NormalizeLogRecord(record); - - Assert.Equal( - """ - After Flag Evaluation Domain:client - ProviderName:provider - FlagKey:test - DefaultValue:False - Context: - key_1: - - """, - message, - ignoreLineEndingDifferences: true - ); - } - - private static string NormalizeLogRecord(FakeLogRecord record) - { - // Raw string literals will convert tab to spaces (the File index style) - const int tabSize = 4; - - return record.Message.Replace("\t", new string(' ', tabSize)); - } + Assert.Multiple( + () => Assert.Contains("key_1:", record.Message), + () => Assert.Contains("key_2:False", record.Message), + () => Assert.Contains($"key_3:{expectedMaxDoubleString}", record.Message), + () => Assert.Contains("key_4:2147483647", record.Message), + () => Assert.Contains("key_5:0001-01-01T00:00:00.0000000", record.Message) + ); + } + + [Fact] + public async Task AfterAsync_With_No_EvaluationContext_Generates_Correct_Log_Message() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + Assert.Equal(1, logger.Collector.Count); + + var record = logger.LatestRecord; + Assert.Equal(LogLevel.Debug, record.Level); + + Assert.Equal( + """ + After Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + Context: + + """, + record.Message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public void Create_LoggingHook_Without_Logger() + { + Assert.Throws(() => new LoggingHook(null!, includeContext: true)); + } + + [Fact] + public async Task With_Structure_Type_In_Context_Returns_Qualified_Name_Of_Value() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", Structure.Builder().Set("inner_key_1", false).Build()) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + var record = logger.LatestRecord; + + // Raw string literals will convert tab to spaces (the File index style) + var message = NormalizeLogRecord(record); + + Assert.Equal( + """ + After Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + Context: + key_1:OpenFeature.Model.Value + + """, + message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task Without_Domain_Returns_Missing() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata(null, "1.0.0"); + var providerMetadata = new Metadata("provider"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", true) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + var record = logger.LatestRecord; + var message = NormalizeLogRecord(record); + + Assert.Equal( + """ + After Flag Evaluation Domain:missing + ProviderName:provider + FlagKey:test + DefaultValue:False + Context: + key_1:True + + """, + message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task Without_Provider_Returns_Missing() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata(null); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", true) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + var record = logger.LatestRecord; + var message = NormalizeLogRecord(record); + + Assert.Equal( + """ + After Flag Evaluation Domain:client + ProviderName:missing + FlagKey:test + DefaultValue:False + Context: + key_1:True + + """, + message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task Without_DefaultValue_Returns_Missing() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", true) + .Build(); + + var context = new HookContext("test", null!, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var details = new FlagEvaluationDetails("test", "true", ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + var record = logger.LatestRecord; + var message = NormalizeLogRecord(record); + + Assert.Equal( + """ + After Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:missing + Context: + key_1:True + + """, + message, + ignoreLineEndingDifferences: true + ); + } + + [Fact] + public async Task Without_EvaluationContextValue_Returns_Nothing() + { + // Arrange + var logger = new FakeLogger(); + + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var evaluationContext = EvaluationContext.Builder() + .Set("key_1", (string)null!) + .Build(); + + var context = new HookContext("test", false, FlagValueType.Object, clientMetadata, + providerMetadata, evaluationContext); + + var details = new FlagEvaluationDetails("test", true, ErrorType.None, reason: null, variant: null); + + var hook = new LoggingHook(logger, includeContext: true); + + // Act + await hook.AfterAsync(context, details); + + // Assert + var record = logger.LatestRecord; + var message = NormalizeLogRecord(record); + + Assert.Equal( + """ + After Flag Evaluation Domain:client + ProviderName:provider + FlagKey:test + DefaultValue:False + Context: + key_1: + + """, + message, + ignoreLineEndingDifferences: true + ); + } + + private static string NormalizeLogRecord(FakeLogRecord record) + { + // Raw string literals will convert tab to spaces (the File index style) + const int tabSize = 4; + + return record.Message.Replace("\t", new string(' ', tabSize)); } } diff --git a/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs b/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs index 7c0aac2a9..9fea9fef7 100644 --- a/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs +++ b/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs @@ -1,17 +1,16 @@ using System; -namespace OpenFeature.Tests.Internal +namespace OpenFeature.Tests.Internal; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public class SpecificationAttribute : Attribute { - [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] - public class SpecificationAttribute : Attribute - { - public string Code { get; } - public string Description { get; } + public string Code { get; } + public string Description { get; } - public SpecificationAttribute(string code, string description) - { - this.Code = code; - this.Description = description; - } + public SpecificationAttribute(string code, string description) + { + this.Code = code; + this.Description = description; } } diff --git a/test/OpenFeature.Tests/OpenFeatureClientTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTests.cs index fc6f415f4..31450a6f9 100644 --- a/test/OpenFeature.Tests/OpenFeatureClientTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureClientTests.cs @@ -16,665 +16,664 @@ using OpenFeature.Tests.Internal; using Xunit; -namespace OpenFeature.Tests +namespace OpenFeature.Tests; + +[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] +public class OpenFeatureClientTests : ClearOpenFeatureInstanceFixture { - [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] - public class OpenFeatureClientTests : ClearOpenFeatureInstanceFixture + [Fact] + [Specification("1.2.1", "The client MUST provide a method to add `hooks` which accepts one or more API-conformant `hooks`, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed.")] + public void OpenFeatureClient_Should_Allow_Hooks() { - [Fact] - [Specification("1.2.1", "The client MUST provide a method to add `hooks` which accepts one or more API-conformant `hooks`, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed.")] - public void OpenFeatureClient_Should_Allow_Hooks() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var hook1 = Substitute.For(); - var hook2 = Substitute.For(); - var hook3 = Substitute.For(); + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + var hook3 = Substitute.For(); - var client = Api.Instance.GetClient(domain, clientVersion); + var client = Api.Instance.GetClient(domain, clientVersion); - client.AddHooks(new[] { hook1, hook2 }); + client.AddHooks(new[] { hook1, hook2 }); - var expectedHooks = new[] { hook1, hook2 }.AsEnumerable(); - Assert.Equal(expectedHooks, client.GetHooks()); + var expectedHooks = new[] { hook1, hook2 }.AsEnumerable(); + Assert.Equal(expectedHooks, client.GetHooks()); - client.AddHooks(hook3); + client.AddHooks(hook3); - expectedHooks = new[] { hook1, hook2, hook3 }.AsEnumerable(); - Assert.Equal(expectedHooks, client.GetHooks()); + expectedHooks = new[] { hook1, hook2, hook3 }.AsEnumerable(); + Assert.Equal(expectedHooks, client.GetHooks()); - client.ClearHooks(); - Assert.Empty(client.GetHooks()); - } + client.ClearHooks(); + Assert.Empty(client.GetHooks()); + } - [Fact] - [Specification("1.2.2", "The client interface MUST define a `metadata` member or accessor, containing an immutable `name` field or accessor of type string, which corresponds to the `name` value supplied during client creation.")] - public void OpenFeatureClient_Metadata_Should_Have_Name() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var client = Api.Instance.GetClient(domain, clientVersion); + [Fact] + [Specification("1.2.2", "The client interface MUST define a `metadata` member or accessor, containing an immutable `name` field or accessor of type string, which corresponds to the `name` value supplied during client creation.")] + public void OpenFeatureClient_Metadata_Should_Have_Name() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var client = Api.Instance.GetClient(domain, clientVersion); - Assert.Equal(domain, client.GetMetadata().Name); - Assert.Equal(clientVersion, client.GetMetadata().Version); - } + Assert.Equal(domain, client.GetMetadata().Name); + Assert.Equal(clientVersion, client.GetMetadata().Version); + } - [Fact] - [Specification("1.3.1", "The `client` MUST provide methods for typed flag evaluation, including boolean, numeric, string, and structure, with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required), `evaluation context` (optional), and `evaluation options` (optional), which returns the flag value.")] - [Specification("1.3.2.1", "The client SHOULD provide functions for floating-point numbers and integers, consistent with language idioms.")] - [Specification("1.3.3", "The `client` SHOULD guarantee the returned value of any typed flag evaluation method is of the expected type. If the value returned by the underlying provider implementation does not match the expected type, it's to be considered abnormal execution, and the supplied `default value` should be returned.")] - public async Task OpenFeatureClient_Should_Allow_Flag_Evaluation() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultBoolValue = fixture.Create(); - var defaultStringValue = fixture.Create(); - var defaultIntegerValue = fixture.Create(); - var defaultDoubleValue = fixture.Create(); - var defaultStructureValue = fixture.Create(); - var emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); - - await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); - var client = Api.Instance.GetClient(domain, clientVersion); - - Assert.Equal(defaultBoolValue, await client.GetBooleanValueAsync(flagName, defaultBoolValue)); - Assert.Equal(defaultBoolValue, await client.GetBooleanValueAsync(flagName, defaultBoolValue, EvaluationContext.Empty)); - Assert.Equal(defaultBoolValue, await client.GetBooleanValueAsync(flagName, defaultBoolValue, EvaluationContext.Empty, emptyFlagOptions)); - - Assert.Equal(defaultIntegerValue, await client.GetIntegerValueAsync(flagName, defaultIntegerValue)); - Assert.Equal(defaultIntegerValue, await client.GetIntegerValueAsync(flagName, defaultIntegerValue, EvaluationContext.Empty)); - Assert.Equal(defaultIntegerValue, await client.GetIntegerValueAsync(flagName, defaultIntegerValue, EvaluationContext.Empty, emptyFlagOptions)); - - Assert.Equal(defaultDoubleValue, await client.GetDoubleValueAsync(flagName, defaultDoubleValue)); - Assert.Equal(defaultDoubleValue, await client.GetDoubleValueAsync(flagName, defaultDoubleValue, EvaluationContext.Empty)); - Assert.Equal(defaultDoubleValue, await client.GetDoubleValueAsync(flagName, defaultDoubleValue, EvaluationContext.Empty, emptyFlagOptions)); - - Assert.Equal(defaultStringValue, await client.GetStringValueAsync(flagName, defaultStringValue)); - Assert.Equal(defaultStringValue, await client.GetStringValueAsync(flagName, defaultStringValue, EvaluationContext.Empty)); - Assert.Equal(defaultStringValue, await client.GetStringValueAsync(flagName, defaultStringValue, EvaluationContext.Empty, emptyFlagOptions)); - - Assert.Equivalent(defaultStructureValue, await client.GetObjectValueAsync(flagName, defaultStructureValue)); - Assert.Equivalent(defaultStructureValue, await client.GetObjectValueAsync(flagName, defaultStructureValue, EvaluationContext.Empty)); - Assert.Equivalent(defaultStructureValue, await client.GetObjectValueAsync(flagName, defaultStructureValue, EvaluationContext.Empty, emptyFlagOptions)); - } + [Fact] + [Specification("1.3.1", "The `client` MUST provide methods for typed flag evaluation, including boolean, numeric, string, and structure, with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required), `evaluation context` (optional), and `evaluation options` (optional), which returns the flag value.")] + [Specification("1.3.2.1", "The client SHOULD provide functions for floating-point numbers and integers, consistent with language idioms.")] + [Specification("1.3.3", "The `client` SHOULD guarantee the returned value of any typed flag evaluation method is of the expected type. If the value returned by the underlying provider implementation does not match the expected type, it's to be considered abnormal execution, and the supplied `default value` should be returned.")] + public async Task OpenFeatureClient_Should_Allow_Flag_Evaluation() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultBoolValue = fixture.Create(); + var defaultStringValue = fixture.Create(); + var defaultIntegerValue = fixture.Create(); + var defaultDoubleValue = fixture.Create(); + var defaultStructureValue = fixture.Create(); + var emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); + + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var client = Api.Instance.GetClient(domain, clientVersion); + + Assert.Equal(defaultBoolValue, await client.GetBooleanValueAsync(flagName, defaultBoolValue)); + Assert.Equal(defaultBoolValue, await client.GetBooleanValueAsync(flagName, defaultBoolValue, EvaluationContext.Empty)); + Assert.Equal(defaultBoolValue, await client.GetBooleanValueAsync(flagName, defaultBoolValue, EvaluationContext.Empty, emptyFlagOptions)); + + Assert.Equal(defaultIntegerValue, await client.GetIntegerValueAsync(flagName, defaultIntegerValue)); + Assert.Equal(defaultIntegerValue, await client.GetIntegerValueAsync(flagName, defaultIntegerValue, EvaluationContext.Empty)); + Assert.Equal(defaultIntegerValue, await client.GetIntegerValueAsync(flagName, defaultIntegerValue, EvaluationContext.Empty, emptyFlagOptions)); + + Assert.Equal(defaultDoubleValue, await client.GetDoubleValueAsync(flagName, defaultDoubleValue)); + Assert.Equal(defaultDoubleValue, await client.GetDoubleValueAsync(flagName, defaultDoubleValue, EvaluationContext.Empty)); + Assert.Equal(defaultDoubleValue, await client.GetDoubleValueAsync(flagName, defaultDoubleValue, EvaluationContext.Empty, emptyFlagOptions)); + + Assert.Equal(defaultStringValue, await client.GetStringValueAsync(flagName, defaultStringValue)); + Assert.Equal(defaultStringValue, await client.GetStringValueAsync(flagName, defaultStringValue, EvaluationContext.Empty)); + Assert.Equal(defaultStringValue, await client.GetStringValueAsync(flagName, defaultStringValue, EvaluationContext.Empty, emptyFlagOptions)); + + Assert.Equivalent(defaultStructureValue, await client.GetObjectValueAsync(flagName, defaultStructureValue)); + Assert.Equivalent(defaultStructureValue, await client.GetObjectValueAsync(flagName, defaultStructureValue, EvaluationContext.Empty)); + Assert.Equivalent(defaultStructureValue, await client.GetObjectValueAsync(flagName, defaultStructureValue, EvaluationContext.Empty, emptyFlagOptions)); + } - [Fact] - [Specification("1.4.1", "The `client` MUST provide methods for detailed flag value evaluation with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required), `evaluation context` (optional), and `evaluation options` (optional), which returns an `evaluation details` structure.")] - [Specification("1.4.2", "The `evaluation details` structure's `value` field MUST contain the evaluated flag value.")] - [Specification("1.4.3.1", "The `evaluation details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] - [Specification("1.4.4", "The `evaluation details` structure's `flag key` field MUST contain the `flag key` argument passed to the detailed flag evaluation method.")] - [Specification("1.4.5", "In cases of normal execution, the `evaluation details` structure's `variant` field MUST contain the value of the `variant` field in the `flag resolution` structure returned by the configured `provider`, if the field is set.")] - [Specification("1.4.6", "In cases of normal execution, the `evaluation details` structure's `reason` field MUST contain the value of the `reason` field in the `flag resolution` structure returned by the configured `provider`, if the field is set.")] - [Specification("1.4.11", "The `client` SHOULD provide asynchronous or non-blocking mechanisms for flag evaluation.")] - [Specification("2.2.8.1", "The `resolution details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] - public async Task OpenFeatureClient_Should_Allow_Details_Flag_Evaluation() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultBoolValue = fixture.Create(); - var defaultStringValue = fixture.Create(); - var defaultIntegerValue = fixture.Create(); - var defaultDoubleValue = fixture.Create(); - var defaultStructureValue = fixture.Create(); - var emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); - - await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); - var client = Api.Instance.GetClient(domain, clientVersion); - - var boolFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultBoolValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - Assert.Equivalent(boolFlagEvaluationDetails, await client.GetBooleanDetailsAsync(flagName, defaultBoolValue)); - Assert.Equivalent(boolFlagEvaluationDetails, await client.GetBooleanDetailsAsync(flagName, defaultBoolValue, EvaluationContext.Empty)); - Assert.Equivalent(boolFlagEvaluationDetails, await client.GetBooleanDetailsAsync(flagName, defaultBoolValue, EvaluationContext.Empty, emptyFlagOptions)); - - var integerFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultIntegerValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - Assert.Equivalent(integerFlagEvaluationDetails, await client.GetIntegerDetailsAsync(flagName, defaultIntegerValue)); - Assert.Equivalent(integerFlagEvaluationDetails, await client.GetIntegerDetailsAsync(flagName, defaultIntegerValue, EvaluationContext.Empty)); - Assert.Equivalent(integerFlagEvaluationDetails, await client.GetIntegerDetailsAsync(flagName, defaultIntegerValue, EvaluationContext.Empty, emptyFlagOptions)); - - var doubleFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultDoubleValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - Assert.Equivalent(doubleFlagEvaluationDetails, await client.GetDoubleDetailsAsync(flagName, defaultDoubleValue)); - Assert.Equivalent(doubleFlagEvaluationDetails, await client.GetDoubleDetailsAsync(flagName, defaultDoubleValue, EvaluationContext.Empty)); - Assert.Equivalent(doubleFlagEvaluationDetails, await client.GetDoubleDetailsAsync(flagName, defaultDoubleValue, EvaluationContext.Empty, emptyFlagOptions)); - - var stringFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultStringValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - Assert.Equivalent(stringFlagEvaluationDetails, await client.GetStringDetailsAsync(flagName, defaultStringValue)); - Assert.Equivalent(stringFlagEvaluationDetails, await client.GetStringDetailsAsync(flagName, defaultStringValue, EvaluationContext.Empty)); - Assert.Equivalent(stringFlagEvaluationDetails, await client.GetStringDetailsAsync(flagName, defaultStringValue, EvaluationContext.Empty, emptyFlagOptions)); - - var structureFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultStructureValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); - Assert.Equivalent(structureFlagEvaluationDetails, await client.GetObjectDetailsAsync(flagName, defaultStructureValue)); - Assert.Equivalent(structureFlagEvaluationDetails, await client.GetObjectDetailsAsync(flagName, defaultStructureValue, EvaluationContext.Empty)); - Assert.Equivalent(structureFlagEvaluationDetails, await client.GetObjectDetailsAsync(flagName, defaultStructureValue, EvaluationContext.Empty, emptyFlagOptions)); - } + [Fact] + [Specification("1.4.1", "The `client` MUST provide methods for detailed flag value evaluation with parameters `flag key` (string, required), `default value` (boolean | number | string | structure, required), `evaluation context` (optional), and `evaluation options` (optional), which returns an `evaluation details` structure.")] + [Specification("1.4.2", "The `evaluation details` structure's `value` field MUST contain the evaluated flag value.")] + [Specification("1.4.3.1", "The `evaluation details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] + [Specification("1.4.4", "The `evaluation details` structure's `flag key` field MUST contain the `flag key` argument passed to the detailed flag evaluation method.")] + [Specification("1.4.5", "In cases of normal execution, the `evaluation details` structure's `variant` field MUST contain the value of the `variant` field in the `flag resolution` structure returned by the configured `provider`, if the field is set.")] + [Specification("1.4.6", "In cases of normal execution, the `evaluation details` structure's `reason` field MUST contain the value of the `reason` field in the `flag resolution` structure returned by the configured `provider`, if the field is set.")] + [Specification("1.4.11", "The `client` SHOULD provide asynchronous or non-blocking mechanisms for flag evaluation.")] + [Specification("2.2.8.1", "The `resolution details` structure SHOULD accept a generic argument (or use an equivalent language feature) which indicates the type of the wrapped `value` field.")] + public async Task OpenFeatureClient_Should_Allow_Details_Flag_Evaluation() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultBoolValue = fixture.Create(); + var defaultStringValue = fixture.Create(); + var defaultIntegerValue = fixture.Create(); + var defaultDoubleValue = fixture.Create(); + var defaultStructureValue = fixture.Create(); + var emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); + + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var client = Api.Instance.GetClient(domain, clientVersion); + + var boolFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultBoolValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(boolFlagEvaluationDetails, await client.GetBooleanDetailsAsync(flagName, defaultBoolValue)); + Assert.Equivalent(boolFlagEvaluationDetails, await client.GetBooleanDetailsAsync(flagName, defaultBoolValue, EvaluationContext.Empty)); + Assert.Equivalent(boolFlagEvaluationDetails, await client.GetBooleanDetailsAsync(flagName, defaultBoolValue, EvaluationContext.Empty, emptyFlagOptions)); + + var integerFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultIntegerValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(integerFlagEvaluationDetails, await client.GetIntegerDetailsAsync(flagName, defaultIntegerValue)); + Assert.Equivalent(integerFlagEvaluationDetails, await client.GetIntegerDetailsAsync(flagName, defaultIntegerValue, EvaluationContext.Empty)); + Assert.Equivalent(integerFlagEvaluationDetails, await client.GetIntegerDetailsAsync(flagName, defaultIntegerValue, EvaluationContext.Empty, emptyFlagOptions)); + + var doubleFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultDoubleValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(doubleFlagEvaluationDetails, await client.GetDoubleDetailsAsync(flagName, defaultDoubleValue)); + Assert.Equivalent(doubleFlagEvaluationDetails, await client.GetDoubleDetailsAsync(flagName, defaultDoubleValue, EvaluationContext.Empty)); + Assert.Equivalent(doubleFlagEvaluationDetails, await client.GetDoubleDetailsAsync(flagName, defaultDoubleValue, EvaluationContext.Empty, emptyFlagOptions)); + + var stringFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultStringValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(stringFlagEvaluationDetails, await client.GetStringDetailsAsync(flagName, defaultStringValue)); + Assert.Equivalent(stringFlagEvaluationDetails, await client.GetStringDetailsAsync(flagName, defaultStringValue, EvaluationContext.Empty)); + Assert.Equivalent(stringFlagEvaluationDetails, await client.GetStringDetailsAsync(flagName, defaultStringValue, EvaluationContext.Empty, emptyFlagOptions)); + + var structureFlagEvaluationDetails = new FlagEvaluationDetails(flagName, defaultStructureValue, ErrorType.None, NoOpProvider.ReasonNoOp, NoOpProvider.Variant); + Assert.Equivalent(structureFlagEvaluationDetails, await client.GetObjectDetailsAsync(flagName, defaultStructureValue)); + Assert.Equivalent(structureFlagEvaluationDetails, await client.GetObjectDetailsAsync(flagName, defaultStructureValue, EvaluationContext.Empty)); + Assert.Equivalent(structureFlagEvaluationDetails, await client.GetObjectDetailsAsync(flagName, defaultStructureValue, EvaluationContext.Empty, emptyFlagOptions)); + } - [Fact] - [Specification("1.1.2", "The `API` MUST provide a function to set the default `provider`, which accepts an API-conformant `provider` implementation.")] - [Specification("1.3.3", "The `client` SHOULD guarantee the returned value of any typed flag evaluation method is of the expected type. If the value returned by the underlying provider implementation does not match the expected type, it's to be considered abnormal execution, and the supplied `default value` should be returned.")] - [Specification("1.4.7", "In cases of abnormal execution, the `evaluation details` structure's `error code` field MUST contain an `error code`.")] - [Specification("1.4.8", "In cases of abnormal execution (network failure, unhandled error, etc) the `reason` field in the `evaluation details` SHOULD indicate an error.")] - [Specification("1.4.9", "Methods, functions, or operations on the client MUST NOT throw exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the `default value` in the event of abnormal execution. Exceptions include functions or methods for the purposes for configuration or setup.")] - [Specification("1.4.10", "In the case of abnormal execution, the client SHOULD log an informative error message.")] - public async Task OpenFeatureClient_Should_Return_DefaultValue_When_Type_Mismatch() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); - var mockedFeatureProvider = Substitute.For(); - var mockedLogger = Substitute.For>(); + [Fact] + [Specification("1.1.2", "The `API` MUST provide a function to set the default `provider`, which accepts an API-conformant `provider` implementation.")] + [Specification("1.3.3", "The `client` SHOULD guarantee the returned value of any typed flag evaluation method is of the expected type. If the value returned by the underlying provider implementation does not match the expected type, it's to be considered abnormal execution, and the supplied `default value` should be returned.")] + [Specification("1.4.7", "In cases of abnormal execution, the `evaluation details` structure's `error code` field MUST contain an `error code`.")] + [Specification("1.4.8", "In cases of abnormal execution (network failure, unhandled error, etc) the `reason` field in the `evaluation details` SHOULD indicate an error.")] + [Specification("1.4.9", "Methods, functions, or operations on the client MUST NOT throw exceptions, or otherwise abnormally terminate. Flag evaluation calls must always return the `default value` in the event of abnormal execution. Exceptions include functions or methods for the purposes for configuration or setup.")] + [Specification("1.4.10", "In the case of abnormal execution, the client SHOULD log an informative error message.")] + public async Task OpenFeatureClient_Should_Return_DefaultValue_When_Type_Mismatch() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + var mockedFeatureProvider = Substitute.For(); + var mockedLogger = Substitute.For>(); - // This will fail to case a String to TestStructure - mockedFeatureProvider.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()).Throws(); - mockedFeatureProvider.GetMetadata().Returns(new Metadata(fixture.Create())); - mockedFeatureProvider.GetProviderHooks().Returns(ImmutableList.Empty); + // This will fail to case a String to TestStructure + mockedFeatureProvider.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()).Throws(); + mockedFeatureProvider.GetMetadata().Returns(new Metadata(fixture.Create())); + mockedFeatureProvider.GetProviderHooks().Returns(ImmutableList.Empty); - await Api.Instance.SetProviderAsync(mockedFeatureProvider); - var client = Api.Instance.GetClient(domain, clientVersion, mockedLogger); + await Api.Instance.SetProviderAsync(mockedFeatureProvider); + var client = Api.Instance.GetClient(domain, clientVersion, mockedLogger); - var evaluationDetails = await client.GetObjectDetailsAsync(flagName, defaultValue); - Assert.Equal(ErrorType.TypeMismatch, evaluationDetails.ErrorType); - Assert.Equal(new InvalidCastException().Message, evaluationDetails.ErrorMessage); + var evaluationDetails = await client.GetObjectDetailsAsync(flagName, defaultValue); + Assert.Equal(ErrorType.TypeMismatch, evaluationDetails.ErrorType); + Assert.Equal(new InvalidCastException().Message, evaluationDetails.ErrorMessage); - _ = mockedFeatureProvider.Received(1).ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); + _ = mockedFeatureProvider.Received(1).ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); - mockedLogger.Received(0).IsEnabled(LogLevel.Error); - } + mockedLogger.Received(0).IsEnabled(LogLevel.Error); + } - [Fact] - [Specification("1.7.3", "The client's provider status accessor MUST indicate READY if the initialize function of the associated provider terminates normally.")] - [Specification("1.7.1", "The client MUST define a provider status accessor which indicates the readiness of the associated provider, with possible values NOT_READY, READY, STALE, ERROR, or FATAL.")] - public async Task Provider_Status_Should_Be_Ready_If_Init_Succeeds() - { - var name = "1.7.3"; - // provider which succeeds initialization - var provider = new TestProvider(); - FeatureClient client = Api.Instance.GetClient(name); - Assert.Equal(ProviderStatus.NotReady, provider.Status); - await Api.Instance.SetProviderAsync(name, provider); - - // after init fails fatally, status should be READY - Assert.Equal(ProviderStatus.Ready, client.ProviderStatus); - } + [Fact] + [Specification("1.7.3", "The client's provider status accessor MUST indicate READY if the initialize function of the associated provider terminates normally.")] + [Specification("1.7.1", "The client MUST define a provider status accessor which indicates the readiness of the associated provider, with possible values NOT_READY, READY, STALE, ERROR, or FATAL.")] + public async Task Provider_Status_Should_Be_Ready_If_Init_Succeeds() + { + var name = "1.7.3"; + // provider which succeeds initialization + var provider = new TestProvider(); + FeatureClient client = Api.Instance.GetClient(name); + Assert.Equal(ProviderStatus.NotReady, provider.Status); + await Api.Instance.SetProviderAsync(name, provider); + + // after init fails fatally, status should be READY + Assert.Equal(ProviderStatus.Ready, client.ProviderStatus); + } - [Fact] - [Specification("1.7.4", "The client's provider status accessor MUST indicate ERROR if the initialize function of the associated provider terminates abnormally.")] - [Specification("1.7.1", "The client MUST define a provider status accessor which indicates the readiness of the associated provider, with possible values NOT_READY, READY, STALE, ERROR, or FATAL.")] - public async Task Provider_Status_Should_Be_Error_If_Init_Fails() - { - var name = "1.7.4"; - // provider which fails initialization - var provider = new TestProvider("some-name", new GeneralException("fake")); - FeatureClient client = Api.Instance.GetClient(name); - Assert.Equal(ProviderStatus.NotReady, provider.Status); - await Api.Instance.SetProviderAsync(name, provider); - - // after init fails fatally, status should be ERROR - Assert.Equal(ProviderStatus.Error, client.ProviderStatus); - } + [Fact] + [Specification("1.7.4", "The client's provider status accessor MUST indicate ERROR if the initialize function of the associated provider terminates abnormally.")] + [Specification("1.7.1", "The client MUST define a provider status accessor which indicates the readiness of the associated provider, with possible values NOT_READY, READY, STALE, ERROR, or FATAL.")] + public async Task Provider_Status_Should_Be_Error_If_Init_Fails() + { + var name = "1.7.4"; + // provider which fails initialization + var provider = new TestProvider("some-name", new GeneralException("fake")); + FeatureClient client = Api.Instance.GetClient(name); + Assert.Equal(ProviderStatus.NotReady, provider.Status); + await Api.Instance.SetProviderAsync(name, provider); + + // after init fails fatally, status should be ERROR + Assert.Equal(ProviderStatus.Error, client.ProviderStatus); + } - [Fact] - [Specification("1.7.5", "The client's provider status accessor MUST indicate FATAL if the initialize function of the associated provider terminates abnormally and indicates error code PROVIDER_FATAL.")] - [Specification("1.7.1", "The client MUST define a provider status accessor which indicates the readiness of the associated provider, with possible values NOT_READY, READY, STALE, ERROR, or FATAL.")] - public async Task Provider_Status_Should_Be_Fatal_If_Init_Fatal() - { - var name = "1.7.5"; - // provider which fails initialization fatally - var provider = new TestProvider(name, new ProviderFatalException("fatal")); - FeatureClient client = Api.Instance.GetClient(name); - Assert.Equal(ProviderStatus.NotReady, provider.Status); - await Api.Instance.SetProviderAsync(name, provider); - - // after init fails fatally, status should be FATAL - Assert.Equal(ProviderStatus.Fatal, client.ProviderStatus); - } + [Fact] + [Specification("1.7.5", "The client's provider status accessor MUST indicate FATAL if the initialize function of the associated provider terminates abnormally and indicates error code PROVIDER_FATAL.")] + [Specification("1.7.1", "The client MUST define a provider status accessor which indicates the readiness of the associated provider, with possible values NOT_READY, READY, STALE, ERROR, or FATAL.")] + public async Task Provider_Status_Should_Be_Fatal_If_Init_Fatal() + { + var name = "1.7.5"; + // provider which fails initialization fatally + var provider = new TestProvider(name, new ProviderFatalException("fatal")); + FeatureClient client = Api.Instance.GetClient(name); + Assert.Equal(ProviderStatus.NotReady, provider.Status); + await Api.Instance.SetProviderAsync(name, provider); + + // after init fails fatally, status should be FATAL + Assert.Equal(ProviderStatus.Fatal, client.ProviderStatus); + } - [Fact] - [Specification("1.7.6", "The client MUST default, run error hooks, and indicate an error if flag resolution is attempted while the provider is in NOT_READY.")] - public async Task Must_Short_Circuit_Not_Ready() - { - var name = "1.7.6"; - var defaultStr = "123-default"; - - // provider which is never ready (ready after maxValue) - var provider = new TestProvider(name, null, int.MaxValue); - FeatureClient client = Api.Instance.GetClient(name); - Assert.Equal(ProviderStatus.NotReady, provider.Status); - _ = Api.Instance.SetProviderAsync(name, provider); - - var details = await client.GetStringDetailsAsync("some-flag", defaultStr); - Assert.Equal(defaultStr, details.Value); - Assert.Equal(ErrorType.ProviderNotReady, details.ErrorType); - Assert.Equal(Reason.Error, details.Reason); - } + [Fact] + [Specification("1.7.6", "The client MUST default, run error hooks, and indicate an error if flag resolution is attempted while the provider is in NOT_READY.")] + public async Task Must_Short_Circuit_Not_Ready() + { + var name = "1.7.6"; + var defaultStr = "123-default"; + + // provider which is never ready (ready after maxValue) + var provider = new TestProvider(name, null, int.MaxValue); + FeatureClient client = Api.Instance.GetClient(name); + Assert.Equal(ProviderStatus.NotReady, provider.Status); + _ = Api.Instance.SetProviderAsync(name, provider); + + var details = await client.GetStringDetailsAsync("some-flag", defaultStr); + Assert.Equal(defaultStr, details.Value); + Assert.Equal(ErrorType.ProviderNotReady, details.ErrorType); + Assert.Equal(Reason.Error, details.Reason); + } - [Fact] - [Specification("1.7.7", "The client MUST default, run error hooks, and indicate an error if flag resolution is attempted while the provider is in NOT_READY.")] - public async Task Must_Short_Circuit_Fatal() - { - var name = "1.7.6"; - var defaultStr = "456-default"; - - // provider which immediately fails fatally - var provider = new TestProvider(name, new ProviderFatalException("fake")); - FeatureClient client = Api.Instance.GetClient(name); - Assert.Equal(ProviderStatus.NotReady, provider.Status); - _ = Api.Instance.SetProviderAsync(name, provider); - - var details = await client.GetStringDetailsAsync("some-flag", defaultStr); - Assert.Equal(defaultStr, details.Value); - Assert.Equal(ErrorType.ProviderFatal, details.ErrorType); - Assert.Equal(Reason.Error, details.Reason); - } + [Fact] + [Specification("1.7.7", "The client MUST default, run error hooks, and indicate an error if flag resolution is attempted while the provider is in NOT_READY.")] + public async Task Must_Short_Circuit_Fatal() + { + var name = "1.7.6"; + var defaultStr = "456-default"; + + // provider which immediately fails fatally + var provider = new TestProvider(name, new ProviderFatalException("fake")); + FeatureClient client = Api.Instance.GetClient(name); + Assert.Equal(ProviderStatus.NotReady, provider.Status); + _ = Api.Instance.SetProviderAsync(name, provider); + + var details = await client.GetStringDetailsAsync("some-flag", defaultStr); + Assert.Equal(defaultStr, details.Value); + Assert.Equal(ErrorType.ProviderFatal, details.ErrorType); + Assert.Equal(Reason.Error, details.Reason); + } - [Fact] - public async Task Should_Resolve_BooleanValue() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); + [Fact] + public async Task Should_Resolve_BooleanValue() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); - var featureProviderMock = Substitute.For(); - featureProviderMock.ResolveBooleanValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); - featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); - featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveBooleanValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - await Api.Instance.SetProviderAsync(featureProviderMock); - var client = Api.Instance.GetClient(domain, clientVersion); + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); - Assert.Equal(defaultValue, await client.GetBooleanValueAsync(flagName, defaultValue)); + Assert.Equal(defaultValue, await client.GetBooleanValueAsync(flagName, defaultValue)); - _ = featureProviderMock.Received(1).ResolveBooleanValueAsync(flagName, defaultValue, Arg.Any()); - } + _ = featureProviderMock.Received(1).ResolveBooleanValueAsync(flagName, defaultValue, Arg.Any()); + } - [Fact] - public async Task Should_Resolve_StringValue() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); + [Fact] + public async Task Should_Resolve_StringValue() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); - var featureProviderMock = Substitute.For(); - featureProviderMock.ResolveStringValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); - featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); - featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveStringValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - await Api.Instance.SetProviderAsync(featureProviderMock); - var client = Api.Instance.GetClient(domain, clientVersion); + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); - Assert.Equal(defaultValue, await client.GetStringValueAsync(flagName, defaultValue)); + Assert.Equal(defaultValue, await client.GetStringValueAsync(flagName, defaultValue)); - _ = featureProviderMock.Received(1).ResolveStringValueAsync(flagName, defaultValue, Arg.Any()); - } + _ = featureProviderMock.Received(1).ResolveStringValueAsync(flagName, defaultValue, Arg.Any()); + } - [Fact] - public async Task Should_Resolve_IntegerValue() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); + [Fact] + public async Task Should_Resolve_IntegerValue() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); - var featureProviderMock = Substitute.For(); - featureProviderMock.ResolveIntegerValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); - featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); - featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveIntegerValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - await Api.Instance.SetProviderAsync(featureProviderMock); - var client = Api.Instance.GetClient(domain, clientVersion); + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); - Assert.Equal(defaultValue, await client.GetIntegerValueAsync(flagName, defaultValue)); + Assert.Equal(defaultValue, await client.GetIntegerValueAsync(flagName, defaultValue)); - _ = featureProviderMock.Received(1).ResolveIntegerValueAsync(flagName, defaultValue, Arg.Any()); - } + _ = featureProviderMock.Received(1).ResolveIntegerValueAsync(flagName, defaultValue, Arg.Any()); + } - [Fact] - public async Task Should_Resolve_DoubleValue() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); + [Fact] + public async Task Should_Resolve_DoubleValue() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); - var featureProviderMock = Substitute.For(); - featureProviderMock.ResolveDoubleValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); - featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); - featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveDoubleValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - await Api.Instance.SetProviderAsync(featureProviderMock); - var client = Api.Instance.GetClient(domain, clientVersion); + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); - Assert.Equal(defaultValue, await client.GetDoubleValueAsync(flagName, defaultValue)); + Assert.Equal(defaultValue, await client.GetDoubleValueAsync(flagName, defaultValue)); - _ = featureProviderMock.Received(1).ResolveDoubleValueAsync(flagName, defaultValue, Arg.Any()); - } + _ = featureProviderMock.Received(1).ResolveDoubleValueAsync(flagName, defaultValue, Arg.Any()); + } - [Fact] - public async Task Should_Resolve_StructureValue() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); + [Fact] + public async Task Should_Resolve_StructureValue() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); - var featureProviderMock = Substitute.For(); - featureProviderMock.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); - featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); - featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()).Returns(new ResolutionDetails(flagName, defaultValue)); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - await Api.Instance.SetProviderAsync(featureProviderMock); - var client = Api.Instance.GetClient(domain, clientVersion); + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); - Assert.Equal(defaultValue, await client.GetObjectValueAsync(flagName, defaultValue)); + Assert.Equal(defaultValue, await client.GetObjectValueAsync(flagName, defaultValue)); - _ = featureProviderMock.Received(1).ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); - } + _ = featureProviderMock.Received(1).ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); + } - [Fact] - public async Task When_Error_Is_Returned_From_Provider_Should_Return_Error() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); - const string testMessage = "Couldn't parse flag data."; - - var featureProviderMock = Substitute.For(); - featureProviderMock.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()).Returns(Task.FromResult(new ResolutionDetails(flagName, defaultValue, ErrorType.ParseError, "ERROR", null, testMessage))); - featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); - featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - - await Api.Instance.SetProviderAsync(featureProviderMock); - var client = Api.Instance.GetClient(domain, clientVersion); - var response = await client.GetObjectDetailsAsync(flagName, defaultValue); - - Assert.Equal(ErrorType.ParseError, response.ErrorType); - Assert.Equal(Reason.Error, response.Reason); - Assert.Equal(testMessage, response.ErrorMessage); - _ = featureProviderMock.Received(1).ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); - } + [Fact] + public async Task When_Error_Is_Returned_From_Provider_Should_Return_Error() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + const string testMessage = "Couldn't parse flag data."; + + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()).Returns(Task.FromResult(new ResolutionDetails(flagName, defaultValue, ErrorType.ParseError, "ERROR", null, testMessage))); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); + var response = await client.GetObjectDetailsAsync(flagName, defaultValue); + + Assert.Equal(ErrorType.ParseError, response.ErrorType); + Assert.Equal(Reason.Error, response.Reason); + Assert.Equal(testMessage, response.ErrorMessage); + _ = featureProviderMock.Received(1).ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); + } - [Fact] - public async Task When_Exception_Occurs_During_Evaluation_Should_Return_Error() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); - const string testMessage = "Couldn't parse flag data."; - - var featureProviderMock = Substitute.For(); - featureProviderMock.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()).Throws(new FeatureProviderException(ErrorType.ParseError, testMessage)); - featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); - featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - - await Api.Instance.SetProviderAsync(featureProviderMock); - var client = Api.Instance.GetClient(domain, clientVersion); - var response = await client.GetObjectDetailsAsync(flagName, defaultValue); - - Assert.Equal(ErrorType.ParseError, response.ErrorType); - Assert.Equal(Reason.Error, response.Reason); - Assert.Equal(testMessage, response.ErrorMessage); - _ = featureProviderMock.Received(1).ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); - } + [Fact] + public async Task When_Exception_Occurs_During_Evaluation_Should_Return_Error() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + const string testMessage = "Couldn't parse flag data."; + + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()).Throws(new FeatureProviderException(ErrorType.ParseError, testMessage)); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); + var response = await client.GetObjectDetailsAsync(flagName, defaultValue); + + Assert.Equal(ErrorType.ParseError, response.ErrorType); + Assert.Equal(Reason.Error, response.Reason); + Assert.Equal(testMessage, response.ErrorMessage); + _ = featureProviderMock.Received(1).ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); + } - [Fact] - public async Task When_Error_Is_Returned_From_Provider_Should_Not_Run_After_Hook_But_Error_Hook() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); - const string testMessage = "Couldn't parse flag data."; - - var featureProviderMock = Substitute.For(); - featureProviderMock.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()) - .Returns(Task.FromResult(new ResolutionDetails(flagName, defaultValue, ErrorType.ParseError, - "ERROR", null, testMessage))); - featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); - featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - - await Api.Instance.SetProviderAsync(featureProviderMock); - var client = Api.Instance.GetClient(domain, clientVersion); - var testHook = new TestHook(); - client.AddHooks(testHook); - var response = await client.GetObjectDetailsAsync(flagName, defaultValue); - - Assert.Equal(ErrorType.ParseError, response.ErrorType); - Assert.Equal(Reason.Error, response.Reason); - Assert.Equal(testMessage, response.ErrorMessage); - _ = featureProviderMock.Received(1) - .ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); - - Assert.Equal(1, testHook.BeforeCallCount); - Assert.Equal(0, testHook.AfterCallCount); - Assert.Equal(1, testHook.ErrorCallCount); - Assert.Equal(1, testHook.FinallyCallCount); - } + [Fact] + public async Task When_Error_Is_Returned_From_Provider_Should_Not_Run_After_Hook_But_Error_Hook() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + const string testMessage = "Couldn't parse flag data."; + + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()) + .Returns(Task.FromResult(new ResolutionDetails(flagName, defaultValue, ErrorType.ParseError, + "ERROR", null, testMessage))); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + + await Api.Instance.SetProviderAsync(featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); + var testHook = new TestHook(); + client.AddHooks(testHook); + var response = await client.GetObjectDetailsAsync(flagName, defaultValue); + + Assert.Equal(ErrorType.ParseError, response.ErrorType); + Assert.Equal(Reason.Error, response.Reason); + Assert.Equal(testMessage, response.ErrorMessage); + _ = featureProviderMock.Received(1) + .ResolveStructureValueAsync(flagName, defaultValue, Arg.Any()); + + Assert.Equal(1, testHook.BeforeCallCount); + Assert.Equal(0, testHook.AfterCallCount); + Assert.Equal(1, testHook.ErrorCallCount); + Assert.Equal(1, testHook.FinallyCallCount); + } - [Fact] - public async Task Cancellation_Token_Added_Is_Passed_To_Provider() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultString = fixture.Create(); - var cancelledReason = "cancelled"; + [Fact] + public async Task Cancellation_Token_Added_Is_Passed_To_Provider() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultString = fixture.Create(); + var cancelledReason = "cancelled"; - var cts = new CancellationTokenSource(); + var cts = new CancellationTokenSource(); - var featureProviderMock = Substitute.For(); - featureProviderMock.ResolveStringValueAsync(flagName, defaultString, Arg.Any(), Arg.Any()).Returns(async args => + var featureProviderMock = Substitute.For(); + featureProviderMock.ResolveStringValueAsync(flagName, defaultString, Arg.Any(), Arg.Any()).Returns(async args => + { + var token = args.ArgAt(3); + while (!token.IsCancellationRequested) { - var token = args.ArgAt(3); - while (!token.IsCancellationRequested) - { - await Task.Delay(10); // artificially delay until cancelled - } + await Task.Delay(10); // artificially delay until cancelled + } - return new ResolutionDetails(flagName, defaultString, ErrorType.None, cancelledReason); - }); - featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); - featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); + return new ResolutionDetails(flagName, defaultString, ErrorType.None, cancelledReason); + }); + featureProviderMock.GetMetadata().Returns(new Metadata(fixture.Create())); + featureProviderMock.GetProviderHooks().Returns(ImmutableList.Empty); - await Api.Instance.SetProviderAsync(domain, featureProviderMock); - var client = Api.Instance.GetClient(domain, clientVersion); - var task = client.GetStringDetailsAsync(flagName, defaultString, EvaluationContext.Empty, null, cts.Token); - cts.Cancel(); // cancel before awaiting + await Api.Instance.SetProviderAsync(domain, featureProviderMock); + var client = Api.Instance.GetClient(domain, clientVersion); + var task = client.GetStringDetailsAsync(flagName, defaultString, EvaluationContext.Empty, null, cts.Token); + cts.Cancel(); // cancel before awaiting - var response = await task; - Assert.Equal(defaultString, response.Value); - Assert.Equal(cancelledReason, response.Reason); - _ = featureProviderMock.Received(1).ResolveStringValueAsync(flagName, defaultString, Arg.Any(), cts.Token); - } + var response = await task; + Assert.Equal(defaultString, response.Value); + Assert.Equal(cancelledReason, response.Reason); + _ = featureProviderMock.Received(1).ResolveStringValueAsync(flagName, defaultString, Arg.Any(), cts.Token); + } - [Fact] - public void Should_Get_And_Set_Context() - { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var KEY = "key"; - var VAL = 1; - FeatureClient client = Api.Instance.GetClient(domain, clientVersion); - client.SetContext(new EvaluationContextBuilder().Set(KEY, VAL).Build()); - Assert.Equal(VAL, client.GetContext().GetValue(KEY).AsInteger); - } + [Fact] + public void Should_Get_And_Set_Context() + { + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var KEY = "key"; + var VAL = 1; + FeatureClient client = Api.Instance.GetClient(domain, clientVersion); + client.SetContext(new EvaluationContextBuilder().Set(KEY, VAL).Build()); + Assert.Equal(VAL, client.GetContext().GetValue(KEY).AsInteger); + } - [Fact] - public void ToFlagEvaluationDetails_Should_Convert_All_Properties() - { - var fixture = new Fixture(); - var flagName = fixture.Create(); - var boolValue = fixture.Create(); - var errorType = fixture.Create(); - var reason = fixture.Create(); - var variant = fixture.Create(); - var errorMessage = fixture.Create(); - var flagData = fixture - .CreateMany>(10) - .ToDictionary(x => x.Key, x => x.Value); - var flagMetadata = new ImmutableMetadata(flagData); - - var expected = new ResolutionDetails(flagName, boolValue, errorType, reason, variant, errorMessage, flagMetadata); - var result = expected.ToFlagEvaluationDetails(); - - Assert.Equivalent(expected, result); - } + [Fact] + public void ToFlagEvaluationDetails_Should_Convert_All_Properties() + { + var fixture = new Fixture(); + var flagName = fixture.Create(); + var boolValue = fixture.Create(); + var errorType = fixture.Create(); + var reason = fixture.Create(); + var variant = fixture.Create(); + var errorMessage = fixture.Create(); + var flagData = fixture + .CreateMany>(10) + .ToDictionary(x => x.Key, x => x.Value); + var flagMetadata = new ImmutableMetadata(flagData); + + var expected = new ResolutionDetails(flagName, boolValue, errorType, reason, variant, errorMessage, flagMetadata); + var result = expected.ToFlagEvaluationDetails(); + + Assert.Equivalent(expected, result); + } - [Fact] - [Specification("6.1.1", "The `client` MUST define a function for tracking the occurrence of a particular action or application state, with parameters `tracking event name` (string, required), `evaluation context` (optional) and `tracking event details` (optional), which returns nothing.")] - [Specification("6.1.2", "The `client` MUST define a function for tracking the occurrence of a particular action or application state, with parameters `tracking event name` (string, required) and `tracking event details` (optional), which returns nothing.")] - [Specification("6.1.4", "If the client's `track` function is called and the associated provider does not implement tracking, the client's `track` function MUST no-op.")] - public async Task TheClient_ImplementsATrackingFunction() - { - var provider = new TestProvider(); - await Api.Instance.SetProviderAsync(provider); - var client = Api.Instance.GetClient(); - - const string trackingEventName = "trackingEventName"; - var trackingEventDetails = new TrackingEventDetailsBuilder().Build(); - client.Track(trackingEventName); - client.Track(trackingEventName, EvaluationContext.Empty); - client.Track(trackingEventName, EvaluationContext.Empty, trackingEventDetails); - client.Track(trackingEventName, trackingEventDetails: trackingEventDetails); - - Assert.Equal(4, provider.GetTrackingInvocations().Count); - Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[0].Item1); - Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[1].Item1); - Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[2].Item1); - Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[3].Item1); - - Assert.NotNull(provider.GetTrackingInvocations()[0].Item2); - Assert.NotNull(provider.GetTrackingInvocations()[1].Item2); - Assert.NotNull(provider.GetTrackingInvocations()[2].Item2); - Assert.NotNull(provider.GetTrackingInvocations()[3].Item2); - - Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[0].Item2!.Count); - Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[1].Item2!.Count); - Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[2].Item2!.Count); - Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[3].Item2!.Count); - - Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[0].Item2!.TargetingKey); - Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[1].Item2!.TargetingKey); - Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[2].Item2!.TargetingKey); - Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[3].Item2!.TargetingKey); - - Assert.Null(provider.GetTrackingInvocations()[0].Item3); - Assert.Null(provider.GetTrackingInvocations()[1].Item3); - Assert.Equal(trackingEventDetails, provider.GetTrackingInvocations()[2].Item3); - Assert.Equal(trackingEventDetails, provider.GetTrackingInvocations()[3].Item3); - } + [Fact] + [Specification("6.1.1", "The `client` MUST define a function for tracking the occurrence of a particular action or application state, with parameters `tracking event name` (string, required), `evaluation context` (optional) and `tracking event details` (optional), which returns nothing.")] + [Specification("6.1.2", "The `client` MUST define a function for tracking the occurrence of a particular action or application state, with parameters `tracking event name` (string, required) and `tracking event details` (optional), which returns nothing.")] + [Specification("6.1.4", "If the client's `track` function is called and the associated provider does not implement tracking, the client's `track` function MUST no-op.")] + public async Task TheClient_ImplementsATrackingFunction() + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); + + const string trackingEventName = "trackingEventName"; + var trackingEventDetails = new TrackingEventDetailsBuilder().Build(); + client.Track(trackingEventName); + client.Track(trackingEventName, EvaluationContext.Empty); + client.Track(trackingEventName, EvaluationContext.Empty, trackingEventDetails); + client.Track(trackingEventName, trackingEventDetails: trackingEventDetails); + + Assert.Equal(4, provider.GetTrackingInvocations().Count); + Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[0].Item1); + Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[1].Item1); + Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[2].Item1); + Assert.Equal(trackingEventName, provider.GetTrackingInvocations()[3].Item1); + + Assert.NotNull(provider.GetTrackingInvocations()[0].Item2); + Assert.NotNull(provider.GetTrackingInvocations()[1].Item2); + Assert.NotNull(provider.GetTrackingInvocations()[2].Item2); + Assert.NotNull(provider.GetTrackingInvocations()[3].Item2); + + Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[0].Item2!.Count); + Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[1].Item2!.Count); + Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[2].Item2!.Count); + Assert.Equal(EvaluationContext.Empty.Count, provider.GetTrackingInvocations()[3].Item2!.Count); + + Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[0].Item2!.TargetingKey); + Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[1].Item2!.TargetingKey); + Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[2].Item2!.TargetingKey); + Assert.Equal(EvaluationContext.Empty.TargetingKey, provider.GetTrackingInvocations()[3].Item2!.TargetingKey); + + Assert.Null(provider.GetTrackingInvocations()[0].Item3); + Assert.Null(provider.GetTrackingInvocations()[1].Item3); + Assert.Equal(trackingEventDetails, provider.GetTrackingInvocations()[2].Item3); + Assert.Equal(trackingEventDetails, provider.GetTrackingInvocations()[3].Item3); + } - [Fact] - public async Task PassingAnEmptyStringAsTrackingEventName_ThrowsArgumentException() - { - var provider = new TestProvider(); - await Api.Instance.SetProviderAsync(provider); - var client = Api.Instance.GetClient(); + [Fact] + public async Task PassingAnEmptyStringAsTrackingEventName_ThrowsArgumentException() + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); - Assert.Throws(() => client.Track("")); - } + Assert.Throws(() => client.Track("")); + } - [Fact] - public async Task PassingABlankStringAsTrackingEventName_ThrowsArgumentException() - { - var provider = new TestProvider(); - await Api.Instance.SetProviderAsync(provider); - var client = Api.Instance.GetClient(); + [Fact] + public async Task PassingABlankStringAsTrackingEventName_ThrowsArgumentException() + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); - Assert.Throws(() => client.Track(" \n ")); - } + Assert.Throws(() => client.Track(" \n ")); + } - public static TheoryData GenerateMergeEvaluationContextTestData() + public static TheoryData GenerateMergeEvaluationContextTestData() + { + const string key = "key"; + const string global = "global"; + const string client = "client"; + const string invocation = "invocation"; + var globalEvaluationContext = new[] { new EvaluationContextBuilder().Set(key, "global").Build(), null }; + var clientEvaluationContext = new[] { new EvaluationContextBuilder().Set(key, "client").Build(), null }; + var invocationEvaluationContext = new[] { new EvaluationContextBuilder().Set(key, "invocation").Build(), null }; + + var data = new TheoryData(); + for (int i = 0; i < 2; i++) { - const string key = "key"; - const string global = "global"; - const string client = "client"; - const string invocation = "invocation"; - var globalEvaluationContext = new[] { new EvaluationContextBuilder().Set(key, "global").Build(), null }; - var clientEvaluationContext = new[] { new EvaluationContextBuilder().Set(key, "client").Build(), null }; - var invocationEvaluationContext = new[] { new EvaluationContextBuilder().Set(key, "invocation").Build(), null }; - - var data = new TheoryData(); - for (int i = 0; i < 2; i++) + for (int j = 0; j < 2; j++) { - for (int j = 0; j < 2; j++) + for (int k = 0; k < 2; k++) { - for (int k = 0; k < 2; k++) - { - if (i == 1 && j == 1 && k == 1) continue; - string expected; - if (k == 0) expected = invocation; - else if (j == 0) expected = client; - else expected = global; - data.Add(key, globalEvaluationContext[i], clientEvaluationContext[j], invocationEvaluationContext[k], expected); - } + if (i == 1 && j == 1 && k == 1) continue; + string expected; + if (k == 0) expected = invocation; + else if (j == 0) expected = client; + else expected = global; + data.Add(key, globalEvaluationContext[i], clientEvaluationContext[j], invocationEvaluationContext[k], expected); } } - - return data; } - [Theory] - [MemberData(nameof(GenerateMergeEvaluationContextTestData))] - [Specification("6.1.3", "The evaluation context passed to the provider's track function MUST be merged in the order: API (global; lowest precedence) - transaction - client - invocation (highest precedence), with duplicate values being overwritten.")] - public async Task TheClient_MergesTheEvaluationContextInTheCorrectOrder(string key, EvaluationContext? globalEvaluationContext, EvaluationContext? clientEvaluationContext, EvaluationContext? invocationEvaluationContext, string expectedResult) - { - var provider = new TestProvider(); - await Api.Instance.SetProviderAsync(provider); - var client = Api.Instance.GetClient(); + return data; + } - const string trackingEventName = "trackingEventName"; + [Theory] + [MemberData(nameof(GenerateMergeEvaluationContextTestData))] + [Specification("6.1.3", "The evaluation context passed to the provider's track function MUST be merged in the order: API (global; lowest precedence) - transaction - client - invocation (highest precedence), with duplicate values being overwritten.")] + public async Task TheClient_MergesTheEvaluationContextInTheCorrectOrder(string key, EvaluationContext? globalEvaluationContext, EvaluationContext? clientEvaluationContext, EvaluationContext? invocationEvaluationContext, string expectedResult) + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); - Api.Instance.SetContext(globalEvaluationContext); - client.SetContext(clientEvaluationContext); - client.Track(trackingEventName, invocationEvaluationContext); - Assert.Single(provider.GetTrackingInvocations()); - var actualEvaluationContext = provider.GetTrackingInvocations()[0].Item2; - Assert.NotNull(actualEvaluationContext); - Assert.NotEqual(0, actualEvaluationContext.Count); + const string trackingEventName = "trackingEventName"; - Assert.Equal(expectedResult, actualEvaluationContext.GetValue(key).AsString); - } + Api.Instance.SetContext(globalEvaluationContext); + client.SetContext(clientEvaluationContext); + client.Track(trackingEventName, invocationEvaluationContext); + Assert.Single(provider.GetTrackingInvocations()); + var actualEvaluationContext = provider.GetTrackingInvocations()[0].Item2; + Assert.NotNull(actualEvaluationContext); + Assert.NotEqual(0, actualEvaluationContext.Count); - [Fact] - [Specification("4.3.8", "'evaluation details' passed to the 'finally' stage matches the evaluation details returned to the application author")] - public async Task FinallyHook_IncludesEvaluationDetails() - { - // Arrange - var provider = new TestProvider(); - var providerHook = Substitute.For(); - provider.AddHook(providerHook); - await Api.Instance.SetProviderAsync(provider); - var client = Api.Instance.GetClient(); + Assert.Equal(expectedResult, actualEvaluationContext.GetValue(key).AsString); + } - const string flagName = "flagName"; + [Fact] + [Specification("4.3.8", "'evaluation details' passed to the 'finally' stage matches the evaluation details returned to the application author")] + public async Task FinallyHook_IncludesEvaluationDetails() + { + // Arrange + var provider = new TestProvider(); + var providerHook = Substitute.For(); + provider.AddHook(providerHook); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(); - // Act - var evaluationDetails = await client.GetBooleanDetailsAsync(flagName, true); + const string flagName = "flagName"; - // Assert - await providerHook.Received(1).FinallyAsync(Arg.Any>(), evaluationDetails); - } + // Act + var evaluationDetails = await client.GetBooleanDetailsAsync(flagName, true); + + // Assert + await providerHook.Received(1).FinallyAsync(Arg.Any>(), evaluationDetails); } } diff --git a/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs b/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs index 20b0ec2e2..630ec435e 100644 --- a/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs @@ -5,218 +5,217 @@ using OpenFeature.Tests.Internal; using Xunit; -namespace OpenFeature.Tests +namespace OpenFeature.Tests; + +public class OpenFeatureEvaluationContextTests { - public class OpenFeatureEvaluationContextTests + [Fact] + public void Should_Merge_Two_Contexts() { - [Fact] - public void Should_Merge_Two_Contexts() - { - var contextBuilder1 = new EvaluationContextBuilder() - .Set("key1", "value1"); - var contextBuilder2 = new EvaluationContextBuilder() - .Set("key2", "value2"); - var context1 = contextBuilder1.Merge(contextBuilder2.Build()).Build(); - - Assert.Equal(2, context1.Count); - Assert.Equal("value1", context1.GetValue("key1").AsString); - Assert.Equal("value2", context1.GetValue("key2").AsString); - } + var contextBuilder1 = new EvaluationContextBuilder() + .Set("key1", "value1"); + var contextBuilder2 = new EvaluationContextBuilder() + .Set("key2", "value2"); + var context1 = contextBuilder1.Merge(contextBuilder2.Build()).Build(); + + Assert.Equal(2, context1.Count); + Assert.Equal("value1", context1.GetValue("key1").AsString); + Assert.Equal("value2", context1.GetValue("key2").AsString); + } - [Fact] - public void Should_Change_TargetingKey_From_OverridingContext() - { - var contextBuilder1 = new EvaluationContextBuilder() - .Set("key1", "value1") - .SetTargetingKey("targeting_key"); - var contextBuilder2 = new EvaluationContextBuilder() - .Set("key2", "value2") - .SetTargetingKey("overriding_key"); + [Fact] + public void Should_Change_TargetingKey_From_OverridingContext() + { + var contextBuilder1 = new EvaluationContextBuilder() + .Set("key1", "value1") + .SetTargetingKey("targeting_key"); + var contextBuilder2 = new EvaluationContextBuilder() + .Set("key2", "value2") + .SetTargetingKey("overriding_key"); - var mergeContext = contextBuilder1.Merge(contextBuilder2.Build()).Build(); + var mergeContext = contextBuilder1.Merge(contextBuilder2.Build()).Build(); - Assert.Equal("overriding_key", mergeContext.TargetingKey); - } + Assert.Equal("overriding_key", mergeContext.TargetingKey); + } - [Fact] - public void Should_Retain_TargetingKey_When_OverridingContext_TargetingKey_Value_IsEmpty() - { - var contextBuilder1 = new EvaluationContextBuilder() - .Set("key1", "value1") - .SetTargetingKey("targeting_key"); - var contextBuilder2 = new EvaluationContextBuilder() - .Set("key2", "value2"); + [Fact] + public void Should_Retain_TargetingKey_When_OverridingContext_TargetingKey_Value_IsEmpty() + { + var contextBuilder1 = new EvaluationContextBuilder() + .Set("key1", "value1") + .SetTargetingKey("targeting_key"); + var contextBuilder2 = new EvaluationContextBuilder() + .Set("key2", "value2"); - var mergeContext = contextBuilder1.Merge(contextBuilder2.Build()).Build(); + var mergeContext = contextBuilder1.Merge(contextBuilder2.Build()).Build(); - Assert.Equal("targeting_key", mergeContext.TargetingKey); - } + Assert.Equal("targeting_key", mergeContext.TargetingKey); + } - [Fact] - [Specification("3.2.2", "Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.")] - public void Should_Merge_TwoContexts_And_Override_Duplicates_With_RightHand_Context() - { - var contextBuilder1 = new EvaluationContextBuilder(); - var contextBuilder2 = new EvaluationContextBuilder(); + [Fact] + [Specification("3.2.2", "Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.")] + public void Should_Merge_TwoContexts_And_Override_Duplicates_With_RightHand_Context() + { + var contextBuilder1 = new EvaluationContextBuilder(); + var contextBuilder2 = new EvaluationContextBuilder(); - contextBuilder1.Set("key1", "value1"); - contextBuilder2.Set("key1", "overriden_value"); - contextBuilder2.Set("key2", "value2"); + contextBuilder1.Set("key1", "value1"); + contextBuilder2.Set("key1", "overriden_value"); + contextBuilder2.Set("key2", "value2"); - var context1 = contextBuilder1.Merge(contextBuilder2.Build()).Build(); + var context1 = contextBuilder1.Merge(contextBuilder2.Build()).Build(); - Assert.Equal(2, context1.Count); - Assert.Equal("overriden_value", context1.GetValue("key1").AsString); - Assert.Equal("value2", context1.GetValue("key2").AsString); - } + Assert.Equal(2, context1.Count); + Assert.Equal("overriden_value", context1.GetValue("key1").AsString); + Assert.Equal("value2", context1.GetValue("key2").AsString); + } - [Fact] - [Specification("3.1.1", "The `evaluation context` structure MUST define an optional `targeting key` field of type string, identifying the subject of the flag evaluation.")] - [Specification("3.1.2", "The evaluation context MUST support the inclusion of custom fields, having keys of type `string`, and values of type `boolean | string | number | datetime | structure`.")] - public void EvaluationContext_Should_All_Types() - { - var fixture = new Fixture(); - var now = fixture.Create(); - var structure = fixture.Create(); - var contextBuilder = new EvaluationContextBuilder() - .SetTargetingKey("targeting_key") - .Set("targeting_key", "userId") - .Set("key1", "value") - .Set("key2", 1) - .Set("key3", true) - .Set("key4", now) - .Set("key5", structure) - .Set("key6", 1.0); - - var context = contextBuilder.Build(); - - Assert.Equal("targeting_key", context.TargetingKey); - var targetingKeyValue = context.GetValue(context.TargetingKey!); - Assert.True(targetingKeyValue.IsString); - Assert.Equal("userId", targetingKeyValue.AsString); - - var value1 = context.GetValue("key1"); - Assert.True(value1.IsString); - Assert.Equal("value", value1.AsString); - - var value2 = context.GetValue("key2"); - Assert.True(value2.IsNumber); - Assert.Equal(1, value2.AsInteger); - - var value3 = context.GetValue("key3"); - Assert.True(value3.IsBoolean); - Assert.True(value3.AsBoolean); - - var value4 = context.GetValue("key4"); - Assert.True(value4.IsDateTime); - Assert.Equal(now, value4.AsDateTime); - - var value5 = context.GetValue("key5"); - Assert.True(value5.IsStructure); - Assert.Equal(structure, value5.AsStructure); - - var value6 = context.GetValue("key6"); - Assert.True(value6.IsNumber); - Assert.Equal(1.0, value6.AsDouble); - } + [Fact] + [Specification("3.1.1", "The `evaluation context` structure MUST define an optional `targeting key` field of type string, identifying the subject of the flag evaluation.")] + [Specification("3.1.2", "The evaluation context MUST support the inclusion of custom fields, having keys of type `string`, and values of type `boolean | string | number | datetime | structure`.")] + public void EvaluationContext_Should_All_Types() + { + var fixture = new Fixture(); + var now = fixture.Create(); + var structure = fixture.Create(); + var contextBuilder = new EvaluationContextBuilder() + .SetTargetingKey("targeting_key") + .Set("targeting_key", "userId") + .Set("key1", "value") + .Set("key2", 1) + .Set("key3", true) + .Set("key4", now) + .Set("key5", structure) + .Set("key6", 1.0); + + var context = contextBuilder.Build(); + + Assert.Equal("targeting_key", context.TargetingKey); + var targetingKeyValue = context.GetValue(context.TargetingKey!); + Assert.True(targetingKeyValue.IsString); + Assert.Equal("userId", targetingKeyValue.AsString); + + var value1 = context.GetValue("key1"); + Assert.True(value1.IsString); + Assert.Equal("value", value1.AsString); + + var value2 = context.GetValue("key2"); + Assert.True(value2.IsNumber); + Assert.Equal(1, value2.AsInteger); + + var value3 = context.GetValue("key3"); + Assert.True(value3.IsBoolean); + Assert.True(value3.AsBoolean); + + var value4 = context.GetValue("key4"); + Assert.True(value4.IsDateTime); + Assert.Equal(now, value4.AsDateTime); + + var value5 = context.GetValue("key5"); + Assert.True(value5.IsStructure); + Assert.Equal(structure, value5.AsStructure); + + var value6 = context.GetValue("key6"); + Assert.True(value6.IsNumber); + Assert.Equal(1.0, value6.AsDouble); + } - [Fact] - [Specification("3.1.4", "The evaluation context fields MUST have an unique key.")] - public void When_Duplicate_Key_Set_It_Replaces_Value() - { - var contextBuilder = new EvaluationContextBuilder().Set("key", "value"); - contextBuilder.Set("key", "overriden_value"); - Assert.Equal("overriden_value", contextBuilder.Build().GetValue("key").AsString); - } + [Fact] + [Specification("3.1.4", "The evaluation context fields MUST have an unique key.")] + public void When_Duplicate_Key_Set_It_Replaces_Value() + { + var contextBuilder = new EvaluationContextBuilder().Set("key", "value"); + contextBuilder.Set("key", "overriden_value"); + Assert.Equal("overriden_value", contextBuilder.Build().GetValue("key").AsString); + } - [Fact] - [Specification("3.1.3", "The evaluation context MUST support fetching the custom fields by key and also fetching all key value pairs.")] - public void Should_Be_Able_To_Get_All_Values() + [Fact] + [Specification("3.1.3", "The evaluation context MUST support fetching the custom fields by key and also fetching all key value pairs.")] + public void Should_Be_Able_To_Get_All_Values() + { + var context = new EvaluationContextBuilder() + .Set("key1", "value1") + .Set("key2", "value2") + .Set("key3", "value3") + .Set("key4", "value4") + .Set("key5", "value5") + .Build(); + + // Iterate over key value pairs and check consistency + var count = 0; + foreach (var keyValue in context) { - var context = new EvaluationContextBuilder() - .Set("key1", "value1") - .Set("key2", "value2") - .Set("key3", "value3") - .Set("key4", "value4") - .Set("key5", "value5") - .Build(); - - // Iterate over key value pairs and check consistency - var count = 0; - foreach (var keyValue in context) - { - Assert.Equal(keyValue.Value.AsString, context.GetValue(keyValue.Key).AsString); - count++; - } - - Assert.Equal(count, context.Count); + Assert.Equal(keyValue.Value.AsString, context.GetValue(keyValue.Key).AsString); + count++; } - [Fact] - public void TryGetValue_WhenCalledWithExistingKey_ReturnsTrueAndExpectedValue() - { - // Arrange - var key = "testKey"; - var expectedValue = new Value("testValue"); - var structure = new Structure(new Dictionary { { key, expectedValue } }); - var evaluationContext = new EvaluationContext(structure); - - // Act - var result = evaluationContext.TryGetValue(key, out var actualValue); - - // Assert - Assert.True(result); - Assert.Equal(expectedValue, actualValue); - } + Assert.Equal(count, context.Count); + } - [Fact] - public void GetValueOnTargetingKeySetWithTargetingKey_Equals_TargetingKey() - { - // Arrange - var value = "my_targeting_key"; - var evaluationContext = EvaluationContext.Builder().SetTargetingKey(value).Build(); - - // Act - var result = evaluationContext.TryGetValue(EvaluationContext.TargetingKeyIndex, out var actualFromStructure); - var actualFromTargetingKey = evaluationContext.TargetingKey; - - // Assert - Assert.True(result); - Assert.Equal(value, actualFromStructure?.AsString); - Assert.Equal(value, actualFromTargetingKey); - } + [Fact] + public void TryGetValue_WhenCalledWithExistingKey_ReturnsTrueAndExpectedValue() + { + // Arrange + var key = "testKey"; + var expectedValue = new Value("testValue"); + var structure = new Structure(new Dictionary { { key, expectedValue } }); + var evaluationContext = new EvaluationContext(structure); + + // Act + var result = evaluationContext.TryGetValue(key, out var actualValue); + + // Assert + Assert.True(result); + Assert.Equal(expectedValue, actualValue); + } - [Fact] - public void GetValueOnTargetingKeySetWithStructure_Equals_TargetingKey() - { - // Arrange - var value = "my_targeting_key"; - var evaluationContext = EvaluationContext.Builder().Set(EvaluationContext.TargetingKeyIndex, new Value(value)).Build(); - - // Act - var result = evaluationContext.TryGetValue(EvaluationContext.TargetingKeyIndex, out var actualFromStructure); - var actualFromTargetingKey = evaluationContext.TargetingKey; - - // Assert - Assert.True(result); - Assert.Equal(value, actualFromStructure?.AsString); - Assert.Equal(value, actualFromTargetingKey); - } + [Fact] + public void GetValueOnTargetingKeySetWithTargetingKey_Equals_TargetingKey() + { + // Arrange + var value = "my_targeting_key"; + var evaluationContext = EvaluationContext.Builder().SetTargetingKey(value).Build(); + + // Act + var result = evaluationContext.TryGetValue(EvaluationContext.TargetingKeyIndex, out var actualFromStructure); + var actualFromTargetingKey = evaluationContext.TargetingKey; + + // Assert + Assert.True(result); + Assert.Equal(value, actualFromStructure?.AsString); + Assert.Equal(value, actualFromTargetingKey); + } - [Fact] - public void GetValueOnTargetingKeySetWithNonStringValue_Equals_Null() - { - // Arrange - var evaluationContext = EvaluationContext.Builder().Set(EvaluationContext.TargetingKeyIndex, new Value(1)).Build(); + [Fact] + public void GetValueOnTargetingKeySetWithStructure_Equals_TargetingKey() + { + // Arrange + var value = "my_targeting_key"; + var evaluationContext = EvaluationContext.Builder().Set(EvaluationContext.TargetingKeyIndex, new Value(value)).Build(); + + // Act + var result = evaluationContext.TryGetValue(EvaluationContext.TargetingKeyIndex, out var actualFromStructure); + var actualFromTargetingKey = evaluationContext.TargetingKey; + + // Assert + Assert.True(result); + Assert.Equal(value, actualFromStructure?.AsString); + Assert.Equal(value, actualFromTargetingKey); + } - // Act - var result = evaluationContext.TryGetValue(EvaluationContext.TargetingKeyIndex, out var actualFromStructure); - var actualFromTargetingKey = evaluationContext.TargetingKey; + [Fact] + public void GetValueOnTargetingKeySetWithNonStringValue_Equals_Null() + { + // Arrange + var evaluationContext = EvaluationContext.Builder().Set(EvaluationContext.TargetingKeyIndex, new Value(1)).Build(); - // Assert - Assert.True(result); - Assert.Null(actualFromStructure?.AsString); - Assert.Null(actualFromTargetingKey); - } + // Act + var result = evaluationContext.TryGetValue(EvaluationContext.TargetingKeyIndex, out var actualFromStructure); + var actualFromTargetingKey = evaluationContext.TargetingKey; + + // Assert + Assert.True(result); + Assert.Null(actualFromStructure?.AsString); + Assert.Null(actualFromTargetingKey); } } diff --git a/test/OpenFeature.Tests/OpenFeatureEventTests.cs b/test/OpenFeature.Tests/OpenFeatureEventTests.cs index a4b0d1116..c8cea92b7 100644 --- a/test/OpenFeature.Tests/OpenFeatureEventTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureEventTests.cs @@ -10,511 +10,510 @@ using OpenFeature.Tests.Internal; using Xunit; -namespace OpenFeature.Tests +namespace OpenFeature.Tests; + +[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] +public class OpenFeatureEventTest : ClearOpenFeatureInstanceFixture { - [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] - public class OpenFeatureEventTest : ClearOpenFeatureInstanceFixture + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + public async Task Event_Executor_Should_Propagate_Events_ToGlobal_Handler() { - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - public async Task Event_Executor_Should_Propagate_Events_ToGlobal_Handler() - { - var eventHandler = Substitute.For(); + var eventHandler = Substitute.For(); - var eventExecutor = new EventExecutor(); + var eventExecutor = new EventExecutor(); - eventExecutor.AddApiLevelHandler(ProviderEventTypes.ProviderConfigurationChanged, eventHandler); + eventExecutor.AddApiLevelHandler(ProviderEventTypes.ProviderConfigurationChanged, eventHandler); - var eventMetadata = new ImmutableMetadata(new Dictionary { { "foo", "bar" } }); - var myEvent = new Event + var eventMetadata = new ImmutableMetadata(new Dictionary { { "foo", "bar" } }); + var myEvent = new Event + { + EventPayload = new ProviderEventPayload { - EventPayload = new ProviderEventPayload + Type = ProviderEventTypes.ProviderConfigurationChanged, + Message = "The provider is ready", + EventMetadata = eventMetadata, + FlagsChanged = new List { - Type = ProviderEventTypes.ProviderConfigurationChanged, - Message = "The provider is ready", - EventMetadata = eventMetadata, - FlagsChanged = new List - { - "flag1", "flag2" - } + "flag1", "flag2" } - }; - eventExecutor.EventChannel.Writer.TryWrite(myEvent); + } + }; + eventExecutor.EventChannel.Writer.TryWrite(myEvent); - Thread.Sleep(1000); + Thread.Sleep(1000); - eventHandler.Received().Invoke(Arg.Is(payload => payload == myEvent.EventPayload)); + eventHandler.Received().Invoke(Arg.Is(payload => payload == myEvent.EventPayload)); - // shut down the event executor - await eventExecutor.ShutdownAsync(); + // shut down the event executor + await eventExecutor.ShutdownAsync(); - // the next event should not be propagated to the event handler - var newEventPayload = new ProviderEventPayload - { - Type = ProviderEventTypes.ProviderStale - }; + // the next event should not be propagated to the event handler + var newEventPayload = new ProviderEventPayload + { + Type = ProviderEventTypes.ProviderStale + }; - eventExecutor.EventChannel.Writer.TryWrite(newEventPayload); + eventExecutor.EventChannel.Writer.TryWrite(newEventPayload); - eventHandler.DidNotReceive().Invoke(newEventPayload); + eventHandler.DidNotReceive().Invoke(newEventPayload); - eventHandler.DidNotReceive().Invoke(Arg.Is(payload => payload.Type == ProviderEventTypes.ProviderStale)); - } + eventHandler.DidNotReceive().Invoke(Arg.Is(payload => payload.Type == ProviderEventTypes.ProviderStale)); + } - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - public async Task API_Level_Event_Handlers_Should_Be_Registered() - { - var eventHandler = Substitute.For(); - - Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); - Api.Instance.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, eventHandler); - Api.Instance.AddHandler(ProviderEventTypes.ProviderError, eventHandler); - Api.Instance.AddHandler(ProviderEventTypes.ProviderStale, eventHandler); - - var testProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(testProvider); - - await testProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); - await testProvider.SendEventAsync(ProviderEventTypes.ProviderError); - await testProvider.SendEventAsync(ProviderEventTypes.ProviderStale); - - await Utils.AssertUntilAsync(_ => eventHandler - .Received() - .Invoke( - Arg.Is( - payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady - ))); - - await Utils.AssertUntilAsync(_ => eventHandler - .Received() - .Invoke( - Arg.Is( - payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged - ))); - - await Utils.AssertUntilAsync(_ => eventHandler - .Received() - .Invoke( - Arg.Is( - payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderError - ))); - - await Utils.AssertUntilAsync(_ => eventHandler - .Received() - .Invoke( - Arg.Is( - payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderStale - ))); - } - - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - [Specification("5.3.1", "If the provider's `initialize` function terminates normally, `PROVIDER_READY` handlers MUST run.")] - [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] - public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Ready_State_After_Registering_Provider_Ready() - { - var eventHandler = Substitute.For(); - - var testProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(testProvider); - - Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); - - await Utils.AssertUntilAsync(_ => eventHandler - .Received() - .Invoke( - Arg.Is( - payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady - ))); - } - - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - [Specification("5.3.1", "If the provider's `initialize` function terminates normally, `PROVIDER_READY` handlers MUST run.")] - [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] - public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Ready_State_After_Registering_Provider_Ready_Sync() - { - var eventHandler = Substitute.For(); - - var testProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(testProvider); - - Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); - - await Utils.AssertUntilAsync(_ => eventHandler - .Received() - .Invoke( - Arg.Is( - payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady - ))); - } - - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - [Specification("5.3.2", "If the provider's `initialize` function terminates abnormally, `PROVIDER_ERROR` handlers MUST run.")] - [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] - public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Error_State_After_Registering_Provider_Error() - { - var eventHandler = Substitute.For(); - - var testProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(testProvider); - - testProvider.Status = ProviderStatus.Error; - - Api.Instance.AddHandler(ProviderEventTypes.ProviderError, eventHandler); - - await Utils.AssertUntilAsync(_ => eventHandler - .Received() - .Invoke( - Arg.Is( - payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderError - ))); - } - - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] - public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Stale_State_After_Registering_Provider_Stale() - { - var eventHandler = Substitute.For(); - - var testProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(testProvider); - - testProvider.Status = ProviderStatus.Stale; - - Api.Instance.AddHandler(ProviderEventTypes.ProviderStale, eventHandler); - - await Utils.AssertUntilAsync(_ => eventHandler - .Received() - .Invoke( - Arg.Is( - payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderStale - ))); - } - - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - [Specification("5.2.6", "Event handlers MUST persist across `provider` changes.")] - public async Task API_Level_Event_Handlers_Should_Be_Exchangeable() - { - var eventHandler = Substitute.For(); + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + public async Task API_Level_Event_Handlers_Should_Be_Registered() + { + var eventHandler = Substitute.For(); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, eventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderError, eventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderStale, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); + + await testProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); + await testProvider.SendEventAsync(ProviderEventTypes.ProviderError); + await testProvider.SendEventAsync(ProviderEventTypes.ProviderStale); + + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady + ))); + + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged + ))); + + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderError + ))); + + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderStale + ))); + } - Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); - Api.Instance.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, eventHandler); + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.1", "If the provider's `initialize` function terminates normally, `PROVIDER_READY` handlers MUST run.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Ready_State_After_Registering_Provider_Ready() + { + var eventHandler = Substitute.For(); - var testProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(testProvider); + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); - await testProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); - var newTestProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(newTestProvider); + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady + ))); + } - await newTestProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.1", "If the provider's `initialize` function terminates normally, `PROVIDER_READY` handlers MUST run.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Ready_State_After_Registering_Provider_Ready_Sync() + { + var eventHandler = Substitute.For(); - await Utils.AssertUntilAsync( - _ => eventHandler.Received(2).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady)) - ); - await Utils.AssertUntilAsync( - _ => eventHandler.Received(2).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)) - ); - } + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); - [Fact] - [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - [Specification("5.2.7", "The `API` and `client` MUST provide a function allowing the removal of event handlers.")] - public async Task API_Level_Event_Handlers_Should_Be_Removable() - { - var eventHandler = Substitute.For(); + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); - Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady + ))); + } - var testProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(testProvider); + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.2", "If the provider's `initialize` function terminates abnormally, `PROVIDER_ERROR` handlers MUST run.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Error_State_After_Registering_Provider_Error() + { + var eventHandler = Substitute.For(); - Thread.Sleep(1000); - Api.Instance.RemoveHandler(ProviderEventTypes.ProviderReady, eventHandler); + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); - var newTestProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(newTestProvider); + testProvider.Status = ProviderStatus.Error; - eventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); - } + Api.Instance.AddHandler(ProviderEventTypes.ProviderError, eventHandler); - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - [Specification("5.2.5", "If a `handler function` terminates abnormally, other `handler` functions MUST run.")] - public async Task API_Level_Event_Handlers_Should_Be_Executed_When_Other_Handler_Fails() - { - var fixture = new Fixture(); - - var failingEventHandler = Substitute.For(); - var eventHandler = Substitute.For(); - - failingEventHandler.When(x => x.Invoke(Arg.Any())) - .Do(x => throw new Exception()); - - Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, failingEventHandler); - Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); - - var testProvider = new TestProvider(fixture.Create()); - await Api.Instance.SetProviderAsync(testProvider); - - await Utils.AssertUntilAsync( - _ => failingEventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) - ); - await Utils.AssertUntilAsync( - _ => eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) - ); - } - - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - public async Task Client_Level_Event_Handlers_Should_Be_Registered() - { - var fixture = new Fixture(); - var eventHandler = Substitute.For(); + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderError + ))); + } - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var myClient = Api.Instance.GetClient(domain, clientVersion); + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task API_Level_Event_Handlers_Should_Be_Informed_About_Stale_State_After_Registering_Provider_Stale() + { + var eventHandler = Substitute.For(); - var testProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name!, testProvider); + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); - myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + testProvider.Status = ProviderStatus.Stale; - eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); - } + Api.Instance.AddHandler(ProviderEventTypes.ProviderStale, eventHandler); - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - [Specification("5.2.5", "If a `handler function` terminates abnormally, other `handler` functions MUST run.")] - public async Task Client_Level_Event_Handlers_Should_Be_Executed_When_Other_Handler_Fails() - { - var fixture = new Fixture(); - - var failingEventHandler = Substitute.For(); - var eventHandler = Substitute.For(); - - failingEventHandler.When(x => x.Invoke(Arg.Any())) - .Do(x => throw new Exception()); - - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var myClient = Api.Instance.GetClient(domain, clientVersion); - - myClient.AddHandler(ProviderEventTypes.ProviderReady, failingEventHandler); - myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); - - var testProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name!, testProvider); - - await Utils.AssertUntilAsync( - _ => failingEventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) - ); - await Utils.AssertUntilAsync( - _ => eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) - ); - } - - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - [Specification("5.1.3", "When a `provider` signals the occurrence of a particular `event`, event handlers on clients which are not associated with that provider MUST NOT run.")] - [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - public async Task Client_Level_Event_Handlers_Should_Be_Registered_To_Default_Provider() - { - var fixture = new Fixture(); - var eventHandler = Substitute.For(); - var clientEventHandler = Substitute.For(); - - var myClientWithNoBoundProvider = Api.Instance.GetClient(fixture.Create(), fixture.Create()); - var myClientWithBoundProvider = Api.Instance.GetClient(fixture.Create(), fixture.Create()); - - var apiProvider = new TestProvider(fixture.Create()); - var clientProvider = new TestProvider(fixture.Create()); - - // set the default provider on API level, but not specifically to the client - await Api.Instance.SetProviderAsync(apiProvider); - // set the other provider specifically for the client - await Api.Instance.SetProviderAsync(myClientWithBoundProvider.GetMetadata().Name!, clientProvider); - - myClientWithNoBoundProvider.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); - myClientWithBoundProvider.AddHandler(ProviderEventTypes.ProviderReady, clientEventHandler); - - eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == apiProvider.GetMetadata().Name)); - eventHandler.DidNotReceive().Invoke(Arg.Is(payload => payload.ProviderName == clientProvider.GetMetadata().Name)); - - clientEventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == clientProvider.GetMetadata().Name)); - clientEventHandler.DidNotReceive().Invoke(Arg.Is(payload => payload.ProviderName == apiProvider.GetMetadata().Name)); - } - - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - [Specification("5.1.3", "When a `provider` signals the occurrence of a particular `event`, event handlers on clients which are not associated with that provider MUST NOT run.")] - [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - [Specification("5.2.6", "Event handlers MUST persist across `provider` changes.")] - public async Task Client_Level_Event_Handlers_Should_Be_Receive_Events_From_Named_Provider_Instead_of_Default() - { - var fixture = new Fixture(); - var clientEventHandler = Substitute.For(); - - var client = Api.Instance.GetClient(fixture.Create(), fixture.Create()); - - var defaultProvider = new TestProvider(fixture.Create()); - var clientProvider = new TestProvider(fixture.Create()); - - // set the default provider - await Api.Instance.SetProviderAsync(defaultProvider); - - client.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, clientEventHandler); - - await defaultProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); - - // verify that the client received the event from the default provider as there is no named provider registered yet - await Utils.AssertUntilAsync( - _ => clientEventHandler.Received(1) - .Invoke(Arg.Is(payload => payload.ProviderName == defaultProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)) - ); - - // set the other provider specifically for the client - await Api.Instance.SetProviderAsync(client.GetMetadata().Name!, clientProvider); - - // now, send another event for the default handler - await defaultProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); - await clientProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); - - // now the client should have received only the event from the named provider - await Utils.AssertUntilAsync( - _ => clientEventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == clientProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)) - ); - // for the default provider, the number of received events should stay unchanged - await Utils.AssertUntilAsync( - _ => clientEventHandler.Received(1) - .Invoke(Arg.Is(payload => payload.ProviderName == defaultProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)) - ); - } - - [Fact] - [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] - [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - [Specification("5.3.1", "If the provider's `initialize` function terminates normally, `PROVIDER_READY` handlers MUST run.")] - [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] - public async Task Client_Level_Event_Handlers_Should_Be_Informed_About_Ready_State_After_Registering() - { - var fixture = new Fixture(); - var eventHandler = Substitute.For(); + await Utils.AssertUntilAsync(_ => eventHandler + .Received() + .Invoke( + Arg.Is( + payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderStale + ))); + } - var myClient = Api.Instance.GetClient(fixture.Create(), fixture.Create()); + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.6", "Event handlers MUST persist across `provider` changes.")] + public async Task API_Level_Event_Handlers_Should_Be_Exchangeable() + { + var eventHandler = Substitute.For(); - var testProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name!, testProvider); + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, eventHandler); - // add the event handler after the provider has already transitioned into the ready state - myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); - eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); - } + await testProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); - [Fact] - [Specification("5.1.3", "When a `provider` signals the occurrence of a particular `event`, event handlers on clients which are not associated with that provider MUST NOT run.")] - [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] - [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] - [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] - [Specification("5.2.7", "The `API` and `client` MUST provide a function allowing the removal of event handlers.")] - public async Task Client_Level_Event_Handlers_Should_Be_Removable() - { - var fixture = new Fixture(); + var newTestProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(newTestProvider); - var eventHandler = Substitute.For(); + await newTestProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); - var myClient = Api.Instance.GetClient(fixture.Create(), fixture.Create()); + await Utils.AssertUntilAsync( + _ => eventHandler.Received(2).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderReady)) + ); + await Utils.AssertUntilAsync( + _ => eventHandler.Received(2).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)) + ); + } - myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + [Fact] + [Specification("5.2.2", "The `API` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.7", "The `API` and `client` MUST provide a function allowing the removal of event handlers.")] + public async Task API_Level_Event_Handlers_Should_Be_Removable() + { + var eventHandler = Substitute.For(); - var testProvider = new TestProvider(); - await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name!, testProvider); + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); - // wait for the first event to be received - await Utils.AssertUntilAsync( - _ => eventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) - ); + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(testProvider); - myClient.RemoveHandler(ProviderEventTypes.ProviderReady, eventHandler); + Thread.Sleep(1000); + Api.Instance.RemoveHandler(ProviderEventTypes.ProviderReady, eventHandler); - // send another event from the provider - this one should not be received - await testProvider.SendEventAsync(ProviderEventTypes.ProviderReady); + var newTestProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(newTestProvider); - // wait a bit and make sure we only have received the first event, but nothing after removing the event handler - await Utils.AssertUntilAsync( - _ => eventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) - ); - } + eventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + } - [Fact] - public void RegisterClientFeatureProvider_WhenCalledWithNullProvider_DoesNotThrowException() - { - // Arrange - var eventExecutor = new EventExecutor(); - string client = "testClient"; - FeatureProvider? provider = null; - - // Act - var exception = Record.Exception(() => eventExecutor.RegisterClientFeatureProvider(client, provider)); - - // Assert - Assert.Null(exception); - } - - [Theory] - [InlineData(ProviderEventTypes.ProviderError, ProviderStatus.Error)] - [InlineData(ProviderEventTypes.ProviderReady, ProviderStatus.Ready)] - [InlineData(ProviderEventTypes.ProviderStale, ProviderStatus.Stale)] - [Specification("5.3.5", "If the provider emits an event, the value of the client's provider status MUST be updated accordingly.")] - public async Task Provider_Events_Should_Update_ProviderStatus(ProviderEventTypes type, ProviderStatus status) - { - var provider = new TestProvider(); - await Api.Instance.SetProviderAsync("5.3.5", provider); - _ = provider.SendEventAsync(type); - await Utils.AssertUntilAsync(_ => Assert.True(provider.Status == status)); - } + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.5", "If a `handler function` terminates abnormally, other `handler` functions MUST run.")] + public async Task API_Level_Event_Handlers_Should_Be_Executed_When_Other_Handler_Fails() + { + var fixture = new Fixture(); + + var failingEventHandler = Substitute.For(); + var eventHandler = Substitute.For(); + + failingEventHandler.When(x => x.Invoke(Arg.Any())) + .Do(x => throw new Exception()); + + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, failingEventHandler); + Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var testProvider = new TestProvider(fixture.Create()); + await Api.Instance.SetProviderAsync(testProvider); + + await Utils.AssertUntilAsync( + _ => failingEventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) + ); + await Utils.AssertUntilAsync( + _ => eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) + ); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + public async Task Client_Level_Event_Handlers_Should_Be_Registered() + { + var fixture = new Fixture(); + var eventHandler = Substitute.For(); + + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var myClient = Api.Instance.GetClient(domain, clientVersion); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name!, testProvider); + + myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.5", "If a `handler function` terminates abnormally, other `handler` functions MUST run.")] + public async Task Client_Level_Event_Handlers_Should_Be_Executed_When_Other_Handler_Fails() + { + var fixture = new Fixture(); + + var failingEventHandler = Substitute.For(); + var eventHandler = Substitute.For(); + + failingEventHandler.When(x => x.Invoke(Arg.Any())) + .Do(x => throw new Exception()); + + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var myClient = Api.Instance.GetClient(domain, clientVersion); + + myClient.AddHandler(ProviderEventTypes.ProviderReady, failingEventHandler); + myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name!, testProvider); + + await Utils.AssertUntilAsync( + _ => failingEventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) + ); + await Utils.AssertUntilAsync( + _ => eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) + ); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.1.3", "When a `provider` signals the occurrence of a particular `event`, event handlers on clients which are not associated with that provider MUST NOT run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + public async Task Client_Level_Event_Handlers_Should_Be_Registered_To_Default_Provider() + { + var fixture = new Fixture(); + var eventHandler = Substitute.For(); + var clientEventHandler = Substitute.For(); + + var myClientWithNoBoundProvider = Api.Instance.GetClient(fixture.Create(), fixture.Create()); + var myClientWithBoundProvider = Api.Instance.GetClient(fixture.Create(), fixture.Create()); + + var apiProvider = new TestProvider(fixture.Create()); + var clientProvider = new TestProvider(fixture.Create()); + + // set the default provider on API level, but not specifically to the client + await Api.Instance.SetProviderAsync(apiProvider); + // set the other provider specifically for the client + await Api.Instance.SetProviderAsync(myClientWithBoundProvider.GetMetadata().Name!, clientProvider); + + myClientWithNoBoundProvider.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + myClientWithBoundProvider.AddHandler(ProviderEventTypes.ProviderReady, clientEventHandler); + + eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == apiProvider.GetMetadata().Name)); + eventHandler.DidNotReceive().Invoke(Arg.Is(payload => payload.ProviderName == clientProvider.GetMetadata().Name)); + + clientEventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == clientProvider.GetMetadata().Name)); + clientEventHandler.DidNotReceive().Invoke(Arg.Is(payload => payload.ProviderName == apiProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.1.3", "When a `provider` signals the occurrence of a particular `event`, event handlers on clients which are not associated with that provider MUST NOT run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.6", "Event handlers MUST persist across `provider` changes.")] + public async Task Client_Level_Event_Handlers_Should_Be_Receive_Events_From_Named_Provider_Instead_of_Default() + { + var fixture = new Fixture(); + var clientEventHandler = Substitute.For(); + + var client = Api.Instance.GetClient(fixture.Create(), fixture.Create()); + + var defaultProvider = new TestProvider(fixture.Create()); + var clientProvider = new TestProvider(fixture.Create()); + + // set the default provider + await Api.Instance.SetProviderAsync(defaultProvider); + + client.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, clientEventHandler); + + await defaultProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); + + // verify that the client received the event from the default provider as there is no named provider registered yet + await Utils.AssertUntilAsync( + _ => clientEventHandler.Received(1) + .Invoke(Arg.Is(payload => payload.ProviderName == defaultProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)) + ); + + // set the other provider specifically for the client + await Api.Instance.SetProviderAsync(client.GetMetadata().Name!, clientProvider); + + // now, send another event for the default handler + await defaultProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); + await clientProvider.SendEventAsync(ProviderEventTypes.ProviderConfigurationChanged); + + // now the client should have received only the event from the named provider + await Utils.AssertUntilAsync( + _ => clientEventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == clientProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)) + ); + // for the default provider, the number of received events should stay unchanged + await Utils.AssertUntilAsync( + _ => clientEventHandler.Received(1) + .Invoke(Arg.Is(payload => payload.ProviderName == defaultProvider.GetMetadata().Name && payload.Type == ProviderEventTypes.ProviderConfigurationChanged)) + ); + } + + [Fact] + [Specification("5.1.2", "When a `provider` signals the occurrence of a particular `event`, the associated `client` and `API` event handlers MUST run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.3.1", "If the provider's `initialize` function terminates normally, `PROVIDER_READY` handlers MUST run.")] + [Specification("5.3.3", "Handlers attached after the provider is already in the associated state, MUST run immediately.")] + public async Task Client_Level_Event_Handlers_Should_Be_Informed_About_Ready_State_After_Registering() + { + var fixture = new Fixture(); + var eventHandler = Substitute.For(); + + var myClient = Api.Instance.GetClient(fixture.Create(), fixture.Create()); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name!, testProvider); + + // add the event handler after the provider has already transitioned into the ready state + myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + eventHandler.Received().Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)); + } + + [Fact] + [Specification("5.1.3", "When a `provider` signals the occurrence of a particular `event`, event handlers on clients which are not associated with that provider MUST NOT run.")] + [Specification("5.2.1", "The `client` MUST provide a function for associating `handler functions` with a particular `provider event type`.")] + [Specification("5.2.3", "The `event details` MUST contain the `provider name` associated with the event.")] + [Specification("5.2.4", "The `handler function` MUST accept a `event details` parameter.")] + [Specification("5.2.7", "The `API` and `client` MUST provide a function allowing the removal of event handlers.")] + public async Task Client_Level_Event_Handlers_Should_Be_Removable() + { + var fixture = new Fixture(); + + var eventHandler = Substitute.For(); + + var myClient = Api.Instance.GetClient(fixture.Create(), fixture.Create()); + + myClient.AddHandler(ProviderEventTypes.ProviderReady, eventHandler); + + var testProvider = new TestProvider(); + await Api.Instance.SetProviderAsync(myClient.GetMetadata().Name!, testProvider); + + // wait for the first event to be received + await Utils.AssertUntilAsync( + _ => eventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) + ); + + myClient.RemoveHandler(ProviderEventTypes.ProviderReady, eventHandler); + + // send another event from the provider - this one should not be received + await testProvider.SendEventAsync(ProviderEventTypes.ProviderReady); + + // wait a bit and make sure we only have received the first event, but nothing after removing the event handler + await Utils.AssertUntilAsync( + _ => eventHandler.Received(1).Invoke(Arg.Is(payload => payload.ProviderName == testProvider.GetMetadata().Name)) + ); + } + + [Fact] + public void RegisterClientFeatureProvider_WhenCalledWithNullProvider_DoesNotThrowException() + { + // Arrange + var eventExecutor = new EventExecutor(); + string client = "testClient"; + FeatureProvider? provider = null; + + // Act + var exception = Record.Exception(() => eventExecutor.RegisterClientFeatureProvider(client, provider)); + + // Assert + Assert.Null(exception); + } + + [Theory] + [InlineData(ProviderEventTypes.ProviderError, ProviderStatus.Error)] + [InlineData(ProviderEventTypes.ProviderReady, ProviderStatus.Ready)] + [InlineData(ProviderEventTypes.ProviderStale, ProviderStatus.Stale)] + [Specification("5.3.5", "If the provider emits an event, the value of the client's provider status MUST be updated accordingly.")] + public async Task Provider_Events_Should_Update_ProviderStatus(ProviderEventTypes type, ProviderStatus status) + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync("5.3.5", provider); + _ = provider.SendEventAsync(type); + await Utils.AssertUntilAsync(_ => Assert.True(provider.Status == status)); } } diff --git a/test/OpenFeature.Tests/OpenFeatureHookTests.cs b/test/OpenFeature.Tests/OpenFeatureHookTests.cs index ae53f6db4..d2e9b5e97 100644 --- a/test/OpenFeature.Tests/OpenFeatureHookTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureHookTests.cs @@ -14,738 +14,737 @@ using OpenFeature.Tests.Internal; using Xunit; -namespace OpenFeature.Tests +namespace OpenFeature.Tests; + +[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] +public class OpenFeatureHookTests : ClearOpenFeatureInstanceFixture { - [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] - public class OpenFeatureHookTests : ClearOpenFeatureInstanceFixture + [Fact] + [Specification("1.5.1", "The `evaluation options` structure's `hooks` field denotes an ordered collection of hooks that the client MUST execute for the respective flag evaluation, in addition to those already configured.")] + [Specification("2.3.1", "The provider interface MUST define a `provider hook` mechanism which can be optionally implemented in order to add `hook` instances to the evaluation life-cycle.")] + [Specification("4.4.2", "Hooks MUST be evaluated in the following order: - before: API, Client, Invocation, Provider - after: Provider, Invocation, Client, API - error (if applicable): Provider, Invocation, Client, API - finally: Provider, Invocation, Client, API")] + public async Task Hooks_Should_Be_Called_In_Order() { - [Fact] - [Specification("1.5.1", "The `evaluation options` structure's `hooks` field denotes an ordered collection of hooks that the client MUST execute for the respective flag evaluation, in addition to those already configured.")] - [Specification("2.3.1", "The provider interface MUST define a `provider hook` mechanism which can be optionally implemented in order to add `hook` instances to the evaluation life-cycle.")] - [Specification("4.4.2", "Hooks MUST be evaluated in the following order: - before: API, Client, Invocation, Provider - after: Provider, Invocation, Client, API - error (if applicable): Provider, Invocation, Client, API - finally: Provider, Invocation, Client, API")] - public async Task Hooks_Should_Be_Called_In_Order() + var fixture = new Fixture(); + var domain = fixture.Create(); + var clientVersion = fixture.Create(); + var flagName = fixture.Create(); + var defaultValue = fixture.Create(); + var apiHook = Substitute.For(); + var clientHook = Substitute.For(); + var invocationHook = Substitute.For(); + var providerHook = Substitute.For(); + + // Sequence + apiHook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); + clientHook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); + invocationHook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); + providerHook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); + providerHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + invocationHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + clientHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + apiHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + providerHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + invocationHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + clientHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + apiHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); + + var testProvider = new TestProvider(); + testProvider.AddHook(providerHook); + Api.Instance.AddHooks(apiHook); + await Api.Instance.SetProviderAsync(testProvider); + var client = Api.Instance.GetClient(domain, clientVersion); + client.AddHooks(clientHook); + + await client.GetBooleanValueAsync(flagName, defaultValue, EvaluationContext.Empty, + new FlagEvaluationOptions(invocationHook, ImmutableDictionary.Empty)); + + Received.InOrder(() => { - var fixture = new Fixture(); - var domain = fixture.Create(); - var clientVersion = fixture.Create(); - var flagName = fixture.Create(); - var defaultValue = fixture.Create(); - var apiHook = Substitute.For(); - var clientHook = Substitute.For(); - var invocationHook = Substitute.For(); - var providerHook = Substitute.For(); - - // Sequence - apiHook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); - clientHook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); - invocationHook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); - providerHook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); - providerHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); - invocationHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); - clientHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); - apiHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); - providerHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); - invocationHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); - clientHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); - apiHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()).Returns(new ValueTask()); - - var testProvider = new TestProvider(); - testProvider.AddHook(providerHook); - Api.Instance.AddHooks(apiHook); - await Api.Instance.SetProviderAsync(testProvider); - var client = Api.Instance.GetClient(domain, clientVersion); - client.AddHooks(clientHook); - - await client.GetBooleanValueAsync(flagName, defaultValue, EvaluationContext.Empty, - new FlagEvaluationOptions(invocationHook, ImmutableDictionary.Empty)); - - Received.InOrder(() => - { - apiHook.BeforeAsync(Arg.Any>(), Arg.Any>()); - clientHook.BeforeAsync(Arg.Any>(), Arg.Any>()); - invocationHook.BeforeAsync(Arg.Any>(), Arg.Any>()); - providerHook.BeforeAsync(Arg.Any>(), Arg.Any>()); - providerHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - invocationHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - clientHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - apiHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - providerHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - invocationHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - clientHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - apiHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - }); + apiHook.BeforeAsync(Arg.Any>(), Arg.Any>()); + clientHook.BeforeAsync(Arg.Any>(), Arg.Any>()); + invocationHook.BeforeAsync(Arg.Any>(), Arg.Any>()); + providerHook.BeforeAsync(Arg.Any>(), Arg.Any>()); + providerHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + invocationHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + clientHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + apiHook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + providerHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + invocationHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + clientHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + apiHook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + }); + + _ = apiHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = clientHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = invocationHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = providerHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = providerHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = invocationHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = clientHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = apiHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = providerHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = invocationHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = clientHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = apiHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + } - _ = apiHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); - _ = clientHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); - _ = invocationHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); - _ = providerHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); - _ = providerHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = invocationHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = clientHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = apiHook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = providerHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = invocationHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = clientHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = apiHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - } - - [Fact] - [Specification("4.1.1", "Hook context MUST provide: the `flag key`, `flag value type`, `evaluation context`, `default value`, and `hook data`.")] - public void Hook_Context_Should_Not_Allow_Nulls() - { - Assert.Throws(() => - new HookContext(null, Structure.Empty, FlagValueType.Object, new ClientMetadata(null, null), - new Metadata(null), EvaluationContext.Empty)); - - Assert.Throws(() => - new HookContext("test", Structure.Empty, FlagValueType.Object, null, - new Metadata(null), EvaluationContext.Empty)); - - Assert.Throws(() => - new HookContext("test", Structure.Empty, FlagValueType.Object, new ClientMetadata(null, null), - null, EvaluationContext.Empty)); - - Assert.Throws(() => - new HookContext("test", Structure.Empty, FlagValueType.Object, new ClientMetadata(null, null), - new Metadata(null), null)); - - Assert.Throws(() => new SharedHookContext("test", Structure.Empty, FlagValueType.Object, - new ClientMetadata(null, null), new Metadata(null)).ToHookContext(null)); - - Assert.Throws(() => - new HookContext(null, EvaluationContext.Empty, - new HookData())); - - Assert.Throws(() => - new HookContext( - new SharedHookContext("test", Structure.Empty, FlagValueType.Object, - new ClientMetadata(null, null), new Metadata(null)), EvaluationContext.Empty, - null)); - } - - [Fact] - [Specification("4.1.2", "The `hook context` SHOULD provide: access to the `client metadata` and the `provider metadata` fields.")] - [Specification("4.1.3", "The `flag key`, `flag type`, and `default value` properties MUST be immutable. If the language does not support immutability, the hook MUST NOT modify these properties.")] - public void Hook_Context_Should_Have_Properties_And_Be_Immutable() - { - var clientMetadata = new ClientMetadata("client", "1.0.0"); - var providerMetadata = new Metadata("provider"); - var testStructure = Structure.Empty; - var context = new HookContext("test", testStructure, FlagValueType.Object, clientMetadata, - providerMetadata, EvaluationContext.Empty); - - Assert.Equal(clientMetadata, context.ClientMetadata); - Assert.Equal(providerMetadata, context.ProviderMetadata); - Assert.Equal("test", context.FlagKey); - Assert.Equal(testStructure, context.DefaultValue); - Assert.Equal(FlagValueType.Object, context.FlagValueType); - } - - [Fact] - [Specification("4.1.4", "The evaluation context MUST be mutable only within the `before` hook.")] - [Specification("4.3.3", "Any `evaluation context` returned from a `before` hook MUST be passed to subsequent `before` hooks (via `HookContext`).")] - public async Task Evaluation_Context_Must_Be_Mutable_Before_Hook() - { - var evaluationContext = new EvaluationContextBuilder().Set("test", "test").Build(); - var hook1 = Substitute.For(); - var hook2 = Substitute.For(); - var hookContext = new HookContext("test", false, - FlagValueType.Boolean, new ClientMetadata("test", "1.0.0"), new Metadata(NoOpProvider.NoOpProviderName), - evaluationContext); - - hook1.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(evaluationContext); - hook2.BeforeAsync(hookContext, Arg.Any>()).Returns(evaluationContext); - - var client = Api.Instance.GetClient("test", "1.0.0"); - await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, - new FlagEvaluationOptions(ImmutableList.Create(hook1, hook2), ImmutableDictionary.Empty)); - - _ = hook1.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); - _ = hook2.Received(1).BeforeAsync(Arg.Is>(a => a.EvaluationContext.GetValue("test").AsString == "test"), Arg.Any>()); - } - - [Fact] - [Specification("4.1.5", "The `hook data` MUST be mutable.")] - public async Task HookData_Must_Be_Mutable() - { - var hook = Substitute.For(); - - hook.BeforeAsync(Arg.Any>(), Arg.Any>()) - .Returns(EvaluationContext.Empty).AndDoes(info => - { - info.Arg>().Data.Set("test-a", true); - }); - hook.AfterAsync(Arg.Any>(), Arg.Any>(), - Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + [Fact] + [Specification("4.1.1", "Hook context MUST provide: the `flag key`, `flag value type`, `evaluation context`, `default value`, and `hook data`.")] + public void Hook_Context_Should_Not_Allow_Nulls() + { + Assert.Throws(() => + new HookContext(null, Structure.Empty, FlagValueType.Object, new ClientMetadata(null, null), + new Metadata(null), EvaluationContext.Empty)); + + Assert.Throws(() => + new HookContext("test", Structure.Empty, FlagValueType.Object, null, + new Metadata(null), EvaluationContext.Empty)); + + Assert.Throws(() => + new HookContext("test", Structure.Empty, FlagValueType.Object, new ClientMetadata(null, null), + null, EvaluationContext.Empty)); + + Assert.Throws(() => + new HookContext("test", Structure.Empty, FlagValueType.Object, new ClientMetadata(null, null), + new Metadata(null), null)); + + Assert.Throws(() => new SharedHookContext("test", Structure.Empty, FlagValueType.Object, + new ClientMetadata(null, null), new Metadata(null)).ToHookContext(null)); + + Assert.Throws(() => + new HookContext(null, EvaluationContext.Empty, + new HookData())); + + Assert.Throws(() => + new HookContext( + new SharedHookContext("test", Structure.Empty, FlagValueType.Object, + new ClientMetadata(null, null), new Metadata(null)), EvaluationContext.Empty, + null)); + } + + [Fact] + [Specification("4.1.2", "The `hook context` SHOULD provide: access to the `client metadata` and the `provider metadata` fields.")] + [Specification("4.1.3", "The `flag key`, `flag type`, and `default value` properties MUST be immutable. If the language does not support immutability, the hook MUST NOT modify these properties.")] + public void Hook_Context_Should_Have_Properties_And_Be_Immutable() + { + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var testStructure = Structure.Empty; + var context = new HookContext("test", testStructure, FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + + Assert.Equal(clientMetadata, context.ClientMetadata); + Assert.Equal(providerMetadata, context.ProviderMetadata); + Assert.Equal("test", context.FlagKey); + Assert.Equal(testStructure, context.DefaultValue); + Assert.Equal(FlagValueType.Object, context.FlagValueType); + } + + [Fact] + [Specification("4.1.4", "The evaluation context MUST be mutable only within the `before` hook.")] + [Specification("4.3.3", "Any `evaluation context` returned from a `before` hook MUST be passed to subsequent `before` hooks (via `HookContext`).")] + public async Task Evaluation_Context_Must_Be_Mutable_Before_Hook() + { + var evaluationContext = new EvaluationContextBuilder().Set("test", "test").Build(); + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + var hookContext = new HookContext("test", false, + FlagValueType.Boolean, new ClientMetadata("test", "1.0.0"), new Metadata(NoOpProvider.NoOpProviderName), + evaluationContext); + + hook1.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(evaluationContext); + hook2.BeforeAsync(hookContext, Arg.Any>()).Returns(evaluationContext); + + var client = Api.Instance.GetClient("test", "1.0.0"); + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, + new FlagEvaluationOptions(ImmutableList.Create(hook1, hook2), ImmutableDictionary.Empty)); + + _ = hook1.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = hook2.Received(1).BeforeAsync(Arg.Is>(a => a.EvaluationContext.GetValue("test").AsString == "test"), Arg.Any>()); + } + + [Fact] + [Specification("4.1.5", "The `hook data` MUST be mutable.")] + public async Task HookData_Must_Be_Mutable() + { + var hook = Substitute.For(); + + hook.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty).AndDoes(info => { - info.Arg>().Data.Set("test-b", "test-value"); + info.Arg>().Data.Set("test-a", true); }); + hook.AfterAsync(Arg.Any>(), Arg.Any>(), + Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + { + info.Arg>().Data.Set("test-b", "test-value"); + }); - await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); - var client = Api.Instance.GetClient("test", "1.0.0"); + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var client = Api.Instance.GetClient("test", "1.0.0"); - await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, - new FlagEvaluationOptions(ImmutableList.Create(hook), ImmutableDictionary.Empty)); + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, + new FlagEvaluationOptions(ImmutableList.Create(hook), ImmutableDictionary.Empty)); - _ = hook.Received(1).AfterAsync(Arg.Is>(hookContext => - (bool)hookContext.Data.Get("test-a") == true - ), Arg.Any>(), Arg.Any>()); - _ = hook.Received(1).FinallyAsync(Arg.Is>(hookContext => - (bool)hookContext.Data.Get("test-a") == true && (string)hookContext.Data.Get("test-b") == "test-value" - ), Arg.Any>(), Arg.Any>()); - } + _ = hook.Received(1).AfterAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("test-a") == true + ), Arg.Any>(), Arg.Any>()); + _ = hook.Received(1).FinallyAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("test-a") == true && (string)hookContext.Data.Get("test-b") == "test-value" + ), Arg.Any>(), Arg.Any>()); + } - [Fact] - [Specification("4.3.2", - "`Hook data` **MUST** must be created before the first `stage` invoked in a hook for a specific evaluation and propagated between each `stage` of the hook. The hook data is not shared between different hooks.")] - public async Task HookData_Must_Be_Unique_Per_Hook() - { - var hook1 = Substitute.For(); - var hook2 = Substitute.For(); - - hook1.BeforeAsync(Arg.Any>(), Arg.Any>()) - .Returns(EvaluationContext.Empty).AndDoes(info => - { - info.Arg>().Data.Set("hook-1-value-a", true); - info.Arg>().Data.Set("same", true); - }); - hook1.AfterAsync(Arg.Any>(), Arg.Any>(), - Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + [Fact] + [Specification("4.3.2", + "`Hook data` **MUST** must be created before the first `stage` invoked in a hook for a specific evaluation and propagated between each `stage` of the hook. The hook data is not shared between different hooks.")] + public async Task HookData_Must_Be_Unique_Per_Hook() + { + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + + hook1.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty).AndDoes(info => { - info.Arg>().Data.Set("hook-1-value-b", "test-value-hook-1"); + info.Arg>().Data.Set("hook-1-value-a", true); + info.Arg>().Data.Set("same", true); }); + hook1.AfterAsync(Arg.Any>(), Arg.Any>(), + Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + { + info.Arg>().Data.Set("hook-1-value-b", "test-value-hook-1"); + }); - hook2.BeforeAsync(Arg.Any>(), Arg.Any>()) - .Returns(EvaluationContext.Empty).AndDoes(info => - { - info.Arg>().Data.Set("hook-2-value-a", false); - info.Arg>().Data.Set("same", false); - }); - hook2.AfterAsync(Arg.Any>(), Arg.Any>(), - Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + hook2.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty).AndDoes(info => { - info.Arg>().Data.Set("hook-2-value-b", "test-value-hook-2"); + info.Arg>().Data.Set("hook-2-value-a", false); + info.Arg>().Data.Set("same", false); }); - - await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); - var client = Api.Instance.GetClient("test", "1.0.0"); - - await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, - new FlagEvaluationOptions(ImmutableList.Create(hook1, hook2), - ImmutableDictionary.Empty)); - - _ = hook1.Received(1).AfterAsync(Arg.Is>(hookContext => - (bool)hookContext.Data.Get("hook-1-value-a") == true && (bool)hookContext.Data.Get("same") == true - ), Arg.Any>(), Arg.Any>()); - _ = hook1.Received(1).FinallyAsync(Arg.Is>(hookContext => - (bool)hookContext.Data.Get("hook-1-value-a") == true && - (bool)hookContext.Data.Get("same") == true && - (string)hookContext.Data.Get("hook-1-value-b") == "test-value-hook-1" && hookContext.Data.Count == 3 - ), Arg.Any>(), Arg.Any>()); - - _ = hook2.Received(1).AfterAsync(Arg.Is>(hookContext => - (bool)hookContext.Data.Get("hook-2-value-a") == false && (bool)hookContext.Data.Get("same") == false - ), Arg.Any>(), Arg.Any>()); - _ = hook2.Received(1).FinallyAsync(Arg.Is>(hookContext => - (bool)hookContext.Data.Get("hook-2-value-a") == false && - (bool)hookContext.Data.Get("same") == false && - (string)hookContext.Data.Get("hook-2-value-b") == "test-value-hook-2" && hookContext.Data.Count == 3 - ), Arg.Any>(), Arg.Any>()); - } - - [Fact] - [Specification("3.2.2", "Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.")] - [Specification("4.3.4", "When `before` hooks have finished executing, any resulting `evaluation context` MUST be merged with the existing `evaluation context`.")] - public async Task Evaluation_Context_Must_Be_Merged_In_Correct_Order() + hook2.AfterAsync(Arg.Any>(), Arg.Any>(), + Arg.Any>()).Returns(new ValueTask()).AndDoes(info => { - var propGlobal = "4.3.4global"; - var propGlobalToOverwrite = "4.3.4globalToOverwrite"; + info.Arg>().Data.Set("hook-2-value-b", "test-value-hook-2"); + }); + + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var client = Api.Instance.GetClient("test", "1.0.0"); + + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, + new FlagEvaluationOptions(ImmutableList.Create(hook1, hook2), + ImmutableDictionary.Empty)); + + _ = hook1.Received(1).AfterAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-1-value-a") == true && (bool)hookContext.Data.Get("same") == true + ), Arg.Any>(), Arg.Any>()); + _ = hook1.Received(1).FinallyAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-1-value-a") == true && + (bool)hookContext.Data.Get("same") == true && + (string)hookContext.Data.Get("hook-1-value-b") == "test-value-hook-1" && hookContext.Data.Count == 3 + ), Arg.Any>(), Arg.Any>()); + + _ = hook2.Received(1).AfterAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-2-value-a") == false && (bool)hookContext.Data.Get("same") == false + ), Arg.Any>(), Arg.Any>()); + _ = hook2.Received(1).FinallyAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-2-value-a") == false && + (bool)hookContext.Data.Get("same") == false && + (string)hookContext.Data.Get("hook-2-value-b") == "test-value-hook-2" && hookContext.Data.Count == 3 + ), Arg.Any>(), Arg.Any>()); + } - var propClient = "4.3.4client"; - var propClientToOverwrite = "4.3.4clientToOverwrite"; + [Fact] + [Specification("3.2.2", "Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.")] + [Specification("4.3.4", "When `before` hooks have finished executing, any resulting `evaluation context` MUST be merged with the existing `evaluation context`.")] + public async Task Evaluation_Context_Must_Be_Merged_In_Correct_Order() + { + var propGlobal = "4.3.4global"; + var propGlobalToOverwrite = "4.3.4globalToOverwrite"; - var propInvocation = "4.3.4invocation"; - var propInvocationToOverwrite = "4.3.4invocationToOverwrite"; + var propClient = "4.3.4client"; + var propClientToOverwrite = "4.3.4clientToOverwrite"; - var propTransaction = "4.3.4transaction"; - var propTransactionToOverwrite = "4.3.4transactionToOverwrite"; + var propInvocation = "4.3.4invocation"; + var propInvocationToOverwrite = "4.3.4invocationToOverwrite"; - var propHook = "4.3.4hook"; + var propTransaction = "4.3.4transaction"; + var propTransactionToOverwrite = "4.3.4transactionToOverwrite"; - // setup a cascade of overwriting properties - Api.Instance.SetContext(new EvaluationContextBuilder() - .Set(propGlobal, true) - .Set(propGlobalToOverwrite, false) - .Build()); + var propHook = "4.3.4hook"; - var clientContext = new EvaluationContextBuilder() - .Set(propClient, true) - .Set(propGlobalToOverwrite, true) - .Set(propClientToOverwrite, false) - .Build(); + // setup a cascade of overwriting properties + Api.Instance.SetContext(new EvaluationContextBuilder() + .Set(propGlobal, true) + .Set(propGlobalToOverwrite, false) + .Build()); - var transactionContext = new EvaluationContextBuilder() - .Set(propTransaction, true) - .Set(propInvocationToOverwrite, true) - .Set(propTransactionToOverwrite, false) - .Build(); + var clientContext = new EvaluationContextBuilder() + .Set(propClient, true) + .Set(propGlobalToOverwrite, true) + .Set(propClientToOverwrite, false) + .Build(); - var invocationContext = new EvaluationContextBuilder() - .Set(propInvocation, true) - .Set(propClientToOverwrite, true) - .Set(propTransactionToOverwrite, true) - .Set(propInvocationToOverwrite, false) - .Build(); + var transactionContext = new EvaluationContextBuilder() + .Set(propTransaction, true) + .Set(propInvocationToOverwrite, true) + .Set(propTransactionToOverwrite, false) + .Build(); + var invocationContext = new EvaluationContextBuilder() + .Set(propInvocation, true) + .Set(propClientToOverwrite, true) + .Set(propTransactionToOverwrite, true) + .Set(propInvocationToOverwrite, false) + .Build(); - var hookContext = new EvaluationContextBuilder() - .Set(propHook, true) - .Set(propInvocationToOverwrite, true) - .Build(); - var transactionContextPropagator = new AsyncLocalTransactionContextPropagator(); - transactionContextPropagator.SetTransactionContext(transactionContext); - Api.Instance.SetTransactionContextPropagator(transactionContextPropagator); + var hookContext = new EvaluationContextBuilder() + .Set(propHook, true) + .Set(propInvocationToOverwrite, true) + .Build(); - var provider = Substitute.For(); + var transactionContextPropagator = new AsyncLocalTransactionContextPropagator(); + transactionContextPropagator.SetTransactionContext(transactionContext); + Api.Instance.SetTransactionContextPropagator(transactionContextPropagator); - provider.GetMetadata().Returns(new Metadata(null)); + var provider = Substitute.For(); - provider.GetProviderHooks().Returns(ImmutableList.Empty); + provider.GetMetadata().Returns(new Metadata(null)); - provider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new ResolutionDetails("test", true)); + provider.GetProviderHooks().Returns(ImmutableList.Empty); - await Api.Instance.SetProviderAsync(provider); + provider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new ResolutionDetails("test", true)); - var hook = Substitute.For(); - hook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(hookContext); + await Api.Instance.SetProviderAsync(provider); + var hook = Substitute.For(); + hook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(hookContext); - var client = Api.Instance.GetClient("test", "1.0.0", null, clientContext); - await client.GetBooleanValueAsync("test", false, invocationContext, new FlagEvaluationOptions(ImmutableList.Create(hook), ImmutableDictionary.Empty)); - // after proper merging, all properties should equal true - _ = provider.Received(1).ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Is(y => - (y.GetValue(propGlobal).AsBoolean ?? false) - && (y.GetValue(propClient).AsBoolean ?? false) - && (y.GetValue(propTransaction).AsBoolean ?? false) - && (y.GetValue(propGlobalToOverwrite).AsBoolean ?? false) - && (y.GetValue(propTransactionToOverwrite).AsBoolean ?? false) - && (y.GetValue(propInvocation).AsBoolean ?? false) - && (y.GetValue(propClientToOverwrite).AsBoolean ?? false) - && (y.GetValue(propHook).AsBoolean ?? false) - && (y.GetValue(propInvocationToOverwrite).AsBoolean ?? false) - )); - } + var client = Api.Instance.GetClient("test", "1.0.0", null, clientContext); + await client.GetBooleanValueAsync("test", false, invocationContext, new FlagEvaluationOptions(ImmutableList.Create(hook), ImmutableDictionary.Empty)); - [Fact] - [Specification("4.2.1", "`hook hints` MUST be a structure supports definition of arbitrary properties, with keys of type `string`, and values of type `boolean | string | number | datetime | structure`..")] - [Specification("4.2.2.1", "Condition: `Hook hints` MUST be immutable.")] - [Specification("4.2.2.2", "Condition: The client `metadata` field in the `hook context` MUST be immutable.")] - [Specification("4.2.2.3", "Condition: The provider `metadata` field in the `hook context` MUST be immutable.")] - [Specification("4.3.1", "Hooks MUST specify at least one stage.")] - public async Task Hook_Should_Return_No_Errors() - { - var hook = new TestHookNoOverride(); - var hookHints = new Dictionary - { - ["string"] = "test", - ["number"] = 1, - ["boolean"] = true, - ["datetime"] = DateTime.Now, - ["structure"] = Structure.Empty - }; - var hookContext = new HookContext("test", false, FlagValueType.Boolean, - new ClientMetadata(null, null), new Metadata(null), EvaluationContext.Empty); - var evaluationDetails = - new FlagEvaluationDetails("test", false, ErrorType.None, "testing", "testing"); - - await hook.BeforeAsync(hookContext, hookHints); - await hook.AfterAsync(hookContext, evaluationDetails, hookHints); - await hook.FinallyAsync(hookContext, evaluationDetails, hookHints); - await hook.ErrorAsync(hookContext, new Exception(), hookHints); - - Assert.Null(hookContext.ClientMetadata.Name); - Assert.Null(hookContext.ClientMetadata.Version); - Assert.Null(hookContext.ProviderMetadata.Name); - } - - [Fact] - [Specification("4.3.5", "The `after` stage MUST run after flag resolution occurs. It accepts a `hook context` (required), `flag evaluation details` (required) and `hook hints` (optional). It has no return value.")] - [Specification("4.3.6", "The `error` hook MUST run when errors are encountered in the `before` stage, the `after` stage or during flag resolution. It accepts `hook context` (required), `exception` representing what went wrong (required), and `hook hints` (optional). It has no return value.")] - [Specification("4.3.7", "The `finally` hook MUST run after the `before`, `after`, and `error` stages. It accepts a `hook context` (required) and `hook hints` (optional). There is no return value.")] - [Specification("4.5.1", "`Flag evaluation options` MAY contain `hook hints`, a map of data to be provided to hook invocations.")] - [Specification("4.5.2", "`hook hints` MUST be passed to each hook.")] - [Specification("4.5.3", "The hook MUST NOT alter the `hook hints` structure.")] - public async Task Hook_Should_Execute_In_Correct_Order() - { - var featureProvider = Substitute.For(); - var hook = Substitute.For(); + // after proper merging, all properties should equal true + _ = provider.Received(1).ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Is(y => + (y.GetValue(propGlobal).AsBoolean ?? false) + && (y.GetValue(propClient).AsBoolean ?? false) + && (y.GetValue(propTransaction).AsBoolean ?? false) + && (y.GetValue(propGlobalToOverwrite).AsBoolean ?? false) + && (y.GetValue(propTransactionToOverwrite).AsBoolean ?? false) + && (y.GetValue(propInvocation).AsBoolean ?? false) + && (y.GetValue(propClientToOverwrite).AsBoolean ?? false) + && (y.GetValue(propHook).AsBoolean ?? false) + && (y.GetValue(propInvocationToOverwrite).AsBoolean ?? false) + )); + } - featureProvider.GetMetadata().Returns(new Metadata(null)); - featureProvider.GetProviderHooks().Returns(ImmutableList.Empty); + [Fact] + [Specification("4.2.1", "`hook hints` MUST be a structure supports definition of arbitrary properties, with keys of type `string`, and values of type `boolean | string | number | datetime | structure`..")] + [Specification("4.2.2.1", "Condition: `Hook hints` MUST be immutable.")] + [Specification("4.2.2.2", "Condition: The client `metadata` field in the `hook context` MUST be immutable.")] + [Specification("4.2.2.3", "Condition: The provider `metadata` field in the `hook context` MUST be immutable.")] + [Specification("4.3.1", "Hooks MUST specify at least one stage.")] + public async Task Hook_Should_Return_No_Errors() + { + var hook = new TestHookNoOverride(); + var hookHints = new Dictionary + { + ["string"] = "test", + ["number"] = 1, + ["boolean"] = true, + ["datetime"] = DateTime.Now, + ["structure"] = Structure.Empty + }; + var hookContext = new HookContext("test", false, FlagValueType.Boolean, + new ClientMetadata(null, null), new Metadata(null), EvaluationContext.Empty); + var evaluationDetails = + new FlagEvaluationDetails("test", false, ErrorType.None, "testing", "testing"); + + await hook.BeforeAsync(hookContext, hookHints); + await hook.AfterAsync(hookContext, evaluationDetails, hookHints); + await hook.FinallyAsync(hookContext, evaluationDetails, hookHints); + await hook.ErrorAsync(hookContext, new Exception(), hookHints); + + Assert.Null(hookContext.ClientMetadata.Name); + Assert.Null(hookContext.ClientMetadata.Version); + Assert.Null(hookContext.ProviderMetadata.Name); + } - // Sequence - hook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); - featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new ResolutionDetails("test", false)); - _ = hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + [Fact] + [Specification("4.3.5", "The `after` stage MUST run after flag resolution occurs. It accepts a `hook context` (required), `flag evaluation details` (required) and `hook hints` (optional). It has no return value.")] + [Specification("4.3.6", "The `error` hook MUST run when errors are encountered in the `before` stage, the `after` stage or during flag resolution. It accepts `hook context` (required), `exception` representing what went wrong (required), and `hook hints` (optional). It has no return value.")] + [Specification("4.3.7", "The `finally` hook MUST run after the `before`, `after`, and `error` stages. It accepts a `hook context` (required) and `hook hints` (optional). There is no return value.")] + [Specification("4.5.1", "`Flag evaluation options` MAY contain `hook hints`, a map of data to be provided to hook invocations.")] + [Specification("4.5.2", "`hook hints` MUST be passed to each hook.")] + [Specification("4.5.3", "The hook MUST NOT alter the `hook hints` structure.")] + public async Task Hook_Should_Execute_In_Correct_Order() + { + var featureProvider = Substitute.For(); + var hook = Substitute.For(); - await Api.Instance.SetProviderAsync(featureProvider); - var client = Api.Instance.GetClient(); - client.AddHooks(hook); + featureProvider.GetMetadata().Returns(new Metadata(null)); + featureProvider.GetProviderHooks().Returns(ImmutableList.Empty); - await client.GetBooleanValueAsync("test", false); + // Sequence + hook.BeforeAsync(Arg.Any>(), Arg.Any>()).Returns(EvaluationContext.Empty); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new ResolutionDetails("test", false)); + _ = hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - Received.InOrder(() => - { - hook.BeforeAsync(Arg.Any>(), Arg.Any>()); - featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); - hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - }); + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); + client.AddHooks(hook); - _ = hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); - _ = hook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - _ = featureProvider.Received(1).ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } + await client.GetBooleanValueAsync("test", false); - [Fact] - [Specification("4.4.1", "The API, Client, Provider, and invocation MUST have a method for registering hooks.")] - public async Task Register_Hooks_Should_Be_Available_At_All_Levels() + Received.InOrder(() => { - var hook1 = Substitute.For(); - var hook2 = Substitute.For(); - var hook3 = Substitute.For(); - var hook4 = Substitute.For(); - - var testProvider = new TestProvider(); - testProvider.AddHook(hook4); - Api.Instance.AddHooks(hook1); - await Api.Instance.SetProviderAsync(testProvider); - var client = Api.Instance.GetClient(); - client.AddHooks(hook2); - await client.GetBooleanValueAsync("test", false, null, - new FlagEvaluationOptions(hook3, ImmutableDictionary.Empty)); - - Assert.Single(Api.Instance.GetHooks()); - Assert.Single(client.GetHooks()); - Assert.Single(testProvider.GetProviderHooks()); - } - - [Fact] - [Specification("4.4.3", "If a `finally` hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining `finally` hooks.")] - public async Task Finally_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() - { - var featureProvider = Substitute.For(); - var hook1 = Substitute.For(); - var hook2 = Substitute.For(); - - featureProvider.GetMetadata().Returns(new Metadata(null)); - featureProvider.GetProviderHooks().Returns(ImmutableList.Empty); - - // Sequence - hook1.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); - hook2.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); - featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new ResolutionDetails("test", false)); - hook2.AfterAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); - hook1.AfterAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); - hook2.FinallyAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); - hook1.FinallyAsync(Arg.Any>(), Arg.Any>(), null).Throws(new Exception()); - - await Api.Instance.SetProviderAsync(featureProvider); - var client = Api.Instance.GetClient(); - client.AddHooks(new[] { hook1, hook2 }); - Assert.Equal(2, client.GetHooks().Count()); - - await client.GetBooleanValueAsync("test", false); - - Received.InOrder(() => - { - hook1.BeforeAsync(Arg.Any>(), null); - hook2.BeforeAsync(Arg.Any>(), null); - featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); - hook2.AfterAsync(Arg.Any>(), Arg.Any>(), null); - hook1.AfterAsync(Arg.Any>(), Arg.Any>(), null); - hook2.FinallyAsync(Arg.Any>(), Arg.Any>(), null); - hook1.FinallyAsync(Arg.Any>(), Arg.Any>(), null); - }); + hook.BeforeAsync(Arg.Any>(), Arg.Any>()); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + }); + + _ = hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = hook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + _ = featureProvider.Received(1).ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } - _ = hook1.Received(1).BeforeAsync(Arg.Any>(), null); - _ = hook2.Received(1).BeforeAsync(Arg.Any>(), null); - _ = featureProvider.Received(1).ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); - _ = hook2.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), null); - _ = hook1.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), null); - _ = hook2.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), null); - _ = hook1.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), null); - } - - [Fact] - [Specification("4.4.4", "If an `error` hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining `error` hooks.")] - public async Task Error_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() - { - var featureProvider1 = Substitute.For(); - var hook1 = Substitute.For(); - var hook2 = Substitute.For(); + [Fact] + [Specification("4.4.1", "The API, Client, Provider, and invocation MUST have a method for registering hooks.")] + public async Task Register_Hooks_Should_Be_Available_At_All_Levels() + { + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + var hook3 = Substitute.For(); + var hook4 = Substitute.For(); + + var testProvider = new TestProvider(); + testProvider.AddHook(hook4); + Api.Instance.AddHooks(hook1); + await Api.Instance.SetProviderAsync(testProvider); + var client = Api.Instance.GetClient(); + client.AddHooks(hook2); + await client.GetBooleanValueAsync("test", false, null, + new FlagEvaluationOptions(hook3, ImmutableDictionary.Empty)); + + Assert.Single(Api.Instance.GetHooks()); + Assert.Single(client.GetHooks()); + Assert.Single(testProvider.GetProviderHooks()); + } - featureProvider1.GetMetadata().Returns(new Metadata(null)); - featureProvider1.GetProviderHooks().Returns(ImmutableList.Empty); + [Fact] + [Specification("4.4.3", "If a `finally` hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining `finally` hooks.")] + public async Task Finally_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() + { + var featureProvider = Substitute.For(); + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + + featureProvider.GetMetadata().Returns(new Metadata(null)); + featureProvider.GetProviderHooks().Returns(ImmutableList.Empty); + + // Sequence + hook1.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); + hook2.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new ResolutionDetails("test", false)); + hook2.AfterAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); + hook1.AfterAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); + hook2.FinallyAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); + hook1.FinallyAsync(Arg.Any>(), Arg.Any>(), null).Throws(new Exception()); + + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); + client.AddHooks(new[] { hook1, hook2 }); + Assert.Equal(2, client.GetHooks().Count()); + + await client.GetBooleanValueAsync("test", false); + + Received.InOrder(() => + { + hook1.BeforeAsync(Arg.Any>(), null); + hook2.BeforeAsync(Arg.Any>(), null); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + hook2.AfterAsync(Arg.Any>(), Arg.Any>(), null); + hook1.AfterAsync(Arg.Any>(), Arg.Any>(), null); + hook2.FinallyAsync(Arg.Any>(), Arg.Any>(), null); + hook1.FinallyAsync(Arg.Any>(), Arg.Any>(), null); + }); + + _ = hook1.Received(1).BeforeAsync(Arg.Any>(), null); + _ = hook2.Received(1).BeforeAsync(Arg.Any>(), null); + _ = featureProvider.Received(1).ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + _ = hook2.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), null); + _ = hook1.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), null); + _ = hook2.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), null); + _ = hook1.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), null); + } - // Sequence - hook1.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); - hook2.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); - featureProvider1.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Throws(new Exception()); - hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null).Throws(new Exception()); - hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null).Returns(new ValueTask()); + [Fact] + [Specification("4.4.4", "If an `error` hook abnormally terminates, evaluation MUST proceed, including the execution of any remaining `error` hooks.")] + public async Task Error_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() + { + var featureProvider1 = Substitute.For(); + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); - await Api.Instance.SetProviderAsync(featureProvider1); - var client = Api.Instance.GetClient(); - client.AddHooks(new[] { hook1, hook2 }); + featureProvider1.GetMetadata().Returns(new Metadata(null)); + featureProvider1.GetProviderHooks().Returns(ImmutableList.Empty); - await client.GetBooleanValueAsync("test", false); + // Sequence + hook1.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); + hook2.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); + featureProvider1.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Throws(new Exception()); + hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null).Throws(new Exception()); + hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null).Returns(new ValueTask()); - Received.InOrder(() => - { - hook1.BeforeAsync(Arg.Any>(), null); - hook2.BeforeAsync(Arg.Any>(), null); - featureProvider1.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); - hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null); - hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null); - }); + await Api.Instance.SetProviderAsync(featureProvider1); + var client = Api.Instance.GetClient(); + client.AddHooks(new[] { hook1, hook2 }); - _ = hook1.Received(1).BeforeAsync(Arg.Any>(), null); - _ = hook2.Received(1).BeforeAsync(Arg.Any>(), null); - _ = hook1.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), null); - _ = hook2.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), null); - } + await client.GetBooleanValueAsync("test", false); - [Fact] - [Specification("4.4.6", "If an error occurs during the evaluation of `before` or `after` hooks, any remaining hooks in the `before` or `after` stages MUST NOT be invoked.")] - public async Task Error_Occurs_During_Before_After_Evaluation_Should_Not_Invoke_Any_Remaining_Hooks() + Received.InOrder(() => { - var featureProvider = Substitute.For(); - var hook1 = Substitute.For(); - var hook2 = Substitute.For(); - - featureProvider.GetMetadata().Returns(new Metadata(null)); - featureProvider.GetProviderHooks().Returns(ImmutableList.Empty); + hook1.BeforeAsync(Arg.Any>(), null); + hook2.BeforeAsync(Arg.Any>(), null); + featureProvider1.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()); + hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null); + hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null); + }); + + _ = hook1.Received(1).BeforeAsync(Arg.Any>(), null); + _ = hook2.Received(1).BeforeAsync(Arg.Any>(), null); + _ = hook1.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), null); + _ = hook2.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), null); + } - // Sequence - hook1.BeforeAsync(Arg.Any>(), Arg.Any>()).Throws(new Exception()); - _ = hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null); - _ = hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null); + [Fact] + [Specification("4.4.6", "If an error occurs during the evaluation of `before` or `after` hooks, any remaining hooks in the `before` or `after` stages MUST NOT be invoked.")] + public async Task Error_Occurs_During_Before_After_Evaluation_Should_Not_Invoke_Any_Remaining_Hooks() + { + var featureProvider = Substitute.For(); + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); - await Api.Instance.SetProviderAsync(featureProvider); - var client = Api.Instance.GetClient(); - client.AddHooks(new[] { hook1, hook2 }); + featureProvider.GetMetadata().Returns(new Metadata(null)); + featureProvider.GetProviderHooks().Returns(ImmutableList.Empty); - await client.GetBooleanValueAsync("test", false); + // Sequence + hook1.BeforeAsync(Arg.Any>(), Arg.Any>()).Throws(new Exception()); + _ = hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null); + _ = hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null); - Received.InOrder(() => - { - hook1.BeforeAsync(Arg.Any>(), Arg.Any>()); - hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null); - hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null); - }); + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); + client.AddHooks(new[] { hook1, hook2 }); - _ = hook1.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); - _ = hook2.DidNotReceive().BeforeAsync(Arg.Any>(), Arg.Any>()); - _ = hook1.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), null); - _ = hook2.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), null); - } + await client.GetBooleanValueAsync("test", false); - [Fact] - [Specification("4.5.1", "`Flag evaluation options` MAY contain `hook hints`, a map of data to be provided to hook invocations.")] - public async Task Hook_Hints_May_Be_Optional() + Received.InOrder(() => { - var featureProvider = Substitute.For(); - var hook = Substitute.For(); - var flagOptions = new FlagEvaluationOptions(hook); + hook1.BeforeAsync(Arg.Any>(), Arg.Any>()); + hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null); + hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null); + }); + + _ = hook1.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = hook2.DidNotReceive().BeforeAsync(Arg.Any>(), Arg.Any>()); + _ = hook1.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), null); + _ = hook2.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), null); + } - featureProvider.GetMetadata() - .Returns(new Metadata(null)); + [Fact] + [Specification("4.5.1", "`Flag evaluation options` MAY contain `hook hints`, a map of data to be provided to hook invocations.")] + public async Task Hook_Hints_May_Be_Optional() + { + var featureProvider = Substitute.For(); + var hook = Substitute.For(); + var flagOptions = new FlagEvaluationOptions(hook); - featureProvider.GetProviderHooks() - .Returns(ImmutableList.Empty); + featureProvider.GetMetadata() + .Returns(new Metadata(null)); - hook.BeforeAsync(Arg.Any>(), Arg.Any>()) - .Returns(EvaluationContext.Empty); + featureProvider.GetProviderHooks() + .Returns(ImmutableList.Empty); - featureProvider.ResolveBooleanValueAsync("test", false, Arg.Any()) - .Returns(new ResolutionDetails("test", false)); + hook.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty); - hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) - .Returns(new ValueTask()); + featureProvider.ResolveBooleanValueAsync("test", false, Arg.Any()) + .Returns(new ResolutionDetails("test", false)); - hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) - .Returns(new ValueTask()); + hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) + .Returns(new ValueTask()); - await Api.Instance.SetProviderAsync(featureProvider); - var client = Api.Instance.GetClient(); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) + .Returns(new ValueTask()); - await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, flagOptions); + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); - Received.InOrder(() => - { - hook.Received().BeforeAsync(Arg.Any>(), Arg.Any>()); - featureProvider.Received().ResolveBooleanValueAsync("test", false, Arg.Any()); - hook.Received().AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - hook.Received().FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - }); - } + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, flagOptions); - [Fact] - [Specification("4.4.5", "If an error occurs in the `before` or `after` hooks, the `error` hooks MUST be invoked.")] - [Specification("4.4.7", "If an error occurs in the `before` hooks, the default value MUST be returned.")] - public async Task When_Error_Occurs_In_Before_Hook_Should_Return_Default_Value() + Received.InOrder(() => { - var featureProvider = Substitute.For(); - var hook = Substitute.For(); - var exceptionToThrow = new Exception("Fails during default"); - - featureProvider.GetMetadata().Returns(new Metadata(null)); + hook.Received().BeforeAsync(Arg.Any>(), Arg.Any>()); + featureProvider.Received().ResolveBooleanValueAsync("test", false, Arg.Any()); + hook.Received().AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + hook.Received().FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + }); + } - // Sequence - hook.BeforeAsync(Arg.Any>(), Arg.Any>()).Throws(exceptionToThrow); - hook.ErrorAsync(Arg.Any>(), Arg.Any(), null).Returns(new ValueTask()); - hook.FinallyAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); + [Fact] + [Specification("4.4.5", "If an error occurs in the `before` or `after` hooks, the `error` hooks MUST be invoked.")] + [Specification("4.4.7", "If an error occurs in the `before` hooks, the default value MUST be returned.")] + public async Task When_Error_Occurs_In_Before_Hook_Should_Return_Default_Value() + { + var featureProvider = Substitute.For(); + var hook = Substitute.For(); + var exceptionToThrow = new Exception("Fails during default"); - var client = Api.Instance.GetClient(); - client.AddHooks(hook); + featureProvider.GetMetadata().Returns(new Metadata(null)); - var resolvedFlag = await client.GetBooleanValueAsync("test", true); + // Sequence + hook.BeforeAsync(Arg.Any>(), Arg.Any>()).Throws(exceptionToThrow); + hook.ErrorAsync(Arg.Any>(), Arg.Any(), null).Returns(new ValueTask()); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), null).Returns(new ValueTask()); - Received.InOrder(() => - { - hook.BeforeAsync(Arg.Any>(), Arg.Any>()); - hook.ErrorAsync(Arg.Any>(), Arg.Any(), null); - hook.FinallyAsync(Arg.Any>(), Arg.Any>(), null); - }); + var client = Api.Instance.GetClient(); + client.AddHooks(hook); - Assert.True(resolvedFlag); - _ = hook.Received(1).BeforeAsync(Arg.Any>(), null); - _ = hook.Received(1).ErrorAsync(Arg.Any>(), exceptionToThrow, null); - _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), null); - } + var resolvedFlag = await client.GetBooleanValueAsync("test", true); - [Fact] - [Specification("4.4.5", "If an error occurs in the `before` or `after` hooks, the `error` hooks MUST be invoked.")] - public async Task When_Error_Occurs_In_After_Hook_Should_Invoke_Error_Hook() + Received.InOrder(() => { - var featureProvider = Substitute.For(); - var hook = Substitute.For(); - var flagOptions = new FlagEvaluationOptions(hook); - var exceptionToThrow = new Exception("Fails during default"); - - featureProvider.GetMetadata() - .Returns(new Metadata(null)); + hook.BeforeAsync(Arg.Any>(), Arg.Any>()); + hook.ErrorAsync(Arg.Any>(), Arg.Any(), null); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), null); + }); + + Assert.True(resolvedFlag); + _ = hook.Received(1).BeforeAsync(Arg.Any>(), null); + _ = hook.Received(1).ErrorAsync(Arg.Any>(), exceptionToThrow, null); + _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), null); + } - featureProvider.GetProviderHooks() - .Returns(ImmutableList.Empty); + [Fact] + [Specification("4.4.5", "If an error occurs in the `before` or `after` hooks, the `error` hooks MUST be invoked.")] + public async Task When_Error_Occurs_In_After_Hook_Should_Invoke_Error_Hook() + { + var featureProvider = Substitute.For(); + var hook = Substitute.For(); + var flagOptions = new FlagEvaluationOptions(hook); + var exceptionToThrow = new Exception("Fails during default"); - hook.BeforeAsync(Arg.Any>(), Arg.Any>()) - .Returns(EvaluationContext.Empty); + featureProvider.GetMetadata() + .Returns(new Metadata(null)); - featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ResolutionDetails("test", false)); + featureProvider.GetProviderHooks() + .Returns(ImmutableList.Empty); - hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) - .Throws(exceptionToThrow); + hook.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty); - hook.ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>()) - .Returns(new ValueTask()); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ResolutionDetails("test", false)); - hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) - .Returns(new ValueTask()); + hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) + .Throws(exceptionToThrow); - await Api.Instance.SetProviderAsync(featureProvider); - var client = Api.Instance.GetClient(); + hook.ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>()) + .Returns(new ValueTask()); - var resolvedFlag = await client.GetBooleanValueAsync("test", true, config: flagOptions); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) + .Returns(new ValueTask()); - Assert.True(resolvedFlag); + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); - Received.InOrder(() => - { - hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); - hook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); - }); + var resolvedFlag = await client.GetBooleanValueAsync("test", true, config: flagOptions); - await featureProvider.DidNotReceive().ResolveBooleanValueAsync("test", false, Arg.Any()); - } + Assert.True(resolvedFlag); - [Fact] - public async Task Successful_Resolution_Should_Pass_Cancellation_Token() + Received.InOrder(() => { - var featureProvider = Substitute.For(); - var hook = Substitute.For(); - var cts = new CancellationTokenSource(); + hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>()); + hook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()); + }); - featureProvider.GetMetadata().Returns(new Metadata(null)); - featureProvider.GetProviderHooks().Returns(ImmutableList.Empty); + await featureProvider.DidNotReceive().ResolveBooleanValueAsync("test", false, Arg.Any()); + } - hook.BeforeAsync(Arg.Any>(), Arg.Any>(), cts.Token).Returns(EvaluationContext.Empty); - featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any(), cts.Token).Returns(new ResolutionDetails("test", false)); - _ = hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); - _ = hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); + [Fact] + public async Task Successful_Resolution_Should_Pass_Cancellation_Token() + { + var featureProvider = Substitute.For(); + var hook = Substitute.For(); + var cts = new CancellationTokenSource(); - await Api.Instance.SetProviderAsync(featureProvider); - var client = Api.Instance.GetClient(); - client.AddHooks(hook); + featureProvider.GetMetadata().Returns(new Metadata(null)); + featureProvider.GetProviderHooks().Returns(ImmutableList.Empty); - await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, null, cts.Token); + hook.BeforeAsync(Arg.Any>(), Arg.Any>(), cts.Token).Returns(EvaluationContext.Empty); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any(), cts.Token).Returns(new ResolutionDetails("test", false)); + _ = hook.AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); + _ = hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); - _ = hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), cts.Token); - _ = hook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); - _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); - } + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); + client.AddHooks(hook); - [Fact] - public async Task Failed_Resolution_Should_Pass_Cancellation_Token() - { - var featureProvider = Substitute.For(); - var hook = Substitute.For(); - var flagOptions = new FlagEvaluationOptions(hook); - var exceptionToThrow = new GeneralException("Fake Exception"); - var cts = new CancellationTokenSource(); + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, null, cts.Token); + + _ = hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), cts.Token); + _ = hook.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); + _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); + } - featureProvider.GetMetadata() - .Returns(new Metadata(null)); + [Fact] + public async Task Failed_Resolution_Should_Pass_Cancellation_Token() + { + var featureProvider = Substitute.For(); + var hook = Substitute.For(); + var flagOptions = new FlagEvaluationOptions(hook); + var exceptionToThrow = new GeneralException("Fake Exception"); + var cts = new CancellationTokenSource(); - featureProvider.GetProviderHooks() - .Returns(ImmutableList.Empty); + featureProvider.GetMetadata() + .Returns(new Metadata(null)); - hook.BeforeAsync(Arg.Any>(), Arg.Any>()) - .Returns(EvaluationContext.Empty); + featureProvider.GetProviderHooks() + .Returns(ImmutableList.Empty); - featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Throws(exceptionToThrow); + hook.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty); - hook.ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>()) - .Returns(new ValueTask()); + featureProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Throws(exceptionToThrow); - hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) - .Returns(new ValueTask()); + hook.ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>()) + .Returns(new ValueTask()); - await Api.Instance.SetProviderAsync(featureProvider); - var client = Api.Instance.GetClient(); + hook.FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>()) + .Returns(new ValueTask()); - await client.GetBooleanValueAsync("test", true, EvaluationContext.Empty, flagOptions, cts.Token); + await Api.Instance.SetProviderAsync(featureProvider); + var client = Api.Instance.GetClient(); - _ = hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), cts.Token); - _ = hook.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>(), cts.Token); - _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); + await client.GetBooleanValueAsync("test", true, EvaluationContext.Empty, flagOptions, cts.Token); - await featureProvider.DidNotReceive().ResolveBooleanValueAsync("test", false, Arg.Any()); - } + _ = hook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), cts.Token); + _ = hook.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>(), cts.Token); + _ = hook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), cts.Token); - [Fact] - public void Add_hooks_should_accept_empty_enumerable() - { - Api.Instance.ClearHooks(); - Api.Instance.AddHooks(Enumerable.Empty()); - } + await featureProvider.DidNotReceive().ResolveBooleanValueAsync("test", false, Arg.Any()); + } + + [Fact] + public void Add_hooks_should_accept_empty_enumerable() + { + Api.Instance.ClearHooks(); + Api.Instance.AddHooks(Enumerable.Empty()); } } diff --git a/test/OpenFeature.Tests/OpenFeatureTests.cs b/test/OpenFeature.Tests/OpenFeatureTests.cs index 24caf9ad8..4dea7f39f 100644 --- a/test/OpenFeature.Tests/OpenFeatureTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureTests.cs @@ -8,313 +8,312 @@ using OpenFeature.Tests.Internal; using Xunit; -namespace OpenFeature.Tests +namespace OpenFeature.Tests; + +[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] +public class OpenFeatureTests : ClearOpenFeatureInstanceFixture { - [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] - public class OpenFeatureTests : ClearOpenFeatureInstanceFixture + [Fact] + [Specification("1.1.1", "The `API`, and any state it maintains SHOULD exist as a global singleton, even in cases wherein multiple versions of the `API` are present at runtime.")] + public void OpenFeature_Should_Be_Singleton() + { + var openFeature = Api.Instance; + var openFeature2 = Api.Instance; + + Assert.Equal(openFeature2, openFeature); + } + + [Fact] + [Specification("1.1.2.2", "The provider mutator function MUST invoke the initialize function on the newly registered provider before using it to resolve flag values.")] + public async Task OpenFeature_Should_Initialize_Provider() + { + var providerMockDefault = Substitute.For(); + providerMockDefault.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync(providerMockDefault); + await providerMockDefault.Received(1).InitializeAsync(Api.Instance.GetContext()); + + var providerMockNamed = Substitute.For(); + providerMockNamed.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync("the-name", providerMockNamed); + await providerMockNamed.Received(1).InitializeAsync(Api.Instance.GetContext()); + } + + [Fact] + [Specification("1.1.2.3", + "The provider mutator function MUST invoke the shutdown function on the previously registered provider once it's no longer being used to resolve flag values.")] + public async Task OpenFeature_Should_Shutdown_Unused_Provider() + { + var providerA = Substitute.For(); + providerA.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync(providerA); + await providerA.Received(1).InitializeAsync(Api.Instance.GetContext()); + + var providerB = Substitute.For(); + providerB.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync(providerB); + await providerB.Received(1).InitializeAsync(Api.Instance.GetContext()); + await providerA.Received(1).ShutdownAsync(); + + var providerC = Substitute.For(); + providerC.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync("named", providerC); + await providerC.Received(1).InitializeAsync(Api.Instance.GetContext()); + + var providerD = Substitute.For(); + providerD.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync("named", providerD); + await providerD.Received(1).InitializeAsync(Api.Instance.GetContext()); + await providerC.Received(1).ShutdownAsync(); + } + + [Fact] + [Specification("1.6.1", "The API MUST define a mechanism to propagate a shutdown request to active providers.")] + public async Task OpenFeature_Should_Support_Shutdown() + { + var providerA = Substitute.For(); + providerA.Status.Returns(ProviderStatus.NotReady); + + var providerB = Substitute.For(); + providerB.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync(providerA); + await Api.Instance.SetProviderAsync("named", providerB); + + await Api.Instance.ShutdownAsync(); + + await providerA.Received(1).ShutdownAsync(); + await providerB.Received(1).ShutdownAsync(); + } + + [Fact] + [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] + public async Task OpenFeature_Should_Not_Change_Named_Providers_When_Setting_Default_Provider() + { + var openFeature = Api.Instance; + + await openFeature.SetProviderAsync(new NoOpFeatureProvider()); + await openFeature.SetProviderAsync(TestProvider.DefaultName, new TestProvider()); + + var defaultClient = openFeature.GetProviderMetadata(); + var domainScopedClient = openFeature.GetProviderMetadata(TestProvider.DefaultName); + + Assert.Equal(NoOpProvider.NoOpProviderName, defaultClient?.Name); + Assert.Equal(TestProvider.DefaultName, domainScopedClient?.Name); + } + + [Fact] + [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] + public async Task OpenFeature_Should_Set_Default_Provide_When_No_Name_Provided() + { + var openFeature = Api.Instance; + + await openFeature.SetProviderAsync(new TestProvider()); + + var defaultClient = openFeature.GetProviderMetadata(); + + Assert.Equal(TestProvider.DefaultName, defaultClient?.Name); + } + + [Fact] + [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] + public async Task OpenFeature_Should_Assign_Provider_To_Existing_Client() + { + const string name = "new-client"; + var openFeature = Api.Instance; + + await openFeature.SetProviderAsync(name, new TestProvider()); + await openFeature.SetProviderAsync(name, new NoOpFeatureProvider()); + + Assert.Equal(NoOpProvider.NoOpProviderName, openFeature.GetProviderMetadata(name)?.Name); + } + + [Fact] + [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] + public async Task OpenFeature_Should_Allow_Multiple_Client_Names_Of_Same_Instance() + { + var openFeature = Api.Instance; + var provider = new TestProvider(); + + await openFeature.SetProviderAsync("a", provider); + await openFeature.SetProviderAsync("b", provider); + + var clientA = openFeature.GetProvider("a"); + var clientB = openFeature.GetProvider("b"); + + Assert.Equal(clientB, clientA); + } + + [Fact] + [Specification("1.1.4", "The `API` MUST provide a function to add `hooks` which accepts one or more API-conformant `hooks`, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed.")] + public void OpenFeature_Should_Add_Hooks() + { + var openFeature = Api.Instance; + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + var hook3 = Substitute.For(); + var hook4 = Substitute.For(); + + openFeature.ClearHooks(); + + openFeature.AddHooks(hook1); + + Assert.Contains(hook1, openFeature.GetHooks()); + Assert.Single(openFeature.GetHooks()); + + openFeature.AddHooks(hook2); + var expectedHooks = new[] { hook1, hook2 }.AsEnumerable(); + Assert.Equal(expectedHooks, openFeature.GetHooks()); + + openFeature.AddHooks(new[] { hook3, hook4 }); + expectedHooks = new[] { hook1, hook2, hook3, hook4 }.AsEnumerable(); + Assert.Equal(expectedHooks, openFeature.GetHooks()); + + openFeature.ClearHooks(); + Assert.Empty(openFeature.GetHooks()); + } + + [Fact] + [Specification("1.1.5", "The API MUST provide a function for retrieving the metadata field of the configured `provider`.")] + public async Task OpenFeature_Should_Get_Metadata() + { + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var openFeature = Api.Instance; + var metadata = openFeature.GetProviderMetadata(); + + Assert.NotNull(metadata); + Assert.Equal(NoOpProvider.NoOpProviderName, metadata?.Name); + } + + [Theory] + [InlineData("client1", "version1")] + [InlineData("client2", null)] + [InlineData(null, null)] + [Specification("1.1.6", "The `API` MUST provide a function for creating a `client` which accepts the following options: - name (optional): A logical string identifier for the client.")] + public void OpenFeature_Should_Create_Client(string? name = null, string? version = null) { - [Fact] - [Specification("1.1.1", "The `API`, and any state it maintains SHOULD exist as a global singleton, even in cases wherein multiple versions of the `API` are present at runtime.")] - public void OpenFeature_Should_Be_Singleton() - { - var openFeature = Api.Instance; - var openFeature2 = Api.Instance; + var openFeature = Api.Instance; + var client = openFeature.GetClient(name, version); - Assert.Equal(openFeature2, openFeature); - } + Assert.NotNull(client); + Assert.Equal(name, client.GetMetadata().Name); + Assert.Equal(version, client.GetMetadata().Version); + } - [Fact] - [Specification("1.1.2.2", "The provider mutator function MUST invoke the initialize function on the newly registered provider before using it to resolve flag values.")] - public async Task OpenFeature_Should_Initialize_Provider() - { - var providerMockDefault = Substitute.For(); - providerMockDefault.Status.Returns(ProviderStatus.NotReady); + [Fact] + public void Should_Set_Given_Context() + { + var context = EvaluationContext.Empty; - await Api.Instance.SetProviderAsync(providerMockDefault); - await providerMockDefault.Received(1).InitializeAsync(Api.Instance.GetContext()); + Api.Instance.SetContext(context); - var providerMockNamed = Substitute.For(); - providerMockNamed.Status.Returns(ProviderStatus.NotReady); + Assert.Equal(context, Api.Instance.GetContext()); - await Api.Instance.SetProviderAsync("the-name", providerMockNamed); - await providerMockNamed.Received(1).InitializeAsync(Api.Instance.GetContext()); - } + context = EvaluationContext.Builder().Build(); - [Fact] - [Specification("1.1.2.3", - "The provider mutator function MUST invoke the shutdown function on the previously registered provider once it's no longer being used to resolve flag values.")] - public async Task OpenFeature_Should_Shutdown_Unused_Provider() - { - var providerA = Substitute.For(); - providerA.Status.Returns(ProviderStatus.NotReady); + Api.Instance.SetContext(context); - await Api.Instance.SetProviderAsync(providerA); - await providerA.Received(1).InitializeAsync(Api.Instance.GetContext()); + Assert.Equal(context, Api.Instance.GetContext()); + } - var providerB = Substitute.For(); - providerB.Status.Returns(ProviderStatus.NotReady); - - await Api.Instance.SetProviderAsync(providerB); - await providerB.Received(1).InitializeAsync(Api.Instance.GetContext()); - await providerA.Received(1).ShutdownAsync(); - - var providerC = Substitute.For(); - providerC.Status.Returns(ProviderStatus.NotReady); - - await Api.Instance.SetProviderAsync("named", providerC); - await providerC.Received(1).InitializeAsync(Api.Instance.GetContext()); + [Fact] + public void Should_Always_Have_Provider() + { + Assert.NotNull(Api.Instance.GetProvider()); + } - var providerD = Substitute.For(); - providerD.Status.Returns(ProviderStatus.NotReady); + [Fact] + public async Task OpenFeature_Should_Allow_Multiple_Client_Mapping() + { + var openFeature = Api.Instance; - await Api.Instance.SetProviderAsync("named", providerD); - await providerD.Received(1).InitializeAsync(Api.Instance.GetContext()); - await providerC.Received(1).ShutdownAsync(); - } + await openFeature.SetProviderAsync("client1", new TestProvider()); + await openFeature.SetProviderAsync("client2", new NoOpFeatureProvider()); - [Fact] - [Specification("1.6.1", "The API MUST define a mechanism to propagate a shutdown request to active providers.")] - public async Task OpenFeature_Should_Support_Shutdown() - { - var providerA = Substitute.For(); - providerA.Status.Returns(ProviderStatus.NotReady); + var client1 = openFeature.GetClient("client1"); + var client2 = openFeature.GetClient("client2"); - var providerB = Substitute.For(); - providerB.Status.Returns(ProviderStatus.NotReady); - - await Api.Instance.SetProviderAsync(providerA); - await Api.Instance.SetProviderAsync("named", providerB); - - await Api.Instance.ShutdownAsync(); - - await providerA.Received(1).ShutdownAsync(); - await providerB.Received(1).ShutdownAsync(); - } - - [Fact] - [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] - public async Task OpenFeature_Should_Not_Change_Named_Providers_When_Setting_Default_Provider() - { - var openFeature = Api.Instance; - - await openFeature.SetProviderAsync(new NoOpFeatureProvider()); - await openFeature.SetProviderAsync(TestProvider.DefaultName, new TestProvider()); - - var defaultClient = openFeature.GetProviderMetadata(); - var domainScopedClient = openFeature.GetProviderMetadata(TestProvider.DefaultName); - - Assert.Equal(NoOpProvider.NoOpProviderName, defaultClient?.Name); - Assert.Equal(TestProvider.DefaultName, domainScopedClient?.Name); - } - - [Fact] - [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] - public async Task OpenFeature_Should_Set_Default_Provide_When_No_Name_Provided() - { - var openFeature = Api.Instance; - - await openFeature.SetProviderAsync(new TestProvider()); - - var defaultClient = openFeature.GetProviderMetadata(); - - Assert.Equal(TestProvider.DefaultName, defaultClient?.Name); - } + Assert.Equal("client1", client1.GetMetadata().Name); + Assert.Equal("client2", client2.GetMetadata().Name); - [Fact] - [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] - public async Task OpenFeature_Should_Assign_Provider_To_Existing_Client() - { - const string name = "new-client"; - var openFeature = Api.Instance; + Assert.True(await client1.GetBooleanValueAsync("test", false)); + Assert.False(await client2.GetBooleanValueAsync("test", false)); + } - await openFeature.SetProviderAsync(name, new TestProvider()); - await openFeature.SetProviderAsync(name, new NoOpFeatureProvider()); + [Fact] + public void SetTransactionContextPropagator_ShouldThrowArgumentNullException_WhenNullPropagatorIsPassed() + { + // Arrange + var api = Api.Instance; - Assert.Equal(NoOpProvider.NoOpProviderName, openFeature.GetProviderMetadata(name)?.Name); - } + // Act & Assert + Assert.Throws(() => api.SetTransactionContextPropagator(null!)); + } - [Fact] - [Specification("1.1.3", "The `API` MUST provide a function to bind a given `provider` to one or more client `name`s. If the client-name already has a bound provider, it is overwritten with the new mapping.")] - public async Task OpenFeature_Should_Allow_Multiple_Client_Names_Of_Same_Instance() - { - var openFeature = Api.Instance; - var provider = new TestProvider(); + [Fact] + public void SetTransactionContextPropagator_ShouldSetPropagator_WhenValidPropagatorIsPassed() + { + // Arrange + var api = Api.Instance; + var mockPropagator = Substitute.For(); - await openFeature.SetProviderAsync("a", provider); - await openFeature.SetProviderAsync("b", provider); + // Act + api.SetTransactionContextPropagator(mockPropagator); - var clientA = openFeature.GetProvider("a"); - var clientB = openFeature.GetProvider("b"); + // Assert + Assert.Equal(mockPropagator, api.GetTransactionContextPropagator()); + } + + [Fact] + public void SetTransactionContext_ShouldThrowArgumentNullException_WhenEvaluationContextIsNull() + { + // Arrange + var api = Api.Instance; + + // Act & Assert + Assert.Throws(() => api.SetTransactionContext(null!)); + } + + [Fact] + public void SetTransactionContext_ShouldSetTransactionContext_WhenValidEvaluationContextIsProvided() + { + // Arrange + var api = Api.Instance; + var evaluationContext = EvaluationContext.Builder() + .Set("initial", "yes") + .Build(); + var mockPropagator = Substitute.For(); + mockPropagator.GetTransactionContext().Returns(evaluationContext); + api.SetTransactionContextPropagator(mockPropagator); + api.SetTransactionContext(evaluationContext); + + // Act + api.SetTransactionContext(evaluationContext); + var result = api.GetTransactionContext(); + + // Assert + mockPropagator.Received().SetTransactionContext(evaluationContext); + Assert.Equal(evaluationContext, result); + Assert.Equal(evaluationContext.GetValue("initial"), result.GetValue("initial")); + } + + [Fact] + public void GetTransactionContext_ShouldReturnEmptyEvaluationContext_WhenNoPropagatorIsSet() + { + // Arrange + var api = Api.Instance; + var context = EvaluationContext.Builder().Set("status", "not-ready").Build(); + api.SetTransactionContext(context); - Assert.Equal(clientB, clientA); - } + // Act + var result = api.GetTransactionContext(); - [Fact] - [Specification("1.1.4", "The `API` MUST provide a function to add `hooks` which accepts one or more API-conformant `hooks`, and appends them to the collection of any previously added hooks. When new hooks are added, previously added hooks are not removed.")] - public void OpenFeature_Should_Add_Hooks() - { - var openFeature = Api.Instance; - var hook1 = Substitute.For(); - var hook2 = Substitute.For(); - var hook3 = Substitute.For(); - var hook4 = Substitute.For(); - - openFeature.ClearHooks(); - - openFeature.AddHooks(hook1); - - Assert.Contains(hook1, openFeature.GetHooks()); - Assert.Single(openFeature.GetHooks()); - - openFeature.AddHooks(hook2); - var expectedHooks = new[] { hook1, hook2 }.AsEnumerable(); - Assert.Equal(expectedHooks, openFeature.GetHooks()); - - openFeature.AddHooks(new[] { hook3, hook4 }); - expectedHooks = new[] { hook1, hook2, hook3, hook4 }.AsEnumerable(); - Assert.Equal(expectedHooks, openFeature.GetHooks()); - - openFeature.ClearHooks(); - Assert.Empty(openFeature.GetHooks()); - } - - [Fact] - [Specification("1.1.5", "The API MUST provide a function for retrieving the metadata field of the configured `provider`.")] - public async Task OpenFeature_Should_Get_Metadata() - { - await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); - var openFeature = Api.Instance; - var metadata = openFeature.GetProviderMetadata(); - - Assert.NotNull(metadata); - Assert.Equal(NoOpProvider.NoOpProviderName, metadata?.Name); - } - - [Theory] - [InlineData("client1", "version1")] - [InlineData("client2", null)] - [InlineData(null, null)] - [Specification("1.1.6", "The `API` MUST provide a function for creating a `client` which accepts the following options: - name (optional): A logical string identifier for the client.")] - public void OpenFeature_Should_Create_Client(string? name = null, string? version = null) - { - var openFeature = Api.Instance; - var client = openFeature.GetClient(name, version); - - Assert.NotNull(client); - Assert.Equal(name, client.GetMetadata().Name); - Assert.Equal(version, client.GetMetadata().Version); - } - - [Fact] - public void Should_Set_Given_Context() - { - var context = EvaluationContext.Empty; - - Api.Instance.SetContext(context); - - Assert.Equal(context, Api.Instance.GetContext()); - - context = EvaluationContext.Builder().Build(); - - Api.Instance.SetContext(context); - - Assert.Equal(context, Api.Instance.GetContext()); - } - - [Fact] - public void Should_Always_Have_Provider() - { - Assert.NotNull(Api.Instance.GetProvider()); - } - - [Fact] - public async Task OpenFeature_Should_Allow_Multiple_Client_Mapping() - { - var openFeature = Api.Instance; - - await openFeature.SetProviderAsync("client1", new TestProvider()); - await openFeature.SetProviderAsync("client2", new NoOpFeatureProvider()); - - var client1 = openFeature.GetClient("client1"); - var client2 = openFeature.GetClient("client2"); - - Assert.Equal("client1", client1.GetMetadata().Name); - Assert.Equal("client2", client2.GetMetadata().Name); - - Assert.True(await client1.GetBooleanValueAsync("test", false)); - Assert.False(await client2.GetBooleanValueAsync("test", false)); - } - - [Fact] - public void SetTransactionContextPropagator_ShouldThrowArgumentNullException_WhenNullPropagatorIsPassed() - { - // Arrange - var api = Api.Instance; - - // Act & Assert - Assert.Throws(() => api.SetTransactionContextPropagator(null!)); - } - - [Fact] - public void SetTransactionContextPropagator_ShouldSetPropagator_WhenValidPropagatorIsPassed() - { - // Arrange - var api = Api.Instance; - var mockPropagator = Substitute.For(); - - // Act - api.SetTransactionContextPropagator(mockPropagator); - - // Assert - Assert.Equal(mockPropagator, api.GetTransactionContextPropagator()); - } - - [Fact] - public void SetTransactionContext_ShouldThrowArgumentNullException_WhenEvaluationContextIsNull() - { - // Arrange - var api = Api.Instance; - - // Act & Assert - Assert.Throws(() => api.SetTransactionContext(null!)); - } - - [Fact] - public void SetTransactionContext_ShouldSetTransactionContext_WhenValidEvaluationContextIsProvided() - { - // Arrange - var api = Api.Instance; - var evaluationContext = EvaluationContext.Builder() - .Set("initial", "yes") - .Build(); - var mockPropagator = Substitute.For(); - mockPropagator.GetTransactionContext().Returns(evaluationContext); - api.SetTransactionContextPropagator(mockPropagator); - api.SetTransactionContext(evaluationContext); - - // Act - api.SetTransactionContext(evaluationContext); - var result = api.GetTransactionContext(); - - // Assert - mockPropagator.Received().SetTransactionContext(evaluationContext); - Assert.Equal(evaluationContext, result); - Assert.Equal(evaluationContext.GetValue("initial"), result.GetValue("initial")); - } - - [Fact] - public void GetTransactionContext_ShouldReturnEmptyEvaluationContext_WhenNoPropagatorIsSet() - { - // Arrange - var api = Api.Instance; - var context = EvaluationContext.Builder().Set("status", "not-ready").Build(); - api.SetTransactionContext(context); - - // Act - var result = api.GetTransactionContext(); - - // Assert - Assert.Equal(EvaluationContext.Empty, result); - } + // Assert + Assert.Equal(EvaluationContext.Empty, result); } } diff --git a/test/OpenFeature.Tests/ProviderRepositoryTests.cs b/test/OpenFeature.Tests/ProviderRepositoryTests.cs index e88de6e97..046d750a6 100644 --- a/test/OpenFeature.Tests/ProviderRepositoryTests.cs +++ b/test/OpenFeature.Tests/ProviderRepositoryTests.cs @@ -9,407 +9,406 @@ // We intentionally do not await for purposes of validating behavior. #pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed -namespace OpenFeature.Tests +namespace OpenFeature.Tests; + +[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] +public class ProviderRepositoryTests { - [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] - public class ProviderRepositoryTests + [Fact] + public async Task Default_Provider_Is_Set_Without_Await() { - [Fact] - public async Task Default_Provider_Is_Set_Without_Await() - { - var repository = new ProviderRepository(); - var provider = new NoOpFeatureProvider(); - var context = new EvaluationContextBuilder().Build(); - await repository.SetProviderAsync(provider, context); - Assert.Equal(provider, repository.GetProvider()); - } - - [Fact] - public async Task Initialization_Provider_Method_Is_Invoked_For_Setting_Default_Provider() - { - var repository = new ProviderRepository(); - var providerMock = Substitute.For(); - providerMock.Status.Returns(ProviderStatus.NotReady); - var context = new EvaluationContextBuilder().Build(); - await repository.SetProviderAsync(providerMock, context); - providerMock.Received(1).InitializeAsync(context); - providerMock.DidNotReceive().ShutdownAsync(); - } - - [Fact] - public async Task AfterInitialization_Is_Invoked_For_Setting_Default_Provider() - { - var repository = new ProviderRepository(); - var providerMock = Substitute.For(); - providerMock.Status.Returns(ProviderStatus.NotReady); - var context = new EvaluationContextBuilder().Build(); - var callCount = 0; - await repository.SetProviderAsync(providerMock, context, afterInitSuccess: (theProvider) => - { - Assert.Equal(providerMock, theProvider); - callCount++; - return Task.CompletedTask; - }); - Assert.Equal(1, callCount); - } + var repository = new ProviderRepository(); + var provider = new NoOpFeatureProvider(); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(provider, context); + Assert.Equal(provider, repository.GetProvider()); + } - [Fact] - public async Task AfterError_Is_Invoked_If_Initialization_Errors_Default_Provider() - { - var repository = new ProviderRepository(); - var providerMock = Substitute.For(); - providerMock.Status.Returns(ProviderStatus.NotReady); - var context = new EvaluationContextBuilder().Build(); - providerMock.When(x => x.InitializeAsync(context)).Throw(new Exception("BAD THINGS")); - var callCount = 0; - Exception? receivedError = null; - await repository.SetProviderAsync(providerMock, context, afterInitError: (theProvider, error) => - { - Assert.Equal(providerMock, theProvider); - callCount++; - receivedError = error; - return Task.CompletedTask; - }); - Assert.Equal("BAD THINGS", receivedError?.Message); - Assert.Equal(1, callCount); - } - - [Theory] - [InlineData(ProviderStatus.Ready)] - [InlineData(ProviderStatus.Stale)] - [InlineData(ProviderStatus.Error)] - internal async Task Initialize_Is_Not_Called_For_Ready_Provider(ProviderStatus status) - { - var repository = new ProviderRepository(); - var providerMock = Substitute.For(); - providerMock.Status.Returns(status); - var context = new EvaluationContextBuilder().Build(); - await repository.SetProviderAsync(providerMock, context); - providerMock.DidNotReceive().InitializeAsync(context); - } - - [Theory] - [InlineData(ProviderStatus.Ready)] - [InlineData(ProviderStatus.Stale)] - [InlineData(ProviderStatus.Error)] - internal async Task AfterInitialize_Is_Not_Called_For_Ready_Provider(ProviderStatus status) - { - var repository = new ProviderRepository(); - var providerMock = Substitute.For(); - providerMock.Status.Returns(status); - var context = new EvaluationContextBuilder().Build(); - var callCount = 0; - await repository.SetProviderAsync(providerMock, context, afterInitSuccess: provider => - { - callCount++; - return Task.CompletedTask; - }); - Assert.Equal(0, callCount); - } + [Fact] + public async Task Initialization_Provider_Method_Is_Invoked_For_Setting_Default_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(providerMock, context); + providerMock.Received(1).InitializeAsync(context); + providerMock.DidNotReceive().ShutdownAsync(); + } - [Fact] - public async Task Replaced_Default_Provider_Is_Shutdown() + [Fact] + public async Task AfterInitialization_Is_Invoked_For_Setting_Default_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + await repository.SetProviderAsync(providerMock, context, afterInitSuccess: (theProvider) => { - var repository = new ProviderRepository(); - var provider1 = Substitute.For(); - provider1.Status.Returns(ProviderStatus.NotReady); - - var provider2 = Substitute.For(); - provider2.Status.Returns(ProviderStatus.NotReady); - - var context = new EvaluationContextBuilder().Build(); - await repository.SetProviderAsync(provider1, context); - await repository.SetProviderAsync(provider2, context); - provider1.Received(1).ShutdownAsync(); - provider2.DidNotReceive().ShutdownAsync(); - } - - [Fact] - public async Task Named_Provider_Provider_Is_Set_Without_Await() + Assert.Equal(providerMock, theProvider); + callCount++; + return Task.CompletedTask; + }); + Assert.Equal(1, callCount); + } + + [Fact] + public async Task AfterError_Is_Invoked_If_Initialization_Errors_Default_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + providerMock.When(x => x.InitializeAsync(context)).Throw(new Exception("BAD THINGS")); + var callCount = 0; + Exception? receivedError = null; + await repository.SetProviderAsync(providerMock, context, afterInitError: (theProvider, error) => { - var repository = new ProviderRepository(); - var provider = new NoOpFeatureProvider(); - var context = new EvaluationContextBuilder().Build(); + Assert.Equal(providerMock, theProvider); + callCount++; + receivedError = error; + return Task.CompletedTask; + }); + Assert.Equal("BAD THINGS", receivedError?.Message); + Assert.Equal(1, callCount); + } - await repository.SetProviderAsync("the-name", provider, context); - Assert.Equal(provider, repository.GetProvider("the-name")); - } + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + internal async Task Initialize_Is_Not_Called_For_Ready_Provider(ProviderStatus status) + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(status); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(providerMock, context); + providerMock.DidNotReceive().InitializeAsync(context); + } - [Fact] - public async Task Initialization_Provider_Method_Is_Invoked_For_Setting_Named_Provider() + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + internal async Task AfterInitialize_Is_Not_Called_For_Ready_Provider(ProviderStatus status) + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(status); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + await repository.SetProviderAsync(providerMock, context, afterInitSuccess: provider => { - var repository = new ProviderRepository(); - var providerMock = Substitute.For(); - providerMock.Status.Returns(ProviderStatus.NotReady); - var context = new EvaluationContextBuilder().Build(); - await repository.SetProviderAsync("the-name", providerMock, context); - providerMock.Received(1).InitializeAsync(context); - providerMock.DidNotReceive().ShutdownAsync(); - } - - [Fact] - public async Task AfterInitialization_Is_Invoked_For_Setting_Named_Provider() + callCount++; + return Task.CompletedTask; + }); + Assert.Equal(0, callCount); + } + + [Fact] + public async Task Replaced_Default_Provider_Is_Shutdown() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(provider1, context); + await repository.SetProviderAsync(provider2, context); + provider1.Received(1).ShutdownAsync(); + provider2.DidNotReceive().ShutdownAsync(); + } + + [Fact] + public async Task Named_Provider_Provider_Is_Set_Without_Await() + { + var repository = new ProviderRepository(); + var provider = new NoOpFeatureProvider(); + var context = new EvaluationContextBuilder().Build(); + + await repository.SetProviderAsync("the-name", provider, context); + Assert.Equal(provider, repository.GetProvider("the-name")); + } + + [Fact] + public async Task Initialization_Provider_Method_Is_Invoked_For_Setting_Named_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync("the-name", providerMock, context); + providerMock.Received(1).InitializeAsync(context); + providerMock.DidNotReceive().ShutdownAsync(); + } + + [Fact] + public async Task AfterInitialization_Is_Invoked_For_Setting_Named_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + await repository.SetProviderAsync("the-name", providerMock, context, afterInitSuccess: (theProvider) => { - var repository = new ProviderRepository(); - var providerMock = Substitute.For(); - providerMock.Status.Returns(ProviderStatus.NotReady); - var context = new EvaluationContextBuilder().Build(); - var callCount = 0; - await repository.SetProviderAsync("the-name", providerMock, context, afterInitSuccess: (theProvider) => - { - Assert.Equal(providerMock, theProvider); - callCount++; - return Task.CompletedTask; - }); - Assert.Equal(1, callCount); - } + Assert.Equal(providerMock, theProvider); + callCount++; + return Task.CompletedTask; + }); + Assert.Equal(1, callCount); + } - [Fact] - public async Task AfterError_Is_Invoked_If_Initialization_Errors_Named_Provider() + [Fact] + public async Task AfterError_Is_Invoked_If_Initialization_Errors_Named_Provider() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + providerMock.When(x => x.InitializeAsync(context)).Throw(new Exception("BAD THINGS")); + var callCount = 0; + Exception? receivedError = null; + await repository.SetProviderAsync("the-provider", providerMock, context, afterInitError: (theProvider, error) => { - var repository = new ProviderRepository(); - var providerMock = Substitute.For(); - providerMock.Status.Returns(ProviderStatus.NotReady); - var context = new EvaluationContextBuilder().Build(); - providerMock.When(x => x.InitializeAsync(context)).Throw(new Exception("BAD THINGS")); - var callCount = 0; - Exception? receivedError = null; - await repository.SetProviderAsync("the-provider", providerMock, context, afterInitError: (theProvider, error) => + Assert.Equal(providerMock, theProvider); + callCount++; + receivedError = error; + return Task.CompletedTask; + }); + Assert.Equal("BAD THINGS", receivedError?.Message); + Assert.Equal(1, callCount); + } + + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + internal async Task Initialize_Is_Not_Called_For_Ready_Named_Provider(ProviderStatus status) + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(status); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync("the-name", providerMock, context); + providerMock.DidNotReceive().InitializeAsync(context); + } + + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + internal async Task AfterInitialize_Is_Not_Called_For_Ready_Named_Provider(ProviderStatus status) + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(status); + var context = new EvaluationContextBuilder().Build(); + var callCount = 0; + await repository.SetProviderAsync("the-name", providerMock, context, + afterInitSuccess: provider => { - Assert.Equal(providerMock, theProvider); callCount++; - receivedError = error; return Task.CompletedTask; }); - Assert.Equal("BAD THINGS", receivedError?.Message); - Assert.Equal(1, callCount); - } - - [Theory] - [InlineData(ProviderStatus.Ready)] - [InlineData(ProviderStatus.Stale)] - [InlineData(ProviderStatus.Error)] - internal async Task Initialize_Is_Not_Called_For_Ready_Named_Provider(ProviderStatus status) - { - var repository = new ProviderRepository(); - var providerMock = Substitute.For(); - providerMock.Status.Returns(status); - var context = new EvaluationContextBuilder().Build(); - await repository.SetProviderAsync("the-name", providerMock, context); - providerMock.DidNotReceive().InitializeAsync(context); - } - - [Theory] - [InlineData(ProviderStatus.Ready)] - [InlineData(ProviderStatus.Stale)] - [InlineData(ProviderStatus.Error)] - internal async Task AfterInitialize_Is_Not_Called_For_Ready_Named_Provider(ProviderStatus status) - { - var repository = new ProviderRepository(); - var providerMock = Substitute.For(); - providerMock.Status.Returns(status); - var context = new EvaluationContextBuilder().Build(); - var callCount = 0; - await repository.SetProviderAsync("the-name", providerMock, context, - afterInitSuccess: provider => - { - callCount++; - return Task.CompletedTask; - }); - Assert.Equal(0, callCount); - } - - [Fact] - public async Task Replaced_Named_Provider_Is_Shutdown() - { - var repository = new ProviderRepository(); - var provider1 = Substitute.For(); - provider1.Status.Returns(ProviderStatus.NotReady); - - var provider2 = Substitute.For(); - provider2.Status.Returns(ProviderStatus.NotReady); - - var context = new EvaluationContextBuilder().Build(); - await repository.SetProviderAsync("the-name", provider1, context); - await repository.SetProviderAsync("the-name", provider2, context); - provider1.Received(1).ShutdownAsync(); - provider2.DidNotReceive().ShutdownAsync(); - } - - [Fact] - public async Task In_Use_Provider_Named_And_Default_Is_Not_Shutdown() - { - var repository = new ProviderRepository(); - var provider1 = Substitute.For(); - provider1.Status.Returns(ProviderStatus.NotReady); + Assert.Equal(0, callCount); + } - var provider2 = Substitute.For(); - provider2.Status.Returns(ProviderStatus.NotReady); + [Fact] + public async Task Replaced_Named_Provider_Is_Shutdown() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); + + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync("the-name", provider1, context); + await repository.SetProviderAsync("the-name", provider2, context); + provider1.Received(1).ShutdownAsync(); + provider2.DidNotReceive().ShutdownAsync(); + } - var context = new EvaluationContextBuilder().Build(); + [Fact] + public async Task In_Use_Provider_Named_And_Default_Is_Not_Shutdown() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); - await repository.SetProviderAsync(provider1, context); - await repository.SetProviderAsync("A", provider1, context); - // Provider one is replaced for "A", but not default. - await repository.SetProviderAsync("A", provider2, context); + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); - provider1.DidNotReceive().ShutdownAsync(); - } + var context = new EvaluationContextBuilder().Build(); - [Fact] - public async Task In_Use_Provider_Two_Named_Is_Not_Shutdown() - { - var repository = new ProviderRepository(); - var provider1 = Substitute.For(); - provider1.Status.Returns(ProviderStatus.NotReady); + await repository.SetProviderAsync(provider1, context); + await repository.SetProviderAsync("A", provider1, context); + // Provider one is replaced for "A", but not default. + await repository.SetProviderAsync("A", provider2, context); - var provider2 = Substitute.For(); - provider2.Status.Returns(ProviderStatus.NotReady); + provider1.DidNotReceive().ShutdownAsync(); + } - var context = new EvaluationContextBuilder().Build(); + [Fact] + public async Task In_Use_Provider_Two_Named_Is_Not_Shutdown() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); - await repository.SetProviderAsync("B", provider1, context); - await repository.SetProviderAsync("A", provider1, context); - // Provider one is replaced for "A", but not "B". - await repository.SetProviderAsync("A", provider2, context); + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); - provider1.DidNotReceive().ShutdownAsync(); - } + var context = new EvaluationContextBuilder().Build(); - [Fact] - public async Task When_All_Instances_Are_Removed_Shutdown_Is_Called() - { - var repository = new ProviderRepository(); - var provider1 = Substitute.For(); - provider1.Status.Returns(ProviderStatus.NotReady); + await repository.SetProviderAsync("B", provider1, context); + await repository.SetProviderAsync("A", provider1, context); + // Provider one is replaced for "A", but not "B". + await repository.SetProviderAsync("A", provider2, context); - var provider2 = Substitute.For(); - provider2.Status.Returns(ProviderStatus.NotReady); + provider1.DidNotReceive().ShutdownAsync(); + } - var context = new EvaluationContextBuilder().Build(); + [Fact] + public async Task When_All_Instances_Are_Removed_Shutdown_Is_Called() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); - await repository.SetProviderAsync("B", provider1, context); - await repository.SetProviderAsync("A", provider1, context); + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); - await repository.SetProviderAsync("A", provider2, context); - await repository.SetProviderAsync("B", provider2, context); + var context = new EvaluationContextBuilder().Build(); - provider1.Received(1).ShutdownAsync(); - } + await repository.SetProviderAsync("B", provider1, context); + await repository.SetProviderAsync("A", provider1, context); - [Fact] - public async Task Can_Get_Providers_By_Name() - { - var repository = new ProviderRepository(); - var provider1 = Substitute.For(); - provider1.Status.Returns(ProviderStatus.NotReady); + await repository.SetProviderAsync("A", provider2, context); + await repository.SetProviderAsync("B", provider2, context); - var provider2 = Substitute.For(); - provider2.Status.Returns(ProviderStatus.NotReady); + provider1.Received(1).ShutdownAsync(); + } - var context = new EvaluationContextBuilder().Build(); + [Fact] + public async Task Can_Get_Providers_By_Name() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); - await repository.SetProviderAsync("A", provider1, context); - await repository.SetProviderAsync("B", provider2, context); + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); - Assert.Equal(provider1, repository.GetProvider("A")); - Assert.Equal(provider2, repository.GetProvider("B")); - } + var context = new EvaluationContextBuilder().Build(); - [Fact] - public async Task Replaced_Named_Provider_Gets_Latest_Set() - { - var repository = new ProviderRepository(); - var provider1 = Substitute.For(); - provider1.Status.Returns(ProviderStatus.NotReady); + await repository.SetProviderAsync("A", provider1, context); + await repository.SetProviderAsync("B", provider2, context); - var provider2 = Substitute.For(); - provider2.Status.Returns(ProviderStatus.NotReady); + Assert.Equal(provider1, repository.GetProvider("A")); + Assert.Equal(provider2, repository.GetProvider("B")); + } - var context = new EvaluationContextBuilder().Build(); + [Fact] + public async Task Replaced_Named_Provider_Gets_Latest_Set() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); - await repository.SetProviderAsync("A", provider1, context); - await repository.SetProviderAsync("A", provider2, context); + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); - Assert.Equal(provider2, repository.GetProvider("A")); - } + var context = new EvaluationContextBuilder().Build(); - [Fact] - public async Task Can_Shutdown_All_Providers() - { - var repository = new ProviderRepository(); - var provider1 = Substitute.For(); - provider1.Status.Returns(ProviderStatus.NotReady); + await repository.SetProviderAsync("A", provider1, context); + await repository.SetProviderAsync("A", provider2, context); - var provider2 = Substitute.For(); - provider2.Status.Returns(ProviderStatus.NotReady); + Assert.Equal(provider2, repository.GetProvider("A")); + } - var provider3 = Substitute.For(); - provider3.Status.Returns(ProviderStatus.NotReady); + [Fact] + public async Task Can_Shutdown_All_Providers() + { + var repository = new ProviderRepository(); + var provider1 = Substitute.For(); + provider1.Status.Returns(ProviderStatus.NotReady); - var context = new EvaluationContextBuilder().Build(); + var provider2 = Substitute.For(); + provider2.Status.Returns(ProviderStatus.NotReady); - await repository.SetProviderAsync(provider1, context); - await repository.SetProviderAsync("provider1", provider1, context); - await repository.SetProviderAsync("provider2", provider2, context); - await repository.SetProviderAsync("provider2a", provider2, context); - await repository.SetProviderAsync("provider3", provider3, context); + var provider3 = Substitute.For(); + provider3.Status.Returns(ProviderStatus.NotReady); - await repository.ShutdownAsync(); + var context = new EvaluationContextBuilder().Build(); - provider1.Received(1).ShutdownAsync(); - provider2.Received(1).ShutdownAsync(); - provider3.Received(1).ShutdownAsync(); - } + await repository.SetProviderAsync(provider1, context); + await repository.SetProviderAsync("provider1", provider1, context); + await repository.SetProviderAsync("provider2", provider2, context); + await repository.SetProviderAsync("provider2a", provider2, context); + await repository.SetProviderAsync("provider3", provider3, context); - [Fact] - public async Task Setting_Same_Default_Provider_Has_No_Effect() - { - var repository = new ProviderRepository(); - var provider = Substitute.For(); - provider.Status.Returns(ProviderStatus.NotReady); - var context = new EvaluationContextBuilder().Build(); - await repository.SetProviderAsync(provider, context); - await repository.SetProviderAsync(provider, context); - - Assert.Equal(provider, repository.GetProvider()); - provider.Received(1).InitializeAsync(context); - provider.DidNotReceive().ShutdownAsync(); - } - - [Fact] - public async Task Setting_Null_Default_Provider_Has_No_Effect() - { - var repository = new ProviderRepository(); - var provider = Substitute.For(); - provider.Status.Returns(ProviderStatus.NotReady); - var context = new EvaluationContextBuilder().Build(); - await repository.SetProviderAsync(provider, context); - await repository.SetProviderAsync(null, context); - - Assert.Equal(provider, repository.GetProvider()); - provider.Received(1).InitializeAsync(context); - provider.DidNotReceive().ShutdownAsync(); - } - - [Fact] - public async Task Setting_Null_Named_Provider_Removes_It() - { - var repository = new ProviderRepository(); + await repository.ShutdownAsync(); + + provider1.Received(1).ShutdownAsync(); + provider2.Received(1).ShutdownAsync(); + provider3.Received(1).ShutdownAsync(); + } + + [Fact] + public async Task Setting_Same_Default_Provider_Has_No_Effect() + { + var repository = new ProviderRepository(); + var provider = Substitute.For(); + provider.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(provider, context); + await repository.SetProviderAsync(provider, context); + + Assert.Equal(provider, repository.GetProvider()); + provider.Received(1).InitializeAsync(context); + provider.DidNotReceive().ShutdownAsync(); + } + + [Fact] + public async Task Setting_Null_Default_Provider_Has_No_Effect() + { + var repository = new ProviderRepository(); + var provider = Substitute.For(); + provider.Status.Returns(ProviderStatus.NotReady); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(provider, context); + await repository.SetProviderAsync(null, context); + + Assert.Equal(provider, repository.GetProvider()); + provider.Received(1).InitializeAsync(context); + provider.DidNotReceive().ShutdownAsync(); + } + + [Fact] + public async Task Setting_Null_Named_Provider_Removes_It() + { + var repository = new ProviderRepository(); - var namedProvider = Substitute.For(); - namedProvider.Status.Returns(ProviderStatus.NotReady); + var namedProvider = Substitute.For(); + namedProvider.Status.Returns(ProviderStatus.NotReady); - var defaultProvider = Substitute.For(); - defaultProvider.Status.Returns(ProviderStatus.NotReady); + var defaultProvider = Substitute.For(); + defaultProvider.Status.Returns(ProviderStatus.NotReady); - var context = new EvaluationContextBuilder().Build(); - await repository.SetProviderAsync(defaultProvider, context); + var context = new EvaluationContextBuilder().Build(); + await repository.SetProviderAsync(defaultProvider, context); - await repository.SetProviderAsync("named-provider", namedProvider, context); - await repository.SetProviderAsync("named-provider", null, context); + await repository.SetProviderAsync("named-provider", namedProvider, context); + await repository.SetProviderAsync("named-provider", null, context); - Assert.Equal(defaultProvider, repository.GetProvider("named-provider")); - } + Assert.Equal(defaultProvider, repository.GetProvider("named-provider")); } } diff --git a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs index c575dc56c..8f1520a7b 100644 --- a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs @@ -7,249 +7,248 @@ using OpenFeature.Providers.Memory; using Xunit; -namespace OpenFeature.Tests.Providers.Memory -{ - [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] - public class InMemoryProviderTests - { - private FeatureProvider commonProvider; - - public InMemoryProviderTests() - { - var provider = new InMemoryProvider(new Dictionary(){ - { - "boolean-flag", new Flag( - variants: new Dictionary(){ - { "on", true }, - { "off", false } - }, - defaultVariant: "on" - ) - }, - { - "string-flag", new Flag( - variants: new Dictionary(){ - { "greeting", "hi" }, - { "parting", "bye" } - }, - defaultVariant: "greeting" - ) - }, - { - "integer-flag", new Flag( - variants: new Dictionary(){ - { "one", 1 }, - { "ten", 10 } - }, - defaultVariant: "ten" - ) - }, - { - "float-flag", new Flag( - variants: new Dictionary(){ - { "tenth", 0.1 }, - { "half", 0.5 } - }, - defaultVariant: "half" - ) - }, - { - "context-aware", new Flag( - variants: new Dictionary(){ - { "internal", "INTERNAL" }, - { "external", "EXTERNAL" } - }, - defaultVariant: "external", - (context) => { - if (context.GetValue("email").AsString?.Contains("@faas.com") == true) - { - return "internal"; - } - else return "external"; - } - ) - }, - { - "object-flag", new Flag( - variants: new Dictionary(){ - { "empty", new Value() }, - { "template", new Value(Structure.Builder() - .Set("showImages", true) - .Set("title", "Check out these pics!") - .Set("imagesPerPage", 100).Build() - ) - } - }, - defaultVariant: "template" - ) - }, - { - "invalid-flag", new Flag( - variants: new Dictionary(){ - { "on", true }, - { "off", false } - }, - defaultVariant: "missing" - ) - }, - { - "invalid-evaluator-flag", new Flag( - variants: new Dictionary(){ - { "on", true }, - { "off", false } - }, - defaultVariant: "on", - (context) => { - return "missing"; - } - ) - } - }); - - this.commonProvider = provider; - } - - [Fact] - public async Task GetBoolean_ShouldEvaluateWithReasonAndVariant() - { - ResolutionDetails details = await this.commonProvider.ResolveBooleanValueAsync("boolean-flag", false, EvaluationContext.Empty); - Assert.True(details.Value); - Assert.Equal(Reason.Static, details.Reason); - Assert.Equal("on", details.Variant); - } - - [Fact] - public async Task GetString_ShouldEvaluateWithReasonAndVariant() - { - ResolutionDetails details = await this.commonProvider.ResolveStringValueAsync("string-flag", "nope", EvaluationContext.Empty); - Assert.Equal("hi", details.Value); - Assert.Equal(Reason.Static, details.Reason); - Assert.Equal("greeting", details.Variant); - } - - [Fact] - public async Task GetInt_ShouldEvaluateWithReasonAndVariant() - { - ResolutionDetails details = await this.commonProvider.ResolveIntegerValueAsync("integer-flag", 13, EvaluationContext.Empty); - Assert.Equal(10, details.Value); - Assert.Equal(Reason.Static, details.Reason); - Assert.Equal("ten", details.Variant); - } - - [Fact] - public async Task GetDouble_ShouldEvaluateWithReasonAndVariant() - { - ResolutionDetails details = await this.commonProvider.ResolveDoubleValueAsync("float-flag", 13, EvaluationContext.Empty); - Assert.Equal(0.5, details.Value); - Assert.Equal(Reason.Static, details.Reason); - Assert.Equal("half", details.Variant); - } - - [Fact] - public async Task GetStruct_ShouldEvaluateWithReasonAndVariant() - { - ResolutionDetails details = await this.commonProvider.ResolveStructureValueAsync("object-flag", new Value(), EvaluationContext.Empty); - Assert.Equal(true, details.Value.AsStructure?["showImages"].AsBoolean); - Assert.Equal("Check out these pics!", details.Value.AsStructure?["title"].AsString); - Assert.Equal(100, details.Value.AsStructure?["imagesPerPage"].AsInteger); - Assert.Equal(Reason.Static, details.Reason); - Assert.Equal("template", details.Variant); - } - - [Fact] - public async Task GetString_ContextSensitive_ShouldEvaluateWithReasonAndVariant() - { - EvaluationContext context = EvaluationContext.Builder().Set("email", "me@faas.com").Build(); - ResolutionDetails details = await this.commonProvider.ResolveStringValueAsync("context-aware", "nope", context); - Assert.Equal("INTERNAL", details.Value); - Assert.Equal(Reason.TargetingMatch, details.Reason); - Assert.Equal("internal", details.Variant); - } - - [Fact] - public async Task EmptyFlags_ShouldWork() - { - var provider = new InMemoryProvider(); - await provider.UpdateFlagsAsync(); - Assert.Equal("InMemory", provider.GetMetadata().Name); - } - - [Fact] - public async Task MissingFlag_ShouldReturnFlagNotFoundEvaluationFlag() - { - // Act - var result = await this.commonProvider.ResolveBooleanValueAsync("missing-flag", false, EvaluationContext.Empty); - - // Assert - Assert.Equal(Reason.Error, result.Reason); - Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); - } - - [Fact] - public async Task MismatchedFlag_ShouldReturnTypeMismatchError() - { - // Act - var result = await this.commonProvider.ResolveStringValueAsync("boolean-flag", "nope", EvaluationContext.Empty); +namespace OpenFeature.Tests.Providers.Memory; - // Assert - Assert.Equal(Reason.Error, result.Reason); - Assert.Equal(ErrorType.TypeMismatch, result.ErrorType); - } - - [Fact] - public async Task MissingDefaultVariant_ShouldThrow() - { - await Assert.ThrowsAsync(() => this.commonProvider.ResolveBooleanValueAsync("invalid-flag", false, EvaluationContext.Empty)); - } - - [Fact] - public async Task MissingEvaluatedVariant_ShouldThrow() - { - await Assert.ThrowsAsync(() => this.commonProvider.ResolveBooleanValueAsync("invalid-evaluator-flag", false, EvaluationContext.Empty)); - } +[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] +public class InMemoryProviderTests +{ + private FeatureProvider commonProvider; - [Fact] - public async Task PutConfiguration_shouldUpdateConfigAndRunHandlers() - { - var provider = new InMemoryProvider(new Dictionary(){ + public InMemoryProviderTests() + { + var provider = new InMemoryProvider(new Dictionary(){ { - "old-flag", new Flag( + "boolean-flag", new Flag( variants: new Dictionary(){ { "on", true }, { "off", false } }, defaultVariant: "on" ) - }}); - - ResolutionDetails details = await provider.ResolveBooleanValueAsync("old-flag", false, EvaluationContext.Empty); - Assert.True(details.Value); - - // update flags - await provider.UpdateFlagsAsync(new Dictionary(){ + }, { - "new-flag", new Flag( + "string-flag", new Flag( variants: new Dictionary(){ { "greeting", "hi" }, { "parting", "bye" } }, defaultVariant: "greeting" ) - }}); + }, + { + "integer-flag", new Flag( + variants: new Dictionary(){ + { "one", 1 }, + { "ten", 10 } + }, + defaultVariant: "ten" + ) + }, + { + "float-flag", new Flag( + variants: new Dictionary(){ + { "tenth", 0.1 }, + { "half", 0.5 } + }, + defaultVariant: "half" + ) + }, + { + "context-aware", new Flag( + variants: new Dictionary(){ + { "internal", "INTERNAL" }, + { "external", "EXTERNAL" } + }, + defaultVariant: "external", + (context) => { + if (context.GetValue("email").AsString?.Contains("@faas.com") == true) + { + return "internal"; + } + else return "external"; + } + ) + }, + { + "object-flag", new Flag( + variants: new Dictionary(){ + { "empty", new Value() }, + { "template", new Value(Structure.Builder() + .Set("showImages", true) + .Set("title", "Check out these pics!") + .Set("imagesPerPage", 100).Build() + ) + } + }, + defaultVariant: "template" + ) + }, + { + "invalid-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "missing" + ) + }, + { + "invalid-evaluator-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "on", + (context) => { + return "missing"; + } + ) + } + }); + + this.commonProvider = provider; + } + + [Fact] + public async Task GetBoolean_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveBooleanValueAsync("boolean-flag", false, EvaluationContext.Empty); + Assert.True(details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("on", details.Variant); + } + + [Fact] + public async Task GetString_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveStringValueAsync("string-flag", "nope", EvaluationContext.Empty); + Assert.Equal("hi", details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("greeting", details.Variant); + } + + [Fact] + public async Task GetInt_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveIntegerValueAsync("integer-flag", 13, EvaluationContext.Empty); + Assert.Equal(10, details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("ten", details.Variant); + } + + [Fact] + public async Task GetDouble_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveDoubleValueAsync("float-flag", 13, EvaluationContext.Empty); + Assert.Equal(0.5, details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("half", details.Variant); + } + + [Fact] + public async Task GetStruct_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveStructureValueAsync("object-flag", new Value(), EvaluationContext.Empty); + Assert.Equal(true, details.Value.AsStructure?["showImages"].AsBoolean); + Assert.Equal("Check out these pics!", details.Value.AsStructure?["title"].AsString); + Assert.Equal(100, details.Value.AsStructure?["imagesPerPage"].AsInteger); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("template", details.Variant); + } + + [Fact] + public async Task GetString_ContextSensitive_ShouldEvaluateWithReasonAndVariant() + { + EvaluationContext context = EvaluationContext.Builder().Set("email", "me@faas.com").Build(); + ResolutionDetails details = await this.commonProvider.ResolveStringValueAsync("context-aware", "nope", context); + Assert.Equal("INTERNAL", details.Value); + Assert.Equal(Reason.TargetingMatch, details.Reason); + Assert.Equal("internal", details.Variant); + } + + [Fact] + public async Task EmptyFlags_ShouldWork() + { + var provider = new InMemoryProvider(); + await provider.UpdateFlagsAsync(); + Assert.Equal("InMemory", provider.GetMetadata().Name); + } + + [Fact] + public async Task MissingFlag_ShouldReturnFlagNotFoundEvaluationFlag() + { + // Act + var result = await this.commonProvider.ResolveBooleanValueAsync("missing-flag", false, EvaluationContext.Empty); + + // Assert + Assert.Equal(Reason.Error, result.Reason); + Assert.Equal(ErrorType.FlagNotFound, result.ErrorType); + } + + [Fact] + public async Task MismatchedFlag_ShouldReturnTypeMismatchError() + { + // Act + var result = await this.commonProvider.ResolveStringValueAsync("boolean-flag", "nope", EvaluationContext.Empty); + + // Assert + Assert.Equal(Reason.Error, result.Reason); + Assert.Equal(ErrorType.TypeMismatch, result.ErrorType); + } + + [Fact] + public async Task MissingDefaultVariant_ShouldThrow() + { + await Assert.ThrowsAsync(() => this.commonProvider.ResolveBooleanValueAsync("invalid-flag", false, EvaluationContext.Empty)); + } + + [Fact] + public async Task MissingEvaluatedVariant_ShouldThrow() + { + await Assert.ThrowsAsync(() => this.commonProvider.ResolveBooleanValueAsync("invalid-evaluator-flag", false, EvaluationContext.Empty)); + } + + [Fact] + public async Task PutConfiguration_shouldUpdateConfigAndRunHandlers() + { + var provider = new InMemoryProvider(new Dictionary(){ + { + "old-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "on" + ) + }}); + + ResolutionDetails details = await provider.ResolveBooleanValueAsync("old-flag", false, EvaluationContext.Empty); + Assert.True(details.Value); + + // update flags + await provider.UpdateFlagsAsync(new Dictionary(){ + { + "new-flag", new Flag( + variants: new Dictionary(){ + { "greeting", "hi" }, + { "parting", "bye" } + }, + defaultVariant: "greeting" + ) + }}); - var res = await provider.GetEventChannel().Reader.ReadAsync() as ProviderEventPayload; - Assert.Equal(ProviderEventTypes.ProviderConfigurationChanged, res?.Type); + var res = await provider.GetEventChannel().Reader.ReadAsync() as ProviderEventPayload; + Assert.Equal(ProviderEventTypes.ProviderConfigurationChanged, res?.Type); - // old flag should be gone - var oldFlag = await provider.ResolveBooleanValueAsync("old-flag", false, EvaluationContext.Empty); + // old flag should be gone + var oldFlag = await provider.ResolveBooleanValueAsync("old-flag", false, EvaluationContext.Empty); - Assert.Equal(Reason.Error, oldFlag.Reason); - Assert.Equal(ErrorType.FlagNotFound, oldFlag.ErrorType); + Assert.Equal(Reason.Error, oldFlag.Reason); + Assert.Equal(ErrorType.FlagNotFound, oldFlag.ErrorType); - // new flag should be present, old gone (defaults), handler run. - ResolutionDetails detailsAfter = await provider.ResolveStringValueAsync("new-flag", "nope", EvaluationContext.Empty); - Assert.True(details.Value); - Assert.Equal("hi", detailsAfter.Value); - } + // new flag should be present, old gone (defaults), handler run. + ResolutionDetails detailsAfter = await provider.ResolveStringValueAsync("new-flag", "nope", EvaluationContext.Empty); + Assert.True(details.Value); + Assert.Equal("hi", detailsAfter.Value); } } diff --git a/test/OpenFeature.Tests/StructureTests.cs b/test/OpenFeature.Tests/StructureTests.cs index 2dd22ae73..484e2b19d 100644 --- a/test/OpenFeature.Tests/StructureTests.cs +++ b/test/OpenFeature.Tests/StructureTests.cs @@ -4,117 +4,116 @@ using OpenFeature.Model; using Xunit; -namespace OpenFeature.Tests +namespace OpenFeature.Tests; + +public class StructureTests { - public class StructureTests + [Fact] + public void No_Arg_Should_Contain_Empty_Attributes() { - [Fact] - public void No_Arg_Should_Contain_Empty_Attributes() - { - Structure structure = Structure.Empty; - Assert.Equal(0, structure.Count); - Assert.Empty(structure.AsDictionary()); - } + Structure structure = Structure.Empty; + Assert.Equal(0, structure.Count); + Assert.Empty(structure.AsDictionary()); + } - [Fact] - public void Dictionary_Arg_Should_Contain_New_Dictionary() - { - string KEY = "key"; - IDictionary dictionary = new Dictionary() { { KEY, new Value(KEY) } }; - Structure structure = new Structure(dictionary); - Assert.Equal(KEY, structure.AsDictionary()[KEY].AsString); - Assert.NotSame(structure.AsDictionary(), dictionary); // should be a copy - } + [Fact] + public void Dictionary_Arg_Should_Contain_New_Dictionary() + { + string KEY = "key"; + IDictionary dictionary = new Dictionary() { { KEY, new Value(KEY) } }; + Structure structure = new Structure(dictionary); + Assert.Equal(KEY, structure.AsDictionary()[KEY].AsString); + Assert.NotSame(structure.AsDictionary(), dictionary); // should be a copy + } - [Fact] - public void Add_And_Get_Add_And_Return_Values() - { - String BOOL_KEY = "bool"; - String STRING_KEY = "string"; - String INT_KEY = "int"; - String DOUBLE_KEY = "double"; - String DATE_KEY = "date"; - String STRUCT_KEY = "struct"; - String LIST_KEY = "list"; - String VALUE_KEY = "value"; + [Fact] + public void Add_And_Get_Add_And_Return_Values() + { + String BOOL_KEY = "bool"; + String STRING_KEY = "string"; + String INT_KEY = "int"; + String DOUBLE_KEY = "double"; + String DATE_KEY = "date"; + String STRUCT_KEY = "struct"; + String LIST_KEY = "list"; + String VALUE_KEY = "value"; - bool BOOL_VAL = true; - String STRING_VAL = "val"; - int INT_VAL = 13; - double DOUBLE_VAL = .5; - DateTime DATE_VAL = DateTime.Now; - Structure STRUCT_VAL = Structure.Empty; - IList LIST_VAL = new List(); - Value VALUE_VAL = new Value(); + bool BOOL_VAL = true; + String STRING_VAL = "val"; + int INT_VAL = 13; + double DOUBLE_VAL = .5; + DateTime DATE_VAL = DateTime.Now; + Structure STRUCT_VAL = Structure.Empty; + IList LIST_VAL = new List(); + Value VALUE_VAL = new Value(); - var structureBuilder = Structure.Builder(); - structureBuilder.Set(BOOL_KEY, BOOL_VAL); - structureBuilder.Set(STRING_KEY, STRING_VAL); - structureBuilder.Set(INT_KEY, INT_VAL); - structureBuilder.Set(DOUBLE_KEY, DOUBLE_VAL); - structureBuilder.Set(DATE_KEY, DATE_VAL); - structureBuilder.Set(STRUCT_KEY, STRUCT_VAL); - structureBuilder.Set(LIST_KEY, ImmutableList.CreateRange(LIST_VAL)); - structureBuilder.Set(VALUE_KEY, VALUE_VAL); - var structure = structureBuilder.Build(); + var structureBuilder = Structure.Builder(); + structureBuilder.Set(BOOL_KEY, BOOL_VAL); + structureBuilder.Set(STRING_KEY, STRING_VAL); + structureBuilder.Set(INT_KEY, INT_VAL); + structureBuilder.Set(DOUBLE_KEY, DOUBLE_VAL); + structureBuilder.Set(DATE_KEY, DATE_VAL); + structureBuilder.Set(STRUCT_KEY, STRUCT_VAL); + structureBuilder.Set(LIST_KEY, ImmutableList.CreateRange(LIST_VAL)); + structureBuilder.Set(VALUE_KEY, VALUE_VAL); + var structure = structureBuilder.Build(); - Assert.Equal(BOOL_VAL, structure.GetValue(BOOL_KEY).AsBoolean); - Assert.Equal(STRING_VAL, structure.GetValue(STRING_KEY).AsString); - Assert.Equal(INT_VAL, structure.GetValue(INT_KEY).AsInteger); - Assert.Equal(DOUBLE_VAL, structure.GetValue(DOUBLE_KEY).AsDouble); - Assert.Equal(DATE_VAL, structure.GetValue(DATE_KEY).AsDateTime); - Assert.Equal(STRUCT_VAL, structure.GetValue(STRUCT_KEY).AsStructure); - Assert.Equal(LIST_VAL, structure.GetValue(LIST_KEY).AsList); - Assert.True(structure.GetValue(VALUE_KEY).IsNull); - } + Assert.Equal(BOOL_VAL, structure.GetValue(BOOL_KEY).AsBoolean); + Assert.Equal(STRING_VAL, structure.GetValue(STRING_KEY).AsString); + Assert.Equal(INT_VAL, structure.GetValue(INT_KEY).AsInteger); + Assert.Equal(DOUBLE_VAL, structure.GetValue(DOUBLE_KEY).AsDouble); + Assert.Equal(DATE_VAL, structure.GetValue(DATE_KEY).AsDateTime); + Assert.Equal(STRUCT_VAL, structure.GetValue(STRUCT_KEY).AsStructure); + Assert.Equal(LIST_VAL, structure.GetValue(LIST_KEY).AsList); + Assert.True(structure.GetValue(VALUE_KEY).IsNull); + } - [Fact] - public void TryGetValue_Should_Return_Value() - { - String KEY = "key"; - String VAL = "val"; + [Fact] + public void TryGetValue_Should_Return_Value() + { + String KEY = "key"; + String VAL = "val"; - var structure = Structure.Builder() - .Set(KEY, VAL).Build(); - Value? value; - Assert.True(structure.TryGetValue(KEY, out value)); - Assert.Equal(VAL, value?.AsString); - } + var structure = Structure.Builder() + .Set(KEY, VAL).Build(); + Value? value; + Assert.True(structure.TryGetValue(KEY, out value)); + Assert.Equal(VAL, value?.AsString); + } - [Fact] - public void Values_Should_Return_Values() - { - String KEY = "key"; - Value VAL = new Value("val"); + [Fact] + public void Values_Should_Return_Values() + { + String KEY = "key"; + Value VAL = new Value("val"); - var structure = Structure.Builder() - .Set(KEY, VAL).Build(); - Assert.Single(structure.Values); - } + var structure = Structure.Builder() + .Set(KEY, VAL).Build(); + Assert.Single(structure.Values); + } - [Fact] - public void Keys_Should_Return_Keys() - { - String KEY = "key"; - Value VAL = new Value("val"); + [Fact] + public void Keys_Should_Return_Keys() + { + String KEY = "key"; + Value VAL = new Value("val"); - var structure = Structure.Builder() - .Set(KEY, VAL).Build(); - Assert.Single(structure.Keys); - Assert.Equal(0, structure.Keys.IndexOf(KEY)); - } + var structure = Structure.Builder() + .Set(KEY, VAL).Build(); + Assert.Single(structure.Keys); + Assert.Equal(0, structure.Keys.IndexOf(KEY)); + } - [Fact] - public void GetEnumerator_Should_Return_Enumerator() - { - string KEY = "key"; - string VAL = "val"; + [Fact] + public void GetEnumerator_Should_Return_Enumerator() + { + string KEY = "key"; + string VAL = "val"; - var structure = Structure.Builder() - .Set(KEY, VAL).Build(); - IEnumerator> enumerator = structure.GetEnumerator(); - enumerator.MoveNext(); - Assert.Equal(VAL, enumerator.Current.Value.AsString); - } + var structure = Structure.Builder() + .Set(KEY, VAL).Build(); + IEnumerator> enumerator = structure.GetEnumerator(); + enumerator.MoveNext(); + Assert.Equal(VAL, enumerator.Current.Value.AsString); } } diff --git a/test/OpenFeature.Tests/TestImplementations.cs b/test/OpenFeature.Tests/TestImplementations.cs index df738efe4..4c298c880 100644 --- a/test/OpenFeature.Tests/TestImplementations.cs +++ b/test/OpenFeature.Tests/TestImplementations.cs @@ -6,151 +6,150 @@ using OpenFeature.Constant; using OpenFeature.Model; -namespace OpenFeature.Tests -{ - public class TestHookNoOverride : Hook - { - } +namespace OpenFeature.Tests; - public class TestHook : Hook - { - private int _beforeCallCount; - public int BeforeCallCount { get => this._beforeCallCount; } +public class TestHookNoOverride : Hook +{ +} - private int _afterCallCount; - public int AfterCallCount { get => this._afterCallCount; } +public class TestHook : Hook +{ + private int _beforeCallCount; + public int BeforeCallCount { get => this._beforeCallCount; } - private int _errorCallCount; - public int ErrorCallCount { get => this._errorCallCount; } + private int _afterCallCount; + public int AfterCallCount { get => this._afterCallCount; } - private int _finallyCallCount; - public int FinallyCallCount { get => this._finallyCallCount; } + private int _errorCallCount; + public int ErrorCallCount { get => this._errorCallCount; } - public override ValueTask BeforeAsync(HookContext context, - IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref this._beforeCallCount); - return new ValueTask(EvaluationContext.Empty); - } + private int _finallyCallCount; + public int FinallyCallCount { get => this._finallyCallCount; } - public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, - IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref this._afterCallCount); - return new ValueTask(); - } + public override ValueTask BeforeAsync(HookContext context, + IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._beforeCallCount); + return new ValueTask(EvaluationContext.Empty); + } - public override ValueTask ErrorAsync(HookContext context, Exception error, - IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref this._errorCallCount); - return new ValueTask(); - } + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, + IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._afterCallCount); + return new ValueTask(); + } - public override ValueTask FinallyAsync(HookContext context, - FlagEvaluationDetails evaluationDetails, - IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref this._finallyCallCount); - return new ValueTask(); - } + public override ValueTask ErrorAsync(HookContext context, Exception error, + IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._errorCallCount); + return new ValueTask(); } - public class TestProvider : FeatureProvider + public override ValueTask FinallyAsync(HookContext context, + FlagEvaluationDetails evaluationDetails, + IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) { - private readonly List _hooks = new List(); + Interlocked.Increment(ref this._finallyCallCount); + return new ValueTask(); + } +} - public static string DefaultName = "test-provider"; - private readonly List> TrackingInvocations = []; +public class TestProvider : FeatureProvider +{ + private readonly List _hooks = new List(); - public string? Name { get; set; } + public static string DefaultName = "test-provider"; + private readonly List> TrackingInvocations = []; - public void AddHook(Hook hook) => this._hooks.Add(hook); + public string? Name { get; set; } - public override IImmutableList GetProviderHooks() => this._hooks.ToImmutableList(); - private Exception? initException = null; - private int initDelay = 0; + public void AddHook(Hook hook) => this._hooks.Add(hook); - public TestProvider() - { - this.Name = DefaultName; - } + public override IImmutableList GetProviderHooks() => this._hooks.ToImmutableList(); + private Exception? initException = null; + private int initDelay = 0; - /// - /// A provider used for testing. - /// - /// the name of the provider. - /// Optional exception to throw during init. - /// - public TestProvider(string? name, Exception? initException = null, int initDelay = 0) - { - this.Name = string.IsNullOrEmpty(name) ? DefaultName : name; - this.initException = initException; - this.initDelay = initDelay; - } + public TestProvider() + { + this.Name = DefaultName; + } - public ImmutableList> GetTrackingInvocations() - { - return this.TrackingInvocations.ToImmutableList(); - } + /// + /// A provider used for testing. + /// + /// the name of the provider. + /// Optional exception to throw during init. + /// + public TestProvider(string? name, Exception? initException = null, int initDelay = 0) + { + this.Name = string.IsNullOrEmpty(name) ? DefaultName : name; + this.initException = initException; + this.initDelay = initDelay; + } - public void Reset() - { - this.TrackingInvocations.Clear(); - } + public ImmutableList> GetTrackingInvocations() + { + return this.TrackingInvocations.ToImmutableList(); + } - public override Metadata GetMetadata() - { - return new Metadata(this.Name); - } + public void Reset() + { + this.TrackingInvocations.Clear(); + } - public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, - EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(new ResolutionDetails(flagKey, !defaultValue)); - } + public override Metadata GetMetadata() + { + return new Metadata(this.Name); + } - public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, - EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); - } + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new ResolutionDetails(flagKey, !defaultValue)); + } - public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, - EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); - } + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } - public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, - EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); - } + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } - public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, - EvaluationContext? context = null, CancellationToken cancellationToken = default) - { - return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); - } + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } - public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) - { - await Task.Delay(this.initDelay).ConfigureAwait(false); - if (this.initException != null) - { - throw this.initException; - } - } + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + } - public override void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) + { + await Task.Delay(this.initDelay).ConfigureAwait(false); + if (this.initException != null) { - this.TrackingInvocations.Add(new Tuple(trackingEventName, evaluationContext, trackingEventDetails)); + throw this.initException; } + } - internal ValueTask SendEventAsync(ProviderEventTypes eventType, CancellationToken cancellationToken = default) - { - return this.EventChannel.Writer.WriteAsync(new ProviderEventPayload { Type = eventType, ProviderName = this.GetMetadata().Name, }, cancellationToken); - } + public override void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + this.TrackingInvocations.Add(new Tuple(trackingEventName, evaluationContext, trackingEventDetails)); + } + + internal ValueTask SendEventAsync(ProviderEventTypes eventType, CancellationToken cancellationToken = default) + { + return this.EventChannel.Writer.WriteAsync(new ProviderEventPayload { Type = eventType, ProviderName = this.GetMetadata().Name, }, cancellationToken); } } diff --git a/test/OpenFeature.Tests/TestUtilsTest.cs b/test/OpenFeature.Tests/TestUtilsTest.cs index b65a91f58..9f5cde861 100644 --- a/test/OpenFeature.Tests/TestUtilsTest.cs +++ b/test/OpenFeature.Tests/TestUtilsTest.cs @@ -3,21 +3,20 @@ using System.Threading.Tasks; using Xunit; -namespace OpenFeature.Tests +namespace OpenFeature.Tests; + +[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] +public class TestUtilsTest { - [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] - public class TestUtilsTest + [Fact] + public async Task Should_Fail_If_Assertion_Fails() { - [Fact] - public async Task Should_Fail_If_Assertion_Fails() - { - await Assert.ThrowsAnyAsync(() => Utils.AssertUntilAsync(_ => Assert.True(1.Equals(2)), 100, 10)); - } + await Assert.ThrowsAnyAsync(() => Utils.AssertUntilAsync(_ => Assert.True(1.Equals(2)), 100, 10)); + } - [Fact] - public async Task Should_Pass_If_Assertion_Fails() - { - await Utils.AssertUntilAsync(_ => Assert.True(1.Equals(1))); - } + [Fact] + public async Task Should_Pass_If_Assertion_Fails() + { + await Utils.AssertUntilAsync(_ => Assert.True(1.Equals(1))); } } diff --git a/test/OpenFeature.Tests/ValueTests.cs b/test/OpenFeature.Tests/ValueTests.cs index ec623a684..34a2eb6b1 100644 --- a/test/OpenFeature.Tests/ValueTests.cs +++ b/test/OpenFeature.Tests/ValueTests.cs @@ -3,235 +3,234 @@ using OpenFeature.Model; using Xunit; -namespace OpenFeature.Tests +namespace OpenFeature.Tests; + +public class ValueTests { - public class ValueTests + class Foo { - class Foo - { - } - - [Fact] - public void No_Arg_Should_Contain_Null() - { - Value value = new Value(); - Assert.True(value.IsNull); - } + } - [Fact] - public void Object_Arg_Should_Contain_Object() - { - // int is a special case, see Int_Object_Arg_Should_Contain_Object() - IList list = new List() - { - true, - "val", - .5, - Structure.Empty, - new List(), - DateTime.Now - }; - - int i = 0; - foreach (Object l in list) - { - Value value = new Value(l); - Assert.Equal(list[i], value.AsObject); - i++; - } - } + [Fact] + public void No_Arg_Should_Contain_Null() + { + Value value = new Value(); + Assert.True(value.IsNull); + } - [Fact] - public void Int_Object_Arg_Should_Contain_Object() + [Fact] + public void Object_Arg_Should_Contain_Object() + { + // int is a special case, see Int_Object_Arg_Should_Contain_Object() + IList list = new List() { - try - { - int innerValue = 1; - Value value = new Value(innerValue); - Assert.True(value.IsNumber); - Assert.Equal(innerValue, value.AsInteger); - } - catch (Exception) - { - Assert.Fail("Expected no exception."); - } - } + true, + "val", + .5, + Structure.Empty, + new List(), + DateTime.Now + }; - [Fact] - public void Invalid_Object_Should_Throw() + int i = 0; + foreach (Object l in list) { - Assert.Throws(() => - { - return new Value(new Foo()); - }); + Value value = new Value(l); + Assert.Equal(list[i], value.AsObject); + i++; } + } - [Fact] - public void Bool_Arg_Should_Contain_Bool() + [Fact] + public void Int_Object_Arg_Should_Contain_Object() + { + try { - bool innerValue = true; + int innerValue = 1; Value value = new Value(innerValue); - Assert.True(value.IsBoolean); - Assert.Equal(innerValue, value.AsBoolean); + Assert.True(value.IsNumber); + Assert.Equal(innerValue, value.AsInteger); } - - [Fact] - public void Numeric_Arg_Should_Return_Double_Or_Int() + catch (Exception) { - double innerDoubleValue = .75; - Value doubleValue = new Value(innerDoubleValue); - Assert.True(doubleValue.IsNumber); - Assert.Equal(1, doubleValue.AsInteger); // should be rounded - Assert.Equal(.75, doubleValue.AsDouble); - - int innerIntValue = 100; - Value intValue = new Value(innerIntValue); - Assert.True(intValue.IsNumber); - Assert.Equal(innerIntValue, intValue.AsInteger); - Assert.Equal(innerIntValue, intValue.AsDouble); + Assert.Fail("Expected no exception."); } + } - [Fact] - public void String_Arg_Should_Contain_String() + [Fact] + public void Invalid_Object_Should_Throw() + { + Assert.Throws(() => { - string innerValue = "hi!"; - Value value = new Value(innerValue); - Assert.True(value.IsString); - Assert.Equal(innerValue, value.AsString); - } + return new Value(new Foo()); + }); + } - [Fact] - public void DateTime_Arg_Should_Contain_DateTime() - { - DateTime innerValue = new DateTime(); - Value value = new Value(innerValue); - Assert.True(value.IsDateTime); - Assert.Equal(innerValue, value.AsDateTime); - } + [Fact] + public void Bool_Arg_Should_Contain_Bool() + { + bool innerValue = true; + Value value = new Value(innerValue); + Assert.True(value.IsBoolean); + Assert.Equal(innerValue, value.AsBoolean); + } - [Fact] - public void Structure_Arg_Should_Contain_Structure() - { - string INNER_KEY = "key"; - string INNER_VALUE = "val"; - Structure innerValue = Structure.Builder().Set(INNER_KEY, INNER_VALUE).Build(); - Value value = new Value(innerValue); - Assert.True(value.IsStructure); - Assert.Equal(INNER_VALUE, value.AsStructure?.GetValue(INNER_KEY).AsString); - } + [Fact] + public void Numeric_Arg_Should_Return_Double_Or_Int() + { + double innerDoubleValue = .75; + Value doubleValue = new Value(innerDoubleValue); + Assert.True(doubleValue.IsNumber); + Assert.Equal(1, doubleValue.AsInteger); // should be rounded + Assert.Equal(.75, doubleValue.AsDouble); + + int innerIntValue = 100; + Value intValue = new Value(innerIntValue); + Assert.True(intValue.IsNumber); + Assert.Equal(innerIntValue, intValue.AsInteger); + Assert.Equal(innerIntValue, intValue.AsDouble); + } - [Fact] - public void List_Arg_Should_Contain_List() - { - string ITEM_VALUE = "val"; - IList innerValue = new List() { new Value(ITEM_VALUE) }; - Value value = new Value(innerValue); - Assert.True(value.IsList); - Assert.Equal(ITEM_VALUE, value.AsList?[0].AsString); - } + [Fact] + public void String_Arg_Should_Contain_String() + { + string innerValue = "hi!"; + Value value = new Value(innerValue); + Assert.True(value.IsString); + Assert.Equal(innerValue, value.AsString); + } - [Fact] - public void Constructor_WhenCalledWithAnotherValue_CopiesInnerValue() - { - // Arrange - var originalValue = new Value("testValue"); + [Fact] + public void DateTime_Arg_Should_Contain_DateTime() + { + DateTime innerValue = new DateTime(); + Value value = new Value(innerValue); + Assert.True(value.IsDateTime); + Assert.Equal(innerValue, value.AsDateTime); + } - // Act - var copiedValue = new Value(originalValue); + [Fact] + public void Structure_Arg_Should_Contain_Structure() + { + string INNER_KEY = "key"; + string INNER_VALUE = "val"; + Structure innerValue = Structure.Builder().Set(INNER_KEY, INNER_VALUE).Build(); + Value value = new Value(innerValue); + Assert.True(value.IsStructure); + Assert.Equal(INNER_VALUE, value.AsStructure?.GetValue(INNER_KEY).AsString); + } - // Assert - Assert.Equal(originalValue.AsObject, copiedValue.AsObject); - } + [Fact] + public void List_Arg_Should_Contain_List() + { + string ITEM_VALUE = "val"; + IList innerValue = new List() { new Value(ITEM_VALUE) }; + Value value = new Value(innerValue); + Assert.True(value.IsList); + Assert.Equal(ITEM_VALUE, value.AsList?[0].AsString); + } - [Fact] - public void AsInteger_WhenCalledWithNonIntegerInnerValue_ReturnsNull() - { - // Arrange - var value = new Value("test"); + [Fact] + public void Constructor_WhenCalledWithAnotherValue_CopiesInnerValue() + { + // Arrange + var originalValue = new Value("testValue"); - // Act - var actualValue = value.AsInteger; + // Act + var copiedValue = new Value(originalValue); - // Assert - Assert.Null(actualValue); - } + // Assert + Assert.Equal(originalValue.AsObject, copiedValue.AsObject); + } - [Fact] - public void AsBoolean_WhenCalledWithNonBooleanInnerValue_ReturnsNull() - { - // Arrange - var value = new Value("test"); + [Fact] + public void AsInteger_WhenCalledWithNonIntegerInnerValue_ReturnsNull() + { + // Arrange + var value = new Value("test"); - // Act - var actualValue = value.AsBoolean; + // Act + var actualValue = value.AsInteger; - // Assert - Assert.Null(actualValue); - } + // Assert + Assert.Null(actualValue); + } - [Fact] - public void AsDouble_WhenCalledWithNonDoubleInnerValue_ReturnsNull() - { - // Arrange - var value = new Value("test"); + [Fact] + public void AsBoolean_WhenCalledWithNonBooleanInnerValue_ReturnsNull() + { + // Arrange + var value = new Value("test"); - // Act - var actualValue = value.AsDouble; + // Act + var actualValue = value.AsBoolean; - // Assert - Assert.Null(actualValue); - } + // Assert + Assert.Null(actualValue); + } - [Fact] - public void AsString_WhenCalledWithNonStringInnerValue_ReturnsNull() - { - // Arrange - var value = new Value(123); + [Fact] + public void AsDouble_WhenCalledWithNonDoubleInnerValue_ReturnsNull() + { + // Arrange + var value = new Value("test"); - // Act - var actualValue = value.AsString; + // Act + var actualValue = value.AsDouble; - // Assert - Assert.Null(actualValue); - } + // Assert + Assert.Null(actualValue); + } - [Fact] - public void AsStructure_WhenCalledWithNonStructureInnerValue_ReturnsNull() - { - // Arrange - var value = new Value("test"); + [Fact] + public void AsString_WhenCalledWithNonStringInnerValue_ReturnsNull() + { + // Arrange + var value = new Value(123); - // Act - var actualValue = value.AsStructure; + // Act + var actualValue = value.AsString; - // Assert - Assert.Null(actualValue); - } + // Assert + Assert.Null(actualValue); + } - [Fact] - public void AsList_WhenCalledWithNonListInnerValue_ReturnsNull() - { - // Arrange - var value = new Value("test"); + [Fact] + public void AsStructure_WhenCalledWithNonStructureInnerValue_ReturnsNull() + { + // Arrange + var value = new Value("test"); - // Act - var actualValue = value.AsList; + // Act + var actualValue = value.AsStructure; - // Assert - Assert.Null(actualValue); - } + // Assert + Assert.Null(actualValue); + } - [Fact] - public void AsDateTime_WhenCalledWithNonDateTimeInnerValue_ReturnsNull() - { - // Arrange - var value = new Value("test"); + [Fact] + public void AsList_WhenCalledWithNonListInnerValue_ReturnsNull() + { + // Arrange + var value = new Value("test"); - // Act - var actualValue = value.AsDateTime; + // Act + var actualValue = value.AsList; - // Assert - Assert.Null(actualValue); - } + // Assert + Assert.Null(actualValue); + } + + [Fact] + public void AsDateTime_WhenCalledWithNonDateTimeInnerValue_ReturnsNull() + { + // Arrange + var value = new Value("test"); + + // Act + var actualValue = value.AsDateTime; + + // Assert + Assert.Null(actualValue); } } From eb688c412983511c7ec0744df95e4a113f610c5f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 24 Apr 2025 20:42:46 +0100 Subject: [PATCH 015/126] chore(deps): update spec digest to 2ba05d8 (#452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | spec | digest | `36944c6` -> `2ba05d8` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index 36944c68d..2ba05d89b 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 36944c68dd60e874661f5efd022ccafb9af76535 +Subproject commit 2ba05d89b5139fbe247018049e0bbff4e584463e From 42ab5368d3d8f874f175ab9ad3077f177a592398 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Fri, 25 Apr 2025 07:07:48 +0100 Subject: [PATCH 016/126] chore: add NuGet auditing (#454) ## This PR - NuGet offers built in functionality for analyzing packages that are included in a software project. These changes will ensure msbuild outputs warnings when dependencies are flagged up with vulnerabilities. ### Related Issues Fixes #444 ### Notes ### Follow-up Tasks ### How to test Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- build/Common.props | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build/Common.props b/build/Common.props index 5cc125150..1c769bcf7 100644 --- a/build/Common.props +++ b/build/Common.props @@ -8,6 +8,9 @@ EnableGenerateDocumentationFile enable true + true + all + low From e0ec8ca28303b7df71699063b02b6967cdc37bcd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 25 Apr 2025 08:22:28 +0100 Subject: [PATCH 017/126] chore(deps): update spec digest to d27e000 (#455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | spec | digest | `2ba05d8` -> `d27e000` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index 2ba05d89b..d27e000b6 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 2ba05d89b5139fbe247018049e0bbff4e584463e +Subproject commit d27e000b6c839b533ff4f3ea0f5b1bfc024fb534 From 7318b8126df9f0ddd5651fdd9fe32da2e4819290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 25 Apr 2025 15:11:34 +0100 Subject: [PATCH 018/126] docs: Update README with spec version (#437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR This pull request includes a small change to the `README.md` file. The change updates the specification badge to reflect the new version 0.8.0. ### Related Issues Fixes #204 Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b3d6ae985..daad6ad4a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ -[![Specification](https://img.shields.io/static/v1?label=specification&message=v0.7.0&color=yellow&style=for-the-badge)](https://github.com/open-feature/spec/releases/tag/v0.7.0) +[![Specification](https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge)](https://github.com/open-feature/spec/releases/tag/v0.8.0) [ ![Release](https://img.shields.io/static/v1?label=release&message=v2.4.0&color=blue&style=for-the-badge) ](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.4.0) From 0821b3b0b8ffb45a16c1fe2e8bc0d1a8cc0e8c9f Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Mon, 28 Apr 2025 11:16:28 -0400 Subject: [PATCH 019/126] chore(main): release 2.5.0 (#435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :robot: I have created a release *beep* *boop* --- ## [2.5.0](https://github.com/open-feature/dotnet-sdk/compare/v2.4.0...v2.5.0) (2025-04-25) ### ✨ New Features * Add support for hook data. ([#387](https://github.com/open-feature/dotnet-sdk/issues/387)) ([4563512](https://github.com/open-feature/dotnet-sdk/commit/456351216ce9113d84b56d0bce1dad39430a26cd)) ### 🧹 Chore * add NuGet auditing ([#454](https://github.com/open-feature/dotnet-sdk/issues/454)) ([42ab536](https://github.com/open-feature/dotnet-sdk/commit/42ab5368d3d8f874f175ab9ad3077f177a592398)) * Change file scoped namespaces and cleanup job ([#453](https://github.com/open-feature/dotnet-sdk/issues/453)) ([1e74a04](https://github.com/open-feature/dotnet-sdk/commit/1e74a04f2b76c128a09c95dfd0b06803f2ef77bf)) * **deps:** update codecov/codecov-action action to v5.4.2 ([#432](https://github.com/open-feature/dotnet-sdk/issues/432)) ([c692ec2](https://github.com/open-feature/dotnet-sdk/commit/c692ec2a26eb4007ff428e54eaa67ea22fd20728)) * **deps:** update github/codeql-action digest to 28deaed ([#446](https://github.com/open-feature/dotnet-sdk/issues/446)) ([dfecd0c](https://github.com/open-feature/dotnet-sdk/commit/dfecd0c6a4467e5c1afe481e785e3e0f179beb25)) * **deps:** update spec digest to 18cde17 ([#395](https://github.com/open-feature/dotnet-sdk/issues/395)) ([5608dfb](https://github.com/open-feature/dotnet-sdk/commit/5608dfbd441b99531add8e89ad842ea9d613f707)) * **deps:** update spec digest to 2ba05d8 ([#452](https://github.com/open-feature/dotnet-sdk/issues/452)) ([eb688c4](https://github.com/open-feature/dotnet-sdk/commit/eb688c412983511c7ec0744df95e4a113f610c5f)) * **deps:** update spec digest to 36944c6 ([#450](https://github.com/open-feature/dotnet-sdk/issues/450)) ([e162169](https://github.com/open-feature/dotnet-sdk/commit/e162169af0b5518f12527a8601d6dfcdf379b4f7)) * **deps:** update spec digest to d27e000 ([#455](https://github.com/open-feature/dotnet-sdk/issues/455)) ([e0ec8ca](https://github.com/open-feature/dotnet-sdk/commit/e0ec8ca28303b7df71699063b02b6967cdc37bcd)) * packages read in release please ([1acc00f](https://github.com/open-feature/dotnet-sdk/commit/1acc00fa7a6a38152d97fd7efc9f7e8befb1c3ed)) * update release permissions ([d0bf40b](https://github.com/open-feature/dotnet-sdk/commit/d0bf40b9b40adc57a2a008a9497098b3cd1a05a7)) * **workflows:** Add permissions for contents and pull-requests ([#439](https://github.com/open-feature/dotnet-sdk/issues/439)) ([568722a](https://github.com/open-feature/dotnet-sdk/commit/568722a4ab1f863d8509dc4a172ac9c29f267825)) ### 📚 Documentation * update documentation on SetProviderAsync ([#449](https://github.com/open-feature/dotnet-sdk/issues/449)) ([858b286](https://github.com/open-feature/dotnet-sdk/commit/858b286dba2313239141c20ec6770504d340fbe0)) * Update README with spec version ([#437](https://github.com/open-feature/dotnet-sdk/issues/437)) ([7318b81](https://github.com/open-feature/dotnet-sdk/commit/7318b8126df9f0ddd5651fdd9fe32da2e4819290)), closes [#204](https://github.com/open-feature/dotnet-sdk/issues/204) ### 🔄 Refactoring * InMemoryProvider throwing when types mismatched ([#442](https://github.com/open-feature/dotnet-sdk/issues/442)) ([8ecf50d](https://github.com/open-feature/dotnet-sdk/commit/8ecf50db2cab3a266de5c6c5216714570cfc6a52)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ README.md | 4 ++-- build/Common.prod.props | 2 +- version.txt | 2 +- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a549f59d0..78baf5bf9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.4.0" + ".": "2.5.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1219039fa..beebbd13c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [2.5.0](https://github.com/open-feature/dotnet-sdk/compare/v2.4.0...v2.5.0) (2025-04-25) + + +### ✨ New Features + +* Add support for hook data. ([#387](https://github.com/open-feature/dotnet-sdk/issues/387)) ([4563512](https://github.com/open-feature/dotnet-sdk/commit/456351216ce9113d84b56d0bce1dad39430a26cd)) + + +### 🧹 Chore + +* add NuGet auditing ([#454](https://github.com/open-feature/dotnet-sdk/issues/454)) ([42ab536](https://github.com/open-feature/dotnet-sdk/commit/42ab5368d3d8f874f175ab9ad3077f177a592398)) +* Change file scoped namespaces and cleanup job ([#453](https://github.com/open-feature/dotnet-sdk/issues/453)) ([1e74a04](https://github.com/open-feature/dotnet-sdk/commit/1e74a04f2b76c128a09c95dfd0b06803f2ef77bf)) +* **deps:** update codecov/codecov-action action to v5.4.2 ([#432](https://github.com/open-feature/dotnet-sdk/issues/432)) ([c692ec2](https://github.com/open-feature/dotnet-sdk/commit/c692ec2a26eb4007ff428e54eaa67ea22fd20728)) +* **deps:** update github/codeql-action digest to 28deaed ([#446](https://github.com/open-feature/dotnet-sdk/issues/446)) ([dfecd0c](https://github.com/open-feature/dotnet-sdk/commit/dfecd0c6a4467e5c1afe481e785e3e0f179beb25)) +* **deps:** update spec digest to 18cde17 ([#395](https://github.com/open-feature/dotnet-sdk/issues/395)) ([5608dfb](https://github.com/open-feature/dotnet-sdk/commit/5608dfbd441b99531add8e89ad842ea9d613f707)) +* **deps:** update spec digest to 2ba05d8 ([#452](https://github.com/open-feature/dotnet-sdk/issues/452)) ([eb688c4](https://github.com/open-feature/dotnet-sdk/commit/eb688c412983511c7ec0744df95e4a113f610c5f)) +* **deps:** update spec digest to 36944c6 ([#450](https://github.com/open-feature/dotnet-sdk/issues/450)) ([e162169](https://github.com/open-feature/dotnet-sdk/commit/e162169af0b5518f12527a8601d6dfcdf379b4f7)) +* **deps:** update spec digest to d27e000 ([#455](https://github.com/open-feature/dotnet-sdk/issues/455)) ([e0ec8ca](https://github.com/open-feature/dotnet-sdk/commit/e0ec8ca28303b7df71699063b02b6967cdc37bcd)) +* packages read in release please ([1acc00f](https://github.com/open-feature/dotnet-sdk/commit/1acc00fa7a6a38152d97fd7efc9f7e8befb1c3ed)) +* update release permissions ([d0bf40b](https://github.com/open-feature/dotnet-sdk/commit/d0bf40b9b40adc57a2a008a9497098b3cd1a05a7)) +* **workflows:** Add permissions for contents and pull-requests ([#439](https://github.com/open-feature/dotnet-sdk/issues/439)) ([568722a](https://github.com/open-feature/dotnet-sdk/commit/568722a4ab1f863d8509dc4a172ac9c29f267825)) + + +### 📚 Documentation + +* update documentation on SetProviderAsync ([#449](https://github.com/open-feature/dotnet-sdk/issues/449)) ([858b286](https://github.com/open-feature/dotnet-sdk/commit/858b286dba2313239141c20ec6770504d340fbe0)) +* Update README with spec version ([#437](https://github.com/open-feature/dotnet-sdk/issues/437)) ([7318b81](https://github.com/open-feature/dotnet-sdk/commit/7318b8126df9f0ddd5651fdd9fe32da2e4819290)), closes [#204](https://github.com/open-feature/dotnet-sdk/issues/204) + + +### 🔄 Refactoring + +* InMemoryProvider throwing when types mismatched ([#442](https://github.com/open-feature/dotnet-sdk/issues/442)) ([8ecf50d](https://github.com/open-feature/dotnet-sdk/commit/8ecf50db2cab3a266de5c6c5216714570cfc6a52)) + ## [2.4.0](https://github.com/open-feature/dotnet-sdk/compare/v2.3.2...v2.4.0) (2025-04-14) diff --git a/README.md b/README.md index daad6ad4a..3e51a690f 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ [![Specification](https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge)](https://github.com/open-feature/spec/releases/tag/v0.8.0) [ -![Release](https://img.shields.io/static/v1?label=release&message=v2.4.0&color=blue&style=for-the-badge) -](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.4.0) +![Release](https://img.shields.io/static/v1?label=release&message=v2.5.0&color=blue&style=for-the-badge) +](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.5.0) [![Slack](https://img.shields.io/badge/slack-%40cncf%2Fopenfeature-brightgreen?style=flat&logo=slack)](https://cloud-native.slack.com/archives/C0344AANLA1) [![Codecov](https://codecov.io/gh/open-feature/dotnet-sdk/branch/main/graph/badge.svg?token=MONAVJBXUJ)](https://codecov.io/gh/open-feature/dotnet-sdk) diff --git a/build/Common.prod.props b/build/Common.prod.props index 92b0ccb51..3f4ba37d2 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -9,7 +9,7 @@ - 2.4.0 + 2.5.0 git https://github.com/open-feature/dotnet-sdk OpenFeature is an open standard for feature flag management, created to support a robust feature flag ecosystem using cloud native technologies. OpenFeature will provide a unified API and SDK, and a developer-first, cloud-native implementation, with extensibility for open source and commercial offerings. diff --git a/version.txt b/version.txt index 197c4d5c2..437459cd9 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.4.0 +2.5.0 From 6a8b00aaa56347e897d4bb6abb930d46ac3443d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Tue, 29 Apr 2025 19:20:06 +0100 Subject: [PATCH 020/126] ci: Move CODEOWNERS (#458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR This pull request removes the `.config/dotnet-tools.json` file, which previously specified the `dotnet-format` tool and its configuration. Key change: * [`.config/dotnet-tools.json`](diffhunk://#diff-7afd3bcf0d0c06d6f87c451ef06b321beef770ece4299b3230bb280470ada2f6L1-L12): Deleted the file, which contained configuration for the `dotnet-format` tool, including its version (`5.1.250801`) and associated commands. --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .config/dotnet-tools.json | 12 ------------ CODEOWNERS => .github/CODEOWNERS | 0 2 files changed, 12 deletions(-) delete mode 100644 .config/dotnet-tools.json rename CODEOWNERS => .github/CODEOWNERS (100%) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json deleted file mode 100644 index a02f5dc5d..000000000 --- a/.config/dotnet-tools.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "dotnet-format": { - "version": "5.1.250801", - "commands": [ - "dotnet-format" - ] - } - } -} diff --git a/CODEOWNERS b/.github/CODEOWNERS similarity index 100% rename from CODEOWNERS rename to .github/CODEOWNERS From 8e3ae54489a5a275124b7c22d73142bbeedee8c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 2 May 2025 20:33:52 +0100 Subject: [PATCH 021/126] build: Tidy build tests (#460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR This pull request primarily addresses warnings related to the use of `ConfigureAwait` in test code. It suppresses these warnings globally for test projects and removes redundant `[SuppressMessage]` attributes from individual test classes. ### Suppression of `ConfigureAwait` warnings: * [`build/Common.tests.props`](diffhunk://#diff-5472aa271be4e6ac0c793a3c1b9226e4f9a7907a6baa99ea16542fb89107ae86R21-R25): Added a global suppression for the `CA2007` warning, which advises the use of `.ConfigureAwait`. This is appropriate for test code where configuring the await context is unnecessary. ### Cleanup of redundant `[SuppressMessage]` attributes: * Removed `[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")]` attributes from the following test classes: - `OpenFeatureClientBenchmarks` in `test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs` - `FeatureProviderTests` in `test/OpenFeature.Tests/FeatureProviderTests.cs` - `LoggingHookTests` in `test/OpenFeature.Tests/Hooks/LoggingHookTests.cs` - `OpenFeatureClientTests` in `test/OpenFeature.Tests/OpenFeatureClientTests.cs` - `OpenFeatureEventTest` in `test/OpenFeature.Tests/OpenFeatureEventTests.cs` - `OpenFeatureHookTests` in `test/OpenFeature.Tests/OpenFeatureHookTests.cs` - `OpenFeatureTests` in `test/OpenFeature.Tests/OpenFeatureTests.cs` - `ProviderRepositoryTests` in `test/OpenFeature.Tests/ProviderRepositoryTests.cs` - `InMemoryProviderTests` in `test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs` - `TestUtilsTest` in `test/OpenFeature.Tests/TestUtilsTest.cs` ### Removal of unused imports: * Removed `System.Diagnostics.CodeAnalysis` using directives from files where `[SuppressMessage]` attributes were deleted. Examples include: - `test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs` - `test/OpenFeature.Tests/FeatureProviderTests.cs` - `test/OpenFeature.Tests/Hooks/LoggingHookTests.cs` These changes simplify the codebase by centralizing the suppression of `ConfigureAwait` warnings and removing unnecessary annotations and imports. --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- build/Common.tests.props | 5 +++++ test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs | 4 +--- test/OpenFeature.Tests/FeatureProviderTests.cs | 2 -- test/OpenFeature.Tests/Hooks/LoggingHookTests.cs | 3 --- test/OpenFeature.Tests/OpenFeatureClientTests.cs | 2 -- test/OpenFeature.Tests/OpenFeatureEventTests.cs | 2 -- test/OpenFeature.Tests/OpenFeatureHookTests.cs | 2 -- test/OpenFeature.Tests/OpenFeatureTests.cs | 2 -- test/OpenFeature.Tests/ProviderRepositoryTests.cs | 2 -- .../Providers/Memory/InMemoryProviderTests.cs | 2 -- test/OpenFeature.Tests/TestUtilsTest.cs | 2 -- 11 files changed, 6 insertions(+), 22 deletions(-) diff --git a/build/Common.tests.props b/build/Common.tests.props index 8ea5c27d7..ac9a64538 100644 --- a/build/Common.tests.props +++ b/build/Common.tests.props @@ -18,4 +18,9 @@ + + + + $(NoWarn);CA2007 + diff --git a/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs b/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs index c2779a31d..d4c770ebf 100644 --- a/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs +++ b/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs @@ -1,6 +1,5 @@ using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using AutoFixture; using BenchmarkDotNet.Attributes; @@ -10,10 +9,9 @@ namespace OpenFeature.Benchmark; [MemoryDiagnoser] -[SimpleJob(RuntimeMoniker.Net60, baseline: true)] +[SimpleJob(RuntimeMoniker.Net80, baseline: true)] [JsonExporterAttribute.Full] [JsonExporterAttribute.FullCompressed] -[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] public class OpenFeatureClientBenchmarks { private readonly string _domain; diff --git a/test/OpenFeature.Tests/FeatureProviderTests.cs b/test/OpenFeature.Tests/FeatureProviderTests.cs index 79ab2f00e..d7e5ca221 100644 --- a/test/OpenFeature.Tests/FeatureProviderTests.cs +++ b/test/OpenFeature.Tests/FeatureProviderTests.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using AutoFixture; using NSubstitute; @@ -9,7 +8,6 @@ namespace OpenFeature.Tests; -[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] public class FeatureProviderTests : ClearOpenFeatureInstanceFixture { [Fact] diff --git a/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs b/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs index 461455eb1..1364f83f5 100644 --- a/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs +++ b/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; @@ -10,8 +9,6 @@ namespace OpenFeature.Tests.Hooks; -[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] - public class LoggingHookTests { [Fact] diff --git a/test/OpenFeature.Tests/OpenFeatureClientTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTests.cs index 31450a6f9..a43cf4d87 100644 --- a/test/OpenFeature.Tests/OpenFeatureClientTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureClientTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -18,7 +17,6 @@ namespace OpenFeature.Tests; -[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] public class OpenFeatureClientTests : ClearOpenFeatureInstanceFixture { [Fact] diff --git a/test/OpenFeature.Tests/OpenFeatureEventTests.cs b/test/OpenFeature.Tests/OpenFeatureEventTests.cs index c8cea92b7..d47a530fb 100644 --- a/test/OpenFeature.Tests/OpenFeatureEventTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureEventTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using AutoFixture; @@ -12,7 +11,6 @@ namespace OpenFeature.Tests; -[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] public class OpenFeatureEventTest : ClearOpenFeatureInstanceFixture { [Fact] diff --git a/test/OpenFeature.Tests/OpenFeatureHookTests.cs b/test/OpenFeature.Tests/OpenFeatureHookTests.cs index d2e9b5e97..cebe40c0e 100644 --- a/test/OpenFeature.Tests/OpenFeatureHookTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureHookTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -16,7 +15,6 @@ namespace OpenFeature.Tests; -[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] public class OpenFeatureHookTests : ClearOpenFeatureInstanceFixture { [Fact] diff --git a/test/OpenFeature.Tests/OpenFeatureTests.cs b/test/OpenFeature.Tests/OpenFeatureTests.cs index 4dea7f39f..6afcc91ca 100644 --- a/test/OpenFeature.Tests/OpenFeatureTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureTests.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using NSubstitute; @@ -10,7 +9,6 @@ namespace OpenFeature.Tests; -[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] public class OpenFeatureTests : ClearOpenFeatureInstanceFixture { [Fact] diff --git a/test/OpenFeature.Tests/ProviderRepositoryTests.cs b/test/OpenFeature.Tests/ProviderRepositoryTests.cs index 046d750a6..16fb5d133 100644 --- a/test/OpenFeature.Tests/ProviderRepositoryTests.cs +++ b/test/OpenFeature.Tests/ProviderRepositoryTests.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using NSubstitute; using OpenFeature.Constant; @@ -11,7 +10,6 @@ namespace OpenFeature.Tests; -[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] public class ProviderRepositoryTests { [Fact] diff --git a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs index 8f1520a7b..6a196fd59 100644 --- a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using OpenFeature.Constant; using OpenFeature.Error; @@ -9,7 +8,6 @@ namespace OpenFeature.Tests.Providers.Memory; -[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] public class InMemoryProviderTests { private FeatureProvider commonProvider; diff --git a/test/OpenFeature.Tests/TestUtilsTest.cs b/test/OpenFeature.Tests/TestUtilsTest.cs index 9f5cde861..ab7867baf 100644 --- a/test/OpenFeature.Tests/TestUtilsTest.cs +++ b/test/OpenFeature.Tests/TestUtilsTest.cs @@ -1,11 +1,9 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Xunit; namespace OpenFeature.Tests; -[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task")] public class TestUtilsTest { [Fact] From 9b04485173978d600a4e3fd24df111347070dc70 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Tue, 6 May 2025 18:00:47 +0100 Subject: [PATCH 022/126] feat: Add Extension Method for adding global Hook via DependencyInjection (#459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR - This adds a new OpenFeatureBuilder extension method to add global or not domain-bound hooks to the OpenFeature Api. ### Related Issues Fixes #456 ### Notes We inject any provided Hooks as Singletons in the DI container. We use keyed singletons and use the class name as the key. Maybe we'd want to use a different name to avoid conflicts? I've done some manual testing with a sample weatherforecast ASP.NET Core web application ### Follow-up Tasks ### How to test --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Co-authored-by: André Silva <2493377+askpt@users.noreply.github.com> --- README.md | 2 + .../Internal/FeatureLifecycleManager.cs | 9 +++ .../OpenFeatureBuilderExtensions.cs | 41 ++++++++++++ .../OpenFeatureOptions.cs | 12 ++++ .../FeatureLifecycleManagerTests.cs | 65 ++++++++++++------- .../NoOpHook.cs | 26 ++++++++ .../OpenFeatureBuilderExtensionsTests.cs | 60 +++++++++++++++++ .../FeatureFlagIntegrationTest.cs | 38 ++++++++++- .../OpenFeature.IntegrationTests.csproj | 1 + 9 files changed, 229 insertions(+), 25 deletions(-) create mode 100644 test/OpenFeature.DependencyInjection.Tests/NoOpHook.cs diff --git a/README.md b/README.md index 3e51a690f..b0063d3a4 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,7 @@ builder.Services.AddOpenFeature(featureBuilder => { featureBuilder .AddHostedFeatureLifecycle() // From Hosting package .AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ }) + .AddHook() .AddInMemoryProvider(); }); ``` @@ -446,6 +447,7 @@ builder.Services.AddOpenFeature(featureBuilder => { featureBuilder .AddHostedFeatureLifecycle() .AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ }) + .AddHook((serviceProvider) => new LoggingHook( /* Custom configuration */ )) .AddInMemoryProvider("name1") .AddInMemoryProvider("name2") .AddPolicyName(options => { diff --git a/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs b/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs index d14d421b6..f2c914f23 100644 --- a/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs +++ b/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs @@ -34,6 +34,15 @@ public async ValueTask EnsureInitializedAsync(CancellationToken cancellationToke var featureProvider = _serviceProvider.GetRequiredKeyedService(name); await _featureApi.SetProviderAsync(name, featureProvider).ConfigureAwait(false); } + + var hooks = new List(); + foreach (var hookName in options.HookNames) + { + var hook = _serviceProvider.GetRequiredKeyedService(hookName); + hooks.Add(hook); + } + + _featureApi.AddHooks(hooks); } /// diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs index a9c3f2586..8f79f3946 100644 --- a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs +++ b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs @@ -262,4 +262,45 @@ public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder /// The configured instance. public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder, Action configureOptions) => AddPolicyName(builder, configureOptions); + + /// + /// Adds a feature hook to the service collection using a factory method. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// Optional factory for controlling how will be created in the DI container. + /// The instance. + public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, Func? implementationFactory = null) + where THook : Hook + { + return builder.AddHook(typeof(THook).Name, implementationFactory); + } + + /// + /// Adds a feature hook to the service collection using a factory method and specified name. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// The name of the that is being added. + /// Optional factory for controlling how will be created in the DI container. + /// The instance. + public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, string hookName, Func? implementationFactory = null) + where THook : Hook + { + builder.Services.PostConfigure(options => options.AddHookName(hookName)); + + if (implementationFactory is not null) + { + builder.Services.TryAddKeyedSingleton(hookName, (serviceProvider, key) => + { + return implementationFactory(serviceProvider); + }); + } + else + { + builder.Services.TryAddKeyedSingleton(hookName); + } + + return builder; + } } diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs index b2f15e442..e9cc3cb12 100644 --- a/src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs +++ b/src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs @@ -46,4 +46,16 @@ protected internal void AddProviderName(string? name) } } } + + private readonly HashSet _hookNames = []; + + internal IReadOnlyCollection HookNames => _hookNames; + + internal void AddHookName(string name) + { + lock (_hookNames) + { + _hookNames.Add(name); + } + } } diff --git a/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs index c55736047..47cc7df5c 100644 --- a/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs +++ b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using NSubstitute; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging.Abstractions; using OpenFeature.DependencyInjection.Internal; using Xunit; @@ -9,27 +8,18 @@ namespace OpenFeature.DependencyInjection.Tests; public class FeatureLifecycleManagerTests { - private readonly FeatureLifecycleManager _systemUnderTest; - private readonly IServiceProvider _mockServiceProvider; + private readonly IServiceCollection _serviceCollection; public FeatureLifecycleManagerTests() { Api.Instance.SetContext(null); Api.Instance.ClearHooks(); - _mockServiceProvider = Substitute.For(); - - var options = new OpenFeatureOptions(); - options.AddDefaultProviderName(); - var optionsMock = Substitute.For>(); - optionsMock.Value.Returns(options); - - _mockServiceProvider.GetService>().Returns(optionsMock); - - _systemUnderTest = new FeatureLifecycleManager( - Api.Instance, - _mockServiceProvider, - Substitute.For>()); + _serviceCollection = new ServiceCollection() + .Configure(options => + { + options.AddDefaultProviderName(); + }); } [Fact] @@ -37,10 +27,13 @@ public async Task EnsureInitializedAsync_ShouldLogAndSetProvider_WhenProviderExi { // Arrange var featureProvider = new NoOpFeatureProvider(); - _mockServiceProvider.GetService(typeof(FeatureProvider)).Returns(featureProvider); + _serviceCollection.AddSingleton(featureProvider); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger.Instance); // Act - await _systemUnderTest.EnsureInitializedAsync().ConfigureAwait(true); + await sut.EnsureInitializedAsync().ConfigureAwait(true); // Assert Assert.Equal(featureProvider, Api.Instance.GetProvider()); @@ -50,14 +43,42 @@ public async Task EnsureInitializedAsync_ShouldLogAndSetProvider_WhenProviderExi public async Task EnsureInitializedAsync_ShouldThrowException_WhenProviderDoesNotExist() { // Arrange - _mockServiceProvider.GetService(typeof(FeatureProvider)).Returns(null as FeatureProvider); + _serviceCollection.RemoveAll(); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger.Instance); // Act - var act = () => _systemUnderTest.EnsureInitializedAsync().AsTask(); + var act = () => sut.EnsureInitializedAsync().AsTask(); // Assert var exception = await Assert.ThrowsAsync(act).ConfigureAwait(true); Assert.NotNull(exception); Assert.False(string.IsNullOrWhiteSpace(exception.Message)); } + + [Fact] + public async Task EnsureInitializedAsync_ShouldSetHook_WhenHooksAreRegistered() + { + // Arrange + var featureProvider = new NoOpFeatureProvider(); + var hook = new NoOpHook(); + + _serviceCollection.AddSingleton(featureProvider) + .AddKeyedSingleton("NoOpHook", (_, key) => hook) + .Configure(options => + { + options.AddHookName("NoOpHook"); + }); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger.Instance); + + // Act + await sut.EnsureInitializedAsync().ConfigureAwait(true); + + // Assert + var actual = Api.Instance.GetHooks().FirstOrDefault(); + Assert.Equal(hook, actual); + } } diff --git a/test/OpenFeature.DependencyInjection.Tests/NoOpHook.cs b/test/OpenFeature.DependencyInjection.Tests/NoOpHook.cs new file mode 100644 index 000000000..cee6ef1df --- /dev/null +++ b/test/OpenFeature.DependencyInjection.Tests/NoOpHook.cs @@ -0,0 +1,26 @@ +using OpenFeature.Model; + +namespace OpenFeature.DependencyInjection.Tests; + +internal class NoOpHook : Hook +{ + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.BeforeAsync(context, hints, cancellationToken); + } + + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.AfterAsync(context, details, hints, cancellationToken); + } + + public override ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails evaluationDetails, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken); + } + + public override ValueTask ErrorAsync(HookContext context, Exception error, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.ErrorAsync(context, error, hints, cancellationToken); + } +} diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs index 6985125d9..07597703a 100644 --- a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs @@ -241,4 +241,64 @@ public void AddProvider_ConfiguresPolicyNameAcrossMultipleProviderSetups(int pro Assert.NotNull(provider); Assert.IsType(provider); } + + [Fact] + public void AddHook_AddsHookAsKeyedService() + { + // Arrange + _systemUnderTest.AddHook(); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var hook = serviceProvider.GetKeyedService("NoOpHook"); + + // Assert + Assert.NotNull(hook); + } + + [Fact] + public void AddHook_AddsHookNameToOpenFeatureOptions() + { + // Arrange + _systemUnderTest.AddHook(sp => new NoOpHook()); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>(); + + // Assert + Assert.Contains(options.Value.HookNames, t => t == "NoOpHook"); + } + + [Fact] + public void AddHook_WithSpecifiedNameToOpenFeatureOptions() + { + // Arrange + _systemUnderTest.AddHook("my-custom-name"); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var hook = serviceProvider.GetKeyedService("my-custom-name"); + + // Assert + Assert.NotNull(hook); + } + + [Fact] + public void AddHook_WithSpecifiedNameAndImplementationFactory_AsKeyedService() + { + // Arrange + _systemUnderTest.AddHook("my-custom-name", (serviceProvider) => new NoOpHook()); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var hook = serviceProvider.GetKeyedService("my-custom-name"); + + // Assert + Assert.NotNull(hook); + } } diff --git a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs index 2f9746eb7..9e1f4bca9 100644 --- a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs +++ b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs @@ -5,7 +5,9 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging.Testing; using OpenFeature.DependencyInjection.Providers.Memory; +using OpenFeature.Hooks; using OpenFeature.IntegrationTests.Services; using OpenFeature.Providers.Memory; @@ -27,7 +29,8 @@ public class FeatureFlagIntegrationTest public async Task VerifyFeatureFlagBehaviorAcrossServiceLifetimesAsync(string userId, bool expectedResult, ServiceLifetime serviceLifetime) { // Arrange - using var server = await CreateServerAsync(serviceLifetime, services => + var logger = new FakeLogger(); + using var server = await CreateServerAsync(serviceLifetime, logger, services => { switch (serviceLifetime) { @@ -50,7 +53,7 @@ public async Task VerifyFeatureFlagBehaviorAcrossServiceLifetimesAsync(string us // Act var response = await client.GetAsync(requestUri).ConfigureAwait(true); - var responseContent = await response.Content.ReadFromJsonAsync>().ConfigureAwait(true); ; + var responseContent = await response.Content.ReadFromJsonAsync>().ConfigureAwait(true); // Assert Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK."); @@ -59,7 +62,35 @@ public async Task VerifyFeatureFlagBehaviorAcrossServiceLifetimesAsync(string us Assert.Equal(expectedResult, responseContent.FeatureValue); } - private static async Task CreateServerAsync(ServiceLifetime serviceLifetime, Action? configureServices = null) + [Fact] + public async Task VerifyLoggingHookIsRegisteredAsync() + { + // Arrange + var logger = new FakeLogger(); + using var server = await CreateServerAsync(ServiceLifetime.Transient, logger, services => + { + services.AddTransient(); + }).ConfigureAwait(true); + + var client = server.CreateClient(); + var requestUri = $"/features/{TestUserId}/flags/{FeatureA}"; + + // Act + var response = await client.GetAsync(requestUri).ConfigureAwait(true); + var logs = logger.Collector.GetSnapshot(); + + // Assert + Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK."); + Assert.Equal(4, logs.Count); + Assert.Multiple(() => + { + Assert.Contains("Before Flag Evaluation", logs[0].Message); + Assert.Contains("After Flag Evaluation", logs[1].Message); + }); + } + + private static async Task CreateServerAsync(ServiceLifetime serviceLifetime, FakeLogger logger, + Action? configureServices = null) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); @@ -94,6 +125,7 @@ private static async Task CreateServerAsync(ServiceLifetime serviceL return flagService.GetFlags(); } }); + cfg.AddHook(serviceProvider => new LoggingHook(logger)); }); var app = builder.Build(); diff --git a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj index baf5fdfb7..151c61b9d 100644 --- a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj +++ b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj @@ -13,6 +13,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive From ea76351a095b7eeb777941aaf7ac42e4d925c366 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 7 May 2025 22:31:29 +0100 Subject: [PATCH 023/126] chore(deps): update github/codeql-action digest to 60168ef (#463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [github/codeql-action](https://github.com/github/codeql-action) | action | digest | `28deaed` -> `60168ef` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index d943ef016..50c339050 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@28deaeda66b76a05916b6923827895f2b14ab387 # v3 + uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@28deaeda66b76a05916b6923827895f2b14ab387 # v3 + uses: github/codeql-action/autobuild@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@28deaeda66b76a05916b6923827895f2b14ab387 # v3 + uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 From 0a5ab0c3c71a1a615b0ee8627dd4ff5db39cac9b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 7 May 2025 21:32:09 +0000 Subject: [PATCH 024/126] chore(deps): update actions/attest-build-provenance action to v2.3.0 (#464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) | action | minor | `v2.2.3` -> `v2.3.0` | --- ### Release Notes
actions/attest-build-provenance (actions/attest-build-provenance) ### [`v2.3.0`](https://github.com/actions/attest-build-provenance/releases/tag/v2.3.0) [Compare Source](https://github.com/actions/attest-build-provenance/compare/v2.2.3...v2.3.0) #### What's Changed - Bump `actions/attest` from 2.2.1 to 2.3.0 by [@​bdehamer](https://github.com/bdehamer) in [https://github.com/actions/attest-build-provenance/pull/615](https://github.com/actions/attest-build-provenance/pull/615) - Updates `@sigstore/oci` from 0.4.0 to 0.5.0 **Full Changelog**: https://github.com/actions/attest-build-provenance/compare/v2.2.3...v2.3.0
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3702d88bd..339c5c8d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ secrets.NUGET_TOKEN }}" --source https://api.nuget.org/v3/index.json - name: Generate artifact attestation - uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 + uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 with: subject-path: "src/**/*.nupkg" From ff414b8a860108ca3cb372dc4a69b942bf4cd005 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Mon, 12 May 2025 08:37:46 +0100 Subject: [PATCH 025/126] feat: add AddHandler extension method to Dependency Injection package (#462) ## This PR - Introduces a new extension method in the OpenFeature.DependencyInjection package which allows consumers to add, at a global/api level, a handler. Example usage: ```csharp builder.Services.AddOpenFeature(feature => { feature.AddHostedFeatureLifecycle() .AddInMemoryProvider() .AddHandler(ProviderEventTypes.ProviderReady, (@event) => { Console.WriteLine("{0}", @event!.ProviderName); }) .AddHandler(ProviderEventTypes.ProviderReady, sp => (@event) => { var logger = sp.GetRequiredService>(); logger.LogInformation("Provider Ready"); }); }); ``` ### Related Issues Fixes #457 ### Notes ### Follow-up Tasks ### How to test --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- README.md | 11 +- .../Internal/EventHandlerDelegateWrapper.cs | 8 ++ .../Internal/FeatureLifecycleManager.cs | 6 + .../OpenFeatureBuilderExtensions.cs | 32 +++++ .../FeatureLifecycleManagerTests.cs | 41 ++++++ .../OpenFeatureBuilderExtensionsTests.cs | 55 ++++++++ .../FeatureFlagIntegrationTest.cs | 122 +++++++++++++++++- 7 files changed, 267 insertions(+), 8 deletions(-) create mode 100644 src/OpenFeature.DependencyInjection/Internal/EventHandlerDelegateWrapper.cs diff --git a/README.md b/README.md index b0063d3a4..d21c111e6 100644 --- a/README.md +++ b/README.md @@ -433,9 +433,18 @@ For a basic configuration, you can use the InMemoryProvider. This provider is si builder.Services.AddOpenFeature(featureBuilder => { featureBuilder .AddHostedFeatureLifecycle() // From Hosting package + .AddInMemoryProvider(); +}); +``` + +You can add EvaluationContext, hooks, and handlers at a global/API level as needed. + +```csharp +builder.Services.AddOpenFeature(featureBuilder => { + featureBuilder .AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ }) .AddHook() - .AddInMemoryProvider(); + .AddHandler(ProviderEventTypes.ProviderReady, (eventDetails) => { /* Handle event */ }); }); ``` diff --git a/src/OpenFeature.DependencyInjection/Internal/EventHandlerDelegateWrapper.cs b/src/OpenFeature.DependencyInjection/Internal/EventHandlerDelegateWrapper.cs new file mode 100644 index 000000000..d31b3355c --- /dev/null +++ b/src/OpenFeature.DependencyInjection/Internal/EventHandlerDelegateWrapper.cs @@ -0,0 +1,8 @@ +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature.DependencyInjection.Internal; + +internal record EventHandlerDelegateWrapper( + ProviderEventTypes ProviderEventType, + EventHandlerDelegate EventHandlerDelegate); diff --git a/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs b/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs index f2c914f23..1ecac4349 100644 --- a/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs +++ b/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs @@ -43,6 +43,12 @@ public async ValueTask EnsureInitializedAsync(CancellationToken cancellationToke } _featureApi.AddHooks(hooks); + + var handlers = _serviceProvider.GetServices(); + foreach (var handler in handlers) + { + _featureApi.AddHandler(handler.ProviderEventType, handler.EventHandlerDelegate); + } } /// diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs index 8f79f3946..317589606 100644 --- a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs +++ b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using OpenFeature.Constant; using OpenFeature.DependencyInjection; +using OpenFeature.DependencyInjection.Internal; using OpenFeature.Model; namespace OpenFeature; @@ -303,4 +305,34 @@ public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, return builder; } + + /// + /// Add a to allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions + /// + /// The instance. + /// The type to handle. + /// The handler which reacts to . + /// The instance. + public static OpenFeatureBuilder AddHandler(this OpenFeatureBuilder builder, ProviderEventTypes type, EventHandlerDelegate eventHandlerDelegate) + { + return AddHandler(builder, type, _ => eventHandlerDelegate); + } + + /// + /// Add a to allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions + /// + /// The instance. + /// The type to handle. + /// The handler factory for creating a handler which reacts to . + /// The instance. + public static OpenFeatureBuilder AddHandler(this OpenFeatureBuilder builder, ProviderEventTypes type, Func implementationFactory) + { + builder.Services.AddSingleton((serviceProvider) => + { + var handler = implementationFactory(serviceProvider); + return new EventHandlerDelegateWrapper(type, handler); + }); + + return builder; + } } diff --git a/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs index 47cc7df5c..db9ac4e00 100644 --- a/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs +++ b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging.Abstractions; +using OpenFeature.Constant; using OpenFeature.DependencyInjection.Internal; +using OpenFeature.Model; using Xunit; namespace OpenFeature.DependencyInjection.Tests; @@ -81,4 +83,43 @@ public async Task EnsureInitializedAsync_ShouldSetHook_WhenHooksAreRegistered() var actual = Api.Instance.GetHooks().FirstOrDefault(); Assert.Equal(hook, actual); } + + [Fact] + public async Task EnsureInitializedAsync_ShouldSetHandler_WhenHandlersAreRegistered() + { + // Arrange + EventHandlerDelegate eventHandlerDelegate = (_) => { }; + var featureProvider = new NoOpFeatureProvider(); + var handler = new EventHandlerDelegateWrapper(ProviderEventTypes.ProviderReady, eventHandlerDelegate); + + _serviceCollection.AddSingleton(featureProvider) + .AddSingleton(_ => handler); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger.Instance); + + // Act + await sut.EnsureInitializedAsync().ConfigureAwait(true); + } + + [Fact] + public async Task EnsureInitializedAsync_ShouldSetHandler_WhenMultipleHandlersAreRegistered() + { + // Arrange + EventHandlerDelegate eventHandlerDelegate1 = (_) => { }; + EventHandlerDelegate eventHandlerDelegate2 = (_) => { }; + var featureProvider = new NoOpFeatureProvider(); + var handler1 = new EventHandlerDelegateWrapper(ProviderEventTypes.ProviderReady, eventHandlerDelegate1); + var handler2 = new EventHandlerDelegateWrapper(ProviderEventTypes.ProviderReady, eventHandlerDelegate2); + + _serviceCollection.AddSingleton(featureProvider) + .AddSingleton(_ => handler1) + .AddSingleton(_ => handler2); + + var serviceProvider = _serviceCollection.BuildServiceProvider(); + var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger.Instance); + + // Act + await sut.EnsureInitializedAsync().ConfigureAwait(true); + } } diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs index 07597703a..f742f98d8 100644 --- a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using OpenFeature.DependencyInjection.Internal; using OpenFeature.Model; using Xunit; @@ -301,4 +302,58 @@ public void AddHook_WithSpecifiedNameAndImplementationFactory_AsKeyedService() // Assert Assert.NotNull(hook); } + + [Fact] + public void AddHandler_AddsEventHandlerDelegateWrapperAsKeyedService() + { + // Arrange + EventHandlerDelegate eventHandler = (eventDetails) => { }; + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, eventHandler); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var handler = serviceProvider.GetService(); + + // Assert + Assert.NotNull(handler); + Assert.Equal(eventHandler, handler.EventHandlerDelegate); + } + + [Fact] + public void AddHandlerTwice_MultipleEventHandlerDelegateWrappersAsKeyedServices() + { + // Arrange + EventHandlerDelegate eventHandler1 = (eventDetails) => { }; + EventHandlerDelegate eventHandler2 = (eventDetails) => { }; + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, eventHandler1); + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, eventHandler2); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var handler = serviceProvider.GetServices(); + + // Assert + Assert.NotEmpty(handler); + Assert.Equal(eventHandler1, handler.ElementAt(0).EventHandlerDelegate); + Assert.Equal(eventHandler2, handler.ElementAt(1).EventHandlerDelegate); + } + + [Fact] + public void AddHandler_WithImplementationFactory_AddsEventHandlerDelegateWrapperAsKeyedService() + { + // Arrange + EventHandlerDelegate eventHandler = (eventDetails) => { }; + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, _ => eventHandler); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var handler = serviceProvider.GetService(); + + // Assert + Assert.NotNull(handler); + Assert.Equal(eventHandler, handler.EventHandlerDelegate); + } } diff --git a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs index 9e1f4bca9..d911135e6 100644 --- a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs +++ b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs @@ -5,7 +5,10 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; +using OpenFeature.Constant; +using OpenFeature.DependencyInjection; using OpenFeature.DependencyInjection.Providers.Memory; using OpenFeature.Hooks; using OpenFeature.IntegrationTests.Services; @@ -29,8 +32,7 @@ public class FeatureFlagIntegrationTest public async Task VerifyFeatureFlagBehaviorAcrossServiceLifetimesAsync(string userId, bool expectedResult, ServiceLifetime serviceLifetime) { // Arrange - var logger = new FakeLogger(); - using var server = await CreateServerAsync(serviceLifetime, logger, services => + using var server = await CreateServerAsync(serviceLifetime, services => { switch (serviceLifetime) { @@ -67,10 +69,17 @@ public async Task VerifyLoggingHookIsRegisteredAsync() { // Arrange var logger = new FakeLogger(); - using var server = await CreateServerAsync(ServiceLifetime.Transient, logger, services => + Action configureServices = services => { services.AddTransient(); - }).ConfigureAwait(true); + }; + + Action openFeatureBuilder = cfg => + { + cfg.AddHook(_ => new LoggingHook(logger)); + }; + + using var server = await CreateServerAsync(ServiceLifetime.Transient, configureServices, openFeatureBuilder).ConfigureAwait(true); var client = server.CreateClient(); var requestUri = $"/features/{TestUserId}/flags/{FeatureA}"; @@ -89,8 +98,103 @@ public async Task VerifyLoggingHookIsRegisteredAsync() }); } - private static async Task CreateServerAsync(ServiceLifetime serviceLifetime, FakeLogger logger, - Action? configureServices = null) + [Fact] + public async Task VerifyHandlerIsRegisteredAsync() + { + // Arrange + Action configureServices = services => + { + services.AddTransient(); + }; + + var handlerSuccess = false; + Action openFeatureBuilder = cfg => + { + cfg.AddHandler(ProviderEventTypes.ProviderReady, (_) => { handlerSuccess = true; }); + }; + + using var server = await CreateServerAsync(ServiceLifetime.Transient, configureServices, openFeatureBuilder) + .ConfigureAwait(true); + + var client = server.CreateClient(); + var requestUri = $"/features/{TestUserId}/flags/{FeatureA}"; + + // Act + var response = await client.GetAsync(requestUri).ConfigureAwait(true); + + // Assert + Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK."); + Assert.True(handlerSuccess); + } + + [Fact] + public async Task VerifyMultipleHandlersAreRegisteredAsync() + { + // Arrange + Action configureServices = services => + { + services.AddTransient(); + }; + + var @lock = new Lock(); + var counter = 0; + Action openFeatureBuilder = cfg => + { + cfg.AddHandler(ProviderEventTypes.ProviderReady, (_) => { lock (@lock) { counter++; } }); + cfg.AddHandler(ProviderEventTypes.ProviderReady, (_) => { lock (@lock) { counter++; } }); + }; + + using var server = await CreateServerAsync(ServiceLifetime.Transient, configureServices, openFeatureBuilder) + .ConfigureAwait(true); + + var client = server.CreateClient(); + var requestUri = $"/features/{TestUserId}/flags/{FeatureA}"; + + // Act + var response = await client.GetAsync(requestUri).ConfigureAwait(true); + + // Assert + Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK."); + Assert.Equal(2, counter); + } + + [Fact] + public async Task VerifyHandlersAreRegisteredWithServiceProviderAsync() + { + // Arrange + var logs = string.Empty; + Action configureServices = services => + { + services.AddFakeLogging(a => a.OutputSink = log => logs = string.Join('|', logs, log)); + services.AddTransient(); + }; + + Action openFeatureBuilder = cfg => + { + cfg.AddHandler(ProviderEventTypes.ProviderReady, sp => (@event) => + { + var innerLoger = sp.GetService>(); + innerLoger!.LogInformation("Handler invoked from builder!"); + }); + }; + + using var server = await CreateServerAsync(ServiceLifetime.Transient, configureServices, openFeatureBuilder) + .ConfigureAwait(true); + + var client = server.CreateClient(); + var requestUri = $"/features/{TestUserId}/flags/{FeatureA}"; + + // Act + var response = await client.GetAsync(requestUri).ConfigureAwait(true); + + // Assert + Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK."); + Assert.Contains("Handler invoked from builder!", logs); + } + + private static async Task CreateServerAsync(ServiceLifetime serviceLifetime, + Action? configureServices = null, + Action? openFeatureBuilder = null) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); @@ -125,7 +229,11 @@ private static async Task CreateServerAsync(ServiceLifetime serviceL return flagService.GetFlags(); } }); - cfg.AddHook(serviceProvider => new LoggingHook(logger)); + + if (openFeatureBuilder is not null) + { + openFeatureBuilder(cfg); + } }); var app = builder.Build(); From cf65a32ac7cdcb27bbca827999e759d0dafb3903 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 15 May 2025 20:12:15 +0100 Subject: [PATCH 026/126] test: Fix flaky integration flaky (#469) ### This PR Reworked the `VerifyMultipleHandlersAreRegisteredAsync` test assertions in the Integration tests to make them more reliable. ### Releated issues Fixes #468 ### Notes Before we were asserting that the handler was invoked twice. However, we don't necessarily need to test how many invocations occured but rather that the invocations happened. I've tweaked the counter to increment when the handler is injected (which should always be twice) and added a couple of boolean variables that we can use to track whether each handler did invoke at least once. I think this makes for a more accurate and reliable test case. --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../FeatureFlagIntegrationTest.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs index d911135e6..ff717f9f1 100644 --- a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs +++ b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs @@ -136,12 +136,13 @@ public async Task VerifyMultipleHandlersAreRegisteredAsync() services.AddTransient(); }; - var @lock = new Lock(); var counter = 0; + var handler1Success = false; + var handler2Success = false; Action openFeatureBuilder = cfg => { - cfg.AddHandler(ProviderEventTypes.ProviderReady, (_) => { lock (@lock) { counter++; } }); - cfg.AddHandler(ProviderEventTypes.ProviderReady, (_) => { lock (@lock) { counter++; } }); + cfg.AddHandler(ProviderEventTypes.ProviderReady, sp => { Interlocked.Increment(ref counter); return _ => { handler1Success = true; }; }); + cfg.AddHandler(ProviderEventTypes.ProviderReady, sp => { Interlocked.Increment(ref counter); return _ => { handler2Success = true; }; }); }; using var server = await CreateServerAsync(ServiceLifetime.Transient, configureServices, openFeatureBuilder) @@ -155,7 +156,12 @@ public async Task VerifyMultipleHandlersAreRegisteredAsync() // Assert Assert.True(response.IsSuccessStatusCode, "Expected HTTP status code 200 OK."); - Assert.Equal(2, counter); + Assert.Multiple(() => + { + Assert.Equal(2, counter); + Assert.True(handler1Success); + Assert.True(handler2Success); + }); } [Fact] From 48f57cd40c2754bbbb08202f338e2452522fe691 Mon Sep 17 00:00:00 2001 From: Weihan Li Date: Fri, 16 May 2025 13:11:07 +0800 Subject: [PATCH 027/126] build: migrate to slnx (#415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR - migrate to slnx ### Related Issues Fixes #411 ### Notes Used the migrated slnx file --------- Signed-off-by: Weihan Li Co-authored-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/dotnet-format.yml | 2 +- .vscode/settings.json | 1 + OpenFeature.sln | 151 ---------------------------- OpenFeature.slnx | 61 +++++++++++ build/Common.tests.props | 2 +- global.json | 4 +- src/Directory.Build.props | 2 +- test/Directory.Build.props | 2 +- 8 files changed, 68 insertions(+), 157 deletions(-) delete mode 100644 OpenFeature.sln create mode 100644 OpenFeature.slnx diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index 16799cf11..e35e37756 100644 --- a/.github/workflows/dotnet-format.yml +++ b/.github/workflows/dotnet-format.yml @@ -23,4 +23,4 @@ jobs: global-json-file: global.json - name: dotnet format - run: dotnet format --verify-no-changes OpenFeature.sln + run: dotnet format --verify-no-changes OpenFeature.slnx diff --git a/.vscode/settings.json b/.vscode/settings.json index a5d74da80..60d215158 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "dotnet.defaultSolution": "OpenFeature.slnx", "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { // shows *.feature.cs files as nested items diff --git a/OpenFeature.sln b/OpenFeature.sln deleted file mode 100644 index 3b5dc9017..000000000 --- a/OpenFeature.sln +++ /dev/null @@ -1,151 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.4.33213.308 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".root", ".root", "{E8916D4F-B97E-42D6-8620-ED410A106F94}" - ProjectSection(SolutionItems) = preProject - README.md = README.md - CONTRIBUTING.md = CONTRIBUTING.md - .editorconfig = .editorconfig - .gitignore = .gitignore - .gitmodules = .gitmodules - .release-please-manifest.json = .release-please-manifest.json - CHANGELOG.md = CHANGELOG.md - CODEOWNERS = CODEOWNERS - global.json = global.json - LICENSE = LICENSE - release-please-config.json = release-please-config.json - renovate.json = renovate.json - version.txt = version.txt - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".config", ".config", "{9392E03B-4E6B-434C-8553-B859424388B1}" - ProjectSection(SolutionItems) = preProject - .config\dotnet-tools.json = .config\dotnet-tools.json - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{C4746B8C-FE19-440B-922C-C2377F906FE8}" - ProjectSection(SolutionItems) = preProject - .github\workflows\ci.yml = .github\workflows\ci.yml - .github\workflows\code-coverage.yml = .github\workflows\code-coverage.yml - .github\workflows\codeql-analysis.yml = .github\workflows\codeql-analysis.yml - .github\workflows\dotnet-format.yml = .github\workflows\dotnet-format.yml - .github\workflows\e2e.yml = .github\workflows\e2e.yml - .github\workflows\lint-pr.yml = .github\workflows\lint-pr.yml - .github\workflows\release.yml = .github\workflows\release.yml - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEMPLATE", "{09BAB3A2-E94C-490A-861C-7D1E11BB7024}" - ProjectSection(SolutionItems) = preProject - .github\ISSUE_TEMPLATE\bug.yaml = .github\ISSUE_TEMPLATE\bug.yaml - .github\ISSUE_TEMPLATE\documentation.yaml = .github\ISSUE_TEMPLATE\documentation.yaml - .github\ISSUE_TEMPLATE\feature.yaml = .github\ISSUE_TEMPLATE\feature.yaml - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".vscode", ".vscode", "{4BB69DB3-9653-4197-9589-37FA6D658CB7}" - ProjectSection(SolutionItems) = preProject - .vscode\launch.json = .vscode\launch.json - .vscode\tasks.json = .vscode\tasks.json - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{72005F60-C2E8-40BF-AE95-893635134D7D}" - ProjectSection(SolutionItems) = preProject - build\Common.prod.props = build\Common.prod.props - build\Common.props = build\Common.props - build\Common.tests.props = build\Common.tests.props - build\openfeature-icon.png = build\openfeature-icon.png - build\xunit.runner.json = build\xunit.runner.json - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C97E9975-E10A-4817-AE2C-4DD69C3C02D4}" - ProjectSection(SolutionItems) = preProject - src\Directory.Build.props = src\Directory.Build.props - src\Directory.Build.targets = src\Directory.Build.targets - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{65FBA159-23E0-4CF9-881B-F78DBFF198E9}" - ProjectSection(SolutionItems) = preProject - test\Directory.Build.props = test\Directory.Build.props - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature", "src\OpenFeature\OpenFeature.csproj", "{07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.Tests", "test\OpenFeature.Tests\OpenFeature.Tests.csproj", "{49BB42BA-10A6-4DA3-A7D5-38C968D57837}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.Benchmarks", "test\OpenFeature.Benchmarks\OpenFeature.Benchmarks.csproj", "{90E7EAD3-251E-4490-AF78-E758E33518E5}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.E2ETests", "test\OpenFeature.E2ETests\OpenFeature.E2ETests.csproj", "{7398C446-2630-4F8C-9278-4E807720E9E5}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.DependencyInjection", "src\OpenFeature.DependencyInjection\OpenFeature.DependencyInjection.csproj", "{C5415057-2700-48B5-940A-7A10969FA639}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.DependencyInjection.Tests", "test\OpenFeature.DependencyInjection.Tests\OpenFeature.DependencyInjection.Tests.csproj", "{EB35F9F6-8A79-410E-A293-9387BC4AC9A7}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenFeature.Hosting", "src\OpenFeature.Hosting\OpenFeature.Hosting.csproj", "{C99DA02A-3981-45A6-B3F8-4A1A48653DEE}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenFeature.IntegrationTests", "test\OpenFeature.IntegrationTests\OpenFeature.IntegrationTests.csproj", "{68463B47-36B4-8DB5-5D02-662C169E85B0}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223}.Debug|Any CPU.Build.0 = Debug|Any CPU - {07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223}.Release|Any CPU.ActiveCfg = Release|Any CPU - {07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223}.Release|Any CPU.Build.0 = Release|Any CPU - {49BB42BA-10A6-4DA3-A7D5-38C968D57837}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {49BB42BA-10A6-4DA3-A7D5-38C968D57837}.Debug|Any CPU.Build.0 = Debug|Any CPU - {49BB42BA-10A6-4DA3-A7D5-38C968D57837}.Release|Any CPU.ActiveCfg = Release|Any CPU - {49BB42BA-10A6-4DA3-A7D5-38C968D57837}.Release|Any CPU.Build.0 = Release|Any CPU - {90E7EAD3-251E-4490-AF78-E758E33518E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {90E7EAD3-251E-4490-AF78-E758E33518E5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {90E7EAD3-251E-4490-AF78-E758E33518E5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {90E7EAD3-251E-4490-AF78-E758E33518E5}.Release|Any CPU.Build.0 = Release|Any CPU - {7398C446-2630-4F8C-9278-4E807720E9E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7398C446-2630-4F8C-9278-4E807720E9E5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7398C446-2630-4F8C-9278-4E807720E9E5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7398C446-2630-4F8C-9278-4E807720E9E5}.Release|Any CPU.Build.0 = Release|Any CPU - {C5415057-2700-48B5-940A-7A10969FA639}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C5415057-2700-48B5-940A-7A10969FA639}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C5415057-2700-48B5-940A-7A10969FA639}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C5415057-2700-48B5-940A-7A10969FA639}.Release|Any CPU.Build.0 = Release|Any CPU - {EB35F9F6-8A79-410E-A293-9387BC4AC9A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EB35F9F6-8A79-410E-A293-9387BC4AC9A7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EB35F9F6-8A79-410E-A293-9387BC4AC9A7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EB35F9F6-8A79-410E-A293-9387BC4AC9A7}.Release|Any CPU.Build.0 = Release|Any CPU - {C99DA02A-3981-45A6-B3F8-4A1A48653DEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C99DA02A-3981-45A6-B3F8-4A1A48653DEE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C99DA02A-3981-45A6-B3F8-4A1A48653DEE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C99DA02A-3981-45A6-B3F8-4A1A48653DEE}.Release|Any CPU.Build.0 = Release|Any CPU - {68463B47-36B4-8DB5-5D02-662C169E85B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {68463B47-36B4-8DB5-5D02-662C169E85B0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {68463B47-36B4-8DB5-5D02-662C169E85B0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {68463B47-36B4-8DB5-5D02-662C169E85B0}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {9392E03B-4E6B-434C-8553-B859424388B1} = {E8916D4F-B97E-42D6-8620-ED410A106F94} - {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} = {E8916D4F-B97E-42D6-8620-ED410A106F94} - {C4746B8C-FE19-440B-922C-C2377F906FE8} = {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} - {09BAB3A2-E94C-490A-861C-7D1E11BB7024} = {2B172AA0-A5A6-4D94-BA1F-B79D59B0C2D8} - {4BB69DB3-9653-4197-9589-37FA6D658CB7} = {E8916D4F-B97E-42D6-8620-ED410A106F94} - {72005F60-C2E8-40BF-AE95-893635134D7D} = {E8916D4F-B97E-42D6-8620-ED410A106F94} - {07A6E6BD-FB7E-4B3B-9CBE-65AE9D0EB223} = {C97E9975-E10A-4817-AE2C-4DD69C3C02D4} - {49BB42BA-10A6-4DA3-A7D5-38C968D57837} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} - {90E7EAD3-251E-4490-AF78-E758E33518E5} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} - {7398C446-2630-4F8C-9278-4E807720E9E5} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} - {C5415057-2700-48B5-940A-7A10969FA639} = {C97E9975-E10A-4817-AE2C-4DD69C3C02D4} - {EB35F9F6-8A79-410E-A293-9387BC4AC9A7} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} - {C99DA02A-3981-45A6-B3F8-4A1A48653DEE} = {C97E9975-E10A-4817-AE2C-4DD69C3C02D4} - {68463B47-36B4-8DB5-5D02-662C169E85B0} = {65FBA159-23E0-4CF9-881B-F78DBFF198E9} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {41F01B78-FB06-404F-8AD0-6ED6973F948F} - EndGlobalSection -EndGlobal diff --git a/OpenFeature.slnx b/OpenFeature.slnx new file mode 100644 index 000000000..8c3b8a2bf --- /dev/null +++ b/OpenFeature.slnx @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/Common.tests.props b/build/Common.tests.props index ac9a64538..3aac9cf9a 100644 --- a/build/Common.tests.props +++ b/build/Common.tests.props @@ -10,7 +10,7 @@
- + PreserveNewest diff --git a/global.json b/global.json index 3018f657c..5fb240dd3 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { "rollForward": "latestFeature", - "version": "9.0.202", + "version": "9.0.300", "allowPrerelease": false } -} \ No newline at end of file +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e9839283c..992a61958 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,3 +1,3 @@ - + diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 1487b2658..78b6928eb 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -1,3 +1,3 @@ - + From af1b20f0822497b46b11ce8c21c47c8d39a5fbe9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 09:07:14 +0100 Subject: [PATCH 028/126] chore(deps): update github/codeql-action digest to ff0a06e (#473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [github/codeql-action](https://github.com/github/codeql-action) | action | digest | `60168ef` -> `ff0a06e` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 50c339050..2f4645ece 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 + uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 + uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 + uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3 From fc3bdfef63147dbd10ace398127eb633dd34c773 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 09:07:34 +0100 Subject: [PATCH 029/126] chore(deps): update spec digest to edf0deb (#474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | spec | digest | `d27e000` -> `edf0deb` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index d27e000b6..edf0debe0 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit d27e000b6c839b533ff4f3ea0f5b1bfc024fb534 +Subproject commit edf0debe0b4547d1f13e49f8e58a6d182237b43b From fbcf3a4b2478fae49f1566b828c9d0a8cffddd46 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 08:07:49 +0000 Subject: [PATCH 030/126] chore(deps): update codecov/codecov-action action to v5.4.3 (#475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [codecov/codecov-action](https://github.com/codecov/codecov-action) | action | patch | `v5.4.2` -> `v5.4.3` | --- ### Release Notes
codecov/codecov-action (codecov/codecov-action) ### [`v5.4.3`](https://github.com/codecov/codecov-action/blob/HEAD/CHANGELOG.md#v543) [Compare Source](https://github.com/codecov/codecov-action/compare/v5.4.2...v5.4.3) ##### What's Changed - build(deps): bump github/codeql-action from 3.28.13 to 3.28.17 by [@​app/dependabot](https://github.com/app/dependabot) in [https://github.com/codecov/codecov-action/pull/1822](https://github.com/codecov/codecov-action/pull/1822) - fix: OIDC on forks by [@​joseph-sentry](https://github.com/joseph-sentry) in [https://github.com/codecov/codecov-action/pull/1823](https://github.com/codecov/codecov-action/pull/1823) **Full Changelog**: https://github.com/codecov/codecov-action/compare/v5.4.2..v5.4.3
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/code-coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index a33413d84..fc7c37f5c 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -37,7 +37,7 @@ jobs: - name: Run Test run: dotnet test --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover - - uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2 + - uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 with: name: Code Coverage for ${{ matrix.os }} fail_ci_if_error: true From 6c44db9237748735a22a162a88f61b2de7f3e9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 19 May 2025 16:02:12 +0100 Subject: [PATCH 031/126] feat: Add OTEL compatible telemetry object builder (#397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR This pull request introduces a feature for generating telemetry events to track feature flag evaluations. It adds a new `EvaluationEvent` class, a builder for creating these events, constants for telemetry attributes, and metadata definitions. It also includes comprehensive unit tests to ensure the correctness of the implementation. ### New Feature: Telemetry Event Generation * **`EvaluationEvent` class**: Added a new class to represent evaluation events for feature flags, including properties for the event name and attributes. (`src/OpenFeature/Telemetry/EvaluationEvent.cs`) * **`EvaluationEventBuilder` class**: Introduced a static builder class to construct `EvaluationEvent` instances using flag evaluation details and hook context. (`src/OpenFeature/Telemetry/EvaluationEventBuilder.cs`) ### Supporting Components * **Telemetry constants**: Defined a set of constants in `TelemetryConstants` to standardize attribute keys for OpenTelemetry-compliant feature flag events. (`src/OpenFeature/Telemetry/TelemetryConstants.cs`) * **Flag metadata attributes**: Added `TelemetryFlagMetadata` to define well-known metadata attributes for telemetry events, such as `contextId`, `flagSetId`, and `version`. (`src/OpenFeature/Telemetry/TelemetryFlagMetadata.cs`) ### Unit Tests * **`EvaluationEventBuilderTests`**: Added unit tests to validate the behavior of `EvaluationEventBuilder`, including scenarios for handling errors, missing metadata, and missing attributes. (`test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs`) ### Related Issues Fixes #381 --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- src/OpenFeature/Telemetry/EvaluationEvent.cs | 30 +++ .../Telemetry/EvaluationEventBuilder.cs | 58 ++++++ .../Telemetry/TelemetryConstants.cs | 59 ++++++ .../Telemetry/TelemetryFlagMetadata.cs | 25 +++ .../Telemetry/EvaluationEventBuilderTests.cs | 174 ++++++++++++++++++ 5 files changed, 346 insertions(+) create mode 100644 src/OpenFeature/Telemetry/EvaluationEvent.cs create mode 100644 src/OpenFeature/Telemetry/EvaluationEventBuilder.cs create mode 100644 src/OpenFeature/Telemetry/TelemetryConstants.cs create mode 100644 src/OpenFeature/Telemetry/TelemetryFlagMetadata.cs create mode 100644 test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs diff --git a/src/OpenFeature/Telemetry/EvaluationEvent.cs b/src/OpenFeature/Telemetry/EvaluationEvent.cs new file mode 100644 index 000000000..51506ad77 --- /dev/null +++ b/src/OpenFeature/Telemetry/EvaluationEvent.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace OpenFeature.Telemetry; + +/// +/// Represents an evaluation event for feature flags. +/// +public class EvaluationEvent +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the event. + /// The attributes of the event. + public EvaluationEvent(string name, IDictionary attributes) + { + Name = name; + Attributes = new Dictionary(attributes); + } + + /// + /// Gets the name of the event. + /// + public string Name { get; } + + /// + /// Gets the attributes of the event. + /// + public IReadOnlyDictionary Attributes { get; } +} diff --git a/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs b/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs new file mode 100644 index 000000000..2f73224ff --- /dev/null +++ b/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature.Telemetry; + +/// +/// Class for creating evaluation events for feature flags. +/// +public sealed class EvaluationEventBuilder +{ + private const string EventName = "feature_flag.evaluation"; + + /// + /// Gets the default instance of the . + /// + public static EvaluationEventBuilder Default { get; } = new(); + + /// + /// Creates an evaluation event based on the provided hook context and flag evaluation details. + /// + /// The context of the hook containing flag key and provider metadata. + /// The details of the flag evaluation including reason, variant, and metadata. + /// An instance of containing the event name, attributes, and body. + public EvaluationEvent Build(HookContext hookContext, FlagEvaluationDetails details) + { + var attributes = new Dictionary + { + { TelemetryConstants.Key, hookContext.FlagKey }, + { TelemetryConstants.Provider, hookContext.ProviderMetadata.Name } + }; + + attributes[TelemetryConstants.Reason] = !string.IsNullOrWhiteSpace(details.Reason) + ? details.Reason?.ToLowerInvariant() + : Reason.Unknown.ToLowerInvariant(); + attributes[TelemetryConstants.Variant] = details.Variant; + attributes[TelemetryConstants.Value] = details.Value; + + if (details.FlagMetadata != null) + { + attributes[TelemetryConstants.ContextId] = details.FlagMetadata.GetString(TelemetryFlagMetadata.ContextId); + attributes[TelemetryConstants.FlagSetId] = details.FlagMetadata.GetString(TelemetryFlagMetadata.FlagSetId); + attributes[TelemetryConstants.Version] = details.FlagMetadata.GetString(TelemetryFlagMetadata.Version); + } + + if (details.ErrorType != ErrorType.None) + { + attributes[TelemetryConstants.ErrorCode] = details.ErrorType.ToString().ToLowerInvariant(); + + if (!string.IsNullOrWhiteSpace(details.ErrorMessage)) + { + attributes[TelemetryConstants.ErrorMessage] = details.ErrorMessage; + } + } + + return new EvaluationEvent(EventName, attributes); + } +} diff --git a/src/OpenFeature/Telemetry/TelemetryConstants.cs b/src/OpenFeature/Telemetry/TelemetryConstants.cs new file mode 100644 index 000000000..62730666c --- /dev/null +++ b/src/OpenFeature/Telemetry/TelemetryConstants.cs @@ -0,0 +1,59 @@ +namespace OpenFeature.Telemetry; + +/// +/// The attributes of an OpenTelemetry compliant event for flag evaluation. +/// +/// +public static class TelemetryConstants +{ + /// + /// The lookup key of the feature flag. + /// + public const string Key = "feature_flag.key"; + + /// + /// Describes a class of error the operation ended with. + /// + public const string ErrorCode = "error.type"; + + /// + /// A message explaining the nature of an error occurring during flag evaluation. + /// + public const string ErrorMessage = "error.message"; + + /// + /// A semantic identifier for an evaluated flag value. + /// + public const string Variant = "feature_flag.result.variant"; + + /// + /// The evaluated value of the feature flag. + /// + public const string Value = "feature_flag.result.value"; + + /// + /// The unique identifier for the flag evaluation context. For example, the targeting key. + /// + public const string ContextId = "feature_flag.context.id"; + + /// + /// The reason code which shows how a feature flag value was determined. + /// + public const string Reason = "feature_flag.result.reason"; + + /// + /// Describes a class of error the operation ended with. + /// + public const string Provider = "feature_flag.provider.name"; + + /// + /// The identifier of the flag set to which the feature flag belongs. + /// + public const string FlagSetId = "feature_flag.set.id"; + + /// + /// The version of the ruleset used during the evaluation. This may be any stable value which uniquely identifies the ruleset. + /// + public const string Version = "feature_flag.version"; + +} diff --git a/src/OpenFeature/Telemetry/TelemetryFlagMetadata.cs b/src/OpenFeature/Telemetry/TelemetryFlagMetadata.cs new file mode 100644 index 000000000..40b58f04f --- /dev/null +++ b/src/OpenFeature/Telemetry/TelemetryFlagMetadata.cs @@ -0,0 +1,25 @@ +namespace OpenFeature.Telemetry; + +/// +/// Well-known flag metadata attributes for telemetry events. +/// See also: https://openfeature.dev/specification/appendix-d#flag-metadata +/// +public static class TelemetryFlagMetadata +{ + /// + /// The context identifier returned in the flag metadata uniquely identifies + /// the subject of the flag evaluation. If not available, the targeting key + /// should be used. + /// + public const string ContextId = "contextId"; + + /// + /// ///A logical identifier for the flag set. + /// + public const string FlagSetId = "flagSetId"; + + /// + /// A version string (format unspecified) for the flag or flag set. + /// + public const string Version = "version"; +} diff --git a/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs b/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs new file mode 100644 index 000000000..3b02a8eeb --- /dev/null +++ b/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs @@ -0,0 +1,174 @@ +using System.Collections.Generic; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Telemetry; +using Xunit; + +namespace OpenFeature.Tests.Telemetry; + +public class EvaluationEventBuilderTests +{ + private readonly EvaluationEventBuilder _builder = EvaluationEventBuilder.Default; + + [Fact] + public void Build_ShouldReturnEventWithCorrectAttributes() + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value(), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var metadata = new Dictionary + { + { "flagSetId", "flagSetId" }, { "contextId", "contextId" }, { "version", "version" } + }; + var flagMetadata = new ImmutableMetadata(metadata); + var details = new FlagEvaluationDetails("flagKey", new Value("value"), ErrorType.None, + reason: "reason", variant: "variant", flagMetadata: flagMetadata); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Equal("feature_flag.evaluation", evaluationEvent.Name); + Assert.Equal("flagKey", evaluationEvent.Attributes[TelemetryConstants.Key]); + Assert.Equal("provider", evaluationEvent.Attributes[TelemetryConstants.Provider]); + Assert.Equal("reason", evaluationEvent.Attributes[TelemetryConstants.Reason]); + Assert.Equal("variant", evaluationEvent.Attributes[TelemetryConstants.Variant]); + Assert.Equal("contextId", evaluationEvent.Attributes[TelemetryConstants.ContextId]); + Assert.Equal("flagSetId", evaluationEvent.Attributes[TelemetryConstants.FlagSetId]); + Assert.Equal("version", evaluationEvent.Attributes[TelemetryConstants.Version]); + } + + [Fact] + public void Build_ShouldHandleErrorDetails() + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value(), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var metadata = new Dictionary + { + { "flagSetId", "flagSetId" }, { "contextId", "contextId" }, { "version", "version" } + }; + var flagMetadata = new ImmutableMetadata(metadata); + var details = new FlagEvaluationDetails("flagKey", new Value("value"), ErrorType.General, + errorMessage: "errorMessage", reason: "reason", variant: "variant", flagMetadata: flagMetadata); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Equal("general", evaluationEvent.Attributes[TelemetryConstants.ErrorCode]); + Assert.Equal("errorMessage", evaluationEvent.Attributes[TelemetryConstants.ErrorMessage]); + } + + [Fact] + public void Build_ShouldHandleMissingVariant() + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value("value"), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var metadata = new Dictionary + { + { "flagSetId", "flagSetId" }, { "contextId", "contextId" }, { "version", "version" } + }; + var flagMetadata = new ImmutableMetadata(metadata); + var details = new FlagEvaluationDetails("flagKey", new Value("value"), ErrorType.None, + reason: "reason", variant: null, flagMetadata: flagMetadata); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Null(evaluationEvent.Attributes[TelemetryConstants.Variant]); + } + + [Fact] + public void Build_ShouldHandleMissingFlagMetadata() + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value("value"), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var flagMetadata = new ImmutableMetadata(); + var details = new FlagEvaluationDetails("flagKey", new Value("value"), ErrorType.None, + reason: "reason", variant: "", flagMetadata: flagMetadata); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Null(evaluationEvent.Attributes[TelemetryConstants.ContextId]); + Assert.Null(evaluationEvent.Attributes[TelemetryConstants.FlagSetId]); + Assert.Null(evaluationEvent.Attributes[TelemetryConstants.Version]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Build_ShouldHandleMissingReason(string? reason) + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value("value"), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var flagMetadata = new ImmutableMetadata(); + var details = new FlagEvaluationDetails("flagKey", new Value("value"), ErrorType.None, + reason: reason, variant: "", flagMetadata: flagMetadata); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Equal(Reason.Unknown.ToLowerInvariant(), evaluationEvent.Attributes[TelemetryConstants.Reason]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Build_ShouldHandleErrorWithEmptyErrorMessage(string? errorMessage) + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value("value"), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var flagMetadata = new ImmutableMetadata(); + var details = new FlagEvaluationDetails("flagKey", new Value("value"), ErrorType.General, + errorMessage: errorMessage, reason: "reason", variant: "", flagMetadata: flagMetadata); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Equal("general", evaluationEvent.Attributes[TelemetryConstants.ErrorCode]); + Assert.False(evaluationEvent.Attributes.ContainsKey(TelemetryConstants.ErrorMessage)); + } + + [Fact] + public void Build_ShouldIncludeValueAttributeInEvent() + { + // Arrange + var clientMetadata = new ClientMetadata("client", "1.0.0"); + var providerMetadata = new Metadata("provider"); + var hookContext = new HookContext("flagKey", new Value(), FlagValueType.Object, clientMetadata, + providerMetadata, EvaluationContext.Empty); + var testValue = new Value("test-value"); + var details = new FlagEvaluationDetails("flagKey", testValue, ErrorType.None, + reason: "reason", variant: "variant", flagMetadata: new ImmutableMetadata()); + + // Act + var evaluationEvent = _builder.Build(hookContext, details); + + // Assert + Assert.Equal(testValue, evaluationEvent.Attributes[TelemetryConstants.Value]); + } +} From 9742a0d4210d2dd6e315b4e5988261aae4352c2f Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 21 May 2025 07:11:48 +0100 Subject: [PATCH 032/126] docs: Add AspNetCore sample app (#477) ## This PR - Adds simple a AspNetCore web application with a `/welcome` minimal API that will show one of two messages based the configuration of the `InMemory` feature provider. ### Related Issues ### Notes While the [README](https://github.com/open-feature/dotnet-sdk/blob/6c44db9237748735a22a162a88f61b2de7f3e9bd/README.md) for OpenFeature dotnet is pretty comprehensive, I think it can be quite intimidating to developers who are unfamilar with feature flagging and OpenFeature. Providing a basic application that can be cloned locally and run with minimal setup would improve the onboarding and upskill experience. Additionally, as a contributor I find myself frequently creating simple applications to test out changes to OpenFeature. This would save the effort of stashing local changes and make it easier to more quickly test or prototype changes. The example could be extended with endpoints that explore the other types of feature flags and how you might utilise them within an application. ### Follow-up Tasks Update the [README](https://github.com/open-feature/dotnet-sdk/blob/6c44db9237748735a22a162a88f61b2de7f3e9bd/README.md) with a reference to the sample application to help guide developers. ### How to test --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- OpenFeature.slnx | 7 ++++ README.md | 24 +++++++++++ build/Common.samples.props | 14 +++++++ samples/AspNetCore/Program.cs | 42 +++++++++++++++++++ .../AspNetCore/Properties/launchSettings.json | 25 +++++++++++ samples/AspNetCore/README.md | 35 ++++++++++++++++ samples/AspNetCore/Samples.AspNetCore.csproj | 9 ++++ .../AspNetCore/appsettings.Development.json | 9 ++++ samples/AspNetCore/appsettings.json | 9 ++++ samples/Directory.Build.props | 3 ++ 10 files changed, 177 insertions(+) create mode 100644 build/Common.samples.props create mode 100644 samples/AspNetCore/Program.cs create mode 100644 samples/AspNetCore/Properties/launchSettings.json create mode 100644 samples/AspNetCore/README.md create mode 100644 samples/AspNetCore/Samples.AspNetCore.csproj create mode 100644 samples/AspNetCore/appsettings.Development.json create mode 100644 samples/AspNetCore/appsettings.json create mode 100644 samples/Directory.Build.props diff --git a/OpenFeature.slnx b/OpenFeature.slnx index 8c3b8a2bf..d6778e50e 100644 --- a/OpenFeature.slnx +++ b/OpenFeature.slnx @@ -39,10 +39,17 @@ + + + + + + + diff --git a/README.md b/README.md index d21c111e6..ecaeb5257 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,30 @@ public async Task Example() } ``` +### Samples + +The [`samples/`](./samples) folder contains example applications demonstrating how to use OpenFeature in different .NET scenarios. + +| Sample Name | Description | +|---------------------------------------------------|----------------------------------------------------------------| +| [AspNetCore](/samples/AspNetCore/README.md) | Feature flags in an ASP.NET Core Web API. | + +**Getting Started with a Sample:** + +1. Navigate to the sample directory + + ```shell + cd samples/AspNetCore + ``` + +2. Restore dependencies and run + + ```shell + dotnet run + ``` + +Want to contribute a new sample? See our [CONTRIBUTING](#-contributing) guide! + ## 🌟 Features | Status | Features | Description | diff --git a/build/Common.samples.props b/build/Common.samples.props new file mode 100644 index 000000000..a5b06c9b9 --- /dev/null +++ b/build/Common.samples.props @@ -0,0 +1,14 @@ + + + net9.0 + enable + enable + true + false + + + + + $(NoWarn);CA2007 + + diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs new file mode 100644 index 000000000..462370861 --- /dev/null +++ b/samples/AspNetCore/Program.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Mvc; +using OpenFeature; +using OpenFeature.DependencyInjection.Providers.Memory; +using OpenFeature.Hooks; +using OpenFeature.Providers.Memory; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddProblemDetails(); + +builder.Services.AddOpenFeature(builder => +{ + builder.AddHostedFeatureLifecycle() + .AddHook(sp => new LoggingHook(sp.GetRequiredService>())) + .AddInMemoryProvider("InMemory", provider => new Dictionary() + { + { + "welcome-message", new Flag( + new Dictionary { { "show", true }, { "hide", false } }, "show") + } + }); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +app.UseExceptionHandler(); + +app.MapGet("/welcome", async ([FromServices] IFeatureClient featureClient) => +{ + var welcomeMessageEnabled = await featureClient.GetBooleanValueAsync("welcome-message", false); + + if (welcomeMessageEnabled) + { + return TypedResults.Ok("Hello world! The welcome-message feature flag was enabled!"); + } + + return TypedResults.Ok("Hello world!"); +}); + +app.Run(); diff --git a/samples/AspNetCore/Properties/launchSettings.json b/samples/AspNetCore/Properties/launchSettings.json new file mode 100644 index 000000000..3c858af2b --- /dev/null +++ b/samples/AspNetCore/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "welcome", + "applicationUrl": "http://localhost:5412", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "welcome", + "applicationUrl": "https://localhost:7381;http://localhost:5412", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/AspNetCore/README.md b/samples/AspNetCore/README.md new file mode 100644 index 000000000..690206938 --- /dev/null +++ b/samples/AspNetCore/README.md @@ -0,0 +1,35 @@ +# OpenFeature Dotnet Web Sample + +This sample demonstrates how to use the OpenFeature .NET Web Application. It includes a simple .NET 9 web application that retrieves and evaluates feature flags using the OpenFeature client. The sample is set up with the `InMemoryProvider` and a relatively simple boolean `welcome-message` feature flag. + +The sample can easily be extended with alternative providers, which you can find in the [dotnet-sdk-contrib](https://github.com/open-feature/dotnet-sdk-contrib) repository. + +## Prerequisites + +- [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0) installed on your machine. + +## Setup + +1. Clone the repository: + + ```shell + git clone https://github.com/open-feature/dotnet-sdk.git openfeature-dotnet-sdk + ``` + +1. Navigate to the Web sample project directory: + + ```shell + cd openfeature-dotnet-sdk/samples/AspNetCore + ``` + +1. Run the following command to start the application: + + ```shell + dotnet run + ``` + +1. Open your web browser and navigate to `http://localhost:5412/welcome` to see the application in action. + +### Enable OpenFeature debug logging + +You can enable OpenFeature debug logging by setting the `Logging:LogLevel:OpenFeature.*` setting in [appsettings.Development.json](appsettings.Development.json) to `Debug`. This will provide detailed logs of the OpenFeature SDK's operations, which can be helpful for troubleshooting and understanding how feature flags are being evaluated. diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj new file mode 100644 index 000000000..01e452d77 --- /dev/null +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/samples/AspNetCore/appsettings.Development.json b/samples/AspNetCore/appsettings.Development.json new file mode 100644 index 000000000..23fbf6b62 --- /dev/null +++ b/samples/AspNetCore/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "OpenFeature.*": "Information" + } + } +} diff --git a/samples/AspNetCore/appsettings.json b/samples/AspNetCore/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/samples/AspNetCore/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props new file mode 100644 index 000000000..7e9694ce7 --- /dev/null +++ b/samples/Directory.Build.props @@ -0,0 +1,3 @@ + + + From 6d7a5359bfcb8d12d648a75cf469e93909b999ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 23 May 2025 14:22:45 +0100 Subject: [PATCH 033/126] chore: Cleanup .props file (#476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> ## This PR This pull request introduces several changes to improve project configuration and clean up unused code. The most significant updates include enabling implicit usings globally, removing redundant `Nullable` and `ImplicitUsings` properties from specific project files, and cleaning up unused `using` directives across multiple source files. ### Project configuration improvements: * Enabled `ImplicitUsings` globally in `build/Common.props` to streamline code and reduce boilerplate. Removed redundant `TreatWarningsAsErrors` configuration for the Release build. [[1]](diffhunk://#diff-3e9cdf5c2ef721be7342fcd07bd9e3567805d13357c97221e15137225f64e063R10) [[2]](diffhunk://#diff-3e9cdf5c2ef721be7342fcd07bd9e3567805d13357c97221e15137225f64e063L21-L24) * Added conditional `Using` directives for test projects in `build/Common.tests.props`, ensuring proper dependencies for E2E tests. ### Code cleanup: * Removed redundant `Nullable` and `ImplicitUsings` properties from `OpenFeature.DependencyInjection.csproj` and `OpenFeature.Hosting.csproj` as these are now handled globally. [[1]](diffhunk://#diff-55279cca9fb64343216b7896e3aa5ea96521b091ba68f0b06a2e9430b0a35962L5-L6) [[2]](diffhunk://#diff-1ceba8f8b16fba8e2b45f9f2f2c78e2e92129949e560f4346df2dd99fdbbd5f4L5-L6) * Cleaned up unused `using` directives across multiple source files, including `Api.cs`, `AsyncLocalTransactionContextPropagator.cs`, and various exception classes, to improve readability and maintainability. [[1]](diffhunk://#diff-dc150fbd3b7be797470374ee7e38c7ab8a31eb9b6b7a52345e05114c2d32a15eL1-L6) [[2]](diffhunk://#diff-d9ca58e32d696079f875c51837dfc6ded087b06eb3aef3513f5ea15ebc22c700L1) [[3]](diffhunk://#diff-a7a382f3d0da52015d1b9e03802692a86c61e7b57580747f31fae37d8dcc5cd6L1) [[4]](diffhunk://#diff-b9fdde9b61e62d7c474069ea5fc2a43d123ab86d93cb9a6d0673254a64536722L1) [[5]](diffhunk://#diff-bb4aadb03ea44e3b6b74b83f12b2b1a258e13836bcf815f996addcd5537a478fL1) [[6]](diffhunk://#diff-89efe60a8b640cc303a38e5b01bc27ad40599e364ff2654a57b7e42138056cdaL1) [[7]](diffhunk://#diff-7a8cbfba7673f1b69d3d927f60eb4ab0aa548403a118fad9eb43b9a22875dc02L1) [[8]](diffhunk://#diff-48ae8447bc31a75ecf4bba768a7b68244fbbc9dd5480db9114d9c74fb10fe2efL1) [[9]](diffhunk://#diff-9a3b5bf7bf3351a6161fc6dd75830bfbaab7aca730cf3f0ae6cd120a76b2f1b1L1) [[10]](diffhunk://#diff-c4388b2e7252e2e3ac0967dbfdd4647a924cdfc54da229667a0db3613b243a7eL1) [[11]](diffhunk://#diff-44c88ae43caf99ee733ec911fa85454a96c57d07fc57d2fadd44e12cd7d31cd4L1) [[12]](diffhunk://#diff-f563baadcf750bb66efaea76d7ec4783320e6efdc45c468effb531c948a2292cL1-L4) [[13]](diffhunk://#diff-f94bf65f426e13a14f798a8db21a5c9dd3306f5941bde1aba79af3e41421bfc0L1-L3) [[14]](diffhunk://#diff-96ebc8fc507d0a19d55b9a5cb57b72a0e8058e09f31ee7d0b39e99b00c5029d8L2-L4) [[15]](diffhunk://#diff-d69e6a4b3c0fb22dcb05a1104cd353a597bc8f54bb29c458ed41d394d8f1c12aL1-L4) [[16]](diffhunk://#diff-ac87556ad78da36624700cf5bea44dc7cf279c59bb486203b6af9689427a8c9cL1) --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- build/Common.props | 5 +---- build/Common.tests.props | 5 +++++ .../OpenFeature.DependencyInjection.csproj | 2 -- src/OpenFeature.Hosting/OpenFeature.Hosting.csproj | 2 -- src/OpenFeature/Api.cs | 5 ----- .../AsyncLocalTransactionContextPropagator.cs | 1 - src/OpenFeature/Error/FeatureProviderException.cs | 1 - src/OpenFeature/Error/FlagNotFoundException.cs | 1 - src/OpenFeature/Error/GeneralException.cs | 1 - src/OpenFeature/Error/InvalidContextException.cs | 1 - src/OpenFeature/Error/ParseErrorException.cs | 1 - src/OpenFeature/Error/ProviderFatalException.cs | 1 - src/OpenFeature/Error/ProviderNotReadyException.cs | 1 - src/OpenFeature/Error/TargetingKeyMissingException.cs | 1 - src/OpenFeature/Error/TypeMismatchException.cs | 1 - src/OpenFeature/EventExecutor.cs | 3 --- src/OpenFeature/Extension/EnumExtensions.cs | 2 -- src/OpenFeature/FeatureProvider.cs | 2 -- src/OpenFeature/Hook.cs | 4 ---- src/OpenFeature/HookData.cs | 1 - src/OpenFeature/HookRunner.cs | 4 ---- src/OpenFeature/Hooks/LoggingHook.cs | 4 ---- src/OpenFeature/IFeatureClient.cs | 3 --- src/OpenFeature/Model/EvaluationContext.cs | 2 -- src/OpenFeature/Model/EvaluationContextBuilder.cs | 2 -- src/OpenFeature/Model/HookContext.cs | 1 - src/OpenFeature/Model/ImmutableMetadata.cs | 1 - src/OpenFeature/Model/ProviderEvents.cs | 1 - src/OpenFeature/Model/Structure.cs | 1 - src/OpenFeature/Model/StructureBuilder.cs | 2 -- src/OpenFeature/Model/TrackingEventDetails.cs | 2 -- src/OpenFeature/Model/TrackingEventDetailsBuilder.cs | 2 -- src/OpenFeature/Model/Value.cs | 2 -- src/OpenFeature/NoOpProvider.cs | 2 -- src/OpenFeature/OpenFeatureClient.cs | 5 ----- src/OpenFeature/ProviderRepository.cs | 4 ---- src/OpenFeature/Providers/Memory/Flag.cs | 2 -- src/OpenFeature/Providers/Memory/InMemoryProvider.cs | 4 ---- src/OpenFeature/SharedHookContext.cs | 1 - src/OpenFeature/Telemetry/EvaluationEvent.cs | 2 -- src/OpenFeature/Telemetry/EvaluationEventBuilder.cs | 1 - .../OpenFeatureClientBenchmarks.cs | 1 - .../FeatureLifecycleManagerTests.cs | 1 - .../OpenFeature.DependencyInjection.Tests.csproj | 5 ----- .../OpenFeatureBuilderExtensionsTests.cs | 1 - .../OpenFeatureServiceCollectionExtensionsTests.cs | 1 - test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs | 5 ----- .../Steps/ContextMergingPrecedenceStepDefinitions.cs | 3 --- .../Steps/EvaluationStepDefinitions.cs | 3 --- test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs | 2 -- .../Steps/MetadataStepDefinitions.cs | 4 ---- .../OpenFeature.E2ETests/Utils/ContextStoringProvider.cs | 2 -- test/OpenFeature.E2ETests/Utils/FlagTypesUtil.cs | 1 - test/OpenFeature.E2ETests/Utils/TestHook.cs | 4 ---- .../OpenFeature.IntegrationTests.csproj | 9 --------- .../AsyncLocalTransactionContextPropagatorTests.cs | 1 - .../OpenFeature.Tests/ClearOpenFeatureInstanceFixture.cs | 3 --- test/OpenFeature.Tests/FeatureProviderExceptionTests.cs | 2 -- test/OpenFeature.Tests/FeatureProviderTests.cs | 2 -- test/OpenFeature.Tests/HookDataTests.cs | 3 --- test/OpenFeature.Tests/Hooks/LoggingHookTests.cs | 3 --- test/OpenFeature.Tests/ImmutableMetadataTest.cs | 2 -- .../OpenFeature.Tests/Internal/SpecificationAttribute.cs | 2 -- test/OpenFeature.Tests/OpenFeatureClientTests.cs | 6 ------ .../OpenFeatureEvaluationContextTests.cs | 3 --- test/OpenFeature.Tests/OpenFeatureEventTests.cs | 5 ----- test/OpenFeature.Tests/OpenFeatureHookTests.cs | 6 ------ test/OpenFeature.Tests/OpenFeatureTests.cs | 4 ---- test/OpenFeature.Tests/ProviderRepositoryTests.cs | 3 --- .../Providers/Memory/InMemoryProviderTests.cs | 3 --- test/OpenFeature.Tests/StructureTests.cs | 3 --- .../Telemetry/EvaluationEventBuilderTests.cs | 2 -- test/OpenFeature.Tests/TestImplementations.cs | 4 ---- test/OpenFeature.Tests/TestUtils.cs | 5 ----- test/OpenFeature.Tests/TestUtilsTest.cs | 4 ---- test/OpenFeature.Tests/TrackingEventDetailsTest.cs | 3 --- test/OpenFeature.Tests/ValueTests.cs | 3 --- 77 files changed, 6 insertions(+), 197 deletions(-) diff --git a/build/Common.props b/build/Common.props index 1c769bcf7..287b32312 100644 --- a/build/Common.props +++ b/build/Common.props @@ -7,6 +7,7 @@ EnableGenerateDocumentationFile enable + enable true true all @@ -18,10 +19,6 @@ true - - true - - diff --git a/build/Common.tests.props b/build/Common.tests.props index 3aac9cf9a..054ad94d4 100644 --- a/build/Common.tests.props +++ b/build/Common.tests.props @@ -13,6 +13,11 @@ PreserveNewest + + + + + diff --git a/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj index 99270ab38..855ab2ab2 100644 --- a/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj +++ b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj @@ -2,8 +2,6 @@ netstandard2.0;net8.0;net9.0;net462 - enable - enable OpenFeature.DependencyInjection diff --git a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj index 43237e0fc..1d54ff02e 100644 --- a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj +++ b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj @@ -2,8 +2,6 @@ net8.0;net9.0 - enable - enable OpenFeature diff --git a/src/OpenFeature/Api.cs b/src/OpenFeature/Api.cs index cc0161c10..cea661398 100644 --- a/src/OpenFeature/Api.cs +++ b/src/OpenFeature/Api.cs @@ -1,9 +1,4 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using OpenFeature.Constant; using OpenFeature.Error; diff --git a/src/OpenFeature/AsyncLocalTransactionContextPropagator.cs b/src/OpenFeature/AsyncLocalTransactionContextPropagator.cs index 86992aacd..723c3dcb5 100644 --- a/src/OpenFeature/AsyncLocalTransactionContextPropagator.cs +++ b/src/OpenFeature/AsyncLocalTransactionContextPropagator.cs @@ -1,4 +1,3 @@ -using System.Threading; using OpenFeature.Model; namespace OpenFeature; diff --git a/src/OpenFeature/Error/FeatureProviderException.cs b/src/OpenFeature/Error/FeatureProviderException.cs index b0431ab7b..162f2c936 100644 --- a/src/OpenFeature/Error/FeatureProviderException.cs +++ b/src/OpenFeature/Error/FeatureProviderException.cs @@ -1,4 +1,3 @@ -using System; using OpenFeature.Constant; namespace OpenFeature.Error; diff --git a/src/OpenFeature/Error/FlagNotFoundException.cs b/src/OpenFeature/Error/FlagNotFoundException.cs index d685bb4a4..a605e9202 100644 --- a/src/OpenFeature/Error/FlagNotFoundException.cs +++ b/src/OpenFeature/Error/FlagNotFoundException.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; diff --git a/src/OpenFeature/Error/GeneralException.cs b/src/OpenFeature/Error/GeneralException.cs index 0f9da24ca..ac9caad48 100644 --- a/src/OpenFeature/Error/GeneralException.cs +++ b/src/OpenFeature/Error/GeneralException.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; diff --git a/src/OpenFeature/Error/InvalidContextException.cs b/src/OpenFeature/Error/InvalidContextException.cs index 881d0464f..526a8775e 100644 --- a/src/OpenFeature/Error/InvalidContextException.cs +++ b/src/OpenFeature/Error/InvalidContextException.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; diff --git a/src/OpenFeature/Error/ParseErrorException.cs b/src/OpenFeature/Error/ParseErrorException.cs index 57bcf2719..364437e8b 100644 --- a/src/OpenFeature/Error/ParseErrorException.cs +++ b/src/OpenFeature/Error/ParseErrorException.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; diff --git a/src/OpenFeature/Error/ProviderFatalException.cs b/src/OpenFeature/Error/ProviderFatalException.cs index 60ba5f251..3865f29f6 100644 --- a/src/OpenFeature/Error/ProviderFatalException.cs +++ b/src/OpenFeature/Error/ProviderFatalException.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; diff --git a/src/OpenFeature/Error/ProviderNotReadyException.cs b/src/OpenFeature/Error/ProviderNotReadyException.cs index 5d2e3af18..b301a0c85 100644 --- a/src/OpenFeature/Error/ProviderNotReadyException.cs +++ b/src/OpenFeature/Error/ProviderNotReadyException.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; diff --git a/src/OpenFeature/Error/TargetingKeyMissingException.cs b/src/OpenFeature/Error/TargetingKeyMissingException.cs index 488009f41..e655ffc74 100644 --- a/src/OpenFeature/Error/TargetingKeyMissingException.cs +++ b/src/OpenFeature/Error/TargetingKeyMissingException.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; diff --git a/src/OpenFeature/Error/TypeMismatchException.cs b/src/OpenFeature/Error/TypeMismatchException.cs index 2df3b29f0..c53b98f68 100644 --- a/src/OpenFeature/Error/TypeMismatchException.cs +++ b/src/OpenFeature/Error/TypeMismatchException.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics.CodeAnalysis; using OpenFeature.Constant; diff --git a/src/OpenFeature/EventExecutor.cs b/src/OpenFeature/EventExecutor.cs index edb75780a..db2b6fb10 100644 --- a/src/OpenFeature/EventExecutor.cs +++ b/src/OpenFeature/EventExecutor.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Threading.Channels; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using OpenFeature.Constant; diff --git a/src/OpenFeature/Extension/EnumExtensions.cs b/src/OpenFeature/Extension/EnumExtensions.cs index d5d7e72b9..73c391250 100644 --- a/src/OpenFeature/Extension/EnumExtensions.cs +++ b/src/OpenFeature/Extension/EnumExtensions.cs @@ -1,6 +1,4 @@ -using System; using System.ComponentModel; -using System.Linq; namespace OpenFeature.Extension; diff --git a/src/OpenFeature/FeatureProvider.cs b/src/OpenFeature/FeatureProvider.cs index 9c9d93277..16c495c62 100644 --- a/src/OpenFeature/FeatureProvider.cs +++ b/src/OpenFeature/FeatureProvider.cs @@ -1,7 +1,5 @@ using System.Collections.Immutable; -using System.Threading; using System.Threading.Channels; -using System.Threading.Tasks; using OpenFeature.Constant; using OpenFeature.Model; diff --git a/src/OpenFeature/Hook.cs b/src/OpenFeature/Hook.cs index d38550ffd..cd9f76d21 100644 --- a/src/OpenFeature/Hook.cs +++ b/src/OpenFeature/Hook.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using OpenFeature.Model; namespace OpenFeature; diff --git a/src/OpenFeature/HookData.cs b/src/OpenFeature/HookData.cs index ecfdfabd4..b450b3bbf 100644 --- a/src/OpenFeature/HookData.cs +++ b/src/OpenFeature/HookData.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Collections.Immutable; using OpenFeature.Model; diff --git a/src/OpenFeature/HookRunner.cs b/src/OpenFeature/HookRunner.cs index c80b86131..073227742 100644 --- a/src/OpenFeature/HookRunner.cs +++ b/src/OpenFeature/HookRunner.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using OpenFeature.Model; diff --git a/src/OpenFeature/Hooks/LoggingHook.cs b/src/OpenFeature/Hooks/LoggingHook.cs index b83081678..77bf703f7 100644 --- a/src/OpenFeature/Hooks/LoggingHook.cs +++ b/src/OpenFeature/Hooks/LoggingHook.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Text; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using OpenFeature.Model; diff --git a/src/OpenFeature/IFeatureClient.cs b/src/OpenFeature/IFeatureClient.cs index c14e6e4bf..acf38804f 100644 --- a/src/OpenFeature/IFeatureClient.cs +++ b/src/OpenFeature/IFeatureClient.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using OpenFeature.Constant; using OpenFeature.Model; diff --git a/src/OpenFeature/Model/EvaluationContext.cs b/src/OpenFeature/Model/EvaluationContext.cs index ed4f989a8..28b522e29 100644 --- a/src/OpenFeature/Model/EvaluationContext.cs +++ b/src/OpenFeature/Model/EvaluationContext.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; namespace OpenFeature.Model; diff --git a/src/OpenFeature/Model/EvaluationContextBuilder.cs b/src/OpenFeature/Model/EvaluationContextBuilder.cs index 3d85ba984..4f1ccc4d4 100644 --- a/src/OpenFeature/Model/EvaluationContextBuilder.cs +++ b/src/OpenFeature/Model/EvaluationContextBuilder.cs @@ -1,5 +1,3 @@ -using System; - namespace OpenFeature.Model; /// diff --git a/src/OpenFeature/Model/HookContext.cs b/src/OpenFeature/Model/HookContext.cs index 4abc773cb..c6448cbd6 100644 --- a/src/OpenFeature/Model/HookContext.cs +++ b/src/OpenFeature/Model/HookContext.cs @@ -1,4 +1,3 @@ -using System; using OpenFeature.Constant; namespace OpenFeature.Model; diff --git a/src/OpenFeature/Model/ImmutableMetadata.cs b/src/OpenFeature/Model/ImmutableMetadata.cs index f1d544499..5af7b5559 100644 --- a/src/OpenFeature/Model/ImmutableMetadata.cs +++ b/src/OpenFeature/Model/ImmutableMetadata.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Collections.Immutable; namespace OpenFeature.Model; diff --git a/src/OpenFeature/Model/ProviderEvents.cs b/src/OpenFeature/Model/ProviderEvents.cs index 1977edb69..18b9ec5cd 100644 --- a/src/OpenFeature/Model/ProviderEvents.cs +++ b/src/OpenFeature/Model/ProviderEvents.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using OpenFeature.Constant; namespace OpenFeature.Model; diff --git a/src/OpenFeature/Model/Structure.cs b/src/OpenFeature/Model/Structure.cs index 9807ec45d..55a7babe3 100644 --- a/src/OpenFeature/Model/Structure.cs +++ b/src/OpenFeature/Model/Structure.cs @@ -1,5 +1,4 @@ using System.Collections; -using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; diff --git a/src/OpenFeature/Model/StructureBuilder.cs b/src/OpenFeature/Model/StructureBuilder.cs index 0cc922aca..eaa7de74b 100644 --- a/src/OpenFeature/Model/StructureBuilder.cs +++ b/src/OpenFeature/Model/StructureBuilder.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; namespace OpenFeature.Model; diff --git a/src/OpenFeature/Model/TrackingEventDetails.cs b/src/OpenFeature/Model/TrackingEventDetails.cs index 0d342cc1b..3e3e43120 100644 --- a/src/OpenFeature/Model/TrackingEventDetails.cs +++ b/src/OpenFeature/Model/TrackingEventDetails.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; namespace OpenFeature.Model; diff --git a/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs b/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs index 6520ab3e5..60381f8b7 100644 --- a/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs +++ b/src/OpenFeature/Model/TrackingEventDetailsBuilder.cs @@ -1,5 +1,3 @@ -using System; - namespace OpenFeature.Model; /// diff --git a/src/OpenFeature/Model/Value.cs b/src/OpenFeature/Model/Value.cs index 2f75eca36..f09a24667 100644 --- a/src/OpenFeature/Model/Value.cs +++ b/src/OpenFeature/Model/Value.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; namespace OpenFeature.Model; diff --git a/src/OpenFeature/NoOpProvider.cs b/src/OpenFeature/NoOpProvider.cs index 20973365d..0317fbd20 100644 --- a/src/OpenFeature/NoOpProvider.cs +++ b/src/OpenFeature/NoOpProvider.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using OpenFeature.Constant; using OpenFeature.Model; diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index 98aae19fb..02acde07c 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -1,10 +1,5 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using OpenFeature.Constant; diff --git a/src/OpenFeature/ProviderRepository.cs b/src/OpenFeature/ProviderRepository.cs index 54e797db3..4f938940d 100644 --- a/src/OpenFeature/ProviderRepository.cs +++ b/src/OpenFeature/ProviderRepository.cs @@ -1,8 +1,4 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using OpenFeature.Constant; diff --git a/src/OpenFeature/Providers/Memory/Flag.cs b/src/OpenFeature/Providers/Memory/Flag.cs index fd8cf19f9..f42b0af7b 100644 --- a/src/OpenFeature/Providers/Memory/Flag.cs +++ b/src/OpenFeature/Providers/Memory/Flag.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using OpenFeature.Constant; using OpenFeature.Error; using OpenFeature.Model; diff --git a/src/OpenFeature/Providers/Memory/InMemoryProvider.cs b/src/OpenFeature/Providers/Memory/InMemoryProvider.cs index fce7afe1f..617a2ecb0 100644 --- a/src/OpenFeature/Providers/Memory/InMemoryProvider.cs +++ b/src/OpenFeature/Providers/Memory/InMemoryProvider.cs @@ -1,7 +1,3 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using OpenFeature.Constant; using OpenFeature.Model; diff --git a/src/OpenFeature/SharedHookContext.cs b/src/OpenFeature/SharedHookContext.cs index c364e40ca..887e19fc9 100644 --- a/src/OpenFeature/SharedHookContext.cs +++ b/src/OpenFeature/SharedHookContext.cs @@ -1,4 +1,3 @@ -using System; using OpenFeature.Constant; using OpenFeature.Model; diff --git a/src/OpenFeature/Telemetry/EvaluationEvent.cs b/src/OpenFeature/Telemetry/EvaluationEvent.cs index 51506ad77..a2a8c2759 100644 --- a/src/OpenFeature/Telemetry/EvaluationEvent.cs +++ b/src/OpenFeature/Telemetry/EvaluationEvent.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace OpenFeature.Telemetry; /// diff --git a/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs b/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs index 2f73224ff..d9520c124 100644 --- a/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs +++ b/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using OpenFeature.Constant; using OpenFeature.Model; diff --git a/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs b/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs index d4c770ebf..0c2e614aa 100644 --- a/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs +++ b/test/OpenFeature.Benchmarks/OpenFeatureClientBenchmarks.cs @@ -1,6 +1,5 @@ using System.Collections.Immutable; -using System.Threading.Tasks; using AutoFixture; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Jobs; diff --git a/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs index db9ac4e00..8dc6a80bc 100644 --- a/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs +++ b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs @@ -4,7 +4,6 @@ using OpenFeature.Constant; using OpenFeature.DependencyInjection.Internal; using OpenFeature.Model; -using Xunit; namespace OpenFeature.DependencyInjection.Tests; diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj b/test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj index 4d714afe6..d6bce29e8 100644 --- a/test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj @@ -2,11 +2,6 @@ net8.0;net9.0 - enable - enable - - false - true diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs index f742f98d8..f1edca4c4 100644 --- a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Options; using OpenFeature.DependencyInjection.Internal; using OpenFeature.Model; -using Xunit; namespace OpenFeature.DependencyInjection.Tests; diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs index d3ce5c8e1..ddda3f224 100644 --- a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.DependencyInjection; using NSubstitute; -using Xunit; namespace OpenFeature.DependencyInjection.Tests; diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs index 6b2bfebfc..7a9c3c0a9 100644 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs @@ -1,11 +1,6 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using OpenFeature.E2ETests.Utils; using OpenFeature.Model; using OpenFeature.Providers.Memory; -using Reqnroll; namespace OpenFeature.E2ETests.Steps; diff --git a/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs index c9f454ac9..c95b20495 100644 --- a/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs @@ -1,7 +1,4 @@ -using System.Threading.Tasks; using OpenFeature.E2ETests.Utils; -using Reqnroll; -using Xunit; namespace OpenFeature.E2ETests.Steps; diff --git a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs index 6efcf3d4e..27e00359b 100644 --- a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs @@ -1,10 +1,7 @@ -using System.Threading.Tasks; using OpenFeature.Constant; using OpenFeature.E2ETests.Utils; using OpenFeature.Extension; using OpenFeature.Model; -using Reqnroll; -using Xunit; namespace OpenFeature.E2ETests.Steps; diff --git a/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs index c8882baa8..a79a616aa 100644 --- a/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs @@ -1,6 +1,4 @@ using OpenFeature.E2ETests.Utils; -using Reqnroll; -using Xunit; namespace OpenFeature.E2ETests.Steps; diff --git a/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs index 63c8cdbe3..033e9bd6c 100644 --- a/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs @@ -1,9 +1,5 @@ -using System; -using System.Linq; using OpenFeature.E2ETests.Utils; using OpenFeature.Model; -using Reqnroll; -using Xunit; namespace OpenFeature.E2ETests.Steps; diff --git a/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs b/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs index 40141e791..193f4f44b 100644 --- a/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs +++ b/test/OpenFeature.E2ETests/Utils/ContextStoringProvider.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using OpenFeature.Model; namespace OpenFeature.E2ETests.Utils; diff --git a/test/OpenFeature.E2ETests/Utils/FlagTypesUtil.cs b/test/OpenFeature.E2ETests/Utils/FlagTypesUtil.cs index 5b05c799a..bc065a7b0 100644 --- a/test/OpenFeature.E2ETests/Utils/FlagTypesUtil.cs +++ b/test/OpenFeature.E2ETests/Utils/FlagTypesUtil.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics.CodeAnalysis; namespace OpenFeature.E2ETests.Utils; diff --git a/test/OpenFeature.E2ETests/Utils/TestHook.cs b/test/OpenFeature.E2ETests/Utils/TestHook.cs index fbe7568b1..4167571ae 100644 --- a/test/OpenFeature.E2ETests/Utils/TestHook.cs +++ b/test/OpenFeature.E2ETests/Utils/TestHook.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; using OpenFeature.Model; namespace OpenFeature.E2ETests.Utils; diff --git a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj index 151c61b9d..aabe1a599 100644 --- a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj +++ b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj @@ -2,11 +2,6 @@ net9.0 - enable - enable - - false - true @@ -27,8 +22,4 @@ - - - - diff --git a/test/OpenFeature.Tests/AsyncLocalTransactionContextPropagatorTests.cs b/test/OpenFeature.Tests/AsyncLocalTransactionContextPropagatorTests.cs index 51c94bfba..4c0ef5447 100644 --- a/test/OpenFeature.Tests/AsyncLocalTransactionContextPropagatorTests.cs +++ b/test/OpenFeature.Tests/AsyncLocalTransactionContextPropagatorTests.cs @@ -1,5 +1,4 @@ using OpenFeature.Model; -using Xunit; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/ClearOpenFeatureInstanceFixture.cs b/test/OpenFeature.Tests/ClearOpenFeatureInstanceFixture.cs index c33518016..7ef471e40 100644 --- a/test/OpenFeature.Tests/ClearOpenFeatureInstanceFixture.cs +++ b/test/OpenFeature.Tests/ClearOpenFeatureInstanceFixture.cs @@ -1,6 +1,3 @@ -using System.Threading.Tasks; -using Xunit; - namespace OpenFeature.Tests; public class ClearOpenFeatureInstanceFixture : IAsyncLifetime diff --git a/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs b/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs index 334f664e4..e1645269e 100644 --- a/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs +++ b/test/OpenFeature.Tests/FeatureProviderExceptionTests.cs @@ -1,8 +1,6 @@ -using System; using OpenFeature.Constant; using OpenFeature.Error; using OpenFeature.Extension; -using Xunit; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/FeatureProviderTests.cs b/test/OpenFeature.Tests/FeatureProviderTests.cs index d7e5ca221..8bea70379 100644 --- a/test/OpenFeature.Tests/FeatureProviderTests.cs +++ b/test/OpenFeature.Tests/FeatureProviderTests.cs @@ -1,10 +1,8 @@ -using System.Threading.Tasks; using AutoFixture; using NSubstitute; using OpenFeature.Constant; using OpenFeature.Model; using OpenFeature.Tests.Internal; -using Xunit; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/HookDataTests.cs b/test/OpenFeature.Tests/HookDataTests.cs index 96cbaf723..0a6ee7696 100644 --- a/test/OpenFeature.Tests/HookDataTests.cs +++ b/test/OpenFeature.Tests/HookDataTests.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using OpenFeature.Model; -using Xunit; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs b/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs index 1364f83f5..5c27959bc 100644 --- a/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs +++ b/test/OpenFeature.Tests/Hooks/LoggingHookTests.cs @@ -1,11 +1,8 @@ -using System; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using OpenFeature.Constant; using OpenFeature.Hooks; using OpenFeature.Model; -using Xunit; namespace OpenFeature.Tests.Hooks; diff --git a/test/OpenFeature.Tests/ImmutableMetadataTest.cs b/test/OpenFeature.Tests/ImmutableMetadataTest.cs index cd2fd1d8e..e1324a054 100644 --- a/test/OpenFeature.Tests/ImmutableMetadataTest.cs +++ b/test/OpenFeature.Tests/ImmutableMetadataTest.cs @@ -1,7 +1,5 @@ -using System.Collections.Generic; using OpenFeature.Model; using OpenFeature.Tests.Internal; -using Xunit; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs b/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs index 9fea9fef7..99fb541b5 100644 --- a/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs +++ b/test/OpenFeature.Tests/Internal/SpecificationAttribute.cs @@ -1,5 +1,3 @@ -using System; - namespace OpenFeature.Tests.Internal; [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] diff --git a/test/OpenFeature.Tests/OpenFeatureClientTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTests.cs index a43cf4d87..cbecddc28 100644 --- a/test/OpenFeature.Tests/OpenFeatureClientTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureClientTests.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using AutoFixture; using Microsoft.Extensions.Logging; using NSubstitute; @@ -13,7 +8,6 @@ using OpenFeature.Extension; using OpenFeature.Model; using OpenFeature.Tests.Internal; -using Xunit; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs b/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs index 630ec435e..9043ea439 100644 --- a/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureEvaluationContextTests.cs @@ -1,9 +1,6 @@ -using System; -using System.Collections.Generic; using AutoFixture; using OpenFeature.Model; using OpenFeature.Tests.Internal; -using Xunit; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/OpenFeatureEventTests.cs b/test/OpenFeature.Tests/OpenFeatureEventTests.cs index d47a530fb..45821899c 100644 --- a/test/OpenFeature.Tests/OpenFeatureEventTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureEventTests.cs @@ -1,13 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using AutoFixture; using NSubstitute; using OpenFeature.Constant; using OpenFeature.Model; using OpenFeature.Tests.Internal; -using Xunit; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/OpenFeatureHookTests.cs b/test/OpenFeature.Tests/OpenFeatureHookTests.cs index cebe40c0e..22a0b17a3 100644 --- a/test/OpenFeature.Tests/OpenFeatureHookTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureHookTests.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using AutoFixture; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -11,7 +6,6 @@ using OpenFeature.Error; using OpenFeature.Model; using OpenFeature.Tests.Internal; -using Xunit; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/OpenFeatureTests.cs b/test/OpenFeature.Tests/OpenFeatureTests.cs index 6afcc91ca..9eb0aa40b 100644 --- a/test/OpenFeature.Tests/OpenFeatureTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureTests.cs @@ -1,11 +1,7 @@ -using System; -using System.Linq; -using System.Threading.Tasks; using NSubstitute; using OpenFeature.Constant; using OpenFeature.Model; using OpenFeature.Tests.Internal; -using Xunit; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/ProviderRepositoryTests.cs b/test/OpenFeature.Tests/ProviderRepositoryTests.cs index 16fb5d133..4284eaeeb 100644 --- a/test/OpenFeature.Tests/ProviderRepositoryTests.cs +++ b/test/OpenFeature.Tests/ProviderRepositoryTests.cs @@ -1,9 +1,6 @@ -using System; -using System.Threading.Tasks; using NSubstitute; using OpenFeature.Constant; using OpenFeature.Model; -using Xunit; // We intentionally do not await for purposes of validating behavior. #pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed diff --git a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs index 6a196fd59..6b04f2f3d 100644 --- a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs @@ -1,10 +1,7 @@ -using System.Collections.Generic; -using System.Threading.Tasks; using OpenFeature.Constant; using OpenFeature.Error; using OpenFeature.Model; using OpenFeature.Providers.Memory; -using Xunit; namespace OpenFeature.Tests.Providers.Memory; diff --git a/test/OpenFeature.Tests/StructureTests.cs b/test/OpenFeature.Tests/StructureTests.cs index 484e2b19d..b2b4e1c0f 100644 --- a/test/OpenFeature.Tests/StructureTests.cs +++ b/test/OpenFeature.Tests/StructureTests.cs @@ -1,8 +1,5 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; using OpenFeature.Model; -using Xunit; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs b/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs index 3b02a8eeb..8bebd3f5d 100644 --- a/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs +++ b/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs @@ -1,8 +1,6 @@ -using System.Collections.Generic; using OpenFeature.Constant; using OpenFeature.Model; using OpenFeature.Telemetry; -using Xunit; namespace OpenFeature.Tests.Telemetry; diff --git a/test/OpenFeature.Tests/TestImplementations.cs b/test/OpenFeature.Tests/TestImplementations.cs index 4c298c880..87ca4fa00 100644 --- a/test/OpenFeature.Tests/TestImplementations.cs +++ b/test/OpenFeature.Tests/TestImplementations.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Threading; -using System.Threading.Tasks; using OpenFeature.Constant; using OpenFeature.Model; diff --git a/test/OpenFeature.Tests/TestUtils.cs b/test/OpenFeature.Tests/TestUtils.cs index 15348db20..1487b928f 100644 --- a/test/OpenFeature.Tests/TestUtils.cs +++ b/test/OpenFeature.Tests/TestUtils.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - internal class Utils { /// diff --git a/test/OpenFeature.Tests/TestUtilsTest.cs b/test/OpenFeature.Tests/TestUtilsTest.cs index ab7867baf..e414ee7f0 100644 --- a/test/OpenFeature.Tests/TestUtilsTest.cs +++ b/test/OpenFeature.Tests/TestUtilsTest.cs @@ -1,7 +1,3 @@ -using System; -using System.Threading.Tasks; -using Xunit; - namespace OpenFeature.Tests; public class TestUtilsTest diff --git a/test/OpenFeature.Tests/TrackingEventDetailsTest.cs b/test/OpenFeature.Tests/TrackingEventDetailsTest.cs index 22b1ce45c..b704286fe 100644 --- a/test/OpenFeature.Tests/TrackingEventDetailsTest.cs +++ b/test/OpenFeature.Tests/TrackingEventDetailsTest.cs @@ -1,8 +1,5 @@ -using System; -using System.Collections.Generic; using OpenFeature.Model; using OpenFeature.Tests.Internal; -using Xunit; namespace OpenFeature.Tests; diff --git a/test/OpenFeature.Tests/ValueTests.cs b/test/OpenFeature.Tests/ValueTests.cs index 34a2eb6b1..da76f29ad 100644 --- a/test/OpenFeature.Tests/ValueTests.cs +++ b/test/OpenFeature.Tests/ValueTests.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using OpenFeature.Model; -using Xunit; namespace OpenFeature.Tests; From ef6aa7842970f58f19f0bf0cecfad5fe671d82cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 23 May 2025 16:34:26 +0100 Subject: [PATCH 034/126] ci: Add SBOM attestations and bump please release (#470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR This pull request introduces a streamlined process for generating and attesting SBOMs (Software Bill of Materials) for .NET projects, along with updates to permissions and workflow configurations. The changes enhance automation, improve security, and simplify the SBOM generation workflow. ### SBOM Generation and Attestation Enhancements: * Added a new composite GitHub Action (`sbom-generator`) to generate SBOMs using CycloneDX, upload them to a release, and create attestations. This action supports configurable inputs like `github-token`, `project-name`, and `release-tag` (`.github/actions/sbom-generator/action.yml`). * Replaced the previous standalone SBOM generation steps in the release workflow with calls to the new `sbom-generator` action for multiple projects (e.g., `OpenFeature`, `OpenFeature.Hosting`, `OpenFeature.DependencyInjection`) (`.github/workflows/release.yml`). ### Workflow and Permissions Updates: * Updated the `permissions` section in the release workflow to include `id-token: write` and `attestations: write`, enabling secure SBOM attestation and release tagging (`.github/workflows/release.yml`). * Added installation of the CycloneDX.NET tool (`dotnet tool install`) to the release workflow for SBOM generation (`.github/workflows/release.yml`). ### Configuration Improvements: * Added a `signoff` field in the `release-please-config.json` to standardize commit sign-offs for release automation (`release-please-config.json`). ### Related Issues Fixes #465 ### Notes I was triggering the build manually to see if I could generate the SBOMs and attest them. They should be visible here: https://github.com/open-feature/dotnet-sdk/actions/runs/15072156929 --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Signed-off-by: André Silva <2493377+askpt@users.noreply.github.amrom.workers.devsigningkey = F1694EC5F3F13898> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Kyle <38759683+kylejuliandev@users.noreply.github.com> Co-authored-by: André Silva <2493377+askpt@users.noreply.github.amrom.workers.devsigningkey = F1694EC5F3F13898> --- .github/actions/sbom-generator/action.yml | 41 +++++++++ .github/workflows/release.yml | 62 ++++++------- release-please-config.json | 107 +++++++++++----------- 3 files changed, 121 insertions(+), 89 deletions(-) create mode 100644 .github/actions/sbom-generator/action.yml diff --git a/.github/actions/sbom-generator/action.yml b/.github/actions/sbom-generator/action.yml new file mode 100644 index 000000000..0af8c2c47 --- /dev/null +++ b/.github/actions/sbom-generator/action.yml @@ -0,0 +1,41 @@ +name: "Generate and Attest SBOM" +description: "Generate SBOM for a .NET project, upload it to a release, and create an attestation" + +inputs: + github-token: + description: "GitHub token for uploading the SBOM to the release" + required: true + project-name: + description: "Name of the project for SBOM generation" + required: true + release-tag: + description: "Tag name for the release" + required: true + +runs: + using: "composite" + steps: + - name: Install CycloneDX.NET + shell: bash + run: dotnet tool install --global CycloneDX --version 5.2.0 + + - name: Generate SBOM + shell: bash + run: | + # Create artifacts/sboms directory if it doesn't exist + mkdir -p ./artifacts/sboms/ + # Generate SBOM using CycloneDX + dotnet CycloneDX --json --exclude-dev -sv "${{ inputs.release-tag }}" ./src/${{ inputs.project-name }}/${{ inputs.project-name }}.csproj --output ./artifacts/sboms/ -fn ${{ inputs.project-name }}.bom.json + + - name: Upload SBOM to release + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + run: | + gh release upload ${{ inputs.release-tag }} ./artifacts/sboms/${{ inputs.project-name }}.bom.json + + - name: Attest package + uses: actions/attest-sbom@115c3be05ff3974bcbd596578934b3f9ce39bf68 # v2.2.0 + with: + subject-path: src/**/${{ inputs.project-name }}.*.nupkg + sbom-path: ./artifacts/sboms/${{ inputs.project-name }}.bom.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 339c5c8d2..38ea8945e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,20 +12,17 @@ permissions: jobs: release-please: permissions: + id-token: write # for googleapis/release-please-action to create release tag contents: write # for googleapis/release-please-action to create release commit pull-requests: write # for googleapis/release-please-action to create release PR - packages: read # for internal nuget reading - + runs-on: ubuntu-latest steps: - - uses: googleapis/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v3 + - uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 #v4 id: release with: - command: manifest token: ${{secrets.RELEASE_PLEASE_ACTION_TOKEN}} - default-branch: main - signoff: "OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>" release-type: simple outputs: release_created: ${{ steps.release.outputs.release_created }} @@ -37,9 +34,10 @@ jobs: needs: release-please permissions: id-token: write - contents: read - attestations: write - if: ${{ needs.release-please.outputs.release_created }} + contents: write # for SBOM release + attestations: write # for actions/attest-sbom to create attestation + packages: read # for internal nuget reading + if: ${{ fromJSON(needs.release-please.outputs.release_created || false) }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -68,34 +66,26 @@ jobs: with: subject-path: "src/**/*.nupkg" - sbom: - runs-on: ubuntu-latest - permissions: - contents: write # upload sbom to a release - needs: release-please - continue-on-error: true - if: ${{ needs.release-please.outputs.release_created }} - - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + # Process OpenFeature project + - name: Generate and Attest SBOM for OpenFeature + uses: ./.github/actions/sbom-generator with: - fetch-depth: 0 + github-token: ${{secrets.GITHUB_TOKEN}} + project-name: OpenFeature + release-tag: ${{ needs.release-please.outputs.release_tag_name }} - - name: Setup .NET SDK - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 - env: - NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Process OpenFeature.Hosting project + - name: Generate and Attest SBOM for OpenFeature.Hosting + uses: ./.github/actions/sbom-generator with: - global-json-file: global.json - source-url: https://nuget.pkg.github.com/open-feature/index.json - - - name: Install CycloneDX.NET - run: dotnet tool install CycloneDX - - - name: Generate .NET BOM - run: dotnet CycloneDX --json --exclude-dev -sv "${{ needs.release-please.outputs.release_tag_name }}" ./src/OpenFeature/OpenFeature.csproj + github-token: ${{secrets.GITHUB_TOKEN}} + project-name: OpenFeature.Hosting + release-tag: ${{ needs.release-please.outputs.release_tag_name }} - - name: Attach SBOM to artifact - env: - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - run: gh release upload ${{ needs.release-please.outputs.release_tag_name }} bom.json + # Process OpenFeature.DependencyInjection project + - name: Generate and Attest SBOM for OpenFeature.DependencyInjection + uses: ./.github/actions/sbom-generator + with: + github-token: ${{secrets.GITHUB_TOKEN}} + project-name: OpenFeature.DependencyInjection + release-tag: ${{ needs.release-please.outputs.release_tag_name }} diff --git a/release-please-config.json b/release-please-config.json index e79a24e50..5a0201f6d 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,4 +1,5 @@ { + "signoff": "OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>", "packages": { ".": { "release-type": "simple", @@ -12,58 +13,58 @@ } }, "changelog-sections": [ - { - "type": "fix", - "section": "🐛 Bug Fixes" - }, - { - "type": "feat", - "section": "✨ New Features" - }, - { - "type": "chore", - "section": "🧹 Chore" - }, - { - "type": "docs", - "section": "📚 Documentation" - }, - { - "type": "perf", - "section": "🚀 Performance" - }, - { - "type": "build", - "hidden": true, - "section": "🛠️ Build" - }, - { - "type": "deps", - "section": "📦 Dependencies" - }, - { - "type": "ci", - "hidden": true, - "section": "🚦 CI" - }, - { - "type": "refactor", - "section": "🔄 Refactoring" - }, - { - "type": "revert", - "section": "🔙 Reverts" - }, - { - "type": "style", - "hidden": true, - "section": "🎨 Styling" - }, - { - "type": "test", - "hidden": true, - "section": "🧪 Tests" - } + { + "type": "fix", + "section": "🐛 Bug Fixes" + }, + { + "type": "feat", + "section": "✨ New Features" + }, + { + "type": "chore", + "section": "🧹 Chore" + }, + { + "type": "docs", + "section": "📚 Documentation" + }, + { + "type": "perf", + "section": "🚀 Performance" + }, + { + "type": "build", + "hidden": true, + "section": "🛠️ Build" + }, + { + "type": "deps", + "section": "📦 Dependencies" + }, + { + "type": "ci", + "hidden": true, + "section": "🚦 CI" + }, + { + "type": "refactor", + "section": "🔄 Refactoring" + }, + { + "type": "revert", + "section": "🔙 Reverts" + }, + { + "type": "style", + "hidden": true, + "section": "🎨 Styling" + }, + { + "type": "test", + "hidden": true, + "section": "🧪 Tests" + } ], "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" -} +} \ No newline at end of file From 69b17cd172871a488ef9cd80eb9ff515d98375fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 23 May 2025 16:54:44 +0100 Subject: [PATCH 035/126] ci: fix config for release (#478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38ea8945e..47898d387 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,6 @@ jobs: id: release with: token: ${{secrets.RELEASE_PLEASE_ACTION_TOKEN}} - release-type: simple outputs: release_created: ${{ steps.release.outputs.release_created }} release_tag_name: ${{ steps.release.outputs.tag_name }} From 7cae5956835ac3461f7ef0c7acd12bb71f74d7c5 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Fri, 23 May 2025 12:09:24 -0400 Subject: [PATCH 036/126] chore(main): release 2.6.0 (#461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :robot: I have created a release *beep* *boop* --- ## [2.6.0](https://github.com/open-feature/dotnet-sdk/compare/v2.5.0...v2.6.0) (2025-05-23) ### ✨ New Features * add AddHandler extension method to Dependency Injection package ([#462](https://github.com/open-feature/dotnet-sdk/issues/462)) ([ff414b8](https://github.com/open-feature/dotnet-sdk/commit/ff414b8a860108ca3cb372dc4a69b942bf4cd005)) * Add Extension Method for adding global Hook via DependencyInjection ([#459](https://github.com/open-feature/dotnet-sdk/issues/459)) ([9b04485](https://github.com/open-feature/dotnet-sdk/commit/9b04485173978d600a4e3fd24df111347070dc70)) * Add OTEL compatible telemetry object builder ([#397](https://github.com/open-feature/dotnet-sdk/issues/397)) ([6c44db9](https://github.com/open-feature/dotnet-sdk/commit/6c44db9237748735a22a162a88f61b2de7f3e9bd)) ### 🧹 Chore * Cleanup .props file ([#476](https://github.com/open-feature/dotnet-sdk/issues/476)) ([6d7a535](https://github.com/open-feature/dotnet-sdk/commit/6d7a5359bfcb8d12d648a75cf469e93909b999ed)) * **deps:** update actions/attest-build-provenance action to v2.3.0 ([#464](https://github.com/open-feature/dotnet-sdk/issues/464)) ([0a5ab0c](https://github.com/open-feature/dotnet-sdk/commit/0a5ab0c3c71a1a615b0ee8627dd4ff5db39cac9b)) * **deps:** update codecov/codecov-action action to v5.4.3 ([#475](https://github.com/open-feature/dotnet-sdk/issues/475)) ([fbcf3a4](https://github.com/open-feature/dotnet-sdk/commit/fbcf3a4b2478fae49f1566b828c9d0a8cffddd46)) * **deps:** update github/codeql-action digest to 60168ef ([#463](https://github.com/open-feature/dotnet-sdk/issues/463)) ([ea76351](https://github.com/open-feature/dotnet-sdk/commit/ea76351a095b7eeb777941aaf7ac42e4d925c366)) * **deps:** update github/codeql-action digest to ff0a06e ([#473](https://github.com/open-feature/dotnet-sdk/issues/473)) ([af1b20f](https://github.com/open-feature/dotnet-sdk/commit/af1b20f0822497b46b11ce8c21c47c8d39a5fbe9)) * **deps:** update spec digest to edf0deb ([#474](https://github.com/open-feature/dotnet-sdk/issues/474)) ([fc3bdfe](https://github.com/open-feature/dotnet-sdk/commit/fc3bdfef63147dbd10ace398127eb633dd34c773)) ### 📚 Documentation * Add AspNetCore sample app ([#477](https://github.com/open-feature/dotnet-sdk/issues/477)) ([9742a0d](https://github.com/open-feature/dotnet-sdk/commit/9742a0d4210d2dd6e315b4e5988261aae4352c2f)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> Co-authored-by: André Silva <2493377+askpt@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 24 ++++++++++++++++++++++++ README.md | 4 ++-- build/Common.prod.props | 2 +- version.txt | 2 +- 5 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 78baf5bf9..69e82f12f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.5.0" + ".": "2.6.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index beebbd13c..e06ef65bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [2.6.0](https://github.com/open-feature/dotnet-sdk/compare/v2.5.0...v2.6.0) (2025-05-23) + + +### ✨ New Features + +* add AddHandler extension method to Dependency Injection package ([#462](https://github.com/open-feature/dotnet-sdk/issues/462)) ([ff414b8](https://github.com/open-feature/dotnet-sdk/commit/ff414b8a860108ca3cb372dc4a69b942bf4cd005)) +* Add Extension Method for adding global Hook via DependencyInjection ([#459](https://github.com/open-feature/dotnet-sdk/issues/459)) ([9b04485](https://github.com/open-feature/dotnet-sdk/commit/9b04485173978d600a4e3fd24df111347070dc70)) +* Add OTEL compatible telemetry object builder ([#397](https://github.com/open-feature/dotnet-sdk/issues/397)) ([6c44db9](https://github.com/open-feature/dotnet-sdk/commit/6c44db9237748735a22a162a88f61b2de7f3e9bd)) + + +### 🧹 Chore + +* Cleanup .props file ([#476](https://github.com/open-feature/dotnet-sdk/issues/476)) ([6d7a535](https://github.com/open-feature/dotnet-sdk/commit/6d7a5359bfcb8d12d648a75cf469e93909b999ed)) +* **deps:** update actions/attest-build-provenance action to v2.3.0 ([#464](https://github.com/open-feature/dotnet-sdk/issues/464)) ([0a5ab0c](https://github.com/open-feature/dotnet-sdk/commit/0a5ab0c3c71a1a615b0ee8627dd4ff5db39cac9b)) +* **deps:** update codecov/codecov-action action to v5.4.3 ([#475](https://github.com/open-feature/dotnet-sdk/issues/475)) ([fbcf3a4](https://github.com/open-feature/dotnet-sdk/commit/fbcf3a4b2478fae49f1566b828c9d0a8cffddd46)) +* **deps:** update github/codeql-action digest to 60168ef ([#463](https://github.com/open-feature/dotnet-sdk/issues/463)) ([ea76351](https://github.com/open-feature/dotnet-sdk/commit/ea76351a095b7eeb777941aaf7ac42e4d925c366)) +* **deps:** update github/codeql-action digest to ff0a06e ([#473](https://github.com/open-feature/dotnet-sdk/issues/473)) ([af1b20f](https://github.com/open-feature/dotnet-sdk/commit/af1b20f0822497b46b11ce8c21c47c8d39a5fbe9)) +* **deps:** update spec digest to edf0deb ([#474](https://github.com/open-feature/dotnet-sdk/issues/474)) ([fc3bdfe](https://github.com/open-feature/dotnet-sdk/commit/fc3bdfef63147dbd10ace398127eb633dd34c773)) + + +### 📚 Documentation + +* Add AspNetCore sample app ([#477](https://github.com/open-feature/dotnet-sdk/issues/477)) ([9742a0d](https://github.com/open-feature/dotnet-sdk/commit/9742a0d4210d2dd6e315b4e5988261aae4352c2f)) + ## [2.5.0](https://github.com/open-feature/dotnet-sdk/compare/v2.4.0...v2.5.0) (2025-04-25) diff --git a/README.md b/README.md index ecaeb5257..5f1b725b5 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ [![Specification](https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge)](https://github.com/open-feature/spec/releases/tag/v0.8.0) [ -![Release](https://img.shields.io/static/v1?label=release&message=v2.5.0&color=blue&style=for-the-badge) -](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.5.0) +![Release](https://img.shields.io/static/v1?label=release&message=v2.6.0&color=blue&style=for-the-badge) +](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.6.0) [![Slack](https://img.shields.io/badge/slack-%40cncf%2Fopenfeature-brightgreen?style=flat&logo=slack)](https://cloud-native.slack.com/archives/C0344AANLA1) [![Codecov](https://codecov.io/gh/open-feature/dotnet-sdk/branch/main/graph/badge.svg?token=MONAVJBXUJ)](https://codecov.io/gh/open-feature/dotnet-sdk) diff --git a/build/Common.prod.props b/build/Common.prod.props index 3f4ba37d2..55264b3c0 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -9,7 +9,7 @@ - 2.5.0 + 2.6.0 git https://github.com/open-feature/dotnet-sdk OpenFeature is an open standard for feature flag management, created to support a robust feature flag ecosystem using cloud native technologies. OpenFeature will provide a unified API and SDK, and a developer-first, cloud-native implementation, with extensibility for open source and commercial offerings. diff --git a/version.txt b/version.txt index 437459cd9..e70b4523a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.5.0 +2.6.0 From dbe8b082c28739a1b81b74b29ed28fbccc94f7bc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 26 May 2025 15:36:06 +0100 Subject: [PATCH 037/126] chore(deps): update spec digest to f014806 (#479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | spec | digest | `edf0deb` -> `f014806` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index edf0debe0..f0148060e 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit edf0debe0b4547d1f13e49f8e58a6d182237b43b +Subproject commit f0148060e6c125ffa95c161b984efda012084c1a From 520d38305c6949c88b057f28e5dfe3305257e437 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 May 2025 09:24:08 +0100 Subject: [PATCH 038/126] chore(deps): update dependency microsoft.net.test.sdk to 17.14.0 (#482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [Microsoft.NET.Test.Sdk](https://github.com/microsoft/vstest) | `17.13.0` -> `17.14.0` | [![age](https://developer.mend.io/api/mc/badges/age/nuget/Microsoft.NET.Test.Sdk/17.14.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/nuget/Microsoft.NET.Test.Sdk/17.14.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/nuget/Microsoft.NET.Test.Sdk/17.13.0/17.14.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Microsoft.NET.Test.Sdk/17.13.0/17.14.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
microsoft/vstest (Microsoft.NET.Test.Sdk) ### [`v17.14.0`](https://github.com/microsoft/vstest/releases/tag/v17.14.0) #### What's Changed ##### .NET versions updated This version of VS Test upgraded .NET to net8 and net9. All projects targeting net6.0 (or other end-of-life .NET target frameworks) should pin their version of Microsoft.NET.Test.SDK to 17.13.0, or update the projects to net8 or newer. We remain backwards compatible with previous versions of Microsoft.NET.Test.SDK. This change does **NOT** prevent you from: - Updating to the latest VS, and running tests from net6.0 test projects. - Updating to the latest .NET SDK, and running tests from net6.0 test projects. It also has no impact on .NET Framework projects, where we continue targeting .NET Framework 4.6.2. - Drop unsupported frameworks by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/10565](https://github.com/microsoft/vstest/pull/10565) ##### Changes - Adding Process Query Flag For UWP .NET 9 Support by [@​adstep](https://github.com/adstep) in [https://github.com/microsoft/vstest/pull/15003](https://github.com/microsoft/vstest/pull/15003) - Fix builds on WinUI and UWP .NET 9 projects by [@​Sergio0694](https://github.com/Sergio0694) in [https://github.com/microsoft/vstest/pull/15004](https://github.com/microsoft/vstest/pull/15004) - don't report communication error on discovery abort by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/14992](https://github.com/microsoft/vstest/pull/14992) - Add dump minitool to vsix by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/14707](https://github.com/microsoft/vstest/pull/14707) - Make test runners long-path aware ([#​5179](https://github.com/microsoft/vstest/issues/5179)) by [@​peetw](https://github.com/peetw) in [https://github.com/microsoft/vstest/pull/15014](https://github.com/microsoft/vstest/pull/15014) - Fix trace in DataCollectionRequestSender.cs by [@​stan-sz](https://github.com/stan-sz) in [https://github.com/microsoft/vstest/pull/15025](https://github.com/microsoft/vstest/pull/15025) - Fix/readme grammar parallelism by [@​dellch](https://github.com/dellch) in [https://github.com/microsoft/vstest/pull/15030](https://github.com/microsoft/vstest/pull/15030) - Add binding redirects by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/15041](https://github.com/microsoft/vstest/pull/15041) - Write props of tests into trx by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/14905](https://github.com/microsoft/vstest/pull/14905) ##### Internal version updates and fixes - Update io.redist by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/13872](https://github.com/microsoft/vstest/pull/13872) - Use preview image for public build by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/13888](https://github.com/microsoft/vstest/pull/13888) - Remove xcopy-msbuild by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/14138](https://github.com/microsoft/vstest/pull/14138) - Move to macos14 by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/14137](https://github.com/microsoft/vstest/pull/14137) - Update diagnose.md by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/14776](https://github.com/microsoft/vstest/pull/14776) - hash with sha2 for mutex lock by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/14777](https://github.com/microsoft/vstest/pull/14777) - Update test projects for vmr by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/14894](https://github.com/microsoft/vstest/pull/14894) - 17.14 branding by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/14903](https://github.com/microsoft/vstest/pull/14903) - Update filter.md for NUnit by [@​OsirisTerje](https://github.com/OsirisTerje) in [https://github.com/microsoft/vstest/pull/14987](https://github.com/microsoft/vstest/pull/14987) - Flag netstandard1.x dependencies in source-build by [@​ViktorHofer](https://github.com/ViktorHofer) in [https://github.com/microsoft/vstest/pull/14986](https://github.com/microsoft/vstest/pull/14986) - Use VS dependencies versions from release VS to have archived symbols by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/14991](https://github.com/microsoft/vstest/pull/14991) - Remove extra ; by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/14995](https://github.com/microsoft/vstest/pull/14995) - Use dependencymodel 6.0.2 by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/14996](https://github.com/microsoft/vstest/pull/14996) - Make Testhost packable only on Windows by [@​mmitche](https://github.com/mmitche) in [https://github.com/microsoft/vstest/pull/15001](https://github.com/microsoft/vstest/pull/15001) - Add system text json to vsix by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/15034](https://github.com/microsoft/vstest/pull/15034) - Add more files to vsix by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/15038](https://github.com/microsoft/vstest/pull/15038) - Remove unnecessary CA2022 suppressions by [@​Winniexu01](https://github.com/Winniexu01) in [https://github.com/microsoft/vstest/pull/15035](https://github.com/microsoft/vstest/pull/15035) - Update package project url by [@​mmitche](https://github.com/mmitche) in [https://github.com/microsoft/vstest/pull/15040](https://github.com/microsoft/vstest/pull/15040) #### New Contributors - [@​OsirisTerje](https://github.com/OsirisTerje) made their first contribution in [https://github.com/microsoft/vstest/pull/14987](https://github.com/microsoft/vstest/pull/14987) - [@​adstep](https://github.com/adstep) made their first contribution in [https://github.com/microsoft/vstest/pull/15003](https://github.com/microsoft/vstest/pull/15003) - [@​Sergio0694](https://github.com/Sergio0694) made their first contribution in [https://github.com/microsoft/vstest/pull/15004](https://github.com/microsoft/vstest/pull/15004) - [@​peetw](https://github.com/peetw) made their first contribution in [https://github.com/microsoft/vstest/pull/15014](https://github.com/microsoft/vstest/pull/15014) - [@​dellch](https://github.com/dellch) made their first contribution in [https://github.com/microsoft/vstest/pull/15030](https://github.com/microsoft/vstest/pull/15030) - [@​Winniexu01](https://github.com/Winniexu01) made their first contribution in [https://github.com/microsoft/vstest/pull/15035](https://github.com/microsoft/vstest/pull/15035) **Full Changelog**: https://github.com/microsoft/vstest/compare/v17.13.0...v17.14.0
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6bdfa4553..b2851b3c9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,7 +27,7 @@ - + From 99f7584c91882ba59412e2306167172470cd4677 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 May 2025 09:24:31 +0100 Subject: [PATCH 039/126] chore(deps): update dependency reqnroll.xunit to 2.4.1 (#483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [Reqnroll.xUnit](https://www.reqnroll.net/) ([source](https://github.com/reqnroll/Reqnroll)) | `2.4.0` -> `2.4.1` | [![age](https://developer.mend.io/api/mc/badges/age/nuget/Reqnroll.xUnit/2.4.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/nuget/Reqnroll.xUnit/2.4.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/nuget/Reqnroll.xUnit/2.4.0/2.4.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Reqnroll.xUnit/2.4.0/2.4.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
reqnroll/Reqnroll (Reqnroll.xUnit) ### [`v2.4.1`](https://github.com/reqnroll/Reqnroll/blob/HEAD/CHANGELOG.md#v241---2025-04-29) #### Bug fixes: - Fix: xUnit async `[AfterTestRun]` hook might not execute fully ([#​530](https://github.com/reqnroll/Reqnroll/issues/530)) - Fix: Scenario, feature and test run finished event is not published when the related "after" hook fails ([#​560](https://github.com/reqnroll/Reqnroll/issues/560)) - Fix: Inconsistent hook execution (duble execution, before/after hook skipped, infrastructure errors) when before or after hooks fail ([#​526](https://github.com/reqnroll/Reqnroll/issues/526)) - Fix: Namespace collisions in generated code when Reqnroll project namespace contains "System" ([#​583](https://github.com/reqnroll/Reqnroll/issues/583)) - Fix: InvalidOperationException when calling test teardown method after the Reqnroll test runner has been released ([#​387](https://github.com/reqnroll/Reqnroll/issues/387)) *Contributors of this release (in alphabetical order):* [@​304NotModified](https://github.com/304NotModified), [@​clrudolphi](https://github.com/clrudolphi), [@​gasparnagy](https://github.com/gasparnagy), [@​obligaron](https://github.com/obligaron)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b2851b3c9..ac8b0e3a1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,7 +29,7 @@ - + From 714425d405a33231e85b1e62019fc678b2e883ef Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 28 May 2025 20:26:12 +0400 Subject: [PATCH 040/126] chore(deps): update dependency benchmarkdotnet to 0.15.0 (#481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet) | `0.14.0` -> `0.15.0` | [![age](https://developer.mend.io/api/mc/badges/age/nuget/BenchmarkDotNet/0.15.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/nuget/BenchmarkDotNet/0.15.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/nuget/BenchmarkDotNet/0.14.0/0.15.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/BenchmarkDotNet/0.14.0/0.15.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
dotnet/BenchmarkDotNet (BenchmarkDotNet) ### [`v0.15.0`](https://github.com/dotnet/BenchmarkDotNet/releases/tag/v0.15.0): 0.15.0 Full changelog: https://benchmarkdotnet.org/changelog/v0.15.0.html
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ac8b0e3a1..89518bb55 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,7 @@ - + From 8435bf7d8131307e627e59453008124ac4c71906 Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Mon, 2 Jun 2025 13:41:01 -0400 Subject: [PATCH 041/126] docs: updated contributing link on the README Resolves an lint issue in the docs pipeline. https://github.com/open-feature/openfeature.dev/actions/runs/15387227215/job/43288342049?pr=1174 Signed-off-by: Michael Beemer --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f1b725b5..9288edfd6 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ The [`samples/`](./samples) folder contains example applications demonstrating h dotnet run ``` -Want to contribute a new sample? See our [CONTRIBUTING](#-contributing) guide! +Want to contribute a new sample? See our [CONTRIBUTING](CONTRIBUTING.md) guide! ## 🌟 Features From 78bfdbf0850e2d5eb80cfbae3bfac8208f6c45b1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 4 Jun 2025 15:15:27 +0100 Subject: [PATCH 042/126] chore(deps): update dependency microsoft.net.test.sdk to 17.14.1 (#485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [Microsoft.NET.Test.Sdk](https://github.com/microsoft/vstest) | `17.14.0` -> `17.14.1` | [![age](https://developer.mend.io/api/mc/badges/age/nuget/Microsoft.NET.Test.Sdk/17.14.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/nuget/Microsoft.NET.Test.Sdk/17.14.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/nuget/Microsoft.NET.Test.Sdk/17.14.0/17.14.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Microsoft.NET.Test.Sdk/17.14.0/17.14.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
microsoft/vstest (Microsoft.NET.Test.Sdk) ### [`v17.14.1`](https://github.com/microsoft/vstest/releases/tag/v17.14.1) #### What's Changed - Error on unsupported target frameworks to prevent silently not running tests by [@​nohwnd](https://github.com/nohwnd) in [https://github.com/microsoft/vstest/pull/15072](https://github.com/microsoft/vstest/pull/15072) and [https://github.com/microsoft/vstest/pull/15078](https://github.com/microsoft/vstest/pull/15078) - Revert writing additional properties to TRX by [@​nohwnd](https://github.com/nohwnd) in https://github.com/microsoft/vstest/commit/47eb51b15ad8ca4a84ad7be5881fcd1713a0f68a **Full Changelog**: https://github.com/microsoft/vstest/compare/v17.14.0...v17.14.1
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 89518bb55..87a661c7d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,7 +27,7 @@ - + From e18ad50e3298cb0dd19143678c3ef0fdcb4484d9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:18:12 +0000 Subject: [PATCH 043/126] chore(deps): update github/codeql-action digest to fca7ace (#486) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [github/codeql-action](https://github.com/github/codeql-action) | action | digest | `ff0a06e` -> `fca7ace` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2f4645ece..f918dc183 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3 + uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3 + uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3 + uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3 From cce224fcf81aede5a626936a26546fe710fbcc30 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 16:38:30 +0100 Subject: [PATCH 044/126] chore(deps): update github/codeql-action digest to ce28f5b (#492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [github/codeql-action](https://github.com/github/codeql-action) | action | digest | `fca7ace` -> `ce28f5b` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f918dc183..6e8d1e32b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3 + uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3 + uses: github/codeql-action/autobuild@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3 + uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3 From 909c51d4e25917d6a9a5ae9bb04cfe48665186ba Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 16:38:44 +0100 Subject: [PATCH 045/126] chore(deps): update spec digest to 42340bb (#493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | spec | digest | `f014806` -> `42340bb` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index f0148060e..42340bb9f 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit f0148060e6c125ffa95c161b984efda012084c1a +Subproject commit 42340bb9f56ccd8f98f90ac3baca52758f5a5859 From f7ca4163e0ce549a015a7a27cb184fb76a199a04 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:39:05 +0000 Subject: [PATCH 046/126] chore(deps): update actions/attest-sbom action to v2.4.0 (#496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/attest-sbom](https://github.com/actions/attest-sbom) | action | minor | `v2.2.0` -> `v2.4.0` | --- ### Release Notes
actions/attest-sbom (actions/attest-sbom) ### [`v2.4.0`](https://github.com/actions/attest-sbom/releases/tag/v2.4.0) [Compare Source](https://github.com/actions/attest-sbom/compare/v2.2.0...v2.4.0) #### What's Changed - Bump actions/attest from 2.2.1 to 2.3.0 in the actions-minor group by [@​dependabot](https://github.com/dependabot) in [https://github.com/actions/attest-sbom/pull/169](https://github.com/actions/attest-sbom/pull/169) - Bump undici from 5.28.5 to 5.29.0 by [@​dependabot](https://github.com/dependabot) in [https://github.com/actions/attest-sbom/pull/172](https://github.com/actions/attest-sbom/pull/172) - Bump actions/attest from 2.3.0 to 2.4.0 by [@​bdehamer](https://github.com/bdehamer) in [https://github.com/actions/attest-sbom/pull/178](https://github.com/actions/attest-sbom/pull/178) - Includes support for the new well-known summary file which will accumulate paths to all attestations generated in a given workflow run **Full Changelog**: https://github.com/actions/attest-sbom/compare/v2.2.0...v2.4.0
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/actions/sbom-generator/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/sbom-generator/action.yml b/.github/actions/sbom-generator/action.yml index 0af8c2c47..7573150b5 100644 --- a/.github/actions/sbom-generator/action.yml +++ b/.github/actions/sbom-generator/action.yml @@ -35,7 +35,7 @@ runs: gh release upload ${{ inputs.release-tag }} ./artifacts/sboms/${{ inputs.project-name }}.bom.json - name: Attest package - uses: actions/attest-sbom@115c3be05ff3974bcbd596578934b3f9ce39bf68 # v2.2.0 + uses: actions/attest-sbom@bd218ad0dbcb3e146bd073d1d9c6d78e08aa8a0b # v2.4.0 with: subject-path: src/**/${{ inputs.project-name }}.*.nupkg sbom-path: ./artifacts/sboms/${{ inputs.project-name }}.bom.json From 349c07301d0ff97c759417344eef74a00b06edbc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:39:42 +0000 Subject: [PATCH 047/126] chore(deps): update actions/attest-build-provenance action to v2.4.0 (#495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) | action | minor | `v2.3.0` -> `v2.4.0` | --- ### Release Notes
actions/attest-build-provenance (actions/attest-build-provenance) ### [`v2.4.0`](https://github.com/actions/attest-build-provenance/releases/tag/v2.4.0) [Compare Source](https://github.com/actions/attest-build-provenance/compare/v2.3.0...v2.4.0) #### What's Changed - Bump undici from 5.28.5 to 5.29.0 by [@​dependabot](https://github.com/dependabot) in [https://github.com/actions/attest-build-provenance/pull/633](https://github.com/actions/attest-build-provenance/pull/633) - Bump actions/attest from 2.3.0 to [2.4.0](https://github.com/actions/attest/releases/tag/v2.4.0) by [@​bdehamer](https://github.com/bdehamer) in [https://github.com/actions/attest-build-provenance/pull/654](https://github.com/actions/attest-build-provenance/pull/654) - Includes support for the new well-known summary file which will accumulate paths to all attestations generated in a given workflow run **Full Changelog**: https://github.com/actions/attest-build-provenance/compare/v2.3.0...v2.4.0
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47898d387..725957a75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,7 +61,7 @@ jobs: run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ secrets.NUGET_TOKEN }}" --source https://api.nuget.org/v3/index.json - name: Generate artifact attestation - uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 with: subject-path: "src/**/*.nupkg" From cab380727fe95b941384ae71f022626cdf23db53 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 16:40:04 +0100 Subject: [PATCH 048/126] chore(deps): update dependency benchmarkdotnet to 0.15.2 (#494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [BenchmarkDotNet](https://github.com/dotnet/BenchmarkDotNet) | `0.15.0` -> `0.15.2` | [![age](https://developer.mend.io/api/mc/badges/age/nuget/BenchmarkDotNet/0.15.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/nuget/BenchmarkDotNet/0.15.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/nuget/BenchmarkDotNet/0.15.0/0.15.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/BenchmarkDotNet/0.15.0/0.15.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
dotnet/BenchmarkDotNet (BenchmarkDotNet) ### [`v0.15.2`](https://github.com/dotnet/BenchmarkDotNet/releases/tag/v0.15.2): 0.15.2 Full changelog: https://benchmarkdotnet.org/changelog/v0.15.2.html #### Highlights - The most significant update in this release is the enhanced accuracy of the memory diagnoser ([#​2562](https://github.com/dotnet/BenchmarkDotNet/pull/2562)). This improvement resolves the issue of incorrectly reported memory allocations ([#​1542](https://github.com/dotnet/BenchmarkDotNet/issues/1542), [#​2582](https://github.com/dotnet/BenchmarkDotNet/issues/2582)). - We have introduced a new feature that allows users to sort benchmark jobs in numerical order ([#​2768](https://github.com/dotnet/BenchmarkDotNet/issues/2768), [#​2770](https://github.com/dotnet/BenchmarkDotNet/pull/2770)). - Benchmark validation has been improved ([#​2771](https://github.com/dotnet/BenchmarkDotNet/pull/2771)). - An issue with non-persistent auto-generated JobId has been fixed ([#​2777](https://github.com/dotnet/BenchmarkDotNet/pull/2777)). ### [`v0.15.1`](https://github.com/dotnet/BenchmarkDotNet/releases/tag/v0.15.1): 0.15.1 Full changelog: https://benchmarkdotnet.org/changelog/v0.15.1.html #### Highlights - Added support for \*.slnx ([#​2763](https://github.com/dotnet/BenchmarkDotNet/issues/2763), [#​2764](https://github.com/dotnet/BenchmarkDotNet/pull/2764)) - Enabled ArgumentsSource to reference methods in other types ([#​2744](https://github.com/dotnet/BenchmarkDotNet/issues/2744), [#​2748](https://github.com/dotnet/BenchmarkDotNet/pull/2748)) - Resolved fatal errors for ARM CPUs ([#​2745](https://github.com/dotnet/BenchmarkDotNet/issues/2745), [#​2756](https://github.com/dotnet/BenchmarkDotNet/pull/2756)) - Fixed bugs related to support for Android, browser, iOS, and tvOS ([#​2739](https://github.com/dotnet/BenchmarkDotNet/issues/2739), [#​2741](https://github.com/dotnet/BenchmarkDotNet/pull/2741), [#​2740](https://github.com/dotnet/BenchmarkDotNet/issues/2740), [#​2742](https://github.com/dotnet/BenchmarkDotNet/pull/2742))
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 87a661c7d..442e43fd7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,7 @@ - + From 08a00e1d35834635ca296fe8a13507001ad25c57 Mon Sep 17 00:00:00 2001 From: Weihan Li Date: Tue, 24 Jun 2025 15:35:46 +0800 Subject: [PATCH 049/126] chore: fix sample build warning (#498) ## This PR - fixes the sample build warning ### Notes the `builder` name override the `WebApplicationBuilder` Signed-off-by: Weihan Li --- samples/AspNetCore/Program.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index 462370861..a9c2cd508 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -9,11 +9,11 @@ // Add services to the container. builder.Services.AddProblemDetails(); -builder.Services.AddOpenFeature(builder => +builder.Services.AddOpenFeature(featureBuilder => { - builder.AddHostedFeatureLifecycle() + featureBuilder.AddHostedFeatureLifecycle() .AddHook(sp => new LoggingHook(sp.GetRequiredService>())) - .AddInMemoryProvider("InMemory", provider => new Dictionary() + .AddInMemoryProvider("InMemory", _ => new Dictionary() { { "welcome-message", new Flag( From 2e3dffd0ebbba4a9d95763e2ce9f3e2ac051a317 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Jun 2025 07:49:59 +0100 Subject: [PATCH 050/126] chore(deps): update spec digest to 1965aae (#499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | spec | digest | `42340bb` -> `1965aae` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index 42340bb9f..1965aae81 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 42340bb9f56ccd8f98f90ac3baca52758f5a5859 +Subproject commit 1965aae810d9b77bc76c797448a84df108bb56ec From 68af6493b09d29be5d4cdda9e6f792ee8667bf4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Wed, 25 Jun 2025 18:07:59 +0100 Subject: [PATCH 051/126] fix: Add generic to evaluation event builder (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../Telemetry/EvaluationEventBuilder.cs | 8 ++++---- .../Telemetry/EvaluationEventBuilderTests.cs | 16 +++++++--------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs b/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs index d9520c124..b17565ce3 100644 --- a/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs +++ b/src/OpenFeature/Telemetry/EvaluationEventBuilder.cs @@ -6,14 +6,14 @@ namespace OpenFeature.Telemetry; /// /// Class for creating evaluation events for feature flags. /// -public sealed class EvaluationEventBuilder +public sealed class EvaluationEventBuilder { private const string EventName = "feature_flag.evaluation"; /// - /// Gets the default instance of the . + /// Gets the default instance of the . /// - public static EvaluationEventBuilder Default { get; } = new(); + public static EvaluationEventBuilder Default { get; } = new(); /// /// Creates an evaluation event based on the provided hook context and flag evaluation details. @@ -21,7 +21,7 @@ public sealed class EvaluationEventBuilder /// The context of the hook containing flag key and provider metadata. /// The details of the flag evaluation including reason, variant, and metadata. /// An instance of containing the event name, attributes, and body. - public EvaluationEvent Build(HookContext hookContext, FlagEvaluationDetails details) + public static EvaluationEvent Build(HookContext hookContext, FlagEvaluationDetails details) { var attributes = new Dictionary { diff --git a/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs b/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs index 8bebd3f5d..79e31df08 100644 --- a/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs +++ b/test/OpenFeature.Tests/Telemetry/EvaluationEventBuilderTests.cs @@ -6,8 +6,6 @@ namespace OpenFeature.Tests.Telemetry; public class EvaluationEventBuilderTests { - private readonly EvaluationEventBuilder _builder = EvaluationEventBuilder.Default; - [Fact] public void Build_ShouldReturnEventWithCorrectAttributes() { @@ -25,7 +23,7 @@ public void Build_ShouldReturnEventWithCorrectAttributes() reason: "reason", variant: "variant", flagMetadata: flagMetadata); // Act - var evaluationEvent = _builder.Build(hookContext, details); + var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details); // Assert Assert.Equal("feature_flag.evaluation", evaluationEvent.Name); @@ -55,7 +53,7 @@ public void Build_ShouldHandleErrorDetails() errorMessage: "errorMessage", reason: "reason", variant: "variant", flagMetadata: flagMetadata); // Act - var evaluationEvent = _builder.Build(hookContext, details); + var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details); // Assert Assert.Equal("general", evaluationEvent.Attributes[TelemetryConstants.ErrorCode]); @@ -79,7 +77,7 @@ public void Build_ShouldHandleMissingVariant() reason: "reason", variant: null, flagMetadata: flagMetadata); // Act - var evaluationEvent = _builder.Build(hookContext, details); + var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details); // Assert Assert.Null(evaluationEvent.Attributes[TelemetryConstants.Variant]); @@ -98,7 +96,7 @@ public void Build_ShouldHandleMissingFlagMetadata() reason: "reason", variant: "", flagMetadata: flagMetadata); // Act - var evaluationEvent = _builder.Build(hookContext, details); + var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details); // Assert Assert.Null(evaluationEvent.Attributes[TelemetryConstants.ContextId]); @@ -122,7 +120,7 @@ public void Build_ShouldHandleMissingReason(string? reason) reason: reason, variant: "", flagMetadata: flagMetadata); // Act - var evaluationEvent = _builder.Build(hookContext, details); + var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details); // Assert Assert.Equal(Reason.Unknown.ToLowerInvariant(), evaluationEvent.Attributes[TelemetryConstants.Reason]); @@ -144,7 +142,7 @@ public void Build_ShouldHandleErrorWithEmptyErrorMessage(string? errorMessage) errorMessage: errorMessage, reason: "reason", variant: "", flagMetadata: flagMetadata); // Act - var evaluationEvent = _builder.Build(hookContext, details); + var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details); // Assert Assert.Equal("general", evaluationEvent.Attributes[TelemetryConstants.ErrorCode]); @@ -164,7 +162,7 @@ public void Build_ShouldIncludeValueAttributeInEvent() reason: "reason", variant: "variant", flagMetadata: new ImmutableMetadata()); // Act - var evaluationEvent = _builder.Build(hookContext, details); + var evaluationEvent = EvaluationEventBuilder.Build(hookContext, details); // Assert Assert.Equal(testValue, evaluationEvent.Attributes[TelemetryConstants.Value]); From 38f63fceb5516cd474fd0e867aa25eae252cf2c1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 07:15:16 +0000 Subject: [PATCH 052/126] chore(deps): update spec digest to c37ac17 (#502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | spec | digest | `1965aae` -> `c37ac17` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index 1965aae81..c37ac17c8 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 1965aae810d9b77bc76c797448a84df108bb56ec +Subproject commit c37ac17c80410de1a2c6c6f061386001c838cb40 From 39f884df420f1a9346852159948c288e728672b8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 07:15:59 +0000 Subject: [PATCH 053/126] chore(deps): update dependency system.valuetuple to 4.6.1 (#503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [System.ValueTuple](https://github.com/dotnet/maintenance-packages) | `4.6.0` -> `4.6.1` | [![age](https://developer.mend.io/api/mc/badges/age/nuget/System.ValueTuple/4.6.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/nuget/System.ValueTuple/4.6.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/nuget/System.ValueTuple/4.6.0/4.6.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/System.ValueTuple/4.6.0/4.6.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 442e43fd7..3e870319d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,7 +17,7 @@ - + From 77f6e1bbb76973e078c1999ad0784c9edc9def96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:33:32 +0100 Subject: [PATCH 054/126] feat: Move OTEL hooks to the SDK (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Move OTEL hooks to the SDK This pull request introduces telemetry enhancements to the OpenFeature .NET SDK by adding new hooks for tracing and metrics, updating dependencies, and providing examples and tests. The most significant changes include the addition of the `TraceEnricherHook` and `MetricsHook` classes, integration of OpenTelemetry in the ASP.NET Core sample, and updates to dependencies to support telemetry features. ### Telemetry Enhancements * **Trace Enricher Hook**: Added `TraceEnricherHook` to enrich telemetry traces with feature flag evaluation details, including tags and events for tracing purposes. This hook integrates with the current `Activity` and supports error handling. * **Metrics Hook**: Introduced `MetricsHook` for capturing metrics such as evaluation requests, successes, errors, and active evaluations. Metrics are collected using OpenTelemetry's `Meter` API. [[1]](diffhunk://#diff-912b71a06f9a65012af403b04269f68f3cb9ee580d0dd62e1d6afc99bd433d31R1-R100) [[2]](diffhunk://#diff-9c7590e55694b19483ea6d802e6a1bb34418366a8f66d6f35b57ae81adb5bf17R1-R16) ### Dependency Updates * **OpenTelemetry Dependencies**: Added `OpenTelemetry.Extensions.Hosting`, `OpenTelemetry.Instrumentation.AspNetCore`, and `OpenTelemetry.Exporter.OpenTelemetryProtocol` to the ASP.NET Core sample project to support telemetry features. [[1]](diffhunk://#diff-40299025547d304b834a53acda1eb9a8a6f49f2ec679b6bcebd3449211497c56R3-R18) [[2]](diffhunk://#diff-711ea17cbdebe419375c7684c8c39a1423d2bebcf8976ddd7bdd78deaab65b21R12) * **DiagnosticSource Dependency**: Included `System.Diagnostics.DiagnosticSource` in the main project and centralized dependency management. [[1]](diffhunk://#diff-5baf5f9e448ad54ab25a091adee0da05d4d228481c9200518fcb1b53a65d4156R19) [[2]](diffhunk://#diff-5baf5f9e448ad54ab25a091adee0da05d4d228481c9200518fcb1b53a65d4156R33-R34) ### Documentation and Examples * **README Updates**: Added documentation for `TraceEnricherHook` and `MetricsHook`, including detailed descriptions, examples, and usage instructions for integrating these hooks with OpenTelemetry. * **ASP.NET Core Sample**: Updated the sample application to demonstrate the use of `TraceEnricherHook` and `MetricsHook` with OpenTelemetry tracing and metrics. [[1]](diffhunk://#diff-41550b31d77b5898b38a3280f8ffbc5d2531fc4d4884079ebf3c5e953a85075dR6-R32) [[2]](diffhunk://#diff-40299025547d304b834a53acda1eb9a8a6f49f2ec679b6bcebd3449211497c56R3-R18) ### Testing * **MetricsHook Tests**: Added unit tests for `MetricsHook` to verify metrics collection during different stages of feature flag evaluation (e.g., `BeforeAsync`, `AfterAsync`, `ErrorAsync`, `FinallyAsync`). ### Related Issues Fixes #175 ### Notes * In this PR I made some changes to the `samples` application. This allow us to see the metrics and traces in any OTEL tool. Check the screenshots below: ![Screenshot 2025-06-25 at 18 58 59](https://github.com/user-attachments/assets/c5ce5e1d-6506-4e4b-a95c-707e65a1d5f6) ### Follow-up Tasks - We should remove any reference in the other repository and mark the Nuget package as deprecated. --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- Directory.Packages.props | 5 +- README.md | 147 ++++++++++++++++-- samples/AspNetCore/Program.cs | 17 ++ samples/AspNetCore/Samples.AspNetCore.csproj | 10 ++ src/OpenFeature/Hooks/MetricsConstants.cs | 16 ++ src/OpenFeature/Hooks/MetricsHook.cs | 100 ++++++++++++ src/OpenFeature/Hooks/TraceEnricherHook.cs | 38 +++++ src/OpenFeature/OpenFeature.csproj | 1 + .../Hooks/MetricsHookTests.cs | 141 +++++++++++++++++ .../Hooks/TraceEnricherHookTests.cs | 91 +++++++++++ .../OpenFeature.Tests.csproj | 2 + 11 files changed, 558 insertions(+), 10 deletions(-) create mode 100644 src/OpenFeature/Hooks/MetricsConstants.cs create mode 100644 src/OpenFeature/Hooks/MetricsHook.cs create mode 100644 src/OpenFeature/Hooks/TraceEnricherHook.cs create mode 100644 test/OpenFeature.Tests/Hooks/MetricsHookTests.cs create mode 100644 test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 3e870319d..9b184d3fc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + @@ -29,6 +30,8 @@ + + @@ -39,4 +42,4 @@ - \ No newline at end of file + diff --git a/README.md b/README.md index 9288edfd6..e87f2584c 100644 --- a/README.md +++ b/README.md @@ -79,23 +79,23 @@ public async Task Example() The [`samples/`](./samples) folder contains example applications demonstrating how to use OpenFeature in different .NET scenarios. -| Sample Name | Description | -|---------------------------------------------------|----------------------------------------------------------------| -| [AspNetCore](/samples/AspNetCore/README.md) | Feature flags in an ASP.NET Core Web API. | +| Sample Name | Description | +| ------------------------------------------- | ----------------------------------------- | +| [AspNetCore](/samples/AspNetCore/README.md) | Feature flags in an ASP.NET Core Web API. | **Getting Started with a Sample:** 1. Navigate to the sample directory - ```shell - cd samples/AspNetCore - ``` + ```shell + cd samples/AspNetCore + ``` 2. Restore dependencies and run - ```shell - dotnet run - ``` + ```shell + dotnet run + ``` Want to contribute a new sample? See our [CONTRIBUTING](CONTRIBUTING.md) guide! @@ -534,6 +534,135 @@ services.AddOpenFeature(builder => }); ``` +### Trace Enricher Hook + +The `TraceEnricherHook` enriches telemetry traces with additional information during the feature flag evaluation lifecycle. This hook adds relevant flag evaluation details as tags and events to the current `Activity` for tracing purposes. + +For this hook to function correctly, an active span must be set in the current `Activity`, otherwise the hook will no-op. + +Below are the tags added to the trace event: + +| Tag Name | Description | Source | +| --------------------------- | ---------------------------------------------------------------------------- | ----------------------------- | +| feature_flag.key | The lookup key of the feature flag | Hook context flag key | +| feature_flag.provider.name | The name of the feature flag provider | Provider metadata | +| feature_flag.result.reason | The reason code which shows how a feature flag value was determined | Evaluation details | +| feature_flag.result.variant | A semantic identifier for an evaluated flag value | Evaluation details | +| feature_flag.result.value | The evaluated value of the feature flag | Evaluation details | +| feature_flag.context.id | The unique identifier for the flag evaluation context | Flag metadata (if available) | +| feature_flag.set.id | The identifier of the flag set to which the feature flag belongs | Flag metadata (if available) | +| feature_flag.version | The version of the ruleset used during the evaluation | Flag metadata (if available) | +| error.type | Describes a class of error the operation ended with | Evaluation details (if error) | +| error.message | A message explaining the nature of an error occurring during flag evaluation | Evaluation details (if error) | + +#### Example + +The following example demonstrates the use of the `TraceEnricherHook` with the `OpenFeature dotnet-sdk`. The traces are sent to a `jaeger` OTLP collector running at `localhost:4317`. + +```csharp +using OpenFeature.Contrib.Providers.Flagd; +using OpenFeature.Hooks; +using OpenTelemetry.Exporter; +using OpenTelemetry.Resources; +using OpenTelemetry; +using OpenTelemetry.Trace; + +namespace OpenFeatureTestApp +{ + class Hello { + static void Main(string[] args) { + + // set up the OpenTelemetry OTLP exporter + var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource("my-tracer") + .ConfigureResource(r => r.AddService("jaeger-test")) + .AddOtlpExporter(o => + { + o.ExportProcessorType = ExportProcessorType.Simple; + }) + .Build(); + + // add the TraceEnricherHook to the OpenFeature instance + OpenFeature.Api.Instance.AddHooks(new TraceEnricherHook()); + + var flagdProvider = new FlagdProvider(new Uri("http://localhost:8013")); + + // Set the flagdProvider as the provider for the OpenFeature SDK + OpenFeature.Api.Instance.SetProvider(flagdProvider); + + var client = OpenFeature.Api.Instance.GetClient("my-app"); + + var val = client.GetBooleanValueAsync("myBoolFlag", false, null); + + // Print the value of the 'myBoolFlag' feature flag + System.Console.WriteLine(val.Result.ToString()); + } + } +} +``` + +After running this example, you will be able to see the traces, including the events sent by the hook in your Jaeger UI. + +### Metrics Hook + +For this hook to function correctly a global `MeterProvider` must be set. +`MetricsHook` performs metric collection by tapping into various hook stages. + +Below are the metrics extracted by this hook and dimensions they carry: + +| Metric key | Description | Unit | Dimensions | +| -------------------------------------- | ------------------------------- | ------------ | ----------------------------- | +| feature_flag.evaluation_requests_total | Number of evaluation requests | {request} | key, provider name | +| feature_flag.evaluation_success_total | Flag evaluation successes | {impression} | key, provider name, reason | +| feature_flag.evaluation_error_total | Flag evaluation errors | 1 | key, provider name, exception | +| feature_flag.evaluation_active_count | Active flag evaluations counter | 1 | key, provider name | + +Consider the following code example for usage. + +#### Example + +The following example demonstrates the use of the `MetricsHook` with the `OpenFeature dotnet-sdk`. The metrics are sent to the `console`. + +```csharp +using OpenFeature.Contrib.Providers.Flagd; +using OpenFeature; +using OpenFeature.Hooks; +using OpenTelemetry; +using OpenTelemetry.Metrics; + +namespace OpenFeatureTestApp +{ + class Hello { + static void Main(string[] args) { + + // set up the OpenTelemetry OTLP exporter + var meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter("OpenFeature") + .ConfigureResource(r => r.AddService("openfeature-test")) + .AddConsoleExporter() + .Build(); + + // add the MetricsHook to the OpenFeature instance + OpenFeature.Api.Instance.AddHooks(new MetricsHook()); + + var flagdProvider = new FlagdProvider(new Uri("http://localhost:8013")); + + // Set the flagdProvider as the provider for the OpenFeature SDK + OpenFeature.Api.Instance.SetProvider(flagdProvider); + + var client = OpenFeature.Api.Instance.GetClient("my-app"); + + var val = client.GetBooleanValueAsync("myBoolFlag", false, null); + + // Print the value of the 'myBoolFlag' feature flag + System.Console.WriteLine(val.Result.ToString()); + } + } +} +``` + +After running this example, you should be able to see some metrics being generated into the console. + ## ⭐️ Support the project diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index a9c2cd508..5f4f01461 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -3,16 +3,33 @@ using OpenFeature.DependencyInjection.Providers.Memory; using OpenFeature.Hooks; using OpenFeature.Providers.Memory; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddProblemDetails(); +// Configure OpenTelemetry +builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService("openfeature-aspnetcore-sample")) + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddOtlpExporter()) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddMeter("OpenFeature") + .AddOtlpExporter()); + builder.Services.AddOpenFeature(featureBuilder => { featureBuilder.AddHostedFeatureLifecycle() .AddHook(sp => new LoggingHook(sp.GetRequiredService>())) + .AddHook() + .AddHook() .AddInMemoryProvider("InMemory", _ => new Dictionary() { { diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj index 01e452d77..3dd554a78 100644 --- a/samples/AspNetCore/Samples.AspNetCore.csproj +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -1,9 +1,19 @@  + + false + + + + + + + + diff --git a/src/OpenFeature/Hooks/MetricsConstants.cs b/src/OpenFeature/Hooks/MetricsConstants.cs new file mode 100644 index 000000000..e54dd61cf --- /dev/null +++ b/src/OpenFeature/Hooks/MetricsConstants.cs @@ -0,0 +1,16 @@ +namespace OpenFeature.Hooks; + +internal static class MetricsConstants +{ + internal const string ActiveCountName = "feature_flag.evaluation_active_count"; + internal const string RequestsTotalName = "feature_flag.evaluation_requests_total"; + internal const string SuccessTotalName = "feature_flag.evaluation_success_total"; + internal const string ErrorTotalName = "feature_flag.evaluation_error_total"; + + internal const string ActiveDescription = "active flag evaluations counter"; + internal const string RequestsDescription = "feature flag evaluation request counter"; + internal const string SuccessDescription = "feature flag evaluation success counter"; + internal const string ErrorDescription = "feature flag evaluation error counter"; + + internal const string ExceptionAttr = "exception"; +} diff --git a/src/OpenFeature/Hooks/MetricsHook.cs b/src/OpenFeature/Hooks/MetricsHook.cs new file mode 100644 index 000000000..2f2314f0d --- /dev/null +++ b/src/OpenFeature/Hooks/MetricsHook.cs @@ -0,0 +1,100 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Reflection; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Telemetry; + +namespace OpenFeature.Hooks; + +/// +/// Represents a hook for capturing metrics related to flag evaluations. +/// The meter instrumentation name is "OpenFeature". +/// +/// This is still experimental and subject to change. +public class MetricsHook : Hook +{ + private static readonly AssemblyName AssemblyName = typeof(MetricsHook).Assembly.GetName(); + private static readonly string InstrumentationName = AssemblyName.Name ?? "OpenFeature"; + private static readonly string InstrumentationVersion = AssemblyName.Version?.ToString() ?? "1.0.0"; + private static readonly Meter Meter = new(InstrumentationName, InstrumentationVersion); + + private readonly UpDownCounter _evaluationActiveUpDownCounter; + private readonly Counter _evaluationRequestCounter; + private readonly Counter _evaluationSuccessCounter; + private readonly Counter _evaluationErrorCounter; + + /// + /// Initializes a new instance of the class. + /// + public MetricsHook() + { + this._evaluationActiveUpDownCounter = Meter.CreateUpDownCounter(MetricsConstants.ActiveCountName, description: MetricsConstants.ActiveDescription); + this._evaluationRequestCounter = Meter.CreateCounter(MetricsConstants.RequestsTotalName, "{request}", MetricsConstants.RequestsDescription); + this._evaluationSuccessCounter = Meter.CreateCounter(MetricsConstants.SuccessTotalName, "{impression}", MetricsConstants.SuccessDescription); + this._evaluationErrorCounter = Meter.CreateCounter(MetricsConstants.ErrorTotalName, description: MetricsConstants.ErrorDescription); + } + + /// + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var tagList = new TagList + { + { TelemetryConstants.Key, context.FlagKey }, + { TelemetryConstants.Provider, context.ProviderMetadata.Name } + }; + + this._evaluationActiveUpDownCounter.Add(1, tagList); + this._evaluationRequestCounter.Add(1, tagList); + + return base.BeforeAsync(context, hints, cancellationToken); + } + + + /// + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var tagList = new TagList + { + { TelemetryConstants.Key, context.FlagKey }, + { TelemetryConstants.Provider, context.ProviderMetadata.Name }, + { TelemetryConstants.Reason, details.Reason ?? Reason.Unknown.ToString() } + }; + + this._evaluationSuccessCounter.Add(1, tagList); + + return base.AfterAsync(context, details, hints, cancellationToken); + } + + /// + public override ValueTask ErrorAsync(HookContext context, Exception error, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var tagList = new TagList + { + { TelemetryConstants.Key, context.FlagKey }, + { TelemetryConstants.Provider, context.ProviderMetadata.Name }, + { MetricsConstants.ExceptionAttr, error.Message } + }; + + this._evaluationErrorCounter.Add(1, tagList); + + return base.ErrorAsync(context, error, hints, cancellationToken); + } + + /// + public override ValueTask FinallyAsync(HookContext context, + FlagEvaluationDetails evaluationDetails, + IReadOnlyDictionary? hints = null, + CancellationToken cancellationToken = default) + { + var tagList = new TagList + { + { TelemetryConstants.Key, context.FlagKey }, + { TelemetryConstants.Provider, context.ProviderMetadata.Name } + }; + + this._evaluationActiveUpDownCounter.Add(-1, tagList); + + return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken); + } +} diff --git a/src/OpenFeature/Hooks/TraceEnricherHook.cs b/src/OpenFeature/Hooks/TraceEnricherHook.cs new file mode 100644 index 000000000..08914b1ca --- /dev/null +++ b/src/OpenFeature/Hooks/TraceEnricherHook.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; +using OpenFeature.Model; +using OpenFeature.Telemetry; + +namespace OpenFeature.Hooks; + +/// +/// A hook that enriches telemetry traces with additional information during the feature flag evaluation lifecycle. +/// This hook adds relevant flag evaluation details as tags and events to the current for tracing purposes. +/// On error, it attaches exception information to the trace, using the appropriate API depending on the .NET version. +/// +/// This is still experimental and subject to change. +public class TraceEnricherHook : Hook +{ + /// + /// Adds tags and events to the current for tracing purposes. + /// + /// The type of the flag value being evaluated. + /// The hook context containing metadata about the evaluation. + /// Details about the flag evaluation including the key, value, and variant. + /// Optional dictionary of hints that can modify hook behavior. + /// A token to cancel the operation. + /// A completed representing the asynchronous operation. + public override ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + var evaluationEvent = EvaluationEventBuilder.Build(context, details); + + var tags = new ActivityTagsCollection(); + foreach (var kvp in evaluationEvent.Attributes) + { + tags[kvp.Key] = kvp.Value; + } + + Activity.Current?.AddEvent(new ActivityEvent(evaluationEvent.Name, tags: tags)); + + return base.FinallyAsync(context, details, hints, cancellationToken); + } +} diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj index c47b109d0..3d81a99eb 100644 --- a/src/OpenFeature/OpenFeature.csproj +++ b/src/OpenFeature/OpenFeature.csproj @@ -9,6 +9,7 @@ + diff --git a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs new file mode 100644 index 000000000..54f6e19cc --- /dev/null +++ b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs @@ -0,0 +1,141 @@ +using OpenFeature.Hooks; +using OpenFeature.Model; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; + +namespace OpenFeature.Tests.Hooks; + +[CollectionDefinition(nameof(MetricsHookTest), DisableParallelization = true)] +public class MetricsHookTest : IDisposable +{ + private readonly List _exportedItems; + private readonly MeterProvider _meterProvider; + + public MetricsHookTest() + { + // Arrange metrics collector + this._exportedItems = []; + this._meterProvider = Sdk.CreateMeterProviderBuilder() + .AddMeter("OpenFeature") + .ConfigureResource(r => r.AddService("open-feature")) + .AddInMemoryExporter(this._exportedItems, + option => option.PeriodicExportingMetricReaderOptions = + new PeriodicExportingMetricReaderOptions { ExportIntervalMilliseconds = 100 }) + .Build(); + } + +#pragma warning disable CA1816 + public void Dispose() + { + this._meterProvider.Shutdown(); + } +#pragma warning restore CA1816 + + [Fact] + public async Task After_Test() + { + // Arrange + const string metricName = "feature_flag.evaluation_success_total"; + var metricsHook = new MetricsHook(); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await metricsHook.AfterAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + this._meterProvider.ForceFlush(); + + // Assert metrics + Assert.NotEmpty(this._exportedItems); + + // check if the metric is present in the exported items + var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); + Assert.NotNull(metric); + + var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); + Assert.True(noOtherMetric); + } + + [Fact] + public async Task Error_Test() + { + // Arrange + const string metricName = "feature_flag.evaluation_error_total"; + var metricsHook = new MetricsHook(); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await metricsHook.ErrorAsync(ctx, new Exception(), new Dictionary()).ConfigureAwait(true); + this._meterProvider.ForceFlush(); + + // Assert metrics + Assert.NotEmpty(this._exportedItems); + + // check if the metric is present in the exported items + var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); + Assert.NotNull(metric); + + var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); + Assert.True(noOtherMetric); + } + + [Fact] + public async Task Finally_Test() + { + // Arrange + const string metricName = "feature_flag.evaluation_active_count"; + var metricsHook = new MetricsHook(); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + var evaluationDetails = new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"); + + // Act + await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true); + this._meterProvider.ForceFlush(); + + // Assert metrics + Assert.NotEmpty(this._exportedItems); + + // check if the metric feature_flag.evaluation_success_total is present in the exported items + var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); + Assert.NotNull(metric); + + var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); + Assert.True(noOtherMetric); + } + + [Fact] + public async Task Before_Test() + { + // Arrange + const string metricName1 = "feature_flag.evaluation_active_count"; + const string metricName2 = "feature_flag.evaluation_requests_total"; + var metricsHook = new MetricsHook(); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await metricsHook.BeforeAsync(ctx, new Dictionary()).ConfigureAwait(true); + this._meterProvider.ForceFlush(); + + // Assert metrics + Assert.NotEmpty(this._exportedItems); + + // check if the metric is present in the exported items + var metric1 = this._exportedItems.FirstOrDefault(m => m.Name == metricName1); + Assert.NotNull(metric1); + + var metric2 = this._exportedItems.FirstOrDefault(m => m.Name == metricName2); + Assert.NotNull(metric2); + + var noOtherMetric = this._exportedItems.All(m => m.Name == metricName1 || m.Name == metricName2); + Assert.True(noOtherMetric); + } +} diff --git a/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs b/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs new file mode 100644 index 000000000..f73d36200 --- /dev/null +++ b/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using OpenFeature.Hooks; +using OpenFeature.Model; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace OpenFeature.Tests.Hooks; + +[CollectionDefinition(nameof(TraceEnricherHookTests), DisableParallelization = true)] +public class TraceEnricherHookTests : IDisposable +{ + private readonly List _exportedItems; + private readonly TracerProvider _tracerProvider; + private readonly Tracer _tracer; + + public TraceEnricherHookTests() + { + // List that will be populated with the traces by InMemoryExporter + this._exportedItems = []; + + // Create a new in-memory exporter + this._tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource("my-tracer") + .ConfigureResource(r => r.AddService("in-memory-test")) + .AddInMemoryExporter(this._exportedItems) + .Build(); + + this._tracer = this._tracerProvider.GetTracer("my-tracer"); + } + +#pragma warning disable CA1816 + public void Dispose() + { + this._tracerProvider.Shutdown(); + } +#pragma warning restore CA1816 + + [Fact] + public async Task TestFinally() + { + // Arrange + var traceEnricherHook = new TraceEnricherHook(); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + var span = this._tracer.StartActiveSpan("my-span"); + await traceEnricherHook.FinallyAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + span.End(); + + this._tracerProvider.ForceFlush(); + + // Assert + Assert.Single(this._exportedItems); + var rootSpan = this._exportedItems.First(); + + Assert.Single(rootSpan.Events); + ActivityEvent ev = rootSpan.Events.First(); + Assert.Equal("feature_flag.evaluation", ev.Name); + + Assert.Contains(new KeyValuePair("feature_flag.key", "my-flag"), ev.Tags); + Assert.Contains(new KeyValuePair("feature_flag.result.variant", "default"), ev.Tags); + Assert.Contains(new KeyValuePair("feature_flag.provider.name", "my-provider"), ev.Tags); + Assert.Contains(new KeyValuePair("feature_flag.result.reason", "static"), ev.Tags); + Assert.Contains(new KeyValuePair("feature_flag.result.value", "foo"), ev.Tags); + } + + [Fact] + public async Task TestFinally_NoSpan() + { + // Arrange + var traceEnricherHook = new TraceEnricherHook(); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await traceEnricherHook.FinallyAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + + this._tracerProvider.ForceFlush(); + + // Assert + Assert.Empty(this._exportedItems); + } +} diff --git a/test/OpenFeature.Tests/OpenFeature.Tests.csproj b/test/OpenFeature.Tests/OpenFeature.Tests.csproj index a556655a4..8abb4891f 100644 --- a/test/OpenFeature.Tests/OpenFeature.Tests.csproj +++ b/test/OpenFeature.Tests/OpenFeature.Tests.csproj @@ -19,6 +19,8 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive From 08ff43ce3426c8bb9f24446bdf62e56b10534c1f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:38:46 +0100 Subject: [PATCH 055/126] chore(deps): update github/codeql-action digest to 39edc49 (#504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [github/codeql-action](https://github.com/github/codeql-action) | action | digest | `ce28f5b` -> `39edc49` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6e8d1e32b..c1785d201 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3 + uses: github/codeql-action/init@39edc492dbe16b1465b0cafca41432d857bdb31a # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3 + uses: github/codeql-action/autobuild@39edc492dbe16b1465b0cafca41432d857bdb31a # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3 + uses: github/codeql-action/analyze@39edc492dbe16b1465b0cafca41432d857bdb31a # v3 From 69dc18611399ab5e573268c35d414a028c77f0ff Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Jun 2025 15:39:36 +0100 Subject: [PATCH 056/126] chore(deps): update opentelemetry-dotnet monorepo to 1.12.0 (#506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [OpenTelemetry](https://opentelemetry.io/) ([source](https://github.com/open-telemetry/opentelemetry-dotnet)) | `1.11.2` -> `1.12.0` | [![age](https://developer.mend.io/api/mc/badges/age/nuget/OpenTelemetry/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/nuget/OpenTelemetry/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/nuget/OpenTelemetry/1.11.2/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/OpenTelemetry/1.11.2/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | | [OpenTelemetry.Exporter.InMemory](https://opentelemetry.io/) ([source](https://github.com/open-telemetry/opentelemetry-dotnet)) | `1.11.2` -> `1.12.0` | [![age](https://developer.mend.io/api/mc/badges/age/nuget/OpenTelemetry.Exporter.InMemory/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/nuget/OpenTelemetry.Exporter.InMemory/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/nuget/OpenTelemetry.Exporter.InMemory/1.11.2/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/OpenTelemetry.Exporter.InMemory/1.11.2/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | | [OpenTelemetry.Exporter.OpenTelemetryProtocol](https://opentelemetry.io/) ([source](https://github.com/open-telemetry/opentelemetry-dotnet)) | `1.9.0` -> `1.12.0` | [![age](https://developer.mend.io/api/mc/badges/age/nuget/OpenTelemetry.Exporter.OpenTelemetryProtocol/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/nuget/OpenTelemetry.Exporter.OpenTelemetryProtocol/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/nuget/OpenTelemetry.Exporter.OpenTelemetryProtocol/1.9.0/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/OpenTelemetry.Exporter.OpenTelemetryProtocol/1.9.0/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | | [OpenTelemetry.Extensions.Hosting](https://opentelemetry.io/) ([source](https://github.com/open-telemetry/opentelemetry-dotnet)) | `1.9.0` -> `1.12.0` | [![age](https://developer.mend.io/api/mc/badges/age/nuget/OpenTelemetry.Extensions.Hosting/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/nuget/OpenTelemetry.Extensions.Hosting/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/nuget/OpenTelemetry.Extensions.Hosting/1.9.0/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/OpenTelemetry.Extensions.Hosting/1.9.0/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
open-telemetry/opentelemetry-dotnet (OpenTelemetry) ### [`v1.12.0`](https://github.com/open-telemetry/opentelemetry-dotnet/blob/HEAD/RELEASENOTES.md#1120) Release details: [1.12.0](https://github.com/open-telemetry/opentelemetry-dotnet/releases/tag/core-1.12.0) - **Breaking Change**: `OpenTelemetry.Exporter.OpenTelemetryProtocol` now defaults to using OTLP/HTTP instead of OTLP/gRPC when targeting .NET Framework and .NET Standard. This change may cause telemetry export to fail unless appropriate adjustments are made. Explicitly setting OTLP/gRPC may result in a `NotSupportedException` unless further configuration is applied. See [#​6209](https://github.com/open-telemetry/opentelemetry-dotnet/issues/6209) for full details and mitigation guidance. [#​6229](https://github.com/open-telemetry/opentelemetry-dotnet/pull/6229)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 4 ++-- samples/AspNetCore/Samples.AspNetCore.csproj | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9b184d3fc..041434a47 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,8 +30,8 @@ - - + + diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj index 3dd554a78..cc5ffba4d 100644 --- a/samples/AspNetCore/Samples.AspNetCore.csproj +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -11,9 +11,9 @@
- + - + From 241d88024ff13ddd57f4e9c5719add95b5864043 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 28 Jun 2025 13:54:49 +0100 Subject: [PATCH 057/126] chore(deps): update dependency opentelemetry.instrumentation.aspnetcore to 1.12.0 (#505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [OpenTelemetry.Instrumentation.AspNetCore](https://opentelemetry.io/) ([source](https://github.com/open-telemetry/opentelemetry-dotnet-contrib)) | `1.9.0` -> `1.12.0` | [![age](https://developer.mend.io/api/mc/badges/age/nuget/OpenTelemetry.Instrumentation.AspNetCore/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/nuget/OpenTelemetry.Instrumentation.AspNetCore/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/nuget/OpenTelemetry.Instrumentation.AspNetCore/1.9.0/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/OpenTelemetry.Instrumentation.AspNetCore/1.9.0/1.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- samples/AspNetCore/Samples.AspNetCore.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj index cc5ffba4d..cd249ab3e 100644 --- a/samples/AspNetCore/Samples.AspNetCore.csproj +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -12,7 +12,7 @@ - + From f923cea14eb552098edb987950ad4bc82bbadab1 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 2 Jul 2025 21:15:59 +0100 Subject: [PATCH 058/126] docs: add XML comment on FeatureClient (#507) ## This PR - Adds XML comment on FeatureClient class. ### Related Issues Fixes #497 ### Notes ### Follow-up Tasks ### How to test Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- src/OpenFeature/OpenFeatureClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index 02acde07c..cc81f8838 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -10,7 +10,7 @@ namespace OpenFeature; /// -/// +/// OpenFeature Client implementation for resolving feature flags and tracking user interactions. /// public sealed partial class FeatureClient : IFeatureClient { From 9151dcdf2cecde9b4b01f06c73d149e0ad3bb539 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:45:08 +0100 Subject: [PATCH 059/126] fix: ArgumentNullException when creating a client with optional name (#508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR - Adds a default placeholder name that gets generated when a Client is generated. This is inspired from the Java SDK (see [EventSource.java](https://github.com/open-feature/java-sdk/blob/0515ad54c4f71863373eb1b7f429393923b27d90/src/main/java/dev/openfeature/sdk/EventSupport.java#L40)). The exception is thrown when an optional client name is specified when retrieving a client from `Api.Instance`. When no name is specified we use a generated one. ### Related Issues Fixes #491 ### Notes ### Follow-up Tasks ### How to test --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> Co-authored-by: André Silva <2493377+askpt@users.noreply.github.com> --- src/OpenFeature/EventExecutor.cs | 36 ++++++++++++++----- .../OpenFeatureClientTests.cs | 15 ++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/OpenFeature/EventExecutor.cs b/src/OpenFeature/EventExecutor.cs index db2b6fb10..f65f41a1a 100644 --- a/src/OpenFeature/EventExecutor.cs +++ b/src/OpenFeature/EventExecutor.cs @@ -14,6 +14,9 @@ internal sealed partial class EventExecutor : IAsyncDisposable private readonly Dictionary _namedProviderReferences = []; private readonly List _activeSubscriptions = []; + /// placeholder for anonymous clients + private static Guid _defaultClientName = Guid.NewGuid(); + private readonly Dictionary> _apiHandlers = []; private readonly Dictionary>> _clientHandlers = []; @@ -58,25 +61,27 @@ internal void RemoveApiLevelHandler(ProviderEventTypes type, EventHandlerDelegat internal void AddClientHandler(string client, ProviderEventTypes eventType, EventHandlerDelegate handler) { + var clientName = GetClientName(client); + lock (this._lockObj) { // check if there is already a list of handlers for the given client and event type - if (!this._clientHandlers.TryGetValue(client, out var registry)) + if (!this._clientHandlers.TryGetValue(clientName, out var registry)) { registry = []; - this._clientHandlers[client] = registry; + this._clientHandlers[clientName] = registry; } - if (!this._clientHandlers[client].TryGetValue(eventType, out var eventHandlers)) + if (!this._clientHandlers[clientName].TryGetValue(eventType, out var eventHandlers)) { eventHandlers = []; - this._clientHandlers[client][eventType] = eventHandlers; + this._clientHandlers[clientName][eventType] = eventHandlers; } - this._clientHandlers[client][eventType].Add(handler); + this._clientHandlers[clientName][eventType].Add(handler); this.EmitOnRegistration( - this._namedProviderReferences.TryGetValue(client, out var clientProviderReference) + this._namedProviderReferences.TryGetValue(clientName, out var clientProviderReference) ? clientProviderReference : this._defaultProvider, eventType, handler); } @@ -84,9 +89,11 @@ internal void AddClientHandler(string client, ProviderEventTypes eventType, Even internal void RemoveClientHandler(string client, ProviderEventTypes type, EventHandlerDelegate handler) { + var clientName = GetClientName(client); + lock (this._lockObj) { - if (this._clientHandlers.TryGetValue(client, out var clientEventHandlers)) + if (this._clientHandlers.TryGetValue(clientName, out var clientEventHandlers)) { if (clientEventHandlers.TryGetValue(type, out var eventHandlers)) { @@ -118,15 +125,18 @@ internal void RegisterClientFeatureProvider(string client, FeatureProvider? prov { return; } + + var clientName = GetClientName(client); + lock (this._lockObj) { FeatureProvider? oldProvider = null; - if (this._namedProviderReferences.TryGetValue(client, out var foundOldProvider)) + if (this._namedProviderReferences.TryGetValue(clientName, out var foundOldProvider)) { oldProvider = foundOldProvider; } - this._namedProviderReferences[client] = provider; + this._namedProviderReferences[clientName] = provider; this.StartListeningAndShutdownOld(provider, oldProvider); } @@ -303,6 +313,14 @@ private void ProcessDefaultProviderHandlers(Event e) } } + private static string GetClientName(string client) + { + if (string.IsNullOrWhiteSpace(client)) + { + return _defaultClientName.ToString(); + } + return client; + } // map events to provider status as per spec: https://openfeature.dev/specification/sections/events/#requirement-535 private static void UpdateProviderStatus(FeatureProvider provider, ProviderEventPayload eventPayload) diff --git a/test/OpenFeature.Tests/OpenFeatureClientTests.cs b/test/OpenFeature.Tests/OpenFeatureClientTests.cs index cbecddc28..9ea3f0dc7 100644 --- a/test/OpenFeature.Tests/OpenFeatureClientTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureClientTests.cs @@ -597,6 +597,21 @@ public async Task PassingABlankStringAsTrackingEventName_ThrowsArgumentException Assert.Throws(() => client.Track(" \n ")); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task PassingBlankClientName_DoesNotThrowArgumentNullException(string? clientName) + { + var provider = new TestProvider(); + await Api.Instance.SetProviderAsync(provider); + var client = Api.Instance.GetClient(clientName); + + var ex = Record.Exception(() => client.AddHandler(ProviderEventTypes.ProviderReady, (args) => { })); + + Assert.Null(ex); + } + public static TheoryData GenerateMergeEvaluationContextTestData() { const string key = "key"; From 075695f9c9dc69aeacf03275600ce599d76d2451 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:04:18 -0400 Subject: [PATCH 060/126] chore(main): release 2.7.0 (#480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :robot: I have created a release *beep* *boop* --- ## [2.7.0](https://github.com/open-feature/dotnet-sdk/compare/v2.6.0...v2.7.0) (2025-07-03) ### 🐛 Bug Fixes * Add generic to evaluation event builder ([#500](https://github.com/open-feature/dotnet-sdk/issues/500)) ([68af649](https://github.com/open-feature/dotnet-sdk/commit/68af6493b09d29be5d4cdda9e6f792ee8667bf4f)) * ArgumentNullException when creating a client with optional name ([#508](https://github.com/open-feature/dotnet-sdk/issues/508)) ([9151dcd](https://github.com/open-feature/dotnet-sdk/commit/9151dcdf2cecde9b4b01f06c73d149e0ad3bb539)) ### ✨ New Features * Move OTEL hooks to the SDK ([#338](https://github.com/open-feature/dotnet-sdk/issues/338)) ([77f6e1b](https://github.com/open-feature/dotnet-sdk/commit/77f6e1bbb76973e078c1999ad0784c9edc9def96)) ### 🧹 Chore * **deps:** update actions/attest-build-provenance action to v2.4.0 ([#495](https://github.com/open-feature/dotnet-sdk/issues/495)) ([349c073](https://github.com/open-feature/dotnet-sdk/commit/349c07301d0ff97c759417344eef74a00b06edbc)) * **deps:** update actions/attest-sbom action to v2.4.0 ([#496](https://github.com/open-feature/dotnet-sdk/issues/496)) ([f7ca416](https://github.com/open-feature/dotnet-sdk/commit/f7ca4163e0ce549a015a7a27cb184fb76a199a04)) * **deps:** update dependency benchmarkdotnet to 0.15.0 ([#481](https://github.com/open-feature/dotnet-sdk/issues/481)) ([714425d](https://github.com/open-feature/dotnet-sdk/commit/714425d405a33231e85b1e62019fc678b2e883ef)) * **deps:** update dependency benchmarkdotnet to 0.15.2 ([#494](https://github.com/open-feature/dotnet-sdk/issues/494)) ([cab3807](https://github.com/open-feature/dotnet-sdk/commit/cab380727fe95b941384ae71f022626cdf23db53)) * **deps:** update dependency microsoft.net.test.sdk to 17.14.0 ([#482](https://github.com/open-feature/dotnet-sdk/issues/482)) ([520d383](https://github.com/open-feature/dotnet-sdk/commit/520d38305c6949c88b057f28e5dfe3305257e437)) * **deps:** update dependency microsoft.net.test.sdk to 17.14.1 ([#485](https://github.com/open-feature/dotnet-sdk/issues/485)) ([78bfdbf](https://github.com/open-feature/dotnet-sdk/commit/78bfdbf0850e2d5eb80cfbae3bfac8208f6c45b1)) * **deps:** update dependency opentelemetry.instrumentation.aspnetcore to 1.12.0 ([#505](https://github.com/open-feature/dotnet-sdk/issues/505)) ([241d880](https://github.com/open-feature/dotnet-sdk/commit/241d88024ff13ddd57f4e9c5719add95b5864043)) * **deps:** update dependency reqnroll.xunit to 2.4.1 ([#483](https://github.com/open-feature/dotnet-sdk/issues/483)) ([99f7584](https://github.com/open-feature/dotnet-sdk/commit/99f7584c91882ba59412e2306167172470cd4677)) * **deps:** update dependency system.valuetuple to 4.6.1 ([#503](https://github.com/open-feature/dotnet-sdk/issues/503)) ([39f884d](https://github.com/open-feature/dotnet-sdk/commit/39f884df420f1a9346852159948c288e728672b8)) * **deps:** update github/codeql-action digest to 39edc49 ([#504](https://github.com/open-feature/dotnet-sdk/issues/504)) ([08ff43c](https://github.com/open-feature/dotnet-sdk/commit/08ff43ce3426c8bb9f24446bdf62e56b10534c1f)) * **deps:** update github/codeql-action digest to ce28f5b ([#492](https://github.com/open-feature/dotnet-sdk/issues/492)) ([cce224f](https://github.com/open-feature/dotnet-sdk/commit/cce224fcf81aede5a626936a26546fe710fbcc30)) * **deps:** update github/codeql-action digest to fca7ace ([#486](https://github.com/open-feature/dotnet-sdk/issues/486)) ([e18ad50](https://github.com/open-feature/dotnet-sdk/commit/e18ad50e3298cb0dd19143678c3ef0fdcb4484d9)) * **deps:** update opentelemetry-dotnet monorepo to 1.12.0 ([#506](https://github.com/open-feature/dotnet-sdk/issues/506)) ([69dc186](https://github.com/open-feature/dotnet-sdk/commit/69dc18611399ab5e573268c35d414a028c77f0ff)) * **deps:** update spec digest to 1965aae ([#499](https://github.com/open-feature/dotnet-sdk/issues/499)) ([2e3dffd](https://github.com/open-feature/dotnet-sdk/commit/2e3dffd0ebbba4a9d95763e2ce9f3e2ac051a317)) * **deps:** update spec digest to 42340bb ([#493](https://github.com/open-feature/dotnet-sdk/issues/493)) ([909c51d](https://github.com/open-feature/dotnet-sdk/commit/909c51d4e25917d6a9a5ae9bb04cfe48665186ba)) * **deps:** update spec digest to c37ac17 ([#502](https://github.com/open-feature/dotnet-sdk/issues/502)) ([38f63fc](https://github.com/open-feature/dotnet-sdk/commit/38f63fceb5516cd474fd0e867aa25eae252cf2c1)) * **deps:** update spec digest to f014806 ([#479](https://github.com/open-feature/dotnet-sdk/issues/479)) ([dbe8b08](https://github.com/open-feature/dotnet-sdk/commit/dbe8b082c28739a1b81b74b29ed28fbccc94f7bc)) * fix sample build warning ([#498](https://github.com/open-feature/dotnet-sdk/issues/498)) ([08a00e1](https://github.com/open-feature/dotnet-sdk/commit/08a00e1d35834635ca296fe8a13507001ad25c57)) ### 📚 Documentation * add XML comment on FeatureClient ([#507](https://github.com/open-feature/dotnet-sdk/issues/507)) ([f923cea](https://github.com/open-feature/dotnet-sdk/commit/f923cea14eb552098edb987950ad4bc82bbadab1)) * updated contributing link on the README ([8435bf7](https://github.com/open-feature/dotnet-sdk/commit/8435bf7d8131307e627e59453008124ac4c71906)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++ README.md | 4 ++-- build/Common.prod.props | 2 +- version.txt | 2 +- 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 69e82f12f..6ed9c8012 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.6.0" + ".": "2.7.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index e06ef65bb..7a176c613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog +## [2.7.0](https://github.com/open-feature/dotnet-sdk/compare/v2.6.0...v2.7.0) (2025-07-03) + + +### 🐛 Bug Fixes + +* Add generic to evaluation event builder ([#500](https://github.com/open-feature/dotnet-sdk/issues/500)) ([68af649](https://github.com/open-feature/dotnet-sdk/commit/68af6493b09d29be5d4cdda9e6f792ee8667bf4f)) +* ArgumentNullException when creating a client with optional name ([#508](https://github.com/open-feature/dotnet-sdk/issues/508)) ([9151dcd](https://github.com/open-feature/dotnet-sdk/commit/9151dcdf2cecde9b4b01f06c73d149e0ad3bb539)) + + +### ✨ New Features + +* Move OTEL hooks to the SDK ([#338](https://github.com/open-feature/dotnet-sdk/issues/338)) ([77f6e1b](https://github.com/open-feature/dotnet-sdk/commit/77f6e1bbb76973e078c1999ad0784c9edc9def96)) + + +### 🧹 Chore + +* **deps:** update actions/attest-build-provenance action to v2.4.0 ([#495](https://github.com/open-feature/dotnet-sdk/issues/495)) ([349c073](https://github.com/open-feature/dotnet-sdk/commit/349c07301d0ff97c759417344eef74a00b06edbc)) +* **deps:** update actions/attest-sbom action to v2.4.0 ([#496](https://github.com/open-feature/dotnet-sdk/issues/496)) ([f7ca416](https://github.com/open-feature/dotnet-sdk/commit/f7ca4163e0ce549a015a7a27cb184fb76a199a04)) +* **deps:** update dependency benchmarkdotnet to 0.15.0 ([#481](https://github.com/open-feature/dotnet-sdk/issues/481)) ([714425d](https://github.com/open-feature/dotnet-sdk/commit/714425d405a33231e85b1e62019fc678b2e883ef)) +* **deps:** update dependency benchmarkdotnet to 0.15.2 ([#494](https://github.com/open-feature/dotnet-sdk/issues/494)) ([cab3807](https://github.com/open-feature/dotnet-sdk/commit/cab380727fe95b941384ae71f022626cdf23db53)) +* **deps:** update dependency microsoft.net.test.sdk to 17.14.0 ([#482](https://github.com/open-feature/dotnet-sdk/issues/482)) ([520d383](https://github.com/open-feature/dotnet-sdk/commit/520d38305c6949c88b057f28e5dfe3305257e437)) +* **deps:** update dependency microsoft.net.test.sdk to 17.14.1 ([#485](https://github.com/open-feature/dotnet-sdk/issues/485)) ([78bfdbf](https://github.com/open-feature/dotnet-sdk/commit/78bfdbf0850e2d5eb80cfbae3bfac8208f6c45b1)) +* **deps:** update dependency opentelemetry.instrumentation.aspnetcore to 1.12.0 ([#505](https://github.com/open-feature/dotnet-sdk/issues/505)) ([241d880](https://github.com/open-feature/dotnet-sdk/commit/241d88024ff13ddd57f4e9c5719add95b5864043)) +* **deps:** update dependency reqnroll.xunit to 2.4.1 ([#483](https://github.com/open-feature/dotnet-sdk/issues/483)) ([99f7584](https://github.com/open-feature/dotnet-sdk/commit/99f7584c91882ba59412e2306167172470cd4677)) +* **deps:** update dependency system.valuetuple to 4.6.1 ([#503](https://github.com/open-feature/dotnet-sdk/issues/503)) ([39f884d](https://github.com/open-feature/dotnet-sdk/commit/39f884df420f1a9346852159948c288e728672b8)) +* **deps:** update github/codeql-action digest to 39edc49 ([#504](https://github.com/open-feature/dotnet-sdk/issues/504)) ([08ff43c](https://github.com/open-feature/dotnet-sdk/commit/08ff43ce3426c8bb9f24446bdf62e56b10534c1f)) +* **deps:** update github/codeql-action digest to ce28f5b ([#492](https://github.com/open-feature/dotnet-sdk/issues/492)) ([cce224f](https://github.com/open-feature/dotnet-sdk/commit/cce224fcf81aede5a626936a26546fe710fbcc30)) +* **deps:** update github/codeql-action digest to fca7ace ([#486](https://github.com/open-feature/dotnet-sdk/issues/486)) ([e18ad50](https://github.com/open-feature/dotnet-sdk/commit/e18ad50e3298cb0dd19143678c3ef0fdcb4484d9)) +* **deps:** update opentelemetry-dotnet monorepo to 1.12.0 ([#506](https://github.com/open-feature/dotnet-sdk/issues/506)) ([69dc186](https://github.com/open-feature/dotnet-sdk/commit/69dc18611399ab5e573268c35d414a028c77f0ff)) +* **deps:** update spec digest to 1965aae ([#499](https://github.com/open-feature/dotnet-sdk/issues/499)) ([2e3dffd](https://github.com/open-feature/dotnet-sdk/commit/2e3dffd0ebbba4a9d95763e2ce9f3e2ac051a317)) +* **deps:** update spec digest to 42340bb ([#493](https://github.com/open-feature/dotnet-sdk/issues/493)) ([909c51d](https://github.com/open-feature/dotnet-sdk/commit/909c51d4e25917d6a9a5ae9bb04cfe48665186ba)) +* **deps:** update spec digest to c37ac17 ([#502](https://github.com/open-feature/dotnet-sdk/issues/502)) ([38f63fc](https://github.com/open-feature/dotnet-sdk/commit/38f63fceb5516cd474fd0e867aa25eae252cf2c1)) +* **deps:** update spec digest to f014806 ([#479](https://github.com/open-feature/dotnet-sdk/issues/479)) ([dbe8b08](https://github.com/open-feature/dotnet-sdk/commit/dbe8b082c28739a1b81b74b29ed28fbccc94f7bc)) +* fix sample build warning ([#498](https://github.com/open-feature/dotnet-sdk/issues/498)) ([08a00e1](https://github.com/open-feature/dotnet-sdk/commit/08a00e1d35834635ca296fe8a13507001ad25c57)) + + +### 📚 Documentation + +* add XML comment on FeatureClient ([#507](https://github.com/open-feature/dotnet-sdk/issues/507)) ([f923cea](https://github.com/open-feature/dotnet-sdk/commit/f923cea14eb552098edb987950ad4bc82bbadab1)) +* updated contributing link on the README ([8435bf7](https://github.com/open-feature/dotnet-sdk/commit/8435bf7d8131307e627e59453008124ac4c71906)) + ## [2.6.0](https://github.com/open-feature/dotnet-sdk/compare/v2.5.0...v2.6.0) (2025-05-23) diff --git a/README.md b/README.md index e87f2584c..66a3d620c 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ [![Specification](https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge)](https://github.com/open-feature/spec/releases/tag/v0.8.0) [ -![Release](https://img.shields.io/static/v1?label=release&message=v2.6.0&color=blue&style=for-the-badge) -](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.6.0) +![Release](https://img.shields.io/static/v1?label=release&message=v2.7.0&color=blue&style=for-the-badge) +](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.7.0) [![Slack](https://img.shields.io/badge/slack-%40cncf%2Fopenfeature-brightgreen?style=flat&logo=slack)](https://cloud-native.slack.com/archives/C0344AANLA1) [![Codecov](https://codecov.io/gh/open-feature/dotnet-sdk/branch/main/graph/badge.svg?token=MONAVJBXUJ)](https://codecov.io/gh/open-feature/dotnet-sdk) diff --git a/build/Common.prod.props b/build/Common.prod.props index 55264b3c0..0a05126f5 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -9,7 +9,7 @@ - 2.6.0 + 2.7.0 git https://github.com/open-feature/dotnet-sdk OpenFeature is an open standard for feature flag management, created to support a robust feature flag ecosystem using cloud native technologies. OpenFeature will provide a unified API and SDK, and a developer-first, cloud-native implementation, with extensibility for open source and commercial offerings. diff --git a/version.txt b/version.txt index e70b4523a..24ba9a38d 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.6.0 +2.7.0 From fa1ad7ef5471c39624cbb0ee28d86cd7488efc58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 3 Jul 2025 17:48:23 +0100 Subject: [PATCH 061/126] ci: update renovate configuration to include package rules for security updates (#510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> ## This PR This pull request updates the `renovate.json` configuration to enhance dependency management rules. The most notable change introduces a new `packageRules` section to handle security updates differently than other dependencies. Dependency management improvements: * [`renovate.json`](diffhunk://#diff-7b5c8955fc544a11b4b74eddb4115f9cc51c9cf162dbffa60d37eeed82a55a57L7-R17): Added a `packageRules` section to create pull requests for security updates without requiring dashboard approval. These updates will not be automatically merged, ensuring manual review. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- renovate.json | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/renovate.json b/renovate.json index 151c402c1..11a40704b 100644 --- a/renovate.json +++ b/renovate.json @@ -4,5 +4,15 @@ "github>open-feature/community-tooling" ], "dependencyDashboardApproval": true, - "recreateWhen": "never" -} + "recreateWhen": "never", + "packageRules": [ + { + "description": "Create PRs for security updates without dashboard approval", + "matchCategories": [ + "security" + ], + "dependencyDashboardApproval": false, + "automerge": false + } + ] +} \ No newline at end of file From 929fa7497197214d385eeaa40aba008932d00896 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Fri, 4 Jul 2025 11:37:11 -0400 Subject: [PATCH 062/126] chore: remove redundant rule (now in parent) Signed-off-by: Todd Baert --- renovate.json | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/renovate.json b/renovate.json index 11a40704b..151c402c1 100644 --- a/renovate.json +++ b/renovate.json @@ -4,15 +4,5 @@ "github>open-feature/community-tooling" ], "dependencyDashboardApproval": true, - "recreateWhen": "never", - "packageRules": [ - { - "description": "Create PRs for security updates without dashboard approval", - "matchCategories": [ - "security" - ], - "dependencyDashboardApproval": false, - "automerge": false - } - ] -} \ No newline at end of file + "recreateWhen": "never" +} From 40bec0d51b6fa782a8b6d90a3d84463f9fb73c1b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 11 Jul 2025 06:46:38 +0100 Subject: [PATCH 063/126] chore(deps): update github/codeql-action digest to 181d5ee (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [github/codeql-action](https://github.com/github/codeql-action) | action | digest | `39edc49` -> `181d5ee` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c1785d201..9ad67c477 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@39edc492dbe16b1465b0cafca41432d857bdb31a # v3 + uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@39edc492dbe16b1465b0cafca41432d857bdb31a # v3 + uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@39edc492dbe16b1465b0cafca41432d857bdb31a # v3 + uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3 From fbc2645efd649c0c37bd1a1cf473fbd98d920948 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 11 Jul 2025 06:46:54 +0100 Subject: [PATCH 064/126] chore(deps): update spec digest to 224b26e (#521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | spec | digest | `c37ac17` -> `224b26e` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/open-feature/dotnet-sdk). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index c37ac17c8..224b26e44 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit c37ac17c80410de1a2c6c6f061386001c838cb40 +Subproject commit 224b26e44ebfe21d1110d5b64d740c8a3055d398 From f6ae8ddfa08f4a8ab54748077e7844a72ad7b4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 11 Jul 2025 20:31:55 +0100 Subject: [PATCH 065/126] ci: add caching for NuGet packages in CI workflows (#522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> ## This PR This pull request enhances the CI/CD workflows by adding caching for NuGet packages across multiple workflow files. This change aims to improve build performance by avoiding repeated downloads of dependencies. ### Workflow Improvements: * [`.github/workflows/ci.yml`](diffhunk://#diff-b803fcb7f17ed9235f1e5cb1fcd2f5d3b2838429d4368ae4c57ce4436577f03fR39-R46): Added a step to cache NuGet packages in both the main CI job and another job within the same file. This uses the `actions/cache` action to store packages in `~/.nuget/packages` and leverages file hashing for cache keys. [[1]](diffhunk://#diff-b803fcb7f17ed9235f1e5cb1fcd2f5d3b2838429d4368ae4c57ce4436577f03fR39-R46) [[2]](diffhunk://#diff-b803fcb7f17ed9235f1e5cb1fcd2f5d3b2838429d4368ae4c57ce4436577f03fR82-R89) * [`.github/workflows/code-coverage.yml`](diffhunk://#diff-49708f979e226a1e7bd7a68d71b2e91aae8114dd3e9254d9830cd3b4d62d4303R37-R44): Introduced NuGet package caching to the code coverage workflow, improving efficiency during the `dotnet test` step. * [`.github/workflows/e2e.yml`](diffhunk://#diff-3e103440521ada06efd263ae09b259e5507e4b8f7408308dc227621ad9efa31eR32-R39): Added caching for NuGet packages in the end-to-end testing workflow, reducing setup time for dependencies. * [`.github/workflows/release.yml`](diffhunk://#diff-87db21a973eed4fef5f32b267aa60fcee5cbdf03c67fafdc2a9b553bb0b15f34R54-R61): Implemented NuGet package caching in the release workflow to optimize dependency installation during the `dotnet restore` step. ### Notes This is similar to https://github.com/open-feature/dotnet-sdk-contrib/pull/453 --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/ci.yml | 16 ++++++++++++++++ .github/workflows/code-coverage.yml | 8 ++++++++ .github/workflows/e2e.yml | 8 ++++++++ .github/workflows/release.yml | 8 ++++++++ 4 files changed, 40 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb1c72273..c5b5edb77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,14 @@ jobs: global-json-file: global.json source-url: https://nuget.pkg.github.com/open-feature/index.json + - name: Cache NuGet packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + - name: Restore run: dotnet restore @@ -71,6 +79,14 @@ jobs: global-json-file: global.json source-url: https://nuget.pkg.github.com/open-feature/index.json + - name: Cache NuGet packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + - name: Restore run: dotnet restore diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index fc7c37f5c..4a8e7d05f 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -34,6 +34,14 @@ jobs: global-json-file: global.json source-url: https://nuget.pkg.github.com/open-feature/index.json + - name: Cache NuGet packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + - name: Run Test run: dotnet test --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ae0ca8391..e1e577385 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -29,6 +29,14 @@ jobs: global-json-file: global.json source-url: https://nuget.pkg.github.com/open-feature/index.json + - name: Cache NuGet packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + - name: Initialize Tests run: | git submodule update --init --recursive diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 725957a75..727f5c9b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,14 @@ jobs: global-json-file: global.json source-url: https://nuget.pkg.github.com/open-feature/index.json + - name: Cache NuGet packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- + - name: Install dependencies run: dotnet restore From 2e7007277e19a0fbc4c4c3944d24eea1608712e6 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Fri, 11 Jul 2025 20:32:08 +0100 Subject: [PATCH 066/126] feat: Add Track method to IFeatureClient (#519) ## This PR Adds Track method to `IFeatureClient` so developers dependent on this abstraction can track user interactions. ### Related Issues Fixes #518 ### Notes ### Follow-up Tasks ### How to test Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- src/OpenFeature/IFeatureClient.cs | 13 +++++++++++-- src/OpenFeature/OpenFeatureClient.cs | 8 +------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/OpenFeature/IFeatureClient.cs b/src/OpenFeature/IFeatureClient.cs index acf38804f..5ea19458a 100644 --- a/src/OpenFeature/IFeatureClient.cs +++ b/src/OpenFeature/IFeatureClient.cs @@ -55,8 +55,8 @@ public interface IFeatureClient : IEventBus /// Returns the current status of the associated provider. ///
/// - ProviderStatus ProviderStatus { get; } - + ProviderStatus ProviderStatus { get; } + /// /// Resolves a boolean feature flag /// @@ -166,4 +166,13 @@ public interface IFeatureClient : IEventBus /// The . /// Resolved flag details Task> GetObjectDetailsAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, FlagEvaluationOptions? config = null, CancellationToken cancellationToken = default); + + /// + /// Use this method to track user interactions and the application state. + /// + /// The name associated with this tracking event + /// The evaluation context used in the evaluation of the flag (optional) + /// Data pertinent to the tracking event (Optional) + /// When trackingEventName is null or empty + void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default); } diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index cc81f8838..c99f4f5c9 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -302,13 +302,7 @@ await hookRunner.TriggerFinallyHooksAsync(evaluation, options?.HookHints, cancel return evaluation; } - /// - /// Use this method to track user interactions and the application state. - /// - /// The name associated with this tracking event - /// The evaluation context used in the evaluation of the flag (optional) - /// Data pertinent to the tracking event (Optional) - /// When trackingEventName is null or empty + /// public void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) { if (string.IsNullOrWhiteSpace(trackingEventName)) From 883f4f3c8b553dc01b5accdbae2782ca7805e8ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 14 Jul 2025 18:54:41 +0100 Subject: [PATCH 067/126] chore: Add comparison to Value (#523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> ## This PR This pull request introduces enhancements to the `Value` class in the `OpenFeature.Model` namespace, ensuring better equality handling, and updates dependencies to include `Microsoft.Bcl.HashCode`. The most significant changes include implementing equality comparison for `Value`, adding hash code generation, and updating project files to include the new dependency. ### Enhancements to `Value` class: * [`src/OpenFeature/Model/Value.cs`](diffhunk://#diff-336aacd3c42458899187108a2064648ae21e439b2b11e6ca7f25b7b7fef00609L9-R9): The `Value` class now implements `IEquatable` and includes methods for equality comparison (`Equals`, `==`, `!=`), hash code generation (`GetHashCode`), and internal helpers for comparing complex types like structures and lists. This ensures more robust and consistent equality checks. [[1]](diffhunk://#diff-336aacd3c42458899187108a2064648ae21e439b2b11e6ca7f25b7b7fef00609L9-R9) [[2]](diffhunk://#diff-336aacd3c42458899187108a2064648ae21e439b2b11e6ca7f25b7b7fef00609R187-R378) ### Dependency updates: * [`Directory.Packages.props`](diffhunk://#diff-5baf5f9e448ad54ab25a091adee0da05d4d228481c9200518fcb1b53a65d4156L13-R25): Added `Microsoft.Bcl.HashCode` as a dependency for hash code generation. Other package references were reformatted for consistency. * [`src/OpenFeature/OpenFeature.csproj`](diffhunk://#diff-711ea17cbdebe419375c7684c8c39a1423d2bebcf8976ddd7bdd78deaab65b21R11): Included `Microsoft.Bcl.HashCode` for specific target frameworks (`net462` and `netstandard2.0`). ### Notes This implementation is necessary for the comparison in the MultiProvider. See: https://github.com/open-feature/dotnet-sdk/pull/488#discussion_r2201848459 --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- Directory.Packages.props | 18 +- src/OpenFeature/Model/Value.cs | 194 +++++++++- src/OpenFeature/OpenFeature.csproj | 1 + test/OpenFeature.Tests/ValueTests.cs | 557 +++++++++++++++++++++++++++ 4 files changed, 763 insertions(+), 7 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 041434a47..194441b93 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,13 +10,19 @@ - - - - + + + + + - + @@ -42,4 +48,4 @@
- + \ No newline at end of file diff --git a/src/OpenFeature/Model/Value.cs b/src/OpenFeature/Model/Value.cs index f09a24667..41b15246b 100644 --- a/src/OpenFeature/Model/Value.cs +++ b/src/OpenFeature/Model/Value.cs @@ -6,7 +6,7 @@ namespace OpenFeature.Model; /// Values serve as a return type for provider objects. Providers may deal in JSON, protobuf, XML or some other data-interchange format. /// This intermediate representation provides a good medium of exchange. ///
-public sealed class Value +public sealed class Value : IEquatable { private readonly object? _innerValue; @@ -184,4 +184,196 @@ public Value(Object value) ///
/// Value as DateTime public DateTime? AsDateTime => this.IsDateTime ? (DateTime?)this._innerValue : null; + + /// + /// Determines whether the specified is equal to the current . + /// + /// The to compare with the current . + /// true if the specified is equal to the current ; otherwise, false. + public bool Equals(Value? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + + // Both are null + if (this.IsNull && other.IsNull) return true; + + // One is null, the other is not + if (this.IsNull != other.IsNull) return false; + + // Different types + if (this.GetValueType() != other.GetValueType()) return false; + + // Compare based on type + return this.GetValueType() switch + { + ValueType.Boolean => this.AsBoolean == other.AsBoolean, + ValueType.Number => this.AsDouble == other.AsDouble, + ValueType.String => this.AsString == other.AsString, + ValueType.DateTime => this.AsDateTime == other.AsDateTime, + ValueType.Structure => this.StructureEquals(other), + ValueType.List => this.ListEquals(other), + _ => false + }; + } + + /// + /// Determines whether the specified object is equal to the current . + /// + /// The object to compare with the current . + /// true if the specified object is equal to the current ; otherwise, false. + public override bool Equals(object? obj) => this.Equals(obj as Value); + + /// + /// Returns the hash code for this . + /// + /// A hash code for the current . + public override int GetHashCode() + { + if (this.IsNull) return 0; + + return this.GetValueType() switch + { + ValueType.Boolean => this.AsBoolean!.GetHashCode(), + ValueType.Number => this.AsDouble!.GetHashCode(), + ValueType.String => this.AsString!.GetHashCode(), + ValueType.DateTime => this.AsDateTime!.GetHashCode(), + ValueType.Structure => this.GetStructureHashCode(), + ValueType.List => this.GetListHashCode(), + _ => 0 + }; + } + + /// + /// Determines whether two instances are equal. + /// + /// The first to compare. + /// The second to compare. + /// true if the values are equal; otherwise, false. + public static bool operator ==(Value? left, Value? right) + { + if (left is null && right is null) return true; + if (left is null || right is null) return false; + return left.Equals(right); + } + + /// + /// Determines whether two instances are not equal. + /// + /// The first to compare. + /// The second to compare. + /// true if the values are not equal; otherwise, false. + public static bool operator !=(Value? left, Value? right) => !(left == right); + + /// + /// Gets the type of the current value. + /// + /// The of the current value. + private ValueType GetValueType() + { + if (this.IsNull) return ValueType.Null; + if (this.IsBoolean) return ValueType.Boolean; + if (this.IsNumber) return ValueType.Number; + if (this.IsString) return ValueType.String; + if (this.IsDateTime) return ValueType.DateTime; + if (this.IsStructure) return ValueType.Structure; + if (this.IsList) return ValueType.List; + return ValueType.Unknown; + } + + /// + /// Compares two Structure values for equality. + /// + /// The other to compare. + /// true if the structures are equal; otherwise, false. + private bool StructureEquals(Value other) + { + var thisStructure = this.AsStructure!; + var otherStructure = other.AsStructure!; + + if (thisStructure.Count != otherStructure.Count) return false; + + foreach (var kvp in thisStructure) + { + if (!otherStructure.TryGetValue(kvp.Key, out var otherValue) || !kvp.Value.Equals(otherValue)) + { + return false; + } + } + + return true; + } + + /// + /// Compares two List values for equality. + /// + /// The other to compare. + /// true if the lists are equal; otherwise, false. + private bool ListEquals(Value other) + { + var thisList = this.AsList!; + var otherList = other.AsList!; + + if (thisList.Count != otherList.Count) return false; + + for (int i = 0; i < thisList.Count; i++) + { + if (!thisList[i].Equals(otherList[i])) + { + return false; + } + } + + return true; + } + + /// + /// Gets the hash code for a Structure value. + /// + /// The hash code of the structure. + private int GetStructureHashCode() + { + var structure = this.AsStructure!; + var hash = new HashCode(); + + foreach (var kvp in structure) + { + hash.Add(kvp.Key); + hash.Add(kvp.Value); + } + + return hash.ToHashCode(); + } + + /// + /// Gets the hash code for a List value. + /// + /// The hash code of the list. + private int GetListHashCode() + { + var list = this.AsList!; + var hash = new HashCode(); + + foreach (var item in list) + { + hash.Add(item); + } + + return hash.ToHashCode(); + } + + /// + /// Represents the different types that a can contain. + /// + private enum ValueType + { + Null, + Boolean, + Number, + String, + DateTime, + Structure, + List, + Unknown + } } diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj index 3d81a99eb..732c92f3b 100644 --- a/src/OpenFeature/OpenFeature.csproj +++ b/src/OpenFeature/OpenFeature.csproj @@ -8,6 +8,7 @@ + diff --git a/test/OpenFeature.Tests/ValueTests.cs b/test/OpenFeature.Tests/ValueTests.cs index da76f29ad..9f94b5eaf 100644 --- a/test/OpenFeature.Tests/ValueTests.cs +++ b/test/OpenFeature.Tests/ValueTests.cs @@ -230,4 +230,561 @@ public void AsDateTime_WhenCalledWithNonDateTimeInnerValue_ReturnsNull() // Assert Assert.Null(actualValue); } + + #region Equality Tests + + [Fact] + public void Equals_WithNullValue_ReturnsFalse() + { + // Arrange + var value = new Value("test"); + + // Act & Assert + Assert.False(value.Equals(null)); + } + + [Fact] + public void Equals_WithSameReference_ReturnsTrue() + { + // Arrange + var value = new Value("test"); + + // Act & Assert + Assert.True(value.Equals(value)); + } + + [Fact] + public void Equals_WithBothNull_ReturnsTrue() + { + // Arrange + var value1 = new Value(); + var value2 = new Value(); + + // Act & Assert + Assert.True(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithOneNullOneNotNull_ReturnsFalse() + { + // Arrange + var nullValue = new Value(); + var stringValue = new Value("test"); + + // Act & Assert + Assert.False(nullValue.Equals(stringValue)); + Assert.False(stringValue.Equals(nullValue)); + } + + [Fact] + public void Equals_WithDifferentTypes_ReturnsFalse() + { + // Arrange + var stringValue = new Value("test"); + var intValue = new Value(42); + var boolValue = new Value(true); + + // Act & Assert + Assert.False(stringValue.Equals(intValue)); + Assert.False(stringValue.Equals(boolValue)); + Assert.False(intValue.Equals(boolValue)); + } + + [Fact] + public void Equals_WithSameStringValues_ReturnsTrue() + { + // Arrange + var value1 = new Value("test"); + var value2 = new Value("test"); + + // Act & Assert + Assert.True(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithDifferentStringValues_ReturnsFalse() + { + // Arrange + var value1 = new Value("test1"); + var value2 = new Value("test2"); + + // Act & Assert + Assert.False(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithSameBooleanValues_ReturnsTrue() + { + // Arrange + var value1 = new Value(true); + var value2 = new Value(true); + var value3 = new Value(false); + var value4 = new Value(false); + + // Act & Assert + Assert.True(value1.Equals(value2)); + Assert.True(value3.Equals(value4)); + } + + [Fact] + public void Equals_WithDifferentBooleanValues_ReturnsFalse() + { + // Arrange + var value1 = new Value(true); + var value2 = new Value(false); + + // Act & Assert + Assert.False(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithSameNumberValues_ReturnsTrue() + { + // Arrange + var value1 = new Value(42.5); + var value2 = new Value(42.5); + var intValue1 = new Value(42); + var intValue2 = new Value(42); + + // Act & Assert + Assert.True(value1.Equals(value2)); + Assert.True(intValue1.Equals(intValue2)); + } + + [Fact] + public void Equals_WithDifferentNumberValues_ReturnsFalse() + { + // Arrange + var value1 = new Value(42.5); + var value2 = new Value(42.6); + + // Act & Assert + Assert.False(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithSameDateTimeValues_ReturnsTrue() + { + // Arrange + var dateTime = DateTime.Now; + var value1 = new Value(dateTime); + var value2 = new Value(dateTime); + + // Act & Assert + Assert.True(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithDifferentDateTimeValues_ReturnsFalse() + { + // Arrange + var value1 = new Value(DateTime.Now); + var value2 = new Value(DateTime.Now.AddDays(1)); + + // Act & Assert + Assert.False(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithSameStructureValues_ReturnsTrue() + { + // Arrange + var structure1 = Structure.Builder() + .Set("key1", new Value("value1")) + .Set("key2", new Value(42)) + .Build(); + var structure2 = Structure.Builder() + .Set("key1", new Value("value1")) + .Set("key2", new Value(42)) + .Build(); + var value1 = new Value(structure1); + var value2 = new Value(structure2); + + // Act & Assert + Assert.True(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithDifferentStructureValues_ReturnsFalse() + { + // Arrange + var structure1 = Structure.Builder() + .Set("key1", new Value("value1")) + .Build(); + var structure2 = Structure.Builder() + .Set("key1", new Value("value2")) + .Build(); + var value1 = new Value(structure1); + var value2 = new Value(structure2); + + // Act & Assert + Assert.False(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithStructuresDifferentKeyCounts_ReturnsFalse() + { + // Arrange + var structure1 = Structure.Builder() + .Set("key1", new Value("value1")) + .Build(); + var structure2 = Structure.Builder() + .Set("key1", new Value("value1")) + .Set("key2", new Value("value2")) + .Build(); + var value1 = new Value(structure1); + var value2 = new Value(structure2); + + // Act & Assert + Assert.False(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithSameListValues_ReturnsTrue() + { + // Arrange + var list1 = new List { new("test"), new(42), new(true) }; + var list2 = new List { new("test"), new(42), new(true) }; + var value1 = new Value(list1); + var value2 = new Value(list2); + + // Act & Assert + Assert.True(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithDifferentListValues_ReturnsFalse() + { + // Arrange + var list1 = new List { new("test1"), new(42) }; + var list2 = new List { new("test2"), new(42) }; + var value1 = new Value(list1); + var value2 = new Value(list2); + + // Act & Assert + Assert.False(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithListsDifferentLengths_ReturnsFalse() + { + // Arrange + var list1 = new List { new("test") }; + var list2 = new List { new("test"), new(42) }; + var value1 = new Value(list1); + var value2 = new Value(list2); + + // Act & Assert + Assert.False(value1.Equals(value2)); + } + + [Fact] + public void Equals_WithObject_CallsTypedEquals() + { + // Arrange + var value1 = new Value("test"); + var value2 = new Value("test"); + object obj = value2; + + // Act & Assert + Assert.True(value1.Equals(obj)); + } + + [Fact] + public void Equals_WithNonValueObject_ReturnsFalse() + { + // Arrange + var value = new Value("test"); + object obj = "test"; + + // Act & Assert + Assert.False(value.Equals(obj)); + } + + #endregion + + #region Operator Tests + + [Fact] + public void OperatorEquals_WithBothNull_ReturnsTrue() + { + // Arrange + Value? value1 = null; + Value? value2 = null; + + // Act & Assert + Assert.True(value1 == value2); + } + + [Fact] + public void OperatorEquals_WithOneNull_ReturnsFalse() + { + // Arrange + Value? value1 = null; + Value value2 = new Value("test"); + + // Act & Assert + Assert.False(value1 == value2); + Assert.False(value2 == value1); + } + + [Fact] + public void OperatorEquals_WithEqualValues_ReturnsTrue() + { + // Arrange + var value1 = new Value("test"); + var value2 = new Value("test"); + + // Act & Assert + Assert.True(value1 == value2); + } + + [Fact] + public void OperatorEquals_WithDifferentValues_ReturnsFalse() + { + // Arrange + var value1 = new Value("test1"); + var value2 = new Value("test2"); + + // Act & Assert + Assert.False(value1 == value2); + } + + [Fact] + public void OperatorNotEquals_WithEqualValues_ReturnsFalse() + { + // Arrange + var value1 = new Value("test"); + var value2 = new Value("test"); + + // Act & Assert + Assert.False(value1 != value2); + } + + [Fact] + public void OperatorNotEquals_WithDifferentValues_ReturnsTrue() + { + // Arrange + var value1 = new Value("test1"); + var value2 = new Value("test2"); + + // Act & Assert + Assert.True(value1 != value2); + } + + #endregion + + #region GetHashCode Tests + + [Fact] + public void GetHashCode_WithNullValue_ReturnsZero() + { + // Arrange + var value = new Value(); + + // Act + var hashCode = value.GetHashCode(); + + // Assert + Assert.Equal(0, hashCode); + } + + [Fact] + public void GetHashCode_WithEqualValues_ReturnsSameHashCode() + { + // Arrange + var value1 = new Value("test"); + var value2 = new Value("test"); + + // Act + var hashCode1 = value1.GetHashCode(); + var hashCode2 = value2.GetHashCode(); + + // Assert + Assert.Equal(hashCode1, hashCode2); + } + + [Fact] + public void GetHashCode_WithBooleanValues_ReturnsConsistentHashCode() + { + // Arrange + var value1 = new Value(true); + var value2 = new Value(true); + var value3 = new Value(false); + + // Act + var hashCode1 = value1.GetHashCode(); + var hashCode2 = value2.GetHashCode(); + var hashCode3 = value3.GetHashCode(); + + // Assert + Assert.Equal(hashCode1, hashCode2); + Assert.NotEqual(hashCode1, hashCode3); + } + + [Fact] + public void GetHashCode_WithNumberValues_ReturnsConsistentHashCode() + { + // Arrange + var value1 = new Value(42.5); + var value2 = new Value(42.5); + var value3 = new Value(42.6); + + // Act + var hashCode1 = value1.GetHashCode(); + var hashCode2 = value2.GetHashCode(); + var hashCode3 = value3.GetHashCode(); + + // Assert + Assert.Equal(hashCode1, hashCode2); + Assert.NotEqual(hashCode1, hashCode3); + } + + [Fact] + public void GetHashCode_WithStringValues_ReturnsConsistentHashCode() + { + // Arrange + var value1 = new Value("test"); + var value2 = new Value("test"); + var value3 = new Value("different"); + + // Act + var hashCode1 = value1.GetHashCode(); + var hashCode2 = value2.GetHashCode(); + var hashCode3 = value3.GetHashCode(); + + // Assert + Assert.Equal(hashCode1, hashCode2); + Assert.NotEqual(hashCode1, hashCode3); + } + + [Fact] + public void GetHashCode_WithDateTimeValues_ReturnsConsistentHashCode() + { + // Arrange + var dateTime = DateTime.Now; + var value1 = new Value(dateTime); + var value2 = new Value(dateTime); + var value3 = new Value(dateTime.AddDays(1)); + + // Act + var hashCode1 = value1.GetHashCode(); + var hashCode2 = value2.GetHashCode(); + var hashCode3 = value3.GetHashCode(); + + // Assert + Assert.Equal(hashCode1, hashCode2); + Assert.NotEqual(hashCode1, hashCode3); + } + + [Fact] + public void GetHashCode_WithStructureValues_ReturnsConsistentHashCode() + { + // Arrange + var structure1 = Structure.Builder() + .Set("key1", new Value("value1")) + .Set("key2", new Value(42)) + .Build(); + var structure2 = Structure.Builder() + .Set("key1", new Value("value1")) + .Set("key2", new Value(42)) + .Build(); + var value1 = new Value(structure1); + var value2 = new Value(structure2); + + // Act + var hashCode1 = value1.GetHashCode(); + var hashCode2 = value2.GetHashCode(); + + // Assert + Assert.Equal(hashCode1, hashCode2); + } + + [Fact] + public void GetHashCode_WithListValues_ReturnsConsistentHashCode() + { + // Arrange + var list1 = new List { new("test"), new(42) }; + var list2 = new List { new("test"), new(42) }; + var value1 = new Value(list1); + var value2 = new Value(list2); + + // Act + var hashCode1 = value1.GetHashCode(); + var hashCode2 = value2.GetHashCode(); + + // Assert + Assert.Equal(hashCode1, hashCode2); + } + + #endregion + + #region Complex Nested Tests + + [Fact] + public void Equals_WithNestedStructuresAndLists_ReturnsTrue() + { + // Arrange + var innerList1 = new List { new("nested"), new(123) }; + var innerList2 = new List { new("nested"), new(123) }; + + var innerStructure1 = Structure.Builder() + .Set("nested_key", new Value("nested_value")) + .Set("nested_list", new Value(innerList1)) + .Build(); + var innerStructure2 = Structure.Builder() + .Set("nested_key", new Value("nested_value")) + .Set("nested_list", new Value(innerList2)) + .Build(); + + var outerStructure1 = Structure.Builder() + .Set("outer_key", new Value("outer_value")) + .Set("inner", new Value(innerStructure1)) + .Build(); + var outerStructure2 = Structure.Builder() + .Set("outer_key", new Value("outer_value")) + .Set("inner", new Value(innerStructure2)) + .Build(); + + var value1 = new Value(outerStructure1); + var value2 = new Value(outerStructure2); + + // Act & Assert + Assert.True(value1.Equals(value2)); + Assert.Equal(value1.GetHashCode(), value2.GetHashCode()); + } + + [Fact] + public void Equals_WithDeeplyNestedDifferences_ReturnsFalse() + { + // Arrange + var innerList1 = new List { new("nested"), new(123) }; + var innerList2 = new List { new("nested"), new(124) }; // Different value + + var innerStructure1 = Structure.Builder() + .Set("nested_key", new Value("nested_value")) + .Set("nested_list", new Value(innerList1)) + .Build(); + var innerStructure2 = Structure.Builder() + .Set("nested_key", new Value("nested_value")) + .Set("nested_list", new Value(innerList2)) + .Build(); + + var outerStructure1 = Structure.Builder() + .Set("outer_key", new Value("outer_value")) + .Set("inner", new Value(innerStructure1)) + .Build(); + var outerStructure2 = Structure.Builder() + .Set("outer_key", new Value("outer_value")) + .Set("inner", new Value(innerStructure2)) + .Build(); + + var value1 = new Value(outerStructure1); + var value2 = new Value(outerStructure2); + + // Act & Assert + Assert.False(value1.Equals(value2)); + } + + #endregion } From 12396b7872a2db6533b33267cf9c299248c41472 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Mon, 14 Jul 2025 20:50:26 +0100 Subject: [PATCH 068/126] feat: Add Hook Dependency Injection extension method with Hook instance (#513) ## This PR In #512 I needed to tweak the sample AspNetCore application to pass Hook options to the MetricsHook. I noticed that in order to pass the options I needed to use the delegate based approach, while discarding the `serviceProvider`, like: ```csharp featureBuilder.AddHostedFeatureLifecycle() .AddHook(_ => new MetricsHook(metricsHookOptions)) ``` It would be simpler and easier for devs to interact with a third overload that allows them to pass an instance of the hook that they want to interact with, like so: ```csharp featureBuilder.AddHostedFeatureLifecycle() .AddHook(new MetricsHook()) ``` ### Related Issues ### Notes ### Follow-up Tasks ### How to test --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- README.md | 1 + .../OpenFeatureBuilderExtensions.cs | 27 +++++++++++++++ .../OpenFeatureBuilderExtensionsTests.cs | 34 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/README.md b/README.md index 66a3d620c..d51bfdc4d 100644 --- a/README.md +++ b/README.md @@ -481,6 +481,7 @@ builder.Services.AddOpenFeature(featureBuilder => { .AddHostedFeatureLifecycle() .AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ }) .AddHook((serviceProvider) => new LoggingHook( /* Custom configuration */ )) + .AddHook(new MetricsHook()) .AddInMemoryProvider("name1") .AddInMemoryProvider("name2") .AddPolicyName(options => { diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs index 317589606..01a535e04 100644 --- a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs +++ b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs @@ -278,6 +278,33 @@ public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, return builder.AddHook(typeof(THook).Name, implementationFactory); } + /// + /// Adds a feature hook to the service collection. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// Instance of Hook to inject into the OpenFeature context. + /// The instance. + public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, THook hook) + where THook : Hook + { + return builder.AddHook(typeof(THook).Name, hook); + } + + /// + /// Adds a feature hook to the service collection with a specified name. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// The name of the that is being added. + /// Instance of Hook to inject into the OpenFeature context. + /// The instance. + public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, string hookName, THook hook) + where THook : Hook + { + return builder.AddHook(hookName, _ => hook); + } + /// /// Adds a feature hook to the service collection using a factory method and specified name. Hooks added here are not domain-bound. /// diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs index f1edca4c4..f7cce0dfc 100644 --- a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs +++ b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs @@ -302,6 +302,40 @@ public void AddHook_WithSpecifiedNameAndImplementationFactory_AsKeyedService() Assert.NotNull(hook); } + [Fact] + public void AddHook_WithInstance_AddsHookAsKeyedService() + { + // Arrange + var expectedHook = new NoOpHook(); + _systemUnderTest.AddHook(expectedHook); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var actualHook = serviceProvider.GetKeyedService("NoOpHook"); + + // Assert + Assert.NotNull(actualHook); + Assert.Equal(expectedHook, actualHook); + } + + [Fact] + public void AddHook_WithSpecifiedNameAndInstance_AddsHookAsKeyedService() + { + // Arrange + var expectedHook = new NoOpHook(); + _systemUnderTest.AddHook("custom-hook", expectedHook); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var actualHook = serviceProvider.GetKeyedService("custom-hook"); + + // Assert + Assert.NotNull(actualHook); + Assert.Equal(expectedHook, actualHook); + } + [Fact] public void AddHandler_AddsEventHandlerDelegateWrapperAsKeyedService() { From 8c92524edbf4579d4ad62c699b338b9811a783fd Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Tue, 15 Jul 2025 14:28:29 -0400 Subject: [PATCH 069/126] docs: remove curly brace from readme The curly brace breaks the Docusaurus complication. Signed-off-by: Michael Beemer --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d51bfdc4d..a5c06efaa 100644 --- a/README.md +++ b/README.md @@ -613,8 +613,8 @@ Below are the metrics extracted by this hook and dimensions they carry: | Metric key | Description | Unit | Dimensions | | -------------------------------------- | ------------------------------- | ------------ | ----------------------------- | -| feature_flag.evaluation_requests_total | Number of evaluation requests | {request} | key, provider name | -| feature_flag.evaluation_success_total | Flag evaluation successes | {impression} | key, provider name, reason | +| feature_flag.evaluation_requests_total | Number of evaluation requests | request | key, provider name | +| feature_flag.evaluation_success_total | Flag evaluation successes | impression | key, provider name, reason | | feature_flag.evaluation_error_total | Flag evaluation errors | 1 | key, provider name, exception | | feature_flag.evaluation_active_count | Active flag evaluations counter | 1 | key, provider name | From 18705c7338a0c89f163f808c81e513a029c95239 Mon Sep 17 00:00:00 2001 From: Michael Beemer Date: Tue, 15 Jul 2025 17:05:26 -0400 Subject: [PATCH 070/126] docs: fix anchor link in readme (#525) This is causing an issue when attempting to update the .NET SDK docs on openfeature.dev. https://github.com/open-feature/openfeature.dev/actions/runs/16301698184/job/46037647418?pr=1248 Signed-off-by: Michael Beemer --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a5c06efaa..1484f7310 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Want to contribute a new sample? See our [CONTRIBUTING](CONTRIBUTING.md) guide! | ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | | ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread). | | ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | -| 🔬 | [DependencyInjection](#DependencyInjection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. | +| 🔬 | [DependencyInjection](#dependency-injection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. | > Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌ | Experimental: 🔬 @@ -433,7 +433,7 @@ Hooks support passing per-evaluation data between that stages using `hook data`. Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs! -### DependencyInjection +### Dependency Injection > [!NOTE] > The OpenFeature.DependencyInjection and OpenFeature.Hosting packages are currently experimental. They streamline the integration of OpenFeature within .NET applications, allowing for seamless configuration and lifecycle management of feature flag providers using dependency injection and hosting services. From 8c05d1d7363db89b8379e1a4e46e455f210888e2 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Sat, 19 Jul 2025 15:19:50 +0100 Subject: [PATCH 071/126] feat: Add Metric Hook Custom Attributes (#512) ## This PR Adds new `MetricsHookOptions` and `MetricsHookOptionsBuilder` to optionally configure custom attributes that can be tagged on the `feature_flag.evaluation_success_total` metric. Example usage: ```csharp var options = MetricsHookOptions.CreateBuilder() .WithCustomDimension("custom_dimension_key", "custom_dimension_value") .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean")) .Build(); OpenFeature.Api.Instance.AddHooks(new MetricsHook(options)); ``` Screenshot below shows the AspNetCore sample application tagging the `feature_flag.evaluation_success_total` counter with the specified dimensions. ![image](https://github.com/user-attachments/assets/e8cda7d8-404a-4d54-96a5-066188d5c18a) ### Related Issues Fixes #509 Fixes #514 ### Notes ### Follow-up Tasks ### How to test --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- README.md | 18 + samples/AspNetCore/Program.cs | 7 +- src/OpenFeature/Hooks/MetricsHook.cs | 45 ++- src/OpenFeature/Hooks/MetricsHookOptions.cs | 91 +++++ .../Hooks/MetricsHookOptionsTests.cs | 84 +++++ .../Hooks/MetricsHookTests.cs | 327 ++++++++++++++---- 6 files changed, 503 insertions(+), 69 deletions(-) create mode 100644 src/OpenFeature/Hooks/MetricsHookOptions.cs create mode 100644 test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs diff --git a/README.md b/README.md index 1484f7310..e79fd544a 100644 --- a/README.md +++ b/README.md @@ -664,6 +664,24 @@ namespace OpenFeatureTestApp After running this example, you should be able to see some metrics being generated into the console. +You can specify custom dimensions on all instruments by the `MetricsHook` by providing `MetricsHookOptions` when adding the hook: + +```csharp +var options = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .Build(); + +OpenFeature.Api.Instance.AddHooks(new MetricsHook(options)); +``` + +You can also write your own extraction logic against the Flag metadata by providing a callback to `WithFlagEvaluationMetadata`. + +```csharp +var options = MetricsHookOptions.CreateBuilder() + .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean")) + .Build(); +``` + ## ⭐️ Support the project diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index 5f4f01461..e09213076 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -26,9 +26,14 @@ builder.Services.AddOpenFeature(featureBuilder => { + var metricsHookOptions = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean")) + .Build(); + featureBuilder.AddHostedFeatureLifecycle() .AddHook(sp => new LoggingHook(sp.GetRequiredService>())) - .AddHook() + .AddHook(_ => new MetricsHook(metricsHookOptions)) .AddHook() .AddInMemoryProvider("InMemory", _ => new Dictionary() { diff --git a/src/OpenFeature/Hooks/MetricsHook.cs b/src/OpenFeature/Hooks/MetricsHook.cs index 2f2314f0d..6852b47c6 100644 --- a/src/OpenFeature/Hooks/MetricsHook.cs +++ b/src/OpenFeature/Hooks/MetricsHook.cs @@ -19,20 +19,24 @@ public class MetricsHook : Hook private static readonly string InstrumentationVersion = AssemblyName.Version?.ToString() ?? "1.0.0"; private static readonly Meter Meter = new(InstrumentationName, InstrumentationVersion); - private readonly UpDownCounter _evaluationActiveUpDownCounter; - private readonly Counter _evaluationRequestCounter; - private readonly Counter _evaluationSuccessCounter; - private readonly Counter _evaluationErrorCounter; + internal readonly UpDownCounter _evaluationActiveUpDownCounter; + internal readonly Counter _evaluationRequestCounter; + internal readonly Counter _evaluationSuccessCounter; + internal readonly Counter _evaluationErrorCounter; + + private readonly MetricsHookOptions _options; /// /// Initializes a new instance of the class. /// - public MetricsHook() + /// Optional configuration for the metrics hook. + public MetricsHook(MetricsHookOptions? options = null) { this._evaluationActiveUpDownCounter = Meter.CreateUpDownCounter(MetricsConstants.ActiveCountName, description: MetricsConstants.ActiveDescription); this._evaluationRequestCounter = Meter.CreateCounter(MetricsConstants.RequestsTotalName, "{request}", MetricsConstants.RequestsDescription); this._evaluationSuccessCounter = Meter.CreateCounter(MetricsConstants.SuccessTotalName, "{impression}", MetricsConstants.SuccessDescription); this._evaluationErrorCounter = Meter.CreateCounter(MetricsConstants.ErrorTotalName, description: MetricsConstants.ErrorDescription); + this._options = options ?? MetricsHookOptions.Default; } /// @@ -44,6 +48,8 @@ public override ValueTask BeforeAsync(HookContext conte { TelemetryConstants.Provider, context.ProviderMetadata.Name } }; + this.AddCustomDimensions(ref tagList); + this._evaluationActiveUpDownCounter.Add(1, tagList); this._evaluationRequestCounter.Add(1, tagList); @@ -61,6 +67,9 @@ public override ValueTask AfterAsync(HookContext context, FlagEvaluationDe { TelemetryConstants.Reason, details.Reason ?? Reason.Unknown.ToString() } }; + this.AddCustomDimensions(ref tagList); + this.AddFlagMetadataDimensions(details.FlagMetadata, ref tagList); + this._evaluationSuccessCounter.Add(1, tagList); return base.AfterAsync(context, details, hints, cancellationToken); @@ -76,6 +85,8 @@ public override ValueTask ErrorAsync(HookContext context, Exception error, { MetricsConstants.ExceptionAttr, error.Message } }; + this.AddCustomDimensions(ref tagList); + this._evaluationErrorCounter.Add(1, tagList); return base.ErrorAsync(context, error, hints, cancellationToken); @@ -93,8 +104,32 @@ public override ValueTask FinallyAsync(HookContext context, { TelemetryConstants.Provider, context.ProviderMetadata.Name } }; + this.AddCustomDimensions(ref tagList); + this.AddFlagMetadataDimensions(evaluationDetails.FlagMetadata, ref tagList); + this._evaluationActiveUpDownCounter.Add(-1, tagList); return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken); } + + private void AddCustomDimensions(ref TagList tagList) + { + foreach (var customDimension in this._options.CustomDimensions) + { + tagList.Add(customDimension.Key, customDimension.Value); + } + } + + private void AddFlagMetadataDimensions(ImmutableMetadata? flagMetadata, ref TagList tagList) + { + flagMetadata ??= new ImmutableMetadata(); + + foreach (var item in this._options.FlagMetadataCallbacks) + { + var flagMetadataCallback = item.Value; + var value = flagMetadataCallback(flagMetadata); + + tagList.Add(item.Key, value); + } + } } diff --git a/src/OpenFeature/Hooks/MetricsHookOptions.cs b/src/OpenFeature/Hooks/MetricsHookOptions.cs new file mode 100644 index 000000000..553431496 --- /dev/null +++ b/src/OpenFeature/Hooks/MetricsHookOptions.cs @@ -0,0 +1,91 @@ +using OpenFeature.Model; + +namespace OpenFeature.Hooks; + +/// +/// Configuration options for the . +/// +public sealed class MetricsHookOptions +{ + /// + /// The default options for the . + /// + public static MetricsHookOptions Default { get; } = new MetricsHookOptions(); + + /// + /// Custom dimensions or tags to be associated with Meters in . + /// + public IReadOnlyCollection> CustomDimensions { get; } + + /// + /// + /// + internal IReadOnlyCollection>> FlagMetadataCallbacks { get; } + + /// + /// Initializes a new instance of the class with default values. + /// + private MetricsHookOptions() : this(null, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Optional custom dimensions to tag Counter increments with. + /// + internal MetricsHookOptions(IReadOnlyCollection>? customDimensions = null, + IReadOnlyCollection>>? flagMetadataSelectors = null) + { + this.CustomDimensions = customDimensions ?? []; + this.FlagMetadataCallbacks = flagMetadataSelectors ?? []; + } + + /// + /// Creates a new builder for . + /// + public static MetricsHookOptionsBuilder CreateBuilder() => new MetricsHookOptionsBuilder(); + + /// + /// A builder for constructing instances. + /// + public sealed class MetricsHookOptionsBuilder + { + private readonly List> _customDimensions = new List>(); + private readonly List>> _flagMetadataExpressions = new List>>(); + + /// + /// Adds a custom dimension. + /// + /// The key for the custom dimension. + /// The value for the custom dimension. + public MetricsHookOptionsBuilder WithCustomDimension(string key, object? value) + { + this._customDimensions.Add(new KeyValuePair(key, value)); + return this; + } + + /// + /// Provide a callback to evaluate flag metadata for a specific flag key. + /// + /// The key for the custom dimension. + /// The callback to retrieve the value to tag successful flag evaluations. + /// + public MetricsHookOptionsBuilder WithFlagEvaluationMetadata(string key, Func flagMetadataCallback) + { + var kvp = new KeyValuePair>(key, flagMetadataCallback); + + this._flagMetadataExpressions.Add(kvp); + + return this; + } + + /// + /// Builds the instance. + /// + public MetricsHookOptions Build() + { + return new MetricsHookOptions(this._customDimensions.AsReadOnly(), this._flagMetadataExpressions.AsReadOnly()); + } + } +} diff --git a/test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs b/test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs new file mode 100644 index 000000000..89f0f56d7 --- /dev/null +++ b/test/OpenFeature.Tests/Hooks/MetricsHookOptionsTests.cs @@ -0,0 +1,84 @@ +using OpenFeature.Hooks; +using OpenFeature.Model; + +namespace OpenFeature.Tests.Hooks; + +public class MetricsHookOptionsTests +{ + [Fact] + public void Default_Options_Should_Be_Initialized_Correctly() + { + // Arrange & Act + var options = MetricsHookOptions.Default; + + // Assert + Assert.NotNull(options); + Assert.Empty(options.CustomDimensions); + Assert.Empty(options.FlagMetadataCallbacks); + } + + [Fact] + public void CreateBuilder_Should_Return_New_Builder_Instance() + { + // Arrange & Act + var builder = MetricsHookOptions.CreateBuilder(); + + // Assert + Assert.NotNull(builder); + Assert.IsType(builder); + } + + [Fact] + public void Build_Should_Return_Options() + { + // Arrange + var builder = MetricsHookOptions.CreateBuilder(); + + // Act + var options = builder.Build(); + + // Assert + Assert.NotNull(options); + Assert.IsType(options); + } + + [Theory] + [InlineData("custom_dimension_value")] + [InlineData(1.0)] + [InlineData(2025)] + [InlineData(null)] + [InlineData(true)] + public void Builder_Should_Allow_Adding_Custom_Dimensions(object? value) + { + // Arrange + var builder = MetricsHookOptions.CreateBuilder(); + var key = "custom_dimension_key"; + + // Act + builder.WithCustomDimension(key, value); + var options = builder.Build(); + + // Assert + Assert.Single(options.CustomDimensions); + Assert.Equal(key, options.CustomDimensions.First().Key); + Assert.Equal(value, options.CustomDimensions.First().Value); + } + + [Fact] + public void Builder_Should_Allow_Adding_Flag_Metadata_Expressions() + { + // Arrange + var builder = MetricsHookOptions.CreateBuilder(); + var key = "flag_metadata_key"; + static object? expression(ImmutableMetadata m) => m.GetString("flag_metadata_key"); + + // Act + builder.WithFlagEvaluationMetadata(key, expression); + var options = builder.Build(); + + // Assert + Assert.Single(options.FlagMetadataCallbacks); + Assert.Equal(key, options.FlagMetadataCallbacks.First().Key); + Assert.Equal(expression, options.FlagMetadataCallbacks.First().Value); + } +} diff --git a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs index 54f6e19cc..f1c3be3ad 100644 --- a/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs +++ b/test/OpenFeature.Tests/Hooks/MetricsHookTests.cs @@ -1,43 +1,78 @@ +using Microsoft.Extensions.Diagnostics.Metrics.Testing; using OpenFeature.Hooks; using OpenFeature.Model; -using OpenTelemetry; -using OpenTelemetry.Metrics; -using OpenTelemetry.Resources; namespace OpenFeature.Tests.Hooks; [CollectionDefinition(nameof(MetricsHookTest), DisableParallelization = true)] -public class MetricsHookTest : IDisposable +public class MetricsHookTest { - private readonly List _exportedItems; - private readonly MeterProvider _meterProvider; - - public MetricsHookTest() + [Fact] + public async Task After_Test() { - // Arrange metrics collector - this._exportedItems = []; - this._meterProvider = Sdk.CreateMeterProviderBuilder() - .AddMeter("OpenFeature") - .ConfigureResource(r => r.AddService("open-feature")) - .AddInMemoryExporter(this._exportedItems, - option => option.PeriodicExportingMetricReaderOptions = - new PeriodicExportingMetricReaderOptions { ExportIntervalMilliseconds = 100 }) - .Build(); + // Arrange + var metricsHook = new MetricsHook(); + + using var collector = new MetricCollector(metricsHook._evaluationSuccessCounter); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await metricsHook.AfterAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + + var measurements = collector.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal("STATIC", measurements.Tags["feature_flag.result.reason"]); } -#pragma warning disable CA1816 - public void Dispose() + [Fact] + public async Task Without_Reason_After_Test_Defaults_To_Unknown() { - this._meterProvider.Shutdown(); + // Arrange + var metricsHook = new MetricsHook(); + + using var collector = new MetricCollector(metricsHook._evaluationSuccessCounter); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await metricsHook.AfterAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, reason: null, "default"), + new Dictionary()).ConfigureAwait(true); + + var measurements = collector.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal("UNKNOWN", measurements.Tags["feature_flag.result.reason"]); } -#pragma warning restore CA1816 [Fact] - public async Task After_Test() + public async Task With_CustomDimensions_After_Test() { // Arrange - const string metricName = "feature_flag.evaluation_success_total"; - var metricsHook = new MetricsHook(); + var metricHookOptions = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .Build(); + + var metricsHook = new MetricsHook(metricHookOptions); + + using var collector = new MetricCollector(metricsHook._evaluationSuccessCounter); + var evaluationContext = EvaluationContext.Empty; var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); @@ -46,50 +81,123 @@ public async Task After_Test() await metricsHook.AfterAsync(ctx, new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), new Dictionary()).ConfigureAwait(true); - this._meterProvider.ForceFlush(); - // Assert metrics - Assert.NotEmpty(this._exportedItems); + var measurements = collector.LastMeasurement; - // check if the metric is present in the exported items - var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); - Assert.NotNull(metric); + // Assert + Assert.NotNull(measurements); - var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); - Assert.True(noOtherMetric); + Assert.Equal(1, measurements.Value); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal("STATIC", measurements.Tags["feature_flag.result.reason"]); + Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]); + } + + [Fact] + public async Task With_FlagMetadataCallback_After_Test() + { + // Arrange + var metricHookOptions = MetricsHookOptions.CreateBuilder() + .WithFlagEvaluationMetadata("bool", m => m.GetBool("bool")) + .Build(); + + var metricsHook = new MetricsHook(metricHookOptions); + + using var collector = new MetricCollector(metricsHook._evaluationSuccessCounter); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + var flagMetadata = new ImmutableMetadata(new Dictionary + { + { "bool", true } + }); + + // Act + await metricsHook.AfterAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default", errorMessage: null, flagMetadata), + new Dictionary()).ConfigureAwait(true); + + var measurements = collector.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal("STATIC", measurements.Tags["feature_flag.result.reason"]); + Assert.Equal(true, measurements.Tags["bool"]); } [Fact] public async Task Error_Test() { // Arrange - const string metricName = "feature_flag.evaluation_error_total"; var metricsHook = new MetricsHook(); + + using var collector = new MetricCollector(metricsHook._evaluationErrorCounter); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + var errorMessage = "An error occurred during evaluation"; + + // Act + await metricsHook.ErrorAsync(ctx, new Exception(errorMessage), new Dictionary()).ConfigureAwait(true); + + var measurements = collector.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal(errorMessage, measurements.Tags["exception"]); + } + + [Fact] + public async Task With_CustomDimensions_Error_Test() + { + // Arrange + var metricHookOptions = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .Build(); + + var metricsHook = new MetricsHook(metricHookOptions); + + using var collector = new MetricCollector(metricsHook._evaluationErrorCounter); + var evaluationContext = EvaluationContext.Empty; var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + var errorMessage = "An error occurred during evaluation"; + // Act - await metricsHook.ErrorAsync(ctx, new Exception(), new Dictionary()).ConfigureAwait(true); - this._meterProvider.ForceFlush(); + await metricsHook.ErrorAsync(ctx, new Exception(errorMessage), new Dictionary()).ConfigureAwait(true); - // Assert metrics - Assert.NotEmpty(this._exportedItems); + var measurements = collector.LastMeasurement; - // check if the metric is present in the exported items - var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); - Assert.NotNull(metric); + // Assert + Assert.NotNull(measurements); - var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); - Assert.True(noOtherMetric); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal(errorMessage, measurements.Tags["exception"]); + Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]); } [Fact] public async Task Finally_Test() { // Arrange - const string metricName = "feature_flag.evaluation_active_count"; var metricsHook = new MetricsHook(); + + using var collector = new MetricCollector(metricsHook._evaluationActiveUpDownCounter); + var evaluationContext = EvaluationContext.Empty; var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); @@ -97,45 +205,138 @@ public async Task Finally_Test() // Act await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true); - this._meterProvider.ForceFlush(); - // Assert metrics - Assert.NotEmpty(this._exportedItems); + var measurements = collector.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal(-1, measurements.Value); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + } + + [Fact] + public async Task With_CustomDimensions_Finally_Test() + { + // Arrange + var metricHookOptions = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .Build(); + + var metricsHook = new MetricsHook(metricHookOptions); + + using var collector = new MetricCollector(metricsHook._evaluationActiveUpDownCounter); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + var evaluationDetails = new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"); + + // Act + await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true); + + var measurements = collector.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal(-1, measurements.Value); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]); + } + + [Fact] + public async Task With_FlagMetadataCallback_Finally_Test() + { + // Arrange + var metricHookOptions = MetricsHookOptions.CreateBuilder() + .WithFlagEvaluationMetadata("status_code", m => m.GetInt("status_code")) + .Build(); + + var metricsHook = new MetricsHook(metricHookOptions); + + using var collector = new MetricCollector(metricsHook._evaluationActiveUpDownCounter); + + var flagMetadata = new ImmutableMetadata(new Dictionary + { + { "status_code", 1521 } + }); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + var evaluationDetails = new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default", flagMetadata: flagMetadata); + + // Act + await metricsHook.FinallyAsync(ctx, evaluationDetails, new Dictionary()).ConfigureAwait(true); + + var measurements = collector.LastMeasurement; - // check if the metric feature_flag.evaluation_success_total is present in the exported items - var metric = this._exportedItems.FirstOrDefault(m => m.Name == metricName); - Assert.NotNull(metric); + // Assert + Assert.NotNull(measurements); - var noOtherMetric = this._exportedItems.All(m => m.Name == metricName); - Assert.True(noOtherMetric); + Assert.Equal(-1, measurements.Value); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal(1521, measurements.Tags["status_code"]); } [Fact] public async Task Before_Test() { // Arrange - const string metricName1 = "feature_flag.evaluation_active_count"; - const string metricName2 = "feature_flag.evaluation_requests_total"; var metricsHook = new MetricsHook(); + + using var collector1 = new MetricCollector(metricsHook._evaluationActiveUpDownCounter); + using var collector2 = new MetricCollector(metricsHook._evaluationRequestCounter); + var evaluationContext = EvaluationContext.Empty; var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); // Act await metricsHook.BeforeAsync(ctx, new Dictionary()).ConfigureAwait(true); - this._meterProvider.ForceFlush(); - // Assert metrics - Assert.NotEmpty(this._exportedItems); + var measurements = collector1.LastMeasurement; + + // Assert + Assert.NotNull(measurements); + + Assert.Equal(1, measurements.Value); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + } + + [Fact] + public async Task With_CustomDimensions_Before_Test() + { + // Arrange + var metricHookOptions = MetricsHookOptions.CreateBuilder() + .WithCustomDimension("custom_dimension_key", "custom_dimension_value") + .Build(); + + var metricsHook = new MetricsHook(metricHookOptions); + + using var collector1 = new MetricCollector(metricsHook._evaluationActiveUpDownCounter); + using var collector2 = new MetricCollector(metricsHook._evaluationRequestCounter); + + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + await metricsHook.BeforeAsync(ctx, new Dictionary()).ConfigureAwait(true); - // check if the metric is present in the exported items - var metric1 = this._exportedItems.FirstOrDefault(m => m.Name == metricName1); - Assert.NotNull(metric1); + var measurements = collector1.LastMeasurement; - var metric2 = this._exportedItems.FirstOrDefault(m => m.Name == metricName2); - Assert.NotNull(metric2); + // Assert + Assert.NotNull(measurements); - var noOtherMetric = this._exportedItems.All(m => m.Name == metricName1 || m.Name == metricName2); - Assert.True(noOtherMetric); + Assert.Equal(1, measurements.Value); + Assert.Equal("my-flag", measurements.Tags["feature_flag.key"]); + Assert.Equal("my-provider", measurements.Tags["feature_flag.provider.name"]); + Assert.Equal("custom_dimension_value", measurements.Tags["custom_dimension_key"]); } } From 03d3b9e5d6ff1706faffc25afeba80a0e2bb37ec Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 20:35:50 +0000 Subject: [PATCH 072/126] chore(deps): update github/codeql-action digest to d6bbdef (#527) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9ad67c477..9f3893836 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3 + uses: github/codeql-action/init@d6bbdef45e766d081b84a2def353b0055f728d3e # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3 + uses: github/codeql-action/autobuild@d6bbdef45e766d081b84a2def353b0055f728d3e # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3 + uses: github/codeql-action/analyze@d6bbdef45e766d081b84a2def353b0055f728d3e # v3 From a0ae014d3194fcf6e5e5e4a17a2f92b1df3dc7c7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 20:36:01 +0000 Subject: [PATCH 073/126] chore(deps): update spec digest to baec39b (#528) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index 224b26e44..baec39b3f 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 224b26e44ebfe21d1110d5b64d740c8a3055d398 +Subproject commit baec39b3fe886667a0e94a902c22ca7b8486a36d From 6e521d25c3dd53c45f2fd30f5319cae5cd2ff46d Mon Sep 17 00:00:00 2001 From: Weihan Li Date: Fri, 25 Jul 2025 00:16:05 +0800 Subject: [PATCH 074/126] feat: Support JSON Serialize for Value (#529) * feat: add ValueJsonConverter to support STJ JSON Serialize Signed-off-by: Weihan Li * test: add test case Signed-off-by: Weihan Li * style: apply dotnet-format Signed-off-by: Weihan Li * test: update test cases Signed-off-by: Weihan Li * test: update deserialize for int/Datetime Signed-off-by: Weihan Li * style: apply dotnet format Signed-off-by: Weihan Li * refactor: update DateTime handling Signed-off-by: Weihan Li * fix: fix build Signed-off-by: Weihan Li * test: update test case to cover nested Structure and beautify JSON test data Signed-off-by: Weihan Li * refactor: update double/int value serialize Signed-off-by: Weihan Li * include boundaries Signed-off-by: Weihan Li * update double test case data Signed-off-by: Weihan Li * test: Update StructureTests double test case Signed-off-by: Weihan Li * refactor: simplify write number value for ValueJsonConverter Signed-off-by: Weihan Li --------- Signed-off-by: Weihan Li --- Directory.Packages.props | 4 +- src/OpenFeature/Model/Value.cs | 2 + src/OpenFeature/Model/ValueJsonConverter.cs | 123 ++++++++++++++++++++ src/OpenFeature/OpenFeature.csproj | 1 + test/OpenFeature.Tests/StructureTests.cs | 64 ++++++++++ 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 src/OpenFeature/Model/ValueJsonConverter.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 194441b93..fe88537d8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,8 @@ + @@ -48,4 +50,4 @@ - \ No newline at end of file + diff --git a/src/OpenFeature/Model/Value.cs b/src/OpenFeature/Model/Value.cs index 41b15246b..524ac4c4c 100644 --- a/src/OpenFeature/Model/Value.cs +++ b/src/OpenFeature/Model/Value.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Text.Json.Serialization; namespace OpenFeature.Model; @@ -6,6 +7,7 @@ namespace OpenFeature.Model; /// Values serve as a return type for provider objects. Providers may deal in JSON, protobuf, XML or some other data-interchange format. /// This intermediate representation provides a good medium of exchange. ///
+[JsonConverter(typeof(ValueJsonConverter))] public sealed class Value : IEquatable { private readonly object? _innerValue; diff --git a/src/OpenFeature/Model/ValueJsonConverter.cs b/src/OpenFeature/Model/ValueJsonConverter.cs new file mode 100644 index 000000000..1551106cc --- /dev/null +++ b/src/OpenFeature/Model/ValueJsonConverter.cs @@ -0,0 +1,123 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OpenFeature.Model; + +internal sealed class ValueJsonConverter : JsonConverter +{ + public override void Write(Utf8JsonWriter writer, Value value, JsonSerializerOptions options) => + WriteJsonValue(value, writer); + + public override Value Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + ReadJsonValue(ref reader); + + private static void WriteJsonValue(Value value, Utf8JsonWriter writer) + { + if (value.IsNull) + { + writer.WriteNullValue(); + return; + } + + if (value.IsBoolean) + { + writer.WriteBooleanValue(value.AsBoolean.GetValueOrDefault()); + return; + } + + if (value.IsNumber) + { + writer.WriteNumberValue(value.AsDouble!.Value); + return; + } + + if (value.IsString) + { + writer.WriteStringValue(value.AsString); + return; + } + + if (value.IsDateTime) + { + writer.WriteStringValue(value.AsDateTime!.Value); + return; + } + + if (value.IsList) + { + writer.WriteStartArray(); + + foreach (var item in value.AsList ?? []) + { + WriteJsonValue(item, writer); + } + + writer.WriteEndArray(); + return; + } + + if (value.IsStructure) + { + writer.WriteStartObject(); + + var dic = value.AsStructure?.AsDictionary(); + if (dic is { Count: > 0 }) + { + foreach (var pair in dic) + { + writer.WritePropertyName(pair.Key); + WriteJsonValue(pair.Value, writer); + } + } + + writer.WriteEndObject(); + } + } + + private static Value ReadJsonValue(ref Utf8JsonReader reader) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return new(true); + case JsonTokenType.False: + return new(false); + case JsonTokenType.Number: + if (reader.TryGetInt32(out var intVal)) + return new(intVal); + + return new(reader.GetDouble()); + case JsonTokenType.String: + if (reader.TryGetDateTime(out var dateTime)) + return new(dateTime); + + return new(reader.GetString()!); + case JsonTokenType.StartArray: + var list = new List(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + break; + } + list.Add(ReadJsonValue(ref reader)); + } + return new(list); + case JsonTokenType.StartObject: + var objectBuilder = Structure.Builder(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var name = reader.GetString(); + Debug.Assert(name is not null); + reader.Read(); + objectBuilder.Set(name!, ReadJsonValue(ref reader)); + } + return new(objectBuilder.Build()); + + default: + return new(); + } + } +} + diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj index 732c92f3b..2a733157d 100644 --- a/src/OpenFeature/OpenFeature.csproj +++ b/src/OpenFeature/OpenFeature.csproj @@ -11,6 +11,7 @@ + diff --git a/test/OpenFeature.Tests/StructureTests.cs b/test/OpenFeature.Tests/StructureTests.cs index b2b4e1c0f..c7b6b8786 100644 --- a/test/OpenFeature.Tests/StructureTests.cs +++ b/test/OpenFeature.Tests/StructureTests.cs @@ -1,4 +1,6 @@ using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Nodes; using OpenFeature.Model; namespace OpenFeature.Tests; @@ -113,4 +115,66 @@ public void GetEnumerator_Should_Return_Enumerator() enumerator.MoveNext(); Assert.Equal(VAL, enumerator.Current.Value.AsString); } + + [Theory] + [MemberData(nameof(JsonSerializeTestData))] + public void JsonSerializeTest(Value value, string expectedJson) + { + var serializedJsonNode = JsonSerializer.SerializeToNode(value); + var expectJsonNode = JsonNode.Parse(expectedJson); + Assert.True(JsonNode.DeepEquals(expectJsonNode, serializedJsonNode)); + } + + [Theory] + [MemberData(nameof(JsonSerializeTestData))] + public void JsonDeserializeTest(Value value, string expectedJson) + { + var serializedJsonNode = JsonSerializer.SerializeToNode(value); + var expectValue = JsonSerializer.Deserialize(expectedJson); + var expectJsonNode = JsonSerializer.SerializeToNode(expectValue); + Assert.True(JsonNode.DeepEquals(expectJsonNode, serializedJsonNode)); + } + + public static IEnumerable JsonSerializeTestData() + { + yield return [new Value("test"), "\"test\""]; + yield return [new Value(1), "1"]; + yield return [new Value(1.2), "1.2"]; + yield return [new Value(int.MaxValue + 1.0), "2147483648"]; + yield return [new Value(true), "true"]; + yield return [new Value(false), "false"]; + yield return + [ + new Value(Structure.Builder() + .Set("name", "Alice") + .Set("age", 16) + .Set("isMale", false) + .Set("bio", new Value()) + .Set("bornAt", new DateTime(2000, 1, 1)) + .Set("tags", new Value([new Value("girl"), new Value("beauty")])) + .Set("job", Structure.Builder() + .Set("title", "Software Engineer") + .Set("grade", "Senior") + .Build()) + .Build() + ), + """ + { + "name": "Alice", + "age": 16, + "isMale": false, + "bio": null, + "bornAt": "2000-01-01T00:00:00", + "tags": [ + "girl", + "beauty" + ], + "job": { + "title": "Software Engineer", + "grade": "Senior" + } + } + """ + ]; + } } From 5a91005c888c8966145eae7745cc40b2b066f343 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Sat, 26 Jul 2025 14:36:21 +0100 Subject: [PATCH 075/126] feat: Add TraceEnricherHookOptions Custom Attributes (#526) * Add TraceEnricherHookOptions to enrich feature_flag.evaluation event * Add unit tests * Update README * Add XML comments Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Update Method name to be consistent with Tags found in Activity Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- README.md | 18 ++++ src/OpenFeature/Hooks/TraceEnricherHook.cs | 35 +++++++ .../Hooks/TraceEnricherHookOptions.cs | 91 +++++++++++++++++++ .../Hooks/TraceEnricherHookOptionsTests.cs | 84 +++++++++++++++++ .../Hooks/TraceEnricherHookTests.cs | 78 ++++++++++++++++ 5 files changed, 306 insertions(+) create mode 100644 src/OpenFeature/Hooks/TraceEnricherHookOptions.cs create mode 100644 test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs diff --git a/README.md b/README.md index e79fd544a..4caa736d3 100644 --- a/README.md +++ b/README.md @@ -604,6 +604,24 @@ namespace OpenFeatureTestApp After running this example, you will be able to see the traces, including the events sent by the hook in your Jaeger UI. +You can specify custom tags on spans created by the `TraceEnricherHook` by providing `TraceEnricherHookOptions` when adding the hook: + +```csharp +var options = TraceEnricherHookOptions.CreateBuilder() + .WithTag("custom_dimension_key", "custom_dimension_value") + .Build(); + +OpenFeature.Api.Instance.AddHooks(new TraceEnricherHook(options)); +``` + +You can also write your own extraction logic against the Flag metadata by providing a callback to `WithFlagEvaluationMetadata`. The below example will add a tag to the span with the key `boolean` and a value specified by the callback. + +```csharp +var options = TraceEnricherHookOptions.CreateBuilder() + .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean")) + .Build(); +``` + ### Metrics Hook For this hook to function correctly a global `MeterProvider` must be set. diff --git a/src/OpenFeature/Hooks/TraceEnricherHook.cs b/src/OpenFeature/Hooks/TraceEnricherHook.cs index 08914b1ca..26a4f91ba 100644 --- a/src/OpenFeature/Hooks/TraceEnricherHook.cs +++ b/src/OpenFeature/Hooks/TraceEnricherHook.cs @@ -12,6 +12,17 @@ namespace OpenFeature.Hooks; /// This is still experimental and subject to change. public class TraceEnricherHook : Hook { + private readonly TraceEnricherHookOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// Optional configuration for the traces hook. + public TraceEnricherHook(TraceEnricherHookOptions? options = null) + { + _options = options ?? TraceEnricherHookOptions.Default; + } + /// /// Adds tags and events to the current for tracing purposes. /// @@ -31,8 +42,32 @@ public override ValueTask FinallyAsync(HookContext context, FlagEvaluation tags[kvp.Key] = kvp.Value; } + this.AddCustomTags(tags); + this.AddFlagMetadataTags(details.FlagMetadata, tags); + Activity.Current?.AddEvent(new ActivityEvent(evaluationEvent.Name, tags: tags)); return base.FinallyAsync(context, details, hints, cancellationToken); } + + private void AddCustomTags(ActivityTagsCollection tagList) + { + foreach (var customDimension in this._options.Tags) + { + tagList.Add(customDimension.Key, customDimension.Value); + } + } + + private void AddFlagMetadataTags(ImmutableMetadata? flagMetadata, ActivityTagsCollection tagList) + { + flagMetadata ??= new ImmutableMetadata(); + + foreach (var item in this._options.FlagMetadataCallbacks) + { + var flagMetadataCallback = item.Value; + var value = flagMetadataCallback(flagMetadata); + + tagList.Add(item.Key, value); + } + } } diff --git a/src/OpenFeature/Hooks/TraceEnricherHookOptions.cs b/src/OpenFeature/Hooks/TraceEnricherHookOptions.cs new file mode 100644 index 000000000..da3aa604c --- /dev/null +++ b/src/OpenFeature/Hooks/TraceEnricherHookOptions.cs @@ -0,0 +1,91 @@ +using OpenFeature.Model; + +namespace OpenFeature.Hooks; + +/// +/// Configuration options for the . +/// +public sealed class TraceEnricherHookOptions +{ + /// + /// The default options for the . + /// + public static TraceEnricherHookOptions Default { get; } = new TraceEnricherHookOptions(); + + /// + /// Custom tags to be associated with current in . + /// + public IReadOnlyCollection> Tags { get; } + + /// + /// Flag metadata callbacks to be associated with current . + /// + internal IReadOnlyCollection>> FlagMetadataCallbacks { get; } + + /// + /// Initializes a new instance of the class with default values. + /// + private TraceEnricherHookOptions() : this(null, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Optional custom tags to tag Counter increments with. + /// Optional flag metadata callbacks to be associated with current . + internal TraceEnricherHookOptions(IReadOnlyCollection>? tags = null, + IReadOnlyCollection>>? flagMetadataSelectors = null) + { + this.Tags = tags ?? []; + this.FlagMetadataCallbacks = flagMetadataSelectors ?? []; + } + + /// + /// Creates a new builder for . + /// + public static TraceEnricherHookOptionsBuilder CreateBuilder() => new TraceEnricherHookOptionsBuilder(); + + /// + /// A builder for constructing instances. + /// + public sealed class TraceEnricherHookOptionsBuilder + { + private readonly List> _customTags = new List>(); + private readonly List>> _flagMetadataExpressions = new List>>(); + + /// + /// Adds a custom tag to the . + /// + /// The key for the custom dimension. + /// The value for the custom dimension. + public TraceEnricherHookOptionsBuilder WithTag(string key, object? value) + { + this._customTags.Add(new KeyValuePair(key, value)); + return this; + } + + /// + /// Provide a callback to evaluate flag metadata and add it as a custom tag on the current . + /// + /// The key for the custom tag. + /// The callback to retrieve the value to tag successful flag evaluations. + /// + public TraceEnricherHookOptionsBuilder WithFlagEvaluationMetadata(string key, Func flagMetadataCallback) + { + var kvp = new KeyValuePair>(key, flagMetadataCallback); + + this._flagMetadataExpressions.Add(kvp); + + return this; + } + + /// + /// Builds the instance. + /// + public TraceEnricherHookOptions Build() + { + return new TraceEnricherHookOptions(this._customTags.AsReadOnly(), this._flagMetadataExpressions.AsReadOnly()); + } + } +} diff --git a/test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs b/test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs new file mode 100644 index 000000000..003102a72 --- /dev/null +++ b/test/OpenFeature.Tests/Hooks/TraceEnricherHookOptionsTests.cs @@ -0,0 +1,84 @@ +using OpenFeature.Hooks; +using OpenFeature.Model; + +namespace OpenFeature.Tests.Hooks; + +public class TraceEnricherHookOptionsTests +{ + [Fact] + public void Default_Options_Should_Be_Initialized_Correctly() + { + // Arrange & Act + var options = TraceEnricherHookOptions.Default; + + // Assert + Assert.NotNull(options); + Assert.Empty(options.Tags); + Assert.Empty(options.FlagMetadataCallbacks); + } + + [Fact] + public void CreateBuilder_Should_Return_New_Builder_Instance() + { + // Arrange & Act + var builder = TraceEnricherHookOptions.CreateBuilder(); + + // Assert + Assert.NotNull(builder); + Assert.IsType(builder); + } + + [Fact] + public void Build_Should_Return_Options() + { + // Arrange + var builder = TraceEnricherHookOptions.CreateBuilder(); + + // Act + var options = builder.Build(); + + // Assert + Assert.NotNull(options); + Assert.IsType(options); + } + + [Theory] + [InlineData("custom_dimension_value")] + [InlineData(1.0)] + [InlineData(2025)] + [InlineData(null)] + [InlineData(true)] + public void Builder_Should_Allow_Adding_Custom_Dimensions(object? value) + { + // Arrange + var builder = TraceEnricherHookOptions.CreateBuilder(); + var key = "custom_dimension_key"; + + // Act + builder.WithTag(key, value); + var options = builder.Build(); + + // Assert + Assert.Single(options.Tags); + Assert.Equal(key, options.Tags.First().Key); + Assert.Equal(value, options.Tags.First().Value); + } + + [Fact] + public void Builder_Should_Allow_Adding_Flag_Metadata_Expressions() + { + // Arrange + var builder = TraceEnricherHookOptions.CreateBuilder(); + var key = "flag_metadata_key"; + static object? expression(ImmutableMetadata m) => m.GetString("flag_metadata_key"); + + // Act + builder.WithFlagEvaluationMetadata(key, expression); + var options = builder.Build(); + + // Assert + Assert.Single(options.FlagMetadataCallbacks); + Assert.Equal(key, options.FlagMetadataCallbacks.First().Key); + Assert.Equal(expression, options.FlagMetadataCallbacks.First().Value); + } +} diff --git a/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs b/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs index f73d36200..5f0b617d3 100644 --- a/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs +++ b/test/OpenFeature.Tests/Hooks/TraceEnricherHookTests.cs @@ -69,6 +69,84 @@ await traceEnricherHook.FinallyAsync(ctx, Assert.Contains(new KeyValuePair("feature_flag.result.value", "foo"), ev.Tags); } + [Fact] + public async Task TestFinally_WithCustomDimension() + { + // Arrange + var traceHookOptions = TraceEnricherHookOptions.CreateBuilder() + .WithTag("custom_dimension_key", "custom_dimension_value") + .Build(); + var traceEnricherHook = new TraceEnricherHook(traceHookOptions); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + // Act + var span = this._tracer.StartActiveSpan("my-span"); + await traceEnricherHook.FinallyAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default"), + new Dictionary()).ConfigureAwait(true); + span.End(); + + this._tracerProvider.ForceFlush(); + + // Assert + Assert.Single(this._exportedItems); + var rootSpan = this._exportedItems.First(); + + Assert.Single(rootSpan.Events); + ActivityEvent ev = rootSpan.Events.First(); + Assert.Equal("feature_flag.evaluation", ev.Name); + + Assert.Contains(new KeyValuePair("custom_dimension_key", "custom_dimension_value"), ev.Tags); + } + + [Fact] + public async Task TestFinally_WithFlagEvaluationMetadata() + { + // Arrange + var traceHookOptions = TraceEnricherHookOptions.CreateBuilder() + .WithFlagEvaluationMetadata("double", metadata => metadata.GetDouble("double")) + .WithFlagEvaluationMetadata("int", metadata => metadata.GetInt("int")) + .WithFlagEvaluationMetadata("bool", metadata => metadata.GetBool("bool")) + .WithFlagEvaluationMetadata("string", metadata => metadata.GetString("string")) + .Build(); + var traceEnricherHook = new TraceEnricherHook(traceHookOptions); + var evaluationContext = EvaluationContext.Empty; + var ctx = new HookContext("my-flag", "foo", Constant.FlagValueType.String, + new ClientMetadata("my-client", "1.0"), new Metadata("my-provider"), evaluationContext); + + var flagMetadata = new ImmutableMetadata(new Dictionary + { + { "double", 1.0 }, + { "int", 2025 }, + { "bool", true }, + { "string", "foo" } + }); + + // Act + var span = this._tracer.StartActiveSpan("my-span"); + await traceEnricherHook.FinallyAsync(ctx, + new FlagEvaluationDetails("my-flag", "foo", Constant.ErrorType.None, "STATIC", "default", flagMetadata: flagMetadata), + new Dictionary()).ConfigureAwait(true); + span.End(); + + this._tracerProvider.ForceFlush(); + + // Assert + Assert.Single(this._exportedItems); + var rootSpan = this._exportedItems.First(); + + Assert.Single(rootSpan.Events); + ActivityEvent ev = rootSpan.Events.First(); + Assert.Equal("feature_flag.evaluation", ev.Name); + + Assert.Contains(new KeyValuePair("double", 1.0), ev.Tags); + Assert.Contains(new KeyValuePair("int", 2025), ev.Tags); + Assert.Contains(new KeyValuePair("bool", true), ev.Tags); + Assert.Contains(new KeyValuePair("string", "foo"), ev.Tags); + } + [Fact] public async Task TestFinally_NoSpan() { From 20d1f37a4f8991419bb14dae7eec9a08c2b32bc6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 26 Jul 2025 14:42:35 +0100 Subject: [PATCH 076/126] chore(deps): update github/codeql-action digest to 4e828ff (#532) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9f3893836..57050602a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@d6bbdef45e766d081b84a2def353b0055f728d3e # v3 + uses: github/codeql-action/init@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@d6bbdef45e766d081b84a2def353b0055f728d3e # v3 + uses: github/codeql-action/autobuild@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@d6bbdef45e766d081b84a2def353b0055f728d3e # v3 + uses: github/codeql-action/analyze@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3 From 2547a574e0d0328f909b7e69f3775d07492de3dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:26:12 +0100 Subject: [PATCH 077/126] refactor: Simplify Provider Repository (#515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: simplify initialization and shutdown methods with cancellation support Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: change visibility of GetProvider and ShutdownAsync methods to internal Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: correct logger instance type in ProviderRepository Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: update preprocessor directives for compatibility with .NET Standard Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: update SetProviderAsync method to enforce non-null domain parameter Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: enhance documentation for GetProvider and FeatureClient constructor parameters Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- src/OpenFeature/Api.cs | 4 +- src/OpenFeature/ProviderRepository.cs | 55 ++++++++++++++------------- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/src/OpenFeature/Api.cs b/src/OpenFeature/Api.cs index cea661398..93deb31cb 100644 --- a/src/OpenFeature/Api.cs +++ b/src/OpenFeature/Api.cs @@ -85,7 +85,7 @@ public FeatureProvider GetProvider() /// Gets the feature provider with given domain ///
/// An identifier which logically binds clients with providers - /// A provider associated with the given domain, if domain is empty or doesn't + /// A provider associated with the given domain, if domain is empty, null, whitespace or doesn't /// have a corresponding provider the default provider will be returned public FeatureProvider GetProvider(string domain) { @@ -114,7 +114,7 @@ public FeatureProvider GetProvider(string domain) /// /// Create a new instance of using the current provider /// - /// Name of client + /// Name of client, if the is not provided a default name will be used /// Version of client /// Logger instance used by client /// Context given to this client diff --git a/src/OpenFeature/ProviderRepository.cs b/src/OpenFeature/ProviderRepository.cs index 4f938940d..4cea63b08 100644 --- a/src/OpenFeature/ProviderRepository.cs +++ b/src/OpenFeature/ProviderRepository.cs @@ -4,7 +4,6 @@ using OpenFeature.Constant; using OpenFeature.Model; - namespace OpenFeature; /// @@ -12,12 +11,11 @@ namespace OpenFeature; /// internal sealed partial class ProviderRepository : IAsyncDisposable { - private ILogger _logger = NullLogger.Instance; + private ILogger _logger = NullLogger.Instance; private FeatureProvider _defaultProvider = new NoOpFeatureProvider(); - private readonly ConcurrentDictionary _featureProviders = - new ConcurrentDictionary(); + private readonly ConcurrentDictionary _featureProviders = new(); /// The reader/writer locks is not disposed because the singleton instance should never be disposed. /// @@ -29,7 +27,7 @@ internal sealed partial class ProviderRepository : IAsyncDisposable /// The second is that a concurrent collection doesn't provide any ordering, so we could check a provider /// as it was being added or removed such as two concurrent calls to SetProvider replacing multiple instances /// of that provider under different names. - private readonly ReaderWriterLockSlim _providersLock = new ReaderWriterLockSlim(); + private readonly ReaderWriterLockSlim _providersLock = new(); public async ValueTask DisposeAsync() { @@ -53,11 +51,13 @@ public async ValueTask DisposeAsync() /// called if an error happens during the initialization of the provider, only called if the provider needed /// initialization /// - public async Task SetProviderAsync( + /// a cancellation token to cancel the operation + internal async Task SetProviderAsync( FeatureProvider? featureProvider, EvaluationContext context, Func? afterInitSuccess = null, - Func? afterInitError = null) + Func? afterInitError = null, + CancellationToken cancellationToken = default) { // Cannot unset the feature provider. if (featureProvider == null) @@ -79,14 +79,14 @@ public async Task SetProviderAsync( this._defaultProvider = featureProvider; // We want to allow shutdown to happen concurrently with initialization, and the caller to not // wait for it. - _ = this.ShutdownIfUnusedAsync(oldProvider); + _ = this.ShutdownIfUnusedAsync(oldProvider, cancellationToken); } finally { this._providersLock.ExitWriteLock(); } - await InitProviderAsync(this._defaultProvider, context, afterInitSuccess, afterInitError) + await InitProviderAsync(this._defaultProvider, context, afterInitSuccess, afterInitError, cancellationToken) .ConfigureAwait(false); } @@ -94,7 +94,8 @@ private static async Task InitProviderAsync( FeatureProvider? newProvider, EvaluationContext context, Func? afterInitialization, - Func? afterError) + Func? afterError, + CancellationToken cancellationToken = default) { if (newProvider == null) { @@ -104,7 +105,7 @@ private static async Task InitProviderAsync( { try { - await newProvider.InitializeAsync(context).ConfigureAwait(false); + await newProvider.InitializeAsync(context, cancellationToken).ConfigureAwait(false); if (afterInitialization != null) { await afterInitialization.Invoke(newProvider).ConfigureAwait(false); @@ -134,7 +135,7 @@ private static async Task InitProviderAsync( /// initialization /// /// The to cancel any async side effects. - public async Task SetProviderAsync(string? domain, + internal async Task SetProviderAsync(string domain, FeatureProvider? featureProvider, EvaluationContext context, Func? afterInitSuccess = null, @@ -142,7 +143,7 @@ public async Task SetProviderAsync(string? domain, CancellationToken cancellationToken = default) { // Cannot set a provider for a null domain. - if (domain == null) + if (string.IsNullOrWhiteSpace(domain)) { return; } @@ -166,21 +167,21 @@ public async Task SetProviderAsync(string? domain, // We want to allow shutdown to happen concurrently with initialization, and the caller to not // wait for it. - _ = this.ShutdownIfUnusedAsync(oldProvider); + _ = this.ShutdownIfUnusedAsync(oldProvider, cancellationToken); } finally { this._providersLock.ExitWriteLock(); } - await InitProviderAsync(featureProvider, context, afterInitSuccess, afterInitError).ConfigureAwait(false); + await InitProviderAsync(featureProvider, context, afterInitSuccess, afterInitError, cancellationToken).ConfigureAwait(false); } /// /// Shutdown the feature provider if it is unused. This must be called within a write lock of the _providersLock. /// private async Task ShutdownIfUnusedAsync( - FeatureProvider? targetProvider) + FeatureProvider? targetProvider, CancellationToken cancellationToken = default) { if (ReferenceEquals(this._defaultProvider, targetProvider)) { @@ -192,7 +193,7 @@ private async Task ShutdownIfUnusedAsync( return; } - await this.SafeShutdownProviderAsync(targetProvider).ConfigureAwait(false); + await this.SafeShutdownProviderAsync(targetProvider, cancellationToken).ConfigureAwait(false); } /// @@ -204,7 +205,7 @@ private async Task ShutdownIfUnusedAsync( /// it would not be meaningful to emit an error. /// /// - private async Task SafeShutdownProviderAsync(FeatureProvider? targetProvider) + private async Task SafeShutdownProviderAsync(FeatureProvider? targetProvider, CancellationToken cancellationToken = default) { if (targetProvider == null) { @@ -213,7 +214,7 @@ private async Task SafeShutdownProviderAsync(FeatureProvider? targetProvider) try { - await targetProvider.ShutdownAsync().ConfigureAwait(false); + await targetProvider.ShutdownAsync(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -221,7 +222,7 @@ private async Task SafeShutdownProviderAsync(FeatureProvider? targetProvider) } } - public FeatureProvider GetProvider() + internal FeatureProvider GetProvider() { this._providersLock.EnterReadLock(); try @@ -234,16 +235,16 @@ public FeatureProvider GetProvider() } } - public FeatureProvider GetProvider(string? domain) + internal FeatureProvider GetProvider(string? domain) { -#if NET6_0_OR_GREATER - if (string.IsNullOrEmpty(domain)) +#if NETFRAMEWORK || NETSTANDARD + // This is a workaround for the issue in .NET Framework where string.IsNullOrEmpty is not nullable compatible. + if (domain == null) { return this.GetProvider(); } #else - // This is a workaround for the issue in .NET Framework where string.IsNullOrEmpty is not nullable compatible. - if (domain == null || string.IsNullOrEmpty(domain)) + if (string.IsNullOrWhiteSpace(domain)) { return this.GetProvider(); } @@ -254,7 +255,7 @@ public FeatureProvider GetProvider(string? domain) : this.GetProvider(); } - public async Task ShutdownAsync(Action? afterError = null, CancellationToken cancellationToken = default) + internal async Task ShutdownAsync(Action? afterError = null, CancellationToken cancellationToken = default) { var providers = new HashSet(); this._providersLock.EnterWriteLock(); @@ -278,7 +279,7 @@ public async Task ShutdownAsync(Action? afterError = foreach (var targetProvider in providers) { // We don't need to take any actions after shutdown. - await this.SafeShutdownProviderAsync(targetProvider).ConfigureAwait(false); + await this.SafeShutdownProviderAsync(targetProvider, cancellationToken).ConfigureAwait(false); } } From 1a3846d7575e75b5d7d05ec2a7db0b0f82c7b274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Wed, 30 Jul 2025 17:44:35 +0100 Subject: [PATCH 078/126] fix: update DI lifecycle to use container instead of static instance (#534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Update Api constructor visibility and adjust service registration Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Enhance FeatureClient to use Api instance for provider access and context retrieval Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Update Api to support singleton instance setup in Dependency Injection Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Refactor FeatureClient constructor to prioritize API instance and improve provider access Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../OpenFeatureServiceCollectionExtensions.cs | 4 +++- src/OpenFeature/Api.cs | 12 +++++++++-- src/OpenFeature/OpenFeature.csproj | 3 ++- src/OpenFeature/OpenFeatureClient.cs | 21 +++++++++++-------- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs index 74d01ad3a..a24c67e78 100644 --- a/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs +++ b/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs @@ -24,7 +24,9 @@ public static IServiceCollection AddOpenFeature(this IServiceCollection services Guard.ThrowIfNull(configure); // Register core OpenFeature services as singletons. - services.TryAddSingleton(Api.Instance); + var api = new Api(); + Api.SetInstance(api); + services.TryAddSingleton(api); services.TryAddSingleton(); var builder = new OpenFeatureBuilder(services); diff --git a/src/OpenFeature/Api.cs b/src/OpenFeature/Api.cs index 93deb31cb..e4a9826c5 100644 --- a/src/OpenFeature/Api.cs +++ b/src/OpenFeature/Api.cs @@ -32,7 +32,7 @@ public sealed class Api : IEventBus // not to mark type as beforeFieldInit // IE Lazy way of ensuring this is thread safe without using locks static Api() { } - private Api() { } + internal Api() { } /// /// Sets the default feature provider. In order to wait for the provider to be set, and initialization to complete, @@ -121,7 +121,7 @@ public FeatureProvider GetProvider(string domain) /// public FeatureClient GetClient(string? name = null, string? version = null, ILogger? logger = null, EvaluationContext? context = null) => - new FeatureClient(() => this._repository.GetProvider(name), name, version, logger, context); + new FeatureClient(this, () => this._repository.GetProvider(name), name, version, logger, context); /// /// Appends list of hooks to global hooks list @@ -360,4 +360,12 @@ internal static void ResetApi() { Instance = new Api(); } + + /// + /// This method should only be used in the Dependency Injection setup. It will set the singleton instance of the API using the provided instance. + /// + internal static void SetInstance(Api api) + { + Instance = api; + } } diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj index 2a733157d..2b1983959 100644 --- a/src/OpenFeature/OpenFeature.csproj +++ b/src/OpenFeature/OpenFeature.csproj @@ -19,7 +19,8 @@ + - + \ No newline at end of file diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index c99f4f5c9..1f47d2d24 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -18,6 +18,7 @@ public sealed partial class FeatureClient : IFeatureClient private readonly ConcurrentStack _hooks = new ConcurrentStack(); private readonly ILogger _logger; private readonly Func _providerAccessor; + private readonly Api _api; private EvaluationContext _evaluationContext; private readonly object _evaluationContextLock = new object(); @@ -40,7 +41,7 @@ public sealed partial class FeatureClient : IFeatureClient { // Alias the provider reference so getting the method and returning the provider are // guaranteed to be the same object. - var provider = Api.Instance.GetProvider(this._metadata.Name!); + var provider = this._api.GetProvider(this._metadata.Name!); return (method(provider), provider); } @@ -69,18 +70,20 @@ public void SetContext(EvaluationContext? context) /// /// Initializes a new instance of the class. /// + /// The API instance for accessing global state and providers /// Function to retrieve current provider /// Name of client /// Version of client /// Logger used by client /// Context given to this client /// Throws if any of the required parameters are null - internal FeatureClient(Func providerAccessor, string? name, string? version, ILogger? logger = null, EvaluationContext? context = null) + internal FeatureClient(Api api, Func providerAccessor, string? name, string? version, ILogger? logger = null, EvaluationContext? context = null) { + this._api = api; + this._providerAccessor = providerAccessor; this._metadata = new ClientMetadata(name, version); this._logger = logger ?? NullLogger.Instance; this._evaluationContext = context ?? EvaluationContext.Empty; - this._providerAccessor = providerAccessor; } /// @@ -99,13 +102,13 @@ internal FeatureClient(Func providerAccessor, string? name, str /// public void AddHandler(ProviderEventTypes eventType, EventHandlerDelegate handler) { - Api.Instance.AddClientHandler(this._metadata.Name!, eventType, handler); + this._api.AddClientHandler(this._metadata.Name!, eventType, handler); } /// public void RemoveHandler(ProviderEventTypes type, EventHandlerDelegate handler) { - Api.Instance.RemoveClientHandler(this._metadata.Name!, type, handler); + this._api.RemoveClientHandler(this._metadata.Name!, type, handler); } /// @@ -213,13 +216,13 @@ private async Task> EvaluateFlagAsync( // merge api, client, transaction and invocation context var evaluationContextBuilder = EvaluationContext.Builder(); - evaluationContextBuilder.Merge(Api.Instance.GetContext()); // API context + evaluationContextBuilder.Merge(this._api.GetContext()); // API context evaluationContextBuilder.Merge(this.GetContext()); // Client context - evaluationContextBuilder.Merge(Api.Instance.GetTransactionContext()); // Transaction context + evaluationContextBuilder.Merge(this._api.GetTransactionContext()); // Transaction context evaluationContextBuilder.Merge(context); // Invocation context var allHooks = ImmutableList.CreateBuilder() - .Concat(Api.Instance.GetHooks()) + .Concat(this._api.GetHooks()) .Concat(this.GetHooks()) .Concat(options?.Hooks ?? Enumerable.Empty()) .Concat(provider.GetProviderHooks()) @@ -310,7 +313,7 @@ public void Track(string trackingEventName, EvaluationContext? evaluationContext throw new ArgumentException("Tracking event cannot be null or empty.", nameof(trackingEventName)); } - var globalContext = Api.Instance.GetContext(); + var globalContext = this._api.GetContext(); var clientContext = this.GetContext(); var evaluationContextBuilder = EvaluationContext.Builder() From c0eb12aaa6b2dbdc474d88e242d0e7659a3cc122 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:52:15 -0400 Subject: [PATCH 079/126] chore(main): release 2.8.0 (#511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(main): release 2.8.0 Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> * Add missing release Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> Co-authored-by: André Silva <2493377+askpt@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++++ README.md | 4 ++-- build/Common.prod.props | 2 +- version.txt | 2 +- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6ed9c8012..7a5647237 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.7.0" + ".": "2.8.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a176c613..3561fbd74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## [2.8.0](https://github.com/open-feature/dotnet-sdk/compare/v2.7.0...v2.8.0) (2025-07-30) + + +### 🐛 Bug Fixes + +* update DI lifecycle to use container instead of static instance ([#534](https://github.com/open-feature/dotnet-sdk/issues/534)) ([1a3846d](https://github.com/open-feature/dotnet-sdk/commit/1a3846d7575e75b5d7d05ec2a7db0b0f82c7b274)) + + +### ✨ New Features + +* Add Hook Dependency Injection extension method with Hook instance ([#513](https://github.com/open-feature/dotnet-sdk/issues/513)) ([12396b7](https://github.com/open-feature/dotnet-sdk/commit/12396b7872a2db6533b33267cf9c299248c41472)) +* Add TraceEnricherHookOptions Custom Attributes ([#526](https://github.com/open-feature/dotnet-sdk/issues/526)) ([5a91005](https://github.com/open-feature/dotnet-sdk/commit/5a91005c888c8966145eae7745cc40b2b066f343)) +* Add Track method to IFeatureClient ([#519](https://github.com/open-feature/dotnet-sdk/issues/519)) ([2e70072](https://github.com/open-feature/dotnet-sdk/commit/2e7007277e19a0fbc4c4c3944d24eea1608712e6)) +* Support JSON Serialize for Value ([#529](https://github.com/open-feature/dotnet-sdk/issues/529)) ([6e521d2](https://github.com/open-feature/dotnet-sdk/commit/6e521d25c3dd53c45f2fd30f5319cae5cd2ff46d)) +* Add Metric Hook Custom Attributes ([#512](https://github.com/open-feature/dotnet-sdk/issues/512)) ([8c05d1d](https://github.com/open-feature/dotnet-sdk/commit/8c05d1d7363db89b8379e1a4e46e455f210888e2)) + + +### 🧹 Chore + +* Add comparison to Value ([#523](https://github.com/open-feature/dotnet-sdk/issues/523)) ([883f4f3](https://github.com/open-feature/dotnet-sdk/commit/883f4f3c8b553dc01b5accdbae2782ca7805e8ed)) +* **deps:** update github/codeql-action digest to 181d5ee ([#520](https://github.com/open-feature/dotnet-sdk/issues/520)) ([40bec0d](https://github.com/open-feature/dotnet-sdk/commit/40bec0d51b6fa782a8b6d90a3d84463f9fb73c1b)) +* **deps:** update github/codeql-action digest to 4e828ff ([#532](https://github.com/open-feature/dotnet-sdk/issues/532)) ([20d1f37](https://github.com/open-feature/dotnet-sdk/commit/20d1f37a4f8991419bb14dae7eec9a08c2b32bc6)) +* **deps:** update github/codeql-action digest to d6bbdef ([#527](https://github.com/open-feature/dotnet-sdk/issues/527)) ([03d3b9e](https://github.com/open-feature/dotnet-sdk/commit/03d3b9e5d6ff1706faffc25afeba80a0e2bb37ec)) +* **deps:** update spec digest to 224b26e ([#521](https://github.com/open-feature/dotnet-sdk/issues/521)) ([fbc2645](https://github.com/open-feature/dotnet-sdk/commit/fbc2645efd649c0c37bd1a1cf473fbd98d920948)) +* **deps:** update spec digest to baec39b ([#528](https://github.com/open-feature/dotnet-sdk/issues/528)) ([a0ae014](https://github.com/open-feature/dotnet-sdk/commit/a0ae014d3194fcf6e5e5e4a17a2f92b1df3dc7c7)) +* remove redundant rule (now in parent) ([929fa74](https://github.com/open-feature/dotnet-sdk/commit/929fa7497197214d385eeaa40aba008932d00896)) + + +### 📚 Documentation + +* fix anchor link in readme ([#525](https://github.com/open-feature/dotnet-sdk/issues/525)) ([18705c7](https://github.com/open-feature/dotnet-sdk/commit/18705c7338a0c89f163f808c81e513a029c95239)) +* remove curly brace from readme ([8c92524](https://github.com/open-feature/dotnet-sdk/commit/8c92524edbf4579d4ad62c699b338b9811a783fd)) + + +### 🔄 Refactoring + +* Simplify Provider Repository ([#515](https://github.com/open-feature/dotnet-sdk/issues/515)) ([2547a57](https://github.com/open-feature/dotnet-sdk/commit/2547a574e0d0328f909b7e69f3775d07492de3dd)) + ## [2.7.0](https://github.com/open-feature/dotnet-sdk/compare/v2.6.0...v2.7.0) (2025-07-03) diff --git a/README.md b/README.md index 4caa736d3..511fa4503 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ [![Specification](https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge)](https://github.com/open-feature/spec/releases/tag/v0.8.0) [ -![Release](https://img.shields.io/static/v1?label=release&message=v2.7.0&color=blue&style=for-the-badge) -](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.7.0) +![Release](https://img.shields.io/static/v1?label=release&message=v2.8.0&color=blue&style=for-the-badge) +](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.8.0) [![Slack](https://img.shields.io/badge/slack-%40cncf%2Fopenfeature-brightgreen?style=flat&logo=slack)](https://cloud-native.slack.com/archives/C0344AANLA1) [![Codecov](https://codecov.io/gh/open-feature/dotnet-sdk/branch/main/graph/badge.svg?token=MONAVJBXUJ)](https://codecov.io/gh/open-feature/dotnet-sdk) diff --git a/build/Common.prod.props b/build/Common.prod.props index 0a05126f5..047997257 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -9,7 +9,7 @@ - 2.7.0 + 2.8.0 git https://github.com/open-feature/dotnet-sdk OpenFeature is an open standard for feature flag management, created to support a robust feature flag ecosystem using cloud native technologies. OpenFeature will provide a unified API and SDK, and a developer-first, cloud-native implementation, with extensibility for open source and commercial offerings. diff --git a/version.txt b/version.txt index 24ba9a38d..834f26295 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.7.0 +2.8.0 From e03aeba0f515f668afaba0a3c6f0ea01b44d6ee4 Mon Sep 17 00:00:00 2001 From: Weihan Li Date: Fri, 1 Aug 2025 02:06:58 +0800 Subject: [PATCH 080/126] fix: expose ValueJsonConverter for generator support and add JsonSourceGenerator test cases (#537) * feat: expose ValueJsonConverter and add JsonSourceGenerator test cases Signed-off-by: Weihan Li * style: apply dotnet-format Signed-off-by: Weihan Li * feat: let the sample aot safe Signed-off-by: Weihan Li * feat: enable aot analyzer and add necessary annotation Signed-off-by: Weihan Li * feat: update aot support for sample project Signed-off-by: Weihan Li * build: fix aot publish error Signed-off-by: Weihan Li * build: simplify the PublishAot error workaround Signed-off-by: Weihan Li * build: fix format action error Signed-off-by: Weihan Li * sample: update sample usage Signed-off-by: Weihan Li --------- Signed-off-by: Weihan Li --- .github/workflows/ci.yml | 4 ++ .github/workflows/dotnet-format.yml | 4 +- samples/AspNetCore/Program.cs | 38 ++++++++++++++++++- samples/AspNetCore/Samples.AspNetCore.csproj | 2 + src/Directory.Build.props | 4 ++ .../OpenFeatureBuilderExtensions.cs | 25 ++++++++++-- src/OpenFeature/Model/ValueJsonConverter.cs | 7 +++- test/OpenFeature.Tests/StructureTests.cs | 26 +++++++++++++ 8 files changed, 102 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5b5edb77..1f72a15f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,10 @@ jobs: - name: Test run: dotnet test -c Release --no-build --logger GitHubActions + - name: aot-publish test + run: | + dotnet publish ./samples/AspNetCore/Samples.AspNetCore.csproj + packaging: needs: build diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index e35e37756..75f603750 100644 --- a/.github/workflows/dotnet-format.yml +++ b/.github/workflows/dotnet-format.yml @@ -23,4 +23,6 @@ jobs: global-json-file: global.json - name: dotnet format - run: dotnet format --verify-no-changes OpenFeature.slnx + run: | + # Exclude diagnostics to work around dotnet-format issue, see https://github.com/dotnet/sdk/issues/50012 + dotnet format --verify-no-changes OpenFeature.slnx --exclude-diagnostics IL2026 --exclude-diagnostics IL3050 diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index e09213076..e8faf5a5e 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -1,16 +1,23 @@ +using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc; using OpenFeature; using OpenFeature.DependencyInjection.Providers.Memory; using OpenFeature.Hooks; +using OpenFeature.Model; using OpenFeature.Providers.Memory; -using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; -var builder = WebApplication.CreateBuilder(args); +var builder = WebApplication.CreateSlimBuilder(args); // Add services to the container. +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); +}); + builder.Services.AddProblemDetails(); // Configure OpenTelemetry @@ -40,6 +47,14 @@ { "welcome-message", new Flag( new Dictionary { { "show", true }, { "hide", false } }, "show") + }, + { + "test-config", new Flag(new Dictionary() + { + { "enable", new Value(Structure.Builder().Set(nameof(TestConfig.Threshold), 100).Build()) }, + { "half", new Value(Structure.Builder().Set(nameof(TestConfig.Threshold), 50).Build()) }, + { "disable", new Value(Structure.Builder().Set(nameof(TestConfig.Threshold), 0).Build()) } + }, "disable") } }); }); @@ -60,5 +75,24 @@ return TypedResults.Ok("Hello world!"); }); +app.MapGet("/test-config", async ([FromServices] IFeatureClient featureClient) => +{ + var testConfigValue = await featureClient.GetObjectValueAsync("test-config", + new Value(Structure.Builder().Set("Threshold", 50).Build()) + ); + var json = JsonSerializer.Serialize(testConfigValue, AppJsonSerializerContext.Default.Value); + var config = JsonSerializer.Deserialize(json, AppJsonSerializerContext.Default.TestConfig); + return Results.Ok(config); +}); app.Run(); + + +public class TestConfig +{ + public int Threshold { get; set; } = 10; +} + +[JsonSerializable(typeof(TestConfig))] +[JsonSerializable(typeof(Value))] +public partial class AppJsonSerializerContext : JsonSerializerContext; diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj index cd249ab3e..b6223bd04 100644 --- a/samples/AspNetCore/Samples.AspNetCore.csproj +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -2,6 +2,8 @@ false + true + true diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 992a61958..3b7879044 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,3 +1,7 @@ + + + $([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0')) + diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs index 01a535e04..d676dc5e9 100644 --- a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs +++ b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs @@ -272,7 +272,11 @@ public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder, /// The instance. /// Optional factory for controlling how will be created in the DI container. /// The instance. - public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, Func? implementationFactory = null) + public static OpenFeatureBuilder AddHook< +#if NET + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + THook>(this OpenFeatureBuilder builder, Func? implementationFactory = null) where THook : Hook { return builder.AddHook(typeof(THook).Name, implementationFactory); @@ -285,7 +289,11 @@ public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, /// The instance. /// Instance of Hook to inject into the OpenFeature context. /// The instance. - public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, THook hook) + public static OpenFeatureBuilder AddHook< +#if NET + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + THook>(this OpenFeatureBuilder builder, THook hook) where THook : Hook { return builder.AddHook(typeof(THook).Name, hook); @@ -299,7 +307,11 @@ public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, /// The name of the that is being added. /// Instance of Hook to inject into the OpenFeature context. /// The instance. - public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, string hookName, THook hook) + public static OpenFeatureBuilder AddHook< +#if NET + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + THook>(this OpenFeatureBuilder builder, string hookName, THook hook) where THook : Hook { return builder.AddHook(hookName, _ => hook); @@ -313,7 +325,12 @@ public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, /// The name of the that is being added. /// Optional factory for controlling how will be created in the DI container. /// The instance. - public static OpenFeatureBuilder AddHook(this OpenFeatureBuilder builder, string hookName, Func? implementationFactory = null) + public static OpenFeatureBuilder AddHook< +#if NET + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + THook> + (this OpenFeatureBuilder builder, string hookName, Func? implementationFactory = null) where THook : Hook { builder.Services.PostConfigure(options => options.AddHookName(hookName)); diff --git a/src/OpenFeature/Model/ValueJsonConverter.cs b/src/OpenFeature/Model/ValueJsonConverter.cs index 1551106cc..911cc45fd 100644 --- a/src/OpenFeature/Model/ValueJsonConverter.cs +++ b/src/OpenFeature/Model/ValueJsonConverter.cs @@ -4,11 +4,16 @@ namespace OpenFeature.Model; -internal sealed class ValueJsonConverter : JsonConverter +/// +/// A for for Json serialization +/// +public sealed class ValueJsonConverter : JsonConverter { + /// public override void Write(Utf8JsonWriter writer, Value value, JsonSerializerOptions options) => WriteJsonValue(value, writer); + /// public override Value Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => ReadJsonValue(ref reader); diff --git a/test/OpenFeature.Tests/StructureTests.cs b/test/OpenFeature.Tests/StructureTests.cs index c7b6b8786..9412e5d33 100644 --- a/test/OpenFeature.Tests/StructureTests.cs +++ b/test/OpenFeature.Tests/StructureTests.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using OpenFeature.Model; namespace OpenFeature.Tests; @@ -125,6 +126,15 @@ public void JsonSerializeTest(Value value, string expectedJson) Assert.True(JsonNode.DeepEquals(expectJsonNode, serializedJsonNode)); } + [Theory] + [MemberData(nameof(JsonSerializeTestData))] + public void JsonSerializeWithGeneratorTest(Value value, string expectedJson) + { + var serializedJsonNode = JsonSerializer.SerializeToNode(value, ValueJsonSerializerContext.Default.Value); + var expectJsonNode = JsonNode.Parse(expectedJson); + Assert.True(JsonNode.DeepEquals(expectJsonNode, serializedJsonNode)); + } + [Theory] [MemberData(nameof(JsonSerializeTestData))] public void JsonDeserializeTest(Value value, string expectedJson) @@ -135,6 +145,17 @@ public void JsonDeserializeTest(Value value, string expectedJson) Assert.True(JsonNode.DeepEquals(expectJsonNode, serializedJsonNode)); } + [Theory] + [MemberData(nameof(JsonSerializeTestData))] + public void JsonDeserializeWithGeneratorTest(Value value, string expectedJson) + { + var serializedJsonNode = JsonSerializer.SerializeToNode(value, ValueJsonSerializerContext.Default.Value); + var expectValue = JsonSerializer.Deserialize(expectedJson, ValueJsonSerializerContext.Default.Value); + Assert.NotNull(expectValue); + var expectJsonNode = JsonSerializer.SerializeToNode(expectValue, ValueJsonSerializerContext.Default.Value); + Assert.True(JsonNode.DeepEquals(expectJsonNode, serializedJsonNode)); + } + public static IEnumerable JsonSerializeTestData() { yield return [new Value("test"), "\"test\""]; @@ -178,3 +199,8 @@ public static IEnumerable JsonSerializeTestData() ]; } } + +[JsonSerializable(typeof(Value))] +public partial class ValueJsonSerializerContext : JsonSerializerContext +{ +} From 417f3fefbafc656f64069b284f741081b1d77113 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Thu, 31 Jul 2025 15:44:14 -0400 Subject: [PATCH 081/126] chore(main): release 2.8.1 (#538) Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ README.md | 4 ++-- build/Common.prod.props | 2 +- version.txt | 2 +- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7a5647237..10d53e3a4 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.8.0" + ".": "2.8.1" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3561fbd74..87051fdfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2.8.1](https://github.com/open-feature/dotnet-sdk/compare/v2.8.0...v2.8.1) (2025-07-31) + + +### 🐛 Bug Fixes + +* expose ValueJsonConverter for generator support and add JsonSourceGenerator test cases ([#537](https://github.com/open-feature/dotnet-sdk/issues/537)) ([e03aeba](https://github.com/open-feature/dotnet-sdk/commit/e03aeba0f515f668afaba0a3c6f0ea01b44d6ee4)) + ## [2.8.0](https://github.com/open-feature/dotnet-sdk/compare/v2.7.0...v2.8.0) (2025-07-30) diff --git a/README.md b/README.md index 511fa4503..8349bc19a 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ [![Specification](https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge)](https://github.com/open-feature/spec/releases/tag/v0.8.0) [ -![Release](https://img.shields.io/static/v1?label=release&message=v2.8.0&color=blue&style=for-the-badge) -](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.8.0) +![Release](https://img.shields.io/static/v1?label=release&message=v2.8.1&color=blue&style=for-the-badge) +](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.8.1) [![Slack](https://img.shields.io/badge/slack-%40cncf%2Fopenfeature-brightgreen?style=flat&logo=slack)](https://cloud-native.slack.com/archives/C0344AANLA1) [![Codecov](https://codecov.io/gh/open-feature/dotnet-sdk/branch/main/graph/badge.svg?token=MONAVJBXUJ)](https://codecov.io/gh/open-feature/dotnet-sdk) diff --git a/build/Common.prod.props b/build/Common.prod.props index 047997257..89451aca7 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -9,7 +9,7 @@ - 2.8.0 + 2.8.1 git https://github.com/open-feature/dotnet-sdk OpenFeature is an open standard for feature flag management, created to support a robust feature flag ecosystem using cloud native technologies. OpenFeature will provide a unified API and SDK, and a developer-first, cloud-native implementation, with extensibility for open source and commercial offerings. diff --git a/version.txt b/version.txt index 834f26295..dbe590065 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.8.0 +2.8.1 From 7237053561d9c36194197169734522f0b978f6e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:09:25 +0100 Subject: [PATCH 082/126] feat: Add multi-provider support (#488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement MultiProvider class with placeholder methods for feature resolution Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add BaseEvaluationStrategy and MultiProvider classes for multi-provider feature flag evaluation Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: implement MultiProvider methods for feature flag resolution using evaluation strategy Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add ComparisonStrategy, FirstMatchStrategy, and FirstSuccessfulStrategy classes for feature flag evaluation Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: implement EvaluateAsync method in FirstMatchStrategy for type-specific feature resolution Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: enhance error handling in FirstMatchStrategy for feature flag resolution Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: implement EvaluateAsync method in FirstSuccessfulStrategy for multi-type feature resolution Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: refactor feature resolution strategies to use EvaluateAsync method for improved multi-provider support Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Removed ComparisonStrategy.cs Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add unit tests for FirstMatchStrategy and FirstSuccessfulStrategy to enhance multi-provider support Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add unit tests for FirstSuccessfulStrategy to validate multi-provider evaluation logic Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add unit tests for MultiProvider and ProviderExtensions to validate multi-provider functionality Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add unit tests for MultiProvider to validate functionality and strategy delegation Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: update GetMetadata method to return non-nullable Metadata type Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: implement ShutdownAsync method to gracefully shut down all providers Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: implement InitializeAsync method to initialize all providers Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Move to Extensions folder Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: add initialization and shutdown tests for MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: enhance ShutdownAsync to handle exceptions from multiple providers Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: implement ComparisonStrategy for evaluating provider values with fallback and mismatch handling Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add constructor to MultiProvider for default evaluation strategy Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: update ComparisonStrategy and MultiProviderTests for improved clarity and consistency Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: rename namespaces from OpenFeature.Extensions.MultiProvider to OpenFeature.Providers.MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Removed old files Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add multi-provider support with evaluation strategies Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Revert "Move to Extensions folder" This reverts commit 9ffd149205199783f0b4cfbb6cbbfcbf7c502960. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: use 'this' keyword for clarity in constructors across multiple models Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: add missing space in ProviderStatus exception handling for consistency Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: enhance ProviderResolutionResult to include exception details in resolution results Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add error collection method and refine ProviderError to use Exception type Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: simplify error handling in FirstMatchStrategy and FirstSuccessfulStrategy Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: add unit tests for FirstSuccessfulStrategy behavior and result determination Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: add unit tests for FirstMatchStrategy behavior and result determination Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: simplify tests Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: add unit tests for ComparisonStrategy RunMode behavior Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: add unit tests for ComparisonStrategy functionality Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: add unit tests for ProviderEntry, ProviderStatus, and RegisteredProvider classes Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: add unit tests for FinalResult and ProviderError classes Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: add unit tests for ProviderExtensions functionality Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: add unit tests for MultiProvider functionality Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: add unit tests for BaseEvaluationStrategy functionality Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: add multi-provider endpoint and evaluation logic Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: update RegisteredProvider to use internal access modifiers and enhance status management; add unit test for SetStatus method Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * docs: add Multi-Provider section to README with usage examples and evaluation strategies Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: change properties in StrategyPerProviderContext to use read-only accessors Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * docs: update summary comments in FirstMatchStrategy and FirstSuccessfulStrategy to clarify provider evaluation order Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Refactor StrategyEvaluationContext to use generic types Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: simplify flag resolution logic in EvaluateAsync method Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: replace hardcoded provider name with constant in MultiProvider strategies Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: Rename Exception property to Error in ProviderStatus class Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Improved the thread safety for Multiprovider. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: Update exception object name in MultiProvider tests Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: Update ObjectDisposedException object name in MultiProvider tests Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: Remove volatile modifier from status fields in RegisteredProvider and MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * docs: Clarify evaluation strategy parameter description in MultiProvider constructor Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: Update shutdown logic to allow shutdown in Ready or Fatal status and add corresponding tests Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: Move fallback provider resolution logic to a more appropriate location in ComparisonStrategy Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: Simplify multi-provider endpoint response and improve error handling Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: Improve dispose pattern handling in MultiProvider to ensure correct async initialization and shutdown Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: Mark _disposed as volatile to ensure thread-safe access in async methods Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: Update MultiProvider to implement IAsyncDisposable and improve dispose pattern handling Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- README.md | 93 +- samples/AspNetCore/Program.cs | 54 ++ .../Models/ChildProviderStatus.cs | 7 + .../MultiProvider/Models/ProviderEntry.cs | 29 + .../Models/RegisteredProvider.cs | 41 + .../Providers/MultiProvider/MultiProvider.cs | 340 +++++++ .../MultiProvider/MultiProviderConstants.cs | 12 + .../MultiProvider/ProviderExtensions.cs | 46 + .../Strategies/BaseEvaluationStrategy.cs | 129 +++ .../Strategies/ComparisonStrategy.cs | 80 ++ .../Strategies/FirstMatchStrategy.cs | 36 + .../Strategies/FirstSuccessfulStrategy.cs | 47 + .../Strategies/Models/FinalResult.cs | 45 + .../Strategies/Models/ProviderError.cs | 29 + .../Models/ProviderResolutionResult.cs | 45 + .../Strategies/Models/RunMode.cs | 17 + .../Models/StrategyEvaluationContext.cs | 22 + .../Models/StrategyPerProviderContext.cs | 40 + .../Models/ChildProviderEntryTests.cs | 93 ++ .../Models/ProviderStatusTests.cs | 123 +++ .../Models/RegisteredProviderTests.cs | 116 +++ .../MultiProvider/MultiProviderTests.cs | 851 ++++++++++++++++++ .../MultiProvider/ProviderExtensionsTests.cs | 334 +++++++ .../Strategies/BaseEvaluationStrategyTests.cs | 500 ++++++++++ .../Strategies/ComparisonStrategyTests.cs | 475 ++++++++++ .../Strategies/FirstMatchStrategyTests.cs | 323 +++++++ .../FirstSuccessfulStrategyTests.cs | 240 +++++ .../Strategies/Models/FinalResultTests.cs | 260 ++++++ .../Strategies/Models/ProviderErrorTests.cs | 146 +++ 29 files changed, 4572 insertions(+), 1 deletion(-) create mode 100644 src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/MultiProvider.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs create mode 100644 src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/FinalResultTests.cs create mode 100644 test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/ProviderErrorTests.cs diff --git a/README.md b/README.md index 8349bc19a..2da256cd8 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,8 @@ Want to contribute a new sample? See our [CONTRIBUTING](CONTRIBUTING.md) guide! | ✅ | [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. | | ✅ | [Transaction Context Propagation](#transaction-context-propagation) | Set a specific [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context) for a transaction (e.g. an HTTP request or a thread). | | ✅ | [Extending](#extending) | Extend OpenFeature with custom providers and hooks. | -| 🔬 | [DependencyInjection](#dependency-injection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. | +| 🔬 | [Multi-Provider](#multi-provider) | Use multiple feature flag providers simultaneously with configurable evaluation strategies. | +| 🔬 | [DependencyInjection](#dependency-injection) | Integrate OpenFeature with .NET's dependency injection for streamlined provider setup. | > Implemented: ✅ | In-progress: ⚠️ | Not implemented yet: ❌ | Experimental: 🔬 @@ -433,6 +434,96 @@ Hooks support passing per-evaluation data between that stages using `hook data`. Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs! +### Multi-Provider + +> [!NOTE] +> The Multi-Provider feature is currently experimental. Hooks and events are not supported at the moment. + +The Multi-Provider enables the use of multiple underlying feature flag providers simultaneously, allowing different providers to be used for different flag keys or based on specific evaluation strategies. + +#### Basic Usage + +```csharp +using OpenFeature.Providers.MultiProvider; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; + +// Create provider entries +var providerEntries = new List +{ + new(new InMemoryProvider(provider1Flags), "Provider1"), + new(new InMemoryProvider(provider2Flags), "Provider2") +}; + +// Create multi-provider with FirstMatchStrategy (default) +var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + +// Set as the default provider +await Api.Instance.SetProviderAsync(multiProvider); + +// Use normally - the multi-provider will handle delegation +var client = Api.Instance.GetClient(); +var flagValue = await client.GetBooleanValueAsync("my-flag", false); +``` + +#### Evaluation Strategies + +The Multi-Provider supports different evaluation strategies that determine how multiple providers are used: + +##### FirstMatchStrategy (Default) + +Evaluates providers sequentially and returns the first result that is not "flag not found". If any provider returns an error, that error is returned immediately. + +```csharp +var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); +``` + +##### FirstSuccessfulStrategy + +Evaluates providers sequentially and returns the first successful result, ignoring errors. Only if all providers fail will errors be returned. + +```csharp +var multiProvider = new MultiProvider(providerEntries, new FirstSuccessfulStrategy()); +``` + +##### ComparisonStrategy + +Evaluates all providers in parallel and compares results. If values agree, returns the agreed value. If they disagree, returns the fallback provider's value (or first provider if no fallback is specified) and optionally calls a mismatch callback. + +```csharp +// Basic comparison +var multiProvider = new MultiProvider(providerEntries, new ComparisonStrategy()); + +// With fallback provider +var multiProvider = new MultiProvider(providerEntries, + new ComparisonStrategy(fallbackProvider: provider1)); + +// With mismatch callback +var multiProvider = new MultiProvider(providerEntries, + new ComparisonStrategy(onMismatch: (mismatchDetails) => { + // Log or handle mismatches between providers + foreach (var kvp in mismatchDetails) + { + Console.WriteLine($"Provider {kvp.Key}: {kvp.Value}"); + } + })); +``` + +#### Evaluation Modes + +The Multi-Provider supports two evaluation modes: + +- **Sequential**: Providers are evaluated one after another (used by `FirstMatchStrategy` and `FirstSuccessfulStrategy`) +- **Parallel**: All providers are evaluated simultaneously (used by `ComparisonStrategy`) + +#### Limitations + +- **Hooks are not supported**: Multi-Provider does not currently support hook registration or execution +- **Events are not supported**: Provider events are not propagated from underlying providers +- **Experimental status**: The API may change in future releases + +For a complete example, see the [AspNetCore sample](./samples/AspNetCore/README.md) which demonstrates Multi-Provider usage. + ### Dependency Injection > [!NOTE] diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index e8faf5a5e..90d1888c6 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -6,6 +6,9 @@ using OpenFeature.Hooks; using OpenFeature.Model; using OpenFeature.Providers.Memory; +using OpenFeature.Providers.MultiProvider; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -75,6 +78,7 @@ return TypedResults.Ok("Hello world!"); }); + app.MapGet("/test-config", async ([FromServices] IFeatureClient featureClient) => { var testConfigValue = await featureClient.GetObjectValueAsync("test-config", @@ -85,6 +89,56 @@ return Results.Ok(config); }); +app.MapGet("/multi-provider", async () => +{ + // Create first in-memory provider with some flags + var provider1Flags = new Dictionary + { + { "providername", new Flag(new Dictionary { { "enabled", "enabled-provider1" }, { "disabled", "disabled-provider1" } }, "enabled") }, + { "max-items", new Flag(new Dictionary { { "low", 10 }, { "high", 100 } }, "high") }, + }; + var provider1 = new InMemoryProvider(provider1Flags); + + // Create second in-memory provider with different flags + var provider2Flags = new Dictionary + { + { "providername", new Flag(new Dictionary { { "enabled", "enabled-provider2" }, { "disabled", "disabled-provider2" } }, "enabled") }, + }; + var provider2 = new InMemoryProvider(provider2Flags); + + // Create provider entries + var providerEntries = new List + { + new(provider1, "Provider1"), + new(provider2, "Provider2") + }; + + // Create multi-provider with FirstMatchStrategy (default) + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + + // Set the multi-provider as the default provider using OpenFeature API + await Api.Instance.SetProviderAsync(multiProvider); + + // Create a client directly using the API + var client = Api.Instance.GetClient(); + + try + { + // Test flag evaluation from different providers + var maxItemsFlag = await client.GetIntegerDetailsAsync("max-items", 0); + var providerNameFlag = await client.GetStringDetailsAsync("providername", "default"); + + // Test a flag that doesn't exist in any provider + var unknownFlag = await client.GetBooleanDetailsAsync("unknown-flag", false); + + return Results.Ok(); + } + catch (Exception) + { + return Results.InternalServerError(); + } +}); + app.Run(); diff --git a/src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs b/src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs new file mode 100644 index 000000000..f66f8fae7 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs @@ -0,0 +1,7 @@ +namespace OpenFeature.Providers.MultiProvider.Models; + +internal class ChildProviderStatus +{ + public string ProviderName { get; set; } = string.Empty; + public Exception? Error { get; set; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs b/src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs new file mode 100644 index 000000000..da720da6c --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs @@ -0,0 +1,29 @@ +namespace OpenFeature.Providers.MultiProvider.Models; + +/// +/// Represents an entry for a provider in the multi-provider configuration. +/// +public class ProviderEntry +{ + /// + /// Initializes a new instance of the class. + /// + /// The feature provider instance. + /// Optional custom name for the provider. If not provided, the provider's metadata name will be used. + public ProviderEntry(FeatureProvider provider, string? name = null) + { + this.Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + this.Name = name; + } + + /// + /// Gets the feature provider instance. + /// + public FeatureProvider Provider { get; } + + /// + /// Gets the optional custom name for the provider. + /// If null, the provider's metadata name should be used. + /// + public string? Name { get; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs new file mode 100644 index 000000000..ee62fd006 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs @@ -0,0 +1,41 @@ +namespace OpenFeature.Providers.MultiProvider.Models; + +internal class RegisteredProvider +{ +#if NET9_0_OR_GREATER + private readonly Lock _statusLock = new(); +#else + private readonly object _statusLock = new object(); +#endif + + private Constant.ProviderStatus _status = Constant.ProviderStatus.NotReady; + + internal RegisteredProvider(FeatureProvider provider, string name) + { + this.Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + this.Name = name ?? throw new ArgumentNullException(nameof(name)); + } + + internal FeatureProvider Provider { get; } + + internal string Name { get; } + + internal Constant.ProviderStatus Status + { + get + { + lock (this._statusLock) + { + return this._status; + } + } + } + + internal void SetStatus(Constant.ProviderStatus status) + { + lock (this._statusLock) + { + this._status = status; + } + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs new file mode 100644 index 000000000..73ce72eba --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs @@ -0,0 +1,340 @@ +using System.Collections.ObjectModel; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Providers.MultiProvider; + +/// +/// A feature provider that enables the use of multiple underlying providers, allowing different providers +/// to be used for different flag keys or based on specific routing logic. +/// +/// +/// The MultiProvider acts as a composite provider that can delegate flag resolution to different +/// underlying providers based on configuration or routing rules. This enables scenarios where +/// different feature flags may be served by different sources or providers within the same application. +/// +/// Multi Provider specification +public sealed class MultiProvider : FeatureProvider, IAsyncDisposable +{ + private readonly BaseEvaluationStrategy _evaluationStrategy; + private readonly IReadOnlyList _registeredProviders; + private readonly Metadata _metadata; + + private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); + private readonly SemaphoreSlim _shutdownSemaphore = new(1, 1); + private ProviderStatus _providerStatus = ProviderStatus.NotReady; + // 0 = Not disposed, 1 = Disposed + // This is to handle the dispose pattern correctly with the async initialization and shutdown methods + private volatile int _disposed = 0; + + /// + /// Initializes a new instance of the class with the specified provider entries and evaluation strategy. + /// + /// A collection of provider entries containing the feature providers and their optional names. + /// The base evaluation strategy to use for determining how to evaluate features across multiple providers. If not specified, the first matching strategy will be used. + public MultiProvider(IEnumerable providerEntries, BaseEvaluationStrategy? evaluationStrategy = null) + { + if (providerEntries == null) + { + throw new ArgumentNullException(nameof(providerEntries)); + } + + var entries = providerEntries.ToList(); + if (entries.Count == 0) + { + throw new ArgumentException("At least one provider entry must be provided.", nameof(providerEntries)); + } + + this._evaluationStrategy = evaluationStrategy ?? new FirstMatchStrategy(); + this._registeredProviders = RegisterProviders(entries); + + // Create aggregate metadata + this._metadata = new Metadata(MultiProviderConstants.ProviderName); + } + + /// + public override Metadata GetMetadata() => this._metadata; + + /// + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + /// + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + /// + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + /// + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + /// + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => + this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); + + + /// + public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) + { + if (this._disposed == 1) + { + throw new ObjectDisposedException(nameof(MultiProvider)); + } + + await this._initializationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (this._providerStatus != ProviderStatus.NotReady || this._disposed == 1) + { + return; + } + + var initializationTasks = this._registeredProviders.Select(async rp => + { + try + { + await rp.Provider.InitializeAsync(context, cancellationToken).ConfigureAwait(false); + rp.SetStatus(ProviderStatus.Ready); + return new ChildProviderStatus { ProviderName = rp.Name }; + } + catch (Exception ex) + { + rp.SetStatus(ProviderStatus.Fatal); + return new ChildProviderStatus { ProviderName = rp.Name, Error = ex }; + } + }); + + var results = await Task.WhenAll(initializationTasks).ConfigureAwait(false); + var failures = results.Where(r => r.Error != null).ToList(); + + if (failures.Count != 0) + { + var exceptions = failures.Select(f => f.Error!).ToList(); + var failedProviders = failures.Select(f => f.ProviderName).ToList(); + this._providerStatus = ProviderStatus.Fatal; + throw new AggregateException( + $"Failed to initialize providers: {string.Join(", ", failedProviders)}", + exceptions); + } + else + { + this._providerStatus = ProviderStatus.Ready; + } + } + finally + { + this._initializationSemaphore.Release(); + } + } + + /// + public override async Task ShutdownAsync(CancellationToken cancellationToken = default) + { + if (this._disposed == 1) + { + throw new ObjectDisposedException(nameof(MultiProvider)); + } + + await this.InternalShutdownAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task> EvaluateAsync(string key, T defaultValue, EvaluationContext? evaluationContext = null, CancellationToken cancellationToken = default) + { + // Check if the provider has been disposed + // This is to handle the dispose pattern correctly with the async initialization and shutdown methods + // It is checked here to avoid the check in every public EvaluateAsync method + if (this._disposed == 1) + { + throw new ObjectDisposedException(nameof(MultiProvider)); + } + + var strategyContext = new StrategyEvaluationContext(key); + var resolutions = this._evaluationStrategy.RunMode switch + { + RunMode.Parallel => await this.ParallelEvaluationAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + RunMode.Sequential => await this.SequentialEvaluationAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + _ => throw new NotSupportedException($"Unsupported run mode: {this._evaluationStrategy.RunMode}") + }; + + var finalResult = this._evaluationStrategy.DetermineFinalResult(strategyContext, key, defaultValue, evaluationContext, resolutions); + return finalResult.Details; + } + + private async Task>> SequentialEvaluationAsync(string key, T defaultValue, EvaluationContext? evaluationContext, CancellationToken cancellationToken) + { + var resolutions = new List>(); + + foreach (var registeredProvider in this._registeredProviders) + { + var providerContext = new StrategyPerProviderContext( + registeredProvider.Provider, + registeredProvider.Name, + registeredProvider.Status, + key); + + if (!this._evaluationStrategy.ShouldEvaluateThisProvider(providerContext, evaluationContext)) + { + continue; + } + + var result = await registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, cancellationToken).ConfigureAwait(false); + resolutions.Add(result); + + if (!this._evaluationStrategy.ShouldEvaluateNextProvider(providerContext, evaluationContext, result)) + { + break; + } + } + + return resolutions; + } + + private async Task>> ParallelEvaluationAsync(string key, T defaultValue, EvaluationContext? evaluationContext, CancellationToken cancellationToken) + { + var resolutions = new List>(); + var tasks = new List>>(); + + foreach (var registeredProvider in this._registeredProviders) + { + var providerContext = new StrategyPerProviderContext( + registeredProvider.Provider, + registeredProvider.Name, + registeredProvider.Status, + key); + + if (this._evaluationStrategy.ShouldEvaluateThisProvider(providerContext, evaluationContext)) + { + tasks.Add(registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, cancellationToken)); + } + } + + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + resolutions.AddRange(results); + + return resolutions; + } + + private static ReadOnlyCollection RegisterProviders(IEnumerable providerEntries) + { + var entries = providerEntries.ToList(); + var registeredProviders = new List(); + var nameGroups = entries.GroupBy(e => e.Name ?? e.Provider.GetMetadata()?.Name ?? "UnknownProvider").ToList(); + + // Check for duplicate explicit names + var duplicateExplicitNames = nameGroups + .FirstOrDefault(g => g.Count(e => e.Name != null) > 1)?.Key; + + if (duplicateExplicitNames != null) + { + throw new ArgumentException($"Multiple providers cannot have the same explicit name: '{duplicateExplicitNames}'"); + } + + // Assign unique names + foreach (var group in nameGroups) + { + var baseName = group.Key; + var groupEntries = group.ToList(); + + if (groupEntries.Count == 1) + { + var entry = groupEntries[0]; + registeredProviders.Add(new RegisteredProvider(entry.Provider, entry.Name ?? baseName)); + } + else + { + // Multiple providers with same metadata name - add indices + var index = 1; + foreach (var entry in groupEntries) + { + var finalName = entry.Name ?? $"{baseName}-{index++}"; + registeredProviders.Add(new RegisteredProvider(entry.Provider, finalName)); + } + } + } + + return registeredProviders.AsReadOnly(); + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref this._disposed, 1) == 1) + { + // Already disposed + return; + } + + try + { + await this.InternalShutdownAsync(CancellationToken.None).ConfigureAwait(false); + } + finally + { + this._initializationSemaphore.Dispose(); + this._shutdownSemaphore.Dispose(); + } + } + + private async Task InternalShutdownAsync(CancellationToken cancellationToken) + { + await this._shutdownSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // We should be able to shutdown the provider when it is in Ready or Fatal status. + if ((this._providerStatus != ProviderStatus.Ready && this._providerStatus != ProviderStatus.Fatal) || this._disposed == 1) + { + return; + } + + var shutdownTasks = this._registeredProviders.Select(async rp => + { + try + { + await rp.Provider.ShutdownAsync(cancellationToken).ConfigureAwait(false); + rp.SetStatus(ProviderStatus.NotReady); + return new ChildProviderStatus { ProviderName = rp.Name }; + } + catch (Exception ex) + { + rp.SetStatus(ProviderStatus.Fatal); + return new ChildProviderStatus { ProviderName = rp.Name, Error = ex }; + } + }); + + var results = await Task.WhenAll(shutdownTasks).ConfigureAwait(false); + var failures = results.Where(r => r.Error != null).ToList(); + + if (failures.Count != 0) + { + var exceptions = failures.Select(f => f.Error!).ToList(); + var failedProviders = failures.Select(f => f.ProviderName).ToList(); + throw new AggregateException( + $"Failed to shutdown providers: {string.Join(", ", failedProviders)}", + exceptions); + } + + this._providerStatus = ProviderStatus.NotReady; + } + finally + { + this._shutdownSemaphore.Release(); + } + } + + /// + /// This should only be used for testing purposes. + /// + /// The status to set. + internal void SetStatus(ProviderStatus providerStatus) + { + this._providerStatus = providerStatus; + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs b/src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs new file mode 100644 index 000000000..76df24448 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs @@ -0,0 +1,12 @@ +namespace OpenFeature.Providers.MultiProvider; + +/// +/// Constants used by the MultiProvider. +/// +internal static class MultiProviderConstants +{ + /// + /// The provider name for MultiProvider. + /// + public const string ProviderName = "MultiProvider"; +} diff --git a/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs new file mode 100644 index 000000000..d8f70dfbf --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs @@ -0,0 +1,46 @@ +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Providers.MultiProvider; + +internal static class ProviderExtensions +{ + internal static async Task> EvaluateAsync( + this FeatureProvider provider, + StrategyPerProviderContext providerContext, + EvaluationContext? evaluationContext, + T defaultValue, + CancellationToken cancellationToken) + { + var key = providerContext.FlagKey; + + try + { + var result = defaultValue switch + { + bool boolDefaultValue => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, boolDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + string stringDefaultValue => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, stringDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + int intDefaultValue => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, intDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + double doubleDefaultValue => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, doubleDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + Value valueDefaultValue => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, valueDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), + null when typeof(T) == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + null when typeof(T) == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + _ => throw new ArgumentException($"Unsupported flag type: {typeof(T)}") + }; + return new ProviderResolutionResult(provider, providerContext.ProviderName, result); + } + catch (Exception ex) + { + // Create an error result + var errorResult = new ResolutionDetails( + key, + defaultValue, + ErrorType.General, + Reason.Error, + errorMessage: ex.Message); + + return new ProviderResolutionResult(provider, providerContext.ProviderName, errorResult, ex); + } + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs new file mode 100644 index 000000000..f31b2c4ab --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs @@ -0,0 +1,129 @@ +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Providers.MultiProvider.Strategies; + +/// +/// Provides a base class for implementing evaluation strategies that determine how feature flags are evaluated across multiple feature providers. +/// +/// +/// This abstract class serves as the foundation for creating custom evaluation strategies that can handle feature flag resolution +/// across multiple providers. Implementations define the specific logic for how providers are selected, prioritized, or combined +/// when evaluating feature flags. +/// +public abstract class BaseEvaluationStrategy +{ + /// + /// Determines whether providers should be evaluated in parallel or sequentially. + /// + public virtual RunMode RunMode => RunMode.Sequential; + + /// + /// Determines whether a specific provider should be evaluated. + /// + /// The type of the flag value. + /// Context information about the provider and evaluation. + /// The evaluation context for the flag resolution. + /// True if the provider should be evaluated, false otherwise. + public virtual bool ShouldEvaluateThisProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext) + { + // Skip providers that are not ready or have fatal errors + return strategyContext.ProviderStatus is not (ProviderStatus.NotReady or ProviderStatus.Fatal); + } + + /// + /// Determines whether the next provider should be evaluated after the current one. + /// This method is only called in sequential mode. + /// + /// The type of the flag value. + /// Context information about the provider and evaluation. + /// The evaluation context for the flag resolution. + /// The result from the current provider evaluation. + /// True if the next provider should be evaluated, false otherwise. + public virtual bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result) + { + return true; + } + + /// + /// Determines the final result from all provider evaluation results. + /// + /// The type of the flag value. + /// Context information about the evaluation. + /// The feature flag key to evaluate. + /// The default value to return if evaluation fails or the flag is not found. + /// The evaluation context for the flag resolution. + /// All resolution results from provider evaluations. + /// The final evaluation result. + public abstract FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions); + + /// + /// Checks if a resolution result represents an error. + /// + /// The type of the resolved value. + /// The resolution result to check. + /// True if the result represents an error, false otherwise. + protected static bool HasError(ProviderResolutionResult resolution) + { + return resolution.ThrownError is not null || resolution.ResolutionDetails switch + { + { } success => success.ErrorType != ErrorType.None, + _ => false + }; + } + + /// + /// Collects errors from provider resolution results. + /// + /// The type of the flag value. + /// The provider resolution results to collect errors from. + /// A list of provider errors. + protected static List CollectProviderErrors(List> resolutions) + { + var errors = new List(); + + foreach (var resolution in resolutions) + { + if (resolution.ThrownError is not null) + { + errors.Add(new ProviderError(resolution.ProviderName, resolution.ThrownError)); + } + else if (resolution.ResolutionDetails?.ErrorType != ErrorType.None) + { + var errorMessage = resolution.ResolutionDetails?.ErrorMessage ?? "unknown error"; + var error = new Exception(errorMessage); // Adjust based on your ErrorWithCode implementation + errors.Add(new ProviderError(resolution.ProviderName, error)); + } + } + + return errors; + } + + /// + /// Checks if a resolution result has a specific error code. + /// + /// The type of the resolved value. + /// The resolution result to check. + /// The error type to check for. + /// True if the result has the specified error type, false otherwise. + protected static bool HasErrorWithCode(ProviderResolutionResult resolution, ErrorType errorType) + { + return resolution.ResolutionDetails switch + { + { } success => success.ErrorType == errorType, + _ => false + }; + } + + /// + /// Converts a resolution result to a final result. + /// + /// The type of the resolved value. + /// The resolution result to convert. + /// The converted final result. + protected static FinalResult ToFinalResult(ProviderResolutionResult resolution) + { + return new FinalResult(resolution.ResolutionDetails, resolution.Provider, resolution.ProviderName, null); + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs new file mode 100644 index 000000000..b004b6d32 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs @@ -0,0 +1,80 @@ +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Providers.MultiProvider.Strategies; + +/// +/// Evaluate all providers in parallel and compare the results. +/// If the values agree, return the value. +/// If the values disagree, return the value from the configured "fallback provider" and execute the "onMismatch" +/// callback if defined. +/// +public sealed class ComparisonStrategy : BaseEvaluationStrategy +{ + private readonly FeatureProvider? _fallbackProvider; + private readonly Action>? _onMismatch; + + /// + public override RunMode RunMode => RunMode.Parallel; + + /// + /// Initializes a new instance of the class. + /// + /// The provider to use as fallback when values don't match. + /// Optional callback that is called when providers return different values. + public ComparisonStrategy(FeatureProvider? fallbackProvider = null, Action>? onMismatch = null) + { + this._fallbackProvider = fallbackProvider; + this._onMismatch = onMismatch; + } + + /// + public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) + { + var successfulResolutions = resolutions.Where(r => !HasError(r)).ToList(); + + if (successfulResolutions.Count == 0) + { + var errorDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed"); + var errors = resolutions.Select(r => new ProviderError(r.ProviderName, new InvalidOperationException($"Provider {r.ProviderName} failed"))).ToList(); + return new FinalResult(errorDetails, null!, MultiProviderConstants.ProviderName, errors); + } + + var firstResult = successfulResolutions.First(); + + // Check if all successful results agree on the value + var allAgree = successfulResolutions.All(r => EqualityComparer.Default.Equals(r.ResolutionDetails.Value, firstResult.ResolutionDetails.Value)); + + if (allAgree) + { + return ToFinalResult(firstResult); + } + + ProviderResolutionResult? fallbackResolution = null; + + // Find fallback provider if specified + if (this._fallbackProvider != null) + { + fallbackResolution = successfulResolutions.FirstOrDefault(r => ReferenceEquals(r.Provider, this._fallbackProvider)); + } + + // Values don't agree, trigger mismatch callback if provided + if (this._onMismatch != null) + { + // Create a dictionary with provider names and their values for the callback + var mismatchDetails = successfulResolutions.ToDictionary( + r => r.ProviderName, + r => (object)r.ResolutionDetails.Value! + ); + this._onMismatch(mismatchDetails); + } + + // Return fallback provider result if available + return fallbackResolution != null + ? ToFinalResult(fallbackResolution) + : + // Default to first provider's result + ToFinalResult(firstResult); + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs new file mode 100644 index 000000000..88eba5509 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs @@ -0,0 +1,36 @@ +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Providers.MultiProvider.Strategies; + +/// +/// Return the first result that did not indicate "flag not found". +/// Providers are evaluated sequentially in the order they were configured. +/// If any provider in the course of evaluation returns or throws an error, throw that error +/// +public sealed class FirstMatchStrategy : BaseEvaluationStrategy +{ + /// + public override bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result) + { + return HasErrorWithCode(result, ErrorType.FlagNotFound); + } + + /// + public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) + { + var lastResult = resolutions.LastOrDefault(); + if (lastResult != null) + { + return ToFinalResult(lastResult); + } + + var errorDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed"); + var errors = new List + { + new(MultiProviderConstants.ProviderName, new InvalidOperationException("No providers available or all providers failed")) + }; + return new FinalResult(errorDetails, null!, MultiProviderConstants.ProviderName, errors); + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs new file mode 100644 index 000000000..7caef6a51 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs @@ -0,0 +1,47 @@ +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Providers.MultiProvider.Strategies; + +/// +/// Return the first result that did not result in an error. +/// Providers are evaluated sequentially in the order they were configured. +/// If any provider in the course of evaluation returns or throws an error, ignore it as long as there is a successful result. +/// If there is no successful result, throw all errors. +/// +public sealed class FirstSuccessfulStrategy : BaseEvaluationStrategy +{ + /// + public override bool ShouldEvaluateNextProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, ProviderResolutionResult result) + { + // evaluate next only if there was an error + return HasError(result); + } + + /// + public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) + { + if (resolutions.Count == 0) + { + var noProvidersDetails = new ResolutionDetails(key, defaultValue, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "No providers available or all providers failed"); + var noProvidersErrors = new List + { + new(MultiProviderConstants.ProviderName, new InvalidOperationException("No providers available or all providers failed")) + }; + return new FinalResult(noProvidersDetails, null!, MultiProviderConstants.ProviderName, noProvidersErrors); + } + + // Find the first successful result + var successfulResult = resolutions.FirstOrDefault(r => !HasError(r)); + if (successfulResult != null) + { + return ToFinalResult(successfulResult); + } + + // All results had errors - collect them and throw + var collectedErrors = CollectProviderErrors(resolutions); + var allFailedDetails = new ResolutionDetails(key, defaultValue, ErrorType.General, Reason.Error, errorMessage: "All providers failed"); + return new FinalResult(allFailedDetails, null!, "MultiProvider", collectedErrors); + } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs new file mode 100644 index 000000000..0bcc0bd7d --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs @@ -0,0 +1,45 @@ +using OpenFeature.Model; + +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Represents the final result of a feature flag resolution operation from a multi-provider strategy. +/// Contains the resolved details, the provider that successfully resolved the flag, and any errors encountered during the resolution process. +/// +public class FinalResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The resolution details containing the resolved value and associated metadata. + /// The provider that successfully resolved the feature flag. + /// The name of the provider that successfully resolved the feature flag. + /// The list of errors encountered during the resolution process. + public FinalResult(ResolutionDetails details, FeatureProvider provider, string providerName, List? errors) + { + this.Details = details; + this.Provider = provider; + this.ProviderName = providerName; + this.Errors = errors ?? []; + } + + /// + /// Gets or sets the resolution details containing the resolved value and associated metadata. + /// + public ResolutionDetails Details { get; private set; } + + /// + /// Gets or sets the provider that successfully resolved the feature flag. + /// + public FeatureProvider Provider { get; private set; } + + /// + /// Gets or sets the name of the provider that successfully resolved the feature flag. + /// + public string ProviderName { get; private set; } + + /// + /// Gets or sets the list of errors encountered during the resolution process. + /// + public List Errors { get; private set; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs new file mode 100644 index 000000000..52204ce5a --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs @@ -0,0 +1,29 @@ +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Represents an error encountered during the resolution process. +/// Contains the name of the provider that encountered the error and the error details. +/// +public class ProviderError +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the provider that encountered the error. + /// The error details. + public ProviderError(string providerName, Exception? error) + { + this.ProviderName = providerName; + this.Error = error; + } + + /// + /// Gets or sets the name of the provider that encountered the error. + /// + public string ProviderName { get; private set; } + + /// + /// Gets or sets the error details. + /// + public Exception? Error { get; private set; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs new file mode 100644 index 000000000..20eddbe44 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs @@ -0,0 +1,45 @@ +using OpenFeature.Model; + +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Base class for provider resolution results. +/// +public class ProviderResolutionResult +{ + /// + /// Initializes a new instance of the class + /// with the specified provider and resolution details. + /// + /// The feature provider that produced this result. + /// The name of the provider that produced this result. + /// The resolution details. + /// The exception that occurred during resolution, if any. + public ProviderResolutionResult(FeatureProvider provider, string providerName, ResolutionDetails resolutionDetails, Exception? thrownError = null) + { + this.Provider = provider; + this.ProviderName = providerName; + this.ResolutionDetails = resolutionDetails; + this.ThrownError = thrownError; + } + + /// + /// The feature provider that produced this result. + /// + public FeatureProvider Provider { get; private set; } + + /// + /// The resolution details. + /// + public ResolutionDetails ResolutionDetails { get; private set; } + + /// + /// The name of the provider that produced this result. + /// + public string ProviderName { get; private set; } + + /// + /// The exception that occurred during resolution, if any. + /// + public Exception? ThrownError { get; private set; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs new file mode 100644 index 000000000..754cb5a9e --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs @@ -0,0 +1,17 @@ +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Specifies how providers should be evaluated. +/// +public enum RunMode +{ + /// + /// Providers are evaluated one after another in sequence. + /// + Sequential, + + /// + /// Providers are evaluated concurrently in parallel. + /// + Parallel +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs new file mode 100644 index 000000000..215c85e4d --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs @@ -0,0 +1,22 @@ +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Evaluation context specific to strategy evaluation containing flag-related information. +/// +/// The type of the flag value being evaluated. +public class StrategyEvaluationContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The feature flag key being evaluated. + public StrategyEvaluationContext(string flagKey) + { + this.FlagKey = flagKey; + } + + /// + /// The feature flag key being evaluated. + /// + public string FlagKey { get; private set; } +} diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs new file mode 100644 index 000000000..4abc434a3 --- /dev/null +++ b/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs @@ -0,0 +1,40 @@ +using OpenFeature.Constant; + +namespace OpenFeature.Providers.MultiProvider.Strategies.Models; + +/// +/// Per-provider context containing provider-specific information for strategy evaluation. +/// +/// The type of the flag value being evaluated. +public class StrategyPerProviderContext : StrategyEvaluationContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The feature provider instance. + /// The name/identifier of the provider. + /// The current status of the provider. + /// The feature flag key being evaluated. + public StrategyPerProviderContext(FeatureProvider provider, string providerName, ProviderStatus providerStatus, string key) + : base(key) + { + this.Provider = provider; + this.ProviderName = providerName; + this.ProviderStatus = providerStatus; + } + + /// + /// The feature provider instance. + /// + public FeatureProvider Provider { get; } + + /// + /// The name/identifier of the provider. + /// + public string ProviderName { get; } + + /// + /// The current status of the provider. + /// + public ProviderStatus ProviderStatus { get; } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs new file mode 100644 index 000000000..69bb62322 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs @@ -0,0 +1,93 @@ +using NSubstitute; +using OpenFeature.Providers.MultiProvider.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Models; + +public class ChildProviderEntryTests +{ + private readonly FeatureProvider _mockProvider = Substitute.For(); + + [Fact] + public void Constructor_WithProvider_CreatesProviderEntry() + { + // Act + var providerEntry = new ProviderEntry(this._mockProvider); + + // Assert + Assert.Equal(this._mockProvider, providerEntry.Provider); + Assert.Null(providerEntry.Name); + } + + [Fact] + public void Constructor_WithProviderAndName_CreatesProviderEntry() + { + // Arrange + const string customName = "custom-provider-name"; + + // Act + var providerEntry = new ProviderEntry(this._mockProvider, customName); + + // Assert + Assert.Equal(this._mockProvider, providerEntry.Provider); + Assert.Equal(customName, providerEntry.Name); + } + + [Fact] + public void Constructor_WithNullProvider_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => new ProviderEntry(null!)); + Assert.Equal("provider", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullName_CreatesProviderEntryWithNullName() + { + // Act + var providerEntry = new ProviderEntry(this._mockProvider, null); + + // Assert + Assert.Equal(this._mockProvider, providerEntry.Provider); + Assert.Null(providerEntry.Name); + } + + [Fact] + public void Constructor_WithEmptyName_CreatesProviderEntryWithEmptyName() + { + // Act + var providerEntry = new ProviderEntry(this._mockProvider, string.Empty); + + // Assert + Assert.Equal(this._mockProvider, providerEntry.Provider); + Assert.Equal(string.Empty, providerEntry.Name); + } + + [Fact] + public void Provider_Property_IsReadOnly() + { + // Arrange + var providerEntry = new ProviderEntry(this._mockProvider); + + // Act & Assert + // Verify that Provider property is read-only by checking it has no setter + var providerProperty = typeof(ProviderEntry).GetProperty(nameof(ProviderEntry.Provider)); + Assert.NotNull(providerProperty); + Assert.True(providerProperty.CanRead); + Assert.False(providerProperty.CanWrite); + } + + [Fact] + public void Name_Property_IsReadOnly() + { + // Arrange + const string customName = "test-name"; + var providerEntry = new ProviderEntry(this._mockProvider, customName); + + // Act & Assert + // Verify that Name property is read-only by checking it has no setter + var nameProperty = typeof(ProviderEntry).GetProperty(nameof(ProviderEntry.Name)); + Assert.NotNull(nameProperty); + Assert.True(nameProperty.CanRead); + Assert.False(nameProperty.CanWrite); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs new file mode 100644 index 000000000..ad3990aaa --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs @@ -0,0 +1,123 @@ +using OpenFeature.Providers.MultiProvider.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Models; + +public class ProviderStatusTests +{ + [Fact] + public void Constructor_CreatesProviderStatusWithDefaultValues() + { + // Act + var providerStatus = new ChildProviderStatus(); + + // Assert + Assert.Equal(string.Empty, providerStatus.ProviderName); + Assert.Null(providerStatus.Error); + } + + [Fact] + public void ProviderName_CanBeSet() + { + // Arrange + const string providerName = "test-provider"; + var providerStatus = new ChildProviderStatus(); + + // Act + providerStatus.ProviderName = providerName; + + // Assert + Assert.Equal(providerName, providerStatus.ProviderName); + } + + [Fact] + public void ProviderName_CanBeSetToNull() + { + // Arrange + var providerStatus = new ChildProviderStatus { ProviderName = "initial-name" }; + + // Act + providerStatus.ProviderName = null!; + + // Assert + Assert.Null(providerStatus.ProviderName); + } + + [Fact] + public void ProviderName_CanBeSetToEmptyString() + { + // Arrange + var providerStatus = new ChildProviderStatus { ProviderName = "initial-name" }; + + // Act + providerStatus.ProviderName = string.Empty; + + // Assert + Assert.Equal(string.Empty, providerStatus.ProviderName); + } + + [Fact] + public void Exception_CanBeSet() + { + // Arrange + var exception = new InvalidOperationException("Test exception"); + var providerStatus = new ChildProviderStatus(); + + // Act + providerStatus.Error = exception; + + // Assert + Assert.Equal(exception, providerStatus.Error); + } + + [Fact] + public void Exception_CanBeSetToNull() + { + // Arrange + var providerStatus = new ChildProviderStatus { Error = new Exception("initial exception") }; + + // Act + providerStatus.Error = null; + + // Assert + Assert.Null(providerStatus.Error); + } + + [Fact] + public void ProviderStatus_CanBeInitializedWithObjectInitializer() + { + // Arrange + const string providerName = "test-provider"; + var exception = new ArgumentException("Test exception"); + + // Act + var providerStatus = new ChildProviderStatus + { + ProviderName = providerName, + Error = exception + }; + + // Assert + Assert.Equal(providerName, providerStatus.ProviderName); + Assert.Equal(exception, providerStatus.Error); + } + + [Fact] + public void ProviderName_Property_HasGetterAndSetter() + { + // Act & Assert + var providerNameProperty = typeof(ChildProviderStatus).GetProperty(nameof(ChildProviderStatus.ProviderName)); + Assert.NotNull(providerNameProperty); + Assert.True(providerNameProperty.CanRead); + Assert.True(providerNameProperty.CanWrite); + } + + [Fact] + public void Exception_Property_HasGetterAndSetter() + { + // Act & Assert + var exceptionProperty = typeof(ChildProviderStatus).GetProperty(nameof(ChildProviderStatus.Error)); + Assert.NotNull(exceptionProperty); + Assert.True(exceptionProperty.CanRead); + Assert.True(exceptionProperty.CanWrite); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs new file mode 100644 index 000000000..8734775a7 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs @@ -0,0 +1,116 @@ +using NSubstitute; +using OpenFeature.Providers.MultiProvider.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Models; + +public class RegisteredProviderTests +{ + private readonly FeatureProvider _mockProvider = Substitute.For(); + private const string TestProviderName = "test-provider"; + + [Fact] + public void Constructor_WithValidParameters_CreatesRegisteredProvider() + { + // Act + var registeredProvider = new RegisteredProvider(this._mockProvider, TestProviderName); + + // Assert + Assert.Equal(this._mockProvider, registeredProvider.Provider); + Assert.Equal(TestProviderName, registeredProvider.Name); + } + + [Fact] + public void Constructor_WithNullProvider_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => new RegisteredProvider(null!, TestProviderName)); + Assert.Equal("provider", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullName_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => new RegisteredProvider(this._mockProvider, null!)); + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public void Constructor_WithEmptyName_CreatesRegisteredProviderWithEmptyName() + { + // Act + var registeredProvider = new RegisteredProvider(this._mockProvider, string.Empty); + + // Assert + Assert.Equal(this._mockProvider, registeredProvider.Provider); + Assert.Equal(string.Empty, registeredProvider.Name); + } + + [Fact] + public void Constructor_WithWhitespaceName_CreatesRegisteredProviderWithWhitespaceName() + { + // Arrange + const string whitespaceName = " "; + + // Act + var registeredProvider = new RegisteredProvider(this._mockProvider, whitespaceName); + + // Assert + Assert.Equal(this._mockProvider, registeredProvider.Provider); + Assert.Equal(whitespaceName, registeredProvider.Name); + } + + [Fact] + public void Constructor_WithSameProviderAndDifferentNames_CreatesDistinctInstances() + { + // Arrange + const string name1 = "provider-1"; + const string name2 = "provider-2"; + + // Act + var registeredProvider1 = new RegisteredProvider(this._mockProvider, name1); + var registeredProvider2 = new RegisteredProvider(this._mockProvider, name2); + + // Assert + Assert.Equal(this._mockProvider, registeredProvider1.Provider); + Assert.Equal(this._mockProvider, registeredProvider2.Provider); + Assert.Equal(name1, registeredProvider1.Name); + Assert.Equal(name2, registeredProvider2.Name); + Assert.NotEqual(registeredProvider1.Name, registeredProvider2.Name); + } + + [Fact] + public void Constructor_WithDifferentProvidersAndSameName_CreatesDistinctInstances() + { + // Arrange + var mockProvider2 = Substitute.For(); + + // Act + var registeredProvider1 = new RegisteredProvider(this._mockProvider, TestProviderName); + var registeredProvider2 = new RegisteredProvider(mockProvider2, TestProviderName); + + // Assert + Assert.Equal(this._mockProvider, registeredProvider1.Provider); + Assert.Equal(mockProvider2, registeredProvider2.Provider); + Assert.Equal(TestProviderName, registeredProvider1.Name); + Assert.Equal(TestProviderName, registeredProvider2.Name); + Assert.NotEqual(registeredProvider1.Provider, registeredProvider2.Provider); + } + + [Theory] + [InlineData(Constant.ProviderStatus.Ready)] + [InlineData(Constant.ProviderStatus.Error)] + [InlineData(Constant.ProviderStatus.Fatal)] + [InlineData(Constant.ProviderStatus.NotReady)] + public void SetStatus_WithDifferentStatuses_UpdatesCorrectly(Constant.ProviderStatus status) + { + // Arrange + var registeredProvider = new RegisteredProvider(new TestProvider(), "test"); + + // Act + registeredProvider.SetStatus(status); + + // Assert + Assert.Equal(status, registeredProvider.Status); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs new file mode 100644 index 000000000..bf1dfb4e6 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs @@ -0,0 +1,851 @@ +using System.Reflection; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; +using MultiProviderImplementation = OpenFeature.Providers.MultiProvider; + +namespace OpenFeature.Tests.Providers.MultiProvider; + +public class MultiProviderClassTests +{ + private const string TestFlagKey = "test-flag"; + private const string TestVariant = "test-variant"; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + private const string Provider3Name = "provider3"; + + private readonly FeatureProvider _mockProvider1 = Substitute.For(); + private readonly FeatureProvider _mockProvider2 = Substitute.For(); + private readonly FeatureProvider _mockProvider3 = Substitute.For(); + private readonly BaseEvaluationStrategy _mockStrategy = Substitute.For(); + private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); + + public MultiProviderClassTests() + { + // Setup default metadata for providers + this._mockProvider1.GetMetadata().Returns(new Metadata(Provider1Name)); + this._mockProvider2.GetMetadata().Returns(new Metadata(Provider2Name)); + this._mockProvider3.GetMetadata().Returns(new Metadata(Provider3Name)); + + // Setup default strategy behavior + this._mockStrategy.RunMode.Returns(RunMode.Sequential); + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), Arg.Any()).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), Arg.Any(), Arg.Any>()).Returns(false); + } + + [Fact] + public void Constructor_WithValidProviderEntries_CreatesMultiProvider() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + + // Act + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Assert + Assert.NotNull(multiProvider); + var metadata = multiProvider.GetMetadata(); + Assert.Equal("MultiProvider", metadata.Name); + } + + [Fact] + public void Constructor_WithNullProviderEntries_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => new MultiProviderImplementation.MultiProvider(null!, this._mockStrategy)); + Assert.Equal("providerEntries", exception.ParamName); + } + + [Fact] + public void Constructor_WithEmptyProviderEntries_ThrowsArgumentException() + { + // Arrange + var emptyProviderEntries = new List(); + + // Act & Assert + var exception = Assert.Throws(() => new MultiProviderImplementation.MultiProvider(emptyProviderEntries, this._mockStrategy)); + Assert.Contains("At least one provider entry must be provided", exception.Message); + Assert.Equal("providerEntries", exception.ParamName); + } + + [Fact] + public void Constructor_WithNullStrategy_UsesDefaultFirstMatchStrategy() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + + // Act + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, null); + + // Assert + Assert.NotNull(multiProvider); + var metadata = multiProvider.GetMetadata(); + Assert.Equal("MultiProvider", metadata.Name); + } + + [Fact] + public void Constructor_WithDuplicateExplicitNames_ThrowsArgumentException() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, "duplicate-name"), + new(this._mockProvider2, "duplicate-name") + }; + + // Act & Assert + var exception = Assert.Throws(() => new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy)); + Assert.Contains("Multiple providers cannot have the same explicit name: 'duplicate-name'", exception.Message); + } + + [Fact] + public async Task ResolveBooleanValueAsync_CallsEvaluateAsync() + { + // Arrange + const bool defaultValue = false; + const bool resolvedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + this._mockStrategy.Received(1).DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()); + } + + [Fact] + public async Task ResolveStringValueAsync_CallsEvaluateAsync() + { + // Arrange + const string defaultValue = "default"; + const string resolvedValue = "resolved"; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + // Act + var result = await multiProvider.ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + } + + [Fact] + public async Task InitializeAsync_WithAllSuccessfulProviders_InitializesAllProviders() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + + // Act + await multiProvider.InitializeAsync(this._evaluationContext); + + // Assert + await this._mockProvider1.Received(1).InitializeAsync(this._evaluationContext, Arg.Any()); + await this._mockProvider2.Received(1).InitializeAsync(this._evaluationContext, Arg.Any()); + } + + [Fact] + public async Task InitializeAsync_WithSomeFailingProviders_ThrowsAggregateException() + { + // Arrange + var expectedException = new InvalidOperationException("Initialization failed"); + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.InitializeAsync(this._evaluationContext, Arg.Any()).ThrowsAsync(expectedException); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => multiProvider.InitializeAsync(this._evaluationContext)); + Assert.Contains("Failed to initialize providers", exception.Message); + Assert.Contains(Provider2Name, exception.Message); + Assert.Contains(expectedException, exception.InnerExceptions); + } + + [Fact] + public async Task ShutdownAsync_WithAllSuccessfulProviders_ShutsDownAllProviders() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + multiProvider.SetStatus(ProviderStatus.Ready); + + this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + + // Act + await multiProvider.ShutdownAsync(); + + // Assert + await this._mockProvider1.Received(1).ShutdownAsync(Arg.Any()); + await this._mockProvider2.Received(1).ShutdownAsync(Arg.Any()); + } + + [Fact] + public async Task ShutdownAsync_WithFatalProvider_ShutsDownAllProviders() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + multiProvider.SetStatus(ProviderStatus.Fatal); + + this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + + // Act + await multiProvider.ShutdownAsync(); + + // Assert + await this._mockProvider1.Received(1).ShutdownAsync(Arg.Any()); + await this._mockProvider2.Received(1).ShutdownAsync(Arg.Any()); + } + + [Fact] + public async Task ShutdownAsync_WithSomeFailingProviders_ThrowsAggregateException() + { + // Arrange + var expectedException = new InvalidOperationException("Shutdown failed"); + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + multiProvider.SetStatus(ProviderStatus.Ready); + + this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.ShutdownAsync(Arg.Any()).ThrowsAsync(expectedException); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => multiProvider.ShutdownAsync()); + Assert.Contains("Failed to shutdown providers", exception.Message); + Assert.Contains(Provider2Name, exception.Message); + Assert.Contains(expectedException, exception.InnerExceptions); + } + + [Fact] + public void GetMetadata_ReturnsMultiProviderMetadata() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act + var metadata = multiProvider.GetMetadata(); + + // Assert + Assert.NotNull(metadata); + Assert.Equal("MultiProvider", metadata.Name); + } + + [Fact] + public async Task ResolveDoubleValueAsync_CallsEvaluateAsync() + { + // Arrange + const double defaultValue = 1.0; + const double resolvedValue = 2.5; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + // Act + var result = await multiProvider.ResolveDoubleValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + this._mockStrategy.Received(1).DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()); + } + + [Fact] + public async Task ResolveIntegerValueAsync_CallsEvaluateAsync() + { + // Arrange + const int defaultValue = 10; + const int resolvedValue = 42; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + // Act + var result = await multiProvider.ResolveIntegerValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + } + + [Fact] + public async Task ResolveStructureValueAsync_CallsEvaluateAsync() + { + // Arrange + var defaultValue = new Value("default"); + var resolvedValue = new Value("resolved"); + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + // Act + var result = await multiProvider.ResolveStructureValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + } + + [Fact] + public async Task EvaluateAsync_WithSequentialMode_EvaluatesProvidersSequentially() + { + // Arrange + const bool defaultValue = false; + const bool resolvedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.RunMode.Returns(RunMode.Sequential); + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), this._evaluationContext, Arg.Any>()).Returns(false); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) + .Returns(expectedDetails); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + await this._mockProvider1.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()); + await this._mockProvider2.DidNotReceive().ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task EvaluateAsync_WithParallelMode_EvaluatesProvidersInParallel() + { + // Arrange + const bool defaultValue = false; + const bool resolvedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.RunMode.Returns(RunMode.Parallel); + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext).Returns(true); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) + .Returns(expectedDetails); + this._mockProvider2.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) + .Returns(expectedDetails); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + await this._mockProvider1.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()); + await this._mockProvider2.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()); + } + + [Fact] + public async Task EvaluateAsync_WithUnsupportedRunMode_ThrowsNotSupportedException() + { + // Arrange + const bool defaultValue = false; + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.RunMode.Returns((RunMode)999); // Invalid enum value + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext)); + Assert.Contains("Unsupported run mode", exception.Message); + } + + [Fact] + public async Task EvaluateAsync_WithStrategySkippingProvider_DoesNotCallSkippedProvider() + { + // Arrange + const bool defaultValue = false; + const bool resolvedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.RunMode.Returns(RunMode.Sequential); + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext) + .Returns(callInfo => + { + var context = callInfo.Arg>(); + return context.ProviderName == Provider1Name; // Only evaluate provider1 + }); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) + .Returns(expectedDetails); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + + // Assert + Assert.Equal(expectedDetails, result); + await this._mockProvider1.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()); + await this._mockProvider2.DidNotReceive().ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task EvaluateAsync_WithCancellationToken_PassesCancellationTokenToProviders() + { + // Arrange + const bool defaultValue = false; + var expectedDetails = new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant); + var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); + + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockStrategy.RunMode.Returns(RunMode.Sequential); + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), this._evaluationContext, Arg.Any>()).Returns(false); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) + .Returns(finalResult); + + this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, Arg.Any()) + .Returns(expectedDetails); + + using var cts = new CancellationTokenSource(); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, cts.Token); + + // Assert + Assert.Equal(expectedDetails, result); + await this._mockProvider1.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, cts.Token); + } + + [Fact] + public void Constructor_WithProvidersHavingSameMetadataName_AssignsUniqueNames() + { + // Arrange + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + provider1.GetMetadata().Returns(new Metadata("SameName")); + provider2.GetMetadata().Returns(new Metadata("SameName")); + + var providerEntries = new List + { + new(provider1), // No explicit name, will use metadata name + new(provider2) // No explicit name, will use metadata name + }; + + // Act + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Assert + Assert.NotNull(multiProvider); + // The internal logic should assign unique names like "SameName-1", "SameName-2" + var metadata = multiProvider.GetMetadata(); + Assert.Equal("MultiProvider", metadata.Name); + } + + [Fact] + public void Constructor_WithProviderHavingNullMetadata_AssignsDefaultName() + { + // Arrange + var provider = Substitute.For(); + provider.GetMetadata().Returns((Metadata?)null); + + var providerEntries = new List { new(provider) }; + + // Act + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Assert + Assert.NotNull(multiProvider); + var metadata = multiProvider.GetMetadata(); + Assert.Equal("MultiProvider", metadata.Name); + } + + [Fact] + public void Constructor_WithProviderHavingNullMetadataName_AssignsDefaultName() + { + // Arrange + var provider = Substitute.For(); + var metadata = new Metadata(null); + provider.GetMetadata().Returns(metadata); + + var providerEntries = new List { new(provider) }; + + // Act + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Assert + Assert.NotNull(multiProvider); + var multiProviderMetadata = multiProvider.GetMetadata(); + Assert.Equal("MultiProvider", multiProviderMetadata.Name); + } + + [Fact] + public async Task InitializeAsync_WithCancellationToken_PassesCancellationTokenToProviders() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + + using var cts = new CancellationTokenSource(); + + // Act + await multiProvider.InitializeAsync(this._evaluationContext, cts.Token); + + // Assert + await this._mockProvider1.Received(1).InitializeAsync(this._evaluationContext, cts.Token); + } + + [Fact] + public async Task ShutdownAsync_WithCancellationToken_PassesCancellationTokenToProviders() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + multiProvider.SetStatus(ProviderStatus.Ready); + + this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + + using var cts = new CancellationTokenSource(); + + // Act + await multiProvider.ShutdownAsync(cts.Token); + + // Assert + await this._mockProvider1.Received(1).ShutdownAsync(cts.Token); + } + + [Fact] + public async Task InitializeAsync_WithAllSuccessfulProviders_CompletesWithoutException() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name), + new(this._mockProvider3, Provider3Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider3.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); + + // Act & Assert + await multiProvider.InitializeAsync(this._evaluationContext); + + // Verify all providers were called + await this._mockProvider1.Received(1).InitializeAsync(this._evaluationContext, Arg.Any()); + await this._mockProvider2.Received(1).InitializeAsync(this._evaluationContext, Arg.Any()); + await this._mockProvider3.Received(1).InitializeAsync(this._evaluationContext, Arg.Any()); + } + + [Fact] + public async Task ShutdownAsync_WithAllSuccessfulProviders_CompletesWithoutException() + { + // Arrange + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name), + new(this._mockProvider3, Provider3Name) + }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + multiProvider.SetStatus(ProviderStatus.Ready); + + this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider2.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + this._mockProvider3.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); + + // Act & Assert + await multiProvider.ShutdownAsync(); + + // Verify all providers were called + await this._mockProvider1.Received(1).ShutdownAsync(Arg.Any()); + await this._mockProvider2.Received(1).ShutdownAsync(Arg.Any()); + await this._mockProvider3.Received(1).ShutdownAsync(Arg.Any()); + } + + [Fact] + public async Task MultiProvider_ConcurrentInitializationAndShutdown_ShouldMaintainConsistentProviderStatus() + { + // Arrange + const int providerCount = 20; + var random = new Random(); + var providerEntries = new List(); + + for (int i = 0; i < providerCount; i++) + { + var provider = Substitute.For(); + + provider.InitializeAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + provider.ShutdownAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + provider.GetMetadata() + .Returns(new Metadata(name: $"provider-{i}")); + + providerEntries.Add(new ProviderEntry(provider)); + } + + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries); + + // Act: simulate concurrent initialization and shutdown with one task each + var initTasks = Enumerable.Range(0, 1).Select(_ => + Task.Run(() => multiProvider.InitializeAsync(Arg.Any(), CancellationToken.None))); + + var shutdownTasks = Enumerable.Range(0, 1).Select(_ => + Task.Run(() => multiProvider.ShutdownAsync(CancellationToken.None))); + + await Task.WhenAll(initTasks.Concat(shutdownTasks)); + + // Assert: ensure that each provider ends in a valid lifecycle state + var statuses = GetRegisteredStatuses().ToList(); + + Assert.All(statuses, status => + { + Assert.True( + status is ProviderStatus.Ready or ProviderStatus.NotReady, + $"Unexpected provider status: {status}"); + }); + + // Local helper: uses reflection to access the private '_registeredProviders' field + // and retrieve the current status of each registered provider. + // Consider replacing this with an internal or public method if testing becomes more frequent. + IEnumerable GetRegisteredStatuses() + { + var field = typeof(MultiProviderImplementation.MultiProvider).GetField("_registeredProviders", BindingFlags.NonPublic | BindingFlags.Instance); + if (field?.GetValue(multiProvider) is not IEnumerable list) + throw new InvalidOperationException("Could not retrieve registered providers via reflection."); + + foreach (var p in list) + { + var statusProperty = p.GetType().GetProperty("Status", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (statusProperty == null) + throw new InvalidOperationException($"'Status' property not found on type {p.GetType().Name}."); + + if (statusProperty.GetValue(p) is not ProviderStatus status) + throw new InvalidOperationException("Unable to read status property value."); + + yield return status; + } + } + } + + [Fact] + public async Task DisposeAsync_ShouldDisposeInternalResources() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act + await multiProvider.DisposeAsync(); + + // Assert - Should not throw any exception + // The internal semaphores should be disposed + Assert.True(true); // If we get here without exception, disposal worked + } + + [Fact] + public async Task DisposeAsync_CalledMultipleTimes_ShouldNotThrow() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act & Assert - Multiple calls to Dispose should not throw + await multiProvider.DisposeAsync(); + await multiProvider.DisposeAsync(); + await multiProvider.DisposeAsync(); + + // If we get here without exception, multiple disposal calls worked correctly + Assert.True(true); + } + + [Fact] + public async Task InitializeAsync_AfterDispose_ShouldThrowObjectDisposedException() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act + await multiProvider.DisposeAsync(); + + // Assert + var exception = await Assert.ThrowsAsync(() => + multiProvider.InitializeAsync(this._evaluationContext)); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), exception.ObjectName); + } + + [Fact] + public async Task ShutdownAsync_AfterDispose_ShouldThrowObjectDisposedException() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act + await multiProvider.DisposeAsync(); + + // Assert + var exception = await Assert.ThrowsAsync(() => + multiProvider.ShutdownAsync()); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), exception.ObjectName); + } + + [Fact] + public async Task InitializeAsync_WhenAlreadyDisposed_DuringExecution_ShouldExitEarly() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Dispose before calling InitializeAsync + await multiProvider.DisposeAsync(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + multiProvider.InitializeAsync(this._evaluationContext)); + + // Verify that the underlying provider was never called since the object was disposed + await this._mockProvider1.DidNotReceive().InitializeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ShutdownAsync_WhenAlreadyDisposed_DuringExecution_ShouldExitEarly() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Dispose before calling ShutdownAsync + await multiProvider.DisposeAsync(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + multiProvider.ShutdownAsync()); + + // Verify that the underlying provider was never called since the object was disposed + await this._mockProvider1.DidNotReceive().ShutdownAsync(Arg.Any()); + } + + [Fact] + public async Task EvaluateAsync_AfterDispose_ShouldThrowObjectDisposedException() + { + // Arrange + var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; + var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + + // Act + await multiProvider.DisposeAsync(); + + // Assert - All evaluate methods should throw ObjectDisposedException + var boolException = await Assert.ThrowsAsync(() => + multiProvider.ResolveBooleanValueAsync(TestFlagKey, false)); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), boolException.ObjectName); + + var stringException = await Assert.ThrowsAsync(() => + multiProvider.ResolveStringValueAsync(TestFlagKey, "default")); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), stringException.ObjectName); + + var intException = await Assert.ThrowsAsync(() => + multiProvider.ResolveIntegerValueAsync(TestFlagKey, 0)); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), intException.ObjectName); + + var doubleException = await Assert.ThrowsAsync(() => + multiProvider.ResolveDoubleValueAsync(TestFlagKey, 0.0)); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), doubleException.ObjectName); + + var structureException = await Assert.ThrowsAsync(() => + multiProvider.ResolveStructureValueAsync(TestFlagKey, new Value())); + Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), structureException.ObjectName); + } + +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs new file mode 100644 index 000000000..702fc3973 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs @@ -0,0 +1,334 @@ +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider; + +public class ProviderExtensionsTests +{ + private const string TestFlagKey = "test-flag"; + private const string TestProviderName = "test-provider"; + private const string TestVariant = "test-variant"; + + private readonly FeatureProvider _mockProvider = Substitute.For(); + private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); + private readonly CancellationToken _cancellationToken = CancellationToken.None; + + [Fact] + public async Task EvaluateAsync_WithBooleanType_CallsResolveBooleanValueAsync() + { + // Arrange + const bool defaultValue = false; + const bool resolvedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Null(result.ThrownError); + await this._mockProvider.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithStringType_CallsResolveStringValueAsync() + { + // Arrange + const string defaultValue = "default"; + const string resolvedValue = "resolved"; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Null(result.ThrownError); + await this._mockProvider.Received(1).ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithIntegerType_CallsResolveIntegerValueAsync() + { + // Arrange + const int defaultValue = 0; + const int resolvedValue = 42; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveIntegerValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Null(result.ThrownError); + await this._mockProvider.Received(1).ResolveIntegerValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithDoubleType_CallsResolveDoubleValueAsync() + { + // Arrange + const double defaultValue = 0.0; + const double resolvedValue = 3.14; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveDoubleValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Null(result.ThrownError); + await this._mockProvider.Received(1).ResolveDoubleValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithValueType_CallsResolveStructureValueAsync() + { + // Arrange + var defaultValue = new Value(); + var resolvedValue = new Value("resolved"); + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveStructureValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Null(result.ThrownError); + await this._mockProvider.Received(1).ResolveStructureValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithUnsupportedType_ThrowsArgumentException() + { + // Arrange + var defaultValue = new DateTime(2023, 1, 1); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(TestFlagKey, result.ResolutionDetails.FlagKey); + Assert.Equal(defaultValue, result.ResolutionDetails.Value); + Assert.Equal(ErrorType.General, result.ResolutionDetails.ErrorType); + Assert.Equal(Reason.Error, result.ResolutionDetails.Reason); + Assert.Contains("Unsupported flag type", result.ResolutionDetails.ErrorMessage); + Assert.NotNull(result.ThrownError); + Assert.IsType(result.ThrownError); + } + + [Fact] + public async Task EvaluateAsync_WhenProviderThrowsException_ReturnsErrorResult() + { + // Arrange + const bool defaultValue = false; + var expectedException = new InvalidOperationException("Provider error"); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .ThrowsAsync(expectedException); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(TestFlagKey, result.ResolutionDetails.FlagKey); + Assert.Equal(defaultValue, result.ResolutionDetails.Value); + Assert.Equal(ErrorType.General, result.ResolutionDetails.ErrorType); + Assert.Equal(Reason.Error, result.ResolutionDetails.Reason); + Assert.Equal("Provider error", result.ResolutionDetails.ErrorMessage); + Assert.Equal(expectedException, result.ThrownError); + } + + [Fact] + public async Task EvaluateAsync_WithNullEvaluationContext_CallsProviderWithNullContext() + { + // Arrange + const bool defaultValue = false; + const bool resolvedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, null, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, null, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Null(result.ThrownError); + await this._mockProvider.Received(1).ResolveBooleanValueAsync(TestFlagKey, defaultValue, null, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithCancellationToken_PassesToProvider() + { + // Arrange + const string defaultValue = "default"; + const string resolvedValue = "resolved"; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + var customCancellationToken = new CancellationTokenSource().Token; + + this._mockProvider.ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext, customCancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, customCancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedDetails, result.ResolutionDetails); + await this._mockProvider.Received(1).ResolveStringValueAsync(TestFlagKey, defaultValue, this._evaluationContext, customCancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithNullDefaultValue_PassesNullToProvider() + { + // Arrange + string? defaultValue = null; + const string resolvedValue = "resolved"; + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveStringValueAsync(TestFlagKey, defaultValue!, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue!, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedDetails, result.ResolutionDetails); + await this._mockProvider.Received(1).ResolveStringValueAsync(TestFlagKey, defaultValue!, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WithDifferentFlagKeys_UsesCorrectKey() + { + // Arrange + const string customFlagKey = "custom-flag-key"; + const int defaultValue = 0; + const int resolvedValue = 123; + var expectedDetails = new ResolutionDetails(customFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, customFlagKey); + + this._mockProvider.ResolveIntegerValueAsync(customFlagKey, defaultValue, this._evaluationContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedDetails, result.ResolutionDetails); + Assert.Equal(customFlagKey, result.ResolutionDetails.FlagKey); + await this._mockProvider.Received(1).ResolveIntegerValueAsync(customFlagKey, defaultValue, this._evaluationContext, this._cancellationToken); + } + + [Fact] + public async Task EvaluateAsync_WhenOperationCancelled_ReturnsErrorResult() + { + // Arrange + const bool defaultValue = false; + var cancellationTokenSource = new CancellationTokenSource(); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, cancellationTokenSource.Token) + .Returns(async callInfo => + { + cancellationTokenSource.Cancel(); + await Task.Delay(100, cancellationTokenSource.Token); + return new ResolutionDetails(TestFlagKey, true); + }); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, cancellationTokenSource.Token); + + // Assert + Assert.NotNull(result); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(TestFlagKey, result.ResolutionDetails.FlagKey); + Assert.Equal(defaultValue, result.ResolutionDetails.Value); + Assert.Equal(ErrorType.General, result.ResolutionDetails.ErrorType); + Assert.Equal(Reason.Error, result.ResolutionDetails.Reason); + Assert.NotNull(result.ThrownError); + Assert.True(result.ThrownError is OperationCanceledException); + } + + [Fact] + public async Task EvaluateAsync_WithComplexEvaluationContext_PassesContextToProvider() + { + // Arrange + const double defaultValue = 1.0; + const double resolvedValue = 2.5; + var complexContext = new EvaluationContextBuilder() + .Set("user", "test-user") + .Set("environment", "test") + .Build(); + var expectedDetails = new ResolutionDetails(TestFlagKey, resolvedValue, ErrorType.None, Reason.Static, TestVariant); + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveDoubleValueAsync(TestFlagKey, defaultValue, complexContext, this._cancellationToken) + .Returns(expectedDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, complexContext, defaultValue, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedDetails, result.ResolutionDetails); + await this._mockProvider.Received(1).ResolveDoubleValueAsync(TestFlagKey, defaultValue, complexContext, this._cancellationToken); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs new file mode 100644 index 000000000..f2960be07 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs @@ -0,0 +1,500 @@ +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; + +public class BaseEvaluationStrategyTests +{ + private const string TestFlagKey = "test-flag"; + private const bool DefaultBoolValue = false; + private const string TestVariant = "variant1"; + private const string TestErrorMessage = "Test error message"; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + + private readonly TestableBaseEvaluationStrategy _strategy = new(); + private readonly FeatureProvider _mockProvider1 = Substitute.For(); + private readonly FeatureProvider _mockProvider2 = Substitute.For(); + private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); + private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey); + + [Fact] + public void RunMode_DefaultValue_ReturnsSequential() + { + // Act + var result = this._strategy.RunMode; + + // Assert + Assert.Equal(RunMode.Sequential, result); + } + + [Fact] + public void ShouldEvaluateThisProvider_WithReadyProvider_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateThisProvider_WithNotReadyProvider_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.NotReady, TestFlagKey); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldEvaluateThisProvider_WithFatalProvider_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Fatal, TestFlagKey); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldEvaluateThisProvider_WithStaleProvider_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Stale, TestFlagKey); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateThisProvider_WithNullEvaluationContext_ReturnsExpectedResult() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, null); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_DefaultImplementation_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var successResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, successResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithErrorResult_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, errorResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithNullEvaluationContext_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var successResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, null, successResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void HasError_WithThrownException_ReturnsTrue() + { + // Arrange + var exception = new InvalidOperationException(TestErrorMessage); + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.None, Reason.Static), + exception); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasError(errorResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void HasError_WithErrorType_ReturnsTrue() + { + // Arrange + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasError(errorResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void HasError_WithNoError_ReturnsFalse() + { + // Arrange + var successResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasError(successResult); + + // Assert + Assert.False(result); + } + + [Fact] + public void CollectProviderErrors_WithThrownExceptions_ReturnsAllErrors() + { + // Arrange + var exception1 = new InvalidOperationException("Error 1"); + var exception2 = new ArgumentException("Error 2"); + + var resolutions = new List> + { + new(this._mockProvider1, Provider1Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.None, Reason.Static), exception1), + new(this._mockProvider2, Provider2Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.None, Reason.Static), exception2) + }; + + // Act + var errors = TestableBaseEvaluationStrategy.TestCollectProviderErrors(resolutions); + + // Assert + Assert.Equal(2, errors.Count); + Assert.Equal(Provider1Name, errors[0].ProviderName); + Assert.Equal(exception1, errors[0].Error); + Assert.Equal(Provider2Name, errors[1].ProviderName); + Assert.Equal(exception2, errors[1].Error); + } + + [Fact] + public void CollectProviderErrors_WithErrorTypes_ReturnsAllErrors() + { + // Arrange + var resolutions = new List> + { + new(this._mockProvider1, Provider1Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: "Error 1")), + new(this._mockProvider2, Provider2Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Error 2")) + }; + + // Act + var errors = TestableBaseEvaluationStrategy.TestCollectProviderErrors(resolutions); + + // Assert + Assert.Equal(2, errors.Count); + Assert.Equal(Provider1Name, errors[0].ProviderName); + Assert.Equal("Error 1", errors[0].Error?.Message); + Assert.Equal(Provider2Name, errors[1].ProviderName); + Assert.Equal("Error 2", errors[1].Error?.Message); + } + + [Fact] + public void CollectProviderErrors_WithMixedErrors_ReturnsAllErrors() + { + // Arrange + var thrownException = new InvalidOperationException("Thrown error"); + var resolutions = new List> + { + new(this._mockProvider1, Provider1Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.None, Reason.Static), thrownException), + new(this._mockProvider2, Provider2Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: "Resolution error")), + new(this._mockProvider1, "provider3", new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static)) + }; + + // Act + var errors = TestableBaseEvaluationStrategy.TestCollectProviderErrors(resolutions); + + // Assert + Assert.Equal(2, errors.Count); + Assert.Equal(Provider1Name, errors[0].ProviderName); + Assert.Equal(thrownException, errors[0].Error); + Assert.Equal(Provider2Name, errors[1].ProviderName); + Assert.Equal("Resolution error", errors[1].Error?.Message); + } + + [Fact] + public void CollectProviderErrors_WithNoErrors_ReturnsEmptyList() + { + // Arrange + var resolutions = new List> + { + new(this._mockProvider1, Provider1Name, new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static)), + new(this._mockProvider2, Provider2Name, new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static)) + }; + + // Act + var errors = TestableBaseEvaluationStrategy.TestCollectProviderErrors(resolutions); + + // Assert + Assert.Empty(errors); + } + + [Fact] + public void CollectProviderErrors_WithNullErrorMessage_UsesDefaultMessage() + { + // Arrange + var resolutions = new List> + { + new(this._mockProvider1, Provider1Name, new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: null)) + }; + + // Act + var errors = TestableBaseEvaluationStrategy.TestCollectProviderErrors(resolutions); + + // Assert + Assert.Single(errors); + Assert.Equal("unknown error", errors[0].Error?.Message); + } + + [Fact] + public void HasErrorWithCode_WithMatchingErrorType_ReturnsTrue() + { + // Arrange + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: TestErrorMessage)); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasErrorWithCode(errorResult, ErrorType.FlagNotFound); + + // Assert + Assert.True(result); + } + + [Fact] + public void HasErrorWithCode_WithDifferentErrorType_ReturnsFalse() + { + // Arrange + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasErrorWithCode(errorResult, ErrorType.FlagNotFound); + + // Assert + Assert.False(result); + } + + [Fact] + public void HasErrorWithCode_WithNoError_ReturnsFalse() + { + // Arrange + var successResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasErrorWithCode(successResult, ErrorType.FlagNotFound); + + // Assert + Assert.False(result); + } + + [Fact] + public void HasErrorWithCode_WithThrownException_ReturnsFalse() + { + // Arrange + var exception = new InvalidOperationException(TestErrorMessage); + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.None, Reason.Static), + exception); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasErrorWithCode(errorResult, ErrorType.General); + + // Assert + Assert.False(result); + } + + [Fact] + public void ToFinalResult_WithSuccessResult_ReturnsCorrectFinalResult() + { + // Arrange + var resolutionDetails = new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant); + var providerResult = new ProviderResolutionResult(this._mockProvider1, Provider1Name, resolutionDetails); + + // Act + var finalResult = TestableBaseEvaluationStrategy.TestToFinalResult(providerResult); + + // Assert + Assert.Equal(resolutionDetails, finalResult.Details); + Assert.Equal(this._mockProvider1, finalResult.Provider); + Assert.Equal(Provider1Name, finalResult.ProviderName); + Assert.Empty(finalResult.Errors); + } + + [Fact] + public void ToFinalResult_WithErrorResult_ReturnsCorrectFinalResult() + { + // Arrange + var resolutionDetails = new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage); + var providerResult = new ProviderResolutionResult(this._mockProvider1, Provider1Name, resolutionDetails); + + // Act + var finalResult = TestableBaseEvaluationStrategy.TestToFinalResult(providerResult); + + // Assert + Assert.Equal(resolutionDetails, finalResult.Details); + Assert.Equal(this._mockProvider1, finalResult.Provider); + Assert.Equal(Provider1Name, finalResult.ProviderName); + Assert.Empty(finalResult.Errors); + } + + [Theory] + [InlineData(ProviderStatus.Ready)] + [InlineData(ProviderStatus.Stale)] + [InlineData(ProviderStatus.Error)] + public void ShouldEvaluateThisProvider_WithAllowedStatuses_ReturnsTrue(ProviderStatus status) + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, status, TestFlagKey); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData(ProviderStatus.NotReady)] + [InlineData(ProviderStatus.Fatal)] + public void ShouldEvaluateThisProvider_WithDisallowedStatuses_ReturnsFalse(ProviderStatus status) + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, status, TestFlagKey); + + // Act + var result = this._strategy.ShouldEvaluateThisProvider(strategyContext, this._evaluationContext); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData(ErrorType.None)] + [InlineData(ErrorType.FlagNotFound)] + [InlineData(ErrorType.General)] + [InlineData(ErrorType.ParseError)] + [InlineData(ErrorType.TypeMismatch)] + [InlineData(ErrorType.TargetingKeyMissing)] + [InlineData(ErrorType.InvalidContext)] + [InlineData(ErrorType.ProviderNotReady)] + public void HasErrorWithCode_WithAllErrorTypes_ReturnsCorrectResult(ErrorType errorType) + { + // Arrange + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, errorType, Reason.Error, errorMessage: TestErrorMessage)); + + // Act + var result = TestableBaseEvaluationStrategy.TestHasErrorWithCode(errorResult, errorType); + + // Assert + Assert.True(result); + } + + [Fact] + public void DetermineFinalResult_IsAbstractMethod_RequiresImplementation() + { + // This test verifies that DetermineFinalResult is abstract and must be implemented + // by testing our concrete implementation + + // Arrange + var resolutions = new List> + { + new(this._mockProvider1, Provider1Name, new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)) + }; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result); + Assert.Equal("TestImplementation", result.ProviderName); // From our test implementation + } + + /// + /// Concrete implementation of BaseEvaluationStrategy for testing purposes. + /// + private class TestableBaseEvaluationStrategy : BaseEvaluationStrategy + { + public override FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions) + { + // Simple test implementation that returns the first result or a default + if (resolutions.Count > 0) + { + return new FinalResult(resolutions[0].ResolutionDetails, resolutions[0].Provider, "TestImplementation", null); + } + + var defaultDetails = new ResolutionDetails(key, defaultValue, ErrorType.None, Reason.Default); + return new FinalResult(defaultDetails, null!, "TestImplementation", null); + } + + // Expose protected methods for testing + public static bool TestHasError(ProviderResolutionResult resolution) => HasError(resolution); + public static List TestCollectProviderErrors(List> resolutions) => CollectProviderErrors(resolutions); + public static bool TestHasErrorWithCode(ProviderResolutionResult resolution, ErrorType errorType) => HasErrorWithCode(resolution, errorType); + public static FinalResult TestToFinalResult(ProviderResolutionResult resolution) => ToFinalResult(resolution); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs new file mode 100644 index 000000000..480ef6b90 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs @@ -0,0 +1,475 @@ +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; + +public class ComparisonStrategyTests +{ + private const string TestFlagKey = "test-flag"; + private const bool DefaultBoolValue = false; + private const string TestVariant = "variant1"; + private const string TestErrorMessage = "Test error message"; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + private const string Provider3Name = "provider3"; + private const string MultiProviderName = "MultiProvider"; + + private readonly FeatureProvider _mockProvider1 = Substitute.For(); + private readonly FeatureProvider _mockProvider2 = Substitute.For(); + private readonly FeatureProvider _mockProvider3 = Substitute.For(); + private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); + private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey); + + [Fact] + public void RunMode_ReturnsParallel() + { + // Arrange + var strategy = new ComparisonStrategy(); + + // Act + var result = strategy.RunMode; + + // Assert + Assert.Equal(RunMode.Parallel, result); + } + + [Fact] + public void Constructor_WithNoParameters_InitializesSuccessfully() + { + // Act + var strategy = new ComparisonStrategy(); + + // Assert + Assert.NotNull(strategy); + Assert.Equal(RunMode.Parallel, strategy.RunMode); + } + + [Fact] + public void Constructor_WithFallbackProvider_InitializesSuccessfully() + { + // Act + var strategy = new ComparisonStrategy(this._mockProvider1); + + // Assert + Assert.NotNull(strategy); + Assert.Equal(RunMode.Parallel, strategy.RunMode); + } + + [Fact] + public void Constructor_WithOnMismatchCallback_InitializesSuccessfully() + { + // Arrange + var onMismatch = Substitute.For>>(); + + // Act + var strategy = new ComparisonStrategy(onMismatch: onMismatch); + + // Assert + Assert.NotNull(strategy); + Assert.Equal(RunMode.Parallel, strategy.RunMode); + } + + [Fact] + public void Constructor_WithBothParameters_InitializesSuccessfully() + { + // Arrange + var onMismatch = Substitute.For>>(); + + // Act + var strategy = new ComparisonStrategy(this._mockProvider1, onMismatch); + + // Assert + Assert.NotNull(strategy); + Assert.Equal(RunMode.Parallel, strategy.RunMode); + } + + [Fact] + public void DetermineFinalResult_WithNoProviders_ReturnsErrorResult() + { + // Arrange + var strategy = new ComparisonStrategy(); + var resolutions = new List>(); + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(DefaultBoolValue, result.Details.Value); + Assert.Equal(ErrorType.ProviderNotReady, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal("No providers available or all providers failed", result.Details.ErrorMessage); + Assert.Equal(MultiProviderName, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithAllFailedProviders_ReturnsErrorResult() + { + // Arrange + var strategy = new ComparisonStrategy(); + var errorResult1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: "Error from provider1")); + + var errorResult2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.InvalidContext, Reason.Error, errorMessage: "Error from provider2")); + + var resolutions = new List> { errorResult1, errorResult2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(DefaultBoolValue, result.Details.Value); + Assert.Equal(ErrorType.ProviderNotReady, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal("No providers available or all providers failed", result.Details.ErrorMessage); + Assert.Equal(MultiProviderName, result.ProviderName); + Assert.Equal(2, result.Errors.Count); + } + + [Fact] + public void DetermineFinalResult_WithSingleSuccessfulProvider_ReturnsResult() + { + // Arrange + var strategy = new ComparisonStrategy(); + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var resolutions = new List> { successfulResult }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithAgreingProviders_ReturnsFirstResult() + { + // Arrange + var strategy = new ComparisonStrategy(); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var result2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant2")); + + var result3 = new ProviderResolutionResult( + this._mockProvider3, + Provider3Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant3")); + + var resolutions = new List> { result1, result2, result3 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithDisagreeingProviders_ReturnsFirstResult() + { + // Arrange + var strategy = new ComparisonStrategy(); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var result2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { result1, result2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithDisagreeingProvidersAndFallback_ReturnsFallbackResult() + { + // Arrange + var strategy = new ComparisonStrategy(this._mockProvider2); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var result2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { result1, result2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.False(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal("variant2", result.Details.Variant); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal(Provider2Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithDisagreeingProvidersAndNonExistentFallback_ReturnsFirstResult() + { + // Arrange + var nonExistentProvider = Substitute.For(); + var strategy = new ComparisonStrategy(nonExistentProvider); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var result2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { result1, result2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithDisagreeingProvidersAndOnMismatchCallback_CallsCallback() + { + // Arrange + var onMismatchCalled = false; + IDictionary? capturedMismatchDetails = null; + + var onMismatch = new Action>(details => + { + onMismatchCalled = true; + capturedMismatchDetails = details; + }); + + var strategy = new ComparisonStrategy(onMismatch: onMismatch); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var result2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { result1, result2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.True(onMismatchCalled); + Assert.NotNull(capturedMismatchDetails); + Assert.Equal(2, capturedMismatchDetails.Count); + Assert.True((bool)capturedMismatchDetails[Provider1Name]); + Assert.False((bool)capturedMismatchDetails[Provider2Name]); + } + + [Fact] + public void DetermineFinalResult_WithAgreingProvidersAndOnMismatchCallback_DoesNotCallCallback() + { + // Arrange + var onMismatchCalled = false; + + var onMismatch = new Action>(_ => + { + onMismatchCalled = true; + }); + + var strategy = new ComparisonStrategy(onMismatch: onMismatch); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var result2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { result1, result2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.False(onMismatchCalled); + } + + [Fact] + public void DetermineFinalResult_WithMixedSuccessAndErrorResults_IgnoresErrors() + { + // Arrange + var strategy = new ComparisonStrategy(); + var successfulResult1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var errorResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + var successfulResult2 = new ProviderResolutionResult( + this._mockProvider3, + Provider3Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant3")); + + var resolutions = new List> { successfulResult1, errorResult, successfulResult2 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithFallbackProviderAndBothSuccessfulAndFallbackAgree_ReturnsFallbackResult() + { + // Arrange + var strategy = new ComparisonStrategy(this._mockProvider2); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var fallbackResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { result1, fallbackResult }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); // Returns first result when all agree + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithFallbackProviderHavingError_UsesFallbackWhenAvailable() + { + // Arrange + var strategy = new ComparisonStrategy(this._mockProvider1); + var result1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var errorResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + var result3 = new ProviderResolutionResult( + this._mockProvider3, + Provider3Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant3")); + + var resolutions = new List> { result1, errorResult, result3 }; + + // Act + var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs new file mode 100644 index 000000000..8c95ef00d --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs @@ -0,0 +1,323 @@ +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; + +public class FirstMatchStrategyTests +{ + private const string TestFlagKey = "test-flag"; + private const bool DefaultBoolValue = false; + private const string TestVariant = "variant1"; + private const string TestErrorMessage = "Test error message"; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + private const string MultiProviderName = "MultiProvider"; + private const string NoProvidersErrorMessage = "No providers available or all providers failed"; + + private readonly FirstMatchStrategy _strategy = new(); + private readonly FeatureProvider _mockProvider1 = Substitute.For(); + private readonly FeatureProvider _mockProvider2 = Substitute.For(); + private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); + private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey); + + [Fact] + public void RunMode_ReturnsSequential() + { + // Act + var result = this._strategy.RunMode; + + // Assert + Assert.Equal(RunMode.Sequential, result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithFlagNotFoundError_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var flagNotFoundResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, flagNotFoundResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithSuccessfulResult_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, successfulResult); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithGeneralError_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var generalErrorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, generalErrorResult); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithInvalidContextError_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var invalidContextResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.InvalidContext, Reason.Error, errorMessage: "Invalid context")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, invalidContextResult); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithThrownException_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var exceptionResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue), + new InvalidOperationException("Test exception")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, exceptionResult); + + // Assert + Assert.False(result); + } + + [Fact] + public void DetermineFinalResult_WithNoProviders_ReturnsErrorResult() + { + // Arrange + var resolutions = new List>(); + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(DefaultBoolValue, result.Details.Value); + Assert.Equal(ErrorType.ProviderNotReady, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal(NoProvidersErrorMessage, result.Details.ErrorMessage); + Assert.Equal(MultiProviderName, result.ProviderName); + Assert.Single(result.Errors); + Assert.Equal(MultiProviderName, result.Errors[0].ProviderName); + Assert.IsType(result.Errors[0].Error); + Assert.Equal(NoProvidersErrorMessage, result.Errors[0].Error?.Message); + } + + [Fact] + public void DetermineFinalResult_WithSingleSuccessfulResult_ReturnsLastResult() + { + // Arrange + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var resolutions = new List> { successfulResult }; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithMultipleResults_ReturnsLastResult() + { + // Arrange + var flagNotFoundResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found")); + + var successfulResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, TestVariant)); + + var resolutions = new List> { flagNotFoundResult, successfulResult }; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(TestVariant, result.Details.Variant); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal(Provider2Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithLastResultHavingError_ReturnsLastResultWithError() + { + // Arrange + var flagNotFoundResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found")); + + var generalErrorResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.General, Reason.Error, errorMessage: TestErrorMessage)); + + var resolutions = new List> { flagNotFoundResult, generalErrorResult }; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(DefaultBoolValue, result.Details.Value); + Assert.Equal(ErrorType.General, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal(TestErrorMessage, result.Details.ErrorMessage); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal(Provider2Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithLastResultHavingException_ReturnsLastResultWithException() + { + // Arrange + var flagNotFoundResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue, ErrorType.FlagNotFound, Reason.Error, errorMessage: "Flag not found")); + + var exceptionResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, DefaultBoolValue), + new ArgumentException("Test argument exception")); + + var resolutions = new List> { flagNotFoundResult, exceptionResult }; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(DefaultBoolValue, result.Details.Value); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal(Provider2Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithStringType_ReturnsCorrectType() + { + // Arrange + const string defaultStringValue = "default"; + const string testStringValue = "test-value"; + const string stringVariant = "string-variant"; + + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, testStringValue, ErrorType.None, Reason.Static, stringVariant)); + + var resolutions = new List> { successfulResult }; + var stringStrategyContext = new StrategyEvaluationContext(TestFlagKey); + + // Act + var result = this._strategy.DetermineFinalResult(stringStrategyContext, TestFlagKey, defaultStringValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(testStringValue, result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(stringVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithIntType_ReturnsCorrectType() + { + // Arrange + const int defaultIntValue = 0; + const int testIntValue = 42; + const string intVariant = "int-variant"; + + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, testIntValue, ErrorType.None, Reason.Static, intVariant)); + + var resolutions = new List> { successfulResult }; + var intStrategyContext = new StrategyEvaluationContext(TestFlagKey); + + // Act + var result = this._strategy.DetermineFinalResult(intStrategyContext, TestFlagKey, defaultIntValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(testIntValue, result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal(intVariant, result.Details.Variant); + Assert.Equal(this._mockProvider1, result.Provider); + Assert.Equal(Provider1Name, result.ProviderName); + Assert.Empty(result.Errors); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs new file mode 100644 index 000000000..da0d87409 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs @@ -0,0 +1,240 @@ +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; + +public class FirstSuccessfulStrategyTests +{ + private const string TestFlagKey = "test-flag"; + private const bool DefaultBoolValue = false; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + private const string Provider3Name = "provider3"; + private const string MultiProviderName = "MultiProvider"; + + private readonly FirstSuccessfulStrategy _strategy = new(); + private readonly FeatureProvider _mockProvider1 = Substitute.For(); + private readonly FeatureProvider _mockProvider2 = Substitute.For(); + private readonly FeatureProvider _mockProvider3 = Substitute.For(); + private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); + private readonly StrategyEvaluationContext _strategyContext = new(TestFlagKey); + + [Fact] + public void RunMode_ReturnsSequential() + { + // Act + var result = this._strategy.RunMode; + + // Assert + Assert.Equal(RunMode.Sequential, result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithSuccessfulResult_ReturnsFalse() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant1")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, successfulResult); + + // Assert + Assert.False(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithErrorResult_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.General, Reason.Error, errorMessage: "Test error")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, errorResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void ShouldEvaluateNextProvider_WithThrownException_ReturnsTrue() + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var exceptionResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, false), + new InvalidOperationException("Test exception")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, exceptionResult); + + // Assert + Assert.True(result); + } + + [Fact] + public void DetermineFinalResult_WithNoProviders_ReturnsErrorResult() + { + // Arrange + var resolutions = new List>(); + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(DefaultBoolValue, result.Details.Value); + Assert.Equal(ErrorType.ProviderNotReady, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal("No providers available or all providers failed", result.Details.ErrorMessage); + Assert.Equal(MultiProviderName, result.ProviderName); + Assert.Single(result.Errors); + Assert.Equal(MultiProviderName, result.Errors[0].ProviderName); + Assert.IsType(result.Errors[0].Error); + } + + [Fact] + public void DetermineFinalResult_WithFirstSuccessfulResult_ReturnsFirstSuccessfulResult() + { + // Arrange + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.General, Reason.Error, errorMessage: "Error from provider1")); + + var successfulResult = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant1")); + + var anotherSuccessfulResult = new ProviderResolutionResult( + this._mockProvider3, + Provider3Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.None, Reason.Static, "variant2")); + + var resolutions = new List> { errorResult, successfulResult, anotherSuccessfulResult }; + const bool defaultValue = false; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, defaultValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + Assert.Equal(Reason.Static, result.Details.Reason); + Assert.Equal("variant1", result.Details.Variant); + Assert.Equal(this._mockProvider2, result.Provider); + Assert.Equal(Provider2Name, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void DetermineFinalResult_WithAllFailedResults_ReturnsAllErrorsCollected() + { + // Arrange + var errorResult1 = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.General, Reason.Error, errorMessage: "Error from provider1")); + + var errorResult2 = new ProviderResolutionResult( + this._mockProvider2, + Provider2Name, + new ResolutionDetails(TestFlagKey, false, ErrorType.InvalidContext, Reason.Error, errorMessage: "Error from provider2")); + + var exceptionResult = new ProviderResolutionResult( + this._mockProvider3, + Provider3Name, + new ResolutionDetails(TestFlagKey, false), + new InvalidOperationException("Exception from provider3")); + + var resolutions = new List> { errorResult1, errorResult2, exceptionResult }; + const bool defaultValue = false; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, defaultValue, this._evaluationContext, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.Equal(defaultValue, result.Details.Value); + Assert.Equal(ErrorType.General, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal("All providers failed", result.Details.ErrorMessage); + Assert.Equal(MultiProviderName, result.ProviderName); + Assert.Equal(3, result.Errors.Count); + + // Verify error from provider1 + Assert.Equal(Provider1Name, result.Errors[0].ProviderName); + Assert.Equal("Error from provider1", result.Errors[0].Error?.Message); + + // Verify error from provider2 + Assert.Equal(Provider2Name, result.Errors[1].ProviderName); + Assert.Equal("Error from provider2", result.Errors[1].Error?.Message); + + // Verify exception from provider3 + Assert.Equal(Provider3Name, result.Errors[2].ProviderName); + Assert.IsType(result.Errors[2].Error); + Assert.Equal("Exception from provider3", result.Errors[2].Error?.Message); + } + + [Fact] + public void DetermineFinalResult_WithNullEvaluationContext_HandlesGracefully() + { + // Arrange + var successfulResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, true, ErrorType.None, Reason.Static, "variant1")); + + var resolutions = new List> { successfulResult }; + const bool defaultValue = false; + + // Act + var result = this._strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, defaultValue, null, resolutions); + + // Assert + Assert.NotNull(result.Details); + Assert.Equal(TestFlagKey, result.Details.FlagKey); + Assert.True(result.Details.Value); + Assert.Equal(ErrorType.None, result.Details.ErrorType); + } + + [Theory] + [InlineData(ErrorType.FlagNotFound)] + [InlineData(ErrorType.ParseError)] + [InlineData(ErrorType.TypeMismatch)] + [InlineData(ErrorType.InvalidContext)] + [InlineData(ErrorType.ProviderNotReady)] + [InlineData(ErrorType.General)] + public void ShouldEvaluateNextProvider_WithDifferentErrorTypes_ReturnsTrue(ErrorType errorType) + { + // Arrange + var strategyContext = new StrategyPerProviderContext(this._mockProvider1, Provider1Name, ProviderStatus.Ready, TestFlagKey); + var errorResult = new ProviderResolutionResult( + this._mockProvider1, + Provider1Name, + new ResolutionDetails(TestFlagKey, false, errorType, Reason.Error, errorMessage: $"Error of type {errorType}")); + + // Act + var result = this._strategy.ShouldEvaluateNextProvider(strategyContext, this._evaluationContext, errorResult); + + // Assert + Assert.True(result); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/FinalResultTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/FinalResultTests.cs new file mode 100644 index 000000000..008f61cf2 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/FinalResultTests.cs @@ -0,0 +1,260 @@ +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Strategies.Models; + +public class FinalResultTests +{ + private const string TestFlagKey = "test-flag"; + private const string TestProviderName = "test-provider"; + private const string TestVariant = "test-variant"; + private const bool TestValue = true; + + private readonly FeatureProvider _mockProvider = Substitute.For(); + private readonly ResolutionDetails _testDetails = new(TestFlagKey, TestValue, ErrorType.None, Reason.Static, TestVariant); + + [Fact] + public void Constructor_WithAllParameters_CreatesFinalResult() + { + // Arrange + var errors = new List + { + new("provider1", new InvalidOperationException("Test error")) + }; + + // Act + var result = new FinalResult(this._testDetails, this._mockProvider, TestProviderName, errors); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(errors, result.Errors); + Assert.Single(result.Errors); + } + + [Fact] + public void Constructor_WithNullErrors_CreatesEmptyErrorsList() + { + // Act + var result = new FinalResult(this._testDetails, this._mockProvider, TestProviderName, null); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.NotNull(result.Errors); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithEmptyErrors_CreatesEmptyErrorsList() + { + // Arrange + var emptyErrors = new List(); + + // Act + var result = new FinalResult(this._testDetails, this._mockProvider, TestProviderName, emptyErrors); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(emptyErrors, result.Errors); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithMultipleErrors_StoresAllErrors() + { + // Arrange + var errors = new List + { + new("provider1", new InvalidOperationException("Error 1")), + new("provider2", new ArgumentException("Error 2")), + new("provider3", null) + }; + + // Act + var result = new FinalResult(this._testDetails, this._mockProvider, TestProviderName, errors); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Equal(errors, result.Errors); + Assert.Equal(3, result.Errors.Count); + } + + [Fact] + public void Constructor_WithDifferentGenericType_CreatesTypedResult() + { + // Arrange + const string stringValue = "test-string-value"; + var stringDetails = new ResolutionDetails(TestFlagKey, stringValue, ErrorType.None, Reason.Static, TestVariant); + + // Act + var result = new FinalResult(stringDetails, this._mockProvider, TestProviderName, null); + + // Assert + Assert.Equal(stringDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithIntegerType_CreatesTypedResult() + { + // Arrange + const int intValue = 42; + var intDetails = new ResolutionDetails(TestFlagKey, intValue, ErrorType.None, Reason.Static, TestVariant); + + // Act + var result = new FinalResult(intDetails, this._mockProvider, TestProviderName, null); + + // Assert + Assert.Equal(intDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithComplexType_CreatesTypedResult() + { + // Arrange + var complexValue = new { Name = "Test", Value = 123 }; + var complexDetails = new ResolutionDetails(TestFlagKey, complexValue, ErrorType.None, Reason.Static, TestVariant); + + // Act + var result = new FinalResult(complexDetails, this._mockProvider, TestProviderName, null); + + // Assert + Assert.Equal(complexDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithErrorDetails_PreservesErrorInformation() + { + // Arrange + var errorDetails = new ResolutionDetails(TestFlagKey, false, ErrorType.ProviderNotReady, Reason.Error, errorMessage: "Provider not ready"); + var errors = new List + { + new(TestProviderName, new InvalidOperationException("Provider initialization failed")) + }; + + // Act + var result = new FinalResult(errorDetails, this._mockProvider, TestProviderName, errors); + + // Assert + Assert.Equal(errorDetails, result.Details); + Assert.Equal(ErrorType.ProviderNotReady, result.Details.ErrorType); + Assert.Equal(Reason.Error, result.Details.Reason); + Assert.Equal("Provider not ready", result.Details.ErrorMessage); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Single(result.Errors); + } + + [Fact] + public void Details_Property_HasPrivateSetter() + { + // Act & Assert + var detailsProperty = typeof(FinalResult).GetProperty(nameof(FinalResult.Details)); + Assert.NotNull(detailsProperty); + Assert.True(detailsProperty.CanRead); + Assert.True(detailsProperty.CanWrite); + Assert.True(detailsProperty.GetSetMethod(true)?.IsPrivate); + } + + [Fact] + public void Provider_Property_HasPrivateSetter() + { + // Act & Assert + var providerProperty = typeof(FinalResult).GetProperty(nameof(FinalResult.Provider)); + Assert.NotNull(providerProperty); + Assert.True(providerProperty.CanRead); + Assert.True(providerProperty.CanWrite); + Assert.True(providerProperty.GetSetMethod(true)?.IsPrivate); + } + + [Fact] + public void ProviderName_Property_HasPrivateSetter() + { + // Act & Assert + var providerNameProperty = typeof(FinalResult).GetProperty(nameof(FinalResult.ProviderName)); + Assert.NotNull(providerNameProperty); + Assert.True(providerNameProperty.CanRead); + Assert.True(providerNameProperty.CanWrite); + Assert.True(providerNameProperty.GetSetMethod(true)?.IsPrivate); + } + + [Fact] + public void Errors_Property_HasPrivateSetter() + { + // Act & Assert + var errorsProperty = typeof(FinalResult).GetProperty(nameof(FinalResult.Errors)); + Assert.NotNull(errorsProperty); + Assert.True(errorsProperty.CanRead); + Assert.True(errorsProperty.CanWrite); + Assert.True(errorsProperty.GetSetMethod(true)?.IsPrivate); + } + + [Fact] + public void Constructor_WithNullProvider_StoresNullProvider() + { + // Act + var result = new FinalResult(this._testDetails, null!, TestProviderName, null); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Null(result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithNullProviderName_StoresNullProviderName() + { + // Act + var result = new FinalResult(this._testDetails, this._mockProvider, null!, null); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Null(result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithEmptyProviderName_StoresEmptyProviderName() + { + // Act + var result = new FinalResult(this._testDetails, this._mockProvider, string.Empty, null); + + // Assert + Assert.Equal(this._testDetails, result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(string.Empty, result.ProviderName); + Assert.Empty(result.Errors); + } + + [Fact] + public void Constructor_WithNullDetails_StoresNullDetails() + { + // Act + var result = new FinalResult(null!, this._mockProvider, TestProviderName, null); + + // Assert + Assert.Null(result.Details); + Assert.Equal(this._mockProvider, result.Provider); + Assert.Equal(TestProviderName, result.ProviderName); + Assert.Empty(result.Errors); + } +} diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/ProviderErrorTests.cs b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/ProviderErrorTests.cs new file mode 100644 index 000000000..b305c2cc7 --- /dev/null +++ b/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/ProviderErrorTests.cs @@ -0,0 +1,146 @@ +using OpenFeature.Providers.MultiProvider.Strategies.Models; + +namespace OpenFeature.Tests.Providers.MultiProvider.Strategies.Models; + +public class ProviderErrorTests +{ + private const string TestProviderName = "test-provider"; + + [Fact] + public void Constructor_WithProviderNameAndException_CreatesProviderError() + { + // Arrange + var exception = new InvalidOperationException("Test exception"); + + // Act + var providerError = new ProviderError(TestProviderName, exception); + + // Assert + Assert.Equal(TestProviderName, providerError.ProviderName); + Assert.Equal(exception, providerError.Error); + } + + [Fact] + public void Constructor_WithProviderNameAndNullException_CreatesProviderError() + { + // Act + var providerError = new ProviderError(TestProviderName, null); + + // Assert + Assert.Equal(TestProviderName, providerError.ProviderName); + Assert.Null(providerError.Error); + } + + [Fact] + public void Constructor_WithNullProviderName_CreatesProviderError() + { + // Arrange + var exception = new ArgumentException("Test exception"); + + // Act + var providerError = new ProviderError(null!, exception); + + // Assert + Assert.Null(providerError.ProviderName); + Assert.Equal(exception, providerError.Error); + } + + [Fact] + public void Constructor_WithEmptyProviderName_CreatesProviderError() + { + // Arrange + var exception = new Exception("Test exception"); + + // Act + var providerError = new ProviderError(string.Empty, exception); + + // Assert + Assert.Equal(string.Empty, providerError.ProviderName); + Assert.Equal(exception, providerError.Error); + } + + [Fact] + public void Constructor_WithWhitespaceProviderName_CreatesProviderError() + { + // Arrange + const string whitespaceName = " "; + var exception = new NotSupportedException("Test exception"); + + // Act + var providerError = new ProviderError(whitespaceName, exception); + + // Assert + Assert.Equal(whitespaceName, providerError.ProviderName); + Assert.Equal(exception, providerError.Error); + } + + [Fact] + public void Constructor_WithDifferentExceptionTypes_CreatesProviderError() + { + // Arrange + var argumentException = new ArgumentException("Argument exception"); + var invalidOperationException = new InvalidOperationException("Invalid operation exception"); + var notImplementedException = new NotImplementedException("Not implemented exception"); + + // Act + var providerError1 = new ProviderError("provider1", argumentException); + var providerError2 = new ProviderError("provider2", invalidOperationException); + var providerError3 = new ProviderError("provider3", notImplementedException); + + // Assert + Assert.Equal("provider1", providerError1.ProviderName); + Assert.Equal(argumentException, providerError1.Error); + Assert.Equal("provider2", providerError2.ProviderName); + Assert.Equal(invalidOperationException, providerError2.Error); + Assert.Equal("provider3", providerError3.ProviderName); + Assert.Equal(notImplementedException, providerError3.Error); + } + + [Fact] + public void ProviderName_Property_HasPrivateSetter() + { + // Act & Assert + var providerNameProperty = typeof(ProviderError).GetProperty(nameof(ProviderError.ProviderName)); + Assert.NotNull(providerNameProperty); + Assert.True(providerNameProperty.CanRead); + Assert.True(providerNameProperty.CanWrite); + Assert.True(providerNameProperty.GetSetMethod(true)?.IsPrivate); + } + + [Fact] + public void Error_Property_HasPrivateSetter() + { + // Act & Assert + var errorProperty = typeof(ProviderError).GetProperty(nameof(ProviderError.Error)); + Assert.NotNull(errorProperty); + Assert.True(errorProperty.CanRead); + Assert.True(errorProperty.CanWrite); + Assert.True(errorProperty.GetSetMethod(true)?.IsPrivate); + } + + [Fact] + public void Constructor_WithNullProviderNameAndNullException_CreatesProviderError() + { + // Act + var providerError = new ProviderError(null!, null); + + // Assert + Assert.Null(providerError.ProviderName); + Assert.Null(providerError.Error); + } + + [Fact] + public void Constructor_WithLongProviderName_CreatesProviderError() + { + // Arrange + var longProviderName = new string('a', 1000); + var exception = new TimeoutException("Test exception"); + + // Act + var providerError = new ProviderError(longProviderName, exception); + + // Assert + Assert.Equal(longProviderName, providerError.ProviderName); + Assert.Equal(exception, providerError.Error); + } +} From 54357917ed999e97940d8982e5f810c9580c8292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Tue, 12 Aug 2025 17:12:06 +0100 Subject: [PATCH 083/126] ci: Update changelog publish details (#546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: update release-please config to hide chore, docs, and deps sections Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * docs: add changelog visibility and release triggers section to CONTRIBUTING.md Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: remove unnecessary changelog section from CONTRIBUTING.md Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- CONTRIBUTING.md | 10 ++++++++++ release-please-config.json | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98800faf8..e8415daff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,6 +130,16 @@ Please make sure you follow the latest [conventions](https://www.conventionalcom If you want to point out a breaking change, you should use `!` after the type. For example: `feat!: excellent new feature`. +### Changelog Visibility and Release Triggers + +Only certain types are visible in the generated changelog: + +- `feat`: ✨ New Features - New functionality added +- `fix`: 🐛 Bug Fixes - Bug fixes and corrections +- `perf`: 🚀 Performance - Performance improvements +- `refactor`: 🔧 Refactoring - Code changes that neither fix bugs nor add features +- `revert`: 🔙 Reverts - Reverted changes + ## Design Choices As with other OpenFeature SDKs, dotnet-sdk follows the diff --git a/release-please-config.json b/release-please-config.json index 5a0201f6d..1f778ed73 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -23,10 +23,12 @@ }, { "type": "chore", + "hidden": true, "section": "🧹 Chore" }, { "type": "docs", + "hidden": true, "section": "📚 Documentation" }, { @@ -40,6 +42,7 @@ }, { "type": "deps", + "hidden": true, "section": "📦 Dependencies" }, { @@ -49,7 +52,7 @@ }, { "type": "refactor", - "section": "🔄 Refactoring" + "section": "🔧 Refactoring" }, { "type": "revert", From ee1deb348eae463f928f04e428209129d711ce31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 14 Aug 2025 14:21:02 +0100 Subject: [PATCH 084/126] chore: move multi-provider to a separate package (#548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Move CODEOWNERS file to .github directory Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Move multi-provider to a separate package Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Add InternalsVisibleTo attributes for testing and dynamic proxy generation Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Refactor multi-provider files and update project references Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Rename test namespaces and files for multi-provider tests Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Update MultiProvider tests to remove implementation alias Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Add SBOM generation for OpenFeature.Providers.MultiProvider in release workflow Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Add documentation for OpenFeature .NET MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Simplify MultiProviderTests by removing unnecessary variables and improving readability Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Refactor ProviderEntryTests and ComparisonStrategyTests for improved clarity Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Move MultiProvider tests to use InMemoryProvider for improved isolation Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Update cancellation logic in ProviderExtensionsTests for compatibility with net462 Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/release.yml | 8 + OpenFeature.slnx | 12 +- samples/AspNetCore/Samples.AspNetCore.csproj | 1 + .../Models/ChildProviderStatus.cs | 0 .../Models/ProviderEntry.cs | 0 .../Models/RegisteredProvider.cs | 0 .../MultiProvider.cs | 0 .../MultiProviderConstants.cs | 0 ...OpenFeature.Providers.MultiProvider.csproj | 18 ++ .../ProviderExtensions.cs | 0 .../README.md | 192 ++++++++++++++++++ .../Strategies/BaseEvaluationStrategy.cs | 0 .../Strategies/ComparisonStrategy.cs | 0 .../Strategies/FirstMatchStrategy.cs | 0 .../Strategies/FirstSuccessfulStrategy.cs | 0 .../Strategies/Models/FinalResult.cs | 0 .../Strategies/Models/ProviderError.cs | 0 .../Models/ProviderResolutionResult.cs | 0 .../Strategies/Models/RunMode.cs | 0 .../Models/StrategyEvaluationContext.cs | 0 .../Models/StrategyPerProviderContext.cs | 0 src/OpenFeature/OpenFeature.csproj | 1 + .../Models/ChildProviderEntryTests.cs | 11 +- .../Models/ProviderStatusTests.cs | 2 +- .../Models/RegisteredProviderTests.cs | 5 +- .../MultiProviderTests.cs | 99 +++++---- ...ature.Providers.MultiProvider.Tests.csproj | 35 ++++ .../ProviderExtensionsTests.cs | 7 +- .../Strategies/BaseEvaluationStrategyTests.cs | 2 +- .../Strategies/ComparisonStrategyTests.cs | 6 +- .../Strategies/FirstMatchStrategyTests.cs | 2 +- .../FirstSuccessfulStrategyTests.cs | 2 +- .../Strategies/Models/FinalResultTests.cs | 2 +- .../Strategies/Models/ProviderErrorTests.cs | 2 +- 34 files changed, 327 insertions(+), 80 deletions(-) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Models/ChildProviderStatus.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Models/ProviderEntry.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Models/RegisteredProvider.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/MultiProvider.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/MultiProviderConstants.cs (100%) create mode 100644 src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/ProviderExtensions.cs (100%) create mode 100644 src/OpenFeature.Providers.MultiProvider/README.md rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Strategies/BaseEvaluationStrategy.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Strategies/ComparisonStrategy.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Strategies/FirstMatchStrategy.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Strategies/FirstSuccessfulStrategy.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Strategies/Models/FinalResult.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Strategies/Models/ProviderError.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Strategies/Models/ProviderResolutionResult.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Strategies/Models/RunMode.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Strategies/Models/StrategyEvaluationContext.cs (100%) rename src/{OpenFeature/Providers/MultiProvider => OpenFeature.Providers.MultiProvider}/Strategies/Models/StrategyPerProviderContext.cs (100%) rename test/{OpenFeature.Tests/Providers/MultiProvider => OpenFeature.Providers.MultiProvider.Tests}/Models/ChildProviderEntryTests.cs (89%) rename test/{OpenFeature.Tests/Providers/MultiProvider => OpenFeature.Providers.MultiProvider.Tests}/Models/ProviderStatusTests.cs (98%) rename test/{OpenFeature.Tests/Providers/MultiProvider => OpenFeature.Providers.MultiProvider.Tests}/Models/RegisteredProviderTests.cs (95%) rename test/{OpenFeature.Tests/Providers/MultiProvider => OpenFeature.Providers.MultiProvider.Tests}/MultiProviderTests.cs (87%) create mode 100644 test/OpenFeature.Providers.MultiProvider.Tests/OpenFeature.Providers.MultiProvider.Tests.csproj rename test/{OpenFeature.Tests/Providers/MultiProvider => OpenFeature.Providers.MultiProvider.Tests}/ProviderExtensionsTests.cs (98%) rename test/{OpenFeature.Tests/Providers/MultiProvider => OpenFeature.Providers.MultiProvider.Tests}/Strategies/BaseEvaluationStrategyTests.cs (99%) rename test/{OpenFeature.Tests/Providers/MultiProvider => OpenFeature.Providers.MultiProvider.Tests}/Strategies/ComparisonStrategyTests.cs (98%) rename test/{OpenFeature.Tests/Providers/MultiProvider => OpenFeature.Providers.MultiProvider.Tests}/Strategies/FirstMatchStrategyTests.cs (99%) rename test/{OpenFeature.Tests/Providers/MultiProvider => OpenFeature.Providers.MultiProvider.Tests}/Strategies/FirstSuccessfulStrategyTests.cs (99%) rename test/{OpenFeature.Tests/Providers/MultiProvider => OpenFeature.Providers.MultiProvider.Tests}/Strategies/Models/FinalResultTests.cs (99%) rename test/{OpenFeature.Tests/Providers/MultiProvider => OpenFeature.Providers.MultiProvider.Tests}/Strategies/Models/ProviderErrorTests.cs (98%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 727f5c9b7..c4c4aa97b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,3 +96,11 @@ jobs: github-token: ${{secrets.GITHUB_TOKEN}} project-name: OpenFeature.DependencyInjection release-tag: ${{ needs.release-please.outputs.release_tag_name }} + + # Process OpenFeature.Providers.MultiProvider project + - name: Generate and Attest SBOM for OpenFeature.Providers.MultiProvider + uses: ./.github/actions/sbom-generator + with: + github-token: ${{secrets.GITHUB_TOKEN}} + project-name: OpenFeature.Providers.MultiProvider + release-tag: ${{ needs.release-please.outputs.release_tag_name }} diff --git a/OpenFeature.slnx b/OpenFeature.slnx index d6778e50e..0f445b446 100644 --- a/OpenFeature.slnx +++ b/OpenFeature.slnx @@ -5,7 +5,7 @@ - + @@ -51,18 +51,20 @@ - - + + + - + + - + \ No newline at end of file diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj index b6223bd04..413de0096 100644 --- a/samples/AspNetCore/Samples.AspNetCore.csproj +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -9,6 +9,7 @@ + diff --git a/src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs b/src/OpenFeature.Providers.MultiProvider/Models/ChildProviderStatus.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Models/ChildProviderStatus.cs rename to src/OpenFeature.Providers.MultiProvider/Models/ChildProviderStatus.cs diff --git a/src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs b/src/OpenFeature.Providers.MultiProvider/Models/ProviderEntry.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Models/ProviderEntry.cs rename to src/OpenFeature.Providers.MultiProvider/Models/ProviderEntry.cs diff --git a/src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs b/src/OpenFeature.Providers.MultiProvider/Models/RegisteredProvider.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Models/RegisteredProvider.cs rename to src/OpenFeature.Providers.MultiProvider/Models/RegisteredProvider.cs diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProvider.cs b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/MultiProvider.cs rename to src/OpenFeature.Providers.MultiProvider/MultiProvider.cs diff --git a/src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs b/src/OpenFeature.Providers.MultiProvider/MultiProviderConstants.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/MultiProviderConstants.cs rename to src/OpenFeature.Providers.MultiProvider/MultiProviderConstants.cs diff --git a/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj b/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj new file mode 100644 index 000000000..000f223b5 --- /dev/null +++ b/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj @@ -0,0 +1,18 @@ + + + + net8.0;net9.0;netstandard2.0;net462 + OpenFeature.Providers.MultiProvider + README.md + + + + + + + + + + + + diff --git a/src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs b/src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/ProviderExtensions.cs rename to src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs diff --git a/src/OpenFeature.Providers.MultiProvider/README.md b/src/OpenFeature.Providers.MultiProvider/README.md new file mode 100644 index 000000000..4465da0f8 --- /dev/null +++ b/src/OpenFeature.Providers.MultiProvider/README.md @@ -0,0 +1,192 @@ +# OpenFeature .NET MultiProvider + +[![NuGet](https://img.shields.io/nuget/vpre/OpenFeature.Providers.MultiProvider)](https://www.nuget.org/packages/OpenFeature.Providers.MultiProvider) + +The MultiProvider is a feature provider that enables the use of multiple underlying providers, allowing different providers to be used for different flag keys or based on specific routing logic. This enables scenarios where different feature flags may be served by different sources or providers within the same application. + +## Overview + +The MultiProvider acts as a composite provider that can delegate flag resolution to different underlying providers based on configuration or routing rules. It supports various evaluation strategies to determine how multiple providers should be evaluated and how their results should be combined. + +For more information about the MultiProvider specification, see the [OpenFeature Multi Provider specification](https://openfeature.dev/specification/appendix-a/#multi-provider). + +## Installation + +```shell +dotnet add package OpenFeature.Providers.MultiProvider +``` + +## Usage + +### Basic Setup + +```csharp +using OpenFeature; +using OpenFeature.Providers.MultiProvider; + +// Create your individual providers +var primaryProvider = new YourPrimaryProvider(); +var fallbackProvider = new YourFallbackProvider(); + +// Create provider entries +var providerEntries = new[] +{ + new ProviderEntry(primaryProvider, "primary"), + new ProviderEntry(fallbackProvider, "fallback") +}; + +// Create and set the MultiProvider +var multiProvider = new MultiProvider(providerEntries); +await Api.Instance.SetProviderAsync(multiProvider); + +// Use the client as normal +var client = Api.Instance.GetClient(); +var result = await client.GetBooleanValueAsync("my-flag", false); +``` + +### Evaluation Strategies + +The MultiProvider supports several evaluation strategies to determine how providers are evaluated: + +#### 1. FirstMatchStrategy (Default) + +Returns the first result that does not indicate "flag not found". Providers are evaluated sequentially in the order they were configured. + +```csharp +using OpenFeature.Providers.MultiProvider.Strategies; + +var strategy = new FirstMatchStrategy(); +var multiProvider = new MultiProvider(providerEntries, strategy); +``` + +#### 2. FirstSuccessfulStrategy + +Returns the first result that does not result in an error. If any provider returns an error, it's ignored as long as there is a successful result. + +```csharp +using OpenFeature.Providers.MultiProvider.Strategies; + +var strategy = new FirstSuccessfulStrategy(); +var multiProvider = new MultiProvider(providerEntries, strategy); +``` + +#### 3. ComparisonStrategy + +Evaluates all providers and compares their results. Useful for testing or validation scenarios where you want to ensure providers return consistent values. + +```csharp +using OpenFeature.Providers.MultiProvider.Strategies; + +var strategy = new ComparisonStrategy(); +var multiProvider = new MultiProvider(providerEntries, strategy); +``` + +### Advanced Configuration + +#### Named Providers + +You can assign names to providers for better identification and debugging: + +```csharp +var providerEntries = new[] +{ + new ProviderEntry(new ProviderA(), "provider-a"), + new ProviderEntry(new ProviderB(), "provider-b"), + new ProviderEntry(new ProviderC(), "provider-c") +}; +``` + +#### Custom Evaluation Context + +The MultiProvider respects evaluation context and passes it to underlying providers: + +```csharp +var context = EvaluationContext.Builder() + .Set("userId", "user123") + .Set("environment", "production") + .Build(); + +var result = await client.GetBooleanValueAsync("feature-flag", false, context); +``` + +## Use Cases + +### Primary/Fallback Configuration + +Use multiple providers with fallback capabilities: + +```csharp +var providerEntries = new[] +{ + new ProviderEntry(new RemoteProvider(), "remote"), + new ProviderEntry(new LocalCacheProvider(), "cache"), + new ProviderEntry(new StaticProvider(), "static") +}; + +var multiProvider = new MultiProvider(providerEntries, new FirstSuccessfulStrategy()); +``` + +### A/B Testing Provider Comparison + +Compare results from different providers for testing purposes: + +```csharp +var providerEntries = new[] +{ + new ProviderEntry(new ProviderA(), "provider-a"), + new ProviderEntry(new ProviderB(), "provider-b") +}; + +var multiProvider = new MultiProvider(providerEntries, new ComparisonStrategy()); +``` + +### Migration Scenarios + +Gradually migrate from one provider to another: + +```csharp +var providerEntries = new[] +{ + new ProviderEntry(new NewProvider(), "new-provider"), + new ProviderEntry(new LegacyProvider(), "legacy-provider") +}; + +var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); +``` + +## Error Handling + +The MultiProvider handles errors from underlying providers according to the chosen evaluation strategy: + +- **FirstMatchStrategy**: Throws errors immediately when encountered +- **FirstSuccessfulStrategy**: Ignores errors if there's a successful result, throws all errors if all providers fail +- **ComparisonStrategy**: Collects and reports all errors for analysis + +## Thread Safety + +The MultiProvider is thread-safe and can be used concurrently across multiple threads. It properly handles initialization and shutdown of underlying providers. + +## Lifecycle Management + +The MultiProvider manages the lifecycle of all registered providers: + +```csharp +// Initialize all providers +await multiProvider.InitializeAsync(context); + +// Shutdown all providers +await multiProvider.ShutdownAsync(); + +// Dispose (implements IAsyncDisposable) +await multiProvider.DisposeAsync(); +``` + +## Requirements + +- .NET 8+ +- .NET Framework 4.6.2+ +- .NET Standard 2.0+ + +## Contributing + +See the [OpenFeature .NET SDK contributing guide](../../CONTRIBUTING.md) for details on how to contribute to this project. diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs b/src/OpenFeature.Providers.MultiProvider/Strategies/BaseEvaluationStrategy.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Strategies/BaseEvaluationStrategy.cs rename to src/OpenFeature.Providers.MultiProvider/Strategies/BaseEvaluationStrategy.cs diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs b/src/OpenFeature.Providers.MultiProvider/Strategies/ComparisonStrategy.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Strategies/ComparisonStrategy.cs rename to src/OpenFeature.Providers.MultiProvider/Strategies/ComparisonStrategy.cs diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs b/src/OpenFeature.Providers.MultiProvider/Strategies/FirstMatchStrategy.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Strategies/FirstMatchStrategy.cs rename to src/OpenFeature.Providers.MultiProvider/Strategies/FirstMatchStrategy.cs diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs b/src/OpenFeature.Providers.MultiProvider/Strategies/FirstSuccessfulStrategy.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Strategies/FirstSuccessfulStrategy.cs rename to src/OpenFeature.Providers.MultiProvider/Strategies/FirstSuccessfulStrategy.cs diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs b/src/OpenFeature.Providers.MultiProvider/Strategies/Models/FinalResult.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Strategies/Models/FinalResult.cs rename to src/OpenFeature.Providers.MultiProvider/Strategies/Models/FinalResult.cs diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs b/src/OpenFeature.Providers.MultiProvider/Strategies/Models/ProviderError.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderError.cs rename to src/OpenFeature.Providers.MultiProvider/Strategies/Models/ProviderError.cs diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs b/src/OpenFeature.Providers.MultiProvider/Strategies/Models/ProviderResolutionResult.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Strategies/Models/ProviderResolutionResult.cs rename to src/OpenFeature.Providers.MultiProvider/Strategies/Models/ProviderResolutionResult.cs diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs b/src/OpenFeature.Providers.MultiProvider/Strategies/Models/RunMode.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Strategies/Models/RunMode.cs rename to src/OpenFeature.Providers.MultiProvider/Strategies/Models/RunMode.cs diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs b/src/OpenFeature.Providers.MultiProvider/Strategies/Models/StrategyEvaluationContext.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyEvaluationContext.cs rename to src/OpenFeature.Providers.MultiProvider/Strategies/Models/StrategyEvaluationContext.cs diff --git a/src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs b/src/OpenFeature.Providers.MultiProvider/Strategies/Models/StrategyPerProviderContext.cs similarity index 100% rename from src/OpenFeature/Providers/MultiProvider/Strategies/Models/StrategyPerProviderContext.cs rename to src/OpenFeature.Providers.MultiProvider/Strategies/Models/StrategyPerProviderContext.cs diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj index 2b1983959..243ab850c 100644 --- a/src/OpenFeature/OpenFeature.csproj +++ b/src/OpenFeature/OpenFeature.csproj @@ -20,6 +20,7 @@ + diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/Models/ChildProviderEntryTests.cs similarity index 89% rename from test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs rename to test/OpenFeature.Providers.MultiProvider.Tests/Models/ChildProviderEntryTests.cs index 69bb62322..7b420f928 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ChildProviderEntryTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/Models/ChildProviderEntryTests.cs @@ -1,7 +1,7 @@ using NSubstitute; using OpenFeature.Providers.MultiProvider.Models; -namespace OpenFeature.Tests.Providers.MultiProvider.Models; +namespace OpenFeature.Providers.MultiProvider.Tests.Models; public class ChildProviderEntryTests { @@ -44,7 +44,7 @@ public void Constructor_WithNullProvider_ThrowsArgumentNullException() public void Constructor_WithNullName_CreatesProviderEntryWithNullName() { // Act - var providerEntry = new ProviderEntry(this._mockProvider, null); + var providerEntry = new ProviderEntry(this._mockProvider); // Assert Assert.Equal(this._mockProvider, providerEntry.Provider); @@ -65,9 +65,6 @@ public void Constructor_WithEmptyName_CreatesProviderEntryWithEmptyName() [Fact] public void Provider_Property_IsReadOnly() { - // Arrange - var providerEntry = new ProviderEntry(this._mockProvider); - // Act & Assert // Verify that Provider property is read-only by checking it has no setter var providerProperty = typeof(ProviderEntry).GetProperty(nameof(ProviderEntry.Provider)); @@ -79,10 +76,6 @@ public void Provider_Property_IsReadOnly() [Fact] public void Name_Property_IsReadOnly() { - // Arrange - const string customName = "test-name"; - var providerEntry = new ProviderEntry(this._mockProvider, customName); - // Act & Assert // Verify that Name property is read-only by checking it has no setter var nameProperty = typeof(ProviderEntry).GetProperty(nameof(ProviderEntry.Name)); diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/Models/ProviderStatusTests.cs similarity index 98% rename from test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs rename to test/OpenFeature.Providers.MultiProvider.Tests/Models/ProviderStatusTests.cs index ad3990aaa..6deac2ea8 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Models/ProviderStatusTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/Models/ProviderStatusTests.cs @@ -1,6 +1,6 @@ using OpenFeature.Providers.MultiProvider.Models; -namespace OpenFeature.Tests.Providers.MultiProvider.Models; +namespace OpenFeature.Providers.MultiProvider.Tests.Models; public class ProviderStatusTests { diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/Models/RegisteredProviderTests.cs similarity index 95% rename from test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs rename to test/OpenFeature.Providers.MultiProvider.Tests/Models/RegisteredProviderTests.cs index 8734775a7..9c24475ae 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Models/RegisteredProviderTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/Models/RegisteredProviderTests.cs @@ -1,7 +1,8 @@ using NSubstitute; +using OpenFeature.Providers.Memory; using OpenFeature.Providers.MultiProvider.Models; -namespace OpenFeature.Tests.Providers.MultiProvider.Models; +namespace OpenFeature.Providers.MultiProvider.Tests.Models; public class RegisteredProviderTests { @@ -105,7 +106,7 @@ public void Constructor_WithDifferentProvidersAndSameName_CreatesDistinctInstanc public void SetStatus_WithDifferentStatuses_UpdatesCorrectly(Constant.ProviderStatus status) { // Arrange - var registeredProvider = new RegisteredProvider(new TestProvider(), "test"); + var registeredProvider = new RegisteredProvider(new InMemoryProvider(), "test"); // Act registeredProvider.SetStatus(status); diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs similarity index 87% rename from test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs rename to test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs index bf1dfb4e6..1e4ddaf2f 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/MultiProviderTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs @@ -6,9 +6,8 @@ using OpenFeature.Providers.MultiProvider.Models; using OpenFeature.Providers.MultiProvider.Strategies; using OpenFeature.Providers.MultiProvider.Strategies.Models; -using MultiProviderImplementation = OpenFeature.Providers.MultiProvider; -namespace OpenFeature.Tests.Providers.MultiProvider; +namespace OpenFeature.Providers.MultiProvider.Tests; public class MultiProviderClassTests { @@ -48,7 +47,7 @@ public void Constructor_WithValidProviderEntries_CreatesMultiProvider() }; // Act - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); // Assert Assert.NotNull(multiProvider); @@ -60,18 +59,15 @@ public void Constructor_WithValidProviderEntries_CreatesMultiProvider() public void Constructor_WithNullProviderEntries_ThrowsArgumentNullException() { // Act & Assert - var exception = Assert.Throws(() => new MultiProviderImplementation.MultiProvider(null!, this._mockStrategy)); + var exception = Assert.Throws(() => new MultiProvider(null!, this._mockStrategy)); Assert.Equal("providerEntries", exception.ParamName); } [Fact] public void Constructor_WithEmptyProviderEntries_ThrowsArgumentException() { - // Arrange - var emptyProviderEntries = new List(); - // Act & Assert - var exception = Assert.Throws(() => new MultiProviderImplementation.MultiProvider(emptyProviderEntries, this._mockStrategy)); + var exception = Assert.Throws(() => new MultiProvider([], this._mockStrategy)); Assert.Contains("At least one provider entry must be provided", exception.Message); Assert.Equal("providerEntries", exception.ParamName); } @@ -83,7 +79,7 @@ public void Constructor_WithNullStrategy_UsesDefaultFirstMatchStrategy() var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; // Act - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, null); + var multiProvider = new MultiProvider(providerEntries); // Assert Assert.NotNull(multiProvider); @@ -102,7 +98,7 @@ public void Constructor_WithDuplicateExplicitNames_ThrowsArgumentException() }; // Act & Assert - var exception = Assert.Throws(() => new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy)); + var exception = Assert.Throws(() => new MultiProvider(providerEntries, this._mockStrategy)); Assert.Contains("Multiple providers cannot have the same explicit name: 'duplicate-name'", exception.Message); } @@ -116,7 +112,7 @@ public async Task ResolveBooleanValueAsync_CallsEvaluateAsync() var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); @@ -139,7 +135,7 @@ public async Task ResolveStringValueAsync_CallsEvaluateAsync() var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); @@ -160,7 +156,7 @@ public async Task InitializeAsync_WithAllSuccessfulProviders_InitializesAllProvi new(this._mockProvider1, Provider1Name), new(this._mockProvider2, Provider2Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); this._mockProvider2.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); @@ -183,7 +179,7 @@ public async Task InitializeAsync_WithSomeFailingProviders_ThrowsAggregateExcept new(this._mockProvider1, Provider1Name), new(this._mockProvider2, Provider2Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); this._mockProvider2.InitializeAsync(this._evaluationContext, Arg.Any()).ThrowsAsync(expectedException); @@ -204,7 +200,7 @@ public async Task ShutdownAsync_WithAllSuccessfulProviders_ShutsDownAllProviders new(this._mockProvider1, Provider1Name), new(this._mockProvider2, Provider2Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); multiProvider.SetStatus(ProviderStatus.Ready); this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); @@ -227,7 +223,7 @@ public async Task ShutdownAsync_WithFatalProvider_ShutsDownAllProviders() new(this._mockProvider1, Provider1Name), new(this._mockProvider2, Provider2Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); multiProvider.SetStatus(ProviderStatus.Fatal); this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); @@ -251,7 +247,7 @@ public async Task ShutdownAsync_WithSomeFailingProviders_ThrowsAggregateExceptio new(this._mockProvider1, Provider1Name), new(this._mockProvider2, Provider2Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); multiProvider.SetStatus(ProviderStatus.Ready); this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); @@ -269,7 +265,7 @@ public void GetMetadata_ReturnsMultiProviderMetadata() { // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); // Act var metadata = multiProvider.GetMetadata(); @@ -289,7 +285,7 @@ public async Task ResolveDoubleValueAsync_CallsEvaluateAsync() var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); @@ -312,7 +308,7 @@ public async Task ResolveIntegerValueAsync_CallsEvaluateAsync() var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); @@ -334,7 +330,7 @@ public async Task ResolveStructureValueAsync_CallsEvaluateAsync() var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, defaultValue, this._evaluationContext, Arg.Any>>()) .Returns(finalResult); @@ -360,7 +356,7 @@ public async Task EvaluateAsync_WithSequentialMode_EvaluatesProvidersSequentiall new(this._mockProvider1, Provider1Name), new(this._mockProvider2, Provider2Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.RunMode.Returns(RunMode.Sequential); this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext).Returns(true); @@ -394,7 +390,7 @@ public async Task EvaluateAsync_WithParallelMode_EvaluatesProvidersInParallel() new(this._mockProvider1, Provider1Name), new(this._mockProvider2, Provider2Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.RunMode.Returns(RunMode.Parallel); this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext).Returns(true); @@ -421,7 +417,7 @@ public async Task EvaluateAsync_WithUnsupportedRunMode_ThrowsNotSupportedExcepti // Arrange const bool defaultValue = false; var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.RunMode.Returns((RunMode)999); // Invalid enum value @@ -445,7 +441,7 @@ public async Task EvaluateAsync_WithStrategySkippingProvider_DoesNotCallSkippedP new(this._mockProvider1, Provider1Name), new(this._mockProvider2, Provider2Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.RunMode.Returns(RunMode.Sequential); this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext) @@ -478,7 +474,7 @@ public async Task EvaluateAsync_WithCancellationToken_PassesCancellationTokenToP var finalResult = new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null); var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockStrategy.RunMode.Returns(RunMode.Sequential); this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), this._evaluationContext).Returns(true); @@ -515,7 +511,7 @@ public void Constructor_WithProvidersHavingSameMetadataName_AssignsUniqueNames() }; // Act - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); // Assert Assert.NotNull(multiProvider); @@ -534,7 +530,7 @@ public void Constructor_WithProviderHavingNullMetadata_AssignsDefaultName() var providerEntries = new List { new(provider) }; // Act - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); // Assert Assert.NotNull(multiProvider); @@ -553,7 +549,7 @@ public void Constructor_WithProviderHavingNullMetadataName_AssignsDefaultName() var providerEntries = new List { new(provider) }; // Act - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); // Assert Assert.NotNull(multiProvider); @@ -566,7 +562,7 @@ public async Task InitializeAsync_WithCancellationToken_PassesCancellationTokenT { // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); @@ -584,7 +580,7 @@ public async Task ShutdownAsync_WithCancellationToken_PassesCancellationTokenToP { // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); multiProvider.SetStatus(ProviderStatus.Ready); this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); @@ -608,7 +604,7 @@ public async Task InitializeAsync_WithAllSuccessfulProviders_CompletesWithoutExc new(this._mockProvider2, Provider2Name), new(this._mockProvider3, Provider3Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); this._mockProvider1.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); this._mockProvider2.InitializeAsync(this._evaluationContext, Arg.Any()).Returns(Task.CompletedTask); @@ -633,7 +629,7 @@ public async Task ShutdownAsync_WithAllSuccessfulProviders_CompletesWithoutExcep new(this._mockProvider2, Provider2Name), new(this._mockProvider3, Provider3Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); multiProvider.SetStatus(ProviderStatus.Ready); this._mockProvider1.ShutdownAsync(Arg.Any()).Returns(Task.CompletedTask); @@ -654,7 +650,6 @@ public async Task MultiProvider_ConcurrentInitializationAndShutdown_ShouldMainta { // Arrange const int providerCount = 20; - var random = new Random(); var providerEntries = new List(); for (int i = 0; i < providerCount; i++) @@ -673,7 +668,7 @@ public async Task MultiProvider_ConcurrentInitializationAndShutdown_ShouldMainta providerEntries.Add(new ProviderEntry(provider)); } - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries); + var multiProvider = new MultiProvider(providerEntries); // Act: simulate concurrent initialization and shutdown with one task each var initTasks = Enumerable.Range(0, 1).Select(_ => @@ -699,7 +694,7 @@ public async Task MultiProvider_ConcurrentInitializationAndShutdown_ShouldMainta // Consider replacing this with an internal or public method if testing becomes more frequent. IEnumerable GetRegisteredStatuses() { - var field = typeof(MultiProviderImplementation.MultiProvider).GetField("_registeredProviders", BindingFlags.NonPublic | BindingFlags.Instance); + var field = typeof(MultiProvider).GetField("_registeredProviders", BindingFlags.NonPublic | BindingFlags.Instance); if (field?.GetValue(multiProvider) is not IEnumerable list) throw new InvalidOperationException("Could not retrieve registered providers via reflection."); @@ -722,7 +717,7 @@ public async Task DisposeAsync_ShouldDisposeInternalResources() { // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); // Act await multiProvider.DisposeAsync(); @@ -737,7 +732,7 @@ public async Task DisposeAsync_CalledMultipleTimes_ShouldNotThrow() { // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); // Act & Assert - Multiple calls to Dispose should not throw await multiProvider.DisposeAsync(); @@ -753,7 +748,7 @@ public async Task InitializeAsync_AfterDispose_ShouldThrowObjectDisposedExceptio { // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); // Act await multiProvider.DisposeAsync(); @@ -761,7 +756,7 @@ public async Task InitializeAsync_AfterDispose_ShouldThrowObjectDisposedExceptio // Assert var exception = await Assert.ThrowsAsync(() => multiProvider.InitializeAsync(this._evaluationContext)); - Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), exception.ObjectName); + Assert.Equal(nameof(MultiProvider), exception.ObjectName); } [Fact] @@ -769,7 +764,7 @@ public async Task ShutdownAsync_AfterDispose_ShouldThrowObjectDisposedException( { // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); // Act await multiProvider.DisposeAsync(); @@ -777,7 +772,7 @@ public async Task ShutdownAsync_AfterDispose_ShouldThrowObjectDisposedException( // Assert var exception = await Assert.ThrowsAsync(() => multiProvider.ShutdownAsync()); - Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), exception.ObjectName); + Assert.Equal(nameof(MultiProvider), exception.ObjectName); } [Fact] @@ -785,13 +780,13 @@ public async Task InitializeAsync_WhenAlreadyDisposed_DuringExecution_ShouldExit { // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); // Dispose before calling InitializeAsync await multiProvider.DisposeAsync(); // Act & Assert - var exception = await Assert.ThrowsAsync(() => + await Assert.ThrowsAsync(() => multiProvider.InitializeAsync(this._evaluationContext)); // Verify that the underlying provider was never called since the object was disposed @@ -803,13 +798,13 @@ public async Task ShutdownAsync_WhenAlreadyDisposed_DuringExecution_ShouldExitEa { // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); // Dispose before calling ShutdownAsync await multiProvider.DisposeAsync(); // Act & Assert - var exception = await Assert.ThrowsAsync(() => + await Assert.ThrowsAsync(() => multiProvider.ShutdownAsync()); // Verify that the underlying provider was never called since the object was disposed @@ -821,7 +816,7 @@ public async Task EvaluateAsync_AfterDispose_ShouldThrowObjectDisposedException( { // Arrange var providerEntries = new List { new(this._mockProvider1, Provider1Name) }; - var multiProvider = new MultiProviderImplementation.MultiProvider(providerEntries, this._mockStrategy); + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); // Act await multiProvider.DisposeAsync(); @@ -829,23 +824,23 @@ public async Task EvaluateAsync_AfterDispose_ShouldThrowObjectDisposedException( // Assert - All evaluate methods should throw ObjectDisposedException var boolException = await Assert.ThrowsAsync(() => multiProvider.ResolveBooleanValueAsync(TestFlagKey, false)); - Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), boolException.ObjectName); + Assert.Equal(nameof(MultiProvider), boolException.ObjectName); var stringException = await Assert.ThrowsAsync(() => multiProvider.ResolveStringValueAsync(TestFlagKey, "default")); - Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), stringException.ObjectName); + Assert.Equal(nameof(MultiProvider), stringException.ObjectName); var intException = await Assert.ThrowsAsync(() => multiProvider.ResolveIntegerValueAsync(TestFlagKey, 0)); - Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), intException.ObjectName); + Assert.Equal(nameof(MultiProvider), intException.ObjectName); var doubleException = await Assert.ThrowsAsync(() => multiProvider.ResolveDoubleValueAsync(TestFlagKey, 0.0)); - Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), doubleException.ObjectName); + Assert.Equal(nameof(MultiProvider), doubleException.ObjectName); var structureException = await Assert.ThrowsAsync(() => multiProvider.ResolveStructureValueAsync(TestFlagKey, new Value())); - Assert.Equal(nameof(MultiProviderImplementation.MultiProvider), structureException.ObjectName); + Assert.Equal(nameof(MultiProvider), structureException.ObjectName); } } diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/OpenFeature.Providers.MultiProvider.Tests.csproj b/test/OpenFeature.Providers.MultiProvider.Tests/OpenFeature.Providers.MultiProvider.Tests.csproj new file mode 100644 index 000000000..f1f016c64 --- /dev/null +++ b/test/OpenFeature.Providers.MultiProvider.Tests/OpenFeature.Providers.MultiProvider.Tests.csproj @@ -0,0 +1,35 @@ + + + + net8.0;net9.0 + $(TargetFrameworks);net462 + OpenFeature.Providers.MultiProvider.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/ProviderExtensionsTests.cs similarity index 98% rename from test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs rename to test/OpenFeature.Providers.MultiProvider.Tests/ProviderExtensionsTests.cs index 702fc3973..f37e0ddf3 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/ProviderExtensionsTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/ProviderExtensionsTests.cs @@ -2,10 +2,9 @@ using NSubstitute.ExceptionExtensions; using OpenFeature.Constant; using OpenFeature.Model; -using OpenFeature.Providers.MultiProvider; using OpenFeature.Providers.MultiProvider.Strategies.Models; -namespace OpenFeature.Tests.Providers.MultiProvider; +namespace OpenFeature.Providers.MultiProvider.Tests; public class ProviderExtensionsTests { @@ -285,8 +284,10 @@ public async Task EvaluateAsync_WhenOperationCancelled_ReturnsErrorResult() var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext, cancellationTokenSource.Token) - .Returns(async callInfo => + .Returns(async _ => { + // net462 does not support CancellationTokenSource.CancelAfter + // ReSharper disable once MethodHasAsyncOverload cancellationTokenSource.Cancel(); await Task.Delay(100, cancellationTokenSource.Token); return new ResolutionDetails(TestFlagKey, true); diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/Strategies/BaseEvaluationStrategyTests.cs similarity index 99% rename from test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs rename to test/OpenFeature.Providers.MultiProvider.Tests/Strategies/BaseEvaluationStrategyTests.cs index f2960be07..a585ef0c9 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/BaseEvaluationStrategyTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/Strategies/BaseEvaluationStrategyTests.cs @@ -4,7 +4,7 @@ using OpenFeature.Providers.MultiProvider.Strategies; using OpenFeature.Providers.MultiProvider.Strategies.Models; -namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; +namespace OpenFeature.Providers.MultiProvider.Tests.Strategies; public class BaseEvaluationStrategyTests { diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/Strategies/ComparisonStrategyTests.cs similarity index 98% rename from test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs rename to test/OpenFeature.Providers.MultiProvider.Tests/Strategies/ComparisonStrategyTests.cs index 480ef6b90..e57006eae 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/ComparisonStrategyTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/Strategies/ComparisonStrategyTests.cs @@ -4,7 +4,7 @@ using OpenFeature.Providers.MultiProvider.Strategies; using OpenFeature.Providers.MultiProvider.Strategies.Models; -namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; +namespace OpenFeature.Providers.MultiProvider.Tests.Strategies; public class ComparisonStrategyTests { @@ -326,7 +326,7 @@ public void DetermineFinalResult_WithDisagreeingProvidersAndOnMismatchCallback_C var resolutions = new List> { result1, result2 }; // Act - var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); // Assert Assert.True(onMismatchCalled); @@ -361,7 +361,7 @@ public void DetermineFinalResult_WithAgreingProvidersAndOnMismatchCallback_DoesN var resolutions = new List> { result1, result2 }; // Act - var result = strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); + strategy.DetermineFinalResult(this._strategyContext, TestFlagKey, DefaultBoolValue, this._evaluationContext, resolutions); // Assert Assert.False(onMismatchCalled); diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/Strategies/FirstMatchStrategyTests.cs similarity index 99% rename from test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs rename to test/OpenFeature.Providers.MultiProvider.Tests/Strategies/FirstMatchStrategyTests.cs index 8c95ef00d..de89b27bc 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstMatchStrategyTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/Strategies/FirstMatchStrategyTests.cs @@ -4,7 +4,7 @@ using OpenFeature.Providers.MultiProvider.Strategies; using OpenFeature.Providers.MultiProvider.Strategies.Models; -namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; +namespace OpenFeature.Providers.MultiProvider.Tests.Strategies; public class FirstMatchStrategyTests { diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/Strategies/FirstSuccessfulStrategyTests.cs similarity index 99% rename from test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs rename to test/OpenFeature.Providers.MultiProvider.Tests/Strategies/FirstSuccessfulStrategyTests.cs index da0d87409..687579cc2 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/FirstSuccessfulStrategyTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/Strategies/FirstSuccessfulStrategyTests.cs @@ -4,7 +4,7 @@ using OpenFeature.Providers.MultiProvider.Strategies; using OpenFeature.Providers.MultiProvider.Strategies.Models; -namespace OpenFeature.Tests.Providers.MultiProvider.Strategies; +namespace OpenFeature.Providers.MultiProvider.Tests.Strategies; public class FirstSuccessfulStrategyTests { diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/FinalResultTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/Strategies/Models/FinalResultTests.cs similarity index 99% rename from test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/FinalResultTests.cs rename to test/OpenFeature.Providers.MultiProvider.Tests/Strategies/Models/FinalResultTests.cs index 008f61cf2..98b8bc87a 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/FinalResultTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/Strategies/Models/FinalResultTests.cs @@ -3,7 +3,7 @@ using OpenFeature.Model; using OpenFeature.Providers.MultiProvider.Strategies.Models; -namespace OpenFeature.Tests.Providers.MultiProvider.Strategies.Models; +namespace OpenFeature.Providers.MultiProvider.Tests.Strategies.Models; public class FinalResultTests { diff --git a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/ProviderErrorTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/Strategies/Models/ProviderErrorTests.cs similarity index 98% rename from test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/ProviderErrorTests.cs rename to test/OpenFeature.Providers.MultiProvider.Tests/Strategies/Models/ProviderErrorTests.cs index b305c2cc7..35c6f965b 100644 --- a/test/OpenFeature.Tests/Providers/MultiProvider/Strategies/Models/ProviderErrorTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/Strategies/Models/ProviderErrorTests.cs @@ -1,6 +1,6 @@ using OpenFeature.Providers.MultiProvider.Strategies.Models; -namespace OpenFeature.Tests.Providers.MultiProvider.Strategies.Models; +namespace OpenFeature.Providers.MultiProvider.Tests.Strategies.Models; public class ProviderErrorTests { From c54bf56e3691d296e3789ab014a3e678d981b9a8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:14:38 +0100 Subject: [PATCH 085/126] chore(deps): update spec digest to 969e11c (#557) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index baec39b3f..969e11c4d 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit baec39b3fe886667a0e94a902c22ca7b8486a36d +Subproject commit 969e11c4d5df4ab16b400965ef1b3e313dcb923e From 90d6b1a48b66cb40c78cdddeec1918e565052c08 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:15:08 +0100 Subject: [PATCH 086/126] chore(deps): update github/codeql-action digest to 96f518a (#556) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 57050602a..df020a29b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3 + uses: github/codeql-action/init@96f518a34f7a870018057716cc4d7a5c014bd61c # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3 + uses: github/codeql-action/autobuild@96f518a34f7a870018057716cc4d7a5c014bd61c # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4e828ff8d448a8a6e532957b1811f387a63867e8 # v3 + uses: github/codeql-action/analyze@96f518a34f7a870018057716cc4d7a5c014bd61c # v3 From 19cde01e85f3aadf04d9ce1e72f3d5b4257fd14e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:18:05 +0100 Subject: [PATCH 087/126] chore(deps): update actions/checkout action to v5 (#559) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/code-coverage.yml | 2 +- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/dotnet-format.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/release.yml | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f72a15f3..3b08f7d5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: fetch-depth: 0 submodules: recursive @@ -70,7 +70,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: fetch-depth: 0 submodules: recursive diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 4a8e7d05f..f8f9deadd 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -22,7 +22,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: fetch-depth: 0 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index df020a29b..a02300437 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index 75f603750..19205c6b9 100644 --- a/.github/workflows/dotnet-format.yml +++ b/.github/workflows/dotnet-format.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - name: Setup .NET SDK uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e1e577385..3a063e6ca 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -17,7 +17,7 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4c4aa97b..e7414ccb5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: if: ${{ fromJSON(needs.release-please.outputs.release_created || false) }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: fetch-depth: 0 From 5777d0e12577e2cdd7e5dce304edeb785e0beb06 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:18:24 +0100 Subject: [PATCH 088/126] chore(deps): update actions/cache action to v4.2.4 (#558) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/code-coverage.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/release.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b08f7d5f..8276f5c7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: source-url: https://nuget.pkg.github.com/open-feature/index.json - name: Cache NuGet packages - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} @@ -84,7 +84,7 @@ jobs: source-url: https://nuget.pkg.github.com/open-feature/index.json - name: Cache NuGet packages - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index f8f9deadd..19093263b 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -35,7 +35,7 @@ jobs: source-url: https://nuget.pkg.github.com/open-feature/index.json - name: Cache NuGet packages - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3a063e6ca..70b987c66 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -30,7 +30,7 @@ jobs: source-url: https://nuget.pkg.github.com/open-feature/index.json - name: Cache NuGet packages - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7414ccb5..ed9f7e304 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: source-url: https://nuget.pkg.github.com/open-feature/index.json - name: Cache NuGet packages - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} From 5630d2b441e96e60446cb912f2a5f889fd1a37b4 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Tue, 19 Aug 2025 16:34:03 +0100 Subject: [PATCH 089/126] chore: Add README and Deprecation notice to DependencyInjection library (#530) * Add README to DependencyInjection library * Add deprecation notice in README * Ensure release-please-config includes extra readme file Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Update README to specify v2.9 in deprecated notice Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- release-please-config.json | 3 +- .../OpenFeature.DependencyInjection.csproj | 1 + src/OpenFeature.DependencyInjection/README.md | 48 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/OpenFeature.DependencyInjection/README.md diff --git a/release-please-config.json b/release-please-config.json index 1f778ed73..6baeed441 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -8,7 +8,8 @@ "versioning": "default", "extra-files": [ "build/Common.prod.props", - "README.md" + "README.md", + "src/OpenFeature.DependencyInjection/README.md" ] } }, diff --git a/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj index 855ab2ab2..923473715 100644 --- a/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj +++ b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj @@ -17,6 +17,7 @@ + diff --git a/src/OpenFeature.DependencyInjection/README.md b/src/OpenFeature.DependencyInjection/README.md new file mode 100644 index 000000000..6b9fcfe72 --- /dev/null +++ b/src/OpenFeature.DependencyInjection/README.md @@ -0,0 +1,48 @@ +# OpenFeature.DependencyInjection + +> **⚠️ DEPRECATED**: This library is now deprecated. The OpenTelemetry Dependency Injection library has been moved to the OpenFeature Hosting integration in version 2.9.0. + +OpenFeature is an open standard for feature flag management, created to support a robust feature flag ecosystem using cloud native technologies. OpenFeature will provide a unified API and SDK, and a developer-first, cloud-native implementation, with extensibility for open source and commercial offerings. + +## Migration Guide + +If you are using `OpenFeature.DependencyInjection`, you should migrate to the `OpenFeature.Hosting` package. The hosting package provides the same functionality but in one package. + +### 1. Update dependencies + +Remove this package: + +```xml + +``` + +Update or install the latest `OpenFeature.Hosting` package: + +```xml + +``` + +### 2. Update your `Program.cs` + +Remove the `AddHostedFeatureLifecycle` method call. + +#### Before + +```csharp +builder.Services.AddOpenFeature(featureBuilder => +{ + featureBuilder + .AddHostedFeatureLifecycle(); + + // Omit for code brevity +}); +``` + +#### After + +```csharp +builder.Services.AddOpenFeature(featureBuilder => +{ + // Omit for code brevity +}); +``` From d2510970f4290d5991c35d5f8c60fd49315e01d2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 25 Aug 2025 07:26:44 +0100 Subject: [PATCH 090/126] chore(deps): update github/codeql-action digest to 3c3833e (#561) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a02300437..1fee02def 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@96f518a34f7a870018057716cc4d7a5c014bd61c # v3 + uses: github/codeql-action/init@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@96f518a34f7a870018057716cc4d7a5c014bd61c # v3 + uses: github/codeql-action/autobuild@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@96f518a34f7a870018057716cc4d7a5c014bd61c # v3 + uses: github/codeql-action/analyze@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3 From 355220981c36a8238fa96efd045b33470b0834dd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 25 Aug 2025 07:27:07 +0100 Subject: [PATCH 091/126] chore(deps): update googleapis/release-please-action digest to c2a5a2b (#562) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed9f7e304..45480d363 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: googleapis/release-please-action@a02a34c4d625f9be7cb89156071d8567266a2445 #v4 + - uses: googleapis/release-please-action@c2a5a2bd6a758a0937f1ddb1e8950609867ed15c # v4 id: release with: token: ${{secrets.RELEASE_PLEASE_ACTION_TOKEN}} From 5d89c378d6c6d7ff868f22a3fb38684104122511 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 25 Aug 2025 06:27:44 +0000 Subject: [PATCH 092/126] chore(deps): update codecov/codecov-action action to v5.5.0 (#563) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/code-coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 19093263b..5d8a9b8c4 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -45,7 +45,7 @@ jobs: - name: Run Test run: dotnet test --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover - - uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 + - uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5.5.0 with: name: Code Coverage for ${{ matrix.os }} fail_ci_if_error: true From 4915f2b316ae0a509fab3ddc8470c44f789d265f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 25 Aug 2025 06:28:10 +0000 Subject: [PATCH 093/126] chore(deps): update amannn/action-semantic-pull-request digest to e32d7e6 (#564) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/lint-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index f23079276..e091a2a75 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -15,6 +15,6 @@ jobs: contents: read pull-requests: write steps: - - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5 + - uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From acd0486563f7b67a782ee169315922fb5d0f343e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:00:42 +0100 Subject: [PATCH 094/126] perf: Add NativeAOT Support (#554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enhance ValueJsonConverter for AOT compatibility with manual JSON handling Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: refactor EnumExtensions to improve AOT compatibility and remove reflection Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add OpenFeatureJsonSerializerContext for AOT compilation support Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add AOT and trimming support for net8.0 and net9.0 Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add NativeAOT compatibility tests and project configuration Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: update project structure for AOT compatibility and add MultiProvider tests Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: remove unnecessary Type attribute project in solution file Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add unit tests for EnumExtensions.GetDescription method Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: remove trimming support properties for net8.0 and net9.0 Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add AOT compatibility workflow with cross-platform testing and size comparison Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: simplify AOT compatibility workflow by removing unnecessary properties and AspNetCore sample tests Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: update AOT compatibility workflow to include runtime in publish step and standardize comment formatting Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: update AOT compatibility workflow to streamline ARM64 handling and switch to PowerShell for script execution Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: standardize shell usage and update publish command syntax in AOT compatibility workflow Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: update AOT size comparison report to remove AspNetCore sample column and enhance binary size logging Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: remove AOT size comparison job and artifact upload steps from workflow Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: update AOT compatibility workflow permissions and enhance documentation for NativeAOT support Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: streamline AOT compatibility documentation by removing redundant sections and enhancing clarity Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: update actions/checkout and actions/cache versions in AOT compatibility workflow Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Weihan Li Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Update .github/workflows/aot-compatibility.yml Co-authored-by: Weihan Li Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: remove unnecessary properties from AOT project configuration Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * docs: update README to clarify NativeAOT compatibility for contrib and community providers Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Weihan Li Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: add descriptions to ErrorType enum values for better clarity Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: remove AOT compatibility references and enhance error handling tests Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: update System.Text.Json package reference in project files Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> Co-authored-by: Weihan Li --- .github/workflows/aot-compatibility.yml | 95 ++++++ Directory.Packages.props | 4 +- OpenFeature.slnx | 7 +- README.md | 18 +- build/Common.prod.props | 9 +- docs/AOT_COMPATIBILITY.md | 152 +++++++++ src/OpenFeature/Extension/EnumExtensions.cs | 27 +- src/OpenFeature/Model/ValueJsonConverter.cs | 4 +- src/OpenFeature/OpenFeature.csproj | 2 +- .../OpenFeatureJsonSerializerContext.cs | 28 ++ .../OpenFeature.AotCompatibility.csproj | 34 ++ test/OpenFeature.AotCompatibility/Program.cs | 299 ++++++++++++++++++ test/OpenFeature.Tests/EnumExtensionsTests.cs | 26 ++ 13 files changed, 686 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/aot-compatibility.yml create mode 100644 docs/AOT_COMPATIBILITY.md create mode 100644 src/OpenFeature/Serialization/OpenFeatureJsonSerializerContext.cs create mode 100644 test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj create mode 100644 test/OpenFeature.AotCompatibility/Program.cs create mode 100644 test/OpenFeature.Tests/EnumExtensionsTests.cs diff --git a/.github/workflows/aot-compatibility.yml b/.github/workflows/aot-compatibility.yml new file mode 100644 index 000000000..7d158474e --- /dev/null +++ b/.github/workflows/aot-compatibility.yml @@ -0,0 +1,95 @@ +name: AOT Compatibility + +on: + push: + branches: [main] + pull_request: + branches: [main] + merge_group: + workflow_dispatch: + +jobs: + aot-compatibility: + name: AOT Test (${{ matrix.os }}, ${{ matrix.arch }}) + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + # Linux x64 + - os: ubuntu-latest + arch: x64 + runtime: linux-x64 + # Linux ARM64 + - os: ubuntu-24.04-arm + arch: arm64 + runtime: linux-arm64 + # Windows x64 + - os: windows-latest + arch: x64 + runtime: win-x64 + # Windows ARM64 + - os: windows-11-arm + arch: arm64 + runtime: win-arm64 + # macOS x64 + - os: macos-13 + arch: x64 + runtime: osx-x64 + # macOS ARM64 (Apple Silicon) + - os: macos-latest + arch: arm64 + runtime: osx-arm64 + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup .NET SDK + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + global-json-file: global.json + + - name: Cache NuGet packages + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-${{ matrix.arch }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.arch }}-nuget- + ${{ runner.os }}-nuget- + + - name: Restore dependencies + shell: pwsh + run: dotnet restore + + - name: Build solution + shell: pwsh + run: dotnet build -c Release --no-restore + + - name: Test AOT compatibility project build + shell: pwsh + run: dotnet build test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj -c Release --no-restore + + - name: Publish AOT compatibility test (cross-platform) + shell: pwsh + run: | + dotnet publish test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj ` + -r ${{ matrix.runtime }} ` + -o ./aot-output + + - name: Run AOT compatibility test + shell: pwsh + run: | + if ("${{ runner.os }}" -eq "Windows") { + ./aot-output/OpenFeature.AotCompatibility.exe + } else { + chmod +x ./aot-output/OpenFeature.AotCompatibility + ./aot-output/OpenFeature.AotCompatibility + } diff --git a/Directory.Packages.props b/Directory.Packages.props index fe88537d8..8f6550782 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,8 +23,7 @@ - + @@ -36,6 +35,7 @@ + diff --git a/OpenFeature.slnx b/OpenFeature.slnx index 0f445b446..fa407cd3b 100644 --- a/OpenFeature.slnx +++ b/OpenFeature.slnx @@ -53,7 +53,7 @@ - + @@ -64,7 +64,8 @@ - + + - \ No newline at end of file + diff --git a/README.md b/README.md index 2da256cd8..c263023f9 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ Note that the packages will aim to support all current .NET versions. Refer to the currently supported versions [.NET](https://dotnet.microsoft.com/download/dotnet) and [.NET Framework](https://dotnet.microsoft.com/download/dotnet-framework) excluding .NET Framework 3.5 +### NativeAOT Support + +✅ **Full NativeAOT Compatibility** - The OpenFeature .NET SDK is fully compatible with .NET NativeAOT compilation for fast startup and small deployment size. See the [AOT Compatibility Guide](docs/AOT_COMPATIBILITY.md) for detailed instructions. + +> While the core OpenFeature SDK is fully NativeAOT compatible, contrib and community-provided providers, hooks, and extensions may not be. Please check with individual provider/hook documentation for their NativeAOT compatibility status. + ### Install Use the following to initialize your project: @@ -720,12 +726,12 @@ For this hook to function correctly a global `MeterProvider` must be set. Below are the metrics extracted by this hook and dimensions they carry: -| Metric key | Description | Unit | Dimensions | -| -------------------------------------- | ------------------------------- | ------------ | ----------------------------- | -| feature_flag.evaluation_requests_total | Number of evaluation requests | request | key, provider name | -| feature_flag.evaluation_success_total | Flag evaluation successes | impression | key, provider name, reason | -| feature_flag.evaluation_error_total | Flag evaluation errors | 1 | key, provider name, exception | -| feature_flag.evaluation_active_count | Active flag evaluations counter | 1 | key, provider name | +| Metric key | Description | Unit | Dimensions | +| -------------------------------------- | ------------------------------- | ---------- | ----------------------------- | +| feature_flag.evaluation_requests_total | Number of evaluation requests | request | key, provider name | +| feature_flag.evaluation_success_total | Flag evaluation successes | impression | key, provider name, reason | +| feature_flag.evaluation_error_total | Flag evaluation errors | 1 | key, provider name, exception | +| feature_flag.evaluation_active_count | Active flag evaluations counter | 1 | key, provider name | Consider the following code example for usage. diff --git a/build/Common.prod.props b/build/Common.prod.props index 89451aca7..7feb1759c 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -1,5 +1,5 @@ - + true @@ -24,8 +24,13 @@ $(VersionNumber) + + + true + + - + diff --git a/docs/AOT_COMPATIBILITY.md b/docs/AOT_COMPATIBILITY.md new file mode 100644 index 000000000..afa6f1e73 --- /dev/null +++ b/docs/AOT_COMPATIBILITY.md @@ -0,0 +1,152 @@ +# OpenFeature .NET SDK - NativeAOT Compatibility + +The OpenFeature .NET SDK is compatible with .NET NativeAOT compilation, allowing you to create self-contained, native executables with faster startup times and lower memory usage. + +## Compatibility Status + +**Fully Compatible** - The SDK can be used in NativeAOT applications without any issues. + +### What's AOT-Compatible + +- Core API functionality (`Api.Instance`, `GetClient()`, flag evaluations) +- All built-in providers (`NoOpProvider`, etc.) +- JSON serialization of `Value`, `Structure`, and `EvaluationContext` +- Error handling and enum descriptions +- Hook system +- Event handling +- Metrics collection +- Dependency injection + +## Using OpenFeature with NativeAOT + +### 1. Project Configuration + +To enable NativeAOT in your project, add these properties to your `.csproj` file: + +```xml + + + net8.0 + Exe + + + true + + + + + + +``` + +### 2. Basic Usage + +```csharp +using OpenFeature; +using OpenFeature.Model; + +// Basic OpenFeature usage - fully AOT compatible +var api = Api.Instance; +var client = api.GetClient("my-app"); + +// All flag evaluation methods work +var boolFlag = await client.GetBooleanValueAsync("feature-enabled", false); +var stringFlag = await client.GetStringValueAsync("welcome-message", "Hello"); +var intFlag = await client.GetIntegerValueAsync("max-items", 10); +``` + +### 3. JSON Serialization (Recommended) + +For optimal AOT performance, use the provided `JsonSerializerContext`: + +```csharp +using System.Text.Json; +using OpenFeature.Model; +using OpenFeature.Serialization; + +var value = new Value(Structure.Builder() + .Set("name", "test") + .Set("enabled", true) + .Build()); + +// Use AOT-compatible serialization +var json = JsonSerializer.Serialize(value, OpenFeatureJsonSerializerContext.Default.Value); +var deserialized = JsonSerializer.Deserialize(json, OpenFeatureJsonSerializerContext.Default.Value); +``` + +### 4. Publishing for NativeAOT + +Build and publish your AOT application: + +```bash +# Build with AOT analysis +dotnet build -c Release + +# Publish as native executable +dotnet publish -c Release + +# Run the native executable (example path for macOS ARM64) +./bin/Release/net9.0/osx-arm64/publish/MyApp +``` + +## Performance Benefits + +NativeAOT compilation provides several benefits: + +- **Faster Startup**: Native executables start faster than JIT-compiled applications +- **Lower Memory Usage**: Reduced memory footprint +- **Self-Contained**: No .NET runtime dependency required +- **Smaller Deployment**: Optimized for size with trimming + +## Testing AOT Compatibility + +The SDK includes an AOT compatibility test project at `test/OpenFeature.AotCompatibility/` that: + +- Tests all core SDK functionality +- Validates JSON serialization with source generation +- Verifies error handling works correctly +- Can be compiled and run as a native executable + +Run the test: + +```bash +cd test/OpenFeature.AotCompatibility +dotnet publish -c Release +./bin/Release/net9.0/[runtime]/publish/OpenFeature.AotCompatibility +``` + +## Limitations + +Currently, there are no known limitations when using OpenFeature with NativeAOT. All core functionality is fully supported. + +## Provider Compatibility + +When using third-party providers, ensure they are also AOT-compatible. Check the provider's documentation for AOT support. + +## Troubleshooting + +### Trimming Warnings + +If you encounter trimming warnings, you can: + +1. Use the provided `JsonSerializerContext` for JSON operations +2. Ensure your providers are AOT-compatible +3. Add appropriate `[DynamicallyAccessedMembers]` attributes if needed + +### Build Issues + +- Ensure you're targeting .NET 8.0 or later +- Verify all dependencies support NativeAOT +- Check that `PublishAot` is set to `true` + +## Migration Guide + +If migrating from a non-AOT setup: + +1. **JSON Serialization**: Replace direct `JsonSerializer` calls with the provided context +2. **Reflection**: The SDK no longer uses reflection, but ensure your custom code doesn't +3. **Dynamic Loading**: Avoid dynamic assembly loading; register providers at compile time + +## Example AOT Application + +See the complete example in `test/OpenFeature.AotCompatibility/Program.cs` for a working AOT application that demonstrates all SDK features. diff --git a/src/OpenFeature/Extension/EnumExtensions.cs b/src/OpenFeature/Extension/EnumExtensions.cs index 73c391250..be84ca3f0 100644 --- a/src/OpenFeature/Extension/EnumExtensions.cs +++ b/src/OpenFeature/Extension/EnumExtensions.cs @@ -1,13 +1,32 @@ -using System.ComponentModel; +using OpenFeature.Constant; namespace OpenFeature.Extension; internal static class EnumExtensions { + /// + /// Gets the description of an enum value without using reflection. + /// This is AOT-compatible and only supports specific known enum types. + /// + /// The enum value to get the description for + /// The description string or the enum value as string if no description is available public static string GetDescription(this Enum value) { - var field = value.GetType().GetField(value.ToString()); - var attribute = field?.GetCustomAttributes(typeof(DescriptionAttribute), false).FirstOrDefault() as DescriptionAttribute; - return attribute?.Description ?? value.ToString(); + return value switch + { + // ErrorType descriptions + ErrorType.None => "NONE", + ErrorType.ProviderNotReady => "PROVIDER_NOT_READY", + ErrorType.FlagNotFound => "FLAG_NOT_FOUND", + ErrorType.ParseError => "PARSE_ERROR", + ErrorType.TypeMismatch => "TYPE_MISMATCH", + ErrorType.General => "GENERAL", + ErrorType.InvalidContext => "INVALID_CONTEXT", + ErrorType.TargetingKeyMissing => "TARGETING_KEY_MISSING", + ErrorType.ProviderFatal => "PROVIDER_FATAL", + + // Fallback for any other enum types + _ => value.ToString() + }; } } diff --git a/src/OpenFeature/Model/ValueJsonConverter.cs b/src/OpenFeature/Model/ValueJsonConverter.cs index 911cc45fd..7ffbf9c14 100644 --- a/src/OpenFeature/Model/ValueJsonConverter.cs +++ b/src/OpenFeature/Model/ValueJsonConverter.cs @@ -5,7 +5,9 @@ namespace OpenFeature.Model; /// -/// A for for Json serialization +/// A for for Json serialization. +/// This converter is AOT-compatible as it uses manual JSON reading/writing +/// instead of reflection-based serialization. /// public sealed class ValueJsonConverter : JsonConverter { diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj index 243ab850c..4a964ef51 100644 --- a/src/OpenFeature/OpenFeature.csproj +++ b/src/OpenFeature/OpenFeature.csproj @@ -24,4 +24,4 @@ - \ No newline at end of file + diff --git a/src/OpenFeature/Serialization/OpenFeatureJsonSerializerContext.cs b/src/OpenFeature/Serialization/OpenFeatureJsonSerializerContext.cs new file mode 100644 index 000000000..820474cb4 --- /dev/null +++ b/src/OpenFeature/Serialization/OpenFeatureJsonSerializerContext.cs @@ -0,0 +1,28 @@ +using System.Collections.Immutable; +using System.Text.Json.Serialization; +using OpenFeature.Model; + +namespace OpenFeature.Serialization; + +/// +/// JSON serializer context for AOT compilation support. +/// This ensures that all necessary types are pre-compiled for JSON serialization +/// when using NativeAOT. +/// +[JsonSerializable(typeof(Value))] +[JsonSerializable(typeof(Structure))] +[JsonSerializable(typeof(EvaluationContext))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(ImmutableDictionary))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(ImmutableList))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(double))] +[JsonSerializable(typeof(DateTime))] +[JsonSourceGenerationOptions( + WriteIndented = false, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +public partial class OpenFeatureJsonSerializerContext : JsonSerializerContext; diff --git a/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj b/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj new file mode 100644 index 000000000..d416bd75b --- /dev/null +++ b/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + Exe + enable + enable + + + true + true + + + false + NU1903 + OpenFeature.AotCompatibility + + + + + + + + + + + + + + + + + + diff --git a/test/OpenFeature.AotCompatibility/Program.cs b/test/OpenFeature.AotCompatibility/Program.cs new file mode 100644 index 000000000..5529eef21 --- /dev/null +++ b/test/OpenFeature.AotCompatibility/Program.cs @@ -0,0 +1,299 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Serialization; + +namespace OpenFeature.AotCompatibility; + +/// +/// This program validates OpenFeature SDK compatibility with NativeAOT. +/// It tests core functionality to ensure everything works correctly when compiled with AOT. +/// +internal class Program +{ + private static async Task Main(string[] args) + { + Console.WriteLine("OpenFeature NativeAOT Compatibility Test"); + Console.WriteLine("=========================================="); + + try + { + // Test basic API functionality + await TestBasicApiAsync(); + + // Test MultiProvider AOT compatibility + await TestMultiProviderAotCompatibilityAsync(); + + // Test JSON serialization with AOT-compatible serializer context + TestJsonSerialization(); + + // Test dependency injection + await TestDependencyInjectionAsync(); + + // Test error handling and enum descriptions + TestErrorHandling(); + + Console.WriteLine("\nAll tests passed! OpenFeature is AOT-compatible."); + } + catch (Exception ex) + { + Console.WriteLine($"\nAOT compatibility test failed: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + Environment.Exit(1); + } + } + + private static async Task TestBasicApiAsync() + { + Console.WriteLine("\nTesting basic API functionality..."); + + // Test singleton instance access + var api = Api.Instance; + Console.WriteLine($"✓- API instance created: {api.GetType().Name}"); + + // Test client creation + var client = api.GetClient("test-client", "1.0.0"); + Console.WriteLine($"✓- Client created: {client.GetType().Name}"); + + // Test flag evaluation with default provider (NoOpProvider) + var boolResult = await client.GetBooleanValueAsync("test-flag", false); + Console.WriteLine($"✓- Boolean flag evaluation: {boolResult}"); + + var stringResult = await client.GetStringValueAsync("test-string-flag", "default"); + Console.WriteLine($"✓- String flag evaluation: {stringResult}"); + + var intResult = await client.GetIntegerValueAsync("test-int-flag", 42); + Console.WriteLine($"✓- Integer flag evaluation: {intResult}"); + + var doubleResult = await client.GetDoubleValueAsync("test-double-flag", 3.14); + Console.WriteLine($"✓- Double flag evaluation: {doubleResult}"); + + // Test evaluation context + var context = EvaluationContext.Builder() + .Set("userId", "user123") + .Set("enabled", true) + .Build(); + api.SetContext(context); + Console.WriteLine($"✓- Evaluation context set with {context.Count} attributes"); + + // Test error flag with AOT-compatible GetDescription() + await TestErrorFlagAsync(client); + } + + private static async Task TestErrorFlagAsync(IFeatureClient client) + { + Console.WriteLine("\nTesting error flag with GetDescription()..."); + + // Set a test provider that can return errors + await Api.Instance.SetProviderAsync(new TestProvider()); + + // Test the error flag - this will internally trigger GetDescription() in the SDK's error handling + var errorResult = await client.GetBooleanDetailsAsync("error-flag", false); + Console.WriteLine($"✓- Error flag evaluation: {errorResult.Value} (Error: {errorResult.ErrorType})"); + Console.WriteLine($"✓- Error message: '{errorResult.ErrorMessage}'"); + Console.WriteLine("✓- GetDescription() method was executed internally by the SDK during error handling"); + } + + private static async Task TestMultiProviderAotCompatibilityAsync() + { + Console.WriteLine("\nTesting MultiProvider AOT compatibility..."); + + // Create test providers for MultiProvider + var primaryProvider = new TestProvider(); + var fallbackProvider = new TestProvider(); + + // Create provider entries for MultiProvider + var providerEntries = new List + { + new(primaryProvider, "primary"), new(fallbackProvider, "fallback") + }; + + // Test MultiProvider creation with FirstMatchStrategy (default) + var multiProvider = new MultiProvider(providerEntries); + Console.WriteLine($"✓- MultiProvider created with {providerEntries.Count} providers"); + + // Test MultiProvider metadata + var metadata = multiProvider.GetMetadata(); + Console.WriteLine($"✓- MultiProvider metadata: {metadata.Name}"); + + await TestStrategy(providerEntries, new FirstMatchStrategy(), "FirstMatchStrategy"); + await TestStrategy(providerEntries, new ComparisonStrategy(), "ComparisonStrategy"); + await TestStrategy(providerEntries, new FirstSuccessfulStrategy(), "FirstSuccessfulStrategy"); + } + + private static async Task TestStrategy(List providerEntries, BaseEvaluationStrategy strategy, string strategyName) + { + // Test MultiProvider with strategy + var multiProvider = new MultiProvider(providerEntries, strategy); + Console.WriteLine($"✓- MultiProvider created with {strategyName}"); + + // Test all value types with MultiProvider + var evaluationContext = EvaluationContext.Builder() + .Set("userId", "aot-test-user") + .Set("environment", "test") + .Build(); + + // Test boolean evaluation + var boolResult = await multiProvider.ResolveBooleanValueAsync("test-bool-flag", false, evaluationContext); + Console.WriteLine($"✓- MultiProvider boolean evaluation: {boolResult.Value} (from {boolResult.Variant})"); + + // Test string evaluation + var stringResult = + await multiProvider.ResolveStringValueAsync("test-string-flag", "default", evaluationContext); + Console.WriteLine($"✓- MultiProvider string evaluation: {stringResult.Value} (from {stringResult.Variant})"); + + // Test integer evaluation + var intResult = await multiProvider.ResolveIntegerValueAsync("test-int-flag", 0, evaluationContext); + Console.WriteLine($"✓- MultiProvider integer evaluation: {intResult.Value} (from {intResult.Variant})"); + + // Test double evaluation + var doubleResult = await multiProvider.ResolveDoubleValueAsync("test-double-flag", 0.0, evaluationContext); + Console.WriteLine($"✓- MultiProvider double evaluation: {doubleResult.Value} (from {doubleResult.Variant})"); + + // Test structure evaluation + var structureResult = + await multiProvider.ResolveStructureValueAsync("test-structure-flag", new Value("default"), + evaluationContext); + Console.WriteLine( + $"✓- MultiProvider structure evaluation: {structureResult.Value} (from {structureResult.Variant})"); + + // Test MultiProvider lifecycle + await multiProvider.InitializeAsync(evaluationContext); + Console.WriteLine("✓- MultiProvider initialization completed"); + + await multiProvider.ShutdownAsync(); + Console.WriteLine("✓- MultiProvider shutdown completed"); + + // Test MultiProvider disposal + await multiProvider.DisposeAsync(); + Console.WriteLine("✓- MultiProvider disposal completed"); + } + + private static void TestJsonSerialization() + { + Console.WriteLine("\nTesting JSON serialization with AOT context..."); + + // Test Value serialization with AOT-compatible context + var structureBuilder = Structure.Builder() + .Set("name", "test") + .Set("enabled", true) + .Set("count", 42) + .Set("score", 98.5); + + var structure = structureBuilder.Build(); + var value = new Value(structure); + + try + { + // Serialize using the AOT-compatible context + var json = JsonSerializer.Serialize(value, OpenFeatureJsonSerializerContext.Default.Value); + Console.WriteLine($"✓- Value serialized to JSON: {json}"); + + // Deserialize back + var deserializedValue = JsonSerializer.Deserialize(json, OpenFeatureJsonSerializerContext.Default.Value); + Console.WriteLine($"✓- Value deserialized from JSON successfully: {value}", deserializedValue); + } + catch (Exception ex) + { + // Fallback test with the custom converter (should still work) + Console.WriteLine($"X- AOT context serialization failed, testing fallback: {ex.Message}"); + } + } + + private static async Task TestDependencyInjectionAsync() + { + Console.WriteLine("\nTesting dependency injection..."); + + var builder = Host.CreateApplicationBuilder(); + + // Add OpenFeature with DI + builder.Services.AddOpenFeature(of => of.AddProvider(_ => new TestProvider()).AddHook(_ => new TestHook())); + + builder.Services.AddLogging(logging => logging.AddConsole()); + + using var host = builder.Build(); + + var api = host.Services.GetRequiredService(); + Console.WriteLine($"✓- FeatureClient resolved from DI: {api.GetType().Name}"); + + var result = await api.GetIntegerValueAsync("di-test-flag", 1); + Console.WriteLine($"✓- Flag evaluation via DI: {result}"); + } + + private static void TestErrorHandling() + { + Console.WriteLine("\nTesting error handling and enum descriptions..."); + + // Test ErrorType enum values (GetDescription will be called internally by the SDK) + var errorTypes = new[] + { + ErrorType.None, ErrorType.ProviderNotReady, ErrorType.FlagNotFound, ErrorType.ParseError, + ErrorType.TypeMismatch, ErrorType.General, ErrorType.InvalidContext, ErrorType.TargetingKeyMissing, + ErrorType.ProviderFatal + }; + + foreach (var errorType in errorTypes) + { + // Just validate the enum values exist and are accessible in AOT + Console.WriteLine($"✓- ErrorType.{errorType} is accessible in AOT compilation"); + } + + Console.WriteLine("✓- All ErrorType enum values validated for AOT compatibility"); + Console.WriteLine("✓- GetDescription() method will be exercised internally when errors occur"); + } +} + +/// +/// A simple test provider for validating DI functionality +/// +internal class TestProvider : FeatureProvider +{ + public override Metadata GetMetadata() => new("test-provider"); + + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + if (flagKey == "error-flag") + { + // Return an error for the "error-flag" key using constructor parameters + return Task.FromResult(new ResolutionDetails( + flagKey: flagKey, + value: defaultValue, + errorType: ErrorType.FlagNotFound, + errorMessage: "The flag key was not found." + )); + } + + return Task.FromResult(new ResolutionDetails(flagKey, true)); + } + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + => Task.FromResult(new ResolutionDetails(flagKey, "test-value")); + + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + => Task.FromResult(new ResolutionDetails(flagKey, 123)); + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + => Task.FromResult(new ResolutionDetails(flagKey, 123.45)); + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) + => Task.FromResult(new ResolutionDetails(flagKey, new Value("test"))); +} + +/// +/// A simple test hook for validating DI functionality +/// +internal class TestHook : Hook +{ + // No implementation needed for this test +} diff --git a/test/OpenFeature.Tests/EnumExtensionsTests.cs b/test/OpenFeature.Tests/EnumExtensionsTests.cs new file mode 100644 index 000000000..35e61a2e0 --- /dev/null +++ b/test/OpenFeature.Tests/EnumExtensionsTests.cs @@ -0,0 +1,26 @@ +using OpenFeature.Constant; +using OpenFeature.Extension; + +namespace OpenFeature.Tests; + +public class EnumExtensionsTests +{ + [Theory] + [InlineData(ErrorType.None, "NONE")] + [InlineData(ErrorType.ProviderNotReady, "PROVIDER_NOT_READY")] + [InlineData(ErrorType.FlagNotFound, "FLAG_NOT_FOUND")] + [InlineData(ErrorType.ParseError, "PARSE_ERROR")] + [InlineData(ErrorType.TypeMismatch, "TYPE_MISMATCH")] + [InlineData(ErrorType.General, "GENERAL")] + [InlineData(ErrorType.InvalidContext, "INVALID_CONTEXT")] + [InlineData(ErrorType.TargetingKeyMissing, "TARGETING_KEY_MISSING")] + [InlineData(ErrorType.ProviderFatal, "PROVIDER_FATAL")] + public void GetDescription_WithErrorType_ReturnsExpectedDescription(ErrorType errorType, string expectedDescription) + { + // Act + var result = errorType.GetDescription(); + + // Assert + Assert.Equal(expectedDescription, result); + } +} From 07d27dd962d5c22ad0d264830442420e7b78ceff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 25 Aug 2025 19:58:29 +0100 Subject: [PATCH 095/126] ci: Fix actions cache (#567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: update cache key to include additional project files Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore: update cache key to include additional project files for NuGet caching Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/aot-compatibility.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/code-coverage.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/release.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/aot-compatibility.yml b/.github/workflows/aot-compatibility.yml index 7d158474e..4f2ce14e8 100644 --- a/.github/workflows/aot-compatibility.yml +++ b/.github/workflows/aot-compatibility.yml @@ -60,7 +60,7 @@ jobs: uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ~/.nuget/packages - key: ${{ runner.os }}-${{ matrix.arch }}-nuget-${{ hashFiles('**/*.csproj') }} + key: ${{ runner.os }}-${{ matrix.arch }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} restore-keys: | ${{ runner.os }}-${{ matrix.arch }}-nuget- ${{ runner.os }}-nuget- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8276f5c7a..14c90f864 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} restore-keys: | ${{ runner.os }}-nuget- @@ -87,7 +87,7 @@ jobs: uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} restore-keys: | ${{ runner.os }}-nuget- diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 5d8a9b8c4..6a45f0b4b 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -38,7 +38,7 @@ jobs: uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} restore-keys: | ${{ runner.os }}-nuget- diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 70b987c66..e9f3f79cd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -33,7 +33,7 @@ jobs: uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} restore-keys: | ${{ runner.os }}-nuget- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45480d363..6b372121f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,7 +55,7 @@ jobs: uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} restore-keys: | ${{ runner.os }}-nuget- From 81ac214f4108b0fd26fa78e4c3a7b82f9194a6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 25 Aug 2025 20:31:16 +0100 Subject: [PATCH 096/126] build: Remove root nuget.config file (#560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: remove nuget.config file Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore: remove NUGET_AUTH_TOKEN and source-url from .NET SDK setup in CI workflows Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/ci.yml | 6 ------ .github/workflows/code-coverage.yml | 3 --- .github/workflows/e2e.yml | 3 --- .github/workflows/release.yml | 3 --- nuget.config | 22 ---------------------- 5 files changed, 37 deletions(-) delete mode 100644 nuget.config diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14c90f864..cba14ea0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,11 +30,8 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 - env: - NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: global-json-file: global.json - source-url: https://nuget.pkg.github.com/open-feature/index.json - name: Cache NuGet packages uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 @@ -77,11 +74,8 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 - env: - NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: global-json-file: global.json - source-url: https://nuget.pkg.github.com/open-feature/index.json - name: Cache NuGet packages uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 6a45f0b4b..7d5087dc4 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -28,11 +28,8 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 - env: - NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: global-json-file: global.json - source-url: https://nuget.pkg.github.com/open-feature/index.json - name: Cache NuGet packages uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e9f3f79cd..dc40f800f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -23,11 +23,8 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 - env: - NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: global-json-file: global.json - source-url: https://nuget.pkg.github.com/open-feature/index.json - name: Cache NuGet packages uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b372121f..8e381829f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,11 +45,8 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 - env: - NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: global-json-file: global.json - source-url: https://nuget.pkg.github.com/open-feature/index.json - name: Cache NuGet packages uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 diff --git a/nuget.config b/nuget.config deleted file mode 100644 index 5a0edf435..000000000 --- a/nuget.config +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - From 8308854461c312985f1516b82c30479bbab3a091 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 08:57:18 +0100 Subject: [PATCH 097/126] chore(deps): update actions/attest-build-provenance action to v3 (#570) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e381829f..6ce8d683b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ secrets.NUGET_TOKEN }}" --source https://api.nuget.org/v3/index.json - name: Generate artifact attestation - uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 with: subject-path: "src/**/*.nupkg" From 8534a07c0078c6b802e7a1c54a01b0b88a9c537f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 08:57:43 +0100 Subject: [PATCH 098/126] chore(deps): update actions/attest-sbom action to v3 (#571) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/actions/sbom-generator/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/sbom-generator/action.yml b/.github/actions/sbom-generator/action.yml index 7573150b5..9d27d486b 100644 --- a/.github/actions/sbom-generator/action.yml +++ b/.github/actions/sbom-generator/action.yml @@ -35,7 +35,7 @@ runs: gh release upload ${{ inputs.release-tag }} ./artifacts/sboms/${{ inputs.project-name }}.bom.json - name: Attest package - uses: actions/attest-sbom@bd218ad0dbcb3e146bd073d1d9c6d78e08aa8a0b # v2.4.0 + uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0 with: subject-path: src/**/${{ inputs.project-name }}.*.nupkg sbom-path: ./artifacts/sboms/${{ inputs.project-name }}.bom.json From 34a483b635c6bb7c8ba8c3fd9f0a4e024c754c5d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 08:58:01 +0100 Subject: [PATCH 099/126] chore(deps): update amannn/action-semantic-pull-request action to v6 (#572) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/lint-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index e091a2a75..a08bb80e4 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -15,6 +15,6 @@ jobs: contents: read pull-requests: write steps: - - uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 5a4f47faef6293fbc79de4d1839c2104d649e3f0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 09:04:27 +0100 Subject: [PATCH 100/126] chore(deps): update github/codeql-action digest to 2d92b76 (#575) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 1fee02def..2f3ec00f4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3 + uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3 + uses: github/codeql-action/autobuild@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3 + uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3 From fc77fb75ee38f566bf87ebbe86c96003d48e9a5c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 09:04:54 +0100 Subject: [PATCH 101/126] chore(deps): update actions/setup-dotnet action to v5 (#576) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/aot-compatibility.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/code-coverage.yml | 2 +- .github/workflows/dotnet-format.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/release.yml | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/aot-compatibility.yml b/.github/workflows/aot-compatibility.yml index 4f2ce14e8..1b779a31e 100644 --- a/.github/workflows/aot-compatibility.yml +++ b/.github/workflows/aot-compatibility.yml @@ -52,7 +52,7 @@ jobs: submodules: recursive - name: Setup .NET SDK - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 with: global-json-file: global.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cba14ea0d..32f115aa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: submodules: recursive - name: Setup .NET SDK - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 with: global-json-file: global.json @@ -73,7 +73,7 @@ jobs: submodules: recursive - name: Setup .NET SDK - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 with: global-json-file: global.json diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 7d5087dc4..28f97e7ed 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -27,7 +27,7 @@ jobs: fetch-depth: 0 - name: Setup .NET SDK - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 with: global-json-file: global.json diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index 19205c6b9..f7857ea64 100644 --- a/.github/workflows/dotnet-format.yml +++ b/.github/workflows/dotnet-format.yml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - name: Setup .NET SDK - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 with: global-json-file: global.json diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index dc40f800f..9032109af 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup .NET SDK - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 with: global-json-file: global.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ce8d683b..23232824b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: fetch-depth: 0 - name: Setup .NET SDK - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 with: global-json-file: global.json From cf14336f382dda5df5ac3470794b67aaf8b17e61 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:43:51 +0100 Subject: [PATCH 102/126] chore(deps): update codecov/codecov-action action to v5.5.1 (#578) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/code-coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 28f97e7ed..226cc9a1c 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -42,7 +42,7 @@ jobs: - name: Run Test run: dotnet test --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=opencover - - uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5.5.0 + - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: name: Code Coverage for ${{ matrix.os }} fail_ci_if_error: true From 3bd6304eab1b9bd738d86f5a0a5754f89f9dc347 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:44:39 +0100 Subject: [PATCH 103/126] chore(deps): update github/codeql-action digest to f1f6e5f (#577) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2f3ec00f4..5e3437199 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3 + uses: github/codeql-action/init@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3 + uses: github/codeql-action/autobuild@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2d92b76c45b91eb80fc44c74ce3fce0ee94e8f9d # v3 + uses: github/codeql-action/analyze@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3 From db82a28d76681b974a3d369ed42d341329ece55d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 14:45:42 +0100 Subject: [PATCH 104/126] chore(deps): update github/codeql-action digest to d3678e2 (#579) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 5e3437199..05b09950b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3 + uses: github/codeql-action/init@d3678e237b9c32a6c9bffb3315c335f976f3549f # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3 + uses: github/codeql-action/autobuild@d3678e237b9c32a6c9bffb3315c335f976f3549f # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f1f6e5f6af878fb37288ce1c627459e94dbf7d01 # v3 + uses: github/codeql-action/analyze@d3678e237b9c32a6c9bffb3315c335f976f3549f # v3 From 76bd94b03ea19ad3c432a52dd644317e362b99ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:20:59 +0100 Subject: [PATCH 105/126] fix: update provider status to Fatal during disposal (#580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- src/OpenFeature.Providers.MultiProvider/MultiProvider.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs index 73ce72eba..c0d04c827 100644 --- a/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs +++ b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs @@ -280,6 +280,7 @@ public async ValueTask DisposeAsync() { this._initializationSemaphore.Dispose(); this._shutdownSemaphore.Dispose(); + this._providerStatus = ProviderStatus.Fatal; } } From fdf229737118639d323e74cceac490d44c4c24dd Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Fri, 12 Sep 2025 16:23:08 +0100 Subject: [PATCH 106/126] feat: Deprecate AddHostedFeatureLifecycle method (#531) * Add Dependency Injection code to Hosting package Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Add HostedFeatureLifecycleService when AddOpenFeature is called * Update Samples App to show how you can work with OpenFeature and Dependency Injection Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Migrate Dependency Injection tests over to the Hosting code * Copy the existing Dependency Injection tests over to a new Hosting.Tests project * Fix issue with the Integration tests Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Fix issue post rebase Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Move DI classes into Hosting namespace Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Address linting issue Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Fix issue with unit tests failing on Ubuntu Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Add additional unit tests to improve test coverage Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Add more unit tests to improve unit test coverage Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Fix formating issues and flaky test Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Improve unit test coverage and remove duplicate test file Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Apply dotnet format fixes Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- OpenFeature.slnx | 1 + samples/AspNetCore/Program.cs | 4 +- samples/AspNetCore/Samples.AspNetCore.csproj | 1 - .../Diagnostics/FeatureCodes.cs | 38 ++ src/OpenFeature.Hosting/Guard.cs | 14 + .../HostedFeatureLifecycleService.cs | 1 - .../IFeatureLifecycleManager.cs | 24 + .../Internal/EventHandlerDelegateWrapper.cs | 8 + .../Internal/FeatureLifecycleManager.cs | 66 ++ .../CallerArgumentExpressionAttribute.cs | 23 + .../MultiTarget/IsExternalInit.cs | 21 + .../OpenFeature.Hosting.csproj | 11 +- src/OpenFeature.Hosting/OpenFeatureBuilder.cs | 60 ++ .../OpenFeatureBuilderExtensions.cs | 371 +++++++++++- src/OpenFeature.Hosting/OpenFeatureOptions.cs | 61 ++ .../OpenFeatureServiceCollectionExtensions.cs | 62 ++ src/OpenFeature.Hosting/PolicyNameOptions.cs | 12 + .../Memory/FeatureBuilderExtensions.cs | 126 ++++ .../Memory/InMemoryProviderOptions.cs | 19 + test/OpenFeature.Hosting.Tests/GuardTests.cs | 30 + .../Internal/FeatureLifecycleManagerTests.cs | 203 +++++++ .../NoOpFeatureProvider.cs | 52 ++ test/OpenFeature.Hosting.Tests/NoOpHook.cs | 26 + .../OpenFeature.Hosting.Tests/NoOpProvider.cs | 8 + .../OpenFeature.Hosting.Tests.csproj | 33 + .../OpenFeatureBuilderExtensionsTests.cs | 562 ++++++++++++++++++ .../OpenFeatureBuilderTests.cs | 93 +++ .../OpenFeatureOptionsTests.cs | 73 +++ ...FeatureServiceCollectionExtensionsTests.cs | 95 +++ .../Memory/FeatureBuilderExtensionsTests.cs | 257 ++++++++ .../FeatureFlagIntegrationTest.cs | 5 +- .../OpenFeature.IntegrationTests.csproj | 10 +- 32 files changed, 2357 insertions(+), 13 deletions(-) create mode 100644 src/OpenFeature.Hosting/Diagnostics/FeatureCodes.cs create mode 100644 src/OpenFeature.Hosting/Guard.cs create mode 100644 src/OpenFeature.Hosting/IFeatureLifecycleManager.cs create mode 100644 src/OpenFeature.Hosting/Internal/EventHandlerDelegateWrapper.cs create mode 100644 src/OpenFeature.Hosting/Internal/FeatureLifecycleManager.cs create mode 100644 src/OpenFeature.Hosting/MultiTarget/CallerArgumentExpressionAttribute.cs create mode 100644 src/OpenFeature.Hosting/MultiTarget/IsExternalInit.cs create mode 100644 src/OpenFeature.Hosting/OpenFeatureBuilder.cs create mode 100644 src/OpenFeature.Hosting/OpenFeatureOptions.cs create mode 100644 src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs create mode 100644 src/OpenFeature.Hosting/PolicyNameOptions.cs create mode 100644 src/OpenFeature.Hosting/Providers/Memory/FeatureBuilderExtensions.cs create mode 100644 src/OpenFeature.Hosting/Providers/Memory/InMemoryProviderOptions.cs create mode 100644 test/OpenFeature.Hosting.Tests/GuardTests.cs create mode 100644 test/OpenFeature.Hosting.Tests/Internal/FeatureLifecycleManagerTests.cs create mode 100644 test/OpenFeature.Hosting.Tests/NoOpFeatureProvider.cs create mode 100644 test/OpenFeature.Hosting.Tests/NoOpHook.cs create mode 100644 test/OpenFeature.Hosting.Tests/NoOpProvider.cs create mode 100644 test/OpenFeature.Hosting.Tests/OpenFeature.Hosting.Tests.csproj create mode 100644 test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs create mode 100644 test/OpenFeature.Hosting.Tests/OpenFeatureBuilderTests.cs create mode 100644 test/OpenFeature.Hosting.Tests/OpenFeatureOptionsTests.cs create mode 100644 test/OpenFeature.Hosting.Tests/OpenFeatureServiceCollectionExtensionsTests.cs create mode 100644 test/OpenFeature.Hosting.Tests/Providers/Memory/FeatureBuilderExtensionsTests.cs diff --git a/OpenFeature.slnx b/OpenFeature.slnx index fa407cd3b..28b31d340 100644 --- a/OpenFeature.slnx +++ b/OpenFeature.slnx @@ -62,6 +62,7 @@ + diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index 90d1888c6..3dc0203b1 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -2,8 +2,8 @@ using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc; using OpenFeature; -using OpenFeature.DependencyInjection.Providers.Memory; using OpenFeature.Hooks; +using OpenFeature.Hosting.Providers.Memory; using OpenFeature.Model; using OpenFeature.Providers.Memory; using OpenFeature.Providers.MultiProvider; @@ -41,7 +41,7 @@ .WithFlagEvaluationMetadata("boolean", s => s.GetBool("boolean")) .Build(); - featureBuilder.AddHostedFeatureLifecycle() + featureBuilder .AddHook(sp => new LoggingHook(sp.GetRequiredService>())) .AddHook(_ => new MetricsHook(metricsHookOptions)) .AddHook() diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj index 413de0096..6a322e8f1 100644 --- a/samples/AspNetCore/Samples.AspNetCore.csproj +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -7,7 +7,6 @@ - diff --git a/src/OpenFeature.Hosting/Diagnostics/FeatureCodes.cs b/src/OpenFeature.Hosting/Diagnostics/FeatureCodes.cs new file mode 100644 index 000000000..f7ecf81cb --- /dev/null +++ b/src/OpenFeature.Hosting/Diagnostics/FeatureCodes.cs @@ -0,0 +1,38 @@ +namespace OpenFeature.Hosting.Diagnostics; + +/// +/// Contains identifiers for experimental features and diagnostics in the OpenFeature framework. +/// +/// +/// Experimental - This class includes identifiers that allow developers to track and conditionally enable +/// experimental features. Each identifier follows a structured code format to indicate the feature domain, +/// maturity level, and unique identifier. Note that experimental features are subject to change or removal +/// in future releases. +/// +/// Basic Information
+/// These identifiers conform to OpenFeature’s Diagnostics Specifications, allowing developers to recognize +/// and manage experimental features effectively. +///
+///
+/// +/// +/// Code Structure: +/// - "OF" - Represents the OpenFeature library. +/// - "DI" - Indicates the Dependency Injection domain. +/// - "001" - Unique identifier for a specific feature. +/// +/// +internal static class FeatureCodes +{ + /// + /// Identifier for the experimental Dependency Injection features within the OpenFeature framework. + /// + /// + /// OFDI001 identifier marks experimental features in the Dependency Injection (DI) domain. + /// + /// Usage: + /// Developers can use this identifier to conditionally enable or test experimental DI features. + /// It is part of the OpenFeature diagnostics system to help track experimental functionality. + /// + public const string NewDi = "OFDI001"; +} diff --git a/src/OpenFeature.Hosting/Guard.cs b/src/OpenFeature.Hosting/Guard.cs new file mode 100644 index 000000000..2d37ef54d --- /dev/null +++ b/src/OpenFeature.Hosting/Guard.cs @@ -0,0 +1,14 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace OpenFeature.Hosting; + +[DebuggerStepThrough] +internal static class Guard +{ + public static void ThrowIfNull(object? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + if (argument is null) + throw new ArgumentNullException(paramName); + } +} diff --git a/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs b/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs index 5209a5257..4411c21bb 100644 --- a/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs +++ b/src/OpenFeature.Hosting/HostedFeatureLifecycleService.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using OpenFeature.DependencyInjection; namespace OpenFeature.Hosting; diff --git a/src/OpenFeature.Hosting/IFeatureLifecycleManager.cs b/src/OpenFeature.Hosting/IFeatureLifecycleManager.cs new file mode 100644 index 000000000..54f791fbc --- /dev/null +++ b/src/OpenFeature.Hosting/IFeatureLifecycleManager.cs @@ -0,0 +1,24 @@ +namespace OpenFeature.Hosting; + +/// +/// Defines the contract for managing the lifecycle of a feature api. +/// +public interface IFeatureLifecycleManager +{ + /// + /// Ensures that the feature provider is properly initialized and ready to be used. + /// This method should handle all necessary checks, configuration, and setup required to prepare the feature provider. + /// + /// Propagates notification that operations should be canceled. + /// A Task representing the asynchronous operation of initializing the feature provider. + /// Thrown when the feature provider is not registered or is in an invalid state. + ValueTask EnsureInitializedAsync(CancellationToken cancellationToken = default); + + /// + /// Gracefully shuts down the feature api, ensuring all resources are properly disposed of and any persistent state is saved. + /// This method should handle all necessary cleanup and shutdown operations for the feature provider. + /// + /// Propagates notification that operations should be canceled. + /// A Task representing the asynchronous operation of shutting down the feature provider. + ValueTask ShutdownAsync(CancellationToken cancellationToken = default); +} diff --git a/src/OpenFeature.Hosting/Internal/EventHandlerDelegateWrapper.cs b/src/OpenFeature.Hosting/Internal/EventHandlerDelegateWrapper.cs new file mode 100644 index 000000000..34e000ce2 --- /dev/null +++ b/src/OpenFeature.Hosting/Internal/EventHandlerDelegateWrapper.cs @@ -0,0 +1,8 @@ +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature.Hosting.Internal; + +internal record EventHandlerDelegateWrapper( + ProviderEventTypes ProviderEventType, + EventHandlerDelegate EventHandlerDelegate); diff --git a/src/OpenFeature.Hosting/Internal/FeatureLifecycleManager.cs b/src/OpenFeature.Hosting/Internal/FeatureLifecycleManager.cs new file mode 100644 index 000000000..4d915946b --- /dev/null +++ b/src/OpenFeature.Hosting/Internal/FeatureLifecycleManager.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace OpenFeature.Hosting.Internal; + +internal sealed partial class FeatureLifecycleManager : IFeatureLifecycleManager +{ + private readonly Api _featureApi; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public FeatureLifecycleManager(Api featureApi, IServiceProvider serviceProvider, ILogger logger) + { + _featureApi = featureApi; + _serviceProvider = serviceProvider; + _logger = logger; + } + + /// + public async ValueTask EnsureInitializedAsync(CancellationToken cancellationToken = default) + { + this.LogStartingInitializationOfFeatureProvider(); + + var options = _serviceProvider.GetRequiredService>().Value; + if (options.HasDefaultProvider) + { + var featureProvider = _serviceProvider.GetRequiredService(); + await _featureApi.SetProviderAsync(featureProvider).ConfigureAwait(false); + } + + foreach (var name in options.ProviderNames) + { + var featureProvider = _serviceProvider.GetRequiredKeyedService(name); + await _featureApi.SetProviderAsync(name, featureProvider).ConfigureAwait(false); + } + + var hooks = new List(); + foreach (var hookName in options.HookNames) + { + var hook = _serviceProvider.GetRequiredKeyedService(hookName); + hooks.Add(hook); + } + + _featureApi.AddHooks(hooks); + + var handlers = _serviceProvider.GetServices(); + foreach (var handler in handlers) + { + _featureApi.AddHandler(handler.ProviderEventType, handler.EventHandlerDelegate); + } + } + + /// + public async ValueTask ShutdownAsync(CancellationToken cancellationToken = default) + { + this.LogShuttingDownFeatureProvider(); + await _featureApi.ShutdownAsync().ConfigureAwait(false); + } + + [LoggerMessage(200, LogLevel.Information, "Starting initialization of the feature provider")] + partial void LogStartingInitializationOfFeatureProvider(); + + [LoggerMessage(200, LogLevel.Information, "Shutting down the feature provider")] + partial void LogShuttingDownFeatureProvider(); +} diff --git a/src/OpenFeature.Hosting/MultiTarget/CallerArgumentExpressionAttribute.cs b/src/OpenFeature.Hosting/MultiTarget/CallerArgumentExpressionAttribute.cs new file mode 100644 index 000000000..afbec6b06 --- /dev/null +++ b/src/OpenFeature.Hosting/MultiTarget/CallerArgumentExpressionAttribute.cs @@ -0,0 +1,23 @@ +// @formatter:off +// ReSharper disable All +#if NETCOREAPP3_0_OR_GREATER +// https://github.com/dotnet/runtime/issues/96197 +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.CallerArgumentExpressionAttribute))] +#else +#pragma warning disable +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Runtime.CompilerServices; + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] +internal sealed class CallerArgumentExpressionAttribute : Attribute +{ + public CallerArgumentExpressionAttribute(string parameterName) + { + ParameterName = parameterName; + } + + public string ParameterName { get; } +} +#endif diff --git a/src/OpenFeature.Hosting/MultiTarget/IsExternalInit.cs b/src/OpenFeature.Hosting/MultiTarget/IsExternalInit.cs new file mode 100644 index 000000000..877141115 --- /dev/null +++ b/src/OpenFeature.Hosting/MultiTarget/IsExternalInit.cs @@ -0,0 +1,21 @@ +// @formatter:off +// ReSharper disable All +#if NET5_0_OR_GREATER +// https://github.com/dotnet/runtime/issues/96197 +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.IsExternalInit))] +#else +#pragma warning disable +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; + +namespace System.Runtime.CompilerServices; + +/// +/// Reserved to be used by the compiler for tracking metadata. +/// This class should not be used by developers in source code. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +static class IsExternalInit { } +#endif diff --git a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj index 1d54ff02e..85131a0fa 100644 --- a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj +++ b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj @@ -1,7 +1,7 @@ - + - net8.0;net9.0 + netstandard2.0;net8.0;net9.0;net462 OpenFeature @@ -10,7 +10,12 @@
- + + + + + +
diff --git a/src/OpenFeature.Hosting/OpenFeatureBuilder.cs b/src/OpenFeature.Hosting/OpenFeatureBuilder.cs new file mode 100644 index 000000000..177a9fac3 --- /dev/null +++ b/src/OpenFeature.Hosting/OpenFeatureBuilder.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace OpenFeature.Hosting; + +/// +/// Describes a backed by an . +/// +/// The services being configured. +public class OpenFeatureBuilder(IServiceCollection services) +{ + /// The services being configured. + public IServiceCollection Services { get; } = services; + + /// + /// Indicates whether the evaluation context has been configured. + /// This property is used to determine if specific configurations or services + /// should be initialized based on the presence of an evaluation context. + /// + public bool IsContextConfigured { get; internal set; } + + /// + /// Indicates whether the policy has been configured. + /// + public bool IsPolicyConfigured { get; internal set; } + + /// + /// Gets a value indicating whether a default provider has been registered. + /// + public bool HasDefaultProvider { get; internal set; } + + /// + /// Gets the count of domain-bound providers that have been registered. + /// This count does not include the default provider. + /// + public int DomainBoundProviderRegistrationCount { get; internal set; } + + /// + /// Validates the current configuration, ensuring that a policy is set when multiple providers are registered + /// or when a default provider is registered alongside another provider. + /// + /// + /// Thrown if multiple providers are registered without a policy, or if both a default provider + /// and an additional provider are registered without a policy configuration. + /// + public void Validate() + { + if (!IsPolicyConfigured) + { + if (DomainBoundProviderRegistrationCount > 1) + { + throw new InvalidOperationException("Multiple providers have been registered, but no policy has been configured."); + } + + if (HasDefaultProvider && DomainBoundProviderRegistrationCount == 1) + { + throw new InvalidOperationException("A default provider and an additional provider have been registered without a policy configuration."); + } + } + } +} diff --git a/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs index 80e760d9d..52c66c42e 100644 --- a/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs +++ b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs @@ -1,6 +1,10 @@ using Microsoft.Extensions.DependencyInjection; -using OpenFeature.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenFeature.Constant; using OpenFeature.Hosting; +using OpenFeature.Hosting.Internal; +using OpenFeature.Model; namespace OpenFeature; @@ -9,6 +13,370 @@ namespace OpenFeature; /// public static partial class OpenFeatureBuilderExtensions { + /// + /// This method is used to add a new context to the service collection. + /// + /// The instance. + /// the desired configuration + /// The instance. + /// Thrown when the or action is null. + public static OpenFeatureBuilder AddContext(this OpenFeatureBuilder builder, Action configure) + { + Guard.ThrowIfNull(builder); + Guard.ThrowIfNull(configure); + + return builder.AddContext((b, _) => configure(b)); + } + + /// + /// This method is used to add a new context to the service collection. + /// + /// The instance. + /// the desired configuration + /// The instance. + /// Thrown when the or action is null. + public static OpenFeatureBuilder AddContext(this OpenFeatureBuilder builder, Action configure) + { + Guard.ThrowIfNull(builder); + Guard.ThrowIfNull(configure); + + builder.IsContextConfigured = true; + builder.Services.TryAddTransient(provider => + { + var contextBuilder = EvaluationContext.Builder(); + configure(contextBuilder, provider); + return contextBuilder.Build(); + }); + + return builder; + } + + /// + /// Adds a feature provider using a factory method without additional configuration options. + /// This method adds the feature provider as a transient service and sets it as the default provider within the application. + /// + /// The used to configure feature flags. + /// + /// A factory method that creates and returns a + /// instance based on the provided service provider. + /// + /// The updated instance with the default feature provider set and configured. + /// Thrown if the is null, as a valid builder is required to add and configure providers. + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, Func implementationFactory) + => AddProvider(builder, implementationFactory, null); + + /// + /// Adds a feature provider using a factory method to create the provider instance and optionally configures its settings. + /// This method adds the feature provider as a transient service and sets it as the default provider within the application. + /// + /// Type derived from used to configure the feature provider. + /// The used to configure feature flags. + /// + /// A factory method that creates and returns a + /// instance based on the provided service provider. + /// + /// An optional delegate to configure the provider-specific options. + /// The updated instance with the default feature provider set and configured. + /// Thrown if the is null, as a valid builder is required to add and configure providers. + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, Func implementationFactory, Action? configureOptions) + where TOptions : OpenFeatureOptions + { + Guard.ThrowIfNull(builder); + + builder.HasDefaultProvider = true; + builder.Services.PostConfigure(options => options.AddDefaultProviderName()); + if (configureOptions != null) + { + builder.Services.Configure(configureOptions); + } + + builder.Services.TryAddTransient(implementationFactory); + builder.AddClient(); + return builder; + } + + /// + /// Adds a feature provider for a specific domain using provided options and a configuration builder. + /// + /// Type derived from used to configure the feature provider. + /// The used to configure feature flags. + /// The unique name of the provider. + /// + /// A factory method that creates a feature provider instance. + /// It adds the provider as a transient service unless it is already added. + /// + /// An optional delegate to configure the provider-specific options. + /// The updated instance with the new feature provider configured. + /// + /// Thrown if either or is null or if the is empty. + /// + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, string domain, Func implementationFactory, Action? configureOptions) + where TOptions : OpenFeatureOptions + { + Guard.ThrowIfNull(builder); + + builder.DomainBoundProviderRegistrationCount++; + + builder.Services.PostConfigure(options => options.AddProviderName(domain)); + if (configureOptions != null) + { + builder.Services.Configure(domain, configureOptions); + } + + builder.Services.TryAddKeyedTransient(domain, (provider, key) => + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + return implementationFactory(provider, key.ToString()!); + }); + + builder.AddClient(domain); + return builder; + } + + /// + /// Adds a feature provider for a specified domain using the default options. + /// This method configures a feature provider without custom options, delegating to the more generic AddProvider method. + /// + /// The used to configure feature flags. + /// The unique name of the provider. + /// + /// A factory method that creates a feature provider instance. + /// It adds the provider as a transient service unless it is already added. + /// + /// The updated instance with the new feature provider configured. + /// + /// Thrown if either or is null or if the is empty. + /// + public static OpenFeatureBuilder AddProvider(this OpenFeatureBuilder builder, string domain, Func implementationFactory) + => AddProvider(builder, domain, implementationFactory, configureOptions: null); + + /// + /// Adds a feature client to the service collection, configuring it to work with a specific context if provided. + /// + /// The instance. + /// Optional: The name for the feature client instance. + /// The instance. + internal static OpenFeatureBuilder AddClient(this OpenFeatureBuilder builder, string? name = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + if (builder.IsContextConfigured) + { + builder.Services.TryAddScoped(static provider => + { + var api = provider.GetRequiredService(); + var client = api.GetClient(); + var context = provider.GetRequiredService(); + client.SetContext(context); + return client; + }); + } + else + { + builder.Services.TryAddScoped(static provider => + { + var api = provider.GetRequiredService(); + return api.GetClient(); + }); + } + } + else + { + if (builder.IsContextConfigured) + { + builder.Services.TryAddKeyedScoped(name, static (provider, key) => + { + var api = provider.GetRequiredService(); + var client = api.GetClient(key!.ToString()); + var context = provider.GetRequiredService(); + client.SetContext(context); + return client; + }); + } + else + { + builder.Services.TryAddKeyedScoped(name, static (provider, key) => + { + var api = provider.GetRequiredService(); + return api.GetClient(key!.ToString()); + }); + } + } + + return builder; + } + + /// + /// Adds a default to the based on the policy name options. + /// This method configures the dependency injection container to resolve the appropriate + /// depending on the policy name selected. + /// If no name is selected (i.e., null), it retrieves the default client. + /// + /// The instance. + /// The configured instance. + internal static OpenFeatureBuilder AddPolicyBasedClient(this OpenFeatureBuilder builder) + { + builder.Services.AddScoped(provider => + { + var policy = provider.GetRequiredService>().Value; + var name = policy.DefaultNameSelector(provider); + if (name == null) + { + return provider.GetRequiredService(); + } + return provider.GetRequiredKeyedService(name); + }); + + return builder; + } + + /// + /// Configures policy name options for OpenFeature using the specified options type. + /// + /// The type of options used to configure . + /// The instance. + /// A delegate to configure . + /// The configured instance. + /// Thrown when the or is null. + public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder, Action configureOptions) + where TOptions : PolicyNameOptions + { + Guard.ThrowIfNull(builder); + Guard.ThrowIfNull(configureOptions); + + builder.IsPolicyConfigured = true; + + builder.Services.Configure(configureOptions); + return builder; + } + + /// + /// Configures the default policy name options for OpenFeature. + /// + /// The instance. + /// A delegate to configure . + /// The configured instance. + public static OpenFeatureBuilder AddPolicyName(this OpenFeatureBuilder builder, Action configureOptions) + => AddPolicyName(builder, configureOptions); + + /// + /// Adds a feature hook to the service collection using a factory method. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// Optional factory for controlling how will be created in the DI container. + /// The instance. + public static OpenFeatureBuilder AddHook< +#if NET + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + THook>(this OpenFeatureBuilder builder, Func? implementationFactory = null) + where THook : Hook + { + return builder.AddHook(typeof(THook).Name, implementationFactory); + } + + /// + /// Adds a feature hook to the service collection. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// Instance of Hook to inject into the OpenFeature context. + /// The instance. + public static OpenFeatureBuilder AddHook< +#if NET + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + THook>(this OpenFeatureBuilder builder, THook hook) + where THook : Hook + { + return builder.AddHook(typeof(THook).Name, hook); + } + + /// + /// Adds a feature hook to the service collection with a specified name. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// The name of the that is being added. + /// Instance of Hook to inject into the OpenFeature context. + /// The instance. + public static OpenFeatureBuilder AddHook< +#if NET + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + THook>(this OpenFeatureBuilder builder, string hookName, THook hook) + where THook : Hook + { + return builder.AddHook(hookName, _ => hook); + } + + /// + /// Adds a feature hook to the service collection using a factory method and specified name. Hooks added here are not domain-bound. + /// + /// The type of to be added. + /// The instance. + /// The name of the that is being added. + /// Optional factory for controlling how will be created in the DI container. + /// The instance. + public static OpenFeatureBuilder AddHook< +#if NET + [System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + THook> + (this OpenFeatureBuilder builder, string hookName, Func? implementationFactory = null) + where THook : Hook + { + builder.Services.PostConfigure(options => options.AddHookName(hookName)); + + if (implementationFactory is not null) + { + builder.Services.TryAddKeyedSingleton(hookName, (serviceProvider, key) => + { + return implementationFactory(serviceProvider); + }); + } + else + { + builder.Services.TryAddKeyedSingleton(hookName); + } + + return builder; + } + + /// + /// Add a to allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions + /// + /// The instance. + /// The type to handle. + /// The handler which reacts to . + /// The instance. + public static OpenFeatureBuilder AddHandler(this OpenFeatureBuilder builder, ProviderEventTypes type, EventHandlerDelegate eventHandlerDelegate) + { + return AddHandler(builder, type, _ => eventHandlerDelegate); + } + + /// + /// Add a to allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions + /// + /// The instance. + /// The type to handle. + /// The handler factory for creating a handler which reacts to . + /// The instance. + public static OpenFeatureBuilder AddHandler(this OpenFeatureBuilder builder, ProviderEventTypes type, Func implementationFactory) + { + builder.Services.AddSingleton((serviceProvider) => + { + var handler = implementationFactory(serviceProvider); + return new EventHandlerDelegateWrapper(type, handler); + }); + + return builder; + } + /// /// Adds the to the OpenFeatureBuilder, /// which manages the lifecycle of features within the application. It also allows @@ -17,6 +385,7 @@ public static partial class OpenFeatureBuilderExtensions /// The instance. /// An optional action to configure . /// The instance. + [Obsolete("Calling AddHostedFeatureLifecycle() is no longer necessary. OpenFeature will inject this automatically when you call AddOpenFeature().")] public static OpenFeatureBuilder AddHostedFeatureLifecycle(this OpenFeatureBuilder builder, Action? configureOptions = null) { if (configureOptions is not null) diff --git a/src/OpenFeature.Hosting/OpenFeatureOptions.cs b/src/OpenFeature.Hosting/OpenFeatureOptions.cs new file mode 100644 index 000000000..9d3dd818e --- /dev/null +++ b/src/OpenFeature.Hosting/OpenFeatureOptions.cs @@ -0,0 +1,61 @@ +namespace OpenFeature.Hosting; + +/// +/// Options to configure OpenFeature +/// +public class OpenFeatureOptions +{ + private readonly HashSet _providerNames = []; + + /// + /// Determines if a default provider has been registered. + /// + public bool HasDefaultProvider { get; private set; } + + /// + /// The type of the configured feature provider. + /// + public Type FeatureProviderType { get; protected internal set; } = null!; + + /// + /// Gets a read-only list of registered provider names. + /// + public IReadOnlyCollection ProviderNames => _providerNames; + + /// + /// Registers the default provider name if no specific name is provided. + /// Sets to true. + /// + protected internal void AddDefaultProviderName() => AddProviderName(null); + + /// + /// Registers a new feature provider name. This operation is thread-safe. + /// + /// The name of the feature provider to register. Registers as default if null. + protected internal void AddProviderName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + HasDefaultProvider = true; + } + else + { + lock (_providerNames) + { + _providerNames.Add(name!); + } + } + } + + private readonly HashSet _hookNames = []; + + internal IReadOnlyCollection HookNames => _hookNames; + + internal void AddHookName(string name) + { + lock (_hookNames) + { + _hookNames.Add(name); + } + } +} diff --git a/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs b/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs new file mode 100644 index 000000000..236dc62b0 --- /dev/null +++ b/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using OpenFeature.Hosting; +using OpenFeature.Hosting.Internal; + +namespace OpenFeature; + +/// +/// Contains extension methods for the class. +/// +public static partial class OpenFeatureServiceCollectionExtensions +{ + /// + /// Adds and configures OpenFeature services to the provided . + /// + /// The instance. + /// A configuration action for customizing OpenFeature setup via + /// The modified instance + /// Thrown if or is null. + public static IServiceCollection AddOpenFeature(this IServiceCollection services, Action configure) + { + Guard.ThrowIfNull(services); + Guard.ThrowIfNull(configure); + + // Register core OpenFeature services as singletons. + services.TryAddSingleton(Api.Instance); + services.TryAddSingleton(); + + var builder = new OpenFeatureBuilder(services); + configure(builder); + + builder.Services.AddHostedService(); + + // If a default provider is specified without additional providers, + // return early as no extra configuration is needed. + if (builder.HasDefaultProvider && builder.DomainBoundProviderRegistrationCount == 0) + { + return services; + } + + // Validate builder configuration to ensure consistency and required setup. + builder.Validate(); + + if (!builder.IsPolicyConfigured) + { + // Add a default name selector policy to use the first registered provider name as the default. + builder.AddPolicyName(options => + { + options.DefaultNameSelector = provider => + { + var options = provider.GetRequiredService>().Value; + return options.ProviderNames.First(); + }; + }); + } + + builder.AddPolicyBasedClient(); + + return services; + } +} diff --git a/src/OpenFeature.Hosting/PolicyNameOptions.cs b/src/OpenFeature.Hosting/PolicyNameOptions.cs new file mode 100644 index 000000000..3dfa76f89 --- /dev/null +++ b/src/OpenFeature.Hosting/PolicyNameOptions.cs @@ -0,0 +1,12 @@ +namespace OpenFeature.Hosting; + +/// +/// Options to configure the default feature client name. +/// +public class PolicyNameOptions +{ + /// + /// A delegate to select the default feature client name. + /// + public Func DefaultNameSelector { get; set; } = null!; +} diff --git a/src/OpenFeature.Hosting/Providers/Memory/FeatureBuilderExtensions.cs b/src/OpenFeature.Hosting/Providers/Memory/FeatureBuilderExtensions.cs new file mode 100644 index 000000000..d63009d62 --- /dev/null +++ b/src/OpenFeature.Hosting/Providers/Memory/FeatureBuilderExtensions.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenFeature.Providers.Memory; + +namespace OpenFeature.Hosting.Providers.Memory; + +/// +/// Extension methods for configuring feature providers with . +/// +#if NET8_0_OR_GREATER +[System.Diagnostics.CodeAnalysis.Experimental(Diagnostics.FeatureCodes.NewDi)] +#endif +public static partial class FeatureBuilderExtensions +{ + /// + /// Adds an in-memory feature provider to the with a factory for flags. + /// + /// The instance to configure. + /// + /// A factory function to provide an of flags. + /// If null, an empty provider will be created. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, Func?> flagsFactory) + => builder.AddProvider(provider => + { + var flags = flagsFactory(provider); + if (flags == null) + { + return new InMemoryProvider(); + } + + return new InMemoryProvider(flags); + }); + + /// + /// Adds an in-memory feature provider to the with a domain and factory for flags. + /// + /// The instance to configure. + /// The unique domain of the provider. + /// + /// A factory function to provide an of flags. + /// If null, an empty provider will be created. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, string domain, Func?> flagsFactory) + => builder.AddInMemoryProvider(domain, (provider, _) => flagsFactory(provider)); + + /// + /// Adds an in-memory feature provider to the with a domain and contextual flag factory. + /// If null, an empty provider will be created. + /// + /// The instance to configure. + /// The unique domain of the provider. + /// + /// A factory function to provide an of flags based on service provider and domain. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, string domain, Func?> flagsFactory) + => builder.AddProvider(domain, (provider, key) => + { + var flags = flagsFactory(provider, key); + if (flags == null) + { + return new InMemoryProvider(); + } + + return new InMemoryProvider(flags); + }); + + /// + /// Adds an in-memory feature provider to the with optional flag configuration. + /// + /// The instance to configure. + /// + /// An optional delegate to configure feature flags in the in-memory provider. + /// If null, an empty provider will be created. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, Action>? configure = null) + => builder.AddProvider(CreateProvider, options => ConfigureFlags(options, configure)); + + /// + /// Adds an in-memory feature provider with a specific domain to the with optional flag configuration. + /// + /// The instance to configure. + /// The unique domain of the provider + /// + /// An optional delegate to configure feature flags in the in-memory provider. + /// If null, an empty provider will be created. + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddInMemoryProvider(this OpenFeatureBuilder builder, string domain, Action>? configure = null) + => builder.AddProvider(domain, CreateProvider, options => ConfigureFlags(options, configure)); + + private static FeatureProvider CreateProvider(IServiceProvider provider, string domain) + { + var options = provider.GetRequiredService>().Get(domain); + if (options.Flags == null) + { + return new InMemoryProvider(); + } + + return new InMemoryProvider(options.Flags); + } + + private static FeatureProvider CreateProvider(IServiceProvider provider) + { + var options = provider.GetRequiredService>().Value; + if (options.Flags == null) + { + return new InMemoryProvider(); + } + + return new InMemoryProvider(options.Flags); + } + + private static void ConfigureFlags(InMemoryProviderOptions options, Action>? configure) + { + if (configure != null) + { + options.Flags = new Dictionary(); + configure.Invoke(options.Flags); + } + } +} diff --git a/src/OpenFeature.Hosting/Providers/Memory/InMemoryProviderOptions.cs b/src/OpenFeature.Hosting/Providers/Memory/InMemoryProviderOptions.cs new file mode 100644 index 000000000..3e7431eef --- /dev/null +++ b/src/OpenFeature.Hosting/Providers/Memory/InMemoryProviderOptions.cs @@ -0,0 +1,19 @@ +using OpenFeature.Providers.Memory; + +namespace OpenFeature.Hosting.Providers.Memory; + +/// +/// Options for configuring the in-memory feature flag provider. +/// +public class InMemoryProviderOptions : OpenFeatureOptions +{ + /// + /// Gets or sets the feature flags to be used by the in-memory provider. + /// + /// + /// This property allows you to specify a dictionary of flags where the key is the flag name + /// and the value is the corresponding instance. + /// If no flags are provided, the in-memory provider will start with an empty set of flags. + /// + public IDictionary? Flags { get; set; } +} diff --git a/test/OpenFeature.Hosting.Tests/GuardTests.cs b/test/OpenFeature.Hosting.Tests/GuardTests.cs new file mode 100644 index 000000000..13b8883d5 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/GuardTests.cs @@ -0,0 +1,30 @@ +namespace OpenFeature.Hosting.Tests; + +public class GuardTests +{ + [Fact] + public void ThrowIfNull_WithNullArgument_ThrowsArgumentNullException() + { + // Arrange + object? argument = null; + + // Act + var exception = Assert.Throws(() => Guard.ThrowIfNull(argument)); + + // Assert + Assert.Equal("argument", exception.ParamName); + } + + [Fact] + public void ThrowIfNull_WithNotNullArgument_DoesNotThrowArgumentNullException() + { + // Arrange + object? argument = "Test argument"; + + // Act + var ex = Record.Exception(() => Guard.ThrowIfNull(argument)); + + // Assert + Assert.Null(ex); + } +} diff --git a/test/OpenFeature.Hosting.Tests/Internal/FeatureLifecycleManagerTests.cs b/test/OpenFeature.Hosting.Tests/Internal/FeatureLifecycleManagerTests.cs new file mode 100644 index 000000000..2d379fc4e --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/Internal/FeatureLifecycleManagerTests.cs @@ -0,0 +1,203 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Testing; +using OpenFeature.Constant; +using OpenFeature.Hosting.Internal; + +namespace OpenFeature.Hosting.Tests.Internal; + +public class FeatureLifecycleManagerTests : IAsyncLifetime +{ + [Fact] + public async Task EnsureInitializedAsync_SetsProvider() + { + // Arrange + var services = new ServiceCollection(); + var provider = new NoOpFeatureProvider(); + services.AddOptions().Configure(options => + { + options.AddProviderName(null); + }); + services.AddSingleton(provider); + + var api = Api.Instance; + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, NullLogger.Instance); + await lifecycleManager.EnsureInitializedAsync(); + + // Assert + var actualProvider = api.GetProvider(); + Assert.Equal(provider, actualProvider); + } + + [Fact] + public async Task EnsureInitializedAsync_SetsMultipleProvider() + { + // Arrange + var services = new ServiceCollection(); + var provider1 = new NoOpFeatureProvider(); + var provider2 = new NoOpFeatureProvider(); + services.AddOptions().Configure(options => + { + options.AddProviderName("provider1"); + options.AddProviderName("provider2"); + }); + services.AddKeyedSingleton("provider1", provider1); + services.AddKeyedSingleton("provider2", provider2); + + var api = Api.Instance; + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, NullLogger.Instance); + await lifecycleManager.EnsureInitializedAsync(); + + // Assert + Assert.Equal(provider1, api.GetProvider("provider1")); + Assert.Equal(provider2, api.GetProvider("provider2")); + } + + [Fact] + public async Task EnsureInitializedAsync_AddsHooks() + { + // Arrange + var services = new ServiceCollection(); + var provider = new NoOpFeatureProvider(); + var hook = new NoOpHook(); + services.AddOptions().Configure(options => + { + options.AddProviderName(null); + options.AddHookName("TestHook"); + }); + services.AddSingleton(provider); + services.AddKeyedSingleton("TestHook", hook); + + var api = Api.Instance; + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, NullLogger.Instance); + await lifecycleManager.EnsureInitializedAsync(); + + // Assert + var actualHooks = api.GetHooks(); + Assert.Single(actualHooks); + Assert.Contains(hook, actualHooks); + } + + [Fact] + public async Task EnsureInitializedAsync_AddHandlers() + { + // Arrange + var services = new ServiceCollection(); + var provider = new NoOpFeatureProvider(); + services.AddOptions().Configure(options => + { + options.AddProviderName(null); + }); + services.AddSingleton(provider); + + bool hookExecuted = false; + services.AddSingleton(new EventHandlerDelegateWrapper(ProviderEventTypes.ProviderReady, (p) => { hookExecuted = true; })); + + var api = Api.Instance; + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, NullLogger.Instance); + await lifecycleManager.EnsureInitializedAsync(); + + // Assert + Assert.True(hookExecuted); + } + + [Fact] + public async Task ShutdownAsync_ResetsApi() + { + // Arrange + var services = new ServiceCollection(); + var provider = new NoOpFeatureProvider(); + + var api = Api.Instance; + await api.SetProviderAsync(provider); + api.AddHooks(new NoOpHook()); + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, NullLogger.Instance); + await lifecycleManager.ShutdownAsync(); + + // Assert + var actualProvider = api.GetProvider(); + Assert.NotEqual(provider, actualProvider); // Default provider should be set after shutdown + Assert.Empty(api.GetHooks()); // Hooks should be cleared + } + + [Fact] + public async Task EnsureInitializedAsync_LogStartingInitialization() + { + // Arrange + var services = new ServiceCollection(); + var provider = new NoOpFeatureProvider(); + services.AddOptions().Configure(options => + { + options.AddProviderName(null); + }); + services.AddSingleton(provider); + + var api = Api.Instance; + var logger = new FakeLogger(); + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, logger); + await lifecycleManager.EnsureInitializedAsync(); + + // Assert + var log = logger.LatestRecord; + Assert.Equal(200, log.Id); + Assert.Equal("Starting initialization of the feature provider", log.Message); + Assert.Equal(LogLevel.Information, log.Level); + } + + [Fact] + public async Task ShutdownAsync_LogShuttingDown() + { + // Arrange + var services = new ServiceCollection(); + var provider = new NoOpFeatureProvider(); + services.AddOptions().Configure(options => + { + options.AddProviderName(null); + }); + services.AddSingleton(provider); + + var api = Api.Instance; + var logger = new FakeLogger(); + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var lifecycleManager = new FeatureLifecycleManager(api, serviceProvider, logger); + await lifecycleManager.ShutdownAsync(); + + // Assert + var log = logger.LatestRecord; + Assert.Equal(200, log.Id); + Assert.Equal("Shutting down the feature provider", log.Message); + Assert.Equal(LogLevel.Information, log.Level); + } + + public async Task InitializeAsync() + { + await Api.Instance.ShutdownAsync(); + } + + // Make sure the singleton is cleared between tests + public async Task DisposeAsync() + { + await Api.Instance.ShutdownAsync().ConfigureAwait(false); + } +} diff --git a/test/OpenFeature.Hosting.Tests/NoOpFeatureProvider.cs b/test/OpenFeature.Hosting.Tests/NoOpFeatureProvider.cs new file mode 100644 index 000000000..a19a78b37 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/NoOpFeatureProvider.cs @@ -0,0 +1,52 @@ +using OpenFeature.Model; + +namespace OpenFeature.Hosting.Tests; + +// This class replicates the NoOpFeatureProvider implementation from src/OpenFeature/NoOpFeatureProvider.cs. +// It is used here to facilitate unit testing without relying on the internal NoOpFeatureProvider class. +// If the InternalsVisibleTo attribute is added to the OpenFeature project, +// this class can be removed and the original NoOpFeatureProvider can be directly accessed for testing. +internal sealed class NoOpFeatureProvider : FeatureProvider +{ + private readonly Metadata _metadata = new Metadata(NoOpProvider.NoOpProviderName); + + public override Metadata GetMetadata() + { + return this._metadata; + } + + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) + { + return Task.FromResult(NoOpResponse(flagKey, defaultValue)); + } + + private static ResolutionDetails NoOpResponse(string flagKey, T defaultValue) + { + return new ResolutionDetails( + flagKey, + defaultValue, + reason: NoOpProvider.ReasonNoOp, + variant: NoOpProvider.Variant + ); + } +} diff --git a/test/OpenFeature.Hosting.Tests/NoOpHook.cs b/test/OpenFeature.Hosting.Tests/NoOpHook.cs new file mode 100644 index 000000000..a0085f3b5 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/NoOpHook.cs @@ -0,0 +1,26 @@ +using OpenFeature.Model; + +namespace OpenFeature.Hosting.Tests; + +internal class NoOpHook : Hook +{ + public override ValueTask BeforeAsync(HookContext context, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.BeforeAsync(context, hints, cancellationToken); + } + + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.AfterAsync(context, details, hints, cancellationToken); + } + + public override ValueTask FinallyAsync(HookContext context, FlagEvaluationDetails evaluationDetails, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.FinallyAsync(context, evaluationDetails, hints, cancellationToken); + } + + public override ValueTask ErrorAsync(HookContext context, Exception error, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + return base.ErrorAsync(context, error, hints, cancellationToken); + } +} diff --git a/test/OpenFeature.Hosting.Tests/NoOpProvider.cs b/test/OpenFeature.Hosting.Tests/NoOpProvider.cs new file mode 100644 index 000000000..423cd3613 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/NoOpProvider.cs @@ -0,0 +1,8 @@ +namespace OpenFeature.Hosting.Tests; + +internal static class NoOpProvider +{ + public const string NoOpProviderName = "No-op Provider"; + public const string ReasonNoOp = "No-op"; + public const string Variant = "No-op"; +} diff --git a/test/OpenFeature.Hosting.Tests/OpenFeature.Hosting.Tests.csproj b/test/OpenFeature.Hosting.Tests/OpenFeature.Hosting.Tests.csproj new file mode 100644 index 000000000..ae8707a85 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/OpenFeature.Hosting.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0;net9.0 + $(TargetFrameworks);net462 + OpenFeature.Hosting.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs new file mode 100644 index 000000000..1a284c918 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs @@ -0,0 +1,562 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OpenFeature.Hosting.Internal; +using OpenFeature.Model; + +namespace OpenFeature.Hosting.Tests; + +public partial class OpenFeatureBuilderExtensionsTests +{ + private readonly IServiceCollection _services; + private readonly OpenFeatureBuilder _systemUnderTest; + + public OpenFeatureBuilderExtensionsTests() + { + _services = new ServiceCollection(); + _systemUnderTest = new OpenFeatureBuilder(_services); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddContext_Delegate_ShouldAddServiceToCollection(bool useServiceProviderDelegate) + { + // Act + var featureBuilder = useServiceProviderDelegate ? + _systemUnderTest.AddContext(_ => { }) : + _systemUnderTest.AddContext((_, _) => { }); + + // Assert + Assert.Equal(_systemUnderTest, featureBuilder); + Assert.True(_systemUnderTest.IsContextConfigured, "The context should be configured."); + Assert.Single(_services, serviceDescriptor => + serviceDescriptor.ServiceType == typeof(EvaluationContext) && + serviceDescriptor.Lifetime == ServiceLifetime.Transient); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddContext_Delegate_ShouldCorrectlyHandles(bool useServiceProviderDelegate) + { + // Arrange + var delegateCalled = false; + + _ = useServiceProviderDelegate ? + _systemUnderTest.AddContext(_ => delegateCalled = true) : + _systemUnderTest.AddContext((_, _) => delegateCalled = true); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var context = serviceProvider.GetService(); + + // Assert + Assert.True(_systemUnderTest.IsContextConfigured, "The context should be configured."); + Assert.NotNull(context); + Assert.True(delegateCalled, "The delegate should be invoked."); + } + +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.Experimental(Diagnostics.FeatureCodes.NewDi)] +#endif + [Theory] + [InlineData(1, true, 0)] + [InlineData(2, false, 1)] + [InlineData(3, true, 0)] + [InlineData(4, false, 1)] + public void AddProvider_ShouldAddProviderToCollection(int providerRegistrationType, bool expectsDefaultProvider, int expectsDomainBoundProvider) + { + // Act + var featureBuilder = providerRegistrationType switch + { + 1 => _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider()), + 2 => _systemUnderTest.AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 3 => _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider(), o => { }), + 4 => _systemUnderTest.AddProvider("test", (_, _) => new NoOpFeatureProvider(), o => { }), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + // Assert + Assert.False(_systemUnderTest.IsContextConfigured, "The context should not be configured."); + Assert.Equal(expectsDefaultProvider, _systemUnderTest.HasDefaultProvider); + Assert.False(_systemUnderTest.IsPolicyConfigured, "The policy should not be configured."); + Assert.Equal(expectsDomainBoundProvider, _systemUnderTest.DomainBoundProviderRegistrationCount); + Assert.Equal(_systemUnderTest, featureBuilder); + Assert.Single(_services, serviceDescriptor => + serviceDescriptor.ServiceType == typeof(FeatureProvider) && + serviceDescriptor.Lifetime == ServiceLifetime.Transient); + } + + class TestOptions : OpenFeatureOptions { } + +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.Experimental(Diagnostics.FeatureCodes.NewDi)] +#endif + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void AddProvider_ShouldResolveCorrectProvider(int providerRegistrationType) + { + // Arrange + _ = providerRegistrationType switch + { + 1 => _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider()), + 2 => _systemUnderTest.AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 3 => _systemUnderTest.AddProvider(_ => new NoOpFeatureProvider(), o => { }), + 4 => _systemUnderTest.AddProvider("test", (_, _) => new NoOpFeatureProvider(), o => { }), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var provider = providerRegistrationType switch + { + 1 or 3 => serviceProvider.GetService(), + 2 or 4 => serviceProvider.GetKeyedService("test"), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Theory] + [InlineData(1, true, 1)] + [InlineData(2, true, 1)] + [InlineData(3, false, 2)] + [InlineData(4, true, 1)] + [InlineData(5, true, 1)] + [InlineData(6, false, 2)] + [InlineData(7, true, 2)] + [InlineData(8, true, 2)] + public void AddProvider_VerifiesDefaultAndDomainBoundProvidersBasedOnConfiguration(int providerRegistrationType, bool expectsDefaultProvider, int expectsDomainBoundProvider) + { + // Act + var featureBuilder = providerRegistrationType switch + { + 1 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 2 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 3 => _systemUnderTest + .AddProvider("test1", (_, _) => new NoOpFeatureProvider()) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()), + 4 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 5 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()), + 6 => _systemUnderTest + .AddProvider("test1", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()), + 7 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()), + 8 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider(), o => { }), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + // Assert + Assert.False(_systemUnderTest.IsContextConfigured, "The context should not be configured."); + Assert.Equal(expectsDefaultProvider, _systemUnderTest.HasDefaultProvider); + Assert.False(_systemUnderTest.IsPolicyConfigured, "The policy should not be configured."); + Assert.Equal(expectsDomainBoundProvider, _systemUnderTest.DomainBoundProviderRegistrationCount); + Assert.Equal(_systemUnderTest, featureBuilder); + } + + [Theory] + [InlineData(1, null)] + [InlineData(2, "test")] + [InlineData(3, "test2")] + [InlineData(4, "test")] + [InlineData(5, null)] + [InlineData(6, "test1")] + [InlineData(7, "test2")] + [InlineData(8, null)] + public void AddProvider_ConfiguresPolicyNameAcrossMultipleProviderSetups(int providerRegistrationType, string? policyName) + { + // Arrange + var featureBuilder = providerRegistrationType switch + { + 1 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 2 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 3 => _systemUnderTest + .AddProvider("test1", (_, _) => new NoOpFeatureProvider()) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 4 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 5 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 6 => _systemUnderTest + .AddProvider("test1", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 7 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider()) + .AddProvider("test", (_, _) => new NoOpFeatureProvider()) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider()) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + 8 => _systemUnderTest + .AddProvider(_ => new NoOpFeatureProvider(), o => { }) + .AddProvider("test", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddProvider("test2", (_, _) => new NoOpFeatureProvider(), o => { }) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => policyName), + _ => throw new InvalidOperationException("Invalid mode.") + }; + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var policy = serviceProvider.GetRequiredService>().Value; + var name = policy.DefaultNameSelector(serviceProvider); + var provider = name == null ? + serviceProvider.GetService() : + serviceProvider.GetRequiredKeyedService(name); + + // Assert + Assert.True(featureBuilder.IsPolicyConfigured, "The policy should be configured."); + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Fact] + public void AddProvider_WithNullKey_ThrowsArgumentNullException() + { + // Arrange & Act + _systemUnderTest.AddProvider(null!, (sp, domain) => new NoOpFeatureProvider()); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var ex = Assert.Throws(() => serviceProvider.GetKeyedService(null)); + + Assert.Equal("key", ex.ParamName); + } + + [Fact] + public void AddHook_AddsHookAsKeyedService() + { + // Arrange + _systemUnderTest.AddHook(); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var hook = serviceProvider.GetKeyedService("NoOpHook"); + + // Assert + Assert.NotNull(hook); + } + + [Fact] + public void AddHook_AddsHookNameToOpenFeatureOptions() + { + // Arrange + _systemUnderTest.AddHook(sp => new NoOpHook()); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>(); + + // Assert + Assert.Contains(options.Value.HookNames, t => t == "NoOpHook"); + } + + [Fact] + public void AddHook_WithSpecifiedNameToOpenFeatureOptions() + { + // Arrange + _systemUnderTest.AddHook("my-custom-name"); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var hook = serviceProvider.GetKeyedService("my-custom-name"); + + // Assert + Assert.NotNull(hook); + } + + [Fact] + public void AddHook_WithSpecifiedNameAndImplementationFactory_AsKeyedService() + { + // Arrange + _systemUnderTest.AddHook("my-custom-name", (serviceProvider) => new NoOpHook()); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var hook = serviceProvider.GetKeyedService("my-custom-name"); + + // Assert + Assert.NotNull(hook); + } + + [Fact] + public void AddHook_WithInstance_AddsHookAsKeyedService() + { + // Arrange + var expectedHook = new NoOpHook(); + _systemUnderTest.AddHook(expectedHook); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var actualHook = serviceProvider.GetKeyedService("NoOpHook"); + + // Assert + Assert.NotNull(actualHook); + Assert.Equal(expectedHook, actualHook); + } + + [Fact] + public void AddHook_WithSpecifiedNameAndInstance_AddsHookAsKeyedService() + { + // Arrange + var expectedHook = new NoOpHook(); + _systemUnderTest.AddHook("custom-hook", expectedHook); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var actualHook = serviceProvider.GetKeyedService("custom-hook"); + + // Assert + Assert.NotNull(actualHook); + Assert.Equal(expectedHook, actualHook); + } + + [Fact] + public void AddHandler_AddsEventHandlerDelegateWrapperAsKeyedService() + { + // Arrange + EventHandlerDelegate eventHandler = (eventDetails) => { }; + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, eventHandler); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var handler = serviceProvider.GetService(); + + // Assert + Assert.NotNull(handler); + Assert.Equal(eventHandler, handler.EventHandlerDelegate); + } + + [Fact] + public void AddHandlerTwice_MultipleEventHandlerDelegateWrappersAsKeyedServices() + { + // Arrange + EventHandlerDelegate eventHandler1 = (eventDetails) => { }; + EventHandlerDelegate eventHandler2 = (eventDetails) => { }; + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, eventHandler1); + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, eventHandler2); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var handler = serviceProvider.GetServices(); + + // Assert + Assert.NotEmpty(handler); + Assert.Equal(eventHandler1, handler.ElementAt(0).EventHandlerDelegate); + Assert.Equal(eventHandler2, handler.ElementAt(1).EventHandlerDelegate); + } + + [Fact] + public void AddHandler_WithImplementationFactory_AddsEventHandlerDelegateWrapperAsKeyedService() + { + // Arrange + EventHandlerDelegate eventHandler = (eventDetails) => { }; + _systemUnderTest.AddHandler(Constant.ProviderEventTypes.ProviderReady, _ => eventHandler); + + var serviceProvider = _services.BuildServiceProvider(); + + // Act + var handler = serviceProvider.GetService(); + + // Assert + Assert.NotNull(handler); + Assert.Equal(eventHandler, handler.EventHandlerDelegate); + } + + [Fact] + public void AddClient_AddsFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + // Act + _systemUnderTest.AddClient(); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var client = serviceProvider.GetService(); + + Assert.NotNull(client); + } + + [Fact] + public void AddClient_WithContext_AddsFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + _systemUnderTest + .AddContext((a) => a.Set("region", "euw")) + .AddProvider(_systemUnderTest => new NoOpFeatureProvider()); + + // Act + _systemUnderTest.AddClient(); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var client = serviceProvider.GetService(); + + Assert.NotNull(client); + + var context = client.GetContext(); + var region = context.GetValue("region"); + Assert.Equal("euw", region.AsString); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void AddClient_WithInvalidName_AddsFeatureClient(string? name) + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + // Act + _systemUnderTest.AddClient(name); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var client = serviceProvider.GetService(); + Assert.NotNull(client); + + var keyedClients = serviceProvider.GetKeyedServices(name); + Assert.Empty(keyedClients); + } + + [Fact] + public void AddClient_WithNullName_AddsFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + // Act + _systemUnderTest.AddClient(null); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var client = serviceProvider.GetService(); + Assert.NotNull(client); + } + + [Fact] + public void AddClient_WithName_AddsFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + // Act + _systemUnderTest.AddClient("client-name"); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var client = serviceProvider.GetKeyedService("client-name"); + + Assert.NotNull(client); + } + + [Fact] + public void AddClient_WithNameAndContext_AddsFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + _systemUnderTest + .AddContext((a) => a.Set("region", "euw")) + .AddProvider(_systemUnderTest => new NoOpFeatureProvider()); + + // Act + _systemUnderTest.AddClient("client-name"); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + var client = serviceProvider.GetKeyedService("client-name"); + + Assert.NotNull(client); + + var context = client.GetContext(); + var region = context.GetValue("region"); + Assert.Equal("euw", region.AsString); + } + + [Fact] + public void AddPolicyBasedClient_AddsScopedFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + _services.AddOptions() + .Configure(options => options.DefaultNameSelector = _ => "default-name"); + + _systemUnderTest.AddProvider("default-name", (_, key) => new NoOpFeatureProvider()); + + // Act + _systemUnderTest.AddPolicyBasedClient(); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + var client = scope.ServiceProvider.GetService(); + Assert.NotNull(client); + } + + [Fact(Skip = "Bug due to https://github.com/open-feature/dotnet-sdk/issues/543")] + public void AddPolicyBasedClient_WithNoDefaultName_AddsScopedFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + _services.AddOptions() + .Configure(options => options.DefaultNameSelector = sp => null); + + _systemUnderTest.AddProvider("default", (_, key) => new NoOpFeatureProvider()); + + // Act + _systemUnderTest.AddPolicyBasedClient(); + + // Assert + using var serviceProvider = _services.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + var client = scope.ServiceProvider.GetService(); + Assert.NotNull(client); + } +} diff --git a/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderTests.cs b/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderTests.cs new file mode 100644 index 000000000..6c4ea9937 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderTests.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace OpenFeature.Hosting.Tests; + +public class OpenFeatureBuilderTests +{ + [Fact] + public void Validate_DoesNotThrowException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + var ex = Record.Exception(builder.Validate); + + // Assert + Assert.Null(ex); + } + + [Fact] + public void Validate_WithPolicySet_DoesNotThrowException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services) + { + IsPolicyConfigured = true + }; + + // Act + var ex = Record.Exception(builder.Validate); + + // Assert + Assert.Null(ex); + } + + [Fact] + public void Validate_WithMultipleDomainProvidersRegistered_ThrowInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services) + { + IsPolicyConfigured = false, + DomainBoundProviderRegistrationCount = 2 + }; + + // Act + var ex = Assert.Throws(builder.Validate); + + // Assert + Assert.Equal("Multiple providers have been registered, but no policy has been configured.", ex.Message); + } + + [Fact] + public void Validate_WithDefaultAndDomainProvidersRegistered_ThrowInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services) + { + IsPolicyConfigured = false, + DomainBoundProviderRegistrationCount = 1, + HasDefaultProvider = true + }; + + // Act + var ex = Assert.Throws(builder.Validate); + + // Assert + Assert.Equal("A default provider and an additional provider have been registered without a policy configuration.", ex.Message); + } + + [Fact] + public void Validate_WithNoDefaultProviderRegistered_DoesNotThrow() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services) + { + IsPolicyConfigured = false, + DomainBoundProviderRegistrationCount = 1, + HasDefaultProvider = false + }; + + // Act + var ex = Record.Exception(builder.Validate); + + // Assert + Assert.Null(ex); + } +} diff --git a/test/OpenFeature.Hosting.Tests/OpenFeatureOptionsTests.cs b/test/OpenFeature.Hosting.Tests/OpenFeatureOptionsTests.cs new file mode 100644 index 000000000..d39d4059f --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/OpenFeatureOptionsTests.cs @@ -0,0 +1,73 @@ +namespace OpenFeature.Hosting.Tests; + +public class OpenFeatureOptionsTests +{ + [Fact] + public void AddProviderName_DoesNotSetHasDefaultProvider() + { + // Arrange + var options = new OpenFeatureOptions(); + + // Act + options.AddProviderName("TestProvider"); + + // Assert + Assert.False(options.HasDefaultProvider); + } + + [Fact] + public void AddProviderName_WithNullName_SetsHasDefaultProvider() + { + // Arrange + var options = new OpenFeatureOptions(); + + // Act + options.AddProviderName(null); + + // Assert + Assert.True(options.HasDefaultProvider); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void AddProviderName_WithEmptyName_SetsHasDefaultProvider(string name) + { + // Arrange + var options = new OpenFeatureOptions(); + + // Act + options.AddProviderName(name); + + // Assert + Assert.True(options.HasDefaultProvider); + } + + [Fact] + public void AddProviderName_WithSameName_OnlyRegistersNameOnce() + { + // Arrange + var options = new OpenFeatureOptions(); + + // Act + options.AddProviderName("test-provider"); + options.AddProviderName("test-provider"); + options.AddProviderName("test-provider"); + + // Assert + Assert.Single(options.ProviderNames); + } + + [Fact] + public void AddHookName_RegistersHookName() + { + // Arrange + var options = new OpenFeatureOptions(); + + // Act + options.AddHookName("test-hook"); + + // Assert + Assert.Single(options.HookNames); + } +} diff --git a/test/OpenFeature.Hosting.Tests/OpenFeatureServiceCollectionExtensionsTests.cs b/test/OpenFeature.Hosting.Tests/OpenFeatureServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000..dc3cc9345 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/OpenFeatureServiceCollectionExtensionsTests.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; + +namespace OpenFeature.Hosting.Tests; + +public class OpenFeatureServiceCollectionExtensionsTests +{ + private readonly IServiceCollection _systemUnderTest; + private readonly Action _configureAction; + + public OpenFeatureServiceCollectionExtensionsTests() + { + _systemUnderTest = new ServiceCollection(); + _configureAction = Substitute.For>(); + } + + [Fact] + public void AddOpenFeature_ShouldRegisterApiInstanceAndLifecycleManagerAsSingleton() + { + // Act + _systemUnderTest.AddOpenFeature(_configureAction); + + Assert.Single(_systemUnderTest, s => s.ServiceType == typeof(Api) && s.Lifetime == ServiceLifetime.Singleton); + Assert.Single(_systemUnderTest, s => s.ServiceType == typeof(IFeatureLifecycleManager) && s.Lifetime == ServiceLifetime.Singleton); + Assert.Single(_systemUnderTest, s => s.ServiceType == typeof(IFeatureClient) && s.Lifetime == ServiceLifetime.Scoped); + } + + [Fact] + public void AddOpenFeature_ShouldInvokeConfigureAction() + { + // Act + _systemUnderTest.AddOpenFeature(_configureAction); + + // Assert + _configureAction.Received(1).Invoke(Arg.Any()); + } + + [Fact] + public void AddOpenFeature_WithDefaultProvider() + { + // Act + _systemUnderTest.AddOpenFeature(builder => + { + builder.AddProvider(_ => new NoOpFeatureProvider()); + }); + + // Assert + using var serviceProvider = _systemUnderTest.BuildServiceProvider(); + var featureClient = serviceProvider.GetRequiredService(); + Assert.NotNull(featureClient); + } + + [Fact] + public void AddOpenFeature_WithNamedDefaultProvider() + { + // Act + _systemUnderTest.AddOpenFeature(builder => + { + builder.AddProvider("no-opprovider", (_, key) => new NoOpFeatureProvider()); + }); + + // Assert + using var serviceProvider = _systemUnderTest.BuildServiceProvider(); + var featureClient = serviceProvider.GetRequiredService(); + Assert.NotNull(featureClient); + } + + [Fact] + public void AddOpenFeature_WithNamedDefaultProvider_InvokesAddPolicyName() + { + // Arrange + var provider1 = new NoOpFeatureProvider(); + var provider2 = new NoOpFeatureProvider(); + + // Act + _systemUnderTest.AddOpenFeature(builder => + { + builder + .AddPolicyName(ss => + { + ss.DefaultNameSelector = (sp) => "no-opprovider"; + }) + .AddProvider("no-opprovider", (_, key) => provider1) + .AddProvider("no-opprovider-2", (_, key) => provider2); + }); + + // Assert + using var serviceProvider = _systemUnderTest.BuildServiceProvider(); + var client = serviceProvider.GetKeyedService("no-opprovider"); + Assert.NotNull(client); + + var otherClient = serviceProvider.GetService(); + Assert.NotNull(otherClient); + } +} diff --git a/test/OpenFeature.Hosting.Tests/Providers/Memory/FeatureBuilderExtensionsTests.cs b/test/OpenFeature.Hosting.Tests/Providers/Memory/FeatureBuilderExtensionsTests.cs new file mode 100644 index 000000000..b36dc82d6 --- /dev/null +++ b/test/OpenFeature.Hosting.Tests/Providers/Memory/FeatureBuilderExtensionsTests.cs @@ -0,0 +1,257 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenFeature.Hosting.Providers.Memory; +using OpenFeature.Model; +using OpenFeature.Providers.Memory; + +namespace OpenFeature.Hosting.Tests.Providers.Memory; + +public class FeatureBuilderExtensionsTests +{ + [Fact] + public void AddInMemoryProvider_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider(); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetService(); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } + + [Fact] + public async Task AddInMemoryProvider_WithFlags_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + Func enableForAlphaGroup = ctx => ctx.GetValue("group").AsString == "alpha" ? "on" : "off"; + var flags = new Dictionary + { + { "feature1", new Flag(new Dictionary { { "on", true }, { "off", false } }, "on") }, + { "feature2", new Flag(new Dictionary { { "on", true }, { "off", false } }, "on", enableForAlphaGroup) } + }; + + // Act + builder.AddInMemoryProvider((sp) => flags); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetService(); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + + var result = await featureProvider.ResolveBooleanValueAsync("feature1", false); + Assert.True(result.Value); + } + + [Fact] + public void AddInMemoryProvider_WithNullFlagsFactory_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider((sp) => null); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetService(); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } + + [Fact] + public void AddInMemoryProvider_WithNullConfigure_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider((Action>?)null); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetService(); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } + + [Fact] + public void AddInMemoryProvider_WithDomain_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider("domain"); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetKeyedService("domain"); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } + + [Fact] + public async Task AddInMemoryProvider_WithDomainAndFlags_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + Func enableForAlphaGroup = ctx => ctx.GetValue("group").AsString == "alpha" ? "on" : "off"; + var flags = new Dictionary + { + { "feature1", new Flag(new Dictionary { { "on", true }, { "off", false } }, "on") }, + { "feature2", new Flag(new Dictionary { { "on", true }, { "off", false } }, "off", enableForAlphaGroup) } + }; + + // Act + builder.AddInMemoryProvider("domain", (sp) => flags); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetKeyedService("domain"); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + + var context = EvaluationContext.Builder().Set("group", "alpha").Build(); + var result = await featureProvider.ResolveBooleanValueAsync("feature2", false, context); + Assert.True(result.Value); + } + + [Fact] + public void AddInMemoryProvider_WithDomainAndNullFlagsFactory_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider("domain", (sp) => null); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetKeyedService("domain"); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } + + [Fact] + public async Task AddInMemoryProvider_WithOptions_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddOptions() + .Configure((opts) => + { + opts.Flags = new Dictionary + { + { "new-feature", new Flag(new Dictionary { { "on", true }, { "off", false } }, "off") }, + }; + }); + + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider(); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetService(); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + + var result = await featureProvider.ResolveBooleanValueAsync("new-feature", true); + Assert.False(result.Value); + } + + [Fact] + public async Task AddInMemoryProvider_WithDomainAndOptions_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddOptions("domain-name") + .Configure((opts) => + { + opts.Flags = new Dictionary + { + { "new-feature", new Flag(new Dictionary { { "on", true }, { "off", false } }, "off") }, + }; + }); + + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider("domain-name"); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetKeyedService("domain-name"); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + + var result = await featureProvider.ResolveBooleanValueAsync("new-feature", true); + Assert.False(result.Value); + } + + [Fact] + public void AddInMemoryProvider_WithDomainAndNullOptions_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddOptions("domain-name") + .Configure((opts) => + { + opts.Flags = null; + }); + + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider("domain-name"); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetKeyedService("domain-name"); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } + + [Fact] + public void AddInMemoryProvider_WithDomainAndNullConfigure_AddsProvider() + { + // Arrange + var services = new ServiceCollection(); + + var builder = new OpenFeatureBuilder(services); + + // Act + builder.AddInMemoryProvider("domain-name", (Action>?)null); + + // Assert + using var provider = services.BuildServiceProvider(); + + var featureProvider = provider.GetKeyedService("domain-name"); + Assert.NotNull(featureProvider); + Assert.IsType(featureProvider); + } +} diff --git a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs index ff717f9f1..9638ff8c1 100644 --- a/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs +++ b/test/OpenFeature.IntegrationTests/FeatureFlagIntegrationTest.cs @@ -8,9 +8,9 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Testing; using OpenFeature.Constant; -using OpenFeature.DependencyInjection; -using OpenFeature.DependencyInjection.Providers.Memory; using OpenFeature.Hooks; +using OpenFeature.Hosting; +using OpenFeature.Hosting.Providers.Memory; using OpenFeature.IntegrationTests.Services; using OpenFeature.Providers.Memory; @@ -211,7 +211,6 @@ private static async Task CreateServerAsync(ServiceLifetime serviceL builder.Services.AddHttpContextAccessor(); builder.Services.AddOpenFeature(cfg => { - cfg.AddHostedFeatureLifecycle(); cfg.AddContext((builder, provider) => { // Retrieve the HttpContext from IHttpContextAccessor, ensuring it's not null. diff --git a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj index aabe1a599..46f99e213 100644 --- a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj +++ b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj @@ -5,7 +5,14 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -18,7 +25,6 @@ - From ce7baa78191c9989154e96de6bda3ad3e9cf268b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sun, 21 Sep 2025 00:17:16 +0100 Subject: [PATCH 107/126] chore: add devcontainer support (#584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: remove .devcontainer from .gitignore and add devcontainer.json Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore: add devcontainer configuration for OpenFeature .NET SDK Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: remove unnecessary extensions from devcontainer configuration Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: update devcontainer image and dotnet version to 9.0 Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * docs: add development containers section to CONTRIBUTING.md Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore: add postCreateCommand to initialize git submodules in devcontainer Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Update .devcontainer/devcontainer.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: standardize memory value format in devcontainer configuration Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .devcontainer/devcontainer.json | 33 +++++++++++++++++++++++++++++++++ .gitignore | 1 - CONTRIBUTING.md | 20 ++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..c92d1a789 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "OpenFeature .NET SDK", + "image": "mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm", + "features": { + "ghcr.io/devcontainers/features/dotnet:latest": { + "version": "9.0", + "additionalVersions": "8.0" + }, + "ghcr.io/devcontainers/features/github-cli:latest": {}, + "ghcr.io/devcontainers/features/docker-in-docker": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "EditorConfig.EditorConfig", + "GitHub.copilot", + "GitHub.copilot-chat", + "GitHub.vscode-github-actions", + "GitHub.vscode-pull-request-github", + "ms-dotnettools.csharp", + "esbenp.prettier-vscode", + "redhat.vscode-yaml", + "cucumberopen.cucumber-official", + "ms-dotnettools.csdevkit" + ] + } + }, + "remoteUser": "vscode", + "hostRequirements": { + "memory": "8gb" + }, + "postCreateCommand": "git submodule update --init --recursive" +} diff --git a/.gitignore b/.gitignore index 055ffe50f..c77e4f530 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ *.user *.userosscache *.sln.docstates -.devcontainer/ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8415daff..e3c6300b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,26 @@ On all platforms, the minimum requirements are: - JetBrains Rider 2022.2+ or Visual Studio 2022+ or Visual Studio Code - .NET Framework 4.6.2+ +### Development Containers + +This repository includes support for [Development Containers](https://containers.dev/) (devcontainers), which provide a consistent, containerized development environment. The devcontainer configuration includes all necessary dependencies and tools pre-configured. + +To use the devcontainer: + +1. **Prerequisites**: Install [Docker](https://www.docker.com/) and either: + - [Visual Studio Code](https://code.visualstudio.com/) with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) + - [GitHub Codespaces](https://github.com/features/codespaces) + +2. **Using with VS Code**: + - Open the repository in VS Code + - When prompted, click "Reopen in Container" or use the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and select "Dev Containers: Reopen in Container" + +3. **Using with GitHub Codespaces**: + - Navigate to the repository on GitHub + - Click the "Code" button and select "Create codespace on [branch-name]" + +The devcontainer provides a pre-configured environment with the .NET SDK and all necessary tools for development and testing. + ## Pull Request All contributions to the OpenFeature project are welcome via GitHub pull requests. From 12de5f10421bac749fdd45c748e7b970f3f69a39 Mon Sep 17 00:00:00 2001 From: lager95 <33402278+lager95@users.noreply.github.com> Date: Mon, 22 Sep 2025 18:10:31 +0200 Subject: [PATCH 108/126] feat: Support retrieving numeric metadata as either integers or decimals (#490) * feat: Support retrieving numeric metadata as either either integers or decimals #443 Signed-off-by: Otto Lagerquist Signed-off-by: lager95 <33402278+lager95@users.noreply.github.com> * Update ImmutableMetadata.cs Updated document comments to reflect the conversions between int and double. Signed-off-by: lager95 <33402278+lager95@users.noreply.github.com> --------- Signed-off-by: Otto Lagerquist Signed-off-by: lager95 <33402278+lager95@users.noreply.github.com> Co-authored-by: Michael Beemer Co-authored-by: Todd Baert --- src/OpenFeature/Model/ImmutableMetadata.cs | 20 +++++++-- .../ImmutableMetadataTest.cs | 45 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/OpenFeature/Model/ImmutableMetadata.cs b/src/OpenFeature/Model/ImmutableMetadata.cs index 5af7b5559..7295d8b56 100644 --- a/src/OpenFeature/Model/ImmutableMetadata.cs +++ b/src/OpenFeature/Model/ImmutableMetadata.cs @@ -42,20 +42,32 @@ public ImmutableMetadata(Dictionary metadata) /// Gets the integer value associated with the specified key. /// /// The key of the value to retrieve. - /// The integer value associated with the key, or null if the key is not found. + /// The value associated with the key as an integer, if it is of type double or int; otherwise, null. public int? GetInt(string key) { - return this.GetValue(key); + var hasValue = this._metadata.TryGetValue(key, out var value); + if (!hasValue) + { + return null; + } + + return value is double || value is int ? Convert.ToInt32(value) : null; } /// /// Gets the double value associated with the specified key. /// /// The key of the value to retrieve. - /// The double value associated with the key, or null if the key is not found. + /// The value associated with the key as a double, if it is of type double or int; otherwise, null. public double? GetDouble(string key) { - return this.GetValue(key); + var hasValue = this._metadata.TryGetValue(key, out var value); + if (!hasValue) + { + return null; + } + + return value is double || value is int ? Convert.ToDouble(value) : null; } /// diff --git a/test/OpenFeature.Tests/ImmutableMetadataTest.cs b/test/OpenFeature.Tests/ImmutableMetadataTest.cs index e1324a054..7f3cf3b46 100644 --- a/test/OpenFeature.Tests/ImmutableMetadataTest.cs +++ b/test/OpenFeature.Tests/ImmutableMetadataTest.cs @@ -100,6 +100,28 @@ public void GetInt_Should_Return_Value_If_Key_Found() Assert.NotNull(result); Assert.Equal(1, result); } + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + [Specification("1.4.14.1", "Condition: `Flag metadata` MUST be immutable.")] + public void GetInt_Should_Return_Value_If_Key_Found_although_double() + { + // Arrange + var metadata = new Dictionary + { + { + "intKey", 1.0 + } + }; + var flagMetadata = new ImmutableMetadata(metadata); + + // Act + var result = flagMetadata.GetInt("intKey"); + + // Assert + Assert.NotNull(result); + Assert.Equal(1, result); + } [Fact] [Specification("1.4.14", @@ -160,6 +182,29 @@ public void GetDouble_Should_Return_Value_If_Key_Found() Assert.Equal(1.2, result); } + [Fact] + [Specification("1.4.14", + "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] + [Specification("1.4.14.1", "Condition: `Flag metadata` MUST be immutable.")] + public void GetDouble_Should_Return_Value_If_Key_Found_Although_Int() + { + // Arrange + var metadata = new Dictionary + { + { + "doubleKey", 1 + } + }; + var flagMetadata = new ImmutableMetadata(metadata); + + // Act + var result = flagMetadata.GetDouble("doubleKey"); + + // Assert + Assert.NotNull(result); + Assert.Equal(1.0, result); + } + [Fact] [Specification("1.4.14", "If the `flag metadata` field in the `flag resolution` structure returned by the configured `provider` is set, the `evaluation details` structure's `flag metadata` field MUST contain that value. Otherwise, it MUST contain an empty record.")] From 9da624191cc9837f3d608ce07ba1d844b02e65a2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:13:41 +0100 Subject: [PATCH 109/126] chore(deps): update github/codeql-action digest to 192325c (#585) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 05b09950b..89b06edc6 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@d3678e237b9c32a6c9bffb3315c335f976f3549f # v3 + uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@d3678e237b9c32a6c9bffb3315c335f976f3549f # v3 + uses: github/codeql-action/autobuild@192325c86100d080feab897ff886c34abd4c83a3 # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@d3678e237b9c32a6c9bffb3315c335f976f3549f # v3 + uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3 From 55d512ddc3b9b0ada428eb1c876d795e656a0d99 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 17:14:07 +0100 Subject: [PATCH 110/126] chore(deps): update dependency benchmarkdotnet to 0.15.3 (#586) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8f6550782..4ec6ecdb6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,7 +30,7 @@ - + From d415340a3971227d3f8b1818345db78f17857c2f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 08:53:19 +0100 Subject: [PATCH 111/126] chore(deps): update github/codeql-action digest to 303c0ae (#588) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 89b06edc6..8abeb4615 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3 + uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@192325c86100d080feab897ff886c34abd4c83a3 # v3 + uses: github/codeql-action/autobuild@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3 + uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3 From 316148d00ed429c27448286241548fc21ebd427d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 08:54:13 +0100 Subject: [PATCH 112/126] chore(deps): update dependency benchmarkdotnet to 0.15.4 (#589) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4ec6ecdb6..9539ed4ca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,7 +30,7 @@ - + From f43625c5ea89b10a58f04b93c695c096b4596891 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 08:54:30 +0100 Subject: [PATCH 113/126] chore(deps): update actions/cache action to v4.3.0 (#590) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/aot-compatibility.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/code-coverage.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/release.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/aot-compatibility.yml b/.github/workflows/aot-compatibility.yml index 1b779a31e..76e16c246 100644 --- a/.github/workflows/aot-compatibility.yml +++ b/.github/workflows/aot-compatibility.yml @@ -57,7 +57,7 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-${{ matrix.arch }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32f115aa0..f62e3cd44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} @@ -78,7 +78,7 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 226cc9a1c..e6c2518c0 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -32,7 +32,7 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9032109af..da2edad27 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,7 +27,7 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23232824b..5372a6ca0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: global-json-file: global.json - name: Cache NuGet packages - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} From 31741fea99e0017a3be171e025c80c2ab0b91c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:23:02 +0100 Subject: [PATCH 114/126] ci: change nuget publishing (#583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: update NuGet publish step to use temporary API key from OIDC login Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: update NuGet login action to specific version and add comment for OIDC token permissions Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> Co-authored-by: Todd Baert --- .github/workflows/release.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5372a6ca0..7734f8ef9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest needs: release-please permissions: - id-token: write + id-token: write # enable GitHub OIDC token issuance for this job (NuGet login) contents: write # for SBOM release attestations: write # for actions/attest-sbom to create attestation packages: read # for internal nuget reading @@ -62,8 +62,15 @@ jobs: - name: Pack run: dotnet pack -c Release --no-restore + # Get a short-lived NuGet API key + - name: NuGet login (OIDC → temp API key) + uses: NuGet/login@76cce0bd8d4b2f5dcdb45e2316d76c328632a902 # v1 + id: login + with: + user: ${{secrets.NUGET_USER}} + - name: Publish to Nuget - run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ secrets.NUGET_TOKEN }}" --source https://api.nuget.org/v3/index.json + run: dotnet nuget push "src/**/*.nupkg" --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json - name: Generate artifact attestation uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 From 154b939c1fea17ddbe1e229fa6490f2edcf49d97 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:01:16 +0100 Subject: [PATCH 115/126] chore(deps): update github/codeql-action digest to 3599b3b (#591) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8abeb4615..b9852777e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3 + uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3 + uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@303c0aef88fc2fe5ff6d63d3b1596bfd83dfa1f9 # v3 + uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3 From 67d5312a56605c897196a52e42d0aee4f5cd1dd9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:01:37 +0100 Subject: [PATCH 116/126] chore(deps): update nuget/login digest to d22cc5f (#592) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7734f8ef9..caf7f9e6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,7 +64,7 @@ jobs: # Get a short-lived NuGet API key - name: NuGet login (OIDC → temp API key) - uses: NuGet/login@76cce0bd8d4b2f5dcdb45e2316d76c328632a902 # v1 + uses: NuGet/login@d22cc5f58ff5b88bf9bd452535b4335137e24544 # v1 id: login with: user: ${{secrets.NUGET_USER}} From 9d8ab037df1749d098f5e1e210f71cf9d1e7adff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:18:32 +0100 Subject: [PATCH 117/126] feat: Add events to the multi provider (#568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Implement event handling infrastructure in MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Implement event processing for MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Enhance event handling in MultiProvider with improved status management Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Add event emission for ProviderConfigurationChanged in MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Implement graceful shutdown for event processing in MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Add error handling and event emission for evaluation failures in MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Update test name for EvaluateAsync to reflect error handling for unsupported run modes Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Add unit tests for event emission in MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Enhance MultiProvider event tests with initialization and error state handling Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Refactor event emission test for error state in MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Refactor MultiProvider event tests for improved readability and error handling Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Refactor MultiProvider event tests for improved clarity and consistency Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Improve event handling logic in MultiProvider for better performance and clarity Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Add event handling documentation for MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Dispose event processing cancellation token in MultiProvider shutdown Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Implement thread-safe access to provider status in MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Replace Dictionary with ConcurrentDictionary for event listening tasks in MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Safeguard against ObjectDisposedException during event processing cancellation in MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Update src/OpenFeature.Providers.MultiProvider/MultiProvider.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: prevent duplicate event listening tasks for registered providers Signed-off-by: GitHub * feat: enhance MultiProvider to include logging and prevent duplicate event listeners Signed-off-by: GitHub * refactor: remove unused config folder and reorganize test project entries Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: prevent duplicate event listening by checking existing tasks before adding Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: clear event listening tasks during provider shutdown Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: streamline event listening task addition by using TryAdd method Signed-off-by: GitHub * fix: remove setting of ProviderStatus to Fatal during shutdown Signed-off-by: GitHub --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> Signed-off-by: GitHub Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- OpenFeature.slnx | 9 +- .../MultiProvider.cs | 338 ++++++++++++++++- .../README.md | 63 +++ src/OpenFeature/OpenFeature.csproj | 1 + .../MultiProviderEventTests.cs | 358 ++++++++++++++++++ .../MultiProviderTests.cs | 9 +- .../Utils/TestProvider.cs | 62 +++ 7 files changed, 811 insertions(+), 29 deletions(-) create mode 100644 test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderEventTests.cs create mode 100644 test/OpenFeature.Providers.MultiProvider.Tests/Utils/TestProvider.cs diff --git a/OpenFeature.slnx b/OpenFeature.slnx index 28b31d340..936079f40 100644 --- a/OpenFeature.slnx +++ b/OpenFeature.slnx @@ -14,9 +14,6 @@ - - - @@ -59,14 +56,14 @@ + - - + - + \ No newline at end of file diff --git a/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs index c0d04c827..574b2e1e4 100644 --- a/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs +++ b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs @@ -1,4 +1,7 @@ +using System.Collections.Concurrent; using System.Collections.ObjectModel; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using OpenFeature.Constant; using OpenFeature.Model; using OpenFeature.Providers.MultiProvider.Models; @@ -17,7 +20,7 @@ namespace OpenFeature.Providers.MultiProvider; /// different feature flags may be served by different sources or providers within the same application. /// /// Multi Provider specification -public sealed class MultiProvider : FeatureProvider, IAsyncDisposable +public sealed partial class MultiProvider : FeatureProvider, IAsyncDisposable { private readonly BaseEvaluationStrategy _evaluationStrategy; private readonly IReadOnlyList _registeredProviders; @@ -25,17 +28,24 @@ public sealed class MultiProvider : FeatureProvider, IAsyncDisposable private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); private readonly SemaphoreSlim _shutdownSemaphore = new(1, 1); + private readonly object _providerStatusLock = new(); private ProviderStatus _providerStatus = ProviderStatus.NotReady; // 0 = Not disposed, 1 = Disposed // This is to handle the dispose pattern correctly with the async initialization and shutdown methods - private volatile int _disposed = 0; + private volatile int _disposed; + + // Event handling infrastructure + private readonly ConcurrentDictionary _eventListeningTasks = new(); + private readonly CancellationTokenSource _eventProcessingCancellation = new(); + private readonly ILogger _logger; /// /// Initializes a new instance of the class with the specified provider entries and evaluation strategy. /// /// A collection of provider entries containing the feature providers and their optional names. /// The base evaluation strategy to use for determining how to evaluate features across multiple providers. If not specified, the first matching strategy will be used. - public MultiProvider(IEnumerable providerEntries, BaseEvaluationStrategy? evaluationStrategy = null) + /// The logger for the client. + public MultiProvider(IEnumerable providerEntries, BaseEvaluationStrategy? evaluationStrategy = null, ILogger? logger = null) { if (providerEntries == null) { @@ -53,11 +63,36 @@ public MultiProvider(IEnumerable providerEntries, BaseEvaluationS // Create aggregate metadata this._metadata = new Metadata(MultiProviderConstants.ProviderName); + + // Start listening to events from all registered providers + this.StartListeningToProviderEvents(); + + // Set logger + this._logger = logger ?? NullLogger.Instance; } /// public override Metadata GetMetadata() => this._metadata; + /// + internal override ProviderStatus Status + { + get + { + lock (this._providerStatusLock) + { + return this._providerStatus; + } + } + set + { + lock (this._providerStatusLock) + { + this._providerStatus = value; + } + } + } + /// public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); @@ -78,7 +113,6 @@ public override Task> ResolveStringValueAsync(string f public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => this.EvaluateAsync(flagKey, defaultValue, context, cancellationToken); - /// public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) { @@ -90,7 +124,7 @@ public override async Task InitializeAsync(EvaluationContext context, Cancellati await this._initializationSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (this._providerStatus != ProviderStatus.NotReady || this._disposed == 1) + if (this.Status != ProviderStatus.NotReady || this._disposed == 1) { return; } @@ -117,14 +151,32 @@ public override async Task InitializeAsync(EvaluationContext context, Cancellati { var exceptions = failures.Select(f => f.Error!).ToList(); var failedProviders = failures.Select(f => f.ProviderName).ToList(); - this._providerStatus = ProviderStatus.Fatal; + this.Status = ProviderStatus.Fatal; + + // Emit error event + await this.EmitEvent(new ProviderEventPayload + { + ProviderName = this._metadata.Name, + Type = ProviderEventTypes.ProviderError, + Message = $"Failed to initialize providers: {string.Join(", ", failedProviders)}", + ErrorType = ErrorType.ProviderFatal + }, cancellationToken).ConfigureAwait(false); + throw new AggregateException( $"Failed to initialize providers: {string.Join(", ", failedProviders)}", exceptions); } else { - this._providerStatus = ProviderStatus.Ready; + this.Status = ProviderStatus.Ready; + + // Emit ready event + await this.EmitEvent(new ProviderEventPayload + { + ProviderName = this._metadata.Name, + Type = ProviderEventTypes.ProviderReady, + Message = "MultiProvider successfully initialized" + }, cancellationToken).ConfigureAwait(false); } } finally @@ -154,16 +206,51 @@ private async Task> EvaluateAsync(string key, T defaultV throw new ObjectDisposedException(nameof(MultiProvider)); } - var strategyContext = new StrategyEvaluationContext(key); - var resolutions = this._evaluationStrategy.RunMode switch + try { - RunMode.Parallel => await this.ParallelEvaluationAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), - RunMode.Sequential => await this.SequentialEvaluationAsync(key, defaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), - _ => throw new NotSupportedException($"Unsupported run mode: {this._evaluationStrategy.RunMode}") - }; + var strategyContext = new StrategyEvaluationContext(key); + var resolutions = this._evaluationStrategy.RunMode switch + { + RunMode.Parallel => await this + .ParallelEvaluationAsync(key, defaultValue, evaluationContext, cancellationToken) + .ConfigureAwait(false), + RunMode.Sequential => await this + .SequentialEvaluationAsync(key, defaultValue, evaluationContext, cancellationToken) + .ConfigureAwait(false), + _ => throw new NotSupportedException($"Unsupported run mode: {this._evaluationStrategy.RunMode}") + }; + + var finalResult = this._evaluationStrategy.DetermineFinalResult(strategyContext, key, defaultValue, + evaluationContext, resolutions); + return finalResult.Details; + } + catch (NotSupportedException ex) + { + // Emit error event for unsupported run mode + await this.EmitEvent(new ProviderEventPayload + { + ProviderName = this._metadata.Name, + Type = ProviderEventTypes.ProviderError, + Message = $"Error evaluating flag '{key}' with run mode {this._evaluationStrategy.RunMode}", + ErrorType = ErrorType.ProviderFatal + }, cancellationToken).ConfigureAwait(false); - var finalResult = this._evaluationStrategy.DetermineFinalResult(strategyContext, key, defaultValue, evaluationContext, resolutions); - return finalResult.Details; + return new ResolutionDetails(key, defaultValue, ErrorType.ProviderFatal, Reason.Error, errorMessage: ex.Message); + } + catch (Exception ex) + { + // Emit error event for evaluation failures + await this.EmitEvent(new ProviderEventPayload + { + ProviderName = this._metadata.Name, + Type = ProviderEventTypes.ProviderError, + Message = $"Error evaluating flag '{key}': {ex.Message}", + ErrorType = ErrorType.General, + FlagsChanged = [key] + }, cancellationToken).ConfigureAwait(false); + + return new ResolutionDetails(key, defaultValue, ErrorType.General, Reason.Error, errorMessage: ex.Message); + } } private async Task>> SequentialEvaluationAsync(string key, T defaultValue, EvaluationContext? evaluationContext, CancellationToken cancellationToken) @@ -220,6 +307,187 @@ private async Task>> ParallelEvaluationAsync return resolutions; } + private void StartListeningToProviderEvents() + { + foreach (var registeredProvider in this._registeredProviders) + { + if (!this._eventListeningTasks.TryAdd(registeredProvider.Provider, this.ProcessProviderEventsAsync(registeredProvider))) + { + // Log a warning if the provider is already being listened to + this.LogProviderAlreadyBeingListenedTo(registeredProvider.Name); + } + } + } + + private async Task ProcessProviderEventsAsync(RegisteredProvider registeredProvider) + { + var eventChannel = registeredProvider.Provider.GetEventChannel(); + + // Get the cancellation token safely for this provider's event processing (this prevents ObjectDisposedException during concurrent shutdown) + CancellationToken cancellationToken; + try + { + cancellationToken = this._eventProcessingCancellation.Token; + } + catch (ObjectDisposedException) + { + // Already disposed, exit early + return; + } + + while (await eventChannel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + { + while (eventChannel.Reader.TryRead(out var item)) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + if (item is not Event { EventPayload: { } eventPayload }) + { + continue; + } + + await this.HandleProviderEventAsync(registeredProvider, eventPayload, cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task HandleProviderEventAsync(RegisteredProvider registeredProvider, ProviderEventPayload eventPayload, CancellationToken cancellationToken = default) + { + try + { + // Handle PROVIDER_CONFIGURATION_CHANGED events specially - these are always re-emitted + if (eventPayload.Type == ProviderEventTypes.ProviderConfigurationChanged) + { + await this.EmitEvent(new ProviderEventPayload + { + ProviderName = $"{this._metadata.Name}/{registeredProvider.Name}", + Type = eventPayload.Type, + Message = eventPayload.Message ?? $"Configuration changed in provider {registeredProvider.Name}", + FlagsChanged = eventPayload.FlagsChanged, + EventMetadata = eventPayload.EventMetadata + }, cancellationToken).ConfigureAwait(false); + return; + } + + // For status-changing events, update provider status and check if MultiProvider status should change + UpdateProviderStatusFromEvent(registeredProvider, eventPayload); + + // Check if MultiProvider status has changed due to this provider's status change + var providerStatuses = this._registeredProviders.Select(rp => rp.Status).ToList(); + var newMultiProviderStatus = DetermineAggregateStatus(providerStatuses); + + ProviderStatus previousStatus; + ProviderEventTypes? eventType = null; + + // Only emit event if MultiProvider status actually changed + lock (this._providerStatusLock) + { + if (newMultiProviderStatus != this._providerStatus) + { + previousStatus = this._providerStatus; + this._providerStatus = newMultiProviderStatus; + + eventType = newMultiProviderStatus switch + { + ProviderStatus.Ready => ProviderEventTypes.ProviderReady, + ProviderStatus.Error or ProviderStatus.Fatal => ProviderEventTypes.ProviderError, + ProviderStatus.Stale => ProviderEventTypes.ProviderStale, + _ => (ProviderEventTypes?)null + }; + } + else + { + return; // No status change, no event to emit + } + } + + if (eventType.HasValue) + { + await this.EmitEvent(new ProviderEventPayload + { + ProviderName = this._metadata.Name, + Type = eventType.Value, + Message = $"MultiProvider status changed from {previousStatus} to {newMultiProviderStatus} due to provider {registeredProvider.Name}", + ErrorType = newMultiProviderStatus == ProviderStatus.Fatal ? ErrorType.ProviderFatal : eventPayload.ErrorType, + FlagsChanged = eventPayload.FlagsChanged, + EventMetadata = eventPayload.EventMetadata + }, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + // If there's an error processing the event, emit an error event + await this.EmitEvent(new ProviderEventPayload + { + ProviderName = this._metadata.Name, + Type = ProviderEventTypes.ProviderError, + Message = $"Error processing event from provider {registeredProvider.Name}: {ex.Message}", + ErrorType = ErrorType.General + }, cancellationToken).ConfigureAwait(false); + } + } + + private static void UpdateProviderStatusFromEvent(RegisteredProvider registeredProvider, ProviderEventPayload eventPayload) + { + var newStatus = eventPayload.Type switch + { + ProviderEventTypes.ProviderReady => ProviderStatus.Ready, + ProviderEventTypes.ProviderError => eventPayload.ErrorType == ErrorType.ProviderFatal + ? ProviderStatus.Fatal + : ProviderStatus.Error, + ProviderEventTypes.ProviderStale => ProviderStatus.Stale, + _ => registeredProvider.Status // No status change for PROVIDER_CONFIGURATION_CHANGED + }; + + if (newStatus != registeredProvider.Status) + { + registeredProvider.SetStatus(newStatus); + } + } + + private async Task EmitEvent(ProviderEventPayload eventPayload, CancellationToken cancellationToken) + { + try + { + await this.EventChannel.Writer.WriteAsync(eventPayload, cancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + // If we can't write to the event channel (e.g., it's closed), ignore the error + } + } + + private static ProviderStatus DetermineAggregateStatus(List providerStatuses) + { + // Check in precedence order as per specification + if (providerStatuses.Any(status => status == ProviderStatus.Fatal)) + { + return ProviderStatus.Fatal; + } + + if (providerStatuses.Any(status => status == ProviderStatus.NotReady)) + { + return ProviderStatus.NotReady; + } + + if (providerStatuses.Any(status => status == ProviderStatus.Error)) + { + return ProviderStatus.Error; + } + + if (providerStatuses.Any(status => status == ProviderStatus.Stale)) + { + return ProviderStatus.Stale; + } + + return providerStatuses.All(status => status == ProviderStatus.Ready) ? ProviderStatus.Ready : + // Default to NotReady if we have mixed statuses not covered above + ProviderStatus.NotReady; + } + private static ReadOnlyCollection RegisterProviders(IEnumerable providerEntries) { var entries = providerEntries.ToList(); @@ -281,16 +549,44 @@ public async ValueTask DisposeAsync() this._initializationSemaphore.Dispose(); this._shutdownSemaphore.Dispose(); this._providerStatus = ProviderStatus.Fatal; + this._eventProcessingCancellation.Dispose(); + } + } + + private async Task ShutdownEventProcessingAsync() + { + // Cancel event processing - protect against ObjectDisposedException during concurrent shutdown + try + { + this._eventProcessingCancellation.Cancel(); + } + catch (ObjectDisposedException) + { + // Expected if already disposed during concurrent shutdown + } + + // Wait for all event listening tasks to complete, ignoring cancellation exceptions + if (this._eventListeningTasks.Count != 0) + { + try + { + await Task.WhenAll(this._eventListeningTasks.Values).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected when shutting down + } } } private async Task InternalShutdownAsync(CancellationToken cancellationToken) { + await this.ShutdownEventProcessingAsync().ConfigureAwait(false); await this._shutdownSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); try { - // We should be able to shutdown the provider when it is in Ready or Fatal status. - if ((this._providerStatus != ProviderStatus.Ready && this._providerStatus != ProviderStatus.Fatal) || this._disposed == 1) + // We should be able to shut down the provider when it is in Ready or Fatal status. + if ((this.Status != ProviderStatus.Ready && this.Status != ProviderStatus.Fatal) || this._disposed == 1) { return; } @@ -322,7 +618,8 @@ private async Task InternalShutdownAsync(CancellationToken cancellationToken) exceptions); } - this._providerStatus = ProviderStatus.NotReady; + this.Status = ProviderStatus.NotReady; + this._eventListeningTasks.Clear(); } finally { @@ -336,6 +633,9 @@ private async Task InternalShutdownAsync(CancellationToken cancellationToken) /// The status to set. internal void SetStatus(ProviderStatus providerStatus) { - this._providerStatus = providerStatus; + this.Status = providerStatus; } + + [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = "Provider {ProviderName} is already being listened to")] + private partial void LogProviderAlreadyBeingListenedTo(string providerName); } diff --git a/src/OpenFeature.Providers.MultiProvider/README.md b/src/OpenFeature.Providers.MultiProvider/README.md index 4465da0f8..8b12807c0 100644 --- a/src/OpenFeature.Providers.MultiProvider/README.md +++ b/src/OpenFeature.Providers.MultiProvider/README.md @@ -181,6 +181,69 @@ await multiProvider.ShutdownAsync(); await multiProvider.DisposeAsync(); ``` +## Events + +The MultiProvider supports OpenFeature events and provides specification-compliant event handling. It follows the [OpenFeature Multi-Provider specification](https://openfeature.dev/specification/appendix-a#status-and-event-handling) for event handling behavior. + +### Event Handling Example + +```csharp +using OpenFeature; +using OpenFeature.Providers.MultiProvider; + +// Create the MultiProvider with multiple providers +var providerEntries = new[] +{ + new ProviderEntry(new ProviderA(), "provider-a"), + new ProviderEntry(new ProviderB(), "provider-b") +}; +var multiProvider = new MultiProvider(providerEntries); + +// Subscribe to MultiProvider events +Api.Instance.AddHandler(ProviderEventTypes.ProviderReady, (eventDetails) => +{ + Console.WriteLine($"MultiProvider is ready: {eventDetails?.ProviderName}"); +}); + +Api.Instance.AddHandler(ProviderEventTypes.ProviderStale, (eventDetails) => +{ + Console.WriteLine($"MultiProvider became stale: {eventDetails?.Message}"); +}); + +Api.Instance.AddHandler(ProviderEventTypes.ProviderConfigurationChanged, (eventDetails) => +{ + Console.WriteLine($"Configuration changed - Flags: {string.Join(", ", eventDetails?.FlagsChanged ?? [])}"); +}); + +Api.Instance.AddHandler(ProviderEventTypes.ProviderError, (eventDetails) => +{ + Console.WriteLine($"MultiProvider error: {eventDetails?.Message}"); +}); + +// Set the provider - this will initialize all underlying providers +// and emit PROVIDER_READY when all are successfully initialized +await Api.Instance.SetProviderAsync(multiProvider); + +// Later, if an underlying provider becomes stale and changes MultiProvider status: +// Only then will a PROVIDER_STALE event be emitted from MultiProvider +``` + +### Event Lifecycle + +1. **During Initialization**: + + - MultiProvider emits `PROVIDER_READY` when all underlying providers initialize successfully + - MultiProvider emits `PROVIDER_ERROR` if any providers fail to initialize (causing aggregate status to become ERROR/FATAL) + +2. **Runtime Status Changes**: + + - Status-changing events from underlying providers are captured internally + - MultiProvider only emits events when its aggregate status changes due to these internal events + - Example: If MultiProvider is READY and one provider becomes STALE, MultiProvider emits `PROVIDER_STALE` + +3. **Configuration Changes**: + - `PROVIDER_CONFIGURATION_CHANGED` events from underlying providers are always re-emitted + ## Requirements - .NET 8+ diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj index 4a964ef51..67d00f0ba 100644 --- a/src/OpenFeature/OpenFeature.csproj +++ b/src/OpenFeature/OpenFeature.csproj @@ -20,6 +20,7 @@ + diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderEventTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderEventTests.cs new file mode 100644 index 000000000..d41e91cfa --- /dev/null +++ b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderEventTests.cs @@ -0,0 +1,358 @@ +using System.Threading.Channels; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using OpenFeature.Constant; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Strategies.Models; +using OpenFeature.Providers.MultiProvider.Tests.Utils; + +namespace OpenFeature.Providers.MultiProvider.Tests; + +/// +/// Tests for the event emission functionality of the MultiProvider. +/// +public class MultiProviderEventTests +{ + private const string TestFlagKey = "test-flag"; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + + private readonly FeatureProvider _provider1 = new TestProvider(Provider1Name); + private readonly FeatureProvider _provider2 = new TestProvider(Provider2Name); + private readonly BaseEvaluationStrategy _strategy = Substitute.For(); + private readonly EvaluationContext _context = new EvaluationContextBuilder().Build(); + + public MultiProviderEventTests() + { + _strategy.RunMode.Returns(RunMode.Sequential); + _strategy.ShouldEvaluateThisProvider(Arg.Any>(), Arg.Any()).Returns(true); + _strategy.ShouldEvaluateNextProvider(Arg.Any>(), Arg.Any(), Arg.Any>()).Returns(false); + } + + [Fact] + public async Task InitializeAsync_OnSuccess_EmitsProviderReadyEvent() + { + // Arrange + var multiProvider = CreateMultiProvider(_provider1, _provider2); + + // Act + await multiProvider.InitializeAsync(_context); + + // Assert + var events = await ReadEvents(multiProvider.GetEventChannel()); + Assert.Single(events); + AssertEvent(events[0], "MultiProvider", ProviderEventTypes.ProviderReady, "MultiProvider successfully initialized"); + } + + [Fact] + public async Task InitializeAsync_OnProviderFailure_EmitsProviderErrorEvent() + { + // Arrange + var failedProvider = new TestProvider("failed", new InvalidOperationException("Init failed")); + var multiProvider = CreateMultiProvider(failedProvider, _provider2); + + // Act & Assert + await Assert.ThrowsAsync(() => multiProvider.InitializeAsync(_context)); + + // Verify the error event was emitted + var events = await ReadEvents(multiProvider.GetEventChannel()); + Assert.Single(events); + AssertEvent(events[0], "MultiProvider", ProviderEventTypes.ProviderError, errorType: ErrorType.ProviderFatal); + } + + [Fact] + public async Task EvaluateAsync_OnUnsupportedRunMode_EmitsProviderErrorEvent() + { + // Arrange + var multiProvider = CreateMultiProvider(_provider1); + _strategy.RunMode.Returns((RunMode)999); // Invalid run mode + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, false, _context); + + // Assert + Assert.Equal(ErrorType.ProviderFatal, result.ErrorType); + Assert.Equal(Reason.Error, result.Reason); + + // Verify the error event was emitted + var events = await ReadEvents(multiProvider.GetEventChannel()); + Assert.Single(events); + AssertEvent(events[0], "MultiProvider", ProviderEventTypes.ProviderError, errorType: ErrorType.ProviderFatal); + } + + [Fact] + public async Task EvaluateAsync_OnGeneralException_EmitsProviderErrorEvent() + { + // Arrange + var multiProvider = CreateMultiProvider(_provider1); + + _strategy.DetermineFinalResult(Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>>()) + .Throws(new InvalidOperationException("Evaluation failed")); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, false, _context); + + // Assert + Assert.Equal(ErrorType.General, result.ErrorType); + Assert.Equal(Reason.Error, result.Reason); + + // Verify the error event was emitted + var events = await ReadEvents(multiProvider.GetEventChannel()); + Assert.Single(events); + AssertEvent(events[0], "MultiProvider", ProviderEventTypes.ProviderError, errorType: ErrorType.General); + } + + [Fact] + public async Task HandleProviderEvent_OnConfigurationChanged_ReEmitsEventWithCorrectProviderName() + { + // Arrange + var multiProvider = CreateMultiProvider(_provider1); + var configEvent = new ProviderEventPayload + { + ProviderName = Provider1Name, + Type = ProviderEventTypes.ProviderConfigurationChanged, + Message = "Config changed", + FlagsChanged = [TestFlagKey] + }; + + // Act - Simulate child provider emitting configuration changed event + await EmitEventToProvider(_provider1, configEvent); + await Task.Delay(50); + + // Assert + var events = await ReadEvents(multiProvider.GetEventChannel()); + Assert.Single(events); + AssertEvent(events[0], $"MultiProvider/{Provider1Name}", ProviderEventTypes.ProviderConfigurationChanged, "Config changed"); + Assert.Contains(TestFlagKey, events[0].FlagsChanged!); + } + + [Fact] + public async Task HandleProviderEvent_OnProviderReady_EmitsMultiProviderReadyWhenAllReady() + { + // Arrange + var multiProvider = CreateMultiProvider(_provider1, _provider2); + multiProvider.SetStatus(ProviderStatus.NotReady); + + // Act - Simulate both child providers becoming ready + await EmitEventToProvider(_provider1, CreateEvent(ProviderEventTypes.ProviderReady)); + await EmitEventToProvider(_provider2, CreateEvent(ProviderEventTypes.ProviderReady)); + await Task.Delay(50); + + // Assert - Should emit MultiProvider ready event when all providers are ready + var events = await ReadEvents(multiProvider.GetEventChannel(), expectedCount: 2); + var readyEvent = events.FirstOrDefault(e => e.Type == ProviderEventTypes.ProviderReady); + Assert.NotNull(readyEvent); + AssertEvent(readyEvent, "MultiProvider", ProviderEventTypes.ProviderReady); + } + + [Fact] + public async Task HandleProviderEvent_OnProviderError_EmitsMultiProviderErrorEvent() + { + // Arrange + var multiProvider = CreateMultiProvider(_provider1); + multiProvider.SetStatus(ProviderStatus.Ready); + + // Act - Simulate child provider emitting error event + await EmitEventToProvider(_provider1, CreateEvent(ProviderEventTypes.ProviderError, ErrorType.ProviderFatal)); + await Task.Delay(50); + + // Assert + var events = await ReadEvents(multiProvider.GetEventChannel()); + Assert.Single(events); + AssertEvent(events[0], "MultiProvider", ProviderEventTypes.ProviderError, errorType: ErrorType.ProviderFatal); + } + + [Fact] + public async Task HandleProviderEvent_OnProviderStale_EmitsMultiProviderStaleEvent() + { + // Arrange + var multiProvider = CreateMultiProvider(_provider1); + multiProvider.SetStatus(ProviderStatus.Ready); + + // Act - Simulate child provider emitting stale event + await EmitEventToProvider(_provider1, CreateEvent(ProviderEventTypes.ProviderStale)); + await Task.Delay(50); + + // Assert + var events = await ReadEvents(multiProvider.GetEventChannel()); + Assert.Single(events); + AssertEvent(events[0], "MultiProvider", ProviderEventTypes.ProviderStale); + } + + [Fact] + public async Task HandleProviderEvent_OnSameStatus_DoesNotEmitEvent() + { + // Arrange + var multiProvider = CreateMultiProvider(_provider1); + multiProvider.SetStatus(ProviderStatus.Ready); + + // Act - Simulate child provider emitting ready event when MultiProvider is already ready + await EmitEventToProvider(_provider1, CreateEvent(ProviderEventTypes.ProviderReady)); + await Task.Delay(50); + + // Assert - Should not emit any events since status didn't change + var events = await ReadEvents(multiProvider.GetEventChannel(), expectedCount: 0, timeoutMs: 300); + Assert.Empty(events); + } + + [Fact] + public async Task MultipleProviders_WithStatusTransitions_EmitsCorrectAggregateEvents() + { + // Arrange + var multiProvider = CreateMultiProvider(_provider1, _provider2); + await multiProvider.InitializeAsync(_context); + await Task.Delay(50); + + // Act - Simulate one provider going to error state + await EmitEventToProvider(_provider1, CreateEvent(ProviderEventTypes.ProviderError, ErrorType.General)); + await Task.Delay(50); + // Simulate the error provider recovering + await EmitEventToProvider(_provider1, CreateEvent(ProviderEventTypes.ProviderReady)); + await Task.Delay(50); + + // Assert - Should see: Init Ready -> Error -> Ready + var events = await ReadEvents(multiProvider.GetEventChannel(), expectedCount: 3); + Assert.Contains(events, e => e.Type == ProviderEventTypes.ProviderReady); + Assert.Contains(events, e => e.Type == ProviderEventTypes.ProviderError); + } + + [Fact] + public async Task HandleProviderEvent_WithEventMetadata_PropagatesMetadata() + { + // Arrange + var multiProvider = CreateMultiProvider(_provider1); + var metadata = new Dictionary { { "source", "test" } }; + var eventPayload = new ProviderEventPayload + { + ProviderName = Provider1Name, + Type = ProviderEventTypes.ProviderConfigurationChanged, + EventMetadata = new ImmutableMetadata(metadata) + }; + + // Act + await EmitEventToProvider(_provider1, eventPayload); + await Task.Delay(50); + + // Assert + var events = await ReadEvents(multiProvider.GetEventChannel()); + Assert.Single(events); + Assert.NotNull(events[0].EventMetadata); + Assert.Equal("test", events[0].EventMetadata?.GetString("source")); + } + + [Fact] + public async Task ShutdownAsync_StopsEventProcessing() + { + // Arrange + var multiProvider = CreateMultiProvider(_provider1); + await multiProvider.InitializeAsync(_context); + + // Act + await multiProvider.ShutdownAsync(); + + // Try to emit an event after shutdown - it should not be processed + await EmitEventToProvider(_provider1, CreateEvent(ProviderEventTypes.ProviderReady)); + await Task.Delay(50); + + // Assert - Should not process any events after shutdown + var events = await ReadEvents(multiProvider.GetEventChannel(), expectedCount: 0, timeoutMs: 300); + Assert.Empty(events); + } + + [Fact] + public async Task ShutdownAsync_WithProviderFailures_ThrowsAggregateException() + { + // Arrange + var failingProvider = new TestProvider("failing", shutdownException: new InvalidOperationException("Shutdown failed")); + var multiProvider = CreateMultiProvider(failingProvider, _provider2); + await multiProvider.InitializeAsync(_context); + + // Act & Assert - Should throw AggregateException due to provider shutdown failure + var exception = await Assert.ThrowsAsync(() => multiProvider.ShutdownAsync()); + Assert.Contains("Failed to shutdown providers", exception.Message); + } + + [Fact] + public async Task DisposeAsync_CleansUpEventProcessing() + { + // Arrange + var multiProvider = CreateMultiProvider(_provider1); + await multiProvider.InitializeAsync(_context); + + // Act + await multiProvider.DisposeAsync(); + + // Assert - Should not throw and should handle disposal gracefully + await Task.Delay(100); // Give time for any potential processing + + // Verify that subsequent operations on disposed provider throw + await Assert.ThrowsAsync(() => + multiProvider.ResolveBooleanValueAsync(TestFlagKey, false)); + } + + // Helper methods + private MultiProvider CreateMultiProvider(params FeatureProvider[] providers) + { + var entries = providers.Select((p, i) => new ProviderEntry(p, $"provider{i + 1}")).ToList(); + return new MultiProvider(entries, _strategy); + } + + private static ProviderEventPayload CreateEvent(ProviderEventTypes type, ErrorType? errorType = null) + { + return new ProviderEventPayload + { + Type = type, + ErrorType = errorType, + Message = $"{type} event" + }; + } + + private static async Task EmitEventToProvider(FeatureProvider provider, ProviderEventPayload eventPayload) + { + var eventChannel = provider.GetEventChannel(); + var eventWrapper = new Event { EventPayload = eventPayload, Provider = provider }; + await eventChannel.Writer.WriteAsync(eventWrapper); + } + + private static async Task> ReadEvents(Channel channel, int expectedCount = 1, int timeoutMs = 1000) + { + var events = new List(); + var cts = new CancellationTokenSource(timeoutMs); + + try + { + while (events.Count < expectedCount && !cts.Token.IsCancellationRequested) + { + if (!await channel.Reader.WaitToReadAsync(cts.Token)) + break; + + while (channel.Reader.TryRead(out var item) && events.Count < expectedCount) + { + if (item is ProviderEventPayload payload) + events.Add(payload); + } + } + } + catch (OperationCanceledException) + { + // Timeout - return what we have + } + + return events; + } + + private static void AssertEvent(ProviderEventPayload eventPayload, string expectedProviderName, + ProviderEventTypes expectedType, string? expectedMessage = null, ErrorType? errorType = null) + { + Assert.Equal(expectedProviderName, eventPayload.ProviderName); + Assert.Equal(expectedType, eventPayload.Type); + + if (expectedMessage != null) + Assert.Contains(expectedMessage, eventPayload.Message); + + if (errorType.HasValue) + Assert.Equal(errorType.Value, eventPayload.ErrorType); + } +} diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs index 1e4ddaf2f..c90a8a68d 100644 --- a/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs @@ -412,7 +412,7 @@ public async Task EvaluateAsync_WithParallelMode_EvaluatesProvidersInParallel() } [Fact] - public async Task EvaluateAsync_WithUnsupportedRunMode_ThrowsNotSupportedException() + public async Task EvaluateAsync_WithUnsupportedRunMode_ReturnsErrorDetails() { // Arrange const bool defaultValue = false; @@ -422,9 +422,10 @@ public async Task EvaluateAsync_WithUnsupportedRunMode_ThrowsNotSupportedExcepti this._mockStrategy.RunMode.Returns((RunMode)999); // Invalid enum value // Act & Assert - var exception = await Assert.ThrowsAsync(() => - multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext)); - Assert.Contains("Unsupported run mode", exception.Message); + var details = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, this._evaluationContext); + Assert.Equal(ErrorType.ProviderFatal, details.ErrorType); + Assert.Equal(Reason.Error, details.Reason); + Assert.Contains("Unsupported run mode", details.ErrorMessage); } [Fact] diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/Utils/TestProvider.cs b/test/OpenFeature.Providers.MultiProvider.Tests/Utils/TestProvider.cs new file mode 100644 index 000000000..883cd6582 --- /dev/null +++ b/test/OpenFeature.Providers.MultiProvider.Tests/Utils/TestProvider.cs @@ -0,0 +1,62 @@ +using OpenFeature.Model; + +namespace OpenFeature.Providers.MultiProvider.Tests.Utils; + +/// +/// A test implementation of FeatureProvider for MultiProvider testing. +/// +public class TestProvider : FeatureProvider +{ + private readonly string _name; + private readonly Exception? _initException; + private readonly Exception? _shutdownException; + + public TestProvider(string name, Exception? initException = null, Exception? shutdownException = null) + { + this._name = name; + this._initException = initException; + this._shutdownException = shutdownException; + } + + public override Metadata GetMetadata() => new(this._name); + + public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) + { + if (this._initException != null) + { + throw this._initException; + } + + await Task.CompletedTask; + } + + public override async Task ShutdownAsync(CancellationToken cancellationToken = default) + { + if (this._shutdownException != null) + { + throw this._shutdownException; + } + + await Task.CompletedTask; + } + + public override Task> ResolveBooleanValueAsync(string flagKey, bool defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) => + Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + + public override Task> ResolveStringValueAsync(string flagKey, string defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) => + Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + + public override Task> ResolveIntegerValueAsync(string flagKey, int defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) => + Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + + public override Task> ResolveDoubleValueAsync(string flagKey, double defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) => + Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + + public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, + EvaluationContext? context = null, CancellationToken cancellationToken = default) => + Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); +} From 40beb30086febc49717f87019256071114c0e56b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:17:01 +0100 Subject: [PATCH 118/126] chore(deps): update dependency microsoft.net.test.sdk to v18 (#593) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9539ed4ca..2420293a8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,7 +36,7 @@ - + From d15960c28a7c34301292d7094c235e5c61f29083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Wed, 8 Oct 2025 18:56:24 +0100 Subject: [PATCH 119/126] ci: update lint PR workflow to improve error handling and messaging (#597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: update lint PR workflow to improve error handling and messaging Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * revert version. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore: add permissions for pull requests in lint PR workflow Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .github/workflows/lint-pr.yml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index a08bb80e4..51e5b5426 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -12,9 +12,31 @@ jobs: name: Validate PR title runs-on: ubuntu-latest permissions: - contents: read pull-requests: write steps: - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 + id: lint_pr_title env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 # v2 + # When the previous steps fails, the workflow would stop. By adding this + # condition you can continue the execution with the populated error message. + if: always() && (steps.lint_pr_title.outputs.error_message != null) + with: + header: pr-title-lint-error + message: | + Hey there and thank you for opening this pull request! 👋🏼 + + We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted. + Details: + + ``` + ${{ steps.lint_pr_title.outputs.error_message }} + ``` + # Delete a previous comment when the issue has been resolved + - if: ${{ steps.lint_pr_title.outputs.error_message == null }} + uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 # v2 + with: + header: pr-title-lint-error + delete: true From b375738eba930ecb35ded051b5adfaeb70fdb921 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:03:16 +0100 Subject: [PATCH 120/126] chore(deps): update github/codeql-action digest to 755f449 (#598) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b9852777e..c410c7669 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3 + uses: github/codeql-action/init@755f44910c12a3d7ca0d8c6e42c048b3362f7cec # v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3 + uses: github/codeql-action/autobuild@755f44910c12a3d7ca0d8c6e42c048b3362f7cec # v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3 + uses: github/codeql-action/analyze@755f44910c12a3d7ca0d8c6e42c048b3362f7cec # v3 From 3dc795fef57f4ec126914377d8ee8ae751996771 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:03:34 +0100 Subject: [PATCH 121/126] chore(deps): update marocchino/sticky-pull-request-comment digest to 7737449 (#599) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/lint-pr.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint-pr.yml b/.github/workflows/lint-pr.yml index 51e5b5426..2aad982e7 100644 --- a/.github/workflows/lint-pr.yml +++ b/.github/workflows/lint-pr.yml @@ -19,7 +19,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 # v2 + - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2 # When the previous steps fails, the workflow would stop. By adding this # condition you can continue the execution with the populated error message. if: always() && (steps.lint_pr_title.outputs.error_message != null) @@ -36,7 +36,7 @@ jobs: ``` # Delete a previous comment when the issue has been resolved - if: ${{ steps.lint_pr_title.outputs.error_message == null }} - uses: marocchino/sticky-pull-request-comment@331f8f5b4215f0445d3c07b4967662a32a2d3e31 # v2 + uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2 with: header: pr-title-lint-error delete: true From a3914cdf86e4fa6b27ce84bb814f2724c6de0a04 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 09:03:53 +0000 Subject: [PATCH 122/126] chore(deps): update opentelemetry-dotnet monorepo to 1.13.1 (#600) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Directory.Packages.props | 4 ++-- samples/AspNetCore/Samples.AspNetCore.csproj | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 2420293a8..0f6a8448b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -38,8 +38,8 @@ - - + + diff --git a/samples/AspNetCore/Samples.AspNetCore.csproj b/samples/AspNetCore/Samples.AspNetCore.csproj index 6a322e8f1..6945e6692 100644 --- a/samples/AspNetCore/Samples.AspNetCore.csproj +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -13,9 +13,9 @@ - + - + From 51aefbc574e067d818164b3837563ce6354907e3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:15:43 +0100 Subject: [PATCH 123/126] chore(deps): update github/codeql-action action to v4 (#601) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c410c7669..74378bfc9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@755f44910c12a3d7ca0d8c6e42c048b3362f7cec # v3 + uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@755f44910c12a3d7ca0d8c6e42c048b3362f7cec # v3 + uses: github/codeql-action/autobuild@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@755f44910c12a3d7ca0d8c6e42c048b3362f7cec # v3 + uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4 From 95ae7f03249e351c20ccd6152d88400a7e1ef764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Wed, 15 Oct 2025 21:54:30 +0100 Subject: [PATCH 124/126] feat: Implement hooks in multi provider (#594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adding GetProviderHooks override. Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Enhance provider evaluation with hook execution support Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Update README to clarify Multi-Provider support for hooks and events Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Replace null with ClientMetadata in EvaluateAsync calls Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: Require ILogger parameter in EvaluateAsync and related methods Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: Add unit test for EvaluateAsync with provider hooks and error handling Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Fix formatting Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: Add unit tests for GetProviderHooks and EvaluateAsync with hooks handling Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * add unit test for GetFlagValueType to validate flag value types Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: remove GetProviderHooks implementation and update related tests to return empty list Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: Add unit test for EvaluateAsync to handle exceptions from after hooks Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Update README.md Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- README.md | 8 +- .../MultiProvider.cs | 4 +- .../ProviderExtensions.cs | 150 ++++++++++- .../MultiProviderTests.cs | 248 ++++++++++++++++++ .../ProviderExtensionsTests.cs | 155 ++++++++++- 5 files changed, 538 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index c263023f9..1b0a96047 100644 --- a/README.md +++ b/README.md @@ -443,10 +443,12 @@ Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/ ### Multi-Provider > [!NOTE] -> The Multi-Provider feature is currently experimental. Hooks and events are not supported at the moment. +> The Multi-Provider feature is currently experimental. The Multi-Provider enables the use of multiple underlying feature flag providers simultaneously, allowing different providers to be used for different flag keys or based on specific evaluation strategies. +The Multi-Provider supports provider hooks and executes them in accordance with the OpenFeature specification. Each provider's hooks are executed with context isolation, ensuring that context modifications by one provider's hooks do not affect other providers. + #### Basic Usage ```csharp @@ -524,9 +526,7 @@ The Multi-Provider supports two evaluation modes: #### Limitations -- **Hooks are not supported**: Multi-Provider does not currently support hook registration or execution -- **Events are not supported**: Provider events are not propagated from underlying providers -- **Experimental status**: The API may change in future releases +- **Experimental status**: The API may change in future releases For a complete example, see the [AspNetCore sample](./samples/AspNetCore/README.md) which demonstrates Multi-Provider usage. diff --git a/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs index 574b2e1e4..9737198f0 100644 --- a/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs +++ b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs @@ -270,7 +270,7 @@ private async Task>> SequentialEvaluationAsync< continue; } - var result = await registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, cancellationToken).ConfigureAwait(false); + var result = await registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, this._logger, cancellationToken).ConfigureAwait(false); resolutions.Add(result); if (!this._evaluationStrategy.ShouldEvaluateNextProvider(providerContext, evaluationContext, result)) @@ -297,7 +297,7 @@ private async Task>> ParallelEvaluationAsync if (this._evaluationStrategy.ShouldEvaluateThisProvider(providerContext, evaluationContext)) { - tasks.Add(registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, cancellationToken)); + tasks.Add(registeredProvider.Provider.EvaluateAsync(providerContext, evaluationContext, defaultValue, this._logger, cancellationToken)); } } diff --git a/src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs b/src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs index d8f70dfbf..160fc9e00 100644 --- a/src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs +++ b/src/OpenFeature.Providers.MultiProvider/ProviderExtensions.cs @@ -1,4 +1,8 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.Logging; using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Extension; using OpenFeature.Model; using OpenFeature.Providers.MultiProvider.Strategies.Models; @@ -11,23 +15,56 @@ internal static async Task> EvaluateAsync( StrategyPerProviderContext providerContext, EvaluationContext? evaluationContext, T defaultValue, - CancellationToken cancellationToken) + ILogger logger, + CancellationToken cancellationToken = default) { var key = providerContext.FlagKey; try { + // Execute provider hooks for this specific provider + var providerHooks = provider.GetProviderHooks(); + EvaluationContext? contextForThisProvider = evaluationContext; + + if (providerHooks.Count > 0) + { + // Execute hooks for this provider with context isolation + var (modifiedContext, hookResult) = await ExecuteBeforeEvaluationHooksAsync( + provider, + providerHooks, + key, + defaultValue, + evaluationContext, + logger, + cancellationToken).ConfigureAwait(false); + + if (hookResult != null) + { + return hookResult; + } + + contextForThisProvider = modifiedContext ?? evaluationContext; + } + + // Evaluate the flag with the (possibly modified) context var result = defaultValue switch { - bool boolDefaultValue => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, boolDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), - string stringDefaultValue => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, stringDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), - int intDefaultValue => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, intDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), - double doubleDefaultValue => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, doubleDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), - Value valueDefaultValue => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, valueDefaultValue, evaluationContext, cancellationToken).ConfigureAwait(false), - null when typeof(T) == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), - null when typeof(T) == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, evaluationContext, cancellationToken).ConfigureAwait(false), + bool boolDefaultValue => (ResolutionDetails)(object)await provider.ResolveBooleanValueAsync(key, boolDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false), + string stringDefaultValue => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, stringDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false), + int intDefaultValue => (ResolutionDetails)(object)await provider.ResolveIntegerValueAsync(key, intDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false), + double doubleDefaultValue => (ResolutionDetails)(object)await provider.ResolveDoubleValueAsync(key, doubleDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false), + Value valueDefaultValue => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, valueDefaultValue, contextForThisProvider, cancellationToken).ConfigureAwait(false), + null when typeof(T) == typeof(string) => (ResolutionDetails)(object)await provider.ResolveStringValueAsync(key, (string)(object)defaultValue!, contextForThisProvider, cancellationToken).ConfigureAwait(false), + null when typeof(T) == typeof(Value) => (ResolutionDetails)(object)await provider.ResolveStructureValueAsync(key, (Value)(object)defaultValue!, contextForThisProvider, cancellationToken).ConfigureAwait(false), _ => throw new ArgumentException($"Unsupported flag type: {typeof(T)}") }; + + // Execute after/finally hooks for this provider if we have them + if (providerHooks.Count > 0) + { + await ExecuteAfterEvaluationHooksAsync(provider, providerHooks, key, defaultValue, contextForThisProvider, result, logger, cancellationToken).ConfigureAwait(false); + } + return new ProviderResolutionResult(provider, providerContext.ProviderName, result); } catch (Exception ex) @@ -43,4 +80,101 @@ null when typeof(T) == typeof(Value) => (ResolutionDetails)(object)await prov return new ProviderResolutionResult(provider, providerContext.ProviderName, errorResult, ex); } } + + private static async Task<(EvaluationContext?, ProviderResolutionResult?)> ExecuteBeforeEvaluationHooksAsync( + FeatureProvider provider, + IImmutableList hooks, + string key, + T defaultValue, + EvaluationContext? evaluationContext, + ILogger logger, + CancellationToken cancellationToken) + { + try + { + var sharedHookContext = new SharedHookContext( + key, + defaultValue, + GetFlagValueType(), + new ClientMetadata(MultiProviderConstants.ProviderName, null), + provider.GetMetadata() + ); + + var initialContext = evaluationContext ?? EvaluationContext.Empty; + var hookRunner = new HookRunner([.. hooks], initialContext, sharedHookContext, logger); + + // Execute before hooks for this provider + var modifiedContext = await hookRunner.TriggerBeforeHooksAsync(null, cancellationToken).ConfigureAwait(false); + return (modifiedContext, null); + } + catch (Exception hookEx) + { + // If before hooks fail, return error result + var errorResult = new ResolutionDetails( + key, + defaultValue, + ErrorType.General, + Reason.Error, + errorMessage: $"Provider hook execution failed: {hookEx.Message}"); + + var result = new ProviderResolutionResult(provider, provider.GetMetadata()?.Name ?? "unknown", errorResult, hookEx); + return (null, result); + } + } + + private static async Task ExecuteAfterEvaluationHooksAsync( + FeatureProvider provider, + IImmutableList hooks, + string key, + T defaultValue, + EvaluationContext? evaluationContext, + ResolutionDetails result, + ILogger logger, + CancellationToken cancellationToken) + { + try + { + var sharedHookContext = new SharedHookContext( + key, + defaultValue, + GetFlagValueType(), + new ClientMetadata(MultiProviderConstants.ProviderName, null), + provider.GetMetadata() + ); + + var hookRunner = new HookRunner([.. hooks], evaluationContext ?? EvaluationContext.Empty, sharedHookContext, logger); + + var evaluationDetails = result.ToFlagEvaluationDetails(); + + if (result.ErrorType == ErrorType.None) + { + await hookRunner.TriggerAfterHooksAsync(evaluationDetails, null, cancellationToken).ConfigureAwait(false); + } + else + { + var exception = new FeatureProviderException(result.ErrorType, result.ErrorMessage); + await hookRunner.TriggerErrorHooksAsync(exception, null, cancellationToken).ConfigureAwait(false); + } + + await hookRunner.TriggerFinallyHooksAsync(evaluationDetails, null, cancellationToken).ConfigureAwait(false); + } + catch (Exception hookEx) + { + // Log hook execution errors but don't fail the evaluation + logger.LogWarning(hookEx, "Provider after/finally hook execution failed for provider {ProviderName}", provider.GetMetadata()?.Name ?? "unknown"); + } + } + + internal static FlagValueType GetFlagValueType() + { + return typeof(T) switch + { + _ when typeof(T) == typeof(bool) => FlagValueType.Boolean, + _ when typeof(T) == typeof(string) => FlagValueType.String, + _ when typeof(T) == typeof(int) => FlagValueType.Number, + _ when typeof(T) == typeof(double) => FlagValueType.Number, + _ when typeof(T) == typeof(Value) => FlagValueType.Object, + _ => FlagValueType.Object // Default fallback + }; + } } diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs index c90a8a68d..615b67c7e 100644 --- a/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Reflection; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -844,4 +845,251 @@ public async Task EvaluateAsync_AfterDispose_ShouldThrowObjectDisposedException( Assert.Equal(nameof(MultiProvider), structureException.ObjectName); } + #region Hook Tests + + [Fact] + public void GetProviderHooks_WithNoProviders_ReturnsEmptyList() + { + // Arrange - Create provider without hooks + var provider = Substitute.For(); + provider.GetProviderHooks().Returns(ImmutableList.Empty); + var providerEntries = new List { new(provider, Provider1Name) }; + + // Act + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); + var hooks = multiProvider.GetProviderHooks(); + + // Assert + Assert.Empty(hooks); + } + + [Fact] + public void GetProviderHooks_WithSingleProviderWithHooks_ReturnsEmptyList() + { + // Arrange + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + var providerHooks = ImmutableList.Create(hook1, hook2); + + var provider = Substitute.For(); + provider.GetProviderHooks().Returns(providerHooks); + var providerEntries = new List { new(provider, Provider1Name) }; + + // Act + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); + var hooks = multiProvider.GetProviderHooks(); + + // Assert + Assert.Empty(hooks); + } + + [Fact] + public void GetProviderHooks_WithMultipleProvidersWithHooks_ReturnsEmptyList() + { + // Arrange + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + var hook3 = Substitute.For(); + + this._mockProvider1.GetProviderHooks().Returns(ImmutableList.Create(hook1, hook2)); + this._mockProvider2.GetProviderHooks().Returns(ImmutableList.Create(hook3)); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + + // Act + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); + var hooks = multiProvider.GetProviderHooks(); + + // Assert + Assert.Empty(hooks); + } + + [Fact] + public async Task EvaluateAsync_WithProviderHooks_ExecutesHooksForEachProvider() + { + // Arrange + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + + // Setup hooks to return modified context + var modifiedContext = new EvaluationContextBuilder() + .Set("modified", "value") + .Build(); + + hook1.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(modifiedContext); + hook2.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(EvaluationContext.Empty); + + this._mockProvider1.GetProviderHooks().Returns(ImmutableList.Create(hook1)); + this._mockProvider2.GetProviderHooks().Returns(ImmutableList.Create(hook2)); + + // Setup providers to return successful results + const bool expectedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, expectedValue, ErrorType.None, Reason.Static, TestVariant); + + this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, false, Arg.Any(), Arg.Any()) + .Returns(expectedDetails); + this._mockProvider2.ResolveBooleanValueAsync(TestFlagKey, false, Arg.Any(), Arg.Any()) + .Returns(expectedDetails); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + + // Setup strategy to evaluate both providers + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), Arg.Any()).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), Arg.Any(), Arg.Any>()).Returns(true); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, false, Arg.Any(), Arg.Any>>()) + .Returns(new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null)); + + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, false, this._evaluationContext); + + // Assert + Assert.Equal(expectedValue, result.Value); + + // Verify hooks were called + await hook1.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()); + await hook2.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()); + + // Verify after hooks were called + await hook1.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), Arg.Any()); + await hook2.Received(1).AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), Arg.Any()); + + // Verify finally hooks were called + await hook1.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), Arg.Any()); + await hook2.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task EvaluateAsync_WithHookContextModification_IsolatesContextBetweenProviders() + { + // Arrange + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + + // Setup hook1 to modify context + var modifiedContext1 = new EvaluationContextBuilder() + .Set("provider1", "modified") + .Build(); + + // Setup hook2 to modify context differently + var modifiedContext2 = new EvaluationContextBuilder() + .Set("provider2", "modified") + .Build(); + + hook1.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(modifiedContext1); + hook2.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(modifiedContext2); + + this._mockProvider1.GetProviderHooks().Returns(ImmutableList.Create(hook1)); + this._mockProvider2.GetProviderHooks().Returns(ImmutableList.Create(hook2)); + + // Setup providers to return results and capture context + EvaluationContext? capturedContext1 = null; + EvaluationContext? capturedContext2 = null; + + const bool expectedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, expectedValue, ErrorType.None, Reason.Static, TestVariant); + + this._mockProvider1.ResolveBooleanValueAsync(TestFlagKey, false, Arg.Do(ctx => capturedContext1 = ctx), Arg.Any()) + .Returns(expectedDetails); + this._mockProvider2.ResolveBooleanValueAsync(TestFlagKey, false, Arg.Do(ctx => capturedContext2 = ctx), Arg.Any()) + .Returns(expectedDetails); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + + // Setup strategy to evaluate both providers + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), Arg.Any()).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), Arg.Any(), Arg.Any>()).Returns(true); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, false, Arg.Any(), Arg.Any>>()) + .Returns(new FinalResult(expectedDetails, this._mockProvider1, Provider1Name, null)); + + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); + + // Act + await multiProvider.ResolveBooleanValueAsync(TestFlagKey, false, this._evaluationContext); + + // Assert - Verify context isolation + Assert.NotNull(capturedContext1); + Assert.NotNull(capturedContext2); + + // Provider1 should have received the context modified by hook1 + Assert.True(capturedContext1!.ContainsKey("provider1")); + Assert.Equal("modified", capturedContext1.GetValue("provider1").AsString); + Assert.False(capturedContext1.ContainsKey("provider2")); + + // Provider2 should have received the context modified by hook2 + Assert.True(capturedContext2!.ContainsKey("provider2")); + Assert.Equal("modified", capturedContext2.GetValue("provider2").AsString); + Assert.False(capturedContext2.ContainsKey("provider1")); + } + + [Fact] + public async Task EvaluateAsync_WithHookError_HandlesErrorAndContinuesEvaluation() + { + // Arrange + var throwingHook = Substitute.For(); + var normalHook = Substitute.For(); + + // Setup throwing hook to throw exception in before hook + throwingHook.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Throws(new InvalidOperationException("Hook error")); + + // Setup normal hook + normalHook.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(EvaluationContext.Empty); + + this._mockProvider1.GetProviderHooks().Returns(ImmutableList.Create(throwingHook)); + this._mockProvider2.GetProviderHooks().Returns(ImmutableList.Create(normalHook)); + + // Setup provider2 to return successful result + const bool expectedValue = true; + var expectedDetails = new ResolutionDetails(TestFlagKey, expectedValue, ErrorType.None, Reason.Static, TestVariant); + + this._mockProvider2.ResolveBooleanValueAsync(TestFlagKey, false, Arg.Any(), Arg.Any()) + .Returns(expectedDetails); + + var providerEntries = new List + { + new(this._mockProvider1, Provider1Name), + new(this._mockProvider2, Provider2Name) + }; + + // Setup strategy to continue evaluation after first provider error + this._mockStrategy.ShouldEvaluateThisProvider(Arg.Any>(), Arg.Any()).Returns(true); + this._mockStrategy.ShouldEvaluateNextProvider(Arg.Any>(), Arg.Any(), Arg.Any>()).Returns(true); + this._mockStrategy.DetermineFinalResult(Arg.Any>(), TestFlagKey, false, Arg.Any(), Arg.Any>>()) + .Returns(new FinalResult(expectedDetails, this._mockProvider2, Provider2Name, null)); + + var multiProvider = new MultiProvider(providerEntries, this._mockStrategy); + + // Act + var result = await multiProvider.ResolveBooleanValueAsync(TestFlagKey, false, this._evaluationContext); + + // Assert + Assert.Equal(expectedValue, result.Value); + + // Verify that the first provider returned an error due to hook failure + // and the second provider succeeded + await this._mockProvider1.DidNotReceive().ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + await this._mockProvider2.Received(1).ResolveBooleanValueAsync(TestFlagKey, false, Arg.Any(), Arg.Any()); + } + + #endregion + } diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/ProviderExtensionsTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/ProviderExtensionsTests.cs index f37e0ddf3..afb14fe65 100644 --- a/test/OpenFeature.Providers.MultiProvider.Tests/ProviderExtensionsTests.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/ProviderExtensionsTests.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; using OpenFeature.Constant; @@ -15,6 +17,7 @@ public class ProviderExtensionsTests private readonly FeatureProvider _mockProvider = Substitute.For(); private readonly EvaluationContext _evaluationContext = new EvaluationContextBuilder().Build(); private readonly CancellationToken _cancellationToken = CancellationToken.None; + private readonly ILogger _mockLogger = Substitute.For(); [Fact] public async Task EvaluateAsync_WithBooleanType_CallsResolveBooleanValueAsync() @@ -29,7 +32,7 @@ public async Task EvaluateAsync_WithBooleanType_CallsResolveBooleanValueAsync() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -53,7 +56,7 @@ public async Task EvaluateAsync_WithStringType_CallsResolveStringValueAsync() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -77,7 +80,7 @@ public async Task EvaluateAsync_WithIntegerType_CallsResolveIntegerValueAsync() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -101,7 +104,7 @@ public async Task EvaluateAsync_WithDoubleType_CallsResolveDoubleValueAsync() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -125,7 +128,7 @@ public async Task EvaluateAsync_WithValueType_CallsResolveStructureValueAsync() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -144,7 +147,7 @@ public async Task EvaluateAsync_WithUnsupportedType_ThrowsArgumentException() var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -171,7 +174,7 @@ public async Task EvaluateAsync_WhenProviderThrowsException_ReturnsErrorResult() .ThrowsAsync(expectedException); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -198,7 +201,7 @@ public async Task EvaluateAsync_WithNullEvaluationContext_CallsProviderWithNullC .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, null, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, null, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -223,7 +226,7 @@ public async Task EvaluateAsync_WithCancellationToken_PassesToProvider() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, customCancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, customCancellationToken); // Assert Assert.NotNull(result); @@ -244,7 +247,7 @@ public async Task EvaluateAsync_WithNullDefaultValue_PassesNullToProvider() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue!, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue!, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -266,7 +269,7 @@ public async Task EvaluateAsync_WithDifferentFlagKeys_UsesCorrectKey() .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); @@ -294,7 +297,7 @@ public async Task EvaluateAsync_WhenOperationCancelled_ReturnsErrorResult() }); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, cancellationTokenSource.Token); + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, cancellationTokenSource.Token); // Assert Assert.NotNull(result); @@ -325,11 +328,137 @@ public async Task EvaluateAsync_WithComplexEvaluationContext_PassesContextToProv .Returns(expectedDetails); // Act - var result = await this._mockProvider.EvaluateAsync(providerContext, complexContext, defaultValue, this._cancellationToken); + var result = await this._mockProvider.EvaluateAsync(providerContext, complexContext, defaultValue, this._mockLogger, this._cancellationToken); // Assert Assert.NotNull(result); Assert.Equal(expectedDetails, result.ResolutionDetails); await this._mockProvider.Received(1).ResolveDoubleValueAsync(TestFlagKey, defaultValue, complexContext, this._cancellationToken); } + + [Fact] + public async Task EvaluateAsync_WithProviderHooksAndErrorResult_TriggersErrorHooks() + { + // Arrange + var mockHook = Substitute.For(); + + // Setup hook to return evaluation context successfully + mockHook.BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(EvaluationContext.Empty); + + // Setup provider metadata + var providerMetadata = new Metadata(TestProviderName); + this._mockProvider.GetMetadata().Returns(providerMetadata); + this._mockProvider.GetProviderHooks().Returns(ImmutableList.Create(mockHook)); + + const bool defaultValue = false; + var errorDetails = new ResolutionDetails( + TestFlagKey, + defaultValue, + ErrorType.FlagNotFound, + Reason.Error, + TestVariant, + errorMessage: "Flag not found"); + + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, Arg.Any(), this._cancellationToken) + .Returns(errorDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(ErrorType.FlagNotFound, result.ResolutionDetails.ErrorType); + Assert.Equal(Reason.Error, result.ResolutionDetails.Reason); + + // Verify before hook was called + await mockHook.Received(1).BeforeAsync(Arg.Any>(), Arg.Any>(), Arg.Any()); + + // Verify error hook was called (not after hook) + await mockHook.Received(1).ErrorAsync(Arg.Any>(), Arg.Any(), Arg.Any>(), Arg.Any()); + await mockHook.DidNotReceive().AfterAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), Arg.Any()); + + // Verify finally hook was called + await mockHook.Received(1).FinallyAsync(Arg.Any>(), Arg.Any>(), Arg.Any>(), Arg.Any()); + } + + [Theory] + [InlineData(typeof(bool), FlagValueType.Boolean)] + [InlineData(typeof(string), FlagValueType.String)] + [InlineData(typeof(int), FlagValueType.Number)] + [InlineData(typeof(double), FlagValueType.Number)] + [InlineData(typeof(Value), FlagValueType.Object)] + [InlineData(typeof(ProviderExtensionsTests), FlagValueType.Object)] // fallback path + public void GetFlagValueType_ReturnsExpectedFlagValueType(Type inputType, FlagValueType expected) + { + FlagValueType result = inputType == typeof(bool) ? ProviderExtensions.GetFlagValueType() + : inputType == typeof(string) ? ProviderExtensions.GetFlagValueType() + : inputType == typeof(int) ? ProviderExtensions.GetFlagValueType() + : inputType == typeof(double) ? ProviderExtensions.GetFlagValueType() + : inputType == typeof(Value) ? ProviderExtensions.GetFlagValueType() + : ProviderExtensions.GetFlagValueType(); + + Assert.Equal(expected, result); + } + + [Fact] + public async Task EvaluateAsync_WhenAfterHookThrowsException_LogsWarningButSucceeds() + { + // Arrange + var hookException = new InvalidOperationException("After hook failed"); + var throwingHook = new ThrowingAfterHook(hookException); + + // Setup provider metadata and hooks + var providerMetadata = new Metadata(TestProviderName); + this._mockProvider.GetMetadata().Returns(providerMetadata); + this._mockProvider.GetProviderHooks().Returns(ImmutableList.Create(throwingHook)); + + const bool defaultValue = false; + const bool resolvedValue = true; + var successDetails = new ResolutionDetails( + TestFlagKey, + resolvedValue, + ErrorType.None, + Reason.Static, + TestVariant); + + var providerContext = new StrategyPerProviderContext(this._mockProvider, TestProviderName, ProviderStatus.Ready, TestFlagKey); + + this._mockProvider.ResolveBooleanValueAsync(TestFlagKey, defaultValue, Arg.Any(), this._cancellationToken) + .Returns(successDetails); + + // Act + var result = await this._mockProvider.EvaluateAsync(providerContext, this._evaluationContext, defaultValue, this._mockLogger, this._cancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Equal(resolvedValue, result.ResolutionDetails.Value); + Assert.Equal(ErrorType.None, result.ResolutionDetails.ErrorType); + Assert.Null(result.ThrownError); // Hook errors don't propagate + + // Verify warning was logged + this._mockLogger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains("Provider after/finally hook execution failed")), + Arg.Is(ex => ex == hookException), + Arg.Any>()); + } +} + +internal class ThrowingAfterHook : Hook +{ + private InvalidOperationException hookException; + + public ThrowingAfterHook(InvalidOperationException hookException) + { + this.hookException = hookException; + } + + public override ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, IReadOnlyDictionary? hints = null, CancellationToken cancellationToken = default) + { + throw this.hookException; + } } From 62a81fea8625dd2ab7f009a1405956df79cce0a6 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 16 Oct 2025 20:45:37 +0100 Subject: [PATCH 125/126] docs: Add OpenFeature.Hosting README (#582) * Add OpenFeature.Hosting package README Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Address issue with DependencyInjection README not being included Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Update repo README with new guidance on how to using Hosting Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Address PR comments Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- README.md | 7 +- .../OpenFeature.DependencyInjection.csproj | 3 +- .../OpenFeature.Hosting.csproj | 2 + src/OpenFeature.Hosting/README.md | 141 ++++++++++++++++++ 4 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 src/OpenFeature.Hosting/README.md diff --git a/README.md b/README.md index 1b0a96047..88c319c1e 100644 --- a/README.md +++ b/README.md @@ -533,14 +533,13 @@ For a complete example, see the [AspNetCore sample](./samples/AspNetCore/README. ### Dependency Injection > [!NOTE] -> The OpenFeature.DependencyInjection and OpenFeature.Hosting packages are currently experimental. They streamline the integration of OpenFeature within .NET applications, allowing for seamless configuration and lifecycle management of feature flag providers using dependency injection and hosting services. +> The OpenFeature.Hosting package is currently experimental. The Hosting package streamlines the integration of OpenFeature within .NET applications, allowing for seamless configuration and lifecycle management of feature flag providers using dependency injection and hosting services. #### Installation -To set up dependency injection and hosting capabilities for OpenFeature, install the following packages: +To set up dependency injection and hosting capabilities for OpenFeature, install the following package: ```sh -dotnet add package OpenFeature.DependencyInjection dotnet add package OpenFeature.Hosting ``` @@ -553,7 +552,6 @@ For a basic configuration, you can use the InMemoryProvider. This provider is si ```csharp builder.Services.AddOpenFeature(featureBuilder => { featureBuilder - .AddHostedFeatureLifecycle() // From Hosting package .AddInMemoryProvider(); }); ``` @@ -575,7 +573,6 @@ builder.Services.AddOpenFeature(featureBuilder => { ```csharp builder.Services.AddOpenFeature(featureBuilder => { featureBuilder - .AddHostedFeatureLifecycle() .AddContext((contextBuilder, serviceProvider) => { /* Custom context configuration */ }) .AddHook((serviceProvider) => new LoggingHook( /* Custom configuration */ )) .AddHook(new MetricsHook()) diff --git a/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj index 923473715..9ae3029df 100644 --- a/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj +++ b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj @@ -3,6 +3,7 @@ netstandard2.0;net8.0;net9.0;net462 OpenFeature.DependencyInjection + README.md @@ -17,7 +18,7 @@ - + diff --git a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj index 85131a0fa..84e5efa61 100644 --- a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj +++ b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj @@ -3,6 +3,7 @@ netstandard2.0;net8.0;net9.0;net462 OpenFeature + README.md @@ -16,6 +17,7 @@ + diff --git a/src/OpenFeature.Hosting/README.md b/src/OpenFeature.Hosting/README.md new file mode 100644 index 000000000..3b530d214 --- /dev/null +++ b/src/OpenFeature.Hosting/README.md @@ -0,0 +1,141 @@ +# OpenFeature.Hosting + +[![NuGet](https://img.shields.io/nuget/vpre/OpenFeature.Hosting?label=OpenFeature.Hosting&style=for-the-badge)](https://www.nuget.org/packages/OpenFeature.Hosting) +[![Specification](https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge)](https://github.com/open-feature/spec/releases/tag/v0.8.0) + +OpenFeature.Hosting is an extension for the [OpenFeature .NET SDK](https://github.com/open-feature/dotnet-sdk) that streamlines integration with .NET applications using dependency injection and hosting. It enables seamless configuration and lifecycle management of feature flag providers, hooks, and evaluation context using idiomatic .NET patterns. + +**🧪 The OpenFeature.Hosting package is still considered experimental and may undergo significant changes. Feedback and contributions are welcome!** + +## 🚀 Quick Start + +### Requirements + +- .NET 8+ +- .NET Framework 4.6.2+ + +### Installation + +Add the package to your project: + +```sh +dotnet add package OpenFeature.Hosting +``` + +### Basic Usage + +Register OpenFeature in your application's dependency injection container (e.g., in `Program.cs` for ASP.NET Core): + +```csharp +builder.Services.AddOpenFeature(featureBuilder => { + featureBuilder + .AddInMemoryProvider(); +}); +``` + +You can add global evaluation context, hooks, and event handlers as needed: + +```csharp +builder.Services.AddOpenFeature(featureBuilder => { + featureBuilder + .AddContext((contextBuilder, serviceProvider) => { + // Custom context configuration + }) + .AddHook() + .AddHandler(ProviderEventTypes.ProviderReady, (eventDetails) => { + // Handle provider ready event + }); +}); +``` + +### Domain-Scoped Providers + +To register multiple providers and select a default provider by domain: + +```csharp +builder.Services.AddOpenFeature(featureBuilder => { + featureBuilder + .AddInMemoryProvider("default") + .AddInMemoryProvider("beta") + .AddPolicyName(options => { + options.DefaultNameSelector = serviceProvider => "default"; + }); +}); +``` + +### Registering a Custom Provider + +You can register a custom provider using a factory: + +```csharp +builder.Services.AddOpenFeature(featureBuilder => { + featureBuilder.AddProvider(provider => { + // Resolve services or configuration as needed + return new MyCustomProvider(); + }); +}); +``` + +## 🧩 Features + +- **Dependency Injection**: Register providers, hooks, and context using the .NET DI container. +- **Domain Support**: Assign providers to logical domains for multi-tenancy or environment separation. +- **Event Handlers**: React to provider lifecycle events (e.g., readiness). +- **Extensibility**: Add custom hooks, context, and providers. + +## 🛠️ Example: ASP.NET Core Integration + +Below is a simple example of integrating OpenFeature with an ASP.NET Core application using an in-memory provider and a logging hook. + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenFeature(featureBuilder => { + featureBuilder + .AddInMemoryProvider() + .AddHook(); +}); + +var app = builder.Build(); + +app.MapGet("/", async (IFeatureClient client) => { + bool enabled = await client.GetBooleanValueAsync("my-flag", false); + return enabled ? "Feature enabled!" : "Feature disabled."; +}); + +app.Run(); +``` + +If you have multiple providers registered, you can specify which client and provider to resolve by using the `FromKeyedServices` attribute: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenFeature(featureBuilder => { + featureBuilder + .AddInMemoryProvider("default") + .AddInMemoryProvider("beta") + .AddPolicyName(options => { + options.DefaultNameSelector = serviceProvider => "default"; + }); +}); + +var app = builder.Build(); + +app.MapGet("/", async ([FromKeyedServices("beta")] IFeatureClient client) => { + bool enabled = await client.GetBooleanValueAsync("my-flag", false); + return enabled ? "Feature enabled!" : "Feature disabled."; +}); + +app.Run(); +``` + +## 📚 Further Reading + +- [OpenFeature .NET SDK Documentation](https://github.com/open-feature/dotnet-sdk) +- [OpenFeature Specification](https://openfeature.dev) +- [Samples](https://github.com/open-feature/dotnet-sdk/blob/main/samples/AspNetCore/README.md) + +## 🤝 Contributing + +Contributions are welcome! See the [CONTRIBUTING](https://github.com/open-feature/dotnet-sdk/blob/main/CONTRIBUTING.md) guide for details. From 29e1657c28f10b71c3573daec4e48b33f42329fb Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Thu, 16 Oct 2025 16:58:41 -0400 Subject: [PATCH 126/126] chore(main): release 2.9.0 (#547) Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 21 +++++++++++++++++++++ README.md | 4 ++-- build/Common.prod.props | 2 +- version.txt | 2 +- 5 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 10d53e3a4..a3906fc08 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.8.1" + ".": "2.9.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 87051fdfd..bdbe5b608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [2.9.0](https://github.com/open-feature/dotnet-sdk/compare/v2.8.1...v2.9.0) (2025-10-16) + + +### 🐛 Bug Fixes + +* update provider status to Fatal during disposal ([#580](https://github.com/open-feature/dotnet-sdk/issues/580)) ([76bd94b](https://github.com/open-feature/dotnet-sdk/commit/76bd94b03ea19ad3c432a52dd644317e362b99ec)) + + +### ✨ New Features + +* Add events to the multi provider ([#568](https://github.com/open-feature/dotnet-sdk/issues/568)) ([9d8ab03](https://github.com/open-feature/dotnet-sdk/commit/9d8ab037df1749d098f5e1e210f71cf9d1e7adff)) +* Add multi-provider support ([#488](https://github.com/open-feature/dotnet-sdk/issues/488)) ([7237053](https://github.com/open-feature/dotnet-sdk/commit/7237053561d9c36194197169734522f0b978f6e5)) +* Deprecate AddHostedFeatureLifecycle method ([#531](https://github.com/open-feature/dotnet-sdk/issues/531)) ([fdf2297](https://github.com/open-feature/dotnet-sdk/commit/fdf229737118639d323e74cceac490d44c4c24dd)) +* Implement hooks in multi provider ([#594](https://github.com/open-feature/dotnet-sdk/issues/594)) ([95ae7f0](https://github.com/open-feature/dotnet-sdk/commit/95ae7f03249e351c20ccd6152d88400a7e1ef764)) +* Support retrieving numeric metadata as either integers or decimals ([#490](https://github.com/open-feature/dotnet-sdk/issues/490)) ([12de5f1](https://github.com/open-feature/dotnet-sdk/commit/12de5f10421bac749fdd45c748e7b970f3f69a39)) + + +### 🚀 Performance + +* Add NativeAOT Support ([#554](https://github.com/open-feature/dotnet-sdk/issues/554)) ([acd0486](https://github.com/open-feature/dotnet-sdk/commit/acd0486563f7b67a782ee169315922fb5d0f343e)) + ## [2.8.1](https://github.com/open-feature/dotnet-sdk/compare/v2.8.0...v2.8.1) (2025-07-31) diff --git a/README.md b/README.md index 88c319c1e..4eb02b551 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ [![Specification](https://img.shields.io/static/v1?label=specification&message=v0.8.0&color=yellow&style=for-the-badge)](https://github.com/open-feature/spec/releases/tag/v0.8.0) [ -![Release](https://img.shields.io/static/v1?label=release&message=v2.8.1&color=blue&style=for-the-badge) -](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.8.1) +![Release](https://img.shields.io/static/v1?label=release&message=v2.9.0&color=blue&style=for-the-badge) +](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.9.0) [![Slack](https://img.shields.io/badge/slack-%40cncf%2Fopenfeature-brightgreen?style=flat&logo=slack)](https://cloud-native.slack.com/archives/C0344AANLA1) [![Codecov](https://codecov.io/gh/open-feature/dotnet-sdk/branch/main/graph/badge.svg?token=MONAVJBXUJ)](https://codecov.io/gh/open-feature/dotnet-sdk) diff --git a/build/Common.prod.props b/build/Common.prod.props index 7feb1759c..f1a21cc26 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -9,7 +9,7 @@ - 2.8.1 + 2.9.0 git https://github.com/open-feature/dotnet-sdk OpenFeature is an open standard for feature flag management, created to support a robust feature flag ecosystem using cloud native technologies. OpenFeature will provide a unified API and SDK, and a developer-first, cloud-native implementation, with extensibility for open source and commercial offerings. diff --git a/version.txt b/version.txt index dbe590065..c8e38b614 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.8.1 +2.9.0