Skip to content

Commit a8a7f59

Browse files
committed
feat: Add support for hook data.
Signed-off-by: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>
1 parent 9185b76 commit a8a7f59

File tree

7 files changed

+579
-115
lines changed

7 files changed

+579
-115
lines changed

src/OpenFeature/HookData.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.Immutable;
4+
using OpenFeature.Model;
5+
6+
namespace OpenFeature
7+
{
8+
/// <summary>
9+
/// A key-value collection of strings to objects used for passing data between hook stages.
10+
/// <para>
11+
/// This collection is scoped to a single evaluation for a single hook. Each hook stage for the evaluation
12+
/// will share the same <see cref="HookData"/>.
13+
/// </para>
14+
/// <para>
15+
/// This collection is intended for use only during the execution of individual hook stages, a reference
16+
/// to the collection should not be retained.
17+
/// </para>
18+
/// <para>
19+
/// This collection is not thread-safe.
20+
/// </para>
21+
/// </summary>
22+
/// <seealso href="https://github.com/open-feature/spec/blob/main/specification/sections/04-hooks.md#46-hook-data"/>
23+
public sealed class HookData
24+
{
25+
private readonly Dictionary<string, object> _data = [];
26+
27+
/// <summary>
28+
/// Set the key to the given value.
29+
/// </summary>
30+
/// <param name="key">The key for the value</param>
31+
/// <param name="value">The value to set</param>
32+
public void Set(string key, object value)
33+
{
34+
this._data[key] = value;
35+
}
36+
37+
/// <summary>
38+
/// Gets the value at the specified key as an object.
39+
/// <remarks>
40+
/// For <see cref="Value"/> types use <see cref="Get"/> instead.
41+
/// </remarks>
42+
/// </summary>
43+
/// <param name="key">The key of the value to be retrieved</param>
44+
/// <returns>The object associated with the key</returns>
45+
/// <exception cref="KeyNotFoundException">
46+
/// Thrown when the context does not contain the specified key
47+
/// </exception>
48+
/// <exception cref="ArgumentNullException">
49+
/// Thrown when the key is <see langword="null" />
50+
/// </exception>
51+
public object Get(string key)
52+
{
53+
return this._data[key];
54+
}
55+
56+
/// <summary>
57+
/// Return a count of all values.
58+
/// </summary>
59+
public int Count => this._data.Count;
60+
61+
/// <summary>
62+
/// Return an enumerator for all values.
63+
/// </summary>
64+
/// <returns>An enumerator for all values</returns>
65+
public IEnumerator<KeyValuePair<string, object>> GetEnumerator()
66+
{
67+
return this._data.GetEnumerator();
68+
}
69+
70+
/// <summary>
71+
/// Return a list containing all the keys in the hook data
72+
/// </summary>
73+
public IImmutableList<string> Keys => this._data.Keys.ToImmutableList();
74+
75+
/// <summary>
76+
/// Return an enumerable containing all the values of the hook data
77+
/// </summary>
78+
public IImmutableList<object> Values => this._data.Values.ToImmutableList();
79+
80+
/// <summary>
81+
/// Gets all values as a read only dictionary.
82+
/// <remarks>
83+
/// The dictionary references the original values and is not a thread-safe copy.
84+
/// </remarks>
85+
/// </summary>
86+
/// <returns>A <see cref="IDictionary{TKey,TValue}"/> representation of the hook data</returns>
87+
public IReadOnlyDictionary<string, object> AsDictionary()
88+
{
89+
return this._data;
90+
}
91+
}
92+
}

