diff --git a/src/OpenFeature/Api.cs b/src/OpenFeature/Api.cs index bc0499dd..5c45a72a 100644 --- a/src/OpenFeature/Api.cs +++ b/src/OpenFeature/Api.cs @@ -22,6 +22,8 @@ public sealed class Api : IEventBus private EventExecutor _eventExecutor = new EventExecutor(); private ProviderRepository _repository = new ProviderRepository(); private readonly ConcurrentStack _hooks = new ConcurrentStack(); + private ITransactionContextPropagator _transactionContextPropagator = new NoOpTransactionContextPropagator(); + private readonly object _transactionContextPropagatorLock = new(); /// The reader/writer locks are not disposed because the singleton instance should never be disposed. private readonly ReaderWriterLockSlim _evaluationContextLock = new ReaderWriterLockSlim(); @@ -47,6 +49,7 @@ public async Task SetProviderAsync(FeatureProvider featureProvider) { this._eventExecutor.RegisterDefaultFeatureProvider(featureProvider); await this._repository.SetProviderAsync(featureProvider, this.GetContext(), this.AfterInitialization, this.AfterError).ConfigureAwait(false); + } /// @@ -85,7 +88,6 @@ public FeatureProvider GetProvider() /// Gets the feature provider with given domain /// /// An identifier which logically binds clients with providers - /// A provider associated with the given domain, if domain is empty or doesn't /// have a corresponding provider the default provider will be returned public FeatureProvider GetProvider(string domain) @@ -109,7 +111,6 @@ public FeatureProvider GetProvider(string domain) /// assigned to it the default provider will be returned /// /// An identifier which logically binds clients with providers - /// Metadata assigned to provider public Metadata? GetProviderMetadata(string domain) => this.GetProvider(domain).GetMetadata(); @@ -218,6 +219,59 @@ public EvaluationContext GetContext() } } + /// + /// Return the transaction context propagator. + /// + /// the registered transaction context propagator + public ITransactionContextPropagator GetTransactionContextPropagator() + { + return this._transactionContextPropagator; + } + + /// + /// Sets the transaction context propagator. + /// + /// the transaction context propagator to be registered + /// Transaction context propagator cannot be null + public void SetTransactionContextPropagator(ITransactionContextPropagator transactionContextPropagator) + { + if (transactionContextPropagator == null) + { + throw new ArgumentNullException(nameof(transactionContextPropagator), + "Transaction context propagator cannot be null"); + } + + lock (this._transactionContextPropagatorLock) + { + this._transactionContextPropagator = transactionContextPropagator; + } + } + + /// + /// Returns the currently defined transaction context using the registered transaction context propagator. + /// + /// The current transaction context + public EvaluationContext GetTransactionContext() + { + return this._transactionContextPropagator.GetTransactionContext(); + } + + /// + /// Sets the transaction context using the registered transaction context propagator. + /// + /// The to set + /// Transaction context propagator is not set. + /// Evaluation context cannot be null + public void SetTransactionContext(EvaluationContext evaluationContext) + { + if (evaluationContext == null) + { + throw new ArgumentNullException(nameof(evaluationContext), "Evaluation context cannot be null"); + } + + this._transactionContextPropagator.SetTransactionContext(evaluationContext); + } + /// /// /// Shut down and reset the current status of OpenFeature API. @@ -234,6 +288,7 @@ public async Task ShutdownAsync() { this._evaluationContext = EvaluationContext.Empty; this._hooks.Clear(); + this._transactionContextPropagator = new NoOpTransactionContextPropagator(); // TODO: make these lazy to avoid extra allocations on the common cleanup path? this._eventExecutor = new EventExecutor(); diff --git a/src/OpenFeature/AsyncLocalTransactionContextPropagator.cs b/src/OpenFeature/AsyncLocalTransactionContextPropagator.cs new file mode 100644 index 00000000..7aec1c91 --- /dev/null +++ b/src/OpenFeature/AsyncLocalTransactionContextPropagator.cs @@ -0,0 +1,25 @@ +using System.Threading; +using OpenFeature.Model; + +namespace OpenFeature; + +/// +/// It is a no-op implementation of +/// It uses the to store the transaction context. +/// +public sealed class AsyncLocalTransactionContextPropagator : ITransactionContextPropagator +{ + private readonly AsyncLocal _transactionContext = new(); + + /// + public EvaluationContext GetTransactionContext() + { + return this._transactionContext.Value ?? EvaluationContext.Empty; + } + + /// + public void SetTransactionContext(EvaluationContext evaluationContext) + { + this._transactionContext.Value = evaluationContext; + } +} diff --git a/src/OpenFeature/Model/ITransactionContextPropagator.cs b/src/OpenFeature/Model/ITransactionContextPropagator.cs new file mode 100644 index 00000000..3fbc43c9 --- /dev/null +++ b/src/OpenFeature/Model/ITransactionContextPropagator.cs @@ -0,0 +1,26 @@ +namespace OpenFeature.Model; + +/// +/// is responsible for persisting a transactional context +/// for the duration of a single transaction. +/// Examples of potential transaction specific context include: a user id, user agent, IP. +/// Transaction context is merged with evaluation context prior to flag evaluation. +/// +/// +/// The precedence of merging context can be seen in +/// the specification. +/// +public interface ITransactionContextPropagator +{ + /// + /// Returns the currently defined transaction context using the registered transaction context propagator. + /// + /// The current transaction context + EvaluationContext GetTransactionContext(); + + /// + /// Sets the transaction context. + /// + /// The transaction context to be set + void SetTransactionContext(EvaluationContext evaluationContext); +} diff --git a/src/OpenFeature/NoOpTransactionContextPropagator.cs b/src/OpenFeature/NoOpTransactionContextPropagator.cs new file mode 100644 index 00000000..70f57cdc --- /dev/null +++ b/src/OpenFeature/NoOpTransactionContextPropagator.cs @@ -0,0 +1,15 @@ +using OpenFeature.Model; + +namespace OpenFeature; + +internal class NoOpTransactionContextPropagator : ITransactionContextPropagator +{ + public EvaluationContext GetTransactionContext() + { + return EvaluationContext.Empty; + } + + public void SetTransactionContext(EvaluationContext evaluationContext) + { + } +} diff --git a/src/OpenFeature/OpenFeatureClient.cs b/src/OpenFeature/OpenFeatureClient.cs index e774c6b5..47654ff5 100644 --- a/src/OpenFeature/OpenFeatureClient.cs +++ b/src/OpenFeature/OpenFeatureClient.cs @@ -221,6 +221,7 @@ private async Task> EvaluateFlagAsync( evaluationContextBuilder.Merge(evaluationContext); evaluationContextBuilder.Merge(this.GetContext()); evaluationContextBuilder.Merge(context); + evaluationContextBuilder.Merge(Api.Instance.GetTransactionContext()); var allHooks = new List() .Concat(Api.Instance.GetHooks()) diff --git a/test/OpenFeature.Tests/AsyncLocalTransactionContextPropagatorTests.cs b/test/OpenFeature.Tests/AsyncLocalTransactionContextPropagatorTests.cs new file mode 100644 index 00000000..ae44aa4b --- /dev/null +++ b/test/OpenFeature.Tests/AsyncLocalTransactionContextPropagatorTests.cs @@ -0,0 +1,55 @@ +using OpenFeature.Model; +using Xunit; + +namespace OpenFeature.Tests; + +public class AsyncLocalTransactionContextPropagatorTests +{ + [Fact] + public void GetTransactionContext_ReturnsEmpty_WhenNoContextIsSet() + { + // Arrange + var propagator = new AsyncLocalTransactionContextPropagator(); + + // Act + var context = propagator.GetTransactionContext(); + + // Assert + Assert.Equal(EvaluationContext.Empty, context); + } + + [Fact] + public void SetTransactionContext_SetsAndGetsContextCorrectly() + { + // Arrange + var propagator = new AsyncLocalTransactionContextPropagator(); + var evaluationContext = EvaluationContext.Empty; + + // Act + propagator.SetTransactionContext(evaluationContext); + var context = propagator.GetTransactionContext(); + + // Assert + Assert.Equal(evaluationContext, context); + } + + [Fact] + public void SetTransactionContext_OverridesPreviousContext() + { + // Arrange + var propagator = new AsyncLocalTransactionContextPropagator(); + + var initialContext = EvaluationContext.Builder() + .Set("initial", "yes") + .Build(); + var newContext = EvaluationContext.Empty; + + // Act + propagator.SetTransactionContext(initialContext); + propagator.SetTransactionContext(newContext); + var context = propagator.GetTransactionContext(); + + // Assert + Assert.Equal(newContext, context); + } +} diff --git a/test/OpenFeature.Tests/OpenFeatureTests.cs b/test/OpenFeature.Tests/OpenFeatureTests.cs index acc53b61..b2c6d9de 100644 --- a/test/OpenFeature.Tests/OpenFeatureTests.cs +++ b/test/OpenFeature.Tests/OpenFeatureTests.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; @@ -244,5 +245,74 @@ public async Task OpenFeature_Should_Allow_Multiple_Client_Mapping() (await client1.GetBooleanValueAsync("test", false)).Should().BeTrue(); (await client2.GetBooleanValueAsync("test", false)).Should().BeFalse(); } + + [Fact] + public void SetTransactionContextPropagator_ShouldThrowArgumentNullException_WhenNullPropagatorIsPassed() + { + // Arrange + var api = Api.Instance; + + // Act & Assert + Assert.Throws(() => api.SetTransactionContextPropagator(null!)); + } + + [Fact] + public void SetTransactionContextPropagator_ShouldSetPropagator_WhenValidPropagatorIsPassed() + { + // Arrange + var api = Api.Instance; + var mockPropagator = Substitute.For(); + + // Act + api.SetTransactionContextPropagator(mockPropagator); + + // Assert + Assert.Equal(mockPropagator, api.GetTransactionContextPropagator()); + } + + [Fact] + public void SetTransactionContext_ShouldThrowArgumentNullException_WhenEvaluationContextIsNull() + { + // Arrange + var api = Api.Instance; + + // Act & Assert + Assert.Throws(() => api.SetTransactionContext(null!)); + } + + [Fact] + public void SetTransactionContext_ShouldSetTransactionContext_WhenValidEvaluationContextIsProvided() + { + // Arrange + var api = Api.Instance; + var evaluationContext = EvaluationContext.Empty; + var mockPropagator = Substitute.For(); + mockPropagator.GetTransactionContext().Returns(evaluationContext); + api.SetTransactionContextPropagator(mockPropagator); + api.SetTransactionContext(evaluationContext); + + // Act + api.SetTransactionContext(evaluationContext); + var result = api.GetTransactionContext(); + + // Assert + mockPropagator.Received().SetTransactionContext(evaluationContext); + Assert.Equal(evaluationContext, result); + } + + [Fact] + public void GetTransactionContext_ShouldReturnEmptyEvaluationContext_WhenNoPropagatorIsSet() + { + // Arrange + var api = Api.Instance; + var context = EvaluationContext.Builder().Set("status", "not-ready").Build(); + api.SetTransactionContext(context); + + // Act + var result = api.GetTransactionContext(); + + // Assert + Assert.Equal(EvaluationContext.Empty, result); + } } }