diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 50a0b812..4dea1592 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -13,11 +13,6 @@ on: jobs: e2e-tests: runs-on: ubuntu-latest - services: - flagd: - image: ghcr.io/open-feature/flagd-testbed:latest - ports: - - 8013:8013 steps: - uses: actions/checkout@v4 with: @@ -36,7 +31,7 @@ jobs: - name: Initialize Tests run: | git submodule update --init --recursive - cp test-harness/features/evaluation.feature test/OpenFeature.E2ETests/Features/ + cp spec/specification/assets/gherkin/evaluation.feature test/OpenFeature.E2ETests/Features/ - name: Run Tests run: dotnet test test/OpenFeature.E2ETests/ --configuration Release --logger GitHubActions diff --git a/.gitmodules b/.gitmodules index 61d2eb45..85115b56 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "test-harness"] - path = test-harness - url = https://github.com/open-feature/test-harness.git +[submodule "spec"] + path = spec + url = https://github.com/open-feature/spec.git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f8cf33c..cdac14e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,12 +67,6 @@ To be able to run the e2e tests, first we need to initialize the submodule and c git submodule update --init --recursive && cp test-harness/features/evaluation.feature test/OpenFeature.E2ETests/Features/ ``` -Afterwards, you need to start flagd locally: - -```bash -docker run -p 8013:8013 ghcr.io/open-feature/flagd-testbed:latest -``` - Now you can run the tests using: ```bash diff --git a/Directory.Packages.props b/Directory.Packages.props index 0714935e..fe75ed4d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,7 +20,6 @@ - diff --git a/spec b/spec new file mode 160000 index 00000000..b58c3b4e --- /dev/null +++ b/spec @@ -0,0 +1 @@ +Subproject commit b58c3b4ec68b0db73e6c33ed4a30e94b1ede5e85 diff --git a/src/OpenFeature/Constant/NoOpProvider.cs b/src/OpenFeature/Constant/Constants.cs similarity index 100% rename from src/OpenFeature/Constant/NoOpProvider.cs rename to src/OpenFeature/Constant/Constants.cs diff --git a/src/OpenFeature/Providers/Memory/Flag.cs b/src/OpenFeature/Providers/Memory/Flag.cs new file mode 100644 index 00000000..99975de3 --- /dev/null +++ b/src/OpenFeature/Providers/Memory/Flag.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Model; + +#nullable enable +namespace OpenFeature.Providers.Memory +{ + /// + /// Flag representation for the in-memory provider. + /// + public interface Flag + { + + } + + /// + /// Flag representation for the in-memory provider. + /// + public sealed class Flag : Flag + { + private Dictionary Variants; + private string DefaultVariant; + private Func? ContextEvaluator; + + /// + /// Flag representation for the in-memory provider. + /// + /// dictionary of variants and their corresponding values + /// default variant (should match 1 key in variants dictionary) + /// optional context-sensitive evaluation function + public Flag(Dictionary variants, string defaultVariant, Func? contextEvaluator = null) + { + this.Variants = variants; + this.DefaultVariant = defaultVariant; + this.ContextEvaluator = contextEvaluator; + } + + internal ResolutionDetails Evaluate(string flagKey, T _, EvaluationContext? evaluationContext) + { + T? value = default; + if (this.ContextEvaluator == null) + { + if (this.Variants.TryGetValue(this.DefaultVariant, out value)) + { + return new ResolutionDetails( + flagKey, + value, + variant: this.DefaultVariant, + reason: Reason.Static + ); + } + else + { + throw new GeneralException($"variant {this.DefaultVariant} not found"); + } + } + else + { + var variant = this.ContextEvaluator.Invoke(evaluationContext ?? EvaluationContext.Empty); + if (!this.Variants.TryGetValue(variant, out value)) + { + throw new GeneralException($"variant {variant} not found"); + } + else + { + return new ResolutionDetails( + flagKey, + value, + variant: variant, + reason: Reason.TargetingMatch + ); + } + } + } + } +} diff --git a/src/OpenFeature/Providers/Memory/InMemoryProvider.cs b/src/OpenFeature/Providers/Memory/InMemoryProvider.cs new file mode 100644 index 00000000..ddd1e270 --- /dev/null +++ b/src/OpenFeature/Providers/Memory/InMemoryProvider.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Model; + +#nullable enable +namespace OpenFeature.Providers.Memory +{ + /// + /// The in memory provider. + /// Useful for testing and demonstration purposes. + /// + /// In Memory Provider specification + public class InMemoryProvider : FeatureProvider + { + + private readonly Metadata _metadata = new Metadata("InMemory"); + + private Dictionary _flags; + + /// + public override Metadata GetMetadata() + { + return this._metadata; + } + + /// + /// Construct a new InMemoryProvider. + /// + /// dictionary of Flags + public InMemoryProvider(IDictionary? flags = null) + { + if (flags == null) + { + this._flags = new Dictionary(); + } + else + { + this._flags = new Dictionary(flags); // shallow copy + } + } + + /// + /// Updating provider flags configuration, replacing all flags. + /// + /// the flags to use instead of the previous flags. + public async ValueTask UpdateFlags(IDictionary? flags = null) + { + var changed = this._flags.Keys.ToList(); + if (flags == null) + { + this._flags = new Dictionary(); + } + else + { + this._flags = new Dictionary(flags); // shallow copy + } + changed.AddRange(this._flags.Keys.ToList()); + var @event = new ProviderEventPayload + { + Type = ProviderEventTypes.ProviderConfigurationChanged, + ProviderName = _metadata.Name, + FlagsChanged = changed, // emit all + Message = "flags changed", + }; + await this.EventChannel.Writer.WriteAsync(@event).ConfigureAwait(false); + } + + /// + public override Task> ResolveBooleanValue( + string flagKey, + bool defaultValue, + EvaluationContext? context = null) + { + return Task.FromResult(Resolve(flagKey, defaultValue, context)); + } + + /// + public override Task> ResolveStringValue( + string flagKey, + string defaultValue, + EvaluationContext? context = null) + { + return Task.FromResult(Resolve(flagKey, defaultValue, context)); + } + + /// + public override Task> ResolveIntegerValue( + string flagKey, + int defaultValue, + EvaluationContext? context = null) + { + return Task.FromResult(Resolve(flagKey, defaultValue, context)); + } + + /// + public override Task> ResolveDoubleValue( + string flagKey, + double defaultValue, + EvaluationContext? context = null) + { + return Task.FromResult(Resolve(flagKey, defaultValue, context)); + } + + /// + public override Task> ResolveStructureValue( + string flagKey, + Value defaultValue, + EvaluationContext? context = null) + { + return Task.FromResult(Resolve(flagKey, defaultValue, context)); + } + + private ResolutionDetails Resolve(string flagKey, T defaultValue, EvaluationContext? context) + { + if (!this._flags.TryGetValue(flagKey, out var flag)) + { + throw new FlagNotFoundException($"flag {flagKey} not found"); + } + else + { + // This check returns False if a floating point flag is evaluated as an integer flag, and vice-versa. + // In a production provider, such behavior is probably not desirable; consider supporting conversion. + if (typeof(Flag).Equals(flag.GetType())) + { + return ((Flag)flag).Evaluate(flagKey, defaultValue, context); + } + else + { + throw new TypeMismatchException($"flag {flagKey} is not of type ${typeof(T)}"); + } + } + } + } +} diff --git a/test-harness b/test-harness deleted file mode 160000 index 01c4a433..00000000 --- a/test-harness +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 01c4a433a3bcb0df6448da8c0f8030d11ce710af diff --git a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj index e0093787..757c4e8f 100644 --- a/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj +++ b/test/OpenFeature.E2ETests/OpenFeature.E2ETests.csproj @@ -16,7 +16,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs index d2cd483d..4f091ab1 100644 --- a/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs +++ b/test/OpenFeature.E2ETests/Steps/EvaluationStepDefinitions.cs @@ -5,8 +5,9 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using OpenFeature.Constant; -using OpenFeature.Contrib.Providers.Flagd; +using OpenFeature.Extension; using OpenFeature.Model; +using OpenFeature.Providers.Memory; using TechTalk.SpecFlow; using Xunit; @@ -41,15 +42,14 @@ public class EvaluationStepDefinitions public EvaluationStepDefinitions(ScenarioContext scenarioContext) { _scenarioContext = scenarioContext; - var flagdProvider = new FlagdProvider(); - Api.Instance.SetProviderAsync(flagdProvider).Wait(); - client = Api.Instance.GetClient(); } - [Given(@"a provider is registered with cache disabled")] - public void Givenaproviderisregisteredwithcachedisabled() + [Given(@"a provider is registered")] + public void GivenAProviderIsRegistered() { - + var memProvider = new InMemoryProvider(e2eFlagConfig); + Api.Instance.SetProviderAsync(memProvider).Wait(); + client = Api.Instance.GetClient(); } [When(@"a boolean flag with key ""(.*)"" is evaluated with default value ""(.*)""")] @@ -247,7 +247,7 @@ public void Thenthedefaultstringvalueshouldbereturned() public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateamissingflagwith(string errorCode) { Assert.Equal(Reason.Error.ToString(), notFoundDetails.Reason); - Assert.Contains(errorCode, notFoundDetails.ErrorMessage); + Assert.Equal(errorCode, notFoundDetails.ErrorType.GetDescription()); } [When(@"a string flag with key ""(.*)"" is evaluated as an integer, with details and a default value (.*)")] @@ -268,8 +268,88 @@ public void Thenthedefaultintegervalueshouldbereturned() public void Giventhereasonshouldindicateanerrorandtheerrorcodeshouldindicateatypemismatchwith(string errorCode) { Assert.Equal(Reason.Error.ToString(), typeErrorDetails.Reason); - Assert.Contains(errorCode, this.typeErrorDetails.ErrorMessage); - } - + Assert.Equal(errorCode, typeErrorDetails.ErrorType.GetDescription()); + } + + private IDictionary e2eFlagConfig = new Dictionary(){ + { + "boolean-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "on" + ) + }, + { + "string-flag", new Flag( + variants: new Dictionary(){ + { "greeting", "hi" }, + { "parting", "bye" } + }, + defaultVariant: "greeting" + ) + }, + { + "integer-flag", new Flag( + variants: new Dictionary(){ + { "one", 1 }, + { "ten", 10 } + }, + defaultVariant: "ten" + ) + }, + { + "float-flag", new Flag( + variants: new Dictionary(){ + { "tenth", 0.1 }, + { "half", 0.5 } + }, + defaultVariant: "half" + ) + }, + { + "object-flag", new Flag( + variants: new Dictionary(){ + { "empty", new Value() }, + { "template", new Value(Structure.Builder() + .Set("showImages", true) + .Set("title", "Check out these pics!") + .Set("imagesPerPage", 100).Build() + ) + } + }, + defaultVariant: "template" + ) + }, + { + "context-aware", new Flag( + variants: new Dictionary(){ + { "internal", "INTERNAL" }, + { "external", "EXTERNAL" } + }, + defaultVariant: "external", + (context) => { + if (context.GetValue("fn").AsString == "Sulisław" + && context.GetValue("ln").AsString == "Świętopełk" + && context.GetValue("age").AsInteger == 29 + && context.GetValue("customer").AsBoolean == false) + { + return "internal"; + } + else return "external"; + } + ) + }, + { + "wrong-flag", new Flag( + variants: new Dictionary(){ + { "one", "uno" }, + { "two", "dos" } + }, + defaultVariant: "one" + ) + } + }; } } diff --git a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs new file mode 100644 index 00000000..3df038ab --- /dev/null +++ b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using OpenFeature.Constant; +using OpenFeature.Error; +using OpenFeature.Model; +using OpenFeature.Providers.Memory; +using Xunit; + +namespace OpenFeature.Tests +{ + public class InMemoryProviderTests + { + private FeatureProvider commonProvider; + + public InMemoryProviderTests() + { + var provider = new InMemoryProvider(new Dictionary(){ + { + "boolean-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "on" + ) + }, + { + "string-flag", new Flag( + variants: new Dictionary(){ + { "greeting", "hi" }, + { "parting", "bye" } + }, + defaultVariant: "greeting" + ) + }, + { + "integer-flag", new Flag( + variants: new Dictionary(){ + { "one", 1 }, + { "ten", 10 } + }, + defaultVariant: "ten" + ) + }, + { + "float-flag", new Flag( + variants: new Dictionary(){ + { "tenth", 0.1 }, + { "half", 0.5 } + }, + defaultVariant: "half" + ) + }, + { + "context-aware", new Flag( + variants: new Dictionary(){ + { "internal", "INTERNAL" }, + { "external", "EXTERNAL" } + }, + defaultVariant: "external", + (context) => { + if (context.GetValue("email").AsString.Contains("@faas.com")) + { + return "internal"; + } + else return "external"; + } + ) + }, + { + "object-flag", new Flag( + variants: new Dictionary(){ + { "empty", new Value() }, + { "template", new Value(Structure.Builder() + .Set("showImages", true) + .Set("title", "Check out these pics!") + .Set("imagesPerPage", 100).Build() + ) + } + }, + defaultVariant: "template" + ) + }, + { + "invalid-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "missing" + ) + }, + { + "invalid-evaluator-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "on", + (context) => { + return "missing"; + } + ) + } + }); + + this.commonProvider = provider; + } + + [Fact] + public async void GetBoolean_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveBooleanValue("boolean-flag", false, EvaluationContext.Empty).ConfigureAwait(false); + Assert.True(details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("on", details.Variant); + } + + [Fact] + public async void GetString_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveStringValue("string-flag", "nope", EvaluationContext.Empty).ConfigureAwait(false); + Assert.Equal("hi", details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("greeting", details.Variant); + } + + [Fact] + public async void GetInt_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveIntegerValue("integer-flag", 13, EvaluationContext.Empty).ConfigureAwait(false); + Assert.Equal(10, details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("ten", details.Variant); + } + + [Fact] + public async void GetDouble_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveDoubleValue("float-flag", 13, EvaluationContext.Empty).ConfigureAwait(false); + Assert.Equal(0.5, details.Value); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("half", details.Variant); + } + + [Fact] + public async void GetStruct_ShouldEvaluateWithReasonAndVariant() + { + ResolutionDetails details = await this.commonProvider.ResolveStructureValue("object-flag", new Value(), EvaluationContext.Empty).ConfigureAwait(false); + Assert.Equal(true, details.Value.AsStructure["showImages"].AsBoolean); + Assert.Equal("Check out these pics!", details.Value.AsStructure["title"].AsString); + Assert.Equal(100, details.Value.AsStructure["imagesPerPage"].AsInteger); + Assert.Equal(Reason.Static, details.Reason); + Assert.Equal("template", details.Variant); + } + + [Fact] + public async void GetString_ContextSensitive_ShouldEvaluateWithReasonAndVariant() + { + EvaluationContext context = EvaluationContext.Builder().Set("email", "me@faas.com").Build(); + ResolutionDetails details = await this.commonProvider.ResolveStringValue("context-aware", "nope", context).ConfigureAwait(false); + Assert.Equal("INTERNAL", details.Value); + Assert.Equal(Reason.TargetingMatch, details.Reason); + Assert.Equal("internal", details.Variant); + } + + [Fact] + public async void EmptyFlags_ShouldWork() + { + var provider = new InMemoryProvider(); + await provider.UpdateFlags().ConfigureAwait(false); + Assert.Equal("InMemory", provider.GetMetadata().Name); + } + + [Fact] + public async void MissingFlag_ShouldThrow() + { + await Assert.ThrowsAsync(() => commonProvider.ResolveBooleanValue("missing-flag", false, EvaluationContext.Empty)).ConfigureAwait(false); + } + + [Fact] + public async void MismatchedFlag_ShouldThrow() + { + await Assert.ThrowsAsync(() => commonProvider.ResolveStringValue("boolean-flag", "nope", EvaluationContext.Empty)).ConfigureAwait(false); + } + + [Fact] + public async void MissingDefaultVariant_ShouldThrow() + { + await Assert.ThrowsAsync(() => commonProvider.ResolveBooleanValue("invalid-flag", false, EvaluationContext.Empty)).ConfigureAwait(false); + } + + [Fact] + public async void MissingEvaluatedVariant_ShouldThrow() + { + await Assert.ThrowsAsync(() => commonProvider.ResolveBooleanValue("invalid-evaluator-flag", false, EvaluationContext.Empty)).ConfigureAwait(false); + } + + [Fact] + public async void PutConfiguration_shouldUpdateConfigAndRunHandlers() + { + var provider = new InMemoryProvider(new Dictionary(){ + { + "old-flag", new Flag( + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "on" + ) + }}); + + ResolutionDetails details = await provider.ResolveBooleanValue("old-flag", false, EvaluationContext.Empty).ConfigureAwait(false); + Assert.True(details.Value); + + // update flags + await provider.UpdateFlags(new Dictionary(){ + { + "new-flag", new Flag( + variants: new Dictionary(){ + { "greeting", "hi" }, + { "parting", "bye" } + }, + defaultVariant: "greeting" + ) + }}).ConfigureAwait(false); + + var res = await provider.GetEventChannel().Reader.ReadAsync().ConfigureAwait(false) as ProviderEventPayload; + Assert.Equal(ProviderEventTypes.ProviderConfigurationChanged, res.Type); + + await Assert.ThrowsAsync(() => provider.ResolveBooleanValue("old-flag", false, EvaluationContext.Empty)).ConfigureAwait(false); + + // new flag should be present, old gone (defaults), handler run. + ResolutionDetails detailsAfter = await provider.ResolveStringValue("new-flag", "nope", EvaluationContext.Empty).ConfigureAwait(false); + Assert.True(details.Value); + Assert.Equal("hi", detailsAfter.Value); + } + } +}