src/OpenFeature/HookRunner.cs

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.Immutable;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Microsoft.Extensions.Logging;
7+
using OpenFeature.Model;
8+
9+
namespace OpenFeature
10+
{
11+
/// <summary>
12+
/// This class manages the execution of hooks.
13+
/// </summary>
14+
/// <typeparam name="T">type of the evaluation detail provided to the hooks</typeparam>
15+
internal partial class HookRunner<T>
16+
{
17+
private readonly ImmutableList<Hook> _hooks;
18+
19+
private readonly List<HookContext<T>> _hookContexts;
20+
21+
private EvaluationContext _evaluationContext;
22+
23+
private readonly ILogger _logger;
24+
25+
/// <summary>
26+
/// Construct a hook runner instance. Each instance should be used for the execution of a single evaluation.
27+
/// </summary>
28+
/// <param name="hooks">
29+
/// The hooks for the evaluation, these should be in the correct order for the before evaluation stage
30+
/// </param>
31+
/// <param name="evaluationContext">
32+
/// The initial evaluation context, this can be updates as the hooks execute
33+
/// </param>
34+
/// <param name="sharedHookContext">
35+
/// Contents of the initial hook context excluding the evaluation context and hook data
36+
/// </param>
37+
/// <param name="logger">Client logger instance</param>
38+
public HookRunner(ImmutableList<Hook> hooks, EvaluationContext evaluationContext,
39+
SharedHookContext<T> sharedHookContext,
40+
ILogger logger)
41+
{
42+
this._evaluationContext = evaluationContext;
43+
this._logger = logger;
44+
this._hooks = hooks;
45+
this._hookContexts = new List<HookContext<T>>(hooks.Count);
46+
for (var i = 0; i < hooks.Count; i++)
47+
{
48+
// Create hook instance specific hook context.
49+
// Hook contexts are instance specific so that the mutable hook data is scoped to each hook.
50+
this._hookContexts.Add(sharedHookContext.ToHookContext(evaluationContext));
51+
}
52+
}
53+
54+
/// <summary>
55+
/// Execute before hooks.
56+
/// </summary>
57+
/// <param name="hints">Optional hook hints</param>
58+
/// <param name="cancellationToken">Cancellation token which can cancel hook operations</param>
59+
/// <returns>Context with any modifications from the before hooks</returns>
60+
public async Task<EvaluationContext> TriggerBeforeHooksAsync(IImmutableDictionary<string, object>? hints,
61+
CancellationToken cancellationToken = default)
62+
{
63+
var evalContextBuilder = EvaluationContext.Builder();
64+
evalContextBuilder.Merge(this._evaluationContext);
65+
66+
for (var i = 0; i < this._hooks.Count; i++)
67+
{
68+
var hook = this._hooks[i];
69+
var hookContext = this._hookContexts[i];
70+
71+
var resp = await hook.BeforeAsync(hookContext, hints, cancellationToken)
72+
.ConfigureAwait(false);
73+
if (resp != null)
74+
{
75+
evalContextBuilder.Merge(resp);
76+
this._evaluationContext = evalContextBuilder.Build();
77+
for (var j = 0; j < this._hookContexts.Count; j++)
78+
{
79+
this._hookContexts[j] = this._hookContexts[j].WithNewEvaluationContext(this._evaluationContext);
80+
}
81+
}
82+
else
83+
{
84+
this.HookReturnedNull(hook.GetType().Name);
85+
}
86+
}
87+
88+
return this._evaluationContext;
89+
}
90+
91+
/// <summary>
92+
/// Execute the after hooks. These are executed in opposite order of the before hooks.
93+
/// </summary>
94+
/// <param name="evaluationDetails">The evaluation details which will be provided to the hook</param>
95+
/// <param name="hints">Optional hook hints</param>
96+
/// <param name="cancellationToken">Cancellation token which can cancel hook operations</param>
97+
public async Task TriggerAfterHooksAsync(FlagEvaluationDetails<T> evaluationDetails,
98+
IImmutableDictionary<string, object>? hints,
99+
CancellationToken cancellationToken = default)
100+
{
101+
// After hooks run in reverse.
102+
for (var i = this._hooks.Count - 1; i >= 0; i--)
103+
{
104+
var hook = this._hooks[i];
105+
var hookContext = this._hookContexts[i];
106+
await hook.AfterAsync(hookContext, evaluationDetails, hints, cancellationToken)
107+
.ConfigureAwait(false);
108+
}
109+
}
110+
111+
/// <summary>
112+
/// Execute the error hooks. These are executed in opposite order of the before hooks.
113+
/// </summary>
114+
/// <param name="exception">Exception which triggered the error</param>
115+
/// <param name="hints">Optional hook hints</param>
116+
/// <param name="cancellationToken">Cancellation token which can cancel hook operations</param>
117+
public async Task TriggerErrorHooksAsync(Exception exception,
118+
IImmutableDictionary<string, object>? hints, CancellationToken cancellationToken = default)
119+
{
120+
// Error hooks run in reverse.
121+
for (var i = this._hooks.Count - 1; i >= 0; i--)
122+
{
123+
var hook = this._hooks[i];
124+
var hookContext = this._hookContexts[i];
125+
try
126+
{
127+
await hook.ErrorAsync(hookContext, exception, hints, cancellationToken)
128+
.ConfigureAwait(false);
129+
}
130+
catch (Exception e)
131+
{
132+
this.ErrorHookError(hook.GetType().Name, e);
133+
}
134+
}
135+
}
136+
137+
/// <summary>
138+
/// Execute the finally hooks. These are executed in opposite order of the before hooks.
139+
/// </summary>
140+
/// <param name="evaluationDetails">The evaluation details which will be provided to the hook</param>
141+
/// <param name="hints">Optional hook hints</param>
142+
/// <param name="cancellationToken">Cancellation token which can cancel hook operations</param>
143+
public async Task TriggerFinallyHooksAsync(FlagEvaluationDetails<T> evaluationDetails,
144+
IImmutableDictionary<string, object>? hints,
145+
CancellationToken cancellationToken = default)
146+
{
147+
// Finally hooks run in reverse
148+
for (var i = this._hooks.Count - 1; i >= 0; i--)
149+
{
150+
var hook = this._hooks[i];
151+
var hookContext = this._hookContexts[i];
152+
try
153+
{
154+
await hook.FinallyAsync(hookContext, evaluationDetails, hints, cancellationToken)
155+
.ConfigureAwait(false);
156+
}
157+
catch (Exception e)
158+
{
159+
this.FinallyHookError(hook.GetType().Name, e);
160+
}
161+
}
162+
}
163+
164+
[LoggerMessage(100, LogLevel.Debug, "Hook {HookName} returned null, nothing to merge back into context")]
165+
partial void HookReturnedNull(string hookName);
166+
167+
[LoggerMessage(103, LogLevel.Error, "Error while executing Error hook {HookName}")]
168+
partial void ErrorHookError(string hookName, Exception exception);
169+
170+
[LoggerMessage(104, LogLevel.Error, "Error while executing Finally hook {HookName}")]
171+
partial void FinallyHookError(string hookName, Exception exception);
172+
}
173+
}

