Skip to content
Next Next commit
feat: Add support for hook data.
Signed-off-by: Ryan Lamb <[email protected]>
  • Loading branch information
kinyoklion committed Feb 24, 2025
commit a8a7f5904dc88cfd63c848b1dddc5dd7670ece5f
92 changes: 92 additions & 0 deletions src/OpenFeature/HookData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using OpenFeature.Model;

namespace OpenFeature
{
/// <summary>
/// A key-value collection of strings to objects used for passing data between hook stages.
/// <para>
/// This collection is scoped to a single evaluation for a single hook. Each hook stage for the evaluation
/// will share the same <see cref="HookData"/>.
/// </para>
/// <para>
/// This collection is intended for use only during the execution of individual hook stages, a reference
/// to the collection should not be retained.
/// </para>
/// <para>
/// This collection is not thread-safe.
/// </para>
/// </summary>
/// <seealso href="https://github.com/open-feature/spec/blob/main/specification/sections/04-hooks.md#46-hook-data"/>
public sealed class HookData
{
private readonly Dictionary<string, object> _data = [];

/// <summary>
/// Set the key to the given value.
/// </summary>
/// <param name="key">The key for the value</param>
/// <param name="value">The value to set</param>
public void Set(string key, object value)
{
this._data[key] = value;
}

/// <summary>
/// Gets the value at the specified key as an object.
/// <remarks>
/// For <see cref="Value"/> types use <see cref="Get"/> instead.
/// </remarks>
/// </summary>
/// <param name="key">The key of the value to be retrieved</param>
/// <returns>The object associated with the key</returns>
/// <exception cref="KeyNotFoundException">
/// Thrown when the context does not contain the specified key
/// </exception>
/// <exception cref="ArgumentNullException">
/// Thrown when the key is <see langword="null" />
/// </exception>
public object Get(string key)
{
return this._data[key];
}

/// <summary>
/// Return a count of all values.
/// </summary>
public int Count => this._data.Count;

/// <summary>
/// Return an enumerator for all values.
/// </summary>
/// <returns>An enumerator for all values</returns>
public IEnumerator<KeyValuePair<string, object>> GetEnumerator()
{
return this._data.GetEnumerator();
}

/// <summary>
/// Return a list containing all the keys in the hook data
/// </summary>
public IImmutableList<string> Keys => this._data.Keys.ToImmutableList();

/// <summary>
/// Return an enumerable containing all the values of the hook data
/// </summary>
public IImmutableList<object> Values => this._data.Values.ToImmutableList();

/// <summary>
/// Gets all values as a read only dictionary.
/// <remarks>
/// The dictionary references the original values and is not a thread-safe copy.
/// </remarks>
/// </summary>
/// <returns>A <see cref="IDictionary{TKey,TValue}"/> representation of the hook data</returns>
public IReadOnlyDictionary<string, object> AsDictionary()
{
return this._data;
}
}
}
173 changes: 173 additions & 0 deletions src/OpenFeature/HookRunner.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// This class manages the execution of hooks.
/// </summary>
/// <typeparam name="T">type of the evaluation detail provided to the hooks</typeparam>
internal partial class HookRunner<T>
{
private readonly ImmutableList<Hook> _hooks;

private readonly List<HookContext<T>> _hookContexts;

private EvaluationContext _evaluationContext;

private readonly ILogger _logger;

/// <summary>
/// Construct a hook runner instance. Each instance should be used for the execution of a single evaluation.
/// </summary>
/// <param name="hooks">
/// The hooks for the evaluation, these should be in the correct order for the before evaluation stage
/// </param>
/// <param name="evaluationContext">
/// The initial evaluation context, this can be updates as the hooks execute
/// </param>
/// <param name="sharedHookContext">
/// Contents of the initial hook context excluding the evaluation context and hook data
/// </param>
/// <param name="logger">Client logger instance</param>
public HookRunner(ImmutableList<Hook> hooks, EvaluationContext evaluationContext,
SharedHookContext<T> sharedHookContext,
ILogger logger)
{
this._evaluationContext = evaluationContext;
this._logger = logger;
this._hooks = hooks;
this._hookContexts = new List<HookContext<T>>(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));
}
}

