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 001/124] 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 002/124] 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 003/124] 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 004/124] 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 005/124] 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 006/124] 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 007/124] 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 008/124] 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 009/124] 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 010/124] 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 011/124] 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 012/124] 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 013/124] 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 014/124] 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 015/124] 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 016/124] 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 017/124] 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 018/124] 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 019/124] 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 020/124] 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 021/124] 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 022/124] 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 023/124] 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 024/124] 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 025/124] 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 026/124] 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 027/124] 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 028/124] 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 029/124] 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 030/124] 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 031/124] 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 032/124] 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 033/124] 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 034/124] 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 035/124] 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 036/124] 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 037/124] 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 038/124] 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 039/124] 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 040/124] 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 041/124] 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 042/124] 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 043/124] 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 044/124] 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 045/124] 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 046/124] 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 047/124] 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 048/124] 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 049/124] 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 050/124] 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 051/124] 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 052/124] 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 053/124] 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 054/124] 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 055/124] 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 056/124] 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 057/124] 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 058/124] 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 059/124] 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 060/124] 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 061/124] 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 062/124] 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 063/124] 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 064/124] 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 065/124] 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 066/124] 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 From dfbc3eef1f7468dc363c71fef1eb1f42e1bb8a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Tue, 28 Oct 2025 20:20:54 +0000 Subject: [PATCH 067/124] refactor: Clean up project files by removing TargetFrameworks and formatting (#611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Clean up project files by removing TargetFrameworks and formatting Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Update src/Directory.Build.props Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> 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> --- src/Directory.Build.props | 9 ++-- .../OpenFeature.DependencyInjection.csproj | 35 +++++++------ .../OpenFeature.Hosting.csproj | 33 ++++++------- ...OpenFeature.Providers.MultiProvider.csproj | 27 +++++----- src/OpenFeature/OpenFeature.csproj | 49 ++++++++++--------- 5 files changed, 78 insertions(+), 75 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 3b7879044..e5439c49b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,7 +1,10 @@ - + - + net462;netstandard2.0;net8.0;net9.0 + $([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0')) - + \ No newline at end of file diff --git a/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj index 9ae3029df..afefeb9a9 100644 --- a/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj +++ b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj @@ -1,24 +1,23 @@ - - netstandard2.0;net8.0;net9.0;net462 - OpenFeature.DependencyInjection - README.md - + + OpenFeature.DependencyInjection + README.md + - - - - + + + + - - - + + + - - - - - + + + + + - + \ No newline at end of file diff --git a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj index 84e5efa61..bf570a897 100644 --- a/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj +++ b/src/OpenFeature.Hosting/OpenFeature.Hosting.csproj @@ -1,23 +1,22 @@  - - netstandard2.0;net8.0;net9.0;net462 - OpenFeature - README.md - + + OpenFeature + README.md + - - - + + + - - - + + + - - - - - + + + + + - + \ No newline at end of file diff --git a/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj b/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj index 000f223b5..d999c5613 100644 --- a/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj +++ b/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj @@ -1,18 +1,17 @@  - - net8.0;net9.0;netstandard2.0;net462 - OpenFeature.Providers.MultiProvider - README.md - + + OpenFeature.Providers.MultiProvider + README.md + - - - - - + + + + + - - - - + + + + \ No newline at end of file diff --git a/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj index 67d00f0ba..a9f1448c6 100644 --- a/src/OpenFeature/OpenFeature.csproj +++ b/src/OpenFeature/OpenFeature.csproj @@ -1,28 +1,31 @@ - - net8.0;net9.0;netstandard2.0;net462 - OpenFeature - README.md - + + OpenFeature + README.md + - - - - - - - + + + + + + + - - - - - - - - - - + + + + + + + + + + - + \ No newline at end of file From 1c6a68454d3be655b2efe1994c4de38a06bc4719 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 29 Oct 2025 18:48:06 +0000 Subject: [PATCH 068/124] test(integration): Enable the integration tests to run on both net8 and net9 (#608) Enable the integration tests to run on both net8 and net9 Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- Directory.Packages.props | 9 ++++++++- .../OpenFeature.IntegrationTests.csproj | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0f6a8448b..e4a0f3ed1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -43,7 +43,14 @@ - + + + + + + + + diff --git a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj index 46f99e213..e16aff9a6 100644 --- a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj +++ b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj @@ -1,7 +1,7 @@ - net9.0 + net8.0;net9.0 From 4b965dddcaeef761e01f8fcbd28941ae3f3074c9 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 29 Oct 2025 18:48:16 +0000 Subject: [PATCH 069/124] fix: Ensure AddPolicyName without adding a Provider does not get stuck in infinite loop (#606) * Fix issue when adding a DefaultNamePolicy * If you add a AddPolicyName without having previously added a Provider, the code will get stuck trying to resolve IFeatureClient Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Address copilot comments and improve unit test coverage Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../OpenFeatureBuilderExtensions.cs | 19 ++++++++--- .../OpenFeatureBuilderExtensionsTests.cs | 32 ++++++++++++++++++- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs index 52c66c42e..5e3de1bcd 100644 --- a/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs +++ b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs @@ -223,16 +223,25 @@ internal static OpenFeatureBuilder AddPolicyBasedClient(this OpenFeatureBuilder { var policy = provider.GetRequiredService>().Value; var name = policy.DefaultNameSelector(provider); - if (name == null) - { - return provider.GetRequiredService(); - } - return provider.GetRequiredKeyedService(name); + return ResolveFeatureClient(provider, name); }); return builder; } + private static IFeatureClient ResolveFeatureClient(IServiceProvider provider, string? name = null) + { + var api = provider.GetRequiredService(); + var client = api.GetClient(name); + var context = provider.GetService(); + if (context != null) + { + client.SetContext(context); + } + + return client; + } + /// /// Configures policy name options for OpenFeature using the specified options type. /// diff --git a/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs index 1a284c918..1f2662ee1 100644 --- a/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs +++ b/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs @@ -539,7 +539,7 @@ public void AddPolicyBasedClient_AddsScopedFeatureClient() Assert.NotNull(client); } - [Fact(Skip = "Bug due to https://github.com/open-feature/dotnet-sdk/issues/543")] + [Fact] public void AddPolicyBasedClient_WithNoDefaultName_AddsScopedFeatureClient() { // Arrange @@ -559,4 +559,34 @@ public void AddPolicyBasedClient_WithNoDefaultName_AddsScopedFeatureClient() var client = scope.ServiceProvider.GetService(); Assert.NotNull(client); } + + [Fact] + public void AddPolicyBasedClient_WithEvaluationContext() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + var context = EvaluationContext.Builder() + .Set("userId", "user-123") + .Build(); + + _services.AddTransient(_ => context); + + _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); + + var actualContext = client.GetContext(); + Assert.Equal(context, actualContext); + } } From c987b58b66c8186486fd06aebdc4042052f30beb Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Wed, 29 Oct 2025 18:48:25 +0000 Subject: [PATCH 070/124] fix: Ensure EvaluationContext is reliably added to the injected FeatureClient (#605) * Fix issue when adding evaluation context after adding the provider Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Tweak test to make sure it confirms the fix is applied Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Restore IsContextConfigured and following unit test assertions * Removing this was technically a breaking change Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../OpenFeatureBuilderExtensions.cs | 52 +++++++------------ .../OpenFeatureBuilderExtensionsTests.cs | 25 +++++++++ 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs index 5e3de1bcd..d8b52c6cd 100644 --- a/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs +++ b/src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs @@ -163,47 +163,35 @@ internal static OpenFeatureBuilder AddClient(this OpenFeatureBuilder builder, st { if (string.IsNullOrWhiteSpace(name)) { - if (builder.IsContextConfigured) + builder.Services.TryAddScoped(static provider => { - builder.Services.TryAddScoped(static provider => + var api = provider.GetRequiredService(); + var client = api.GetClient(); + + var context = provider.GetService(); + if (context is not null) { - 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(); - }); - } + } + + return client; + }); } else { - if (builder.IsContextConfigured) + builder.Services.TryAddKeyedScoped(name, static (provider, key) => { - builder.Services.TryAddKeyedScoped(name, static (provider, key) => + var api = provider.GetRequiredService(); + var client = api.GetClient(key!.ToString()); + + var context = provider.GetService(); + if (context is not null) { - 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 client; + }); } return builder; diff --git a/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs index 1f2662ee1..0b0feca08 100644 --- a/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs +++ b/test/OpenFeature.Hosting.Tests/OpenFeatureBuilderExtensionsTests.cs @@ -518,6 +518,31 @@ public void AddClient_WithNameAndContext_AddsFeatureClient() Assert.Equal("euw", region.AsString); } + [Fact] + public void AddClient_WithContextAfterAddProvider_AddsFeatureClient() + { + // Arrange + _services.AddSingleton(sp => Api.Instance); + + _systemUnderTest + .AddProvider("client-name", (_systemUnderTest, name) => new NoOpFeatureProvider()); + + // Act + _systemUnderTest + .AddClient("client-name") + .AddContext((a) => a.Set("region", "euw")); + + // Act + 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() { From 184dde01efc3d5234ab0fe196e8e0dfc4fcd8414 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:54:39 +0000 Subject: [PATCH 071/124] chore(deps): update github/codeql-action digest to 4e94bd1 (#616) 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 74378bfc9..a27bbe0d4 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@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4 + uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # 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@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4 + uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # 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@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4 + uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4 From 1a499791260e908295cd2c29bfcdcdead8484b24 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:55:00 +0000 Subject: [PATCH 072/124] chore(deps): update googleapis/release-please-action digest to 16a9c90 (#617) 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 caf7f9e6f..6a3ec09af 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@c2a5a2bd6a758a0937f1ddb1e8950609867ed15c # v4 + - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4 id: release with: token: ${{secrets.RELEASE_PLEASE_ACTION_TOKEN}} From f3384a5d4c0e1ddbe929ac6e63b1183e5f9f277d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:55:17 +0000 Subject: [PATCH 073/124] chore(deps): update dependency opentelemetry.instrumentation.aspnetcore to 1.13.0 (#618) 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 6945e6692..fc3fe17c0 100644 --- a/samples/AspNetCore/Samples.AspNetCore.csproj +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -14,7 +14,7 @@ - + From 0f1bc5942c36796dbc26d931d1e074de246705eb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 07:55:38 +0000 Subject: [PATCH 074/124] chore(deps): update actions/upload-artifact action to v5 (#619) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f62e3cd44..cf9d3c36a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,7 @@ jobs: - name: Publish NuGet packages (fork) if: github.event.pull_request.head.repo.fork == true - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: nupkgs path: src/**/*.nupkg From 1b40391034b0762aa755a05374a908eb97cdf444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Thu, 30 Oct 2025 08:12:12 +0000 Subject: [PATCH 075/124] feat: Add SourceLink configuration for .NET to enhance debugging experience (#614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: Add SourceLink configuration for .NET SDK 8+ to enhance debugging experience Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- build/Common.prod.props | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/build/Common.prod.props b/build/Common.prod.props index f1a21cc26..c9f732646 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -33,4 +33,16 @@ + + + + + true + + true + + true + snupkg + + From a8d12ef12d75aaa770551b3052cd8725b65b5fd8 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:38:49 +0000 Subject: [PATCH 076/124] fix: Address issue with FeatureClient not being resolved when no Provider added (#607) * Fix issue with FeatureClient not being resolved * Address issue when adding context to the feature builder without providing any provider or default feature client. Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Add unit test for bug Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Run dotnet format Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Address Gemini comment Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../OpenFeatureServiceCollectionExtensions.cs | 3 ++- .../OpenFeatureServiceCollectionExtensionsTests.cs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs b/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs index 236dc62b0..260b01319 100644 --- a/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs +++ b/src/OpenFeature.Hosting/OpenFeatureServiceCollectionExtensions.cs @@ -30,6 +30,7 @@ public static IServiceCollection AddOpenFeature(this IServiceCollection services var builder = new OpenFeatureBuilder(services); configure(builder); + builder.Services.Configure(c => { }); // Ensures IOptions is available even when no providers are configured. builder.Services.AddHostedService(); // If a default provider is specified without additional providers, @@ -50,7 +51,7 @@ public static IServiceCollection AddOpenFeature(this IServiceCollection services options.DefaultNameSelector = provider => { var options = provider.GetRequiredService>().Value; - return options.ProviderNames.First(); + return options.ProviderNames.FirstOrDefault(); }; }); } diff --git a/test/OpenFeature.Hosting.Tests/OpenFeatureServiceCollectionExtensionsTests.cs b/test/OpenFeature.Hosting.Tests/OpenFeatureServiceCollectionExtensionsTests.cs index dc3cc9345..ba9419b4b 100644 --- a/test/OpenFeature.Hosting.Tests/OpenFeatureServiceCollectionExtensionsTests.cs +++ b/test/OpenFeature.Hosting.Tests/OpenFeatureServiceCollectionExtensionsTests.cs @@ -92,4 +92,16 @@ public void AddOpenFeature_WithNamedDefaultProvider_InvokesAddPolicyName() var otherClient = serviceProvider.GetService(); Assert.NotNull(otherClient); } + + [Fact] + public void AddOpenFeature_WithNoProvider_CanResolveFeatureClient() + { + // Act + _systemUnderTest.AddOpenFeature(builder => { }); + + // Assert + using var serviceProvider = _systemUnderTest.BuildServiceProvider(); + var client = serviceProvider.GetService(); + Assert.NotNull(client); + } } From 94fcdc142c61f41619af222778d6d84264f2831c Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:38:57 +0000 Subject: [PATCH 077/124] fix: Address issues when evaluating the context in the InMemoryProvider (#615) * Address issues when evaluating the context in the InMemoryProvider Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Address Gemini review comments Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Improve unit test coverage Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- src/OpenFeature/Providers/Memory/Flag.cs | 69 ++++++----- .../Providers/Memory/InMemoryProviderTests.cs | 108 +++++++++++++++++- 2 files changed, 144 insertions(+), 33 deletions(-) diff --git a/src/OpenFeature/Providers/Memory/Flag.cs b/src/OpenFeature/Providers/Memory/Flag.cs index f42b0af7b..532611477 100644 --- a/src/OpenFeature/Providers/Memory/Flag.cs +++ b/src/OpenFeature/Providers/Memory/Flag.cs @@ -36,41 +36,48 @@ public Flag(Dictionary variants, string defaultVariant, Func Evaluate(string flagKey, T _, EvaluationContext? evaluationContext) { - T? value; if (this._contextEvaluator == null) { - 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"); - } + return this.EvaluateDefaultVariant(flagKey); } - else + + string variant; + try + { + variant = this._contextEvaluator.Invoke(evaluationContext ?? EvaluationContext.Empty); + } + catch (Exception) { - 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 this.EvaluateDefaultVariant(flagKey, Reason.Default); } + + if (!this._variants.TryGetValue(variant, out var value)) + { + return this.EvaluateDefaultVariant(flagKey, Reason.Default); + } + + return new ResolutionDetails( + flagKey, + value, + variant: variant, + reason: Reason.TargetingMatch, + flagMetadata: this._flagMetadata + ); + } + + private ResolutionDetails EvaluateDefaultVariant(string flagKey, string reason = Reason.Static) + { + if (this._variants.TryGetValue(this._defaultVariant, out var value)) + { + return new ResolutionDetails( + flagKey, + value, + variant: this._defaultVariant, + reason: reason, + flagMetadata: this._flagMetadata + ); + } + + throw new GeneralException($"variant {this._defaultVariant} not found"); } } diff --git a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs index 6b04f2f3d..b60c1004e 100644 --- a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs @@ -98,6 +98,18 @@ public InMemoryProviderTests() return "missing"; } ) + }, + { + "evaluator-throws-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "on", + (context) => { + throw new Exception("Cannot evaluate flag at the moment."); + } + ) } }); @@ -113,6 +125,18 @@ public async Task GetBoolean_ShouldEvaluateWithReasonAndVariant() Assert.Equal("on", details.Variant); } + [Fact] + public async Task GetBoolean_WithNoEvaluationContext_ShouldEvaluateWithReasonAndVariant() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveBooleanValueAsync("boolean-flag", false); + + // Assert + Assert.True(details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("on", details.Variant); + } + [Fact] public async Task GetString_ShouldEvaluateWithReasonAndVariant() { @@ -122,6 +146,18 @@ public async Task GetString_ShouldEvaluateWithReasonAndVariant() Assert.Equal("greeting", details.Variant); } + [Fact] + public async Task GetString_WithNoEvaluationContext_ShouldEvaluateWithReasonAndVariant() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveStringValueAsync("string-flag", "nope"); + + // Assert + Assert.Equal("hi", details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("greeting", details.Variant); + } + [Fact] public async Task GetInt_ShouldEvaluateWithReasonAndVariant() { @@ -131,6 +167,18 @@ public async Task GetInt_ShouldEvaluateWithReasonAndVariant() Assert.Equal("ten", details.Variant); } + [Fact] + public async Task GetInt_WithNoEvaluationContext_ShouldEvaluateWithReasonAndVariant() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveIntegerValueAsync("integer-flag", 13); + + // Assert + Assert.Equal(10, details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("ten", details.Variant); + } + [Fact] public async Task GetDouble_ShouldEvaluateWithReasonAndVariant() { @@ -140,6 +188,18 @@ public async Task GetDouble_ShouldEvaluateWithReasonAndVariant() Assert.Equal("half", details.Variant); } + [Fact] + public async Task GetDouble_WithNoEvaluationContext_ShouldEvaluateWithReasonAndVariant() + { + // Arrange + ResolutionDetails details = await this.commonProvider.ResolveDoubleValueAsync("float-flag", 13); + + // Assert + Assert.Equal(0.5, details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("half", details.Variant); + } + [Fact] public async Task GetStruct_ShouldEvaluateWithReasonAndVariant() { @@ -151,6 +211,20 @@ public async Task GetStruct_ShouldEvaluateWithReasonAndVariant() Assert.Equal("template", details.Variant); } + [Fact] + public async Task GetStruct_WithNoEvaluationContext_ShouldEvaluateWithReasonAndVariant() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveStructureValueAsync("object-flag", new Value()); + + // Assert + 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() { @@ -161,6 +235,18 @@ public async Task GetString_ContextSensitive_ShouldEvaluateWithReasonAndVariant( Assert.Equal("internal", details.Variant); } + [Fact] + public async Task GetString_ContextSensitive_WithNoEvaluationContext_ShouldEvaluateWithReasonAndVariant() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveStringValueAsync("context-aware", "nope"); + + // Assert + Assert.Equal("EXTERNAL", details.Value); + Assert.Equal(Reason.Default, details.Reason); + Assert.Equal("external", details.Variant); + } + [Fact] public async Task EmptyFlags_ShouldWork() { @@ -198,9 +284,27 @@ public async Task MissingDefaultVariant_ShouldThrow() } [Fact] - public async Task MissingEvaluatedVariant_ShouldThrow() + public async Task MissingEvaluatedVariant_ReturnsDefaultVariant() { - await Assert.ThrowsAsync(() => this.commonProvider.ResolveBooleanValueAsync("invalid-evaluator-flag", false, EvaluationContext.Empty)); + // Act + var result = await this.commonProvider.ResolveBooleanValueAsync("invalid-evaluator-flag", false, EvaluationContext.Empty); + + // Assert + Assert.True(result.Value); + Assert.Equal(Reason.Default, result.Reason); + Assert.Equal("on", result.Variant); + } + + [Fact] + public async Task ContextEvaluatorThrows_ReturnsDefaultVariant() + { + // Act + var result = await this.commonProvider.ResolveBooleanValueAsync("evaluator-throws-flag", false, EvaluationContext.Empty); + + // Assert + Assert.True(result.Value); + Assert.Equal(Reason.Default, result.Reason); + Assert.Equal("on", result.Variant); } [Fact] From df1f40e7207430d828709c9a06bb4a9a13fd6fa0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 12:20:09 +0000 Subject: [PATCH 078/124] chore(deps): update dependency benchmarkdotnet to 0.15.5 (#623) 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 e4a0f3ed1..6d5a9ec99 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,7 +30,7 @@ - + From 6ffd08573ce72c6c4af4b3bdcc1b5b2d3ca3456a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 12:20:19 +0000 Subject: [PATCH 079/124] chore(deps): update github/codeql-action digest to 0499de3 (#622) 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 a27bbe0d4..e09e56b71 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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4 + uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # 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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4 + uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # 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@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4 + uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4 From dcb4c17ae52474931ad76de0ed49ac1bb6379960 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 12:59:20 +0000 Subject: [PATCH 080/124] chore(deps): update dependency reqnroll.xunit to v3 (#573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): update dependency reqnroll.xunit to v3 * fix: Add missing PackageReference for System.Threading.Channels version 9.0.6 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: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: André Silva <2493377+askpt@users.noreply.github.com> --- Directory.Packages.props | 2 +- test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6d5a9ec99..d2b0d3133 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,7 +40,7 @@ - + diff --git a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj index 0d5ed8cec..c27e693c5 100644 --- a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj +++ b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj @@ -23,6 +23,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + From 51c8521da1c627e422c44a27b268c4a58a0ff2c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Sun, 2 Nov 2025 21:53:51 +0000 Subject: [PATCH 081/124] docs: Update deprecation notice in README.md (#620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update deprecation notice in README.md Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- src/OpenFeature.DependencyInjection/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenFeature.DependencyInjection/README.md b/src/OpenFeature.DependencyInjection/README.md index 6b9fcfe72..ba9a1e898 100644 --- a/src/OpenFeature.DependencyInjection/README.md +++ b/src/OpenFeature.DependencyInjection/README.md @@ -1,6 +1,6 @@ # 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. +> **⚠️ DEPRECATED**: This library is now deprecated. The OpenFeature 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. From 7b72c198327dbcba594b16fdb2a389a07f962507 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Thu, 6 Nov 2025 18:44:24 +0000 Subject: [PATCH 082/124] test(e2e): Update e2e tests to support newer spec submodule (#604) * Initial update Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Fix ProviderNotReady tests Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Fix test on provider status Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Add remaining E2E tests where possible Some of the test cases have been skipped due to the SDK not having support for it yet. Some tests will fail due to a bug in the InMemoryProvider. Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Reduce duplicate code for fetching Enum from descriptions Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Reduce code duplication Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Add FlagDictionaryJsonConverter instead of mapping manually Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Remove README and test-flags.json Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Add tests to handle Object flag types Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Fix build by ensuring test-flags.json is linked from the submodule Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Fix issue when running e2e tests in non e2e ci Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Reduce code duplication by adopting StepArgumentTransformation Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Remove step definitions for deprecated gherkin file Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Align step definition files like it is in other languages and sdks Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Rename provider step definition file Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .gitignore | 2 + spec | 2 +- .../OpenFeature.E2ETests.csproj | 13 +- .../Steps/BaseStepDefinitions.cs | 270 ------------------ ...ContextMergingPrecedenceStepDefinitions.cs | 32 --- .../Steps/EvaluationContextStepDefinitions.cs | 144 ++++++++++ .../Steps/EvaluationStepDefinitions.cs | 103 +++---- .../Steps/ExcludedTagsStep.cs | 17 ++ .../Steps/FlagStepDefinitions.cs | 232 +++++++++++++++ .../Steps/HooksStepDefinitions.cs | 19 +- .../Steps/MetadataStepDefinitions.cs | 104 ++++--- .../Steps/ProviderStepDefinitions.cs | 101 +++++++ test/OpenFeature.E2ETests/Utils/BeforeHook.cs | 18 ++ .../Utils/ContextEvaluatorUtility.cs | 138 +++++++++ .../OpenFeature.E2ETests/Utils/EnumHelpers.cs | 20 ++ .../Utils/FlagDictionaryJsonConverter.cs | 187 ++++++++++++ .../Utils/JsonStructureLoader.cs | 72 +++++ 17 files changed, 1075 insertions(+), 399 deletions(-) delete mode 100644 test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs delete mode 100644 test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs create mode 100644 test/OpenFeature.E2ETests/Steps/EvaluationContextStepDefinitions.cs create mode 100644 test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs create mode 100644 test/OpenFeature.E2ETests/Steps/FlagStepDefinitions.cs create mode 100644 test/OpenFeature.E2ETests/Steps/ProviderStepDefinitions.cs create mode 100644 test/OpenFeature.E2ETests/Utils/BeforeHook.cs create mode 100644 test/OpenFeature.E2ETests/Utils/ContextEvaluatorUtility.cs create mode 100644 test/OpenFeature.E2ETests/Utils/EnumHelpers.cs create mode 100644 test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs create mode 100644 test/OpenFeature.E2ETests/Utils/JsonStructureLoader.cs diff --git a/.gitignore b/.gitignore index c77e4f530..5648ca1dc 100644 --- a/.gitignore +++ b/.gitignore @@ -351,5 +351,7 @@ ASALocalRun/ # integration tests test/OpenFeature.E2ETests/Features/*.feature test/OpenFeature.E2ETests/Features/*.feature.cs +test/OpenFeature.E2ETests/Features/README.md +test/OpenFeature.E2ETests/Features/test-flags.json cs-report.json specification.json diff --git a/spec b/spec index 969e11c4d..4542c3cdf 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 969e11c4d5df4ab16b400965ef1b3e313dcb923e +Subproject commit 4542c3cdfe8ae9947c7963cb91bfef1d21b643d4 diff --git a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj index c27e693c5..a6fabda89 100644 --- a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj +++ b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj @@ -1,4 +1,4 @@ - + net8.0;net9.0 @@ -18,6 +18,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -35,7 +36,15 @@ - + + + + + + PreserveNewest + + + diff --git a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs deleted file mode 100644 index 7a9c3c0a9..000000000 --- a/test/OpenFeature.E2ETests/Steps/BaseStepDefinitions.cs +++ /dev/null @@ -1,270 +0,0 @@ -using OpenFeature.E2ETests.Utils; -using OpenFeature.Model; -using OpenFeature.Providers.Memory; - -namespace OpenFeature.E2ETests.Steps; - -[Binding] -public class BaseStepDefinitions -{ - protected readonly State State; - - public BaseStepDefinitions(State state) - { - this.State = state; - } - - [Given(@"a stable provider")] - public async Task GivenAStableProvider() - { - var memProvider = new InMemoryProvider(E2EFlagConfig); - await Api.Instance.SetProviderAsync(memProvider).ConfigureAwait(false); - this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); - } - - [Given(@"a Boolean-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a boolean-flag with key ""(.*)"" and a default value ""(.*)""")] - public void GivenABoolean_FlagWithKeyAndADefaultValue(string key, string defaultType) - { - var flagState = new FlagState(key, defaultType, FlagType.Boolean); - this.State.Flag = flagState; - } - - [Given(@"a Float-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a float-flag with key ""(.*)"" and a default value ""(.*)""")] - public void GivenAFloat_FlagWithKeyAndADefaultValue(string key, string defaultType) - { - var flagState = new FlagState(key, defaultType, FlagType.Float); - this.State.Flag = flagState; - } - - [Given(@"a Integer-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a integer-flag with key ""(.*)"" and a default value ""(.*)""")] - public void GivenAnInteger_FlagWithKeyAndADefaultValue(string key, string defaultType) - { - var flagState = new FlagState(key, defaultType, FlagType.Integer); - this.State.Flag = flagState; - } - - [Given(@"a String-flag with key ""(.*)"" and a default value ""(.*)""")] - [Given(@"a string-flag with key ""(.*)"" and a default value ""(.*)""")] - public void GivenAString_FlagWithKeyAndADefaultValue(string key, string defaultType) - { - var flagState = new FlagState(key, defaultType, FlagType.String); - 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() - { - var flag = this.State.Flag!; - - switch (flag.Type) - { - case FlagType.Boolean: - this.State.FlagEvaluationDetailsResult = await this.State.Client! - .GetBooleanDetailsAsync(flag.Key, bool.Parse(flag.DefaultValue)).ConfigureAwait(false); - break; - case FlagType.Float: - this.State.FlagEvaluationDetailsResult = await this.State.Client! - .GetDoubleDetailsAsync(flag.Key, double.Parse(flag.DefaultValue)).ConfigureAwait(false); - break; - case FlagType.Integer: - this.State.FlagEvaluationDetailsResult = await this.State.Client! - .GetIntegerDetailsAsync(flag.Key, int.Parse(flag.DefaultValue)).ConfigureAwait(false); - break; - case FlagType.String: - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetStringDetailsAsync(flag.Key, flag.DefaultValue) - .ConfigureAwait(false); - 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 - { - { - "metadata-flag", new Flag( - variants: new Dictionary { { "on", true }, { "off", false } }, - defaultVariant: "on", - flagMetadata: new ImmutableMetadata(new Dictionary - { - { "string", "1.0.2" }, { "integer", 2 }, { "float", 0.1 }, { "boolean", true } - }) - ) - }, - { - "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" - ) - }, - { - "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" - ) - }, - { - "context-aware", new Flag( - variants: new Dictionary() { { "internal", "INTERNAL" }, { "external", "EXTERNAL" } }, - defaultVariant: "external", - (context) => - { - if (context.GetValue("fn").AsString == "Sulisław" - && context.GetValue("ln").AsString == "Świętopełk" - && context.GetValue("age").AsInteger == 29 - && context.GetValue("customer").AsBoolean == false) - { - return "internal"; - } - else return "external"; - } - ) - }, - { - "wrong-flag", new Flag( - variants: new Dictionary() { { "one", "uno" }, { "two", "dos" } }, - defaultVariant: "one" - ) - } - }; - - 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 deleted file mode 100644 index c95b20495..000000000 --- a/test/OpenFeature.E2ETests/Steps/ContextMergingPrecedenceStepDefinitions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using OpenFeature.E2ETests.Utils; - -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/Steps/EvaluationContextStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationContextStepDefinitions.cs new file mode 100644 index 000000000..198d47cdb --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/EvaluationContextStepDefinitions.cs @@ -0,0 +1,144 @@ +using OpenFeature.E2ETests.Utils; +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +public class EvaluationContextStepDefinitions +{ + private readonly State _state; + + public EvaluationContextStepDefinitions(State state) + { + this._state = state; + } + + [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 context containing a key ""(.*)"", with type ""(.*)"" and with value ""(.*)""")] + public void GivenAContextContainingAKeyWithTypeAndWithValue(string key, string type, string value) + { + var context = EvaluationContext.Builder() + .Merge(this._state.EvaluationContext ?? EvaluationContext.Empty); + + switch (type) + { + case "Integer": + context = context.Set(key, int.Parse(value)); + break; + case "Float": + context = context.Set(key, double.Parse(value)); + break; + case "String": + context = context.Set(key, value); + break; + case "Boolean": + context = context.Set(key, bool.Parse(value)); + break; + case "Object": + context = context.Set(key, new Value(value)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + + this._state.EvaluationContext = context.Build(); + } + + [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); + } + } + + [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(); + } + + [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); + } + + 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"); + } + } +} diff --git a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs index 27e00359b..7c843b453 100644 --- a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs @@ -7,79 +7,82 @@ namespace OpenFeature.E2ETests.Steps; [Binding] [Scope(Feature = "Flag evaluation")] -public class EvaluationStepDefinitions : BaseStepDefinitions +public class EvaluationStepDefinitions { - public EvaluationStepDefinitions(State state) : base(state) + private readonly State _state; + + public EvaluationStepDefinitions(State state) { + this._state = state; } [When(@"a boolean flag with key ""(.*)"" is evaluated with default value ""(.*)""")] public async Task Whenabooleanflagwithkeyisevaluatedwithdefaultvalue(string flagKey, bool defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Boolean); - this.State.FlagResult = await this.State.Client!.GetBooleanValueAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Boolean); + this._state.FlagResult = await this._state.Client!.GetBooleanValueAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved boolean value should be ""(.*)""")] public void Thentheresolvedbooleanvalueshouldbe(bool expectedValue) { - var result = this.State.FlagResult as bool?; + var result = this._state.FlagResult as bool?; Assert.Equal(expectedValue, result); } [When(@"a string flag with key ""(.*)"" is evaluated with default value ""(.*)""")] public async Task Whenastringflagwithkeyisevaluatedwithdefaultvalue(string flagKey, string defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue, FlagType.String); - this.State.FlagResult = await this.State.Client!.GetStringValueAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue, FlagType.String); + this._state.FlagResult = await this._state.Client!.GetStringValueAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved string value should be ""(.*)""")] public void Thentheresolvedstringvalueshouldbe(string expected) { - var result = this.State.FlagResult as string; + var result = this._state.FlagResult as string; Assert.Equal(expected, result); } [When(@"an integer flag with key ""(.*)"" is evaluated with default value (.*)")] public async Task Whenanintegerflagwithkeyisevaluatedwithdefaultvalue(string flagKey, int defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Integer); - this.State.FlagResult = await this.State.Client!.GetIntegerValueAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Integer); + this._state.FlagResult = await this._state.Client!.GetIntegerValueAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved integer value should be (.*)")] public void Thentheresolvedintegervalueshouldbe(int expected) { - var result = this.State.FlagResult as int?; + var result = this._state.FlagResult as int?; Assert.Equal(expected, result); } [When(@"a float flag with key ""(.*)"" is evaluated with default value (.*)")] public async Task Whenafloatflagwithkeyisevaluatedwithdefaultvalue(string flagKey, double defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Float); - this.State.FlagResult = await this.State.Client!.GetDoubleValueAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Float); + this._state.FlagResult = await this._state.Client!.GetDoubleValueAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved float value should be (.*)")] public void Thentheresolvedfloatvalueshouldbe(double expected) { - var result = this.State.FlagResult as double?; + var result = this._state.FlagResult as double?; Assert.Equal(expected, result); } [When(@"an object flag with key ""(.*)"" is evaluated with a null default value")] public async Task Whenanobjectflagwithkeyisevaluatedwithanulldefaultvalue(string flagKey) { - this.State.Flag = new FlagState(flagKey, null!, FlagType.Object); - this.State.FlagResult = await this.State.Client!.GetObjectValueAsync(flagKey, new Value()).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, null!, FlagType.Object); + this._state.FlagResult = await this._state.Client!.GetObjectValueAsync(flagKey, new Value()).ConfigureAwait(false); } [Then(@"the resolved object value should be contain fields ""(.*)"", ""(.*)"", and ""(.*)"", with values ""(.*)"", ""(.*)"" and (.*), respectively")] public void Thentheresolvedobjectvalueshouldbecontainfieldsandwithvaluesandrespectively(string boolField, string stringField, string numberField, bool boolValue, string stringValue, int numberValue) { - Value? value = this.State.FlagResult as Value; + Value? value = this._state.FlagResult as Value; Assert.Equal(boolValue, value?.AsStructure?[boolField].AsBoolean); Assert.Equal(stringValue, value?.AsStructure?[stringField].AsString); Assert.Equal(numberValue, value?.AsStructure?[numberField].AsInteger); @@ -88,14 +91,14 @@ public void Thentheresolvedobjectvalueshouldbecontainfieldsandwithvaluesandrespe [When(@"a boolean flag with key ""(.*)"" is evaluated with details and default value ""(.*)""")] public async Task Whenabooleanflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, bool defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Boolean); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetBooleanDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Boolean); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetBooleanDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved boolean details value should be ""(.*)"", the variant should be ""(.*)"", and the reason should be ""(.*)""")] public void Thentheresolvedbooleandetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(bool expectedValue, string expectedVariant, string expectedReason) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.Equal(expectedValue, result?.Value); Assert.Equal(expectedVariant, result?.Variant); Assert.Equal(expectedReason, result?.Reason); @@ -104,14 +107,14 @@ public void Thentheresolvedbooleandetailsvalueshouldbethevariantshouldbeandthere [When(@"a string flag with key ""(.*)"" is evaluated with details and default value ""(.*)""")] public async Task Whenastringflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, string defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue, FlagType.String); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue, FlagType.String); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved string details value should be ""(.*)"", the variant should be ""(.*)"", and the reason should be ""(.*)""")] public void Thentheresolvedstringdetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(string expectedValue, string expectedVariant, string expectedReason) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.Equal(expectedValue, result?.Value); Assert.Equal(expectedVariant, result?.Variant); Assert.Equal(expectedReason, result?.Reason); @@ -120,14 +123,14 @@ public void Thentheresolvedstringdetailsvalueshouldbethevariantshouldbeandtherea [When(@"an integer flag with key ""(.*)"" is evaluated with details and default value (.*)")] public async Task Whenanintegerflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, int defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Integer); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetIntegerDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Integer); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetIntegerDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved integer details value should be (.*), the variant should be ""(.*)"", and the reason should be ""(.*)""")] public void Thentheresolvedintegerdetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(int expectedValue, string expectedVariant, string expectedReason) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.Equal(expectedValue, result?.Value); Assert.Equal(expectedVariant, result?.Variant); Assert.Equal(expectedReason, result?.Reason); @@ -136,14 +139,14 @@ public void Thentheresolvedintegerdetailsvalueshouldbethevariantshouldbeandthere [When(@"a float flag with key ""(.*)"" is evaluated with details and default value (.*)")] public async Task Whenafloatflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, double defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Float); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetDoubleDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Float); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetDoubleDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the resolved float details value should be (.*), the variant should be ""(.*)"", and the reason should be ""(.*)""")] public void Thentheresolvedfloatdetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(double expectedValue, string expectedVariant, string expectedReason) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.Equal(expectedValue, result?.Value); Assert.Equal(expectedVariant, result?.Variant); Assert.Equal(expectedReason, result?.Reason); @@ -152,14 +155,14 @@ public void Thentheresolvedfloatdetailsvalueshouldbethevariantshouldbeandthereas [When(@"an object flag with key ""(.*)"" is evaluated with details and a null default value")] public async Task Whenanobjectflagwithkeyisevaluatedwithdetailsandanulldefaultvalue(string flagKey) { - this.State.Flag = new FlagState(flagKey, null!, FlagType.Object); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetObjectDetailsAsync(flagKey, new Value()).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, null!, FlagType.Object); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetObjectDetailsAsync(flagKey, new Value()).ConfigureAwait(false); } [Then(@"the resolved object details value should be contain fields ""(.*)"", ""(.*)"", and ""(.*)"", with values ""(.*)"", ""(.*)"" and (.*), respectively")] public void Thentheresolvedobjectdetailsvalueshouldbecontainfieldsandwithvaluesandrespectively(string boolField, string stringField, string numberField, bool boolValue, string stringValue, int numberValue) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; var value = result?.Value; Assert.NotNull(value); Assert.Equal(boolValue, value?.AsStructure?[boolField].AsBoolean); @@ -170,7 +173,7 @@ public void Thentheresolvedobjectdetailsvalueshouldbecontainfieldsandwithvaluesa [Then(@"the variant should be ""(.*)"", and the reason should be ""(.*)""")] public void Giventhevariantshouldbeandthereasonshouldbe(string expectedVariant, string expectedReason) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.NotNull(result); Assert.Equal(expectedVariant, result?.Variant); Assert.Equal(expectedReason, result?.Reason); @@ -179,7 +182,7 @@ public void Giventhevariantshouldbeandthereasonshouldbe(string expectedVariant, [When(@"context contains keys ""(.*)"", ""(.*)"", ""(.*)"", ""(.*)"" with values ""(.*)"", ""(.*)"", (.*), ""(.*)""")] public void Whencontextcontainskeyswithvalues(string field1, string field2, string field3, string field4, string value1, string value2, int value3, string value4) { - this.State.EvaluationContext = new EvaluationContextBuilder() + this._state.EvaluationContext = new EvaluationContextBuilder() .Set(field1, value1) .Set(field2, value2) .Set(field3, value3) @@ -189,46 +192,46 @@ public void Whencontextcontainskeyswithvalues(string field1, string field2, stri [When(@"a flag with key ""(.*)"" is evaluated with default value ""(.*)""")] public async Task Givenaflagwithkeyisevaluatedwithdefaultvalue(string flagKey, string defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue, FlagType.String); - this.State.FlagResult = await this.State.Client!.GetStringValueAsync(flagKey, defaultValue, this.State.EvaluationContext).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue, FlagType.String); + this._state.FlagResult = await this._state.Client!.GetStringValueAsync(flagKey, defaultValue, this._state.EvaluationContext).ConfigureAwait(false); } [Then(@"the resolved string response should be ""(.*)""")] public void Thentheresolvedstringresponseshouldbe(string expected) { - var result = this.State.FlagResult as string; + var result = this._state.FlagResult as string; Assert.Equal(expected, result); } [Then(@"the resolved flag value is ""(.*)"" when the context is empty")] public async Task Giventheresolvedflagvalueiswhenthecontextisempty(string expected) { - var key = this.State.Flag!.Key; - var defaultValue = this.State.Flag.DefaultValue; + var key = this._state.Flag!.Key; + var defaultValue = this._state.Flag.DefaultValue; - string? emptyContextValue = await this.State.Client!.GetStringValueAsync(key, defaultValue, EvaluationContext.Empty).ConfigureAwait(false); + string? emptyContextValue = await this._state.Client!.GetStringValueAsync(key, defaultValue, EvaluationContext.Empty).ConfigureAwait(false); Assert.Equal(expected, emptyContextValue); } [When(@"a non-existent string flag with key ""(.*)"" is evaluated with details and a default value ""(.*)""")] public async Task Whenanonexistentstringflagwithkeyisevaluatedwithdetailsandadefaultvalue(string flagKey, string defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue, FlagType.String); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue, FlagType.String); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the default string value should be returned")] public void Thenthedefaultstringvalueshouldbereturned() { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - var defaultValue = this.State.Flag!.DefaultValue; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var defaultValue = this._state.Flag!.DefaultValue; Assert.Equal(defaultValue, result?.Value); } [Then(@"the reason should indicate an error and the error code should indicate a missing flag with ""(.*)""")] public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateamissingflagwith(string errorCode) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.Equal(Reason.Error, result?.Reason); Assert.Equal(errorCode, result?.ErrorType.GetDescription()); } @@ -236,22 +239,22 @@ public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateamis [When(@"a string flag with key ""(.*)"" is evaluated as an integer, with details and a default value (.*)")] public async Task Whenastringflagwithkeyisevaluatedasanintegerwithdetailsandadefaultvalue(string flagKey, int defaultValue) { - this.State.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.String); - this.State.FlagEvaluationDetailsResult = await this.State.Client!.GetIntegerDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); + this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.String); + this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetIntegerDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); } [Then(@"the default integer value should be returned")] public void Thenthedefaultintegervalueshouldbereturned() { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; - var defaultValue = int.Parse(this.State.Flag!.DefaultValue); + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var defaultValue = int.Parse(this._state.Flag!.DefaultValue); Assert.Equal(defaultValue, result?.Value); } [Then(@"the reason should indicate an error and the error code should indicate a type mismatch with ""(.*)""")] public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateatypemismatchwith(string errorCode) { - var result = this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails; + var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; Assert.Equal(Reason.Error, result?.Reason); Assert.Equal(errorCode, result?.ErrorType.GetDescription()); } diff --git a/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs b/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs new file mode 100644 index 000000000..19b474db8 --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs @@ -0,0 +1,17 @@ +namespace OpenFeature.E2ETests.Steps; + +[Binding] +[Scope(Tag = "evaluation-options")] +[Scope(Tag = "immutability")] +[Scope(Tag = "async")] +[Scope(Tag = "reason-codes-cached")] +[Scope(Tag = "reason-codes-disabled")] +[Scope(Tag = "deprecated")] +public class ExcludedTagsStep +{ + [BeforeScenario] + public static void BeforeScenario() + { + Skip.If(true, "Tag is not supported"); + } +} diff --git a/test/OpenFeature.E2ETests/Steps/FlagStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/FlagStepDefinitions.cs new file mode 100644 index 000000000..599ae62e2 --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/FlagStepDefinitions.cs @@ -0,0 +1,232 @@ +using OpenFeature.Constant; +using OpenFeature.E2ETests.Utils; +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +public class FlagStepDefinitions +{ + private readonly State _state; + + public FlagStepDefinitions(State state) + { + this._state = state; + } + + [Given(@"a (Boolean|boolean|Float|Integer|String|string|Object)(?:-flag)? with key ""(.*)"" and a default value ""(.*)""")] + [Given(@"a (Boolean|boolean|Float|Integer|String|string|Object)(?:-flag)? with key ""(.*)"" and a fallback value ""(.*)""")] + public void GivenAFlagType_FlagWithKeyAndADefaultValue(FlagType flagType, string key, string defaultType) + { + var flagState = new FlagState(key, defaultType, flagType); + this._state.Flag = flagState; + } + + [StepArgumentTransformation(@"^(Boolean|boolean|Float|Integer|String|string|Object)(?:-flag)?$")] + public static FlagType TransformFlagType(string raw) + => raw.Replace("-flag", "").ToLowerInvariant() switch + { + "boolean" => FlagType.Boolean, + "float" => FlagType.Float, + "integer" => FlagType.Integer, + "string" => FlagType.String, + "object" => FlagType.Object, + _ => throw new Exception($"Unsupported flag type '{raw}'") + }; + + [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); + } + + [When(@"the flag was evaluated with details")] + public async Task WhenTheFlagWasEvaluatedWithDetails() + { + var flag = this._state.Flag!; + + switch (flag.Type) + { + case FlagType.Boolean: + this._state.FlagEvaluationDetailsResult = await this._state.Client! + .GetBooleanDetailsAsync(flag.Key, bool.Parse(flag.DefaultValue), this._state.EvaluationContext) + .ConfigureAwait(false); + break; + case FlagType.Float: + this._state.FlagEvaluationDetailsResult = await this._state.Client! + .GetDoubleDetailsAsync(flag.Key, double.Parse(flag.DefaultValue), this._state.EvaluationContext) + .ConfigureAwait(false); + break; + case FlagType.Integer: + this._state.FlagEvaluationDetailsResult = await this._state.Client! + .GetIntegerDetailsAsync(flag.Key, int.Parse(flag.DefaultValue), this._state.EvaluationContext) + .ConfigureAwait(false); + break; + case FlagType.String: + this._state.FlagEvaluationDetailsResult = await this._state.Client! + .GetStringDetailsAsync(flag.Key, flag.DefaultValue, this._state.EvaluationContext) + .ConfigureAwait(false); + break; + case FlagType.Object: + var defaultStructure = JsonStructureLoader.ParseJsonValue(flag.DefaultValue); + this._state.FlagEvaluationDetailsResult = await this._state.Client! + .GetObjectDetailsAsync(flag.Key, new Value(defaultStructure), this._state.EvaluationContext) + .ConfigureAwait(false); + break; + } + } + + [Then(@"the resolved details value should be ""(.*)""")] + public void ThenTheResolvedDetailsValueShouldBe(string value) + { + switch (this._state.Flag!.Type) + { + case FlagType.Integer: + var intValue = int.Parse(value); + AssertOnDetails(r => Assert.Equal(intValue, r.Value)); + break; + case FlagType.Float: + var floatValue = double.Parse(value); + AssertOnDetails(r => Assert.Equal(floatValue, r.Value)); + break; + case FlagType.String: + var stringValue = value; + AssertOnDetails(r => Assert.Equal(stringValue, r.Value)); + break; + case FlagType.Boolean: + var booleanValue = bool.Parse(value); + AssertOnDetails(r => Assert.Equal(booleanValue, r.Value)); + break; + case FlagType.Object: + var objectValue = JsonStructureLoader.ParseJsonValue(value); + AssertOnDetails(r => Assert.Equal(new Value(objectValue), r.Value)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + } + + [Then(@"the reason should be ""(.*)""")] + public void ThenTheReasonShouldBe(string reason) + { + switch (this._state.Flag!.Type) + { + case FlagType.Integer: + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); + break; + case FlagType.Float: + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); + break; + case FlagType.String: + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); + break; + case FlagType.Boolean: + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); + break; + case FlagType.Object: + AssertOnDetails(r => Assert.Equal(reason, r.Reason)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + } + + [Then(@"the error-code should be ""(.*)""")] + public void ThenTheError_CodeShouldBe(string error) + { + var errorType = EnumHelpers.ParseFromDescription(error); + switch (this._state.Flag!.Type) + { + case FlagType.Integer: + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); + break; + case FlagType.Float: + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); + break; + case FlagType.String: + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); + break; + case FlagType.Boolean: + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); + break; + case FlagType.Object: + AssertOnDetails(r => Assert.Equal(errorType, r.ErrorType)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + } + + [Then(@"the flag key should be ""(.*)""")] + public void ThenTheFlagKeyShouldBe(string key) + { + switch (this._state.Flag!.Type) + { + case FlagType.Integer: + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); + break; + case FlagType.Float: + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); + break; + case FlagType.String: + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); + break; + case FlagType.Boolean: + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); + break; + case FlagType.Object: + AssertOnDetails(r => Assert.Equal(key, r.FlagKey)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + } + + [Then(@"the variant should be ""(.*)""")] + public void ThenTheVariantShouldBe(string variant) + { + switch (this._state.Flag!.Type) + { + case FlagType.Integer: + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); + break; + case FlagType.Float: + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); + break; + case FlagType.String: + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); + break; + case FlagType.Boolean: + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); + break; + case FlagType.Object: + AssertOnDetails(r => Assert.Equal(variant, r.Variant)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; + } + } + + [Given(@"a context containing a key ""(.*)"" with null value")] + public void GivenAContextContainingAKeyWithNullValue(string key) + { + this._state.EvaluationContext = EvaluationContext.Builder() + .Merge(this._state.EvaluationContext ?? EvaluationContext.Empty) + .Set(key, (string?)null!) + .Build(); + } + + private void AssertOnDetails(Action> assertion) + { + var details = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; + + Assert.NotNull(details); + assertion(details); + } +} diff --git a/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs index a79a616aa..0d5e6264b 100644 --- a/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/HooksStepDefinitions.cs @@ -4,17 +4,20 @@ namespace OpenFeature.E2ETests.Steps; [Binding] [Scope(Feature = "Evaluation details through hooks")] -public class HooksStepDefinitions : BaseStepDefinitions +public class HooksStepDefinitions { - public HooksStepDefinitions(State state) : base(state) + private readonly State _state; + + public HooksStepDefinitions(State state) { + this._state = state; } [Given(@"a client with added hook")] public void GivenAClientWithAddedHook() { - this.State.TestHook = new TestHook(); - this.State.Client!.AddHooks(this.State.TestHook); + this._state.TestHook = new TestHook(); + this._state.Client!.AddHooks(this._state.TestHook); } [Then(@"the ""(.*)"" hook should have been executed")] @@ -116,16 +119,16 @@ private void CheckHookExecution(string hook) switch (hook) { case "before": - Assert.Equal(1, this.State.TestHook!.BeforeCount); + Assert.Equal(1, this._state.TestHook!.BeforeCount); break; case "after": - Assert.Equal(1, this.State.TestHook!.AfterCount); + Assert.Equal(1, this._state.TestHook!.AfterCount); break; case "error": - Assert.Equal(1, this.State.TestHook!.ErrorCount); + Assert.Equal(1, this._state.TestHook!.ErrorCount); break; case "finally": - Assert.Equal(1, this.State.TestHook!.FinallyCount); + Assert.Equal(1, this._state.TestHook!.FinallyCount); break; } } diff --git a/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs index 033e9bd6c..83b83f901 100644 --- a/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/MetadataStepDefinitions.cs @@ -4,67 +4,99 @@ namespace OpenFeature.E2ETests.Steps; [Binding] -[Scope(Feature = "Metadata")] -public class MetadataStepDefinitions : BaseStepDefinitions +public class MetadataStepDefinitions { - MetadataStepDefinitions(State state) : base(state) + private readonly State _state; + + public MetadataStepDefinitions(State _state) { + this._state = _state; } [Then("the resolved metadata should contain")] - [Scope(Scenario = "Returns metadata")] - public void ThenTheResolvedMetadataShouldContain(DataTable itemsTable) + public void ThenTheResolvedMetadataShouldContain(DataTable dataTable) { - var items = itemsTable.Rows.Select(row => new DataTableRows(row["key"], row["value"], row["metadata_type"])).ToList(); - var metadata = (this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata; - - foreach (var item in items) + switch (this._state.Flag!.Type) { - var key = item.Key; - var value = item.Value; - var metadataType = item.MetadataType; - - string? actual = null!; - switch (metadataType) - { - case FlagType.Boolean: - actual = metadata!.GetBool(key).ToString(); - break; - case FlagType.Integer: - actual = metadata!.GetInt(key).ToString(); - break; - case FlagType.Float: - actual = metadata!.GetDouble(key).ToString(); - break; - case FlagType.String: - actual = metadata!.GetString(key); - break; - } - - Assert.Equal(value.ToLowerInvariant(), actual?.ToLowerInvariant()); + case FlagType.Integer: + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); + break; + case FlagType.Float: + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); + break; + case FlagType.String: + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); + break; + case FlagType.Boolean: + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); + break; + case FlagType.Object: + AssertOnDetails(r => AssertMetadataContains(dataTable, r)); + break; + default: + Assert.Fail("FlagType not yet supported."); + break; } } [Then("the resolved metadata is empty")] public void ThenTheResolvedMetadataIsEmpty() { - var flag = this.State.Flag!; + var flag = this._state.Flag!; switch (flag.Type) { case FlagType.Boolean: - Assert.Null((this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata?.Count); + AssertOnDetails(d => Assert.Null(d.FlagMetadata?.Count)); break; case FlagType.Float: - Assert.Null((this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata?.Count); + AssertOnDetails(d => Assert.Null(d.FlagMetadata?.Count)); break; case FlagType.Integer: - Assert.Null((this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata?.Count); + AssertOnDetails(d => Assert.Null(d.FlagMetadata?.Count)); break; case FlagType.String: - Assert.Null((this.State.FlagEvaluationDetailsResult as FlagEvaluationDetails)?.FlagMetadata?.Count); + AssertOnDetails(d => Assert.Null(d.FlagMetadata?.Count)); break; default: throw new ArgumentOutOfRangeException(); } } + + private void AssertOnDetails(Action> assertion) + { + var details = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; + + Assert.NotNull(details); + assertion(details); + } + + private static void AssertMetadataContains(DataTable dataTable, FlagEvaluationDetails details) + { + foreach (var row in dataTable.Rows) + { + var key = row[0]; + var metadataType = row[1]; + var expected = row[2]; + + object expectedValue = metadataType switch + { + "String" => expected, + "Integer" => int.Parse(expected), + "Float" => double.Parse(expected), + "Boolean" => bool.Parse(expected), + _ => throw new ArgumentException("Unsupported metadata type"), + }; + object? actualValue = metadataType switch + { + "String" => details.FlagMetadata!.GetString(key), + "Integer" => details.FlagMetadata!.GetInt(key), + "Float" => details.FlagMetadata!.GetDouble(key), + "Boolean" => details.FlagMetadata!.GetBool(key), + _ => throw new ArgumentException("Unsupported metadata type") + }; + + Assert.NotNull(actualValue); + Assert.Equal(expectedValue, actualValue); + } + } } diff --git a/test/OpenFeature.E2ETests/Steps/ProviderStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinitions.cs new file mode 100644 index 000000000..d3f482556 --- /dev/null +++ b/test/OpenFeature.E2ETests/Steps/ProviderStepDefinitions.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using NSubstitute; +using OpenFeature.Constant; +using OpenFeature.E2ETests.Utils; +using OpenFeature.Model; +using OpenFeature.Providers.Memory; + +namespace OpenFeature.E2ETests.Steps; + +[Binding] +public class ProviderStepDefinitions +{ + private State State { get; } + + public ProviderStepDefinitions(State state) + { + this.State = state; + } + + [Given(@"a stable provider")] + public async Task GivenAStableProvider() + { + var options = new JsonSerializerOptions() + { + PropertyNameCaseInsensitive = true + }; + options.Converters.Add(new FlagDictionaryJsonConverter()); + + var json = File.ReadAllText(Path.Combine("Features", "test-flags.json")); + var flags = JsonSerializer.Deserialize>(json, options) + ?? new Dictionary(); + + var memProvider = new InMemoryProvider(flags); + await Api.Instance.SetProviderAsync(memProvider).ConfigureAwait(false); + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [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 error provider")] + public async Task GivenAErrorProvider() + { + var provider = Substitute.For(); + provider.GetMetadata().Returns(new Metadata("NSubstituteProvider")); + provider.Status.Returns(ProviderStatus.Error); + + await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Given("a stale provider")] + public async Task GivenAStaleProvider() + { + var provider = Substitute.For(); + provider.GetMetadata().Returns(new Metadata("NSubstituteProvider")); + provider.Status.Returns(ProviderStatus.Stale); + + await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Given("a not ready provider")] + public async Task GivenANotReadyProvider() + { + var provider = Substitute.For(); + provider.GetMetadata().Returns(new Metadata("NSubstituteProvider")); + provider.Status.Returns(ProviderStatus.Ready, ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Given("a fatal provider")] + public async Task GivenAFatalProvider() + { + var provider = Substitute.For(); + provider.GetMetadata().Returns(new Metadata("NSubstituteProvider")); + provider.Status.Returns(ProviderStatus.Fatal); + + await Api.Instance.SetProviderAsync(provider).ConfigureAwait(false); + this.State.Client = Api.Instance.GetClient("TestClient", "1.0.0"); + } + + [Then(@"the provider status should be ""(.*)""")] + public void ThenTheProviderStatusShouldBe(string status) + { + var expectedStatus = EnumHelpers.ParseFromDescription(status); + var provider = Api.Instance.GetProvider(); + Assert.Equal(expectedStatus, provider.Status); + } +} diff --git a/test/OpenFeature.E2ETests/Utils/BeforeHook.cs b/test/OpenFeature.E2ETests/Utils/BeforeHook.cs new file mode 100644 index 000000000..e6da0bb9c --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/BeforeHook.cs @@ -0,0 +1,18 @@ +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Utils; + +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/Utils/ContextEvaluatorUtility.cs b/test/OpenFeature.E2ETests/Utils/ContextEvaluatorUtility.cs new file mode 100644 index 000000000..fea0026b6 --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/ContextEvaluatorUtility.cs @@ -0,0 +1,138 @@ +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Utils; + +public class ContextEvaluatorUtility +{ + // Very small expression translator for patterns like: + // "email == 'ballmer@macrosoft.com' ? 'zero' : ''" + // "!customer && email == 'x' && age > 10 ? 'internal' : ''" + public static Func? BuildContextEvaluator(string expression) + { + // Split "condition ? 'trueVariant' : 'falseVariant'" + var qIndex = expression.IndexOf('?'); + var colonIndex = expression.LastIndexOf(':'); + if (qIndex < 0 || colonIndex < 0 || colonIndex < qIndex) + return null; // unsupported format, ignore + + var conditionPart = expression.Substring(0, qIndex).Trim(); + var truePart = ExtractQuoted(expression.Substring(qIndex + 1, colonIndex - qIndex - 1)); + var falsePart = ExtractQuoted(expression.Substring(colonIndex + 1)); + + var conditions = conditionPart.Split(new[] { "&&" }, StringSplitOptions.RemoveEmptyEntries); + + return ctx => + { + foreach (var raw in conditions) + { + if (!EvaluateSingle(raw.Trim(), ctx)) + return falsePart; + } + return truePart; + }; + } + + private static string ExtractQuoted(string segment) + { + segment = segment.Trim(); + if (segment.StartsWith("'") && segment.EndsWith("'") && segment.Length >= 2) + return segment.Substring(1, segment.Length - 2); + return segment; + } + + private static bool EvaluateSingle(string expr, EvaluationContext ctx) + { + // Supported fragments: + // !key + // key + // key == 'string' + // key != 'string' + // key > number, key < number, key >= number, key <= number + expr = expr.Trim(); + + bool negate = false; + if (expr.StartsWith("!")) + { + negate = true; + expr = expr.Substring(1).Trim(); + } + + bool result; + if (TryParseComparison(expr, ctx, out result)) + { + return negate ? !result : result; + } + + // Treat raw key presence / truthiness + if (!ctx.TryGetValue(expr, out var value) || value == null || value.IsNull) + result = false; + else if (value.IsBoolean) + result = value.AsBoolean == true; + else if (value.IsString) + result = !string.IsNullOrEmpty(value.AsString); + else if (value.IsNumber) + result = value.AsDouble.GetValueOrDefault() != 0.0; + else + result = true; + + return negate ? !result : result; + } + + // Supported operations + static readonly string[] _operations = ["==", "!=", ">=", "<=", ">", "<"]; + + private static bool TryParseComparison(string expr, EvaluationContext ctx, out bool result) + { + result = false; + + foreach (var op in _operations) + { + var idx = expr.IndexOf(op, StringComparison.Ordinal); + if (idx <= 0) continue; + + var left = expr.Substring(0, idx).Trim(); + var right = expr.Substring(idx + op.Length).Trim(); + + if (!ctx.TryGetValue(left, out var val) || val == null) + return true; // treat missing as false; caller will interpret + + if (right.StartsWith("'") && right.EndsWith("'")) + { + var literal = right.Substring(1, right.Length - 2); + var strVal = val.AsString; + result = op switch + { + "==" => strVal == literal, + "!=" => strVal != literal, + _ => false + }; + + return true; + } + + if (double.TryParse(right, out var numRight)) + { + var numLeft = val.AsDouble ?? val.AsInteger; + if (numLeft == null) + return true; + + result = op switch + { + ">" => numLeft > numRight, + "<" => numLeft < numRight, + ">=" => numLeft >= numRight, + "<=" => numLeft <= numRight, + "==" => Math.Abs(numLeft.Value - numRight) < double.Epsilon, + "!=" => Math.Abs(numLeft.Value - numRight) >= double.Epsilon, + _ => false + }; + + return true; + } + + return true; + } + + return false; + } +} diff --git a/test/OpenFeature.E2ETests/Utils/EnumHelpers.cs b/test/OpenFeature.E2ETests/Utils/EnumHelpers.cs new file mode 100644 index 000000000..a4e535682 --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/EnumHelpers.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; +using System.Reflection; + +namespace OpenFeature.E2ETests.Utils; + +public static class EnumHelpers +{ + public static TEnum ParseFromDescription(string description) where TEnum : struct, Enum + { + foreach (var field in typeof(TEnum).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + var attr = field.GetCustomAttribute(); + if (attr != null && attr.Description == description) + { + return (TEnum)field.GetValue(null)!; + } + } + throw new ArgumentException($"No {typeof(TEnum).Name} with description '{description}' found."); + } +} diff --git a/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs b/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs new file mode 100644 index 000000000..153de67da --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs @@ -0,0 +1,187 @@ +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenFeature.Model; +using OpenFeature.Providers.Memory; + +namespace OpenFeature.E2ETests.Utils; + +public sealed class FlagDictionaryJsonConverter : JsonConverter> +{ + public override Dictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException("Root of flags JSON must be an object"); + + var result = new Dictionary(StringComparer.Ordinal); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + break; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException("Expected property name (flag key)"); + + var flagKey = reader.GetString()!; + reader.Read(); + + var flagDoc = JsonDocument.ParseValue(ref reader); + var flagElement = flagDoc.RootElement; + result[flagKey] = ReadFlag(flagKey, flagElement); + } + + return result; + } + + private static Flag ReadFlag(string flagKey, JsonElement flagElement) + { + if (!flagElement.TryGetProperty("variants", out var variantsElement) || variantsElement.ValueKind != JsonValueKind.Object) + throw new JsonException($"Flag '{flagKey}' is missing 'variants' object"); + + // Infer variant type + VariantKind? inferredKind = null; + foreach (var v in variantsElement.EnumerateObject()) + { + var kind = ClassifyVariantValue(v.Value); + inferredKind = inferredKind == null ? kind : Promote(inferredKind.Value, kind); + } + + if (inferredKind == null) + throw new JsonException($"Flag '{flagKey}' has no variants"); + + var defaultVariant = InferDefaultVariant(flagElement, variantsElement); + + var contextEvaluator = flagElement.TryGetProperty("contextEvaluator", out var ctxElem) && ctxElem.ValueKind == JsonValueKind.String + ? ContextEvaluatorUtility.BuildContextEvaluator(ctxElem.GetString()!) + : null; + + var metadata = flagElement.TryGetProperty("flagMetadata", out var metaElem) && metaElem.ValueKind == JsonValueKind.Object + ? BuildMetadata(metaElem) + : null; + + // NOTE: The current Flag type does not model 'disabled' + + return inferredKind switch + { + VariantKind.Boolean => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetBoolean()), + VariantKind.Integer => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetInt32()), + VariantKind.Double => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetDouble()), + VariantKind.String => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetString()!), + VariantKind.Object => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, ExtractObjectVariant), + _ => throw new JsonException($"Unsupported variant kind for flag '{flagKey}'") + }; + } + + public override void Write(Utf8JsonWriter writer, Dictionary value, JsonSerializerOptions options) + => throw new NotSupportedException("Serialization is not implemented."); + + private static Flag BuildFlag( + JsonElement variantsElement, + string? defaultVariant, + Func? contextEvaluator, + ImmutableMetadata? metadata, + Func projector) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var v in variantsElement.EnumerateObject()) + { + dict[v.Name] = projector(v.Value); + } + return new Flag(dict, defaultVariant!, contextEvaluator, metadata); + } + + private static string? InferDefaultVariant(JsonElement flagElement, JsonElement variantsElement) + { + if (flagElement.TryGetProperty("defaultVariant", out var dv)) + { + if (dv.ValueKind == JsonValueKind.String) + return dv.GetString()!; + } + + return null; + } + + private static ImmutableMetadata? BuildMetadata(JsonElement metaElem) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var p in metaElem.EnumerateObject()) + { + switch (p.Value.ValueKind) + { + case JsonValueKind.String: dict[p.Name] = p.Value.GetString()!; break; + case JsonValueKind.Number: + if (p.Value.TryGetInt64(out var l) && l >= int.MinValue && l <= int.MaxValue) + dict[p.Name] = (int)l; + else + dict[p.Name] = p.Value.GetDouble(); + break; + case JsonValueKind.True: + case JsonValueKind.False: + dict[p.Name] = p.Value.GetBoolean(); + break; + default: + // Ignore null or complex types + break; + } + } + return dict.Count == 0 ? null : new ImmutableMetadata(dict); + } + + private static Value ExtractObjectVariant(JsonElement obj) + { + if (obj.ValueKind != JsonValueKind.Object) + throw new JsonException("Expected object for variant"); + + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var p in obj.EnumerateObject()) + { + dict[p.Name] = ConvertElement(p.Value); + } + + var structure = dict.Count == 0 ? Structure.Empty : new Structure(dict); + return new Value(structure); + } + + private static Value ConvertElement(JsonElement el) => + el.ValueKind switch + { + JsonValueKind.Object => ExtractObjectVariant(el), // delegates to structure builder + JsonValueKind.Array => new Value(el.EnumerateArray().Select(ConvertElement).ToImmutableList()), + JsonValueKind.String => new Value(el.GetString()!), + JsonValueKind.Number => el.TryGetInt64(out var l) && l is >= int.MinValue and <= int.MaxValue + ? new Value((int)l) + : new Value(el.GetDouble()), + JsonValueKind.True => new Value(true), + JsonValueKind.False => new Value(false), + JsonValueKind.Null => new Value(), + JsonValueKind.Undefined => new Value(), + _ => throw new JsonException($"Unsupported JSON token: {el.ValueKind}") + }; + + private enum VariantKind { Boolean, Integer, Double, String, Object } + + private static VariantKind ClassifyVariantValue(JsonElement e) => + e.ValueKind switch + { + JsonValueKind.True or JsonValueKind.False => VariantKind.Boolean, + JsonValueKind.String => VariantKind.String, + JsonValueKind.Object => VariantKind.Object, + JsonValueKind.Number => e.TryGetInt64(out _) ? VariantKind.Integer : VariantKind.Double, + _ => throw new JsonException($"Unsupported variant value kind '{e.ValueKind}'") + }; + + // Promote mixed numeric (int + double) to double + private static VariantKind Promote(VariantKind existing, VariantKind incoming) + { + static bool IsNumeric(VariantKind k) => k == VariantKind.Integer || k == VariantKind.Double; + + if (existing == incoming) + return existing; + + if (IsNumeric(existing) && IsNumeric(incoming)) + return VariantKind.Double; + + throw new JsonException($"Mixed incompatible variant kinds: {existing} and {incoming}"); + } +} diff --git a/test/OpenFeature.E2ETests/Utils/JsonStructureLoader.cs b/test/OpenFeature.E2ETests/Utils/JsonStructureLoader.cs new file mode 100644 index 000000000..e03149bae --- /dev/null +++ b/test/OpenFeature.E2ETests/Utils/JsonStructureLoader.cs @@ -0,0 +1,72 @@ +using System.Collections.Immutable; +using System.Text.Json; +using OpenFeature.Model; + +namespace OpenFeature.E2ETests.Utils; + +public static class JsonStructureLoader +{ + public static Value ParseJsonValue(string raw) + { + var json = UnescapeGherkinJson(raw); + if (json == "{}") + return new Value(Structure.Empty); + + using var doc = JsonDocument.Parse(json); + return ConvertElement(doc.RootElement); + } + + private static string UnescapeGherkinJson(string s) + { + if (string.IsNullOrWhiteSpace(s)) + return s; + + // Replace escaped quotes, if still present. + if (s.Contains("\\\"")) + s = s.Replace("\\\"", "\""); + + // Trim wrapping quotes "\"{...}\"" if present. + if (s.Length > 2 && s[0] == '"' && s[s.Length - 1] == '"' && s[1] == '{' && s[s.Length - 2] == '}') + { + var inner = s.Substring(1, s.Length - 2); + if (inner.StartsWith("{") && inner.EndsWith("}")) + s = inner; + } + + return s.Trim(); + } + + private static Structure ConvertObject(JsonElement element) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var prop in element.EnumerateObject()) + { + dict[prop.Name] = ConvertElement(prop.Value); + } + return new Structure(dict); + } + + private static Value ConvertElement(JsonElement el) => + el.ValueKind switch + { + JsonValueKind.Object => new Value(ConvertObject(el)), + JsonValueKind.Array => new Value(el.EnumerateArray().Select(ConvertElement).ToImmutableList()), + JsonValueKind.String => new Value(el.GetString()!), + JsonValueKind.Number => ConvertNumber(el), + JsonValueKind.True => new Value(true), + JsonValueKind.False => new Value(false), + JsonValueKind.Null => new Value(), // null inner value + JsonValueKind.Undefined => new Value(), + _ => throw new ArgumentOutOfRangeException(nameof(el), $"Unsupported JSON token: {el.ValueKind}") + }; + + private static Value ConvertNumber(JsonElement el) + { + // Prefer int when representable; Value(int) internally stores as double. + if (el.TryGetInt64(out var l) && l is >= int.MinValue and <= int.MaxValue) + { + return new Value((int)l); + } + return new Value(el.GetDouble()); + } +} From 96fc0e02c803be13d935f05a3249c9fa5e97add5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:51:40 +0000 Subject: [PATCH 083/124] chore(deps): update spec digest to 9c1fde2 (#602) 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 4542c3cdf..9c1fde2cf 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 4542c3cdfe8ae9947c7963cb91bfef1d21b643d4 +Subproject commit 9c1fde2cf7160b06d879bc1da71226b058edd751 From 2576022ff517a7a4199ad265ff094f600e7a6bf3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:53:31 +0000 Subject: [PATCH 084/124] chore(deps): update dependency benchmarkdotnet to 0.15.6 (#627) 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 d2b0d3133..8cd988771 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,7 +30,7 @@ - + From 186b3574702258fb33716162094888b9f7560c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 7 Nov 2025 17:06:43 +0000 Subject: [PATCH 085/124] feat: Add tracking to multi-provider (#612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add tracking event handling to MultiProvider and evaluation strategy Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Add validation for tracking event name in MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Update ShouldTrackWithThisProvider to support generic provider types Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Enhance TestProvider with tracking invocation methods and event simulation Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Add comprehensive tracking tests for MultiProvider functionality Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: Clarify tracking context handling and rename test for invalid tracking event name Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: Change TrackingInvocation from record to class for enhanced flexibility Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: Change log level to Error for tracking event errors in MultiProvider Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .../MultiProvider.cs | 43 +++ .../Strategies/BaseEvaluationStrategy.cs | 14 + .../MultiProviderTrackingTests.cs | 299 ++++++++++++++++++ .../Utils/TestProvider.cs | 42 +++ 4 files changed, 398 insertions(+) create mode 100644 test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTrackingTests.cs diff --git a/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs index 9737198f0..e05ce9128 100644 --- a/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs +++ b/src/OpenFeature.Providers.MultiProvider/MultiProvider.cs @@ -113,6 +113,43 @@ 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 void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + if (this._disposed == 1) + { + throw new ObjectDisposedException(nameof(MultiProvider)); + } + + if (string.IsNullOrWhiteSpace(trackingEventName)) + { + this.LogErrorTrackingEventEmptyName(); + return; + } + + foreach (var registeredProvider in this._registeredProviders) + { + var providerContext = new StrategyPerProviderContext( + registeredProvider.Provider, + registeredProvider.Name, + registeredProvider.Status, + string.Empty); // Tracking operations are not flag-specific, so the flag key is intentionally set to an empty string + + if (this._evaluationStrategy.ShouldTrackWithThisProvider(providerContext, evaluationContext, trackingEventName, trackingEventDetails)) + { + try + { + registeredProvider.Provider.Track(trackingEventName, evaluationContext, trackingEventDetails); + } + catch (Exception ex) + { + // Log tracking errors but don't throw - tracking should not disrupt application flow + this.LogErrorTrackingEvent(registeredProvider.Name, trackingEventName, ex); + } + } + } + } + /// public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) { @@ -638,4 +675,10 @@ internal void SetStatus(ProviderStatus providerStatus) [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = "Provider {ProviderName} is already being listened to")] private partial void LogProviderAlreadyBeingListenedTo(string providerName); + + [LoggerMessage(EventId = 2, Level = LogLevel.Error, Message = "Error tracking event {TrackingEventName} with provider {ProviderName}")] + private partial void LogErrorTrackingEvent(string providerName, string trackingEventName, Exception exception); + + [LoggerMessage(EventId = 3, Level = LogLevel.Error, Message = "Tracking event with empty name is not allowed")] + private partial void LogErrorTrackingEventEmptyName(); } diff --git a/src/OpenFeature.Providers.MultiProvider/Strategies/BaseEvaluationStrategy.cs b/src/OpenFeature.Providers.MultiProvider/Strategies/BaseEvaluationStrategy.cs index f31b2c4ab..2b1646036 100644 --- a/src/OpenFeature.Providers.MultiProvider/Strategies/BaseEvaluationStrategy.cs +++ b/src/OpenFeature.Providers.MultiProvider/Strategies/BaseEvaluationStrategy.cs @@ -58,6 +58,20 @@ public virtual bool ShouldEvaluateNextProvider(StrategyPerProviderContext /// The final evaluation result. public abstract FinalResult DetermineFinalResult(StrategyEvaluationContext strategyContext, string key, T defaultValue, EvaluationContext? evaluationContext, List> resolutions); + /// + /// Determines whether a specific provider should receive tracking events. + /// + /// Context information about the provider. + /// The evaluation context for the tracking event. + /// The name of the tracking event. + /// The tracking event details. + /// True if the provider should receive tracking events, false otherwise. + public virtual bool ShouldTrackWithThisProvider(StrategyPerProviderContext strategyContext, EvaluationContext? evaluationContext, string trackingEventName, TrackingEventDetails? trackingEventDetails) + { + // By default, track with providers that are ready + return strategyContext.ProviderStatus == ProviderStatus.Ready; + } + /// /// Checks if a resolution result represents an error. /// diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTrackingTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTrackingTests.cs new file mode 100644 index 000000000..d0d9d3d54 --- /dev/null +++ b/test/OpenFeature.Providers.MultiProvider.Tests/MultiProviderTrackingTests.cs @@ -0,0 +1,299 @@ +using NSubstitute; +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; + +public class MultiProviderTrackingTests +{ + private const string TestTrackingEventName = "test-event"; + private const string Provider1Name = "provider1"; + private const string Provider2Name = "provider2"; + private const string Provider3Name = "provider3"; + + private readonly TestProvider _testProvider1 = new(Provider1Name); + private readonly TestProvider _testProvider2 = new(Provider2Name); + private readonly TestProvider _testProvider3 = new(Provider3Name); + private readonly EvaluationContext _evaluationContext = EvaluationContext.Builder().Build(); + + [Fact] + public async Task Track_WithMultipleReadyProviders_CallsTrackOnAllReadyProviders() + { + // Arrange + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(this._testProvider2, Provider2Name), + new(this._testProvider3, Provider3Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + + var trackingDetails = TrackingEventDetails.Builder().SetValue(99.99).Build(); + + // Act + multiProvider.Track(TestTrackingEventName, this._evaluationContext, trackingDetails); + + // Assert + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider2Invocations = this._testProvider2.GetTrackingInvocations(); + var provider3Invocations = this._testProvider3.GetTrackingInvocations(); + + Assert.Single(provider1Invocations); + Assert.Single(provider2Invocations); + Assert.Single(provider3Invocations); + + Assert.Equal(TestTrackingEventName, provider1Invocations[0].EventName); + Assert.Equal(TestTrackingEventName, provider2Invocations[0].EventName); + Assert.Equal(TestTrackingEventName, provider3Invocations[0].EventName); + + Assert.Equal(trackingDetails.Value, provider1Invocations[0].TrackingEventDetails?.Value); + Assert.Equal(trackingDetails.Value, provider2Invocations[0].TrackingEventDetails?.Value); + Assert.Equal(trackingDetails.Value, provider3Invocations[0].TrackingEventDetails?.Value); + } + + [Fact] + public async Task Track_WithNullEvaluationContext_CallsTrackWithNullContext() + { + // Arrange + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(this._testProvider2, Provider2Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + + // Act + multiProvider.Track(TestTrackingEventName); + + // Assert + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider2Invocations = this._testProvider2.GetTrackingInvocations(); + + Assert.Single(provider1Invocations); + Assert.Single(provider2Invocations); + + Assert.Equal(TestTrackingEventName, provider1Invocations[0].EventName); + Assert.Equal(TestTrackingEventName, provider2Invocations[0].EventName); + } + + [Fact] + public async Task Track_WithNullTrackingDetails_CallsTrackWithNullDetails() + { + // Arrange + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(this._testProvider2, Provider2Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + + // Act + multiProvider.Track(TestTrackingEventName, this._evaluationContext); + + // Assert + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider2Invocations = this._testProvider2.GetTrackingInvocations(); + + Assert.Single(provider1Invocations); + Assert.Single(provider2Invocations); + + Assert.Equal(TestTrackingEventName, provider1Invocations[0].EventName); + Assert.Null(provider1Invocations[0].TrackingEventDetails); + + Assert.Equal(TestTrackingEventName, provider2Invocations[0].EventName); + Assert.Null(provider2Invocations[0].TrackingEventDetails); + } + + [Fact] + public async Task Track_WhenProviderThrowsException_ContinuesWithOtherProviders() + { + // Arrange + var throwingProvider = Substitute.For(); + throwingProvider.GetMetadata().Returns(new Metadata(Provider2Name)); + throwingProvider.When(x => x.Track(Arg.Any(), Arg.Any(), Arg.Any())) + .Do(_ => throw new InvalidOperationException("Test exception")); + + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(throwingProvider, Provider2Name), + new(this._testProvider3, Provider3Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + + // Manually set all providers to Ready status + throwingProvider.Status.Returns(ProviderStatus.Ready); + + var trackingDetails = TrackingEventDetails.Builder().SetValue(99.99).Build(); + + // Act + multiProvider.Track(TestTrackingEventName, this._evaluationContext, trackingDetails); + + // Assert - should not throw and should continue with other providers + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider3Invocations = this._testProvider3.GetTrackingInvocations(); + + Assert.Single(provider1Invocations); + Assert.Single(provider3Invocations); + + throwingProvider.Received(1).Track(TestTrackingEventName, Arg.Any(), trackingDetails); + } + + [Fact] + public async Task Track_WhenDisposed_ThrowsObjectDisposedException() + { + // Arrange + var providerEntries = new List + { + new(this._testProvider1, Provider1Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + await multiProvider.DisposeAsync(); + + // Act & Assert + Assert.Throws(() => multiProvider.Track(TestTrackingEventName, this._evaluationContext)); + } + + [Fact] + public async Task Track_WithCustomStrategy_RespectsStrategyDecision() + { + // Arrange + var customStrategy = Substitute.For(); + customStrategy.RunMode.Returns(RunMode.Sequential); + + // Only allow tracking with the first provider + customStrategy.ShouldTrackWithThisProvider( + Arg.Is>(ctx => ctx.ProviderName == Provider1Name), + Arg.Any(), + Arg.Any(), + Arg.Any() + ).Returns(true); + + customStrategy.ShouldTrackWithThisProvider( + Arg.Is>(ctx => ctx.ProviderName != Provider1Name), + Arg.Any(), + Arg.Any(), + Arg.Any() + ).Returns(false); + + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(this._testProvider2, Provider2Name), + new(this._testProvider3, Provider3Name) + }; + + var multiProvider = new MultiProvider(providerEntries, customStrategy); + await multiProvider.InitializeAsync(this._evaluationContext); + + var trackingDetails = TrackingEventDetails.Builder().SetValue(99.99).Build(); + + // Act + multiProvider.Track(TestTrackingEventName, this._evaluationContext, trackingDetails); + + // Assert - only provider1 should receive the tracking call + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider2Invocations = this._testProvider2.GetTrackingInvocations(); + var provider3Invocations = this._testProvider3.GetTrackingInvocations(); + + Assert.Single(provider1Invocations); + Assert.Empty(provider2Invocations); + Assert.Empty(provider3Invocations); + + customStrategy.Received(3).ShouldTrackWithThisProvider( + Arg.Any>(), + Arg.Any(), + TestTrackingEventName, + trackingDetails + ); + } + + [Fact] + public async Task Track_WithComplexTrackingDetails_PropagatesAllDetails() + { + // Arrange + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(this._testProvider2, Provider2Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + + var trackingDetails = TrackingEventDetails.Builder() + .SetValue(199.99) + .Set("currency", new Value("USD")) + .Set("productId", new Value("prod-123")) + .Set("quantity", new Value(5)) + .Build(); + + // Act + multiProvider.Track(TestTrackingEventName, this._evaluationContext, trackingDetails); + + // Assert + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider2Invocations = this._testProvider2.GetTrackingInvocations(); + + Assert.Single(provider1Invocations); + Assert.Single(provider2Invocations); + + var details1 = provider1Invocations[0].TrackingEventDetails; + var details2 = provider2Invocations[0].TrackingEventDetails; + + Assert.NotNull(details1); + Assert.NotNull(details2); + + Assert.Equal(199.99, details1.Value); + Assert.Equal(199.99, details2.Value); + + Assert.Equal("USD", details1.GetValue("currency").AsString); + Assert.Equal("USD", details2.GetValue("currency").AsString); + + Assert.Equal("prod-123", details1.GetValue("productId").AsString); + Assert.Equal("prod-123", details2.GetValue("productId").AsString); + + Assert.Equal(5, details1.GetValue("quantity").AsInteger); + Assert.Equal(5, details2.GetValue("quantity").AsInteger); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task Track_WhenInvalidTrackingEventName_DoesNotCallProviders(string? trackingEventName) + { + // Arrange + var providerEntries = new List + { + new(this._testProvider1, Provider1Name), + new(this._testProvider2, Provider2Name) + }; + + var multiProvider = new MultiProvider(providerEntries, new FirstMatchStrategy()); + await multiProvider.InitializeAsync(this._evaluationContext); + + // Act & Assert + multiProvider.Track(trackingEventName!, this._evaluationContext, TrackingEventDetails.Empty); + + var provider1Invocations = this._testProvider1.GetTrackingInvocations(); + var provider2Invocations = this._testProvider2.GetTrackingInvocations(); + + Assert.Empty(provider1Invocations); + Assert.Empty(provider2Invocations); + } +} diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/Utils/TestProvider.cs b/test/OpenFeature.Providers.MultiProvider.Tests/Utils/TestProvider.cs index 883cd6582..0bfd7bb01 100644 --- a/test/OpenFeature.Providers.MultiProvider.Tests/Utils/TestProvider.cs +++ b/test/OpenFeature.Providers.MultiProvider.Tests/Utils/TestProvider.cs @@ -1,7 +1,25 @@ +using OpenFeature.Constant; using OpenFeature.Model; namespace OpenFeature.Providers.MultiProvider.Tests.Utils; +/// +/// Represents a tracking invocation for testing purposes. +/// +public class TrackingInvocation +{ + public string EventName { get; } + public EvaluationContext? EvaluationContext { get; } + public TrackingEventDetails? TrackingEventDetails { get; } + + public TrackingInvocation(string eventName, EvaluationContext? evaluationContext, TrackingEventDetails? trackingEventDetails) + { + this.EventName = eventName; + this.EvaluationContext = evaluationContext; + this.TrackingEventDetails = trackingEventDetails; + } +} + /// /// A test implementation of FeatureProvider for MultiProvider testing. /// @@ -10,6 +28,7 @@ public class TestProvider : FeatureProvider private readonly string _name; private readonly Exception? _initException; private readonly Exception? _shutdownException; + private readonly List _trackingInvocations = new(); public TestProvider(string name, Exception? initException = null, Exception? shutdownException = null) { @@ -18,6 +37,10 @@ public TestProvider(string name, Exception? initException = null, Exception? shu this._shutdownException = shutdownException; } + public IReadOnlyList GetTrackingInvocations() => this._trackingInvocations.AsReadOnly(); + + public void ResetTrackingInvocations() => this._trackingInvocations.Clear(); + public override Metadata GetMetadata() => new(this._name); public override async Task InitializeAsync(EvaluationContext context, CancellationToken cancellationToken = default) @@ -59,4 +82,23 @@ public override Task> ResolveDoubleValueAsync(string f public override Task> ResolveStructureValueAsync(string flagKey, Value defaultValue, EvaluationContext? context = null, CancellationToken cancellationToken = default) => Task.FromResult(new ResolutionDetails(flagKey, defaultValue)); + + public override void Track(string trackingEventName, EvaluationContext? evaluationContext = default, TrackingEventDetails? trackingEventDetails = default) + { + this._trackingInvocations.Add(new TrackingInvocation(trackingEventName, evaluationContext, trackingEventDetails)); + } + + /// + /// Sends a provider event to simulate status changes. + /// + public async Task SendProviderEventAsync(ProviderEventTypes eventType, ErrorType? errorType = null, CancellationToken cancellationToken = default) + { + var payload = new ProviderEventPayload + { + Type = eventType, + ProviderName = this._name, + ErrorType = errorType + }; + await this.EventChannel.Writer.WriteAsync(payload, cancellationToken); + } } From ddd8f22c75767444b965f90047d27b33f34a5894 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 8 Nov 2025 07:18:57 +0000 Subject: [PATCH 086/124] chore(deps): update dependency reqnroll.xunit to 3.2.1 (#628) 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 8cd988771..06ef4950e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,7 +40,7 @@ - + From 7805184b40d483d7a55a084b153de13593c1e76b Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:31:38 +0000 Subject: [PATCH 087/124] test(e2e): Remove deprecated e2e evaluation steps definitions (#631) Remove deprecated e2e evaluation steps definitions Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .../Steps/EvaluationStepDefinitions.cs | 261 ------------------ 1 file changed, 261 deletions(-) delete mode 100644 test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs diff --git a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs deleted file mode 100644 index 7c843b453..000000000 --- a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs +++ /dev/null @@ -1,261 +0,0 @@ -using OpenFeature.Constant; -using OpenFeature.E2ETests.Utils; -using OpenFeature.Extension; -using OpenFeature.Model; - -namespace OpenFeature.E2ETests.Steps; - -[Binding] -[Scope(Feature = "Flag evaluation")] -public class EvaluationStepDefinitions -{ - private readonly State _state; - - public EvaluationStepDefinitions(State state) - { - this._state = state; - } - - [When(@"a boolean flag with key ""(.*)"" is evaluated with default value ""(.*)""")] - public async Task Whenabooleanflagwithkeyisevaluatedwithdefaultvalue(string flagKey, bool defaultValue) - { - this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Boolean); - this._state.FlagResult = await this._state.Client!.GetBooleanValueAsync(flagKey, defaultValue).ConfigureAwait(false); - } - - [Then(@"the resolved boolean value should be ""(.*)""")] - public void Thentheresolvedbooleanvalueshouldbe(bool expectedValue) - { - var result = this._state.FlagResult as bool?; - Assert.Equal(expectedValue, result); - } - - [When(@"a string flag with key ""(.*)"" is evaluated with default value ""(.*)""")] - public async Task Whenastringflagwithkeyisevaluatedwithdefaultvalue(string flagKey, string defaultValue) - { - this._state.Flag = new FlagState(flagKey, defaultValue, FlagType.String); - this._state.FlagResult = await this._state.Client!.GetStringValueAsync(flagKey, defaultValue).ConfigureAwait(false); - } - - [Then(@"the resolved string value should be ""(.*)""")] - public void Thentheresolvedstringvalueshouldbe(string expected) - { - var result = this._state.FlagResult as string; - Assert.Equal(expected, result); - } - - [When(@"an integer flag with key ""(.*)"" is evaluated with default value (.*)")] - public async Task Whenanintegerflagwithkeyisevaluatedwithdefaultvalue(string flagKey, int defaultValue) - { - this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Integer); - this._state.FlagResult = await this._state.Client!.GetIntegerValueAsync(flagKey, defaultValue).ConfigureAwait(false); - } - - [Then(@"the resolved integer value should be (.*)")] - public void Thentheresolvedintegervalueshouldbe(int expected) - { - var result = this._state.FlagResult as int?; - Assert.Equal(expected, result); - } - - [When(@"a float flag with key ""(.*)"" is evaluated with default value (.*)")] - public async Task Whenafloatflagwithkeyisevaluatedwithdefaultvalue(string flagKey, double defaultValue) - { - this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Float); - this._state.FlagResult = await this._state.Client!.GetDoubleValueAsync(flagKey, defaultValue).ConfigureAwait(false); - } - - [Then(@"the resolved float value should be (.*)")] - public void Thentheresolvedfloatvalueshouldbe(double expected) - { - var result = this._state.FlagResult as double?; - Assert.Equal(expected, result); - } - - [When(@"an object flag with key ""(.*)"" is evaluated with a null default value")] - public async Task Whenanobjectflagwithkeyisevaluatedwithanulldefaultvalue(string flagKey) - { - this._state.Flag = new FlagState(flagKey, null!, FlagType.Object); - this._state.FlagResult = await this._state.Client!.GetObjectValueAsync(flagKey, new Value()).ConfigureAwait(false); - } - - [Then(@"the resolved object value should be contain fields ""(.*)"", ""(.*)"", and ""(.*)"", with values ""(.*)"", ""(.*)"" and (.*), respectively")] - public void Thentheresolvedobjectvalueshouldbecontainfieldsandwithvaluesandrespectively(string boolField, string stringField, string numberField, bool boolValue, string stringValue, int numberValue) - { - Value? value = this._state.FlagResult as Value; - Assert.Equal(boolValue, value?.AsStructure?[boolField].AsBoolean); - Assert.Equal(stringValue, value?.AsStructure?[stringField].AsString); - Assert.Equal(numberValue, value?.AsStructure?[numberField].AsInteger); - } - - [When(@"a boolean flag with key ""(.*)"" is evaluated with details and default value ""(.*)""")] - public async Task Whenabooleanflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, bool defaultValue) - { - this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Boolean); - this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetBooleanDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); - } - - [Then(@"the resolved boolean details value should be ""(.*)"", the variant should be ""(.*)"", and the reason should be ""(.*)""")] - public void Thentheresolvedbooleandetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(bool expectedValue, string expectedVariant, string expectedReason) - { - var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.Equal(expectedValue, result?.Value); - Assert.Equal(expectedVariant, result?.Variant); - Assert.Equal(expectedReason, result?.Reason); - } - - [When(@"a string flag with key ""(.*)"" is evaluated with details and default value ""(.*)""")] - public async Task Whenastringflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, string defaultValue) - { - this._state.Flag = new FlagState(flagKey, defaultValue, FlagType.String); - this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); - } - - [Then(@"the resolved string details value should be ""(.*)"", the variant should be ""(.*)"", and the reason should be ""(.*)""")] - public void Thentheresolvedstringdetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(string expectedValue, string expectedVariant, string expectedReason) - { - var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.Equal(expectedValue, result?.Value); - Assert.Equal(expectedVariant, result?.Variant); - Assert.Equal(expectedReason, result?.Reason); - } - - [When(@"an integer flag with key ""(.*)"" is evaluated with details and default value (.*)")] - public async Task Whenanintegerflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, int defaultValue) - { - this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Integer); - this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetIntegerDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); - } - - [Then(@"the resolved integer details value should be (.*), the variant should be ""(.*)"", and the reason should be ""(.*)""")] - public void Thentheresolvedintegerdetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(int expectedValue, string expectedVariant, string expectedReason) - { - var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.Equal(expectedValue, result?.Value); - Assert.Equal(expectedVariant, result?.Variant); - Assert.Equal(expectedReason, result?.Reason); - } - - [When(@"a float flag with key ""(.*)"" is evaluated with details and default value (.*)")] - public async Task Whenafloatflagwithkeyisevaluatedwithdetailsanddefaultvalue(string flagKey, double defaultValue) - { - this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.Float); - this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetDoubleDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); - } - - [Then(@"the resolved float details value should be (.*), the variant should be ""(.*)"", and the reason should be ""(.*)""")] - public void Thentheresolvedfloatdetailsvalueshouldbethevariantshouldbeandthereasonshouldbe(double expectedValue, string expectedVariant, string expectedReason) - { - var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.Equal(expectedValue, result?.Value); - Assert.Equal(expectedVariant, result?.Variant); - Assert.Equal(expectedReason, result?.Reason); - } - - [When(@"an object flag with key ""(.*)"" is evaluated with details and a null default value")] - public async Task Whenanobjectflagwithkeyisevaluatedwithdetailsandanulldefaultvalue(string flagKey) - { - this._state.Flag = new FlagState(flagKey, null!, FlagType.Object); - this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetObjectDetailsAsync(flagKey, new Value()).ConfigureAwait(false); - } - - [Then(@"the resolved object details value should be contain fields ""(.*)"", ""(.*)"", and ""(.*)"", with values ""(.*)"", ""(.*)"" and (.*), respectively")] - public void Thentheresolvedobjectdetailsvalueshouldbecontainfieldsandwithvaluesandrespectively(string boolField, string stringField, string numberField, bool boolValue, string stringValue, int numberValue) - { - var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; - var value = result?.Value; - Assert.NotNull(value); - Assert.Equal(boolValue, value?.AsStructure?[boolField].AsBoolean); - Assert.Equal(stringValue, value?.AsStructure?[stringField].AsString); - Assert.Equal(numberValue, value?.AsStructure?[numberField].AsInteger); - } - - [Then(@"the variant should be ""(.*)"", and the reason should be ""(.*)""")] - public void Giventhevariantshouldbeandthereasonshouldbe(string expectedVariant, string expectedReason) - { - var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.NotNull(result); - Assert.Equal(expectedVariant, result?.Variant); - Assert.Equal(expectedReason, result?.Reason); - } - - [When(@"context contains keys ""(.*)"", ""(.*)"", ""(.*)"", ""(.*)"" with values ""(.*)"", ""(.*)"", (.*), ""(.*)""")] - public void Whencontextcontainskeyswithvalues(string field1, string field2, string field3, string field4, string value1, string value2, int value3, string value4) - { - this._state.EvaluationContext = new EvaluationContextBuilder() - .Set(field1, value1) - .Set(field2, value2) - .Set(field3, value3) - .Set(field4, bool.Parse(value4)).Build(); - } - - [When(@"a flag with key ""(.*)"" is evaluated with default value ""(.*)""")] - public async Task Givenaflagwithkeyisevaluatedwithdefaultvalue(string flagKey, string defaultValue) - { - this._state.Flag = new FlagState(flagKey, defaultValue, FlagType.String); - this._state.FlagResult = await this._state.Client!.GetStringValueAsync(flagKey, defaultValue, this._state.EvaluationContext).ConfigureAwait(false); - } - - [Then(@"the resolved string response should be ""(.*)""")] - public void Thentheresolvedstringresponseshouldbe(string expected) - { - var result = this._state.FlagResult as string; - Assert.Equal(expected, result); - } - - [Then(@"the resolved flag value is ""(.*)"" when the context is empty")] - public async Task Giventheresolvedflagvalueiswhenthecontextisempty(string expected) - { - var key = this._state.Flag!.Key; - var defaultValue = this._state.Flag.DefaultValue; - - string? emptyContextValue = await this._state.Client!.GetStringValueAsync(key, defaultValue, EvaluationContext.Empty).ConfigureAwait(false); - Assert.Equal(expected, emptyContextValue); - } - - [When(@"a non-existent string flag with key ""(.*)"" is evaluated with details and a default value ""(.*)""")] - public async Task Whenanonexistentstringflagwithkeyisevaluatedwithdetailsandadefaultvalue(string flagKey, string defaultValue) - { - this._state.Flag = new FlagState(flagKey, defaultValue, FlagType.String); - this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetStringDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); - } - - [Then(@"the default string value should be returned")] - public void Thenthedefaultstringvalueshouldbereturned() - { - var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; - var defaultValue = this._state.Flag!.DefaultValue; - Assert.Equal(defaultValue, result?.Value); - } - - [Then(@"the reason should indicate an error and the error code should indicate a missing flag with ""(.*)""")] - public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateamissingflagwith(string errorCode) - { - var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.Equal(Reason.Error, result?.Reason); - Assert.Equal(errorCode, result?.ErrorType.GetDescription()); - } - - [When(@"a string flag with key ""(.*)"" is evaluated as an integer, with details and a default value (.*)")] - public async Task Whenastringflagwithkeyisevaluatedasanintegerwithdetailsandadefaultvalue(string flagKey, int defaultValue) - { - this._state.Flag = new FlagState(flagKey, defaultValue.ToString(), FlagType.String); - this._state.FlagEvaluationDetailsResult = await this._state.Client!.GetIntegerDetailsAsync(flagKey, defaultValue).ConfigureAwait(false); - } - - [Then(@"the default integer value should be returned")] - public void Thenthedefaultintegervalueshouldbereturned() - { - var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; - var defaultValue = int.Parse(this._state.Flag!.DefaultValue); - Assert.Equal(defaultValue, result?.Value); - } - - [Then(@"the reason should indicate an error and the error code should indicate a type mismatch with ""(.*)""")] - public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateatypemismatchwith(string errorCode) - { - var result = this._state.FlagEvaluationDetailsResult as FlagEvaluationDetails; - Assert.Equal(Reason.Error, result?.Reason); - Assert.Equal(errorCode, result?.ErrorType.GetDescription()); - } -} From ee862f09cb2c58f43f84957fa95e8b25e8e36f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Tue, 11 Nov 2025 17:58:24 +0000 Subject: [PATCH 088/124] feat: Add DI for multi provider (#621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Implement multi-provider configuration with dependency injection support Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: Add multi-provider support with dependency injection and flag evaluation endpoints Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: Simplify AddMultiProvider method by removing redundant parameters Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * test: Add unit tests for MultiProviderBuilder and MultiProviderDependencyInjection Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: Update project reference to use OpenFeature.Hosting instead of OpenFeature.DependencyInjection Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * refactor: Remove MultiProviderOptions class as part of code cleanup Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * docs: add dependency injection documentation to MultiProvider README - Document AddMultiProvider extension methods - Explain MultiProviderBuilder usage patterns - Show examples for adding providers via factory, instance, and DI - Document evaluation strategy configuration - Add domain-scoped provider configuration examples - Position DI setup as recommended approach Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Kyle <38759683+kylejuliandev@users.noreply.github.com> Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Kyle <38759683+kylejuliandev@users.noreply.github.com> Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * fix: Improve null argument exception messages in MultiProvider configuration Signed-off-by: GitHub --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> Signed-off-by: GitHub Co-authored-by: Kyle <38759683+kylejuliandev@users.noreply.github.com> --- samples/AspNetCore/Program.cs | 43 ++- .../FeatureBuilderExtensions.cs | 94 +++++ .../MultiProviderBuilder.cs | 135 ++++++++ ...OpenFeature.Providers.MultiProvider.csproj | 25 +- .../README.md | 83 +++++ .../OpenFeature.AotCompatibility.csproj | 2 +- .../MultiProviderBuilderTests.cs | 327 ++++++++++++++++++ .../MultiProviderDependencyInjectionTests.cs | 292 ++++++++++++++++ 8 files changed, 987 insertions(+), 14 deletions(-) create mode 100644 src/OpenFeature.Providers.MultiProvider/DependencyInjection/FeatureBuilderExtensions.cs create mode 100644 src/OpenFeature.Providers.MultiProvider/DependencyInjection/MultiProviderBuilder.cs create mode 100644 test/OpenFeature.Providers.MultiProvider.Tests/DependencyInjection/MultiProviderBuilderTests.cs create mode 100644 test/OpenFeature.Providers.MultiProvider.Tests/DependencyInjection/MultiProviderDependencyInjectionTests.cs diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index 3dc0203b1..6651e4fb4 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -7,6 +7,7 @@ using OpenFeature.Model; using OpenFeature.Providers.Memory; using OpenFeature.Providers.MultiProvider; +using OpenFeature.Providers.MultiProvider.DependencyInjection; using OpenFeature.Providers.MultiProvider.Models; using OpenFeature.Providers.MultiProvider.Strategies; using OpenTelemetry.Metrics; @@ -59,7 +60,28 @@ { "disable", new Value(Structure.Builder().Set(nameof(TestConfig.Threshold), 0).Build()) } }, "disable") } - }); + }) + .AddMultiProvider("multi-provider", multiProviderBuilder => + { + // Create provider 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 provider2Flags = new Dictionary + { + { "providername", new Flag(new Dictionary { { "enabled", "enabled-provider2" }, { "disabled", "disabled-provider2" } }, "enabled") }, + }; + + // Use the factory pattern to create providers - they will be properly initialized + multiProviderBuilder + .AddProvider("p1", sp => new InMemoryProvider(provider1Flags)) + .AddProvider("p2", sp => new InMemoryProvider(provider2Flags)) + .UseStrategy(); + }) + .AddPolicyName(policy => policy.DefaultNameSelector = provider => "InMemory"); }); var app = builder.Build(); @@ -139,6 +161,25 @@ } }); +app.MapGet("/multi-provider-di", async ([FromKeyedServices("multi-provider")] IFeatureClient featureClient) => +{ + try + { + // Test flag evaluation from different providers + var maxItemsFlag = await featureClient.GetIntegerDetailsAsync("max-items", 0); + var providerNameFlag = await featureClient.GetStringDetailsAsync("providername", "default"); + + // Test a flag that doesn't exist in any provider + var unknownFlag = await featureClient.GetBooleanDetailsAsync("unknown-flag", false); + + return Results.Ok(); + } + catch (Exception ex) + { + return Results.Problem($"Error: {ex.Message}\n\nStack: {ex.StackTrace}"); + } +}); + app.Run(); diff --git a/src/OpenFeature.Providers.MultiProvider/DependencyInjection/FeatureBuilderExtensions.cs b/src/OpenFeature.Providers.MultiProvider/DependencyInjection/FeatureBuilderExtensions.cs new file mode 100644 index 000000000..12d61c253 --- /dev/null +++ b/src/OpenFeature.Providers.MultiProvider/DependencyInjection/FeatureBuilderExtensions.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenFeature.Hosting; + +namespace OpenFeature.Providers.MultiProvider.DependencyInjection; + +/// +/// Extension methods for configuring the multi-provider with . +/// +public static class FeatureBuilderExtensions +{ + /// + /// Adds a multi-provider to the with a configuration builder. + /// + /// The instance to configure. + /// + /// A delegate to configure the multi-provider using the . + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddMultiProvider( + this OpenFeatureBuilder builder, + Action configure) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + return builder.AddProvider( + serviceProvider => CreateMultiProviderFromConfigure(serviceProvider, configure)); + } + + /// + /// Adds a multi-provider with a specific domain to the with a configuration builder. + /// + /// The instance to configure. + /// The unique domain of the provider. + /// + /// A delegate to configure the multi-provider using the . + /// + /// The instance for chaining. + public static OpenFeatureBuilder AddMultiProvider( + this OpenFeatureBuilder builder, + string domain, + Action configure) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (string.IsNullOrWhiteSpace(domain)) + { + throw new ArgumentException("Domain cannot be null or empty.", nameof(domain)); + } + + if (configure == null) + { + throw new ArgumentNullException(nameof(configure), "Configure action cannot be null. Please provide a valid configuration for the multi-provider."); + } + + return builder.AddProvider( + domain, + (serviceProvider, _) => CreateMultiProviderFromConfigure(serviceProvider, configure)); + } + + private static MultiProvider CreateMultiProviderFromConfigure(IServiceProvider serviceProvider, Action configure) + { + // Build the multi-provider configuration using the builder + var multiProviderBuilder = new MultiProviderBuilder(); + + // Apply the configuration action + configure(multiProviderBuilder); + + // Build provider entries and strategy from the builder using the service provider + var providerEntries = multiProviderBuilder.BuildProviderEntries(serviceProvider); + var evaluationStrategy = multiProviderBuilder.BuildEvaluationStrategy(serviceProvider); + + if (providerEntries.Count == 0) + { + throw new InvalidOperationException("At least one provider must be configured for the multi-provider."); + } + + // Get logger from DI + var logger = serviceProvider.GetService>(); + + return new MultiProvider(providerEntries, evaluationStrategy, logger); + } +} diff --git a/src/OpenFeature.Providers.MultiProvider/DependencyInjection/MultiProviderBuilder.cs b/src/OpenFeature.Providers.MultiProvider/DependencyInjection/MultiProviderBuilder.cs new file mode 100644 index 000000000..3353e6122 --- /dev/null +++ b/src/OpenFeature.Providers.MultiProvider/DependencyInjection/MultiProviderBuilder.cs @@ -0,0 +1,135 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenFeature.Providers.MultiProvider.Models; +using OpenFeature.Providers.MultiProvider.Strategies; + +namespace OpenFeature.Providers.MultiProvider.DependencyInjection; + +/// +/// Builder for configuring a multi-provider with dependency injection. +/// +public class MultiProviderBuilder +{ + private readonly List> _providerFactories = []; + private Func? _strategyFactory; + + /// + /// Adds a provider to the multi-provider configuration using a factory method. + /// + /// The name for the provider. + /// A factory method to create the provider instance. + /// The instance for chaining. + public MultiProviderBuilder AddProvider(string name, Func factory) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Provider name cannot be null or empty.", nameof(name)); + } + + if (factory == null) + { + throw new ArgumentNullException(nameof(factory), "Provider configuration cannot be null."); + } + + return AddProvider(name, sp => factory(sp)); + } + + /// + /// Adds a provider to the multi-provider configuration using a type. + /// + /// The type of the provider to add. + /// The name for the provider. + /// An optional factory method to create the provider instance. If not provided, the provider will be resolved from the service provider. + /// The instance for chaining. + public MultiProviderBuilder AddProvider(string name, Func? factory = null) + where TProvider : FeatureProvider + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Provider name cannot be null or empty.", nameof(name)); + } + + this._providerFactories.Add(sp => + { + var provider = factory != null + ? factory(sp) + : sp.GetRequiredService(); + return new ProviderEntry(provider, name); + }); + + return this; + } + + /// + /// Adds a provider instance to the multi-provider configuration. + /// + /// The name for the provider. + /// The provider instance to add. + /// The instance for chaining. + public MultiProviderBuilder AddProvider(string name, FeatureProvider provider) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Provider name cannot be null or empty.", nameof(name)); + } + + if (provider == null) + { + throw new ArgumentNullException(nameof(provider), "Provider configuration cannot be null."); + } + + return AddProvider(name, _ => provider); + } + + /// + /// Sets the evaluation strategy for the multi-provider. + /// + /// The type of the evaluation strategy. + /// The instance for chaining. + public MultiProviderBuilder UseStrategy() + where TStrategy : BaseEvaluationStrategy, new() + { + return UseStrategy(static _ => new TStrategy()); + } + + /// + /// Sets the evaluation strategy for the multi-provider using a factory method. + /// + /// A factory method to create the strategy instance. + /// The instance for chaining. + public MultiProviderBuilder UseStrategy(Func factory) + { + this._strategyFactory = factory ?? throw new ArgumentNullException(nameof(factory), "Strategy for multi-provider cannot be null."); + return this; + } + + /// + /// Sets the evaluation strategy for the multi-provider. + /// + /// The strategy instance to use. + /// The instance for chaining. + public MultiProviderBuilder UseStrategy(BaseEvaluationStrategy strategy) + { + if (strategy == null) + { + throw new ArgumentNullException(nameof(strategy)); + } + + return UseStrategy(_ => strategy); + } + + /// + /// Builds the provider entries using the service provider. + /// + internal List BuildProviderEntries(IServiceProvider serviceProvider) + { + return this._providerFactories.Select(factory => factory(serviceProvider)).ToList(); + } + + /// + /// Builds the evaluation strategy using the service provider. + /// + internal BaseEvaluationStrategy? BuildEvaluationStrategy(IServiceProvider serviceProvider) + { + return this._strategyFactory?.Invoke(serviceProvider); + } +} diff --git a/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj b/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj index d999c5613..99d30b4a7 100644 --- a/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj +++ b/src/OpenFeature.Providers.MultiProvider/OpenFeature.Providers.MultiProvider.csproj @@ -1,17 +1,18 @@  - - OpenFeature.Providers.MultiProvider - README.md - + + OpenFeature.Providers.MultiProvider + README.md + - - - - - + + + + + - - - + + + + \ No newline at end of file diff --git a/src/OpenFeature.Providers.MultiProvider/README.md b/src/OpenFeature.Providers.MultiProvider/README.md index 8b12807c0..00108c0a2 100644 --- a/src/OpenFeature.Providers.MultiProvider/README.md +++ b/src/OpenFeature.Providers.MultiProvider/README.md @@ -18,8 +18,91 @@ dotnet add package OpenFeature.Providers.MultiProvider ## Usage +### Dependency Injection Setup (Recommended) + +The MultiProvider integrates seamlessly with the OpenFeature dependency injection system, allowing you to configure multiple providers using the `AddMultiProvider` extension method: + +```csharp +using OpenFeature.Providers.MultiProvider.DependencyInjection; + +builder.Services.AddOpenFeature(featureBuilder => +{ + featureBuilder + .AddMultiProvider("multi-provider", multiProviderBuilder => + { + // Add providers using factory methods for proper DI integration + multiProviderBuilder + .AddProvider("primary", sp => new YourPrimaryProvider()) + .AddProvider("fallback", sp => new YourFallbackProvider()) + .UseStrategy(); + }); +}); + +// Retrieve and use the client +var featureClient = openFeatureApi.GetClient("multi-provider"); +var result = await featureClient.GetBooleanValueAsync("my-flag", false); +``` + +#### Adding Providers with DI + +The `MultiProviderBuilder` provides several methods to add providers: + +**Using Factory Methods:** +```csharp +multiProviderBuilder + .AddProvider("provider-name", sp => new InMemoryProvider(flags)) + .AddProvider("another-provider", sp => sp.GetRequiredService()); +``` + +**Using Provider Instances:** +```csharp +var provider = new InMemoryProvider(flags); +multiProviderBuilder.AddProvider("provider-name", provider); +``` + +**Using Generic Type Resolution:** +```csharp +// Provider will be resolved from DI container +multiProviderBuilder.AddProvider("provider-name"); + +// Or with custom factory +multiProviderBuilder.AddProvider("provider-name", sp => new YourProvider(config)); +``` + +#### Configuring Evaluation Strategy + +Specify an evaluation strategy using any of these methods: + +```csharp +// Using generic type +multiProviderBuilder.UseStrategy(); + +// Using factory method with DI +multiProviderBuilder.UseStrategy(sp => new FirstMatchStrategy()); + +// Using strategy instance +multiProviderBuilder.UseStrategy(new ComparisonStrategy()); +``` + +#### Using with Named Domains + +Configure the MultiProvider for a specific domain: + +```csharp +featureBuilder + .AddMultiProvider("production-domain", multiProviderBuilder => + { + multiProviderBuilder + .AddProvider("remote", sp => new RemoteProvider()) + .AddProvider("cache", sp => new CacheProvider()) + .UseStrategy(); + }); +``` + ### Basic Setup +For scenarios where dependency injection is not available, you can use the traditional setup: + ```csharp using OpenFeature; using OpenFeature.Providers.MultiProvider; diff --git a/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj b/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj index d416bd75b..823d96f0e 100644 --- a/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj +++ b/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj @@ -18,7 +18,7 @@ - + diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/DependencyInjection/MultiProviderBuilderTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/DependencyInjection/MultiProviderBuilderTests.cs new file mode 100644 index 000000000..40fbb6b12 --- /dev/null +++ b/test/OpenFeature.Providers.MultiProvider.Tests/DependencyInjection/MultiProviderBuilderTests.cs @@ -0,0 +1,327 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.DependencyInjection; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Tests.Utils; + +namespace OpenFeature.Providers.MultiProvider.Tests.DependencyInjection; + +public class MultiProviderBuilderTests +{ + private readonly FeatureProvider _mockProvider = Substitute.For(); + + public MultiProviderBuilderTests() + { + _mockProvider.GetMetadata().Returns(new Metadata("mock-provider")); + } + + [Fact] + public void AddProvider_WithNullName_ThrowsArgumentException() + { + // Arrange + var builder = new MultiProviderBuilder(); + + // Act & Assert + Assert.Throws(() => + builder.AddProvider(null!, _mockProvider)); + } + + [Fact] + public void AddProvider_WithEmptyName_ThrowsArgumentException() + { + // Arrange + var builder = new MultiProviderBuilder(); + + // Act & Assert + Assert.Throws(() => + builder.AddProvider("", _mockProvider)); + } + + [Fact] + public void AddProvider_WithNullProvider_ThrowsArgumentNullException() + { + // Arrange + var builder = new MultiProviderBuilder(); + + // Act & Assert + Assert.Throws(() => + builder.AddProvider("test", (FeatureProvider)null!)); + } + + [Fact] + public void AddProvider_WithFactory_WithNullName_ThrowsArgumentException() + { + // Arrange + var builder = new MultiProviderBuilder(); + + // Act & Assert + Assert.Throws(() => + builder.AddProvider(null!, sp => _mockProvider)); + } + + [Fact] + public void AddProvider_WithFactory_WithNullFactory_ThrowsArgumentNullException() + { + // Arrange + var builder = new MultiProviderBuilder(); + + // Act & Assert + Assert.Throws(() => + builder.AddProvider("test", (Func)null!)); + } + + [Fact] + public void AddProvider_Generic_WithNullName_ThrowsArgumentException() + { + // Arrange + var builder = new MultiProviderBuilder(); + + // Act & Assert + Assert.Throws(() => + builder.AddProvider(null!)); + } + + [Fact] + public void AddProvider_Generic_WithEmptyName_ThrowsArgumentException() + { + // Arrange + var builder = new MultiProviderBuilder(); + + // Act & Assert + Assert.Throws(() => + builder.AddProvider("")); + } + + [Fact] + public void AddProvider_AddsProviderToBuilder() + { + // Arrange + var builder = new MultiProviderBuilder(); + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + + // Act + builder.AddProvider("test-provider", _mockProvider); + var entries = builder.BuildProviderEntries(serviceProvider); + + // Assert + Assert.Single(entries); + Assert.Equal("test-provider", entries[0].Name); + Assert.Same(_mockProvider, entries[0].Provider); + } + + [Fact] + public void AddProvider_WithFactory_AddsProviderToBuilder() + { + // Arrange + var builder = new MultiProviderBuilder(); + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + + // Act + builder.AddProvider("test-provider", sp => _mockProvider); + var entries = builder.BuildProviderEntries(serviceProvider); + + // Assert + Assert.Single(entries); + Assert.Equal("test-provider", entries[0].Name); + Assert.Same(_mockProvider, entries[0].Provider); + } + + [Fact] + public void AddProvider_Generic_WithFactory_AddsProviderToBuilder() + { + // Arrange + var builder = new MultiProviderBuilder(); + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + + // Act + builder.AddProvider("test-provider", sp => new TestProvider("test-provider")); + var entries = builder.BuildProviderEntries(serviceProvider); + + // Assert + Assert.Single(entries); + Assert.Equal("test-provider", entries[0].Name); + Assert.IsType(entries[0].Provider); + } + + [Fact] + public void AddProvider_Generic_WithoutFactory_ResolvesFromServiceProvider() + { + // Arrange + var builder = new MultiProviderBuilder(); + var services = new ServiceCollection(); + services.AddTransient(_ => new TestProvider("test-provider")); + var serviceProvider = services.BuildServiceProvider(); + + // Act + builder.AddProvider("test-provider"); + var entries = builder.BuildProviderEntries(serviceProvider); + + // Assert + Assert.Single(entries); + Assert.Equal("test-provider", entries[0].Name); + Assert.IsType(entries[0].Provider); + } + + [Fact] + public void AddProvider_MultipleProviders_AddsAllProviders() + { + // Arrange + var builder = new MultiProviderBuilder(); + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var provider3 = Substitute.For(); + + provider1.GetMetadata().Returns(new Metadata("provider1")); + provider2.GetMetadata().Returns(new Metadata("provider2")); + provider3.GetMetadata().Returns(new Metadata("provider3")); + + // Act + builder + .AddProvider("provider1", provider1) + .AddProvider("provider2", sp => provider2) + .AddProvider("provider3", sp => new TestProvider("provider3")); + + var entries = builder.BuildProviderEntries(serviceProvider); + + // Assert + Assert.Equal(3, entries.Count); + Assert.Equal("provider1", entries[0].Name); + Assert.Equal("provider2", entries[1].Name); + Assert.Equal("provider3", entries[2].Name); + } + + [Fact] + public void UseStrategy_Generic_SetsStrategy() + { + // Arrange + var builder = new MultiProviderBuilder(); + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + + // Act + builder.UseStrategy(); + var strategy = builder.BuildEvaluationStrategy(serviceProvider); + + // Assert + Assert.NotNull(strategy); + Assert.IsType(strategy); + } + + [Fact] + public void UseStrategy_WithInstance_SetsStrategy() + { + // Arrange + var builder = new MultiProviderBuilder(); + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + var strategyInstance = new FirstMatchStrategy(); + + // Act + builder.UseStrategy(strategyInstance); + var strategy = builder.BuildEvaluationStrategy(serviceProvider); + + // Assert + Assert.NotNull(strategy); + Assert.Same(strategyInstance, strategy); + } + + [Fact] + public void UseStrategy_WithNullInstance_ThrowsArgumentNullException() + { + // Arrange + var builder = new MultiProviderBuilder(); + + // Act & Assert + Assert.Throws(() => + builder.UseStrategy((FirstMatchStrategy)null!)); + } + + [Fact] + public void UseStrategy_WithFactory_SetsStrategy() + { + // Arrange + var builder = new MultiProviderBuilder(); + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + + // Act + builder.UseStrategy(sp => new FirstMatchStrategy()); + var strategy = builder.BuildEvaluationStrategy(serviceProvider); + + // Assert + Assert.NotNull(strategy); + Assert.IsType(strategy); + } + + [Fact] + public void UseStrategy_WithNullFactory_ThrowsArgumentNullException() + { + // Arrange + var builder = new MultiProviderBuilder(); + + // Act & Assert + Assert.Throws(() => + builder.UseStrategy((Func)null!)); + } + + [Fact] + public void BuildEvaluationStrategy_WithNoStrategy_ReturnsNull() + { + // Arrange + var builder = new MultiProviderBuilder(); + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + + // Act + var strategy = builder.BuildEvaluationStrategy(serviceProvider); + + // Assert + Assert.Null(strategy); + } + + [Fact] + public void BuildProviderEntries_WithNoProviders_ReturnsEmptyList() + { + // Arrange + var builder = new MultiProviderBuilder(); + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + + // Act + var entries = builder.BuildProviderEntries(serviceProvider); + + // Assert + Assert.NotNull(entries); + Assert.Empty(entries); + } + + [Fact] + public void Builder_ChainsMethodsCorrectly() + { + // Arrange + var builder = new MultiProviderBuilder(); + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + + // Act + var result = builder + .AddProvider("provider1", _mockProvider) + .AddProvider("provider2", sp => _mockProvider) + .UseStrategy(); + + var entries = builder.BuildProviderEntries(serviceProvider); + var strategy = builder.BuildEvaluationStrategy(serviceProvider); + + // Assert + Assert.Same(builder, result); + Assert.Equal(2, entries.Count); + Assert.NotNull(strategy); + } +} diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/DependencyInjection/MultiProviderDependencyInjectionTests.cs b/test/OpenFeature.Providers.MultiProvider.Tests/DependencyInjection/MultiProviderDependencyInjectionTests.cs new file mode 100644 index 000000000..5d2b6dbca --- /dev/null +++ b/test/OpenFeature.Providers.MultiProvider.Tests/DependencyInjection/MultiProviderDependencyInjectionTests.cs @@ -0,0 +1,292 @@ +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using OpenFeature.Hosting; +using OpenFeature.Model; +using OpenFeature.Providers.MultiProvider.DependencyInjection; +using OpenFeature.Providers.MultiProvider.Strategies; +using OpenFeature.Providers.MultiProvider.Tests.Utils; + +namespace OpenFeature.Providers.MultiProvider.Tests.DependencyInjection; + +public class MultiProviderDependencyInjectionTests +{ + private readonly FeatureProvider _mockProvider = Substitute.For(); + + public MultiProviderDependencyInjectionTests() + { + _mockProvider.GetMetadata().Returns(new Metadata("test-provider")); + _mockProvider.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new ResolutionDetails("test", true))); + } + + [Fact] + public void AddMultiProvider_WithNullBuilder_ThrowsArgumentNullException() + { + // Arrange + OpenFeatureBuilder builder = null!; + + // Act & Assert + Assert.Throws(() => + builder.AddMultiProvider(b => b.AddProvider("test", _mockProvider))); + } + + [Fact] + public void AddMultiProvider_WithNullConfigure_ThrowsArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act & Assert + Assert.Throws(() => + builder.AddMultiProvider(null!)); + } + + [Fact] + public void AddMultiProvider_WithDomain_WithNullBuilder_ThrowsArgumentNullException() + { + // Arrange + OpenFeatureBuilder builder = null!; + + // Act & Assert + Assert.Throws(() => + builder.AddMultiProvider("test-domain", b => b.AddProvider("test", _mockProvider))); + } + + [Fact] + public void AddMultiProvider_WithDomain_WithNullDomain_ThrowsArgumentException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act & Assert + Assert.Throws(() => + builder.AddMultiProvider(null!, b => b.AddProvider("test", _mockProvider))); + } + + [Fact] + public void AddMultiProvider_WithDomain_WithEmptyDomain_ThrowsArgumentException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act & Assert + Assert.Throws(() => + builder.AddMultiProvider("", b => b.AddProvider("test", _mockProvider))); + } + + [Fact] + public void AddMultiProvider_WithDomain_WithNullConfigure_ThrowsArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenFeatureBuilder(services); + + // Act & Assert + Assert.Throws(() => + builder.AddMultiProvider("test-domain", null!)); + } + + [Fact] + public void AddMultiProvider_WithNoProviders_ThrowsInvalidOperationException() + { + // Arrange + var services = new ServiceCollection(); + services.AddOpenFeature(builder => + { + builder.AddMultiProvider(b => { }); // Empty configuration + }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act & Assert + Assert.Throws(() => + serviceProvider.GetRequiredService()); + } + + [Fact] + public void AddMultiProvider_RegistersProviderCorrectly() + { + // Arrange + var services = new ServiceCollection(); + services.AddOpenFeature(builder => + { + builder.AddMultiProvider(b => + { + b.AddProvider("provider1", _mockProvider); + }); + }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var provider = serviceProvider.GetRequiredService(); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Fact] + public void AddMultiProvider_WithDomain_RegistersProviderCorrectly() + { + // Arrange + var services = new ServiceCollection(); + services.AddOpenFeature(builder => + { + builder.AddMultiProvider("test-domain", b => + { + b.AddProvider("provider1", _mockProvider); + }); + }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var provider = serviceProvider.GetRequiredKeyedService("test-domain"); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Fact] + public void AddMultiProvider_WithMultipleProviders_CreatesMultiProvider() + { + // Arrange + var services = new ServiceCollection(); + var provider1 = Substitute.For(); + var provider2 = Substitute.For(); + var provider3 = Substitute.For(); + + provider1.GetMetadata().Returns(new Metadata("provider1")); + provider2.GetMetadata().Returns(new Metadata("provider2")); + provider3.GetMetadata().Returns(new Metadata("provider3")); + + services.AddOpenFeature(builder => + { + builder.AddMultiProvider(b => + { + b.AddProvider("provider1", provider1) + .AddProvider("provider2", provider2) + .AddProvider("provider3", provider3); + }); + }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var provider = serviceProvider.GetRequiredService(); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Fact] + public void AddMultiProvider_WithStrategy_CreatesMultiProviderWithStrategy() + { + // Arrange + var services = new ServiceCollection(); + services.AddOpenFeature(builder => + { + builder.AddMultiProvider(b => + { + b.AddProvider("provider1", _mockProvider) + .UseStrategy(); + }); + }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var provider = serviceProvider.GetRequiredService(); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Fact] + public void AddMultiProvider_WithFactoryProvider_CreatesProviderFromFactory() + { + // Arrange + var services = new ServiceCollection(); + var factoryCalled = false; + + services.AddOpenFeature(builder => + { + builder.AddMultiProvider(b => + { + b.AddProvider("provider1", sp => + { + factoryCalled = true; + var mockProvider = Substitute.For(); + mockProvider.GetMetadata().Returns(new Metadata("factory-provider")); + return mockProvider; + }); + }); + }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var provider = serviceProvider.GetRequiredService(); + + // Assert + Assert.NotNull(provider); + Assert.True(factoryCalled, "Factory method should have been called"); + } + + [Fact] + public void AddMultiProvider_WithTypedProvider_ResolvesFromServiceProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddTransient(_ => new TestProvider("test-provider")); + + services.AddOpenFeature(builder => + { + builder.AddMultiProvider(b => + { + b.AddProvider("provider1"); + }); + }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var provider = serviceProvider.GetRequiredService(); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + } + + [Fact] + public void AddMultiProvider_WithLogger_CreatesMultiProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddOpenFeature(builder => + { + builder.AddMultiProvider(b => + { + b.AddProvider("provider1", _mockProvider); + }); + }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var provider = serviceProvider.GetRequiredService(); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + } +} From 10a43c939cd35a557ab46441a2d9c02eec0e6561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Wed, 12 Nov 2025 04:06:09 +0000 Subject: [PATCH 089/124] ci: Fix build for the dotnet SDK and macOS runner image (#636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: update SDK version and rollForward policy in global.json Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * feat: add workaround for .NET Framework 4.6.2 build warnings Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore: update macOS version in AOT compatibility 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/aot-compatibility.yml | 2 +- build/Common.props | 7 +++++++ global.json | 6 +++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/aot-compatibility.yml b/.github/workflows/aot-compatibility.yml index 76e16c246..1ed51794f 100644 --- a/.github/workflows/aot-compatibility.yml +++ b/.github/workflows/aot-compatibility.yml @@ -34,7 +34,7 @@ jobs: arch: arm64 runtime: win-arm64 # macOS x64 - - os: macos-13 + - os: macos-15-intel arch: x64 runtime: osx-x64 # macOS ARM64 (Apple Silicon) diff --git a/build/Common.props b/build/Common.props index 287b32312..41df868d0 100644 --- a/build/Common.props +++ b/build/Common.props @@ -28,4 +28,11 @@ + + + + true + diff --git a/global.json b/global.json index 5fb240dd3..3e42ca302 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "rollForward": "latestFeature", - "version": "9.0.300", + "rollForward": "disable", + "version": "9.0.306", "allowPrerelease": false } -} +} \ No newline at end of file From 578adc620be3ce1c3f06d8f4a6da97e1287dbedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 14 Nov 2025 18:20:07 +0000 Subject: [PATCH 090/124] docs: Update copyright year and owner in LICENSE file (#639) 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> --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 261eeb9e9..67b17bf85 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2025 OpenFeature Maintainers Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 6b46a93aaa6cb2b70adde87778d17d67fb6af059 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 14 Nov 2025 18:34:14 +0000 Subject: [PATCH 091/124] chore(deps): update dependency microsoft.net.test.sdk to 18.0.1 (#635) 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 06ef4950e..6f94afade 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,7 +36,7 @@ - + From f65b099e7cb55c2dae674a7b72cbe857117b89b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:14:33 +0000 Subject: [PATCH 092/124] docs: Update copyright year in LICENSE file (#641) 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> --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 67b17bf85..96b3dc8fc 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 OpenFeature Maintainers + Copyright OpenFeature Maintainers Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From 8b472d8ccd1367ba82a2ab39ad7a77b1a6609ce0 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Tue, 18 Nov 2025 07:50:29 +0000 Subject: [PATCH 093/124] refactor: Pass cancellation tokens to Provider Initialization functions (#640) * Ensure we pass Cancellation Tokens to provider functions Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Rename methods to include Async suffix Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Add missing test case Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Address gemini code review comments Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- src/OpenFeature/Api.cs | 16 ++- src/OpenFeature/ProviderRepository.cs | 16 +-- .../ProviderRepositoryTests.cs | 117 ++++++++++++++++-- 3 files changed, 127 insertions(+), 22 deletions(-) diff --git a/src/OpenFeature/Api.cs b/src/OpenFeature/Api.cs index e4a9826c5..33a7c79d9 100644 --- a/src/OpenFeature/Api.cs +++ b/src/OpenFeature/Api.cs @@ -43,7 +43,8 @@ internal Api() { } public async Task SetProviderAsync(FeatureProvider featureProvider) { this._eventExecutor.RegisterDefaultFeatureProvider(featureProvider); - await this._repository.SetProviderAsync(featureProvider, this.GetContext(), this.AfterInitialization, this.AfterError).ConfigureAwait(false); + await this._repository.SetProviderAsync(featureProvider, this.GetContext(), this.AfterInitializationAsync, this.AfterErrorAsync) + .ConfigureAwait(false); } @@ -62,7 +63,8 @@ public async Task SetProviderAsync(string domain, FeatureProvider featureProvide throw new ArgumentNullException(nameof(domain)); } this._eventExecutor.RegisterClientFeatureProvider(domain, featureProvider); - await this._repository.SetProviderAsync(domain, featureProvider, this.GetContext(), this.AfterInitialization, this.AfterError).ConfigureAwait(false); + await this._repository.SetProviderAsync(domain, featureProvider, this.GetContext(), this.AfterInitializationAsync, this.AfterErrorAsync) + .ConfigureAwait(false); } /// @@ -324,7 +326,7 @@ internal void RemoveClientHandler(string client, ProviderEventTypes eventType, E /// /// Update the provider state to READY and emit a READY event after successful init. /// - private async Task AfterInitialization(FeatureProvider provider) + private async Task AfterInitializationAsync(FeatureProvider provider, CancellationToken cancellationToken = default) { provider.Status = ProviderStatus.Ready; var eventPayload = new ProviderEventPayload @@ -334,13 +336,14 @@ private async Task AfterInitialization(FeatureProvider provider) ProviderName = provider.GetMetadata()?.Name, }; - await this._eventExecutor.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }).ConfigureAwait(false); + await this._eventExecutor.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }, cancellationToken) + .ConfigureAwait(false); } /// /// Update the provider state to ERROR and emit an ERROR after failed init. /// - private async Task AfterError(FeatureProvider provider, Exception? ex) + private async Task AfterErrorAsync(FeatureProvider provider, Exception? ex, CancellationToken cancellationToken = default) { provider.Status = typeof(ProviderFatalException) == ex?.GetType() ? ProviderStatus.Fatal : ProviderStatus.Error; var eventPayload = new ProviderEventPayload @@ -350,7 +353,8 @@ private async Task AfterError(FeatureProvider provider, Exception? ex) ProviderName = provider.GetMetadata()?.Name, }; - await this._eventExecutor.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }).ConfigureAwait(false); + await this._eventExecutor.EventChannel.Writer.WriteAsync(new Event { Provider = provider, EventPayload = eventPayload }, cancellationToken) + .ConfigureAwait(false); } /// diff --git a/src/OpenFeature/ProviderRepository.cs b/src/OpenFeature/ProviderRepository.cs index 4cea63b08..5539d0eab 100644 --- a/src/OpenFeature/ProviderRepository.cs +++ b/src/OpenFeature/ProviderRepository.cs @@ -55,8 +55,8 @@ public async ValueTask DisposeAsync() internal async Task SetProviderAsync( FeatureProvider? featureProvider, EvaluationContext context, - Func? afterInitSuccess = null, - Func? afterInitError = null, + Func? afterInitSuccess = null, + Func? afterInitError = null, CancellationToken cancellationToken = default) { // Cannot unset the feature provider. @@ -93,8 +93,8 @@ await InitProviderAsync(this._defaultProvider, context, afterInitSuccess, afterI private static async Task InitProviderAsync( FeatureProvider? newProvider, EvaluationContext context, - Func? afterInitialization, - Func? afterError, + Func? afterInitialization, + Func? afterError, CancellationToken cancellationToken = default) { if (newProvider == null) @@ -108,14 +108,14 @@ private static async Task InitProviderAsync( await newProvider.InitializeAsync(context, cancellationToken).ConfigureAwait(false); if (afterInitialization != null) { - await afterInitialization.Invoke(newProvider).ConfigureAwait(false); + await afterInitialization.Invoke(newProvider, cancellationToken).ConfigureAwait(false); } } catch (Exception ex) { if (afterError != null) { - await afterError.Invoke(newProvider, ex).ConfigureAwait(false); + await afterError.Invoke(newProvider, ex, cancellationToken).ConfigureAwait(false); } } } @@ -138,8 +138,8 @@ private static async Task InitProviderAsync( internal async Task SetProviderAsync(string domain, FeatureProvider? featureProvider, EvaluationContext context, - Func? afterInitSuccess = null, - Func? afterInitError = null, + Func? afterInitSuccess = null, + Func? afterInitError = null, CancellationToken cancellationToken = default) { // Cannot set a provider for a null domain. diff --git a/test/OpenFeature.Tests/ProviderRepositoryTests.cs b/test/OpenFeature.Tests/ProviderRepositoryTests.cs index 4284eaeeb..43fc71355 100644 --- a/test/OpenFeature.Tests/ProviderRepositoryTests.cs +++ b/test/OpenFeature.Tests/ProviderRepositoryTests.cs @@ -39,7 +39,7 @@ public async Task AfterInitialization_Is_Invoked_For_Setting_Default_Provider() providerMock.Status.Returns(ProviderStatus.NotReady); var context = new EvaluationContextBuilder().Build(); var callCount = 0; - await repository.SetProviderAsync(providerMock, context, afterInitSuccess: (theProvider) => + await repository.SetProviderAsync(providerMock, context, afterInitSuccess: (theProvider, ct) => { Assert.Equal(providerMock, theProvider); callCount++; @@ -48,6 +48,31 @@ await repository.SetProviderAsync(providerMock, context, afterInitSuccess: (theP Assert.Equal(1, callCount); } + [Fact] + public async Task AfterInitialization_Is_Invoked_With_CancellationToken() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + + using var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + var context = new EvaluationContextBuilder().Build(); + + var initCancellationToken = CancellationToken.None; + await repository.SetProviderAsync(providerMock, context, afterInitSuccess: (theProvider, ct) => + { + Assert.Equal(providerMock, theProvider); + + initCancellationToken = ct; + + return Task.CompletedTask; + }, cancellationToken: cancellationToken); + + Assert.Equal(cancellationToken, initCancellationToken); + } + [Fact] public async Task AfterError_Is_Invoked_If_Initialization_Errors_Default_Provider() { @@ -55,10 +80,10 @@ public async Task AfterError_Is_Invoked_If_Initialization_Errors_Default_Provide 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")); + providerMock.When(x => x.InitializeAsync(context, Arg.Any())).Throw(new Exception("BAD THINGS")); var callCount = 0; Exception? receivedError = null; - await repository.SetProviderAsync(providerMock, context, afterInitError: (theProvider, error) => + await repository.SetProviderAsync(providerMock, context, afterInitError: (theProvider, error, ct) => { Assert.Equal(providerMock, theProvider); callCount++; @@ -69,6 +94,32 @@ await repository.SetProviderAsync(providerMock, context, afterInitError: (thePro Assert.Equal(1, callCount); } + [Fact] + public async Task AfterError_Is_Invoked_With_CancellationToken() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + + using var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + var context = new EvaluationContextBuilder().Build(); + providerMock.When(x => x.InitializeAsync(context, cancellationToken)).Throw(new Exception("BAD THINGS")); + + var errorCancellationToken = CancellationToken.None; + await repository.SetProviderAsync(providerMock, context, afterInitError: (theProvider, error, ct) => + { + Assert.Equal(providerMock, theProvider); + + errorCancellationToken = ct; + + return Task.CompletedTask; + }, cancellationToken: cancellationToken); + + Assert.Equal(cancellationToken, errorCancellationToken); + } + [Theory] [InlineData(ProviderStatus.Ready)] [InlineData(ProviderStatus.Stale)] @@ -94,7 +145,7 @@ internal async Task AfterInitialize_Is_Not_Called_For_Ready_Provider(ProviderSta providerMock.Status.Returns(status); var context = new EvaluationContextBuilder().Build(); var callCount = 0; - await repository.SetProviderAsync(providerMock, context, afterInitSuccess: provider => + await repository.SetProviderAsync(providerMock, context, afterInitSuccess: (provider, ct) => { callCount++; return Task.CompletedTask; @@ -150,7 +201,7 @@ public async Task AfterInitialization_Is_Invoked_For_Setting_Named_Provider() providerMock.Status.Returns(ProviderStatus.NotReady); var context = new EvaluationContextBuilder().Build(); var callCount = 0; - await repository.SetProviderAsync("the-name", providerMock, context, afterInitSuccess: (theProvider) => + await repository.SetProviderAsync("the-name", providerMock, context, afterInitSuccess: (theProvider, ct) => { Assert.Equal(providerMock, theProvider); callCount++; @@ -159,6 +210,30 @@ await repository.SetProviderAsync("the-name", providerMock, context, afterInitSu Assert.Equal(1, callCount); } + [Fact] + public async Task AfterInitialization_WithNamedProvider_Is_Invoked_With_CancellationToken() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + + var context = new EvaluationContextBuilder().Build(); + using var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + var initCancellationToken = CancellationToken.None; + await repository.SetProviderAsync("the-name", providerMock, context, afterInitSuccess: (theProvider, ct) => + { + Assert.Equal(providerMock, theProvider); + + initCancellationToken = ct; + + return Task.CompletedTask; + }, cancellationToken: cancellationToken); + + Assert.Equal(cancellationToken, initCancellationToken); + } + [Fact] public async Task AfterError_Is_Invoked_If_Initialization_Errors_Named_Provider() { @@ -166,10 +241,10 @@ public async Task AfterError_Is_Invoked_If_Initialization_Errors_Named_Provider( 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")); + providerMock.When(x => x.InitializeAsync(context, Arg.Any())).Throw(new Exception("BAD THINGS")); var callCount = 0; Exception? receivedError = null; - await repository.SetProviderAsync("the-provider", providerMock, context, afterInitError: (theProvider, error) => + await repository.SetProviderAsync("the-provider", providerMock, context, afterInitError: (theProvider, error, ct) => { Assert.Equal(providerMock, theProvider); callCount++; @@ -180,6 +255,32 @@ await repository.SetProviderAsync("the-provider", providerMock, context, afterIn Assert.Equal(1, callCount); } + [Fact] + public async Task AfterError_WithNamedProvider_Is_Invoked_With_CancellationToken() + { + var repository = new ProviderRepository(); + var providerMock = Substitute.For(); + providerMock.Status.Returns(ProviderStatus.NotReady); + + using var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + var context = new EvaluationContextBuilder().Build(); + providerMock.When(x => x.InitializeAsync(context, cancellationToken)).Throw(new Exception("BAD THINGS")); + + var errorCancellationToken = CancellationToken.None; + await repository.SetProviderAsync("the-provider", providerMock, context, afterInitError: (theProvider, error, ct) => + { + Assert.Equal(providerMock, theProvider); + + errorCancellationToken = ct; + + return Task.CompletedTask; + }, cancellationToken: cancellationToken); + + Assert.Equal(cancellationToken, errorCancellationToken); + } + [Theory] [InlineData(ProviderStatus.Ready)] [InlineData(ProviderStatus.Stale)] @@ -206,7 +307,7 @@ internal async Task AfterInitialize_Is_Not_Called_For_Ready_Named_Provider(Provi var context = new EvaluationContextBuilder().Build(); var callCount = 0; await repository.SetProviderAsync("the-name", providerMock, context, - afterInitSuccess: provider => + afterInitSuccess: (provider, ct) => { callCount++; return Task.CompletedTask; From df1765c7abc4e9e5f76954ddb361b3fd5bf0ddf7 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Tue, 18 Nov 2025 07:56:53 +0000 Subject: [PATCH 094/124] feat: Add disabled flag support to InMemoryProvider (#632) * Add support for disabling InMemoryProvider flags Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Enable disabled reason e2e tests Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Add example disabled in memory flag Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Address gemini code review comments Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- samples/AspNetCore/Program.cs | 5 + src/OpenFeature/Providers/Memory/Flag.cs | 29 ++++- .../Steps/ExcludedTagsStep.cs | 1 - .../Utils/FlagDictionaryJsonConverter.cs | 19 +-- .../Providers/Memory/InMemoryProviderTests.cs | 122 ++++++++++++++++++ 5 files changed, 163 insertions(+), 13 deletions(-) diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index 6651e4fb4..87238c158 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -52,6 +52,11 @@ "welcome-message", new Flag( new Dictionary { { "show", true }, { "hide", false } }, "show") }, + { + "disabled-flag", new Flag( + new Dictionary { { "on", "This flag is on" }, { "off", "This flag is off" } }, "off", + disabled: true) + }, { "test-config", new Flag(new Dictionary() { diff --git a/src/OpenFeature/Providers/Memory/Flag.cs b/src/OpenFeature/Providers/Memory/Flag.cs index 532611477..d4601f4d1 100644 --- a/src/OpenFeature/Providers/Memory/Flag.cs +++ b/src/OpenFeature/Providers/Memory/Flag.cs @@ -7,7 +7,13 @@ namespace OpenFeature.Providers.Memory; /// /// Flag representation for the in-memory provider. /// -public interface Flag; +public interface Flag +{ + /// + /// Indicates if the flag is disabled. When disabled, the flag will resolve to the default value. + /// + bool Disabled { get; } +} /// /// Flag representation for the in-memory provider. @@ -26,16 +32,33 @@ public sealed class Flag : Flag /// 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) + /// indicates if the flag is disabled + public Flag(Dictionary variants, string defaultVariant, Func? contextEvaluator = null, ImmutableMetadata? flagMetadata = null, bool disabled = false) { this._variants = variants; this._defaultVariant = defaultVariant; this._contextEvaluator = contextEvaluator; this._flagMetadata = flagMetadata; + this.Disabled = disabled; } - internal ResolutionDetails Evaluate(string flagKey, T _, EvaluationContext? evaluationContext) + /// + /// Indicates if the flag is disabled. When disabled, the flag will resolve to the default value. + /// + public bool Disabled { get; } + + internal ResolutionDetails Evaluate(string flagKey, T defaultValue, EvaluationContext? evaluationContext) { + if (this.Disabled) + { + return new ResolutionDetails( + flagKey, + defaultValue, + reason: Reason.Disabled, + flagMetadata: this._flagMetadata + ); + } + if (this._contextEvaluator == null) { return this.EvaluateDefaultVariant(flagKey); diff --git a/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs b/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs index 19b474db8..1037c1892 100644 --- a/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs +++ b/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs @@ -5,7 +5,6 @@ namespace OpenFeature.E2ETests.Steps; [Scope(Tag = "immutability")] [Scope(Tag = "async")] [Scope(Tag = "reason-codes-cached")] -[Scope(Tag = "reason-codes-disabled")] [Scope(Tag = "deprecated")] public class ExcludedTagsStep { diff --git a/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs b/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs index 153de67da..363bf1cba 100644 --- a/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs +++ b/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs @@ -50,7 +50,7 @@ private static Flag ReadFlag(string flagKey, JsonElement flagElement) if (inferredKind == null) throw new JsonException($"Flag '{flagKey}' has no variants"); - var defaultVariant = InferDefaultVariant(flagElement, variantsElement); + var defaultVariant = InferDefaultVariant(flagElement); var contextEvaluator = flagElement.TryGetProperty("contextEvaluator", out var ctxElem) && ctxElem.ValueKind == JsonValueKind.String ? ContextEvaluatorUtility.BuildContextEvaluator(ctxElem.GetString()!) @@ -60,15 +60,15 @@ private static Flag ReadFlag(string flagKey, JsonElement flagElement) ? BuildMetadata(metaElem) : null; - // NOTE: The current Flag type does not model 'disabled' + var disabled = flagElement.TryGetProperty("disabled", out var disabledElem) && disabledElem.ValueKind == JsonValueKind.True; return inferredKind switch { - VariantKind.Boolean => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetBoolean()), - VariantKind.Integer => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetInt32()), - VariantKind.Double => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetDouble()), - VariantKind.String => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetString()!), - VariantKind.Object => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, ExtractObjectVariant), + VariantKind.Boolean => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, disabled, static e => e.GetBoolean()), + VariantKind.Integer => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, disabled, static e => e.GetInt32()), + VariantKind.Double => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, disabled, static e => e.GetDouble()), + VariantKind.String => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, disabled, static e => e.GetString()!), + VariantKind.Object => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, disabled, ExtractObjectVariant), _ => throw new JsonException($"Unsupported variant kind for flag '{flagKey}'") }; } @@ -81,6 +81,7 @@ private static Flag BuildFlag( string? defaultVariant, Func? contextEvaluator, ImmutableMetadata? metadata, + bool? disabled, Func projector) { var dict = new Dictionary(StringComparer.Ordinal); @@ -88,10 +89,10 @@ private static Flag BuildFlag( { dict[v.Name] = projector(v.Value); } - return new Flag(dict, defaultVariant!, contextEvaluator, metadata); + return new Flag(dict, defaultVariant!, contextEvaluator, metadata, disabled ?? false); } - private static string? InferDefaultVariant(JsonElement flagElement, JsonElement variantsElement) + private static string? InferDefaultVariant(JsonElement flagElement) { if (flagElement.TryGetProperty("defaultVariant", out var dv)) { diff --git a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs index b60c1004e..52eace286 100644 --- a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs @@ -21,6 +21,16 @@ public InMemoryProviderTests() defaultVariant: "on" ) }, + { + "boolean-disabled-flag", new Flag( + disabled: true, + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "on" + ) + }, { "string-flag", new Flag( variants: new Dictionary(){ @@ -30,6 +40,16 @@ public InMemoryProviderTests() defaultVariant: "greeting" ) }, + { + "string-disabled-flag", new Flag( + disabled: true, + variants: new Dictionary(){ + { "greeting", "hi" }, + { "parting", "bye" } + }, + defaultVariant: "greeting" + ) + }, { "integer-flag", new Flag( variants: new Dictionary(){ @@ -39,6 +59,16 @@ public InMemoryProviderTests() defaultVariant: "ten" ) }, + { + "integer-disabled-flag", new Flag( + disabled: true, + variants: new Dictionary(){ + { "one", 1 }, + { "ten", 10 } + }, + defaultVariant: "ten" + ) + }, { "float-flag", new Flag( variants: new Dictionary(){ @@ -48,6 +78,16 @@ public InMemoryProviderTests() defaultVariant: "half" ) }, + { + "float-disabled-flag", new Flag( + disabled: true, + variants: new Dictionary(){ + { "tenth", 0.1 }, + { "half", 0.5 } + }, + defaultVariant: "half" + ) + }, { "context-aware", new Flag( variants: new Dictionary(){ @@ -78,6 +118,21 @@ public InMemoryProviderTests() defaultVariant: "template" ) }, + { + "object-disabled-flag", new Flag( + disabled: true, + 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(){ @@ -137,6 +192,18 @@ public async Task GetBoolean_WithNoEvaluationContext_ShouldEvaluateWithReasonAnd Assert.Equal("on", details.Variant); } + [Fact] + public async Task GetBoolean_WhenDisabled_ShouldEvaluateWithDefaultReason() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveBooleanValueAsync("boolean-disabled-flag", false, EvaluationContext.Empty); + + // Assert + Assert.False(details.Value); + Assert.Equal(Reason.Disabled, details.Reason); + Assert.Null(details.Variant); + } + [Fact] public async Task GetString_ShouldEvaluateWithReasonAndVariant() { @@ -158,6 +225,18 @@ public async Task GetString_WithNoEvaluationContext_ShouldEvaluateWithReasonAndV Assert.Equal("greeting", details.Variant); } + [Fact] + public async Task GetString_WhenDisabled_ShouldEvaluateWithDefaultReason() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveStringValueAsync("string-disabled-flag", "nope"); + + // Assert + Assert.Equal("nope", details.Value); + Assert.Equal(Reason.Disabled, details.Reason); + Assert.Null(details.Variant); + } + [Fact] public async Task GetInt_ShouldEvaluateWithReasonAndVariant() { @@ -179,6 +258,18 @@ public async Task GetInt_WithNoEvaluationContext_ShouldEvaluateWithReasonAndVari Assert.Equal("ten", details.Variant); } + [Fact] + public async Task GetInt_WhenDisabled_ShouldEvaluateWithDefaultReason() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveIntegerValueAsync("integer-disabled-flag", 13); + + // Assert + Assert.Equal(13, details.Value); + Assert.Equal(Reason.Disabled, details.Reason); + Assert.Null(details.Variant); + } + [Fact] public async Task GetDouble_ShouldEvaluateWithReasonAndVariant() { @@ -200,6 +291,18 @@ public async Task GetDouble_WithNoEvaluationContext_ShouldEvaluateWithReasonAndV Assert.Equal("half", details.Variant); } + [Fact] + public async Task GetDouble_WhenDisabled_ShouldEvaluateWithDefaultReason() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveDoubleValueAsync("float-disabled-flag", 1.3); + + // Assert + Assert.Equal(1.3, details.Value); + Assert.Equal(Reason.Disabled, details.Reason); + Assert.Null(details.Variant); + } + [Fact] public async Task GetStruct_ShouldEvaluateWithReasonAndVariant() { @@ -225,6 +328,25 @@ public async Task GetStruct_WithNoEvaluationContext_ShouldEvaluateWithReasonAndV Assert.Equal("template", details.Variant); } + [Fact] + public async Task GetStruct_WhenDisabled_ShouldEvaluateWithDefaultReason() + { + // Arrange + var defaultValue = new Value( + Structure.Builder() + .Set("default", true) + .Build() + ); + + // Act + ResolutionDetails details = await this.commonProvider.ResolveStructureValueAsync("object-disabled-flag", defaultValue); + + // Assert + Assert.Equal(true, details.Value.AsStructure?["default"].AsBoolean); + Assert.Equal(Reason.Disabled, details.Reason); + Assert.Null(details.Variant); + } + [Fact] public async Task GetString_ContextSensitive_ShouldEvaluateWithReasonAndVariant() { From a36a9067102a70f80e7837ce18d287430c7452fc Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Tue, 18 Nov 2025 07:56:59 +0000 Subject: [PATCH 095/124] refactor: Remove deprecated Dependency Injection code (#626) * Remove deprecated Dependency Injection code Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Remove OpenFeature.DependencyInjection references Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Remove DependencyInjection README reference Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- .github/workflows/release.yml | 8 - OpenFeature.slnx | 2 - release-please-config.json | 3 +- .../Diagnostics/FeatureCodes.cs | 38 -- src/OpenFeature.DependencyInjection/Guard.cs | 20 - .../IFeatureLifecycleManager.cs | 24 -- .../Internal/EventHandlerDelegateWrapper.cs | 8 - .../Internal/FeatureLifecycleManager.cs | 66 --- .../CallerArgumentExpressionAttribute.cs | 23 - .../MultiTarget/IsExternalInit.cs | 21 - .../OpenFeature.DependencyInjection.csproj | 23 - .../OpenFeatureBuilder.cs | 60 --- .../OpenFeatureBuilderExtensions.cs | 382 ----------------- .../OpenFeatureOptions.cs | 61 --- .../OpenFeatureServiceCollectionExtensions.cs | 61 --- .../PolicyNameOptions.cs | 12 - .../Memory/FeatureBuilderExtensions.cs | 126 ------ .../Memory/InMemoryProviderOptions.cs | 19 - src/OpenFeature/OpenFeature.csproj | 1 - .../OpenFeature.AotCompatibility.csproj | 2 +- .../FeatureLifecycleManagerTests.cs | 124 ------ .../NoOpFeatureProvider.cs | 52 --- .../NoOpHook.cs | 26 -- .../NoOpProvider.cs | 8 - ...enFeature.DependencyInjection.Tests.csproj | 31 -- .../OpenFeatureBuilderExtensionsTests.cs | 392 ------------------ ...FeatureServiceCollectionExtensionsTests.cs | 37 -- 27 files changed, 2 insertions(+), 1628 deletions(-) delete mode 100644 src/OpenFeature.DependencyInjection/Diagnostics/FeatureCodes.cs delete mode 100644 src/OpenFeature.DependencyInjection/Guard.cs delete mode 100644 src/OpenFeature.DependencyInjection/IFeatureLifecycleManager.cs delete mode 100644 src/OpenFeature.DependencyInjection/Internal/EventHandlerDelegateWrapper.cs delete mode 100644 src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs delete mode 100644 src/OpenFeature.DependencyInjection/MultiTarget/CallerArgumentExpressionAttribute.cs delete mode 100644 src/OpenFeature.DependencyInjection/MultiTarget/IsExternalInit.cs delete mode 100644 src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj delete mode 100644 src/OpenFeature.DependencyInjection/OpenFeatureBuilder.cs delete mode 100644 src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs delete mode 100644 src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs delete mode 100644 src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs delete mode 100644 src/OpenFeature.DependencyInjection/PolicyNameOptions.cs delete mode 100644 src/OpenFeature.DependencyInjection/Providers/Memory/FeatureBuilderExtensions.cs delete mode 100644 src/OpenFeature.DependencyInjection/Providers/Memory/InMemoryProviderOptions.cs delete mode 100644 test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs delete mode 100644 test/OpenFeature.DependencyInjection.Tests/NoOpFeatureProvider.cs delete mode 100644 test/OpenFeature.DependencyInjection.Tests/NoOpHook.cs delete mode 100644 test/OpenFeature.DependencyInjection.Tests/NoOpProvider.cs delete mode 100644 test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj delete mode 100644 test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs delete mode 100644 test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a3ec09af..4d4e2b94a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,14 +93,6 @@ jobs: project-name: OpenFeature.Hosting release-tag: ${{ needs.release-please.outputs.release_tag_name }} - # 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 }} - # Process OpenFeature.Providers.MultiProvider project - name: Generate and Attest SBOM for OpenFeature.Providers.MultiProvider uses: ./.github/actions/sbom-generator diff --git a/OpenFeature.slnx b/OpenFeature.slnx index 936079f40..db8f40024 100644 --- a/OpenFeature.slnx +++ b/OpenFeature.slnx @@ -48,7 +48,6 @@ - @@ -58,7 +57,6 @@ - diff --git a/release-please-config.json b/release-please-config.json index 6baeed441..1f778ed73 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -8,8 +8,7 @@ "versioning": "default", "extra-files": [ "build/Common.prod.props", - "README.md", - "src/OpenFeature.DependencyInjection/README.md" + "README.md" ] } }, diff --git a/src/OpenFeature.DependencyInjection/Diagnostics/FeatureCodes.cs b/src/OpenFeature.DependencyInjection/Diagnostics/FeatureCodes.cs deleted file mode 100644 index 582ab39c9..000000000 --- a/src/OpenFeature.DependencyInjection/Diagnostics/FeatureCodes.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace OpenFeature.DependencyInjection.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.DependencyInjection/Guard.cs b/src/OpenFeature.DependencyInjection/Guard.cs deleted file mode 100644 index 337a8290f..000000000 --- a/src/OpenFeature.DependencyInjection/Guard.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; - -namespace OpenFeature.DependencyInjection; - -[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); - } - - public static void ThrowIfNullOrWhiteSpace(string? argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null) - { - if (string.IsNullOrWhiteSpace(argument)) - throw new ArgumentNullException(paramName); - } -} diff --git a/src/OpenFeature.DependencyInjection/IFeatureLifecycleManager.cs b/src/OpenFeature.DependencyInjection/IFeatureLifecycleManager.cs deleted file mode 100644 index 4891f2e8b..000000000 --- a/src/OpenFeature.DependencyInjection/IFeatureLifecycleManager.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace OpenFeature.DependencyInjection; - -/// -/// 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.DependencyInjection/Internal/EventHandlerDelegateWrapper.cs b/src/OpenFeature.DependencyInjection/Internal/EventHandlerDelegateWrapper.cs deleted file mode 100644 index d31b3355c..000000000 --- a/src/OpenFeature.DependencyInjection/Internal/EventHandlerDelegateWrapper.cs +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 1ecac4349..000000000 --- a/src/OpenFeature.DependencyInjection/Internal/FeatureLifecycleManager.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace OpenFeature.DependencyInjection.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.DependencyInjection/MultiTarget/CallerArgumentExpressionAttribute.cs b/src/OpenFeature.DependencyInjection/MultiTarget/CallerArgumentExpressionAttribute.cs deleted file mode 100644 index afbec6b06..000000000 --- a/src/OpenFeature.DependencyInjection/MultiTarget/CallerArgumentExpressionAttribute.cs +++ /dev/null @@ -1,23 +0,0 @@ -// @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.DependencyInjection/MultiTarget/IsExternalInit.cs b/src/OpenFeature.DependencyInjection/MultiTarget/IsExternalInit.cs deleted file mode 100644 index 877141115..000000000 --- a/src/OpenFeature.DependencyInjection/MultiTarget/IsExternalInit.cs +++ /dev/null @@ -1,21 +0,0 @@ -// @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.DependencyInjection/OpenFeature.DependencyInjection.csproj b/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj deleted file mode 100644 index afefeb9a9..000000000 --- a/src/OpenFeature.DependencyInjection/OpenFeature.DependencyInjection.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - OpenFeature.DependencyInjection - README.md - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureBuilder.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilder.cs deleted file mode 100644 index ae1e8c8fb..000000000 --- a/src/OpenFeature.DependencyInjection/OpenFeatureBuilder.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace OpenFeature.DependencyInjection; - -/// -/// 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.DependencyInjection/OpenFeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs deleted file mode 100644 index d676dc5e9..000000000 --- a/src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs +++ /dev/null @@ -1,382 +0,0 @@ -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; - -/// -/// Contains extension methods for the class. -/// -#if NET8_0_OR_GREATER -[System.Diagnostics.CodeAnalysis.Experimental(DependencyInjection.Diagnostics.FeatureCodes.NewDi)] -#endif -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; - } -} diff --git a/src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs deleted file mode 100644 index e9cc3cb12..000000000 --- a/src/OpenFeature.DependencyInjection/OpenFeatureOptions.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace OpenFeature.DependencyInjection; - -/// -/// 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.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs b/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs deleted file mode 100644 index a24c67e78..000000000 --- a/src/OpenFeature.DependencyInjection/OpenFeatureServiceCollectionExtensions.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; -using OpenFeature.DependencyInjection; -using OpenFeature.DependencyInjection.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. - var api = new Api(); - Api.SetInstance(api); - services.TryAddSingleton(api); - services.TryAddSingleton(); - - var builder = new OpenFeatureBuilder(services); - configure(builder); - - // 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.DependencyInjection/PolicyNameOptions.cs b/src/OpenFeature.DependencyInjection/PolicyNameOptions.cs deleted file mode 100644 index f77b019b1..000000000 --- a/src/OpenFeature.DependencyInjection/PolicyNameOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace OpenFeature.DependencyInjection; - -/// -/// 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.DependencyInjection/Providers/Memory/FeatureBuilderExtensions.cs b/src/OpenFeature.DependencyInjection/Providers/Memory/FeatureBuilderExtensions.cs deleted file mode 100644 index d6346ad78..000000000 --- a/src/OpenFeature.DependencyInjection/Providers/Memory/FeatureBuilderExtensions.cs +++ /dev/null @@ -1,126 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using OpenFeature.Providers.Memory; - -namespace OpenFeature.DependencyInjection.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) - => AddInMemoryProvider(builder, 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.DependencyInjection/Providers/Memory/InMemoryProviderOptions.cs b/src/OpenFeature.DependencyInjection/Providers/Memory/InMemoryProviderOptions.cs deleted file mode 100644 index ea5433f4e..000000000 --- a/src/OpenFeature.DependencyInjection/Providers/Memory/InMemoryProviderOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -using OpenFeature.Providers.Memory; - -namespace OpenFeature.DependencyInjection.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/src/OpenFeature/OpenFeature.csproj b/src/OpenFeature/OpenFeature.csproj index a9f1448c6..5a7aec847 100644 --- a/src/OpenFeature/OpenFeature.csproj +++ b/src/OpenFeature/OpenFeature.csproj @@ -22,7 +22,6 @@ - diff --git a/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj b/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj index 823d96f0e..42cb2770f 100644 --- a/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj +++ b/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj @@ -1,4 +1,4 @@ - + net9.0 diff --git a/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs b/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs deleted file mode 100644 index 8dc6a80bc..000000000 --- a/test/OpenFeature.DependencyInjection.Tests/FeatureLifecycleManagerTests.cs +++ /dev/null @@ -1,124 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging.Abstractions; -using OpenFeature.Constant; -using OpenFeature.DependencyInjection.Internal; -using OpenFeature.Model; - -namespace OpenFeature.DependencyInjection.Tests; - -public class FeatureLifecycleManagerTests -{ - private readonly IServiceCollection _serviceCollection; - - public FeatureLifecycleManagerTests() - { - Api.Instance.SetContext(null); - Api.Instance.ClearHooks(); - - _serviceCollection = new ServiceCollection() - .Configure(options => - { - options.AddDefaultProviderName(); - }); - } - - [Fact] - public async Task EnsureInitializedAsync_ShouldLogAndSetProvider_WhenProviderExists() - { - // Arrange - var featureProvider = new NoOpFeatureProvider(); - _serviceCollection.AddSingleton(featureProvider); - - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger.Instance); - - // Act - await sut.EnsureInitializedAsync().ConfigureAwait(true); - - // Assert - Assert.Equal(featureProvider, Api.Instance.GetProvider()); - } - - [Fact] - public async Task EnsureInitializedAsync_ShouldThrowException_WhenProviderDoesNotExist() - { - // Arrange - _serviceCollection.RemoveAll(); - - var serviceProvider = _serviceCollection.BuildServiceProvider(); - var sut = new FeatureLifecycleManager(Api.Instance, serviceProvider, NullLogger.Instance); - - // Act - 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); - } - - [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/NoOpFeatureProvider.cs b/test/OpenFeature.DependencyInjection.Tests/NoOpFeatureProvider.cs deleted file mode 100644 index ac3e52096..000000000 --- a/test/OpenFeature.DependencyInjection.Tests/NoOpFeatureProvider.cs +++ /dev/null @@ -1,52 +0,0 @@ -using OpenFeature.Model; - -namespace OpenFeature.DependencyInjection.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.DependencyInjection.Tests/NoOpHook.cs b/test/OpenFeature.DependencyInjection.Tests/NoOpHook.cs deleted file mode 100644 index cee6ef1df..000000000 --- a/test/OpenFeature.DependencyInjection.Tests/NoOpHook.cs +++ /dev/null @@ -1,26 +0,0 @@ -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/NoOpProvider.cs b/test/OpenFeature.DependencyInjection.Tests/NoOpProvider.cs deleted file mode 100644 index 7bf20bcac..000000000 --- a/test/OpenFeature.DependencyInjection.Tests/NoOpProvider.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OpenFeature.DependencyInjection.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.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj b/test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj deleted file mode 100644 index d6bce29e8..000000000 --- a/test/OpenFeature.DependencyInjection.Tests/OpenFeature.DependencyInjection.Tests.csproj +++ /dev/null @@ -1,31 +0,0 @@ - - - - net8.0;net9.0 - - - - - - - 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.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs deleted file mode 100644 index f7cce0dfc..000000000 --- a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureBuilderExtensionsTests.cs +++ /dev/null @@ -1,392 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using OpenFeature.DependencyInjection.Internal; -using OpenFeature.Model; - -namespace OpenFeature.DependencyInjection.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 - bool 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 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); - } -} diff --git a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs b/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs deleted file mode 100644 index ddda3f224..000000000 --- a/test/OpenFeature.DependencyInjection.Tests/OpenFeatureServiceCollectionExtensionsTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; - -namespace OpenFeature.DependencyInjection.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()); - } -} From 728781de0b0df4ad632d1b0c04f03ae12ee92a32 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:47:07 +0000 Subject: [PATCH 096/124] chore(deps): update actions/checkout digest to 93cb6ef (#642) 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/codeql-analysis.yml | 2 +- .github/workflows/dotnet-format.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/release.yml | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/aot-compatibility.yml b/.github/workflows/aot-compatibility.yml index 1ed51794f..b3aa57f84 100644 --- a/.github/workflows/aot-compatibility.yml +++ b/.github/workflows/aot-compatibility.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 submodules: recursive diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf9d3c36a..50a7d8467 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 submodules: recursive @@ -67,7 +67,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 submodules: recursive diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index e6c2518c0..8e54c4745 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e09e56b71..86cf64e8b 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 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 f7857ea64..e7d1ae6e2 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Setup .NET SDK uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index da2edad27..8950c4a88 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d4e2b94a..b76c2720f 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 From 9b77322f8991f2a44b0e8fc94c593d53a7f19e0d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:47:57 +0000 Subject: [PATCH 097/124] chore(deps): update dependency opentelemetry.instrumentation.aspnetcore to 1.14.0 (#645) 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 fc3fe17c0..7e2dbaffd 100644 --- a/samples/AspNetCore/Samples.AspNetCore.csproj +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -14,7 +14,7 @@ - + From dbb1f33acc3f1584b538cfaba6b7048f683b7775 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:48:43 +0000 Subject: [PATCH 098/124] chore(deps): update spec digest to 3622b5c (#644) 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 9c1fde2cf..3622b5cd0 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 9c1fde2cf7160b06d879bc1da71226b058edd751 +Subproject commit 3622b5cd06e40494935e5e183e525cd76b4d0963 From 27839384dd48a3e42c8a776b75fd94779b453e86 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:49:45 +0000 Subject: [PATCH 099/124] chore(deps): update github/codeql-action digest to 014f16e (#643) 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 86cf64e8b..d8c9e2d35 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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4 + uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # 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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4 + uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # 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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4 + uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4 From 6b6f4782fab130eb7ae04a20d67644acddaf4e80 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 11:03:39 +0000 Subject: [PATCH 100/124] chore(deps): update opentelemetry-dotnet monorepo to 1.14.0 (#646) 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 6f94afade..f9637989e 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 7e2dbaffd..723bbd4bd 100644 --- a/samples/AspNetCore/Samples.AspNetCore.csproj +++ b/samples/AspNetCore/Samples.AspNetCore.csproj @@ -13,9 +13,9 @@ - + - + From a1f7ff6434842ff051e32af5c787e1bf40a5cb66 Mon Sep 17 00:00:00 2001 From: Kyle <38759683+kylejuliandev@users.noreply.github.com> Date: Tue, 18 Nov 2025 20:23:02 +0000 Subject: [PATCH 101/124] feat: Add optional CancellationToken parameter to SetProviderAsync (#638) * Add CancellationToken optional parameter to SetProviderAsync Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Address gemini review comments Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> * Address API review comments Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --------- Signed-off-by: Kyle Julian <38759683+kylejuliandev@users.noreply.github.com> --- src/OpenFeature/Api.cs | 37 +++++++++++++++++++--- test/OpenFeature.Tests/OpenFeatureTests.cs | 37 ++++++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/OpenFeature/Api.cs b/src/OpenFeature/Api.cs index 33a7c79d9..3532e99e5 100644 --- a/src/OpenFeature/Api.cs +++ b/src/OpenFeature/Api.cs @@ -40,12 +40,39 @@ internal Api() { } ///
/// 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) + /// A that completes once Provider initialization is complete. + public Task SetProviderAsync(FeatureProvider featureProvider) + { + return this.SetProviderAsync(featureProvider, CancellationToken.None); + } + + /// + /// 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 + /// Propagates notification that the provider initialization should be canceled. + /// A that completes once Provider initialization is complete. + public async Task SetProviderAsync(FeatureProvider featureProvider, CancellationToken cancellationToken) { this._eventExecutor.RegisterDefaultFeatureProvider(featureProvider); - await this._repository.SetProviderAsync(featureProvider, this.GetContext(), this.AfterInitializationAsync, this.AfterErrorAsync) + await this._repository.SetProviderAsync(featureProvider, this.GetContext(), this.AfterInitializationAsync, this.AfterErrorAsync, cancellationToken) .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 + /// A that completes once Provider initialization is complete. + public Task SetProviderAsync(string domain, FeatureProvider featureProvider) + { + return this.SetProviderAsync(domain, featureProvider, CancellationToken.None); } /// @@ -55,15 +82,17 @@ await this._repository.SetProviderAsync(featureProvider, this.GetContext(), this /// 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 + /// Propagates notification that the provider initialization should be canceled. /// domain cannot be null or empty - public async Task SetProviderAsync(string domain, FeatureProvider featureProvider) + /// A that completes once Provider initialization is complete. + public async Task SetProviderAsync(string domain, FeatureProvider featureProvider, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(domain)) { throw new ArgumentNullException(nameof(domain)); } this._eventExecutor.RegisterClientFeatureProvider(domain, featureProvider); - await this._repository.SetProviderAsync(domain, featureProvider, this.GetContext(), this.AfterInitializationAsync, this.AfterErrorAsync) + await this._repository.SetProviderAsync(domain, featureProvider, this.GetContext(), this.AfterInitializationAsync, this.AfterErrorAsync, cancellationToken) .ConfigureAwait(false); } diff --git a/test/OpenFeature.Tests/OpenFeatureTests.cs b/test/OpenFeature.Tests/OpenFeatureTests.cs index 9eb0aa40b..835406ef4 100644 --- a/test/OpenFeature.Tests/OpenFeatureTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureTests.cs @@ -34,6 +34,43 @@ public async Task OpenFeature_Should_Initialize_Provider() await providerMockNamed.Received(1).InitializeAsync(Api.Instance.GetContext()); } + [Fact] + public async Task OpenFeature_Should_Initialize_Provider_WithCancellationToken() + { + var providerMockDefault = Substitute.For(); + providerMockDefault.Status.Returns(ProviderStatus.NotReady); + + using var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + await Api.Instance.SetProviderAsync(providerMockDefault, cancellationToken); + await providerMockDefault.Received(1).InitializeAsync(Api.Instance.GetContext(), cancellationToken); + + var providerMockNamed = Substitute.For(); + providerMockNamed.Status.Returns(ProviderStatus.NotReady); + + await Api.Instance.SetProviderAsync("the-name", providerMockNamed, cancellationToken); + await providerMockNamed.Received(1).InitializeAsync(Api.Instance.GetContext(), cancellationToken); + } + + [Fact] + public async Task OpenFeature_Should_Handle_Cancellation_During_Initialization() + { + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + var cancellationToken = cancellationTokenSource.Token; + + var providerMockDefault = Substitute.For(); + providerMockDefault.InitializeAsync(Arg.Any(), cancellationToken) + .Returns(ci => Task.FromCanceled(cancellationToken)); + + await Assert.ThrowsAsync(() => + Api.Instance.SetProviderAsync(providerMockDefault, cancellationToken)); + + await providerMockDefault.Received(1).InitializeAsync(Api.Instance.GetContext(), cancellationToken); + Assert.Equal(ProviderStatus.Error, providerMockDefault.Status); + } + [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.")] From a93bf09aeb89fc709bf86ba5e15f7e1e75479a70 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 09:27:46 +0000 Subject: [PATCH 102/124] chore(deps): update github/codeql-action digest to e12f017 (#650) 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 d8c9e2d35..0f8e3f0c7 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@014f16e7ab1402f30e7c3329d33797e7948572db # v4 + uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # 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@014f16e7ab1402f30e7c3329d33797e7948572db # v4 + uses: github/codeql-action/autobuild@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # 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@014f16e7ab1402f30e7c3329d33797e7948572db # v4 + uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4 From 5972aff225b9d046da9fd37fbcd4bf34cc766d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Fri, 21 Nov 2025 16:44:35 +0000 Subject: [PATCH 103/124] ci: Update SDK version and rollForward policy (#649) 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> --- global.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/global.json b/global.json index 3e42ca302..5fb240dd3 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "rollForward": "disable", - "version": "9.0.306", + "rollForward": "latestFeature", + "version": "9.0.300", "allowPrerelease": false } -} \ No newline at end of file +} From 13ff031b179c721ecf314c015ba3f03259b256b8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:04:15 +0000 Subject: [PATCH 104/124] chore(deps): update actions/setup-dotnet digest to 2016bd2 (#651) 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 b3aa57f84..70b0a45ee 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@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 + uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5 with: global-json-file: global.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50a7d8467..bf0ecdcd6 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@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 + uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5 with: global-json-file: global.json @@ -73,7 +73,7 @@ jobs: submodules: recursive - name: Setup .NET SDK - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 + uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5 with: global-json-file: global.json diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 8e54c4745..988d3cf02 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@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 + uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5 with: global-json-file: global.json diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index e7d1ae6e2..cd46fe969 100644 --- a/.github/workflows/dotnet-format.yml +++ b/.github/workflows/dotnet-format.yml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - name: Setup .NET SDK - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 + uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5 with: global-json-file: global.json diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8950c4a88..f47c74136 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@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 + uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5 with: global-json-file: global.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b76c2720f..70bb6c693 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@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5 + uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5 with: global-json-file: global.json From e2df717959323b256f14bf22afc065d829484b72 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:04:31 +0000 Subject: [PATCH 105/124] chore(deps): update github/codeql-action digest to fdbfb4d (#652) 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 0f8e3f0c7..bf0d38049 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@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4 + uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # 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@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4 + uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # 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@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4 + uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4 From dec29c143f1db3d07eb82efccada738235009f84 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:04:46 +0000 Subject: [PATCH 106/124] chore(deps): update spec digest to 6db4f2f (#653) 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 3622b5cd0..6db4f2fee 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 3622b5cd06e40494935e5e183e525cd76b4d0963 +Subproject commit 6db4f2feec38926ba46978bacd4a228cf175dafa From c42708e8b5985c09b1e28971e9393f01ff5e4b41 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:05:06 +0000 Subject: [PATCH 107/124] chore(deps): update actions/checkout action to v6 (#654) 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/codeql-analysis.yml | 2 +- .github/workflows/dotnet-format.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/release.yml | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/aot-compatibility.yml b/.github/workflows/aot-compatibility.yml index 70b0a45ee..20ca24a22 100644 --- a/.github/workflows/aot-compatibility.yml +++ b/.github/workflows/aot-compatibility.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 with: fetch-depth: 0 submodules: recursive diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf0ecdcd6..6f3f5c8eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 with: fetch-depth: 0 submodules: recursive @@ -67,7 +67,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 with: fetch-depth: 0 submodules: recursive diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 988d3cf02..67d2e629b 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 with: fetch-depth: 0 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index bf0d38049..ee4dc7f53 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index cd46fe969..170886165 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - name: Setup .NET SDK uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f47c74136..d975ca0bd 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70bb6c693..7cd116145 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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 with: fetch-depth: 0 From 261e04bc2f61944124210540982d9a564281bce7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:05:21 +0000 Subject: [PATCH 108/124] chore(deps): update dependency githubactionstestlogger to v3 (#655) 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 f9637989e..678496989 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,7 +33,7 @@ - + From 7e4f6d96cf26018ca1506a4eca5ba503a3fad65e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 09:34:24 +0000 Subject: [PATCH 109/124] chore(deps): update spec digest to 3fc2e49 (#656) 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 6db4f2fee..3fc2e4949 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 6db4f2feec38926ba46978bacd4a228cf175dafa +Subproject commit 3fc2e4949e53f761daf3e1f7197678481015fdf0 From d633b273c1a5b219e68a09d69ef02a3f8cb9ed1f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 09:34:30 +0000 Subject: [PATCH 110/124] chore(deps): update dependency benchmarkdotnet to 0.15.8 (#657) 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 678496989..58b3afa57 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,7 +30,7 @@ - + From 4080f0395653df985737812e1928e8241235ba7c Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Mon, 1 Dec 2025 13:27:36 -0500 Subject: [PATCH 111/124] chore(main): release 2.10.0 (#613) Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 27 +++++++++++++++++++++++++++ README.md | 4 ++-- build/Common.prod.props | 2 +- version.txt | 2 +- 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a3906fc08..f393718c9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.9.0" + ".": "2.10.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index bdbe5b608..59501e851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [2.10.0](https://github.com/open-feature/dotnet-sdk/compare/v2.9.0...v2.10.0) (2025-12-01) + + +### 🐛 Bug Fixes + +* Address issue with FeatureClient not being resolved when no Provider added ([#607](https://github.com/open-feature/dotnet-sdk/issues/607)) ([a8d12ef](https://github.com/open-feature/dotnet-sdk/commit/a8d12ef12d75aaa770551b3052cd8725b65b5fd8)) +* Address issues when evaluating the context in the InMemoryProvider ([#615](https://github.com/open-feature/dotnet-sdk/issues/615)) ([94fcdc1](https://github.com/open-feature/dotnet-sdk/commit/94fcdc142c61f41619af222778d6d84264f2831c)) +* Ensure AddPolicyName without adding a Provider does not get stuck in infinite loop ([#606](https://github.com/open-feature/dotnet-sdk/issues/606)) ([4b965dd](https://github.com/open-feature/dotnet-sdk/commit/4b965dddcaeef761e01f8fcbd28941ae3f3074c9)) +* Ensure EvaluationContext is reliably added to the injected FeatureClient ([#605](https://github.com/open-feature/dotnet-sdk/issues/605)) ([c987b58](https://github.com/open-feature/dotnet-sdk/commit/c987b58b66c8186486fd06aebdc4042052f30beb)) + + +### ✨ New Features + +* Add DI for multi provider ([#621](https://github.com/open-feature/dotnet-sdk/issues/621)) ([ee862f0](https://github.com/open-feature/dotnet-sdk/commit/ee862f09cb2c58f43f84957fa95e8b25e8e36f72)) +* Add disabled flag support to InMemoryProvider ([#632](https://github.com/open-feature/dotnet-sdk/issues/632)) ([df1765c](https://github.com/open-feature/dotnet-sdk/commit/df1765c7abc4e9e5f76954ddb361b3fd5bf0ddf7)) +* Add optional CancellationToken parameter to SetProviderAsync ([#638](https://github.com/open-feature/dotnet-sdk/issues/638)) ([a1f7ff6](https://github.com/open-feature/dotnet-sdk/commit/a1f7ff6434842ff051e32af5c787e1bf40a5cb66)) +* Add SourceLink configuration for .NET SDK 8+ to enhance debugging experience ([1b40391](https://github.com/open-feature/dotnet-sdk/commit/1b40391034b0762aa755a05374a908eb97cdf444)) +* Add SourceLink configuration for .NET to enhance debugging experience ([#614](https://github.com/open-feature/dotnet-sdk/issues/614)) ([1b40391](https://github.com/open-feature/dotnet-sdk/commit/1b40391034b0762aa755a05374a908eb97cdf444)) +* Add tracking to multi-provider ([#612](https://github.com/open-feature/dotnet-sdk/issues/612)) ([186b357](https://github.com/open-feature/dotnet-sdk/commit/186b3574702258fb33716162094888b9f7560c7c)) + + +### 🔧 Refactoring + +* Clean up project files by removing TargetFrameworks and formatting ([#611](https://github.com/open-feature/dotnet-sdk/issues/611)) ([dfbc3ee](https://github.com/open-feature/dotnet-sdk/commit/dfbc3eef1f7468dc363c71fef1eb1f42e1bb8a88)) +* Pass cancellation tokens to Provider Initialization functions ([#640](https://github.com/open-feature/dotnet-sdk/issues/640)) ([8b472d8](https://github.com/open-feature/dotnet-sdk/commit/8b472d8ccd1367ba82a2ab39ad7a77b1a6609ce0)) +* Remove deprecated Dependency Injection code ([#626](https://github.com/open-feature/dotnet-sdk/issues/626)) ([a36a906](https://github.com/open-feature/dotnet-sdk/commit/a36a9067102a70f80e7837ce18d287430c7452fc)) + ## [2.9.0](https://github.com/open-feature/dotnet-sdk/compare/v2.8.1...v2.9.0) (2025-10-16) diff --git a/README.md b/README.md index 4eb02b551..ae4253285 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.9.0&color=blue&style=for-the-badge) -](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.9.0) +![Release](https://img.shields.io/static/v1?label=release&message=v2.10.0&color=blue&style=for-the-badge) +](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.10.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 c9f732646..bfe58d3d1 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -9,7 +9,7 @@ - 2.9.0 + 2.10.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 c8e38b614..10c2c0c3d 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.9.0 +2.10.0 From c7be7e0fbff694ec9ef794548f6e7a478412b68b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Silva?= <2493377+askpt@users.noreply.github.com> Date: Wed, 3 Dec 2025 08:40:47 +0000 Subject: [PATCH 112/124] feat: Upgrade to dotnet version 10 (#658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): upgrade .NET SDK version to 10.0.100 Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore(deps): add .NET 10.0 to target frameworks Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore(deps): update target frameworks to include .NET 10.0 Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore(deps): upgrade target framework to .NET 10.0 Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore(deps): upgrade devcontainer image and .NET version to 10.0 Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore(deps): add support for .NET 10.0 and update related package versions Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore(docs): update AOT compatibility documentation to include .NET 10.0 in target framework options Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore(deps): update AOT compatibility test to target .NET 10.0 Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore(deps): update Microsoft.Extensions.Diagnostics.Testing to version 10.0.0 and upgrade Microsoft.AspNetCore.TestHost versions for net8.0 and net9.0 Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> * chore(deps): update dotnet feature configuration to use dotnetRuntimeVersions for versioning Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --------- Signed-off-by: André Silva <2493377+askpt@users.noreply.github.com> --- .devcontainer/devcontainer.json | 8 ++++---- .github/workflows/aot-compatibility.yml | 1 + Directory.Packages.props | 13 ++++++++++--- build/Common.samples.props | 2 +- docs/AOT_COMPATIBILITY.md | 2 +- global.json | 2 +- src/Directory.Build.props | 4 ++-- test/Directory.Build.props | 4 ++++ .../OpenFeature.AotCompatibility.csproj | 2 +- .../OpenFeature.Benchmarks.csproj | 2 +- .../OpenFeature.E2ETests.csproj | 2 -- .../OpenFeature.Hosting.Tests.csproj | 2 -- .../OpenFeature.IntegrationTests.csproj | 2 +- ...OpenFeature.Providers.MultiProvider.Tests.csproj | 2 -- test/OpenFeature.Tests/OpenFeature.Tests.csproj | 2 -- 15 files changed, 27 insertions(+), 23 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c92d1a789..2ec7a6cb2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,10 +1,10 @@ { "name": "OpenFeature .NET SDK", - "image": "mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm", + "image": "mcr.microsoft.com/devcontainers/dotnet:10.0", "features": { "ghcr.io/devcontainers/features/dotnet:latest": { - "version": "9.0", - "additionalVersions": "8.0" + "version": "10.0", + "dotnetRuntimeVersions": "9.0, 8.0" }, "ghcr.io/devcontainers/features/github-cli:latest": {}, "ghcr.io/devcontainers/features/docker-in-docker": {} @@ -30,4 +30,4 @@ "memory": "8gb" }, "postCreateCommand": "git submodule update --init --recursive" -} +} \ No newline at end of file diff --git a/.github/workflows/aot-compatibility.yml b/.github/workflows/aot-compatibility.yml index 20ca24a22..214b06db8 100644 --- a/.github/workflows/aot-compatibility.yml +++ b/.github/workflows/aot-compatibility.yml @@ -81,6 +81,7 @@ jobs: shell: pwsh run: | dotnet publish test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj ` + -f net10.0 ` -r ${{ matrix.runtime }} ` -o ./aot-output diff --git a/Directory.Packages.props b/Directory.Packages.props index 58b3afa57..622dcc537 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,9 @@ 9.0.0 + + 10.0.0 + @@ -34,7 +37,7 @@ - + @@ -46,11 +49,15 @@ - + - + + + + + diff --git a/build/Common.samples.props b/build/Common.samples.props index a5b06c9b9..6b2058a0e 100644 --- a/build/Common.samples.props +++ b/build/Common.samples.props @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable enable true diff --git a/docs/AOT_COMPATIBILITY.md b/docs/AOT_COMPATIBILITY.md index afa6f1e73..d4b72952b 100644 --- a/docs/AOT_COMPATIBILITY.md +++ b/docs/AOT_COMPATIBILITY.md @@ -26,7 +26,7 @@ To enable NativeAOT in your project, add these properties to your `.csproj` file ```xml - net8.0 + net8.0 Exe diff --git a/global.json b/global.json index 5fb240dd3..d8d11dbec 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { "rollForward": "latestFeature", - "version": "9.0.300", + "version": "10.0.100", "allowPrerelease": false } } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e5439c49b..bacf8984e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,9 +2,9 @@ - net462;netstandard2.0;net8.0;net9.0 + net462;netstandard2.0;net8.0;net9.0;net10.0 $([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0')) - \ No newline at end of file + diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 78b6928eb..e50e40a34 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -1,3 +1,7 @@ + + net8.0;net9.0;net10.0 + $(TargetFrameworks);net462 + diff --git a/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj b/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj index 42cb2770f..bc98f8a38 100644 --- a/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj +++ b/test/OpenFeature.AotCompatibility/OpenFeature.AotCompatibility.csproj @@ -1,7 +1,7 @@  - net9.0 + net9.0;net10.0 Exe enable enable diff --git a/test/OpenFeature.Benchmarks/OpenFeature.Benchmarks.csproj b/test/OpenFeature.Benchmarks/OpenFeature.Benchmarks.csproj index c0dc300a6..248e34133 100644 --- a/test/OpenFeature.Benchmarks/OpenFeature.Benchmarks.csproj +++ b/test/OpenFeature.Benchmarks/OpenFeature.Benchmarks.csproj @@ -1,7 +1,7 @@ - net8.0;net9.0 + net9.0;net10.0 OpenFeature.Benchmark Exe diff --git a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj index a6fabda89..11120976d 100644 --- a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj +++ b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj @@ -1,8 +1,6 @@  - net8.0;net9.0 - $(TargetFrameworks);net462 OpenFeature.E2ETests diff --git a/test/OpenFeature.Hosting.Tests/OpenFeature.Hosting.Tests.csproj b/test/OpenFeature.Hosting.Tests/OpenFeature.Hosting.Tests.csproj index ae8707a85..88251769c 100644 --- a/test/OpenFeature.Hosting.Tests/OpenFeature.Hosting.Tests.csproj +++ b/test/OpenFeature.Hosting.Tests/OpenFeature.Hosting.Tests.csproj @@ -1,8 +1,6 @@ - net8.0;net9.0 - $(TargetFrameworks);net462 OpenFeature.Hosting.Tests diff --git a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj index e16aff9a6..35d85dfda 100644 --- a/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj +++ b/test/OpenFeature.IntegrationTests/OpenFeature.IntegrationTests.csproj @@ -1,7 +1,7 @@ - net8.0;net9.0 + net8.0;net9.0;net10.0 diff --git a/test/OpenFeature.Providers.MultiProvider.Tests/OpenFeature.Providers.MultiProvider.Tests.csproj b/test/OpenFeature.Providers.MultiProvider.Tests/OpenFeature.Providers.MultiProvider.Tests.csproj index f1f016c64..1a7dae716 100644 --- a/test/OpenFeature.Providers.MultiProvider.Tests/OpenFeature.Providers.MultiProvider.Tests.csproj +++ b/test/OpenFeature.Providers.MultiProvider.Tests/OpenFeature.Providers.MultiProvider.Tests.csproj @@ -1,8 +1,6 @@  - net8.0;net9.0 - $(TargetFrameworks);net462 OpenFeature.Providers.MultiProvider.Tests diff --git a/test/OpenFeature.Tests/OpenFeature.Tests.csproj b/test/OpenFeature.Tests/OpenFeature.Tests.csproj index 8abb4891f..7a529650b 100644 --- a/test/OpenFeature.Tests/OpenFeature.Tests.csproj +++ b/test/OpenFeature.Tests/OpenFeature.Tests.csproj @@ -1,8 +1,6 @@  - net8.0;net9.0 - $(TargetFrameworks);net462 OpenFeature.Tests From 19d196714acfc40efaad22fd79ff82ed1f1257ad Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 09:21:33 +0000 Subject: [PATCH 113/124] chore(deps): update github/codeql-action digest to fe4161a (#660) 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 ee4dc7f53..ff9c61fc9 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@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4 + uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # 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@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4 + uses: github/codeql-action/autobuild@fe4161a26a8629af62121b670040955b330f9af2 # 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@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4 + uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4 From 0aed3e323122dc53223dbc9a4a9823d03ccf4c55 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 09:21:40 +0000 Subject: [PATCH 114/124] chore(deps): update actions/checkout digest to 8e8c483 (#659) 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/codeql-analysis.yml | 2 +- .github/workflows/dotnet-format.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/release.yml | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/aot-compatibility.yml b/.github/workflows/aot-compatibility.yml index 214b06db8..15fbaf348 100644 --- a/.github/workflows/aot-compatibility.yml +++ b/.github/workflows/aot-compatibility.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: fetch-depth: 0 submodules: recursive diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f3f5c8eb..74ad57133 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: fetch-depth: 0 submodules: recursive @@ -67,7 +67,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: fetch-depth: 0 submodules: recursive diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 67d2e629b..9fccd53ec 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: fetch-depth: 0 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ff9c61fc9..4b6663e8c 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml index 170886165..e4326e045 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - name: Setup .NET SDK uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index d975ca0bd..d592afba1 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7cd116145..0a17658a5 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: fetch-depth: 0 From 56993f50d18382c3929596034d98ac67cd76c83a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 09:21:48 +0000 Subject: [PATCH 115/124] chore(deps): update spec digest to 41a1b0a (#661) 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 3fc2e4949..41a1b0a02 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 3fc2e4949e53f761daf3e1f7197678481015fdf0 +Subproject commit 41a1b0a026f44e6d2b9dcc2c18a44e5562a62b30 From a6e2d28c3069c41fc792b3283191f9b25efe39e8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:15:28 +0000 Subject: [PATCH 116/124] chore(deps): update github/codeql-action digest to cf1bb45 (#664) 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 4b6663e8c..20c987d0b 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@fe4161a26a8629af62121b670040955b330f9af2 # v4 + uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # 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@fe4161a26a8629af62121b670040955b330f9af2 # v4 + uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # 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@fe4161a26a8629af62121b670040955b330f9af2 # v4 + uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4 From 32b0465e8d8a2ae49ac7d2cfb1af23f42c2c10be Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:15:49 +0000 Subject: [PATCH 117/124] chore(deps): update codecov/codecov-action action to v5.5.2 (#666) 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 9fccd53ec..aea133fc7 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@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: name: Code Coverage for ${{ matrix.os }} fail_ci_if_error: true From 6b6a51eba56cbd39222ca885ed103b15b213b4f0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:16:08 +0000 Subject: [PATCH 118/124] chore(deps): update spec digest to 400fa30 (#665) 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 41a1b0a02..400fa3038 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 41a1b0a026f44e6d2b9dcc2c18a44e5562a62b30 +Subproject commit 400fa3038d4469f0cbf77af3e79fedda406afc21 From 3dcb6c5c700a8d784f288ff52d65bb72fc454b5b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:09:00 +0000 Subject: [PATCH 119/124] chore(deps): update github/codeql-action digest to 1b168cd (#667) 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 20c987d0b..ebbbc3c19 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@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4 + uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # 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@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4 + uses: github/codeql-action/autobuild@1b168cd39490f61582a9beae412bb7057a6b2c4e # 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@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4 + uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4 From 08f6bc1870c817717acfbd5f998150852f9fb2da Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:09:21 +0000 Subject: [PATCH 120/124] chore(deps): update actions/cache action to v5 (#668) 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 15fbaf348..6f22c7ae8 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.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 74ad57133..00689c36b 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.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 aea133fc7..b9e3718b0 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.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 d592afba1..ef2d34f55 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.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 0a17658a5..1188ccb43 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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} From 506767ae45c9551e023645ef5ab1725aad7cf98a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 08:37:11 +0000 Subject: [PATCH 121/124] chore(deps): update actions/upload-artifact action to v6 (#673) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00689c36b..9ad5b6f22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,7 +102,7 @@ jobs: - name: Publish NuGet packages (fork) if: github.event.pull_request.head.repo.fork == true - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: nupkgs path: src/**/*.nupkg From ff24f2eeea64cc8aeb720de6c04044dc4d921bec Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 08:37:31 +0000 Subject: [PATCH 122/124] chore(deps): update actions/cache action to v5.0.1 (#672) 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 6f22c7ae8..4ba1698ce 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@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 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 9ad5b6f22..d92c7a849 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@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 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@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 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 b9e3718b0..c221b5911 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@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 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 ef2d34f55..fa02626eb 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@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 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 1188ccb43..4468cb37d 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@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.nuget/packages key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', 'Directory.Packages.props', 'global.json') }} From ff4bdedac3c2db12182f40d8c687e4e43420223d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:33:37 +0000 Subject: [PATCH 123/124] chore(deps): update github/codeql-action digest to 5d4e8d1 (#674) 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 ebbbc3c19..af80cac1d 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@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4 + uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # 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@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4 + uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # 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@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4 + uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4 From cb772527a8ddc62856941109c8fc936789fc9e92 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Thu, 18 Dec 2025 12:53:35 -0500 Subject: [PATCH 124/124] chore(main): release 2.11.0 (#662) 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 f393718c9..a9b8e02a9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.10.0" + ".": "2.11.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 59501e851..3c7b76af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2.11.0](https://github.com/open-feature/dotnet-sdk/compare/v2.10.0...v2.11.0) (2025-12-18) + + +### ✨ New Features + +* Upgrade to dotnet version 10 ([#658](https://github.com/open-feature/dotnet-sdk/issues/658)) ([c7be7e0](https://github.com/open-feature/dotnet-sdk/commit/c7be7e0fbff694ec9ef794548f6e7a478412b68b)) + ## [2.10.0](https://github.com/open-feature/dotnet-sdk/compare/v2.9.0...v2.10.0) (2025-12-01) diff --git a/README.md b/README.md index ae4253285..dfedfabe1 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.10.0&color=blue&style=for-the-badge) -](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.10.0) +![Release](https://img.shields.io/static/v1?label=release&message=v2.11.0&color=blue&style=for-the-badge) +](https://github.com/open-feature/dotnet-sdk/releases/tag/v2.11.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 bfe58d3d1..cfcd00340 100644 --- a/build/Common.prod.props +++ b/build/Common.prod.props @@ -9,7 +9,7 @@ - 2.10.0 + 2.11.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 10c2c0c3d..46b81d815 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.10.0 +2.11.0