diff --git a/README.md b/README.md index cf8101ab2..7a2261772 100644 --- a/README.md +++ b/README.md @@ -336,13 +336,13 @@ public class MyHook : Hook } public ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, - IReadOnlyDictionary hints = null) + IReadOnlyDictionary? hints = null) { // code to run after successful flag evaluation } public ValueTask ErrorAsync(HookContext context, Exception error, - IReadOnlyDictionary hints = null) + IReadOnlyDictionary? hints = null) { // code to run if there's an error during before hooks or during flag evaluation } @@ -354,6 +354,29 @@ public class MyHook : Hook } ``` +Hooks support passing per-evaluation data between that stages using `hook data`. The below example hook uses `hook data` to measure the duration between the execution of the `before` and `after` stage. + +```csharp + class TimingHook : Hook + { + public ValueTask BeforeAsync(HookContext context, + IReadOnlyDictionary? hints = null) + { + context.Data.Set("beforeTime", DateTime.Now); + return ValueTask.FromResult(context.EvaluationContext); + } + + public ValueTask AfterAsync(HookContext context, FlagEvaluationDetails details, + IReadOnlyDictionary? hints = null) + { + var beforeTime = context.Data.Get("beforeTime") as DateTime?; + var duration = DateTime.Now - beforeTime; + Console.WriteLine($"Duration: {duration}"); + return ValueTask.CompletedTask; + } + } +``` + Built a new hook? [Let us know](https://github.com/open-feature/openfeature.dev/issues/new?assignees=&labels=hook&projects=&template=document-hook.yaml&title=%5BHook%5D%3A+) so we can add it to the docs! ### DependencyInjection diff --git a/src/OpenFeature/HookData.cs b/src/OpenFeature/HookData.cs new file mode 100644 index 000000000..5d56eb870 --- /dev/null +++ b/src/OpenFeature/HookData.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using OpenFeature.Model; + +namespace OpenFeature +{ + /// + /// A key-value collection of strings to objects used for passing data between hook stages. + /// + /// This collection is scoped to a single evaluation for a single hook. Each hook stage for the evaluation + /// will share the same . + /// + /// + /// This collection is intended for use only during the execution of individual hook stages, a reference + /// to the collection should not be retained. + /// + /// + /// This collection is not thread-safe. + /// + /// + /// + public sealed class HookData + { + private readonly Dictionary _data = []; + + /// + /// Set the key to the given value. + /// + /// The key for the value + /// The value to set + /// This hook data instance + public HookData Set(string key, object value) + { + this._data[key] = value; + return this; + } + + /// + /// Gets the value at the specified key as an object. + /// + /// For types use instead. + /// + /// + /// The key of the value to be retrieved + /// The object associated with the key + /// + /// Thrown when the context does not contain the specified key + /// + public object Get(string key) + { + return this._data[key]; + } + + /// + /// Return a count of all values. + /// + public int Count => this._data.Count; + + /// + /// Return an enumerator for all values. + /// + /// An enumerator for all values + public IEnumerator> GetEnumerator() + { + return this._data.GetEnumerator(); + } + + /// + /// Return a list containing all the keys in the hook data + /// + public IImmutableList Keys => this._data.Keys.ToImmutableList(); + + /// + /// Return an enumerable containing all the values of the hook data + /// + public IImmutableList Values => this._data.Values.ToImmutableList(); + + /// + /// Gets all values as a read only dictionary. + /// + /// The dictionary references the original values and is not a thread-safe copy. + /// + /// + /// A representation of the hook data + public IReadOnlyDictionary AsDictionary() + { + return this._data; + } + + /// + /// Gets or sets the value associated with the specified key. + /// + /// The key of the value to get or set + /// The value associated with the specified key + /// + /// Thrown when getting a value and the context does not contain the specified key + /// + public object this[string key] + { + get => this.Get(key); + set => this.Set(key, value); + } + } +} diff --git a/src/OpenFeature/HookRunner.cs b/src/OpenFeature/HookRunner.cs new file mode 100644 index 000000000..8c1dbb510 --- /dev/null +++ b/src/OpenFeature/HookRunner.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OpenFeature.Model; + +namespace OpenFeature +{ + /// + /// This class manages the execution of hooks. + /// + /// type of the evaluation detail provided to the hooks + internal partial class HookRunner + { + private readonly ImmutableList _hooks; + + private readonly List> _hookContexts; + + private EvaluationContext _evaluationContext; + + private readonly ILogger _logger; + + /// + /// Construct a hook runner instance. Each instance should be used for the execution of a single evaluation. + /// + /// + /// The hooks for the evaluation, these should be in the correct order for the before evaluation stage + /// + /// + /// The initial evaluation context, this can be updated as the hooks execute + /// + /// + /// Contents of the initial hook context excluding the evaluation context and hook data + /// + /// Client logger instance + public HookRunner(ImmutableList hooks, EvaluationContext evaluationContext, + SharedHookContext sharedHookContext, + ILogger logger) + { + this._evaluationContext = evaluationContext; + this._logger = logger; + this._hooks = hooks; + this._hookContexts = new List>(hooks.Count); + for (var i = 0; i < hooks.Count; i++) + { + // Create hook instance specific hook context. + // Hook contexts are instance specific so that the mutable hook data is scoped to each hook. + this._hookContexts.Add(sharedHookContext.ToHookContext(evaluationContext)); + } + } + + /// + /// Execute before hooks. + /// + /// Optional hook hints + /// Cancellation token which can cancel hook operations + /// Context with any modifications from the before hooks + public async Task TriggerBeforeHooksAsync(IImmutableDictionary? hints, + CancellationToken cancellationToken = default) + { + var evalContextBuilder = EvaluationContext.Builder(); + evalContextBuilder.Merge(this._evaluationContext); + + for (var i = 0; i < this._hooks.Count; i++) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + + var resp = await hook.BeforeAsync(hookContext, hints, cancellationToken) + .ConfigureAwait(false); + if (resp != null) + { + evalContextBuilder.Merge(resp); + this._evaluationContext = evalContextBuilder.Build(); + for (var j = 0; j < this._hookContexts.Count; j++) + { + this._hookContexts[j] = this._hookContexts[j].WithNewEvaluationContext(this._evaluationContext); + } + } + else + { + this.HookReturnedNull(hook.GetType().Name); + } + } + + return this._evaluationContext; + } + + /// + /// Execute the after hooks. These are executed in opposite order of the before hooks. + /// + /// The evaluation details which will be provided to the hook + /// Optional hook hints + /// Cancellation token which can cancel hook operations + public async Task TriggerAfterHooksAsync(FlagEvaluationDetails evaluationDetails, + IImmutableDictionary? hints, + CancellationToken cancellationToken = default) + { + // After hooks run in reverse. + for (var i = this._hooks.Count - 1; i >= 0; i--) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + await hook.AfterAsync(hookContext, evaluationDetails, hints, cancellationToken) + .ConfigureAwait(false); + } + } + + /// + /// Execute the error hooks. These are executed in opposite order of the before hooks. + /// + /// Exception which triggered the error + /// Optional hook hints + /// Cancellation token which can cancel hook operations + public async Task TriggerErrorHooksAsync(Exception exception, + IImmutableDictionary? hints, CancellationToken cancellationToken = default) + { + // Error hooks run in reverse. + for (var i = this._hooks.Count - 1; i >= 0; i--) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + try + { + await hook.ErrorAsync(hookContext, exception, hints, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) + { + this.ErrorHookError(hook.GetType().Name, e); + } + } + } + + /// + /// Execute the finally hooks. These are executed in opposite order of the before hooks. + /// + /// The evaluation details which will be provided to the hook + /// Optional hook hints + /// Cancellation token which can cancel hook operations + public async Task TriggerFinallyHooksAsync(FlagEvaluationDetails evaluationDetails, + IImmutableDictionary? hints, + CancellationToken cancellationToken = default) + { + // Finally hooks run in reverse + for (var i = this._hooks.Count - 1; i >= 0; i--) + { + var hook = this._hooks[i]; + var hookContext = this._hookContexts[i]; + try + { + await hook.FinallyAsync(hookContext, evaluationDetails, hints, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception e) + { + this.FinallyHookError(hook.GetType().Name, e); + } + } + } + + [LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")] + partial void HookReturnedNull(string hookName); + + [LoggerMessage(103, LogLevel.Error, "Error while executing Error hook {HookName}")] + partial void ErrorHookError(string hookName, Exception exception); + + [LoggerMessage(104, LogLevel.Error, "Error while executing Finally hook {HookName}")] + partial void FinallyHookError(string hookName, Exception exception); + } +} diff --git a/src/OpenFeature/Model/HookContext.cs b/src/OpenFeature/Model/HookContext.cs index 69f58bde8..8d99a2836 100644 --- a/src/OpenFeature/Model/HookContext.cs +++ b/src/OpenFeature/Model/HookContext.cs @@ -10,20 +10,22 @@ namespace OpenFeature.Model /// public sealed class HookContext { + private readonly SharedHookContext _shared; + /// /// Feature flag being evaluated /// - public string FlagKey { get; } + public string FlagKey => this._shared.FlagKey; /// /// Default value if flag fails to be evaluated /// - public T DefaultValue { get; } + public T DefaultValue => this._shared.DefaultValue; /// /// The value type of the flag /// - public FlagValueType FlagValueType { get; } + public FlagValueType FlagValueType => this._shared.FlagValueType; /// /// User defined evaluation context used in the evaluation process @@ -34,12 +36,17 @@ public sealed class HookContext /// /// Client metadata /// - public ClientMetadata ClientMetadata { get; } + public ClientMetadata ClientMetadata => this._shared.ClientMetadata; /// /// Provider metadata /// - public Metadata ProviderMetadata { get; } + public Metadata ProviderMetadata => this._shared.ProviderMetadata; + + /// + /// Hook data + /// + public HookData Data { get; } /// /// Initialize a new instance of @@ -58,23 +65,27 @@ public HookContext(string? flagKey, Metadata? providerMetadata, EvaluationContext? evaluationContext) { - this.FlagKey = flagKey ?? throw new ArgumentNullException(nameof(flagKey)); - this.DefaultValue = defaultValue; - this.FlagValueType = flagValueType; - this.ClientMetadata = clientMetadata ?? throw new ArgumentNullException(nameof(clientMetadata)); - this.ProviderMetadata = providerMetadata ?? throw new ArgumentNullException(nameof(providerMetadata)); + this._shared = new SharedHookContext( + flagKey, defaultValue, flagValueType, clientMetadata, providerMetadata); + + this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext)); + this.Data = new HookData(); + } + + internal HookContext(SharedHookContext? sharedHookContext, EvaluationContext? evaluationContext, + HookData? hookData) + { + this._shared = sharedHookContext ?? throw new ArgumentNullException(nameof(sharedHookContext)); this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext)); + this.Data = hookData ?? throw new ArgumentNullException(nameof(hookData)); } internal HookContext WithNewEvaluationContext(EvaluationContext context) { return new HookContext( - this.FlagKey, - this.DefaultValue, - this.FlagValueType, - this.ClientMetadata, - this.ProviderMetadata, - context + this._shared, + context, + this.Data ); } } diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index 03420a2a4..4a00aa440 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -222,32 +223,29 @@ private async Task> EvaluateFlagAsync( evaluationContextBuilder.Merge(Api.Instance.GetTransactionContext()); // Transaction context evaluationContextBuilder.Merge(context); // Invocation context - var allHooks = new List() + var allHooks = ImmutableList.CreateBuilder() .Concat(Api.Instance.GetHooks()) .Concat(this.GetHooks()) .Concat(options?.Hooks ?? Enumerable.Empty()) .Concat(provider.GetProviderHooks()) - .ToList() - .AsReadOnly(); + .ToImmutableList(); - var allHooksReversed = allHooks - .AsEnumerable() - .Reverse() - .ToList() - .AsReadOnly(); - - var hookContext = new HookContext( + var sharedHookContext = new SharedHookContext( flagKey, defaultValue, - flagValueType, this._metadata, - provider.GetMetadata(), - evaluationContextBuilder.Build() + flagValueType, + this._metadata, + provider.GetMetadata() ); FlagEvaluationDetails? evaluation = null; + var hookRunner = new HookRunner(allHooks, evaluationContextBuilder.Build(), sharedHookContext, + this._logger); + try { - var contextFromHooks = await this.TriggerBeforeHooksAsync(allHooks, hookContext, options, cancellationToken).ConfigureAwait(false); + var evaluationContextFromHooks = await hookRunner.TriggerBeforeHooksAsync(options?.HookHints, cancellationToken) + .ConfigureAwait(false); // short circuit evaluation entirely if provider is in a bad state if (provider.Status == ProviderStatus.NotReady) @@ -260,23 +258,24 @@ private async Task> EvaluateFlagAsync( } evaluation = - (await resolveValueDelegate.Invoke(flagKey, defaultValue, contextFromHooks.EvaluationContext, cancellationToken).ConfigureAwait(false)) + (await resolveValueDelegate + .Invoke(flagKey, defaultValue, evaluationContextFromHooks, cancellationToken) + .ConfigureAwait(false)) .ToFlagEvaluationDetails(); if (evaluation.ErrorType == ErrorType.None) { - await this.TriggerAfterHooksAsync( - allHooksReversed, - hookContext, + await hookRunner.TriggerAfterHooksAsync( evaluation, - options, + options?.HookHints, cancellationToken ).ConfigureAwait(false); } else { var exception = new FeatureProviderException(evaluation.ErrorType, evaluation.ErrorMessage); - await this.TriggerErrorHooksAsync(allHooksReversed, hookContext, exception, options, cancellationToken) + this.FlagEvaluationErrorWithDescription(flagKey, evaluation.ErrorType.GetDescription(), exception); + await hookRunner.TriggerErrorHooksAsync(exception, options?.HookHints, cancellationToken) .ConfigureAwait(false); } } @@ -285,88 +284,29 @@ await this.TriggerErrorHooksAsync(allHooksReversed, hookContext, exception, opti this.FlagEvaluationErrorWithDescription(flagKey, ex.ErrorType.GetDescription(), ex); evaluation = new FlagEvaluationDetails(flagKey, defaultValue, ex.ErrorType, Reason.Error, string.Empty, ex.Message); - await this.TriggerErrorHooksAsync(allHooksReversed, hookContext, ex, options, cancellationToken).ConfigureAwait(false); + await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken) + .ConfigureAwait(false); } catch (Exception ex) { var errorCode = ex is InvalidCastException ? ErrorType.TypeMismatch : ErrorType.General; - evaluation = new FlagEvaluationDetails(flagKey, defaultValue, errorCode, Reason.Error, string.Empty, ex.Message); - await this.TriggerErrorHooksAsync(allHooksReversed, hookContext, ex, options, cancellationToken).ConfigureAwait(false); + evaluation = new FlagEvaluationDetails(flagKey, defaultValue, errorCode, Reason.Error, string.Empty, + ex.Message); + await hookRunner.TriggerErrorHooksAsync(ex, options?.HookHints, cancellationToken) + .ConfigureAwait(false); } finally { - evaluation ??= new FlagEvaluationDetails(flagKey, defaultValue, ErrorType.General, Reason.Error, string.Empty, + evaluation ??= new FlagEvaluationDetails(flagKey, defaultValue, ErrorType.General, Reason.Error, + string.Empty, "Evaluation failed to return a result."); - await this.TriggerFinallyHooksAsync(allHooksReversed, evaluation, hookContext, options, cancellationToken).ConfigureAwait(false); + await hookRunner.TriggerFinallyHooksAsync(evaluation, options?.HookHints, cancellationToken) + .ConfigureAwait(false); } return evaluation; } - private async Task> TriggerBeforeHooksAsync(IReadOnlyList hooks, HookContext context, - FlagEvaluationOptions? options, CancellationToken cancellationToken = default) - { - var evalContextBuilder = EvaluationContext.Builder(); - evalContextBuilder.Merge(context.EvaluationContext); - - foreach (var hook in hooks) - { - var resp = await hook.BeforeAsync(context, options?.HookHints, cancellationToken).ConfigureAwait(false); - if (resp != null) - { - evalContextBuilder.Merge(resp); - context = context.WithNewEvaluationContext(evalContextBuilder.Build()); - } - else - { - this.HookReturnedNull(hook.GetType().Name); - } - } - - return context.WithNewEvaluationContext(evalContextBuilder.Build()); - } - - private async Task TriggerAfterHooksAsync(IReadOnlyList hooks, HookContext context, - FlagEvaluationDetails evaluationDetails, FlagEvaluationOptions? options, CancellationToken cancellationToken = default) - { - foreach (var hook in hooks) - { - await hook.AfterAsync(context, evaluationDetails, options?.HookHints, cancellationToken).ConfigureAwait(false); - } - } - - private async Task TriggerErrorHooksAsync(IReadOnlyList hooks, HookContext context, Exception exception, - FlagEvaluationOptions? options, CancellationToken cancellationToken = default) - { - foreach (var hook in hooks) - { - try - { - await hook.ErrorAsync(context, exception, options?.HookHints, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - this.ErrorHookError(hook.GetType().Name, e); - } - } - } - - private async Task TriggerFinallyHooksAsync(IReadOnlyList hooks, FlagEvaluationDetails evaluation, - HookContext context, FlagEvaluationOptions? options, CancellationToken cancellationToken = default) - { - foreach (var hook in hooks) - { - try - { - await hook.FinallyAsync(context, evaluation, options?.HookHints, cancellationToken).ConfigureAwait(false); - } - catch (Exception e) - { - this.FinallyHookError(hook.GetType().Name, e); - } - } - } - /// /// Use this method to track user interactions and the application state. /// @@ -392,16 +332,13 @@ public void Track(string trackingEventName, EvaluationContext? evaluationContext this._providerAccessor.Invoke().Track(trackingEventName, evaluationContextBuilder.Build(), trackingEventDetails); } + [LoggerMessage(101, LogLevel.Error, "Error while evaluating flag {FlagKey}")] + partial void FlagEvaluationError(string flagKey, Exception exception); + [LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")] partial void HookReturnedNull(string hookName); [LoggerMessage(102, LogLevel.Error, "Error while evaluating flag {FlagKey}: {ErrorType}")] partial void FlagEvaluationErrorWithDescription(string flagKey, string errorType, Exception exception); - - [LoggerMessage(103, LogLevel.Error, "Error while executing Error hook {HookName}")] - partial void ErrorHookError(string hookName, Exception exception); - - [LoggerMessage(104, LogLevel.Error, "Error while executing Finally hook {HookName}")] - partial void FinallyHookError(string hookName, Exception exception); } } diff --git a/src/OpenFeature/SharedHookContext.cs b/src/OpenFeature/SharedHookContext.cs new file mode 100644 index 000000000..3d6b787c6 --- /dev/null +++ b/src/OpenFeature/SharedHookContext.cs @@ -0,0 +1,60 @@ +using System; +using OpenFeature.Constant; +using OpenFeature.Model; + +namespace OpenFeature +{ + /// + /// Component of the hook context which shared between all hook instances + /// + /// Feature flag key + /// Default value + /// Flag value type + /// Client metadata + /// Provider metadata + /// Flag value type + internal class SharedHookContext( + string? flagKey, + T defaultValue, + FlagValueType flagValueType, + ClientMetadata? clientMetadata, + Metadata? providerMetadata) + { + /// + /// Feature flag being evaluated + /// + public string FlagKey { get; } = flagKey ?? throw new ArgumentNullException(nameof(flagKey)); + + /// + /// Default value if flag fails to be evaluated + /// + public T DefaultValue { get; } = defaultValue; + + /// + /// The value type of the flag + /// + public FlagValueType FlagValueType { get; } = flagValueType; + + /// + /// Client metadata + /// + public ClientMetadata ClientMetadata { get; } = + clientMetadata ?? throw new ArgumentNullException(nameof(clientMetadata)); + + /// + /// Provider metadata + /// + public Metadata ProviderMetadata { get; } = + providerMetadata ?? throw new ArgumentNullException(nameof(providerMetadata)); + + /// + /// Create a hook context from this shared context. + /// + /// Evaluation context + /// A hook context + public HookContext ToHookContext(EvaluationContext? evaluationContext) + { + return new HookContext(this, evaluationContext, new HookData()); + } + } +} diff --git a/test/OpenFeature.Tests/HookDataTests.cs b/test/OpenFeature.Tests/HookDataTests.cs new file mode 100644 index 000000000..96cbaf723 --- /dev/null +++ b/test/OpenFeature.Tests/HookDataTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.Tests; + +public class HookDataTests +{ + private readonly HookData _commonHookData = new(); + + public HookDataTests() + { + this._commonHookData.Set("bool", true); + this._commonHookData.Set("string", "string"); + this._commonHookData.Set("int", 1); + this._commonHookData.Set("double", 1.2); + this._commonHookData.Set("float", 1.2f); + } + + [Fact] + public void HookData_Can_Set_And_Get_Data() + { + var hookData = new HookData(); + hookData.Set("bool", true); + hookData.Set("string", "string"); + hookData.Set("int", 1); + hookData.Set("double", 1.2); + hookData.Set("float", 1.2f); + var structure = Structure.Builder().Build(); + hookData.Set("structure", structure); + + Assert.True((bool)hookData.Get("bool")); + Assert.Equal("string", hookData.Get("string")); + Assert.Equal(1, hookData.Get("int")); + Assert.Equal(1.2, hookData.Get("double")); + Assert.Equal(1.2f, hookData.Get("float")); + Assert.Same(structure, hookData.Get("structure")); + } + + [Fact] + public void HookData_Can_Chain_Set() + { + var structure = Structure.Builder().Build(); + + var hookData = new HookData(); + hookData.Set("bool", true) + .Set("string", "string") + .Set("int", 1) + .Set("double", 1.2) + .Set("float", 1.2f) + .Set("structure", structure); + + Assert.True((bool)hookData.Get("bool")); + Assert.Equal("string", hookData.Get("string")); + Assert.Equal(1, hookData.Get("int")); + Assert.Equal(1.2, hookData.Get("double")); + Assert.Equal(1.2f, hookData.Get("float")); + Assert.Same(structure, hookData.Get("structure")); + } + + [Fact] + public void HookData_Can_Set_And_Get_Data_Using_Indexer() + { + var hookData = new HookData(); + hookData["bool"] = true; + hookData["string"] = "string"; + hookData["int"] = 1; + hookData["double"] = 1.2; + hookData["float"] = 1.2f; + var structure = Structure.Builder().Build(); + hookData["structure"] = structure; + + Assert.True((bool)hookData["bool"]); + Assert.Equal("string", hookData["string"]); + Assert.Equal(1, hookData["int"]); + Assert.Equal(1.2, hookData["double"]); + Assert.Equal(1.2f, hookData["float"]); + Assert.Same(structure, hookData["structure"]); + } + + [Fact] + public void HookData_Can_Be_Enumerated() + { + var asList = new List>(); + foreach (var kvp in this._commonHookData) + { + asList.Add(kvp); + } + + asList.Sort((a, b) => + string.Compare(a.Key, b.Key, StringComparison.Ordinal)); + + Assert.Equal([ + new KeyValuePair("bool", true), + new KeyValuePair("double", 1.2), + new KeyValuePair("float", 1.2f), + new KeyValuePair("int", 1), + new KeyValuePair("string", "string") + ], asList); + } + + [Fact] + public void HookData_Has_Count() + { + Assert.Equal(5, this._commonHookData.Count); + } + + [Fact] + public void HookData_Has_Keys() + { + Assert.Equal(5, this._commonHookData.Keys.Count); + Assert.Contains("bool", this._commonHookData.Keys); + Assert.Contains("double", this._commonHookData.Keys); + Assert.Contains("float", this._commonHookData.Keys); + Assert.Contains("int", this._commonHookData.Keys); + Assert.Contains("string", this._commonHookData.Keys); + } + + [Fact] + public void HookData_Has_Values() + { + Assert.Equal(5, this._commonHookData.Values.Count); + Assert.Contains(true, this._commonHookData.Values); + Assert.Contains(1, this._commonHookData.Values); + Assert.Contains(1.2f, this._commonHookData.Values); + Assert.Contains(1.2, this._commonHookData.Values); + Assert.Contains("string", this._commonHookData.Values); + } + + [Fact] + public void HookData_Can_Be_Converted_To_Dictionary() + { + var asDictionary = this._commonHookData.AsDictionary(); + Assert.Equal(5, asDictionary.Count); + Assert.Equal(true, asDictionary["bool"]); + Assert.Equal(1.2, asDictionary["double"]); + Assert.Equal(1.2f, asDictionary["float"]); + Assert.Equal(1, asDictionary["int"]); + Assert.Equal("string", asDictionary["string"]); + } + + [Fact] + public void HookData_Get_Should_Throw_When_Key_Not_Found() + { + var hookData = new HookData(); + + Assert.Throws(() => hookData.Get("nonexistent")); + } + + [Fact] + public void HookData_Indexer_Should_Throw_When_Key_Not_Found() + { + var hookData = new HookData(); + + Assert.Throws(() => _ = hookData["nonexistent"]); + } +} diff --git a/test/OpenFeature.Tests/OpenFeatureHookTests.cs b/test/OpenFeature.Tests/OpenFeatureHookTests.cs index bbb4da3fd..ae53f6db4 100644 --- a/test/OpenFeature.Tests/OpenFeatureHookTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureHookTests.cs @@ -90,7 +90,7 @@ await client.GetBooleanValueAsync(flagName, defaultValue, EvaluationContext.Empt } [Fact] - [Specification("4.1.1", "Hook context MUST provide: the `flag key`, `flag value type`, `evaluation context`, and the `default value`.")] + [Specification("4.1.1", "Hook context MUST provide: the `flag key`, `flag value type`, `evaluation context`, `default value`, and `hook data`.")] public void Hook_Context_Should_Not_Allow_Nulls() { Assert.Throws(() => @@ -108,6 +108,19 @@ public void Hook_Context_Should_Not_Allow_Nulls() Assert.Throws(() => new HookContext("test", Structure.Empty, FlagValueType.Object, new ClientMetadata(null, null), new Metadata(null), null)); + + Assert.Throws(() => new SharedHookContext("test", Structure.Empty, FlagValueType.Object, + new ClientMetadata(null, null), new Metadata(null)).ToHookContext(null)); + + Assert.Throws(() => + new HookContext(null, EvaluationContext.Empty, + new HookData())); + + Assert.Throws(() => + new HookContext( + new SharedHookContext("test", Structure.Empty, FlagValueType.Object, + new ClientMetadata(null, null), new Metadata(null)), EvaluationContext.Empty, + null)); } [Fact] @@ -151,6 +164,95 @@ await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, _ = hook2.Received(1).BeforeAsync(Arg.Is>(a => a.EvaluationContext.GetValue("test").AsString == "test"), Arg.Any>()); } + [Fact] + [Specification("4.1.5", "The `hook data` MUST be mutable.")] + public async Task HookData_Must_Be_Mutable() + { + var hook = Substitute.For(); + + hook.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty).AndDoes(info => + { + info.Arg>().Data.Set("test-a", true); + }); + hook.AfterAsync(Arg.Any>(), Arg.Any>(), + Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + { + info.Arg>().Data.Set("test-b", "test-value"); + }); + + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var client = Api.Instance.GetClient("test", "1.0.0"); + + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, + new FlagEvaluationOptions(ImmutableList.Create(hook), ImmutableDictionary.Empty)); + + _ = hook.Received(1).AfterAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("test-a") == true + ), Arg.Any>(), Arg.Any>()); + _ = hook.Received(1).FinallyAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("test-a") == true && (string)hookContext.Data.Get("test-b") == "test-value" + ), Arg.Any>(), Arg.Any>()); + } + + [Fact] + [Specification("4.3.2", + "`Hook data` **MUST** must be created before the first `stage` invoked in a hook for a specific evaluation and propagated between each `stage` of the hook. The hook data is not shared between different hooks.")] + public async Task HookData_Must_Be_Unique_Per_Hook() + { + var hook1 = Substitute.For(); + var hook2 = Substitute.For(); + + hook1.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty).AndDoes(info => + { + info.Arg>().Data.Set("hook-1-value-a", true); + info.Arg>().Data.Set("same", true); + }); + hook1.AfterAsync(Arg.Any>(), Arg.Any>(), + Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + { + info.Arg>().Data.Set("hook-1-value-b", "test-value-hook-1"); + }); + + hook2.BeforeAsync(Arg.Any>(), Arg.Any>()) + .Returns(EvaluationContext.Empty).AndDoes(info => + { + info.Arg>().Data.Set("hook-2-value-a", false); + info.Arg>().Data.Set("same", false); + }); + hook2.AfterAsync(Arg.Any>(), Arg.Any>(), + Arg.Any>()).Returns(new ValueTask()).AndDoes(info => + { + info.Arg>().Data.Set("hook-2-value-b", "test-value-hook-2"); + }); + + await Api.Instance.SetProviderAsync(new NoOpFeatureProvider()); + var client = Api.Instance.GetClient("test", "1.0.0"); + + await client.GetBooleanValueAsync("test", false, EvaluationContext.Empty, + new FlagEvaluationOptions(ImmutableList.Create(hook1, hook2), + ImmutableDictionary.Empty)); + + _ = hook1.Received(1).AfterAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-1-value-a") == true && (bool)hookContext.Data.Get("same") == true + ), Arg.Any>(), Arg.Any>()); + _ = hook1.Received(1).FinallyAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-1-value-a") == true && + (bool)hookContext.Data.Get("same") == true && + (string)hookContext.Data.Get("hook-1-value-b") == "test-value-hook-1" && hookContext.Data.Count == 3 + ), Arg.Any>(), Arg.Any>()); + + _ = hook2.Received(1).AfterAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-2-value-a") == false && (bool)hookContext.Data.Get("same") == false + ), Arg.Any>(), Arg.Any>()); + _ = hook2.Received(1).FinallyAsync(Arg.Is>(hookContext => + (bool)hookContext.Data.Get("hook-2-value-a") == false && + (bool)hookContext.Data.Get("same") == false && + (string)hookContext.Data.Get("hook-2-value-b") == "test-value-hook-2" && hookContext.Data.Count == 3 + ), Arg.Any>(), Arg.Any>()); + } + [Fact] [Specification("3.2.2", "Evaluation context MUST be merged in the order: API (global; lowest precedence) - client - invocation - before hooks (highest precedence), with duplicate values being overwritten.")] [Specification("4.3.4", "When `before` hooks have finished executing, any resulting `evaluation context` MUST be merged with the existing `evaluation context`.")] @@ -394,7 +496,7 @@ public async Task Error_Hook_Should_Be_Executed_Even_If_Abnormal_Termination() hook1.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); hook2.BeforeAsync(Arg.Any>(), null).Returns(EvaluationContext.Empty); featureProvider1.ResolveBooleanValueAsync(Arg.Any(), Arg.Any(), Arg.Any()).Throws(new Exception()); - hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null).Returns(new ValueTask()); + hook2.ErrorAsync(Arg.Any>(), Arg.Any(), null).Throws(new Exception()); hook1.ErrorAsync(Arg.Any>(), Arg.Any(), null).Returns(new ValueTask()); await Api.Instance.SetProviderAsync(featureProvider1);