/// <summary>
/// Execute before hooks.
/// </summary>
/// <param name="hints">Optional hook hints</param>
/// <param name="cancellationToken">Cancellation token which can cancel hook operations</param>
/// <returns>Context with any modifications from the before hooks</returns>
public async Task<EvaluationContext> TriggerBeforeHooksAsync(IImmutableDictionary<string, object>? 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;
}

/// <summary>
/// Execute the after hooks. These are executed in opposite order of the before hooks.
/// </summary>
/// <param name="evaluationDetails">The evaluation details which will be provided to the hook</param>
/// <param name="hints">Optional hook hints</param>
/// <param name="cancellationToken">Cancellation token which can cancel hook operations</param>
public async Task TriggerAfterHooksAsync(FlagEvaluationDetails<T> evaluationDetails,
IImmutableDictionary<string, object>? 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);
}
}

/// <summary>
/// Execute the error hooks. These are executed in opposite order of the before hooks.
/// </summary>
/// <param name="exception">Exception which triggered the error</param>
/// <param name="hints">Optional hook hints</param>
/// <param name="cancellationToken">Cancellation token which can cancel hook operations</param>
public async Task TriggerErrorHooksAsync(Exception exception,
IImmutableDictionary<string, object>? 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);
}

Check warning on line 133 in src/OpenFeature/HookRunner.cs

View check run for this annotation

Codecov / codecov/patch

src/OpenFeature/HookRunner.cs#L130-L133

Added lines #L130 - L133 were not covered by tests
}
}

/// <summary>
/// Execute the finally hooks. These are executed in opposite order of the before hooks.
/// </summary>
/// <param name="evaluationDetails">The evaluation details which will be provided to the hook</param>
/// <param name="hints">Optional hook hints</param>
/// <param name="cancellationToken">Cancellation token which can cancel hook operations</param>
public async Task TriggerFinallyHooksAsync(FlagEvaluationDetails<T> evaluationDetails,
IImmutableDictionary<string, object>? 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);
}
}
43 changes: 27 additions & 16 deletions src/OpenFeature/Model/HookContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,22 @@ namespace OpenFeature.Model
/// <seealso href="https://github.com/open-feature/spec/blob/v0.5.2/specification/sections/04-hooks.md#41-hook-context"/>
public sealed class HookContext<T>
{
private readonly SharedHookContext<T> _shared;

/// <summary>
/// Feature flag being evaluated
/// </summary>
public string FlagKey { get; }
public string FlagKey => this._shared.FlagKey;

/// <summary>
/// Default value if flag fails to be evaluated
/// </summary>
public T DefaultValue { get; }
public T DefaultValue => this._shared.DefaultValue;

/// <summary>
/// The value type of the flag
/// </summary>
public FlagValueType FlagValueType { get; }
public FlagValueType FlagValueType => this._shared.FlagValueType;

/// <summary>
/// User defined evaluation context used in the evaluation process
Expand All @@ -34,12 +36,17 @@ public sealed class HookContext<T>
/// <summary>
/// Client metadata
/// </summary>
public ClientMetadata ClientMetadata { get; }
public ClientMetadata ClientMetadata => this._shared.ClientMetadata;

/// <summary>
/// Provider metadata
/// </summary>
public Metadata ProviderMetadata { get; }
public Metadata ProviderMetadata => this._shared.ProviderMetadata;

/// <summary>
/// Hook data
/// </summary>
public HookData Data { get; }

/// <summary>
/// Initialize a new instance of <see cref="HookContext{T}"/>
Expand All @@ -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<T>(
flagKey, defaultValue, flagValueType, clientMetadata, providerMetadata);

this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext));
this.Data = new HookData();
}

internal HookContext(SharedHookContext<T>? 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<T> WithNewEvaluationContext(EvaluationContext context)
{
return new HookContext<T>(
this.FlagKey,
this.DefaultValue,
this.FlagValueType,
this.ClientMetadata,
this.ProviderMetadata,
context
this._shared,
context,
this.Data
);
}
}
Expand Down
Loading