From 609016fc86f8eee8d848a9227b57aaef0d9b85b0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 12 Oct 2022 11:27:06 -0700 Subject: [PATCH] feat!: Thread safe hooks, provider, and context (#79) --- src/OpenFeatureSDK/FeatureProvider.cs | 7 +- .../Model/FlagEvaluationOptions.cs | 20 +-- src/OpenFeatureSDK/OpenFeature.cs | 114 +++++++++++++++--- src/OpenFeatureSDK/OpenFeatureClient.cs | 109 +++++++++++++---- .../OpenFeatureClientTests.cs | 35 +++--- .../OpenFeatureHookTests.cs | 38 +++--- test/OpenFeatureSDK.Tests/OpenFeatureTests.cs | 9 +- .../TestImplementations.cs | 3 +- 8 files changed, 245 insertions(+), 90 deletions(-) diff --git a/src/OpenFeatureSDK/FeatureProvider.cs b/src/OpenFeatureSDK/FeatureProvider.cs index 639363a8..e2934cea 100644 --- a/src/OpenFeatureSDK/FeatureProvider.cs +++ b/src/OpenFeatureSDK/FeatureProvider.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Immutable; using System.Threading.Tasks; using OpenFeatureSDK.Model; @@ -22,8 +21,8 @@ public abstract class FeatureProvider /// error (if applicable): Provider, Invocation, Client, API /// finally: Provider, Invocation, Client, API /// - /// - public virtual IReadOnlyList GetProviderHooks() => Array.Empty(); + /// Immutable list of hooks + public virtual IImmutableList GetProviderHooks() => ImmutableList.Empty; /// /// Metadata describing the provider. diff --git a/src/OpenFeatureSDK/Model/FlagEvaluationOptions.cs b/src/OpenFeatureSDK/Model/FlagEvaluationOptions.cs index cb8bc353..38396723 100644 --- a/src/OpenFeatureSDK/Model/FlagEvaluationOptions.cs +++ b/src/OpenFeatureSDK/Model/FlagEvaluationOptions.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Immutable; namespace OpenFeatureSDK.Model { @@ -12,33 +12,33 @@ public class FlagEvaluationOptions /// /// A immutable list of /// - public IReadOnlyList Hooks { get; } + public IImmutableList Hooks { get; } /// /// A immutable dictionary of hook hints /// - public IReadOnlyDictionary HookHints { get; } + public IImmutableDictionary HookHints { get; } /// /// Initializes a new instance of the class. /// - /// + /// An immutable list of hooks to use during evaluation /// Optional - a list of hints that are passed through the hook lifecycle - public FlagEvaluationOptions(IReadOnlyList hooks, IReadOnlyDictionary hookHints = null) + public FlagEvaluationOptions(IImmutableList hooks, IImmutableDictionary hookHints = null) { this.Hooks = hooks; - this.HookHints = hookHints ?? new Dictionary(); + this.HookHints = hookHints ?? ImmutableDictionary.Empty; } /// /// Initializes a new instance of the class. /// - /// + /// A hook to use during the evaluation /// Optional - a list of hints that are passed through the hook lifecycle - public FlagEvaluationOptions(Hook hook, IReadOnlyDictionary hookHints = null) + public FlagEvaluationOptions(Hook hook, ImmutableDictionary hookHints = null) { - this.Hooks = new[] { hook }; - this.HookHints = hookHints ?? new Dictionary(); + this.Hooks = ImmutableList.Create(hook); + this.HookHints = hookHints ?? ImmutableDictionary.Empty; } } } diff --git a/src/OpenFeatureSDK/OpenFeature.cs b/src/OpenFeatureSDK/OpenFeature.cs index 42b23dcf..cf119f9f 100644 --- a/src/OpenFeatureSDK/OpenFeature.cs +++ b/src/OpenFeatureSDK/OpenFeature.cs @@ -1,4 +1,8 @@ +using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; +using System.Threading; using Microsoft.Extensions.Logging; using OpenFeatureSDK.Model; @@ -13,7 +17,11 @@ public sealed class OpenFeature { private EvaluationContext _evaluationContext = EvaluationContext.Empty; private FeatureProvider _featureProvider = new NoOpFeatureProvider(); - private readonly List _hooks = new List(); + private readonly ConcurrentStack _hooks = new ConcurrentStack(); + + /// The reader/writer locks are not disposed because the singleton instance should never be disposed. + private readonly ReaderWriterLockSlim _evaluationContextLock = new ReaderWriterLockSlim(); + private readonly ReaderWriterLockSlim _featureProviderLock = new ReaderWriterLockSlim(); /// /// Singleton instance of OpenFeature @@ -30,19 +38,53 @@ private OpenFeature() { } /// Sets the feature provider /// /// Implementation of - public void SetProvider(FeatureProvider featureProvider) => this._featureProvider = featureProvider; + public void SetProvider(FeatureProvider featureProvider) + { + this._featureProviderLock.EnterWriteLock(); + try + { + this._featureProvider = featureProvider; + } + finally + { + this._featureProviderLock.ExitWriteLock(); + } + } /// /// Gets the feature provider + /// + /// The feature provider may be set from multiple threads, when accessing the global feature provider + /// it should be accessed once for an operation, and then that reference should be used for all dependent + /// operations. For instance, during an evaluation the flag resolution method, and the provider hooks + /// should be accessed from the same reference, not two independent calls to + /// . + /// /// /// - public FeatureProvider GetProvider() => this._featureProvider; + public FeatureProvider GetProvider() + { + this._featureProviderLock.EnterReadLock(); + try + { + return this._featureProvider; + } + finally + { + this._featureProviderLock.ExitReadLock(); + } + } /// /// Gets providers metadata + /// + /// This method is not guaranteed to return the same provider instance that may be used during an evaluation + /// in the case where the provider may be changed from another thread. + /// For multiple dependent provider operations see . + /// /// /// - public Metadata GetProviderMetadata() => this._featureProvider.GetMetadata(); + public Metadata GetProviderMetadata() => this.GetProvider().GetMetadata(); /// /// Create a new instance of using the current provider @@ -52,26 +94,39 @@ private OpenFeature() { } /// Logger instance used by client /// Context given to this client /// - public FeatureClient GetClient(string name = null, string version = null, ILogger logger = null, EvaluationContext context = null) => - new FeatureClient(this._featureProvider, name, version, logger, context); + public FeatureClient GetClient(string name = null, string version = null, ILogger logger = null, + EvaluationContext context = null) => + new FeatureClient(name, version, logger, context); /// /// Appends list of hooks to global hooks list + /// + /// The appending operation will be atomic. + /// /// /// A list of - public void AddHooks(IEnumerable hooks) => this._hooks.AddRange(hooks); + public void AddHooks(IEnumerable hooks) => this._hooks.PushRange(hooks.ToArray()); /// /// Adds a hook to global hooks list + /// + /// Hooks which are dependent on each other should be provided in a collection + /// using the . + /// /// - /// A list of - public void AddHooks(Hook hook) => this._hooks.Add(hook); + /// Hook that implements the interface + public void AddHooks(Hook hook) => this._hooks.Push(hook); /// - /// Returns the global immutable hooks list + /// Enumerates the global hooks. + /// + /// The items enumerated will reflect the registered hooks + /// at the start of enumeration. Hooks added during enumeration + /// will not be included. + /// /// - /// A immutable list of - public IReadOnlyList GetHooks() => this._hooks.AsReadOnly(); + /// Enumeration of + public IEnumerable GetHooks() => this._hooks.Reverse(); /// /// Removes all hooks from global hooks list @@ -81,13 +136,40 @@ public FeatureClient GetClient(string name = null, string version = null, ILogge /// /// Sets the global /// - /// - public void SetContext(EvaluationContext context) => this._evaluationContext = context ?? EvaluationContext.Empty; + /// The to set + public void SetContext(EvaluationContext context) + { + this._evaluationContextLock.EnterWriteLock(); + try + { + this._evaluationContext = context ?? EvaluationContext.Empty; + } + finally + { + this._evaluationContextLock.ExitWriteLock(); + } + } /// /// Gets the global + /// + /// The evaluation context may be set from multiple threads, when accessing the global evaluation context + /// it should be accessed once for an operation, and then that reference should be used for all dependent + /// operations. + /// /// - /// - public EvaluationContext GetContext() => this._evaluationContext; + /// An + public EvaluationContext GetContext() + { + this._evaluationContextLock.EnterReadLock(); + try + { + return this._evaluationContext; + } + finally + { + this._evaluationContextLock.ExitReadLock(); + } + } } } diff --git a/src/OpenFeatureSDK/OpenFeatureClient.cs b/src/OpenFeatureSDK/OpenFeatureClient.cs index 3d4120b7..efe47cb6 100644 --- a/src/OpenFeatureSDK/OpenFeatureClient.cs +++ b/src/OpenFeatureSDK/OpenFeatureClient.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -17,34 +18,80 @@ namespace OpenFeatureSDK public sealed class FeatureClient : IFeatureClient { private readonly ClientMetadata _metadata; - private readonly FeatureProvider _featureProvider; - private readonly List _hooks = new List(); + private readonly ConcurrentStack _hooks = new ConcurrentStack(); private readonly ILogger _logger; private EvaluationContext _evaluationContext; + private readonly object _evaluationContextLock = new object(); + + /// + /// Get a provider and an associated typed flag resolution method. + /// + /// The global provider could change between two accesses, so in order to safely get provider information we + /// must first alias it and then use that alias to access everything we need. + /// + /// + /// + /// This method should return the desired flag resolution method from the given provider reference. + /// + /// The type of the resolution method + /// A tuple containing a resolution method and the provider it came from. + private (Func>>, FeatureProvider) + ExtractProvider( + Func>>> method) + { + // Alias the provider reference so getting the method and returning the provider are + // guaranteed to be the same object. + var provider = OpenFeature.Instance.GetProvider(); + + if (provider == null) + { + provider = new NoOpFeatureProvider(); + this._logger.LogDebug("No provider configured, using no-op provider"); + } + + return (method(provider), provider); + } + /// /// Gets the EvaluationContext of this client + /// + /// The evaluation context may be set from multiple threads, when accessing the client evaluation context + /// it should be accessed once for an operation, and then that reference should be used for all dependent + /// operations. + /// /// /// of this client - public EvaluationContext GetContext() => this._evaluationContext; + public EvaluationContext GetContext() + { + lock (this._evaluationContextLock) + { + return this._evaluationContext; + } + } /// /// Sets the EvaluationContext of the client /// - public void SetContext(EvaluationContext evaluationContext) => this._evaluationContext = evaluationContext; + /// The to set + public void SetContext(EvaluationContext context) + { + lock (this._evaluationContextLock) + { + this._evaluationContext = context ?? EvaluationContext.Empty; + } + } /// /// Initializes a new instance of the class. /// - /// Feature provider used by client /// Name of client /// Version of client /// Logger used by client /// Context given to this client /// Throws if any of the required parameters are null - public FeatureClient(FeatureProvider featureProvider, string name, string version, ILogger logger = null, EvaluationContext context = null) + public FeatureClient(string name, string version, ILogger logger = null, EvaluationContext context = null) { - this._featureProvider = featureProvider ?? throw new ArgumentNullException(nameof(featureProvider)); this._metadata = new ClientMetadata(name, version); this._logger = logger ?? new Logger(new NullLoggerFactory()); this._evaluationContext = context ?? EvaluationContext.Empty; @@ -58,21 +105,33 @@ public FeatureClient(FeatureProvider featureProvider, string name, string versio /// /// Add hook to client + /// + /// Hooks which are dependent on each other should be provided in a collection + /// using the . + /// /// /// Hook that implements the interface - public void AddHooks(Hook hook) => this._hooks.Add(hook); + public void AddHooks(Hook hook) => this._hooks.Push(hook); /// /// Appends hooks to client + /// + /// The appending operation will be atomic. + /// /// /// A list of Hooks that implement the interface - public void AddHooks(IEnumerable hooks) => this._hooks.AddRange(hooks); + public void AddHooks(IEnumerable hooks) => this._hooks.PushRange(hooks.ToArray()); /// - /// Return a immutable list of hooks that are registered against the client + /// Enumerates the global hooks. + /// + /// The items enumerated will reflect the registered hooks + /// at the start of enumeration. Hooks added during enumeration + /// will not be included. + /// /// - /// A list of immutable hooks - public IReadOnlyList GetHooks() => this._hooks.ToList().AsReadOnly(); + /// Enumeration of + public IEnumerable GetHooks() => this._hooks.Reverse(); /// /// Removes all hooks from the client @@ -101,7 +160,8 @@ public async Task GetBooleanValue(string flagKey, bool defaultValue, Evalu /// Resolved flag details public async Task> GetBooleanDetails(string flagKey, bool defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - await this.EvaluateFlag(this._featureProvider.ResolveBooleanValue, FlagValueType.Boolean, flagKey, + await this.EvaluateFlag(this.ExtractProvider(provider => provider.ResolveBooleanValue), + FlagValueType.Boolean, flagKey, defaultValue, context, config); /// @@ -126,7 +186,8 @@ public async Task GetStringValue(string flagKey, string defaultValue, Ev /// Resolved flag details public async Task> GetStringDetails(string flagKey, string defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - await this.EvaluateFlag(this._featureProvider.ResolveStringValue, FlagValueType.String, flagKey, + await this.EvaluateFlag(this.ExtractProvider(provider => provider.ResolveStringValue), + FlagValueType.String, flagKey, defaultValue, context, config); /// @@ -151,7 +212,8 @@ public async Task GetIntegerValue(string flagKey, int defaultValue, Evaluat /// Resolved flag details public async Task> GetIntegerDetails(string flagKey, int defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - await this.EvaluateFlag(this._featureProvider.ResolveIntegerValue, FlagValueType.Number, flagKey, + await this.EvaluateFlag(this.ExtractProvider(provider => provider.ResolveIntegerValue), + FlagValueType.Number, flagKey, defaultValue, context, config); /// @@ -177,7 +239,8 @@ public async Task GetDoubleValue(string flagKey, double defaultValue, /// Resolved flag details public async Task> GetDoubleDetails(string flagKey, double defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - await this.EvaluateFlag(this._featureProvider.ResolveDoubleValue, FlagValueType.Number, flagKey, + await this.EvaluateFlag(this.ExtractProvider(provider => provider.ResolveDoubleValue), + FlagValueType.Number, flagKey, defaultValue, context, config); /// @@ -202,14 +265,18 @@ public async Task GetObjectValue(string flagKey, Value defaultValue, Eval /// Resolved flag details public async Task> GetObjectDetails(string flagKey, Value defaultValue, EvaluationContext context = null, FlagEvaluationOptions config = null) => - await this.EvaluateFlag(this._featureProvider.ResolveStructureValue, FlagValueType.Object, flagKey, + await this.EvaluateFlag(this.ExtractProvider(provider => provider.ResolveStructureValue), + FlagValueType.Object, flagKey, defaultValue, context, config); private async Task> EvaluateFlag( - Func>> resolveValueDelegate, + (Func>>, FeatureProvider) providerInfo, FlagValueType flagValueType, string flagKey, T defaultValue, EvaluationContext context = null, FlagEvaluationOptions options = null) { + var resolveValueDelegate = providerInfo.Item1; + var provider = providerInfo.Item2; + // New up a evaluation context if one was not provided. if (context == null) { @@ -225,9 +292,9 @@ private async Task> EvaluateFlag( var allHooks = new List() .Concat(OpenFeature.Instance.GetHooks()) - .Concat(this._hooks) + .Concat(this.GetHooks()) .Concat(options?.Hooks ?? Enumerable.Empty()) - .Concat(this._featureProvider.GetProviderHooks()) + .Concat(provider.GetProviderHooks()) .ToList() .AsReadOnly(); @@ -241,7 +308,7 @@ private async Task> EvaluateFlag( flagKey, defaultValue, flagValueType, this._metadata, - OpenFeature.Instance.GetProviderMetadata(), + provider.GetMetadata(), evaluationContextBuilder.Build() ); diff --git a/test/OpenFeatureSDK.Tests/OpenFeatureClientTests.cs b/test/OpenFeatureSDK.Tests/OpenFeatureClientTests.cs index 6babbdf5..519fe452 100644 --- a/test/OpenFeatureSDK.Tests/OpenFeatureClientTests.cs +++ b/test/OpenFeatureSDK.Tests/OpenFeatureClientTests.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; using System.Threading.Tasks; using AutoFixture; using FluentAssertions; @@ -30,14 +32,14 @@ public void OpenFeatureClient_Should_Allow_Hooks() client.AddHooks(new[] { hook1, hook2 }); client.GetHooks().Should().ContainInOrder(hook1, hook2); - client.GetHooks().Count.Should().Be(2); + client.GetHooks().Count().Should().Be(2); client.AddHooks(hook3); client.GetHooks().Should().ContainInOrder(hook1, hook2, hook3); - client.GetHooks().Count.Should().Be(3); + client.GetHooks().Count().Should().Be(3); client.ClearHooks(); - client.GetHooks().Count.Should().Be(0); + Assert.Empty(client.GetHooks()); } [Fact] @@ -68,7 +70,7 @@ public async Task OpenFeatureClient_Should_Allow_Flag_Evaluation() var defaultIntegerValue = fixture.Create(); var defaultDoubleValue = fixture.Create(); var defaultStructureValue = fixture.Create(); - var emptyFlagOptions = new FlagEvaluationOptions(new List(), new Dictionary()); + var emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); OpenFeature.Instance.SetProvider(new NoOpFeatureProvider()); var client = OpenFeature.Instance.GetClient(clientName, clientVersion); @@ -114,7 +116,7 @@ public async Task OpenFeatureClient_Should_Allow_Details_Flag_Evaluation() var defaultIntegerValue = fixture.Create(); var defaultDoubleValue = fixture.Create(); var defaultStructureValue = fixture.Create(); - var emptyFlagOptions = new FlagEvaluationOptions(new List(), new Dictionary()); + var emptyFlagOptions = new FlagEvaluationOptions(ImmutableList.Empty, ImmutableDictionary.Empty); OpenFeature.Instance.SetProvider(new NoOpFeatureProvider()); var client = OpenFeature.Instance.GetClient(clientName, clientVersion); @@ -169,7 +171,7 @@ public async Task OpenFeatureClient_Should_Return_DefaultValue_When_Type_Mismatc mockedFeatureProvider.Setup(x => x.GetMetadata()) .Returns(new Metadata(fixture.Create())); mockedFeatureProvider.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); OpenFeature.Instance.SetProvider(mockedFeatureProvider.Object); var client = OpenFeature.Instance.GetClient(clientName, clientVersion, mockedLogger.Object); @@ -206,7 +208,7 @@ public async Task Should_Resolve_BooleanValue() featureProviderMock.Setup(x => x.GetMetadata()) .Returns(new Metadata(fixture.Create())); featureProviderMock.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); OpenFeature.Instance.SetProvider(featureProviderMock.Object); var client = OpenFeature.Instance.GetClient(clientName, clientVersion); @@ -232,7 +234,7 @@ public async Task Should_Resolve_StringValue() featureProviderMock.Setup(x => x.GetMetadata()) .Returns(new Metadata(fixture.Create())); featureProviderMock.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); OpenFeature.Instance.SetProvider(featureProviderMock.Object); var client = OpenFeature.Instance.GetClient(clientName, clientVersion); @@ -258,7 +260,7 @@ public async Task Should_Resolve_IntegerValue() featureProviderMock.Setup(x => x.GetMetadata()) .Returns(new Metadata(fixture.Create())); featureProviderMock.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); OpenFeature.Instance.SetProvider(featureProviderMock.Object); var client = OpenFeature.Instance.GetClient(clientName, clientVersion); @@ -284,7 +286,7 @@ public async Task Should_Resolve_DoubleValue() featureProviderMock.Setup(x => x.GetMetadata()) .Returns(new Metadata(fixture.Create())); featureProviderMock.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); OpenFeature.Instance.SetProvider(featureProviderMock.Object); var client = OpenFeature.Instance.GetClient(clientName, clientVersion); @@ -310,7 +312,7 @@ public async Task Should_Resolve_StructureValue() featureProviderMock.Setup(x => x.GetMetadata()) .Returns(new Metadata(fixture.Create())); featureProviderMock.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); OpenFeature.Instance.SetProvider(featureProviderMock.Object); var client = OpenFeature.Instance.GetClient(clientName, clientVersion); @@ -338,7 +340,7 @@ public async Task When_Error_Is_Returned_From_Provider_Should_Return_Error() featureProviderMock.Setup(x => x.GetMetadata()) .Returns(new Metadata(fixture.Create())); featureProviderMock.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); OpenFeature.Instance.SetProvider(featureProviderMock.Object); var client = OpenFeature.Instance.GetClient(clientName, clientVersion); @@ -367,7 +369,7 @@ public async Task When_Exception_Occurs_During_Evaluation_Should_Return_Error() featureProviderMock.Setup(x => x.GetMetadata()) .Returns(new Metadata(fixture.Create())); featureProviderMock.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); OpenFeature.Instance.SetProvider(featureProviderMock.Object); var client = OpenFeature.Instance.GetClient(clientName, clientVersion); @@ -380,10 +382,11 @@ public async Task When_Exception_Occurs_During_Evaluation_Should_Return_Error() } [Fact] - public void Should_Throw_ArgumentNullException_When_Provider_Is_Null() + public async Task Should_Use_No_Op_When_Provider_Is_Null() { - TestProvider provider = null; - Assert.Throws(() => new FeatureClient(provider, "test", "test")); + OpenFeature.Instance.SetProvider(null); + var client = new FeatureClient("test", "test"); + (await client.GetIntegerValue("some-key", 12)).Should().Be(12); } [Fact] diff --git a/test/OpenFeatureSDK.Tests/OpenFeatureHookTests.cs b/test/OpenFeatureSDK.Tests/OpenFeatureHookTests.cs index 0c97e18b..3b13e6b8 100644 --- a/test/OpenFeatureSDK.Tests/OpenFeatureHookTests.cs +++ b/test/OpenFeatureSDK.Tests/OpenFeatureHookTests.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; using System.Threading.Tasks; using AutoFixture; using FluentAssertions; @@ -83,7 +85,7 @@ public async Task Hooks_Should_Be_Called_In_Order() client.AddHooks(clientHook.Object); await client.GetBooleanValue(flagName, defaultValue, EvaluationContext.Empty, - new FlagEvaluationOptions(invocationHook.Object, new Dictionary())); + new FlagEvaluationOptions(invocationHook.Object, ImmutableDictionary.Empty)); apiHook.Verify(x => x.Before( It.IsAny>(), It.IsAny>()), Times.Once); @@ -173,19 +175,19 @@ public async Task Evaluation_Context_Must_Be_Mutable_Before_Hook() FlagValueType.Boolean, new ClientMetadata("test", "1.0.0"), new Metadata(NoOpProvider.NoOpProviderName), evaluationContext); - hook1.Setup(x => x.Before(It.IsAny>(), It.IsAny>())) + hook1.Setup(x => x.Before(It.IsAny>(), It.IsAny>())) .ReturnsAsync(evaluationContext); hook2.Setup(x => - x.Before(hookContext, It.IsAny>())) + x.Before(hookContext, It.IsAny>())) .ReturnsAsync(evaluationContext); var client = OpenFeature.Instance.GetClient("test", "1.0.0"); await client.GetBooleanValue("test", false, EvaluationContext.Empty, - new FlagEvaluationOptions(new[] { hook1.Object, hook2.Object }, new Dictionary())); + new FlagEvaluationOptions(ImmutableList.Create(hook1.Object, hook2.Object), ImmutableDictionary.Empty)); - hook1.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); - hook2.Verify(x => x.Before(It.Is>(a => a.EvaluationContext.GetValue("test").AsString == "test"), It.IsAny>()), Times.Once); + hook1.Verify(x => x.Before(It.IsAny>(), It.IsAny>()), Times.Once); + hook2.Verify(x => x.Before(It.Is>(a => a.EvaluationContext.GetValue("test").AsString == "test"), It.IsAny>()), Times.Once); } [Fact] @@ -232,7 +234,7 @@ public async Task Evaluation_Context_Must_Be_Merged_In_Correct_Order() .Returns(new Metadata(null)); provider.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); provider.Setup(x => x.ResolveBooleanValue(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ResolutionDetails("test", true)); @@ -240,12 +242,12 @@ public async Task Evaluation_Context_Must_Be_Merged_In_Correct_Order() OpenFeature.Instance.SetProvider(provider.Object); var hook = new Mock(MockBehavior.Strict); - hook.Setup(x => x.Before(It.IsAny>(), It.IsAny>())) + hook.Setup(x => x.Before(It.IsAny>(), It.IsAny>())) .ReturnsAsync(hookContext); var client = OpenFeature.Instance.GetClient("test", "1.0.0", null, clientContext); - await client.GetBooleanValue("test", false, invocationContext, new FlagEvaluationOptions(new[] { hook.Object }, new Dictionary())); + await client.GetBooleanValue("test", false, invocationContext, new FlagEvaluationOptions(ImmutableList.Create(hook.Object), ImmutableDictionary.Empty)); // after proper merging, all properties should equal true provider.Verify(x => x.ResolveBooleanValue(It.IsAny(), It.IsAny(), It.Is(y => @@ -307,7 +309,7 @@ public async Task Hook_Should_Execute_In_Correct_Order() .Returns(new Metadata(null)); featureProvider.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); hook.InSequence(sequence).Setup(x => x.Before(It.IsAny>(), It.IsAny>())) @@ -351,10 +353,10 @@ public async Task Register_Hooks_Should_Be_Available_At_All_Levels() var client = OpenFeature.Instance.GetClient(); client.AddHooks(hook2.Object); await client.GetBooleanValue("test", false, null, - new FlagEvaluationOptions(hook3.Object, new Dictionary())); + new FlagEvaluationOptions(hook3.Object, ImmutableDictionary.Empty)); - OpenFeature.Instance.GetHooks().Count.Should().Be(1); - client.GetHooks().Count.Should().Be(1); + Assert.Single(OpenFeature.Instance.GetHooks()); + client.GetHooks().Count().Should().Be(1); testProvider.GetProviderHooks().Count.Should().Be(1); } @@ -372,7 +374,7 @@ public async Task Finally_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() .Returns(new Metadata(null)); featureProvider.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); hook1.InSequence(sequence).Setup(x => x.Before(It.IsAny>(), null)) @@ -405,7 +407,7 @@ public async Task Finally_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() OpenFeature.Instance.SetProvider(featureProvider.Object); var client = OpenFeature.Instance.GetClient(); client.AddHooks(new[] { hook1.Object, hook2.Object }); - client.GetHooks().Count.Should().Be(2); + client.GetHooks().Count().Should().Be(2); await client.GetBooleanValue("test", false); @@ -431,7 +433,7 @@ public async Task Error_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() featureProvider1.Setup(x => x.GetMetadata()) .Returns(new Metadata(null)); featureProvider1.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); hook1.InSequence(sequence).Setup(x => x.Before(It.IsAny>(), null)) @@ -478,7 +480,7 @@ public async Task Error_Occurs_During_Before_After_Evaluation_Should_Not_Invoke_ featureProvider.Setup(x => x.GetMetadata()) .Returns(new Metadata(null)); featureProvider.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); hook1.InSequence(sequence).Setup(x => x.Before(It.IsAny>(), It.IsAny>())) @@ -518,7 +520,7 @@ public async Task Hook_Hints_May_Be_Optional() .Returns(new Metadata(null)); featureProvider.Setup(x => x.GetProviderHooks()) - .Returns(Array.Empty()); + .Returns(ImmutableList.Empty); hook.InSequence(sequence) .Setup(x => x.Before(It.IsAny>(), defaultEmptyHookHints)) diff --git a/test/OpenFeatureSDK.Tests/OpenFeatureTests.cs b/test/OpenFeatureSDK.Tests/OpenFeatureTests.cs index 5f9a778b..8a96e84b 100644 --- a/test/OpenFeatureSDK.Tests/OpenFeatureTests.cs +++ b/test/OpenFeatureSDK.Tests/OpenFeatureTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using FluentAssertions; using Moq; using OpenFeatureSDK.Constant; @@ -34,18 +35,18 @@ public void OpenFeature_Should_Add_Hooks() openFeature.AddHooks(hook1); openFeature.GetHooks().Should().Contain(hook1); - openFeature.GetHooks().Count.Should().Be(1); + Assert.Single(openFeature.GetHooks()); openFeature.AddHooks(hook2); openFeature.GetHooks().Should().ContainInOrder(hook1, hook2); - openFeature.GetHooks().Count.Should().Be(2); + openFeature.GetHooks().Count().Should().Be(2); openFeature.AddHooks(new[] { hook3, hook4 }); openFeature.GetHooks().Should().ContainInOrder(hook1, hook2, hook3, hook4); - openFeature.GetHooks().Count.Should().Be(4); + openFeature.GetHooks().Count().Should().Be(4); openFeature.ClearHooks(); - openFeature.GetHooks().Count.Should().Be(0); + Assert.Empty(openFeature.GetHooks()); } [Fact] diff --git a/test/OpenFeatureSDK.Tests/TestImplementations.cs b/test/OpenFeatureSDK.Tests/TestImplementations.cs index 4236077c..2da88c7b 100644 --- a/test/OpenFeatureSDK.Tests/TestImplementations.cs +++ b/test/OpenFeatureSDK.Tests/TestImplementations.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Threading.Tasks; using OpenFeatureSDK.Model; @@ -39,7 +40,7 @@ public class TestProvider : FeatureProvider public void AddHook(Hook hook) => this._hooks.Add(hook); - public override IReadOnlyList GetProviderHooks() => this._hooks.AsReadOnly(); + public override IImmutableList GetProviderHooks() => this._hooks.ToImmutableList(); public override Metadata GetMetadata() {