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);
+ }
+ }
+}