diff --git a/samples/AspNetCore/Program.cs b/samples/AspNetCore/Program.cs index 3dc0203b1..b629af1e2 100644 --- a/samples/AspNetCore/Program.cs +++ b/samples/AspNetCore/Program.cs @@ -51,6 +51,11 @@ "welcome-message", new Flag( new Dictionary { { "show", true }, { "hide", false } }, "show") }, + { + "disabled-flag", new Flag( + new Dictionary { { "on", "This flag is on" }, { "off", "This flag is off" } }, "off", + disabled: true) + }, { "test-config", new Flag(new Dictionary() { diff --git a/src/OpenFeature/Providers/Memory/Flag.cs b/src/OpenFeature/Providers/Memory/Flag.cs index 532611477..d4601f4d1 100644 --- a/src/OpenFeature/Providers/Memory/Flag.cs +++ b/src/OpenFeature/Providers/Memory/Flag.cs @@ -7,7 +7,13 @@ namespace OpenFeature.Providers.Memory; /// /// Flag representation for the in-memory provider. /// -public interface Flag; +public interface Flag +{ + /// + /// Indicates if the flag is disabled. When disabled, the flag will resolve to the default value. + /// + bool Disabled { get; } +} /// /// Flag representation for the in-memory provider. @@ -26,16 +32,33 @@ public sealed class Flag : Flag /// default variant (should match 1 key in variants dictionary) /// optional context-sensitive evaluation function /// optional metadata for the flag - public Flag(Dictionary variants, string defaultVariant, Func? contextEvaluator = null, ImmutableMetadata? flagMetadata = null) + /// indicates if the flag is disabled + public Flag(Dictionary variants, string defaultVariant, Func? contextEvaluator = null, ImmutableMetadata? flagMetadata = null, bool disabled = false) { this._variants = variants; this._defaultVariant = defaultVariant; this._contextEvaluator = contextEvaluator; this._flagMetadata = flagMetadata; + this.Disabled = disabled; } - internal ResolutionDetails Evaluate(string flagKey, T _, EvaluationContext? evaluationContext) + /// + /// Indicates if the flag is disabled. When disabled, the flag will resolve to the default value. + /// + public bool Disabled { get; } + + internal ResolutionDetails Evaluate(string flagKey, T defaultValue, EvaluationContext? evaluationContext) { + if (this.Disabled) + { + return new ResolutionDetails( + flagKey, + defaultValue, + reason: Reason.Disabled, + flagMetadata: this._flagMetadata + ); + } + if (this._contextEvaluator == null) { return this.EvaluateDefaultVariant(flagKey); diff --git a/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs b/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs index 19b474db8..1037c1892 100644 --- a/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs +++ b/test/OpenFeature.E2ETests/Steps/ExcludedTagsStep.cs @@ -5,7 +5,6 @@ namespace OpenFeature.E2ETests.Steps; [Scope(Tag = "immutability")] [Scope(Tag = "async")] [Scope(Tag = "reason-codes-cached")] -[Scope(Tag = "reason-codes-disabled")] [Scope(Tag = "deprecated")] public class ExcludedTagsStep { diff --git a/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs b/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs index 153de67da..363bf1cba 100644 --- a/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs +++ b/test/OpenFeature.E2ETests/Utils/FlagDictionaryJsonConverter.cs @@ -50,7 +50,7 @@ private static Flag ReadFlag(string flagKey, JsonElement flagElement) if (inferredKind == null) throw new JsonException($"Flag '{flagKey}' has no variants"); - var defaultVariant = InferDefaultVariant(flagElement, variantsElement); + var defaultVariant = InferDefaultVariant(flagElement); var contextEvaluator = flagElement.TryGetProperty("contextEvaluator", out var ctxElem) && ctxElem.ValueKind == JsonValueKind.String ? ContextEvaluatorUtility.BuildContextEvaluator(ctxElem.GetString()!) @@ -60,15 +60,15 @@ private static Flag ReadFlag(string flagKey, JsonElement flagElement) ? BuildMetadata(metaElem) : null; - // NOTE: The current Flag type does not model 'disabled' + var disabled = flagElement.TryGetProperty("disabled", out var disabledElem) && disabledElem.ValueKind == JsonValueKind.True; return inferredKind switch { - VariantKind.Boolean => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetBoolean()), - VariantKind.Integer => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetInt32()), - VariantKind.Double => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetDouble()), - VariantKind.String => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, static e => e.GetString()!), - VariantKind.Object => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, ExtractObjectVariant), + VariantKind.Boolean => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, disabled, static e => e.GetBoolean()), + VariantKind.Integer => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, disabled, static e => e.GetInt32()), + VariantKind.Double => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, disabled, static e => e.GetDouble()), + VariantKind.String => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, disabled, static e => e.GetString()!), + VariantKind.Object => BuildFlag(variantsElement, defaultVariant, contextEvaluator, metadata, disabled, ExtractObjectVariant), _ => throw new JsonException($"Unsupported variant kind for flag '{flagKey}'") }; } @@ -81,6 +81,7 @@ private static Flag BuildFlag( string? defaultVariant, Func? contextEvaluator, ImmutableMetadata? metadata, + bool? disabled, Func projector) { var dict = new Dictionary(StringComparer.Ordinal); @@ -88,10 +89,10 @@ private static Flag BuildFlag( { dict[v.Name] = projector(v.Value); } - return new Flag(dict, defaultVariant!, contextEvaluator, metadata); + return new Flag(dict, defaultVariant!, contextEvaluator, metadata, disabled ?? false); } - private static string? InferDefaultVariant(JsonElement flagElement, JsonElement variantsElement) + private static string? InferDefaultVariant(JsonElement flagElement) { if (flagElement.TryGetProperty("defaultVariant", out var dv)) { diff --git a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs index b60c1004e..52eace286 100644 --- a/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs +++ b/test/OpenFeature.Tests/Providers/Memory/InMemoryProviderTests.cs @@ -21,6 +21,16 @@ public InMemoryProviderTests() defaultVariant: "on" ) }, + { + "boolean-disabled-flag", new Flag( + disabled: true, + variants: new Dictionary(){ + { "on", true }, + { "off", false } + }, + defaultVariant: "on" + ) + }, { "string-flag", new Flag( variants: new Dictionary(){ @@ -30,6 +40,16 @@ public InMemoryProviderTests() defaultVariant: "greeting" ) }, + { + "string-disabled-flag", new Flag( + disabled: true, + variants: new Dictionary(){ + { "greeting", "hi" }, + { "parting", "bye" } + }, + defaultVariant: "greeting" + ) + }, { "integer-flag", new Flag( variants: new Dictionary(){ @@ -39,6 +59,16 @@ public InMemoryProviderTests() defaultVariant: "ten" ) }, + { + "integer-disabled-flag", new Flag( + disabled: true, + variants: new Dictionary(){ + { "one", 1 }, + { "ten", 10 } + }, + defaultVariant: "ten" + ) + }, { "float-flag", new Flag( variants: new Dictionary(){ @@ -48,6 +78,16 @@ public InMemoryProviderTests() defaultVariant: "half" ) }, + { + "float-disabled-flag", new Flag( + disabled: true, + variants: new Dictionary(){ + { "tenth", 0.1 }, + { "half", 0.5 } + }, + defaultVariant: "half" + ) + }, { "context-aware", new Flag( variants: new Dictionary(){ @@ -78,6 +118,21 @@ public InMemoryProviderTests() defaultVariant: "template" ) }, + { + "object-disabled-flag", new Flag( + disabled: true, + 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(){ @@ -137,6 +192,18 @@ public async Task GetBoolean_WithNoEvaluationContext_ShouldEvaluateWithReasonAnd Assert.Equal("on", details.Variant); } + [Fact] + public async Task GetBoolean_WhenDisabled_ShouldEvaluateWithDefaultReason() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveBooleanValueAsync("boolean-disabled-flag", false, EvaluationContext.Empty); + + // Assert + Assert.False(details.Value); + Assert.Equal(Reason.Disabled, details.Reason); + Assert.Null(details.Variant); + } + [Fact] public async Task GetString_ShouldEvaluateWithReasonAndVariant() { @@ -158,6 +225,18 @@ public async Task GetString_WithNoEvaluationContext_ShouldEvaluateWithReasonAndV Assert.Equal("greeting", details.Variant); } + [Fact] + public async Task GetString_WhenDisabled_ShouldEvaluateWithDefaultReason() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveStringValueAsync("string-disabled-flag", "nope"); + + // Assert + Assert.Equal("nope", details.Value); + Assert.Equal(Reason.Disabled, details.Reason); + Assert.Null(details.Variant); + } + [Fact] public async Task GetInt_ShouldEvaluateWithReasonAndVariant() { @@ -179,6 +258,18 @@ public async Task GetInt_WithNoEvaluationContext_ShouldEvaluateWithReasonAndVari Assert.Equal("ten", details.Variant); } + [Fact] + public async Task GetInt_WhenDisabled_ShouldEvaluateWithDefaultReason() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveIntegerValueAsync("integer-disabled-flag", 13); + + // Assert + Assert.Equal(13, details.Value); + Assert.Equal(Reason.Disabled, details.Reason); + Assert.Null(details.Variant); + } + [Fact] public async Task GetDouble_ShouldEvaluateWithReasonAndVariant() { @@ -200,6 +291,18 @@ public async Task GetDouble_WithNoEvaluationContext_ShouldEvaluateWithReasonAndV Assert.Equal("half", details.Variant); } + [Fact] + public async Task GetDouble_WhenDisabled_ShouldEvaluateWithDefaultReason() + { + // Act + ResolutionDetails details = await this.commonProvider.ResolveDoubleValueAsync("float-disabled-flag", 1.3); + + // Assert + Assert.Equal(1.3, details.Value); + Assert.Equal(Reason.Disabled, details.Reason); + Assert.Null(details.Variant); + } + [Fact] public async Task GetStruct_ShouldEvaluateWithReasonAndVariant() { @@ -225,6 +328,25 @@ public async Task GetStruct_WithNoEvaluationContext_ShouldEvaluateWithReasonAndV Assert.Equal("template", details.Variant); } + [Fact] + public async Task GetStruct_WhenDisabled_ShouldEvaluateWithDefaultReason() + { + // Arrange + var defaultValue = new Value( + Structure.Builder() + .Set("default", true) + .Build() + ); + + // Act + ResolutionDetails details = await this.commonProvider.ResolveStructureValueAsync("object-disabled-flag", defaultValue); + + // Assert + Assert.Equal(true, details.Value.AsStructure?["default"].AsBoolean); + Assert.Equal(Reason.Disabled, details.Reason); + Assert.Null(details.Variant); + } + [Fact] public async Task GetString_ContextSensitive_ShouldEvaluateWithReasonAndVariant() {