Skip to content

Commit

Permalink
feat: Add support for hook data.
Browse files Browse the repository at this point in the history
Signed-off-by: Ryan Lamb <[email protected]>
  • Loading branch information
kinyoklion committed Feb 23, 2025
1 parent 9185b76 commit 1264589
Show file tree
Hide file tree
Showing 6 changed files with 390 additions and 114 deletions.
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();
}

Check warning on line 68 in src/OpenFeature/HookData.cs

View check run for this annotation

Codecov / codecov/patch

src/OpenFeature/HookData.cs#L66-L68

Added lines #L66 - L68 were not covered by tests

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

Check warning on line 73 in src/OpenFeature/HookData.cs

View check run for this annotation

Codecov / codecov/patch

src/OpenFeature/HookData.cs#L73

Added line #L73 was not covered by tests

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

Check warning on line 78 in src/OpenFeature/HookData.cs

View check run for this annotation

Codecov / codecov/patch

src/OpenFeature/HookData.cs#L78

Added line #L78 was not covered by tests

/// <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;
}

Check warning on line 90 in src/OpenFeature/HookData.cs

View check run for this annotation

Codecov / codecov/patch

src/OpenFeature/HookData.cs#L88-L90

Added lines #L88 - L90 were not covered by tests
}
}
128 changes: 128 additions & 0 deletions src/OpenFeature/HookRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using OpenFeature.Model;

namespace OpenFeature;

internal partial class HookRunner<T>
{
private readonly ImmutableList<Hook> _hooks;

private readonly List<HookContext<T>> _hookContexts;

private EvaluationContext _evaluationContext;

private readonly ILogger _logger;

public HookRunner(ImmutableList<Hook>? hooks, EvaluationContext evaluationContext,
SharedHookContext<T> sharedHookContext,
ILogger? logger = null)
{
this._evaluationContext = evaluationContext;
this._logger = logger ?? NullLogger<FeatureClient>.Instance;
this._hooks = hooks ?? throw new ArgumentNullException(nameof(hooks));
this._hookContexts = new List<HookContext<T>>(hooks.Count);
for (var i = 0; i < hooks.Count; i++)
{
this._hookContexts.Add(sharedHookContext.ToHookContext(evaluationContext));
}
}

public async Task<EvaluationContext> TriggerBeforeHooksAsync(FlagEvaluationOptions? options,
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, options?.HookHints, 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;
}

public async Task TriggerAfterHooksAsync(FlagEvaluationDetails<T> evaluationDetails,
FlagEvaluationOptions? options,
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, options?.HookHints, cancellationToken)
.ConfigureAwait(false);
}
}

public async Task TriggerErrorHooksAsync(Exception exception,
FlagEvaluationOptions? options, 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, options?.HookHints, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception e)
{
this.ErrorHookError(hook.GetType().Name, e);
}

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

View check run for this annotation

Codecov / codecov/patch

src/OpenFeature/HookRunner.cs#L93-L96

Added lines #L93 - L96 were not covered by tests
}
}

public async Task TriggerFinallyHooksAsync(FlagEvaluationDetails<T> evaluation, FlagEvaluationOptions? options,
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, evaluation, options?.HookHints, 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

0 comments on commit 1264589

Please sign in to comment.