src/OpenFeature/Model/HookContext.cs

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,22 @@ namespace OpenFeature.Model
1010
/// <seealso href="https://github.com/open-feature/spec/blob/v0.5.2/specification/sections/04-hooks.md#41-hook-context"/>
1111
public sealed class HookContext<T>
1212
{
13+
private readonly SharedHookContext<T> _shared;
14+
1315
/// <summary>
1416
/// Feature flag being evaluated
1517
/// </summary>
16-
public string FlagKey { get; }
18+
public string FlagKey => this._shared.FlagKey;
1719

1820
/// <summary>
1921
/// Default value if flag fails to be evaluated
2022
/// </summary>
21-
public T DefaultValue { get; }
23+
public T DefaultValue => this._shared.DefaultValue;
2224

2325
/// <summary>
2426
/// The value type of the flag
2527
/// </summary>
26-
public FlagValueType FlagValueType { get; }
28+
public FlagValueType FlagValueType => this._shared.FlagValueType;
2729

2830
/// <summary>
2931
/// User defined evaluation context used in the evaluation process
@@ -34,12 +36,17 @@ public sealed class HookContext<T>
3436
/// <summary>
3537
/// Client metadata
3638
/// </summary>
37-
public ClientMetadata ClientMetadata { get; }
39+
public ClientMetadata ClientMetadata => this._shared.ClientMetadata;
3840

3941
/// <summary>
4042
/// Provider metadata
4143
/// </summary>
42-
public Metadata ProviderMetadata { get; }
44+
public Metadata ProviderMetadata => this._shared.ProviderMetadata;
45+
46+
/// <summary>
47+
/// Hook data
48+
/// </summary>
49+
public HookData Data { get; }
4350

4451
/// <summary>
4552
/// Initialize a new instance of <see cref="HookContext{T}"/>
@@ -58,23 +65,27 @@ public HookContext(string? flagKey,
5865
Metadata? providerMetadata,
5966
EvaluationContext? evaluationContext)
6067
{
61-
this.FlagKey = flagKey ?? throw new ArgumentNullException(nameof(flagKey));
62-
this.DefaultValue = defaultValue;
63-
this.FlagValueType = flagValueType;
64-
this.ClientMetadata = clientMetadata ?? throw new ArgumentNullException(nameof(clientMetadata));
65-
this.ProviderMetadata = providerMetadata ?? throw new ArgumentNullException(nameof(providerMetadata));
68+
this._shared = new SharedHookContext<T>(
69+
flagKey, defaultValue, flagValueType, clientMetadata, providerMetadata);
70+
71+
this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext));
72+
this.Data = new HookData();
73+
}
74+
75+
internal HookContext(SharedHookContext<T>? sharedHookContext, EvaluationContext? evaluationContext,
76+
HookData? hookData)
77+
{
78+
this._shared = sharedHookContext ?? throw new ArgumentNullException(nameof(sharedHookContext));
6679
this.EvaluationContext = evaluationContext ?? throw new ArgumentNullException(nameof(evaluationContext));
80+
this.Data = hookData ?? throw new ArgumentNullException(nameof(hookData));
6781
}
6882

6983
internal HookContext<T> WithNewEvaluationContext(EvaluationContext context)
7084
{
7185
return new HookContext<T>(
72-
this.FlagKey,
73-
this.DefaultValue,
74-
this.FlagValueType,
75-
this.ClientMetadata,
76-
this.ProviderMetadata,
77-
context
86+
this._shared,
87+
context,
88+
this.Data
7889
);
7990
}
8091
}

0 commit comments

Comments
 (0)