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 01/45] 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 02/45] 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 03/45] 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 04/45] 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 05/45] 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 06/45] 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 07/45] 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 08/45] 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 09/45] 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 10/45] 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 11/45] 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 12/45] 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 13/45] 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 14/45] 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 15/45] 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 16/45] 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 17/45] 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 18/45] 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 19/45] 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 20/45] 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 21/45] 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 22/45] 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 23/45] 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 24/45] 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 25/45] 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 26/45] 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 27/45] 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 28/45] 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 29/45] 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 30/45] 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 31/45] 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 32/45] 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 33/45] 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 34/45] 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 35/45] 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 36/45] 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 37/45] 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 38/45] 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 39/45] 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 40/45] 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 41/45] 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 42/45] 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 43/45] 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 44/45] 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 45/45] 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