From ef0bfd48cce2b0c8528e723e761a9c40c2ad1ec6 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Mon, 9 Sep 2024 17:22:23 -0700 Subject: [PATCH 01/35] Working allocationId --- .../FeatureManager.cs | 45 +---- .../Telemetry/TelemetryEventHandler.cs | 154 ++++++++++++++++++ 2 files changed, 155 insertions(+), 44 deletions(-) create mode 100644 src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index 2661bf88..eb8d54d5 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -373,56 +373,13 @@ private async ValueTask EvaluateFeature(string featur Activity.Current != null && Activity.Current.IsAllDataRequested) { - AddEvaluationActivityEvent(evaluationEvent); + TelemetryEventHandler.HandleEvaluationEvent(evaluationEvent, Logger); } } return evaluationEvent; } - private void AddEvaluationActivityEvent(EvaluationEvent evaluationEvent) - { - Debug.Assert(evaluationEvent != null); - Debug.Assert(evaluationEvent.FeatureDefinition != null); - - var tags = new ActivityTagsCollection() - { - { "FeatureName", evaluationEvent.FeatureDefinition.Name }, - { "Enabled", evaluationEvent.Enabled }, - { "VariantAssignmentReason", evaluationEvent.VariantAssignmentReason }, - { "Version", ActivitySource.Version } - }; - - if (!string.IsNullOrEmpty(evaluationEvent.TargetingContext?.UserId)) - { - tags["TargetingId"] = evaluationEvent.TargetingContext.UserId; - } - - if (!string.IsNullOrEmpty(evaluationEvent.Variant?.Name)) - { - tags["Variant"] = evaluationEvent.Variant.Name; - } - - if (evaluationEvent.FeatureDefinition.Telemetry.Metadata != null) - { - foreach (KeyValuePair kvp in evaluationEvent.FeatureDefinition.Telemetry.Metadata) - { - if (tags.ContainsKey(kvp.Key)) - { - Logger?.LogWarning($"{kvp.Key} from telemetry metadata will be ignored, as it would override an existing key."); - - continue; - } - - tags[kvp.Key] = kvp.Value; - } - } - - var activityEvent = new ActivityEvent("FeatureFlag", DateTimeOffset.UtcNow, tags); - - Activity.Current.AddEvent(activityEvent); - } - private async ValueTask IsEnabledAsync(FeatureDefinition featureDefinition, TContext appContext, bool useAppContext, CancellationToken cancellationToken) { Debug.Assert(featureDefinition != null); diff --git a/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs b/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs new file mode 100644 index 00000000..b969629e --- /dev/null +++ b/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs @@ -0,0 +1,154 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Web; + +namespace Microsoft.FeatureManagement.Telemetry +{ + internal static class TelemetryEventHandler + { + private static readonly string EvaluationEventVersion = "1.0.0"; + + /// + /// Handles an evaluation event by adding it as an activity event to the current Activity. + /// + /// The to publish as an + /// Optional logger to log warnings to + public static void HandleEvaluationEvent(EvaluationEvent evaluationEvent, ILogger logger) + { + Debug.Assert(evaluationEvent != null); + Debug.Assert(evaluationEvent.FeatureDefinition != null); + + var tags = new ActivityTagsCollection() + { + { "FeatureName", evaluationEvent.FeatureDefinition.Name }, + { "Enabled", evaluationEvent.Enabled }, + { "VariantAssignmentReason", evaluationEvent.VariantAssignmentReason }, + { "Version", EvaluationEventVersion } + }; + + if (!string.IsNullOrEmpty(evaluationEvent.TargetingContext?.UserId)) + { + tags["TargetingId"] = evaluationEvent.TargetingContext.UserId; + } + + if (!string.IsNullOrEmpty(evaluationEvent.Variant?.Name)) + { + tags["Variant"] = evaluationEvent.Variant.Name; + } + + if (evaluationEvent.FeatureDefinition.Telemetry.Metadata != null) + { + foreach (KeyValuePair kvp in evaluationEvent.FeatureDefinition.Telemetry.Metadata) + { + if (tags.ContainsKey(kvp.Key)) + { + logger?.LogWarning($"{kvp.Key} from telemetry metadata will be ignored, as it would override an existing key."); + + continue; + } + + tags[kvp.Key] = kvp.Value; + } + } + + // VariantAllocationPercentage + if (evaluationEvent.FeatureDefinition.Allocation?.Percentile != null) + { + tags["VariantAssignmentPercentage"] = evaluationEvent.FeatureDefinition.Allocation.Percentile + .Where(p => p.Variant == evaluationEvent.Variant.Name) + .Sum(p => p.To - p.From); + } + + // DefaultWhenEnabled + if (evaluationEvent.FeatureDefinition.Allocation?.DefaultWhenEnabled != null) + { + tags["DefaultWhenEnabled"] = evaluationEvent.FeatureDefinition.Allocation.DefaultWhenEnabled; + } + + // AllocationId + string allocationId = GenerateAllocationId(evaluationEvent.FeatureDefinition); + + if (allocationId != null) + { + tags["AllocationId"] = allocationId; + } + + var activityEvent = new ActivityEvent("FeatureFlag", DateTimeOffset.UtcNow, tags); + + Activity.Current.AddEvent(activityEvent); + } + + private static string GenerateAllocationId(FeatureDefinition featureDefinition) + { + StringBuilder inputBuilder = new StringBuilder(); + + // Seed + inputBuilder.Append($"seed={featureDefinition.Allocation?.Seed ?? ""}"); + + var allocatedVariants = new HashSet(); + + // DefaultWhenEnabled + if (featureDefinition.Allocation?.DefaultWhenEnabled != null) + { + allocatedVariants.Add(featureDefinition.Allocation.DefaultWhenEnabled); + } + + inputBuilder.Append("\n"); + inputBuilder.Append($"default_when_enabled={featureDefinition.Allocation?.DefaultWhenEnabled ?? ""}"); + + // Percentiles + inputBuilder.Append("\n"); + inputBuilder.Append("percentiles="); + + if (featureDefinition.Allocation?.Percentile != null && featureDefinition.Allocation.Percentile.Any()) + { + var sortedPercentiles = featureDefinition.Allocation.Percentile + .Where(p => p.From != p.To) + .OrderBy(p => p.From) + .ToList(); + + allocatedVariants.UnionWith(sortedPercentiles.Select(p => p.Variant)); + + inputBuilder.Append(string.Join(";", sortedPercentiles.Select(p => $"{p.From},{p.Variant},{p.To}"))); + } + + // Variants + inputBuilder.Append("\n"); + inputBuilder.Append("variants="); + + if (allocatedVariants.Any() && featureDefinition.Variants != null && featureDefinition.Variants.Any()) + { + var sortedVariants = featureDefinition.Variants + .Where(variant => allocatedVariants.Contains(variant.Name)) + .OrderBy(variant => variant.Name) + .ToList(); + + inputBuilder.Append(string.Join(";", sortedVariants.Select(v => $"{v.Name},{v.ConfigurationValue?.Value}"))); + } + + // If there's not a special seed and no variants allocated, return null + if (featureDefinition.Allocation?.Seed == null && + !allocatedVariants.Any()) + { + return null; + } + + // Example input string + // input == "seed=123abc\ndefault_when_enabled=Control\npercentiles=0,Control,20;20,Test,100\nvariants=Control,standard;Test,special" + string input = inputBuilder.ToString(); + + using (SHA256 sha256 = SHA256.Create()) + { + byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(input)); + byte[] truncatedHash = new byte[15]; + Array.Copy(hash, truncatedHash, 15); + return Convert.ToBase64String(truncatedHash); + } + } + } +} From 27330d741f30ce187f210da0c8d2f49035a3d7a1 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Tue, 10 Sep 2024 17:34:23 -0700 Subject: [PATCH 02/35] Adds Base64Url byte extension, adjusts TelemetryEventHandler logic, adjusts and adds tests. --- .../Extensions/BytesExtensions.cs | 43 +++++++++++++++++++ .../Telemetry/TelemetryEventHandler.cs | 28 +++++++++--- .../FeatureManagementTest.cs | 42 ++++++++++++++++++ tests/Tests.FeatureManagement/Features.cs | 1 + .../Tests.FeatureManagement/appsettings.json | 16 +++++++ 5 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 src/Microsoft.FeatureManagement/Extensions/BytesExtensions.cs diff --git a/src/Microsoft.FeatureManagement/Extensions/BytesExtensions.cs b/src/Microsoft.FeatureManagement/Extensions/BytesExtensions.cs new file mode 100644 index 00000000..44f697d9 --- /dev/null +++ b/src/Microsoft.FeatureManagement/Extensions/BytesExtensions.cs @@ -0,0 +1,43 @@ +using System; +using System.Text; + +namespace Microsoft.FeatureManagement.Extensions +{ + internal static class BytesExtensions + { + /// + /// Converts a byte array to Base64URL string with optional padding ('=') characters removed. + /// Base64 description: https://datatracker.ietf.org/doc/html/rfc4648.html#section-4 + /// + public static string ToBase64Url(this byte[] bytes) + { + string bytesBase64 = Convert.ToBase64String(bytes); + + int indexOfEquals = bytesBase64.IndexOf("="); + + // Skip the optional padding of '=' characters based on the Base64Url spec if any are present from the Base64 conversion + int stringBuilderCapacity = indexOfEquals != -1 ? indexOfEquals : bytesBase64.Length; + + StringBuilder stringBuilder = new StringBuilder(stringBuilderCapacity); + + // Construct Base64URL string by replacing characters in Base64 conversion that are not URL safe + for (int i = 0; i < stringBuilderCapacity; i++) + { + if (bytesBase64[i] == '+') + { + stringBuilder.Append('-'); + } + else if (bytesBase64[i] == '/') + { + stringBuilder.Append('_'); + } + else + { + stringBuilder.Append(bytesBase64[i]); + } + } + + return stringBuilder.ToString(); + } + } +} diff --git a/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs b/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs index b969629e..db7b769c 100644 --- a/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs +++ b/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using Microsoft.FeatureManagement.Extensions; using System; using System.Collections.Generic; using System.Diagnostics; @@ -57,11 +58,28 @@ public static void HandleEvaluationEvent(EvaluationEvent evaluationEvent, ILogge } // VariantAllocationPercentage - if (evaluationEvent.FeatureDefinition.Allocation?.Percentile != null) + if (evaluationEvent.VariantAssignmentReason == VariantAssignmentReason.DefaultWhenEnabled) { - tags["VariantAssignmentPercentage"] = evaluationEvent.FeatureDefinition.Allocation.Percentile - .Where(p => p.Variant == evaluationEvent.Variant.Name) - .Sum(p => p.To - p.From); + // If the variant was assigned due to DefaultWhenEnabled, the percentage is 100% - all allocated percentiles + double allocatedPercentage = 0; + + if (evaluationEvent.FeatureDefinition.Allocation?.Percentile != null) + { + allocatedPercentage += evaluationEvent.FeatureDefinition.Allocation.Percentile + .Sum(p => p.To - p.From); + } + + tags["VariantAssignmentPercentage"] = 100 - allocatedPercentage; + } + else if (evaluationEvent.VariantAssignmentReason == VariantAssignmentReason.Percentile) + { + // If the variant was assigned due to Percentile, the percentage is the sum of the allocated percentiles for the given variant + if (evaluationEvent.FeatureDefinition.Allocation?.Percentile != null) + { + tags["VariantAssignmentPercentage"] = evaluationEvent.FeatureDefinition.Allocation.Percentile + .Where(p => p.Variant == evaluationEvent.Variant?.Name) + .Sum(p => p.To - p.From); + } } // DefaultWhenEnabled @@ -147,7 +165,7 @@ private static string GenerateAllocationId(FeatureDefinition featureDefinition) byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(input)); byte[] truncatedHash = new byte[15]; Array.Copy(hash, truncatedHash, 15); - return Convert.ToBase64String(truncatedHash); + return truncatedHash.ToBase64Url(); } } } diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index d8adf251..87cac41c 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -1736,6 +1736,10 @@ public async Task TelemetryPublishing() string label = evaluationEvent.Tags.FirstOrDefault(kvp => kvp.Key == "Label").Value?.ToString(); string firstTag = evaluationEvent.Tags.FirstOrDefault(kvp => kvp.Key == "Tags.Tag1").Value?.ToString(); + string variantAssignmentPercentage = evaluationEvent.Tags.FirstOrDefault(kvp => kvp.Key == "VariantAssignmentPercentage").Value?.ToString(); + string defaultWhenEnabled = evaluationEvent.Tags.FirstOrDefault(kvp => kvp.Key == "DefaultWhenEnabled").Value?.ToString(); + string allocationId = evaluationEvent.Tags.FirstOrDefault(kvp => kvp.Key == "AllocationId").Value?.ToString(); + // Test telemetry cases switch (featureName) { @@ -1763,6 +1767,9 @@ public async Task TelemetryPublishing() Assert.Equal("True", enabled); Assert.Equal("Medium", variantName); Assert.Equal(VariantAssignmentReason.DefaultWhenEnabled.ToString(), variantAssignmentReason); + Assert.Equal("100", variantAssignmentPercentage); + Assert.Equal("Medium", defaultWhenEnabled); + Assert.Equal("kLGyMxiMp7fFb5N3cT_I", allocationId); break; case Features.VariantFeatureDefaultDisabled: @@ -1771,6 +1778,9 @@ public async Task TelemetryPublishing() Assert.Equal("False", enabled); Assert.Equal("Small", variantName); Assert.Equal(VariantAssignmentReason.DefaultWhenDisabled.ToString(), variantAssignmentReason); + Assert.Null(variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); + Assert.Null(allocationId); break; case Features.VariantFeaturePercentileOn: @@ -1793,6 +1803,9 @@ public async Task TelemetryPublishing() currentTest = 0; Assert.Null(variantName); Assert.Equal(VariantAssignmentReason.DefaultWhenDisabled.ToString(), variantAssignmentReason); + Assert.Null(variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); + Assert.Equal("JiCG2_6VXr16dczqxGFl", allocationId); break; case Features.VariantFeatureUser: @@ -1800,6 +1813,9 @@ public async Task TelemetryPublishing() currentTest = 0; Assert.Equal("Small", variantName); Assert.Equal(VariantAssignmentReason.User.ToString(), variantAssignmentReason); + Assert.Null(variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); + Assert.Null(allocationId); break; case Features.VariantFeatureGroup: @@ -1807,6 +1823,9 @@ public async Task TelemetryPublishing() currentTest = 0; Assert.Equal("Small", variantName); Assert.Equal(VariantAssignmentReason.Group.ToString(), variantAssignmentReason); + Assert.Null(variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); + Assert.Null(allocationId); break; case Features.VariantFeatureNoVariants: @@ -1814,6 +1833,9 @@ public async Task TelemetryPublishing() currentTest = 0; Assert.Null(variantName); Assert.Equal(VariantAssignmentReason.None.ToString(), variantAssignmentReason); + Assert.Null(variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); + Assert.Null(allocationId); break; case Features.VariantFeatureNoAllocation: @@ -1821,6 +1843,9 @@ public async Task TelemetryPublishing() currentTest = 0; Assert.Null(variantName); Assert.Equal(VariantAssignmentReason.DefaultWhenEnabled.ToString(), variantAssignmentReason); + Assert.Equal("100", variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); + Assert.Null(allocationId); break; case Features.VariantFeatureAlwaysOffNoAllocation: @@ -1828,6 +1853,19 @@ public async Task TelemetryPublishing() currentTest = 0; Assert.Null(variantName); Assert.Equal(VariantAssignmentReason.DefaultWhenDisabled.ToString(), variantAssignmentReason); + Assert.Null(variantAssignmentPercentage); + Assert.Null(defaultWhenEnabled); + Assert.Null(allocationId); + break; + + case Features.VariantFeatureIncorrectDefaultWhenEnabled: + Assert.Equal(13, currentTest); + currentTest = 0; + Assert.Null(variantName); + Assert.Equal(VariantAssignmentReason.DefaultWhenEnabled.ToString(), variantAssignmentReason); + Assert.Equal("100", variantAssignmentPercentage); + Assert.Equal("Foo", defaultWhenEnabled); + Assert.Equal("jGJG4WioOB6hH6rh7jOx", allocationId); break; default: @@ -1892,6 +1930,10 @@ public async Task TelemetryPublishing() await featureManager.GetVariantAsync(Features.VariantFeatureAlwaysOffNoAllocation, cancellationToken); Assert.Equal(0, currentTest); + currentTest = 13; + await featureManager.GetVariantAsync(Features.VariantFeatureIncorrectDefaultWhenEnabled, cancellationToken); + Assert.Equal(0, currentTest); + // Test a feature with telemetry disabled- should throw if the listener hits it bool result = await featureManager.IsEnabledAsync(Features.OnTestFeature, cancellationToken); diff --git a/tests/Tests.FeatureManagement/Features.cs b/tests/Tests.FeatureManagement/Features.cs index 22c22201..3f219766 100644 --- a/tests/Tests.FeatureManagement/Features.cs +++ b/tests/Tests.FeatureManagement/Features.cs @@ -23,6 +23,7 @@ static class Features public const string VariantFeatureGroup = "VariantFeatureGroup"; public const string VariantFeatureNoVariants = "VariantFeatureNoVariants"; public const string VariantFeatureNoAllocation = "VariantFeatureNoAllocation"; + public const string VariantFeatureIncorrectDefaultWhenEnabled = "VariantFeatureIncorrectDefaultWhenEnabled"; public const string VariantFeatureAlwaysOffNoAllocation = "VariantFeatureAlwaysOffNoAllocation"; public const string VariantFeatureInvalidStatusOverride = "VariantFeatureInvalidStatusOverride"; public const string VariantFeatureInvalidFromTo = "VariantFeatureInvalidFromTo"; diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index a99a325d..e9357782 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -383,6 +383,22 @@ "enabled": true } }, + { + "id": "VariantFeatureIncorrectDefaultWhenEnabled", + "enabled": true, + "variants": [ + { + "name": "Small", + "configuration_value": "300px" + } + ], + "allocation": { + "default_when_enabled": "Foo" + }, + "telemetry": { + "enabled": true + } + }, { "id": "VariantFeatureAlwaysOffNoAllocation", "enabled": false, From ba1676c24940aa6c5f7cf6cdfd0dab45a30700ce Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Tue, 10 Sep 2024 17:46:15 -0700 Subject: [PATCH 03/35] Update comments --- .../Telemetry/TelemetryEventHandler.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs b/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs index db7b769c..6e8a62fc 100644 --- a/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs +++ b/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs @@ -57,10 +57,10 @@ public static void HandleEvaluationEvent(EvaluationEvent evaluationEvent, ILogge } } - // VariantAllocationPercentage + // VariantAssignmentPercentage if (evaluationEvent.VariantAssignmentReason == VariantAssignmentReason.DefaultWhenEnabled) { - // If the variant was assigned due to DefaultWhenEnabled, the percentage is 100% - all allocated percentiles + // If the variant was assigned due to DefaultWhenEnabled, the percentage reflects the unallocated percentiles double allocatedPercentage = 0; if (evaluationEvent.FeatureDefinition.Allocation?.Percentile != null) From ada8823116e7602eb237a1df06ffbf9dffe9b8b0 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Fri, 13 Sep 2024 11:50:40 -0700 Subject: [PATCH 04/35] Removed allocation id and fixed some tests --- .../Telemetry/TelemetryEventHandler.cs | 80 ------------------- .../FeatureManagementTest.cs | 10 --- .../Tests.FeatureManagement/appsettings.json | 6 +- 3 files changed, 3 insertions(+), 93 deletions(-) diff --git a/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs b/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs index 6e8a62fc..fc514428 100644 --- a/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs +++ b/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs @@ -1,12 +1,8 @@ using Microsoft.Extensions.Logging; -using Microsoft.FeatureManagement.Extensions; using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Web; namespace Microsoft.FeatureManagement.Telemetry { @@ -88,85 +84,9 @@ public static void HandleEvaluationEvent(EvaluationEvent evaluationEvent, ILogge tags["DefaultWhenEnabled"] = evaluationEvent.FeatureDefinition.Allocation.DefaultWhenEnabled; } - // AllocationId - string allocationId = GenerateAllocationId(evaluationEvent.FeatureDefinition); - - if (allocationId != null) - { - tags["AllocationId"] = allocationId; - } - var activityEvent = new ActivityEvent("FeatureFlag", DateTimeOffset.UtcNow, tags); Activity.Current.AddEvent(activityEvent); } - - private static string GenerateAllocationId(FeatureDefinition featureDefinition) - { - StringBuilder inputBuilder = new StringBuilder(); - - // Seed - inputBuilder.Append($"seed={featureDefinition.Allocation?.Seed ?? ""}"); - - var allocatedVariants = new HashSet(); - - // DefaultWhenEnabled - if (featureDefinition.Allocation?.DefaultWhenEnabled != null) - { - allocatedVariants.Add(featureDefinition.Allocation.DefaultWhenEnabled); - } - - inputBuilder.Append("\n"); - inputBuilder.Append($"default_when_enabled={featureDefinition.Allocation?.DefaultWhenEnabled ?? ""}"); - - // Percentiles - inputBuilder.Append("\n"); - inputBuilder.Append("percentiles="); - - if (featureDefinition.Allocation?.Percentile != null && featureDefinition.Allocation.Percentile.Any()) - { - var sortedPercentiles = featureDefinition.Allocation.Percentile - .Where(p => p.From != p.To) - .OrderBy(p => p.From) - .ToList(); - - allocatedVariants.UnionWith(sortedPercentiles.Select(p => p.Variant)); - - inputBuilder.Append(string.Join(";", sortedPercentiles.Select(p => $"{p.From},{p.Variant},{p.To}"))); - } - - // Variants - inputBuilder.Append("\n"); - inputBuilder.Append("variants="); - - if (allocatedVariants.Any() && featureDefinition.Variants != null && featureDefinition.Variants.Any()) - { - var sortedVariants = featureDefinition.Variants - .Where(variant => allocatedVariants.Contains(variant.Name)) - .OrderBy(variant => variant.Name) - .ToList(); - - inputBuilder.Append(string.Join(";", sortedVariants.Select(v => $"{v.Name},{v.ConfigurationValue?.Value}"))); - } - - // If there's not a special seed and no variants allocated, return null - if (featureDefinition.Allocation?.Seed == null && - !allocatedVariants.Any()) - { - return null; - } - - // Example input string - // input == "seed=123abc\ndefault_when_enabled=Control\npercentiles=0,Control,20;20,Test,100\nvariants=Control,standard;Test,special" - string input = inputBuilder.ToString(); - - using (SHA256 sha256 = SHA256.Create()) - { - byte[] hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(input)); - byte[] truncatedHash = new byte[15]; - Array.Copy(hash, truncatedHash, 15); - return truncatedHash.ToBase64Url(); - } - } } } diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index 87cac41c..4e783db5 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -1738,7 +1738,6 @@ public async Task TelemetryPublishing() string variantAssignmentPercentage = evaluationEvent.Tags.FirstOrDefault(kvp => kvp.Key == "VariantAssignmentPercentage").Value?.ToString(); string defaultWhenEnabled = evaluationEvent.Tags.FirstOrDefault(kvp => kvp.Key == "DefaultWhenEnabled").Value?.ToString(); - string allocationId = evaluationEvent.Tags.FirstOrDefault(kvp => kvp.Key == "AllocationId").Value?.ToString(); // Test telemetry cases switch (featureName) @@ -1769,7 +1768,6 @@ public async Task TelemetryPublishing() Assert.Equal(VariantAssignmentReason.DefaultWhenEnabled.ToString(), variantAssignmentReason); Assert.Equal("100", variantAssignmentPercentage); Assert.Equal("Medium", defaultWhenEnabled); - Assert.Equal("kLGyMxiMp7fFb5N3cT_I", allocationId); break; case Features.VariantFeatureDefaultDisabled: @@ -1780,7 +1778,6 @@ public async Task TelemetryPublishing() Assert.Equal(VariantAssignmentReason.DefaultWhenDisabled.ToString(), variantAssignmentReason); Assert.Null(variantAssignmentPercentage); Assert.Null(defaultWhenEnabled); - Assert.Null(allocationId); break; case Features.VariantFeaturePercentileOn: @@ -1805,7 +1802,6 @@ public async Task TelemetryPublishing() Assert.Equal(VariantAssignmentReason.DefaultWhenDisabled.ToString(), variantAssignmentReason); Assert.Null(variantAssignmentPercentage); Assert.Null(defaultWhenEnabled); - Assert.Equal("JiCG2_6VXr16dczqxGFl", allocationId); break; case Features.VariantFeatureUser: @@ -1815,7 +1811,6 @@ public async Task TelemetryPublishing() Assert.Equal(VariantAssignmentReason.User.ToString(), variantAssignmentReason); Assert.Null(variantAssignmentPercentage); Assert.Null(defaultWhenEnabled); - Assert.Null(allocationId); break; case Features.VariantFeatureGroup: @@ -1825,7 +1820,6 @@ public async Task TelemetryPublishing() Assert.Equal(VariantAssignmentReason.Group.ToString(), variantAssignmentReason); Assert.Null(variantAssignmentPercentage); Assert.Null(defaultWhenEnabled); - Assert.Null(allocationId); break; case Features.VariantFeatureNoVariants: @@ -1835,7 +1829,6 @@ public async Task TelemetryPublishing() Assert.Equal(VariantAssignmentReason.None.ToString(), variantAssignmentReason); Assert.Null(variantAssignmentPercentage); Assert.Null(defaultWhenEnabled); - Assert.Null(allocationId); break; case Features.VariantFeatureNoAllocation: @@ -1845,7 +1838,6 @@ public async Task TelemetryPublishing() Assert.Equal(VariantAssignmentReason.DefaultWhenEnabled.ToString(), variantAssignmentReason); Assert.Equal("100", variantAssignmentPercentage); Assert.Null(defaultWhenEnabled); - Assert.Null(allocationId); break; case Features.VariantFeatureAlwaysOffNoAllocation: @@ -1855,7 +1847,6 @@ public async Task TelemetryPublishing() Assert.Equal(VariantAssignmentReason.DefaultWhenDisabled.ToString(), variantAssignmentReason); Assert.Null(variantAssignmentPercentage); Assert.Null(defaultWhenEnabled); - Assert.Null(allocationId); break; case Features.VariantFeatureIncorrectDefaultWhenEnabled: @@ -1865,7 +1856,6 @@ public async Task TelemetryPublishing() Assert.Equal(VariantAssignmentReason.DefaultWhenEnabled.ToString(), variantAssignmentReason); Assert.Equal("100", variantAssignmentPercentage); Assert.Equal("Foo", defaultWhenEnabled); - Assert.Equal("jGJG4WioOB6hH6rh7jOx", allocationId); break; default: diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index e9357782..75a75d21 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -209,7 +209,7 @@ "to": 50 } ], - "seed": 1234 + "seed": "1234" }, "telemetry": { "enabled": true @@ -231,7 +231,7 @@ "to": 50 } ], - "seed": 12345 + "seed": "12345" }, "telemetry": { "enabled": true @@ -253,7 +253,7 @@ "to": 100 } ], - "seed": 12345 + "seed": "12345" }, "telemetry": { "enabled": true From 85cdd0aad3776c69de4c1a27ffcdff8dbbef3620 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Mon, 16 Sep 2024 11:45:44 -0700 Subject: [PATCH 05/35] Removed bytes extension --- .../Extensions/BytesExtensions.cs | 43 ------------------- 1 file changed, 43 deletions(-) delete mode 100644 src/Microsoft.FeatureManagement/Extensions/BytesExtensions.cs diff --git a/src/Microsoft.FeatureManagement/Extensions/BytesExtensions.cs b/src/Microsoft.FeatureManagement/Extensions/BytesExtensions.cs deleted file mode 100644 index 44f697d9..00000000 --- a/src/Microsoft.FeatureManagement/Extensions/BytesExtensions.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Text; - -namespace Microsoft.FeatureManagement.Extensions -{ - internal static class BytesExtensions - { - /// - /// Converts a byte array to Base64URL string with optional padding ('=') characters removed. - /// Base64 description: https://datatracker.ietf.org/doc/html/rfc4648.html#section-4 - /// - public static string ToBase64Url(this byte[] bytes) - { - string bytesBase64 = Convert.ToBase64String(bytes); - - int indexOfEquals = bytesBase64.IndexOf("="); - - // Skip the optional padding of '=' characters based on the Base64Url spec if any are present from the Base64 conversion - int stringBuilderCapacity = indexOfEquals != -1 ? indexOfEquals : bytesBase64.Length; - - StringBuilder stringBuilder = new StringBuilder(stringBuilderCapacity); - - // Construct Base64URL string by replacing characters in Base64 conversion that are not URL safe - for (int i = 0; i < stringBuilderCapacity; i++) - { - if (bytesBase64[i] == '+') - { - stringBuilder.Append('-'); - } - else if (bytesBase64[i] == '/') - { - stringBuilder.Append('_'); - } - else - { - stringBuilder.Append(bytesBase64[i]); - } - } - - return stringBuilder.ToString(); - } - } -} From 94069992773cbc80c666d92e6aa95c57f9e87a32 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Wed, 23 Oct 2024 11:35:34 -0700 Subject: [PATCH 06/35] Update src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs Co-authored-by: Sami Sadfa <71456174+samsadsam@users.noreply.github.com> --- .../Telemetry/TelemetryEventHandler.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs b/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs index fc514428..bc10a2ce 100644 --- a/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs +++ b/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs @@ -57,13 +57,7 @@ public static void HandleEvaluationEvent(EvaluationEvent evaluationEvent, ILogge if (evaluationEvent.VariantAssignmentReason == VariantAssignmentReason.DefaultWhenEnabled) { // If the variant was assigned due to DefaultWhenEnabled, the percentage reflects the unallocated percentiles - double allocatedPercentage = 0; - - if (evaluationEvent.FeatureDefinition.Allocation?.Percentile != null) - { - allocatedPercentage += evaluationEvent.FeatureDefinition.Allocation.Percentile - .Sum(p => p.To - p.From); - } +double allocatedPercentage = evaluationEvent.FeatureDefinition.Allocation?.Percentile?.Sum(p => p.To - p.From) ?? 0; tags["VariantAssignmentPercentage"] = 100 - allocatedPercentage; } From e53aeb000bfebc800b12865aa18a6872e1c0c669 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Wed, 23 Oct 2024 11:35:53 -0700 Subject: [PATCH 07/35] Resolving comments --- .../FeatureManager.cs | 2 +- ...ndler.cs => FeatureEvaluationTelemetry.cs} | 20 +++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) rename src/Microsoft.FeatureManagement/Telemetry/{TelemetryEventHandler.cs => FeatureEvaluationTelemetry.cs} (85%) diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index eb8d54d5..d1037452 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -373,7 +373,7 @@ private async ValueTask EvaluateFeature(string featur Activity.Current != null && Activity.Current.IsAllDataRequested) { - TelemetryEventHandler.HandleEvaluationEvent(evaluationEvent, Logger); + FeatureEvaluationTelemetry.Publish(evaluationEvent, Logger); } } diff --git a/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs b/src/Microsoft.FeatureManagement/Telemetry/FeatureEvaluationTelemetry.cs similarity index 85% rename from src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs rename to src/Microsoft.FeatureManagement/Telemetry/FeatureEvaluationTelemetry.cs index fc514428..aaf92649 100644 --- a/src/Microsoft.FeatureManagement/Telemetry/TelemetryEventHandler.cs +++ b/src/Microsoft.FeatureManagement/Telemetry/FeatureEvaluationTelemetry.cs @@ -6,7 +6,7 @@ namespace Microsoft.FeatureManagement.Telemetry { - internal static class TelemetryEventHandler + internal static class FeatureEvaluationTelemetry { private static readonly string EvaluationEventVersion = "1.0.0"; @@ -15,10 +15,22 @@ internal static class TelemetryEventHandler /// /// The to publish as an /// Optional logger to log warnings to - public static void HandleEvaluationEvent(EvaluationEvent evaluationEvent, ILogger logger) + public static void Publish(EvaluationEvent evaluationEvent, ILogger logger) { - Debug.Assert(evaluationEvent != null); - Debug.Assert(evaluationEvent.FeatureDefinition != null); + if (Activity.Current == null) + { + throw new InvalidOperationException("An Activity must be created before calling this method."); + } + + if (evaluationEvent == null) + { + throw new ArgumentNullException(nameof(evaluationEvent)); + } + + if (evaluationEvent.FeatureDefinition == null) + { + throw new ArgumentNullException(nameof(evaluationEvent.FeatureDefinition)); + } var tags = new ActivityTagsCollection() { From 4cb6a17598b4b212998467b37b0abcc8fc0f9bc0 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Wed, 23 Oct 2024 11:54:31 -0700 Subject: [PATCH 08/35] Fix formatting --- .../Telemetry/FeatureEvaluationTelemetry.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.FeatureManagement/Telemetry/FeatureEvaluationTelemetry.cs b/src/Microsoft.FeatureManagement/Telemetry/FeatureEvaluationTelemetry.cs index 8b8add67..26952ca5 100644 --- a/src/Microsoft.FeatureManagement/Telemetry/FeatureEvaluationTelemetry.cs +++ b/src/Microsoft.FeatureManagement/Telemetry/FeatureEvaluationTelemetry.cs @@ -69,7 +69,7 @@ public static void Publish(EvaluationEvent evaluationEvent, ILogger logger) if (evaluationEvent.VariantAssignmentReason == VariantAssignmentReason.DefaultWhenEnabled) { // If the variant was assigned due to DefaultWhenEnabled, the percentage reflects the unallocated percentiles -double allocatedPercentage = evaluationEvent.FeatureDefinition.Allocation?.Percentile?.Sum(p => p.To - p.From) ?? 0; + double allocatedPercentage = evaluationEvent.FeatureDefinition.Allocation?.Percentile?.Sum(p => p.To - p.From) ?? 0; tags["VariantAssignmentPercentage"] = 100 - allocatedPercentage; } From 98a682db45b49ceeab487483b845f128305bb274 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Wed, 23 Oct 2024 12:30:12 -0700 Subject: [PATCH 09/35] Version Bump --- .../Microsoft.FeatureManagement.AspNetCore.csproj | 1 + ...rosoft.FeatureManagement.Telemetry.ApplicationInsights.csproj | 1 + .../Microsoft.FeatureManagement.csproj | 1 + 3 files changed, 3 insertions(+) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj index 81429a96..47abd7c0 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj +++ b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj @@ -7,6 +7,7 @@ 4 0 0 + -preview5 diff --git a/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj b/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj index 69f03a3d..58d2e5a4 100644 --- a/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj +++ b/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj @@ -6,6 +6,7 @@ 4 0 0 + -preview5 diff --git a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj index dfd5ccc8..9f09649d 100644 --- a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj +++ b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj @@ -7,6 +7,7 @@ 4 0 0 + -preview5 From b830f09e7459cb7ed300fc025b2b469f7fc5e648 Mon Sep 17 00:00:00 2001 From: Matthew Metcalf Date: Fri, 1 Nov 2024 15:30:12 -0700 Subject: [PATCH 10/35] Create codeql.yml --- .github/workflows/codeql.yml | 92 ++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..0eb636a1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,92 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "main", "preview", "release/*" ] + pull_request: + branches: [ "main", "preview", "release/*" ] + schedule: + - cron: '21 11 * * 3' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: csharp + build-mode: none + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" From 57c8f24e66e2d14956d450c0baccb017a406df74 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Wed, 4 Dec 2024 10:33:49 +0800 Subject: [PATCH 11/35] use cookie auth in variant service example (#518) --- .../HttpContextTargetingContextAccessor.cs | 53 ------------------- .../VariantServiceDemo/Pages/Index.cshtml.cs | 21 ++++++-- examples/VariantServiceDemo/Program.cs | 12 ++--- 3 files changed, 21 insertions(+), 65 deletions(-) delete mode 100644 examples/VariantServiceDemo/HttpContextTargetingContextAccessor.cs diff --git a/examples/VariantServiceDemo/HttpContextTargetingContextAccessor.cs b/examples/VariantServiceDemo/HttpContextTargetingContextAccessor.cs deleted file mode 100644 index 159c172f..00000000 --- a/examples/VariantServiceDemo/HttpContextTargetingContextAccessor.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using Microsoft.FeatureManagement.FeatureFilters; - -namespace VariantServiceDemo -{ - /// - /// Provides an implementation of that creates a targeting context using info from the current HTTP request. - /// - public class HttpContextTargetingContextAccessor : ITargetingContextAccessor - { - private const string TargetingContextLookup = "HttpContextTargetingContextAccessor.TargetingContext"; - private readonly IHttpContextAccessor _httpContextAccessor; - - public HttpContextTargetingContextAccessor(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); - } - - public ValueTask GetContextAsync() - { - HttpContext httpContext = _httpContextAccessor.HttpContext; - - // - // Try cache lookup - if (httpContext.Items.TryGetValue(TargetingContextLookup, out object value)) - { - return new ValueTask((TargetingContext)value); - } - - // - // Grab username from cookie - string username = httpContext.Request.Cookies["username"]; - - var groups = new List(); - - // - // Build targeting context based on user info - var targetingContext = new TargetingContext - { - UserId = username, - Groups = groups - }; - - // - // Cache for subsequent lookup - httpContext.Items[TargetingContextLookup] = targetingContext; - - return new ValueTask(targetingContext); - } - } -} diff --git a/examples/VariantServiceDemo/Pages/Index.cshtml.cs b/examples/VariantServiceDemo/Pages/Index.cshtml.cs index 6c1fb59b..8e90e656 100644 --- a/examples/VariantServiceDemo/Pages/Index.cshtml.cs +++ b/examples/VariantServiceDemo/Pages/Index.cshtml.cs @@ -1,6 +1,8 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.FeatureManagement; +using System.Security.Claims; namespace VariantServiceDemo.Pages { @@ -19,11 +21,22 @@ public IActionResult OnGet() { // // generate a new visitor - string visitor = Random.Shared.Next().ToString(); + Username = Random.Shared.Next().ToString(); - Response.Cookies.Append("username", visitor); + // Clear Application Insights cookies and + Response.Cookies.Delete("ai_user"); + Response.Cookies.Delete("ai_session"); - Username = visitor; + // Generate new user claim + var claims = new List + { + new Claim(ClaimTypes.Name, Username) + }; + + var identity = new ClaimsIdentity(claims, "CookieAuth"); + var principal = new ClaimsPrincipal(identity); + + HttpContext.SignInAsync("CookieAuth", principal); return Page(); } diff --git a/examples/VariantServiceDemo/Program.cs b/examples/VariantServiceDemo/Program.cs index 8ac9a031..a866d02d 100644 --- a/examples/VariantServiceDemo/Program.cs +++ b/examples/VariantServiceDemo/Program.cs @@ -1,9 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // -using Microsoft.ApplicationInsights.Extensibility; using Microsoft.FeatureManagement; -using Microsoft.FeatureManagement.Telemetry.ApplicationInsights; using VariantServiceDemo; var builder = WebApplication.CreateBuilder(args); @@ -14,14 +12,12 @@ // Add services to the container. builder.Services.AddRazorPages(); -builder.Services.AddHttpContextAccessor(); +// +// Use cookie auth for simplicity and randomizing user +builder.Services.AddAuthentication("CookieAuth"); builder.Services.AddApplicationInsightsTelemetry(); -// -// App Insights TargetingId Tagging -builder.Services.AddSingleton(); - // // Add variant implementations of ICalculator builder.Services.AddSingleton(); @@ -35,7 +31,7 @@ // Including user targeting capability and the variant service provider of ICalculator which is bounded with the variant feature flag "Calculator" // Wire up evaluation event emission builder.Services.AddFeatureManagement() - .WithTargeting() + .WithTargeting() .WithVariantService("Calculator") .AddApplicationInsightsTelemetry(); From 325271bd622aa43d041b366c7ba2e9c30507e5a0 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:53:41 +0800 Subject: [PATCH 12/35] Negate FeatureGateAttribute (#519) * add negate property for FeatureGateAttriburte * update * revert change * add testcases * fix lint --- .../FeatureGateAttribute.cs | 65 ++++++++++++++++++- .../FeatureManagementAspNetCore.cs | 24 +++++++ .../Pages/RazorTestAllNegate.cshtml | 2 + .../Pages/RazorTestAllNegate.cshtml.cs | 18 +++++ .../Pages/RazorTestAnyNegate.cshtml | 4 ++ .../Pages/RazorTestAnyNegate.cshtml.cs | 19 ++++++ .../TestController.cs | 18 ++++- 7 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml create mode 100644 tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml.cs create mode 100644 tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml create mode 100644 tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml.cs diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs index 98ba2096..0c87b107 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateAttribute.cs @@ -22,7 +22,7 @@ public class FeatureGateAttribute : ActionFilterAttribute, IAsyncPageFilter /// /// The names of the features that the attribute will represent. public FeatureGateAttribute(params string[] features) - : this(RequirementType.All, features) + : this(RequirementType.All, false, features) { } @@ -32,6 +32,27 @@ public FeatureGateAttribute(params string[] features) /// Specifies whether all or any of the provided features should be enabled in order to pass. /// The names of the features that the attribute will represent. public FeatureGateAttribute(RequirementType requirementType, params string[] features) + : this(requirementType, false, features) + { + } + + /// + /// Creates an attribute that can be used to gate actions or pages. The gate can be configured to negate the evaluation result. + /// + /// Specifies the evaluation for the provided features gate should be negated. + /// The names of the features that the attribute will represent. + public FeatureGateAttribute(bool negate, params string[] features) + : this(RequirementType.All, negate, features) + { + } + + /// + /// Creates an attribute that can be used to gate actions or pages. The gate can be configured to require all or any of the provided feature(s) to pass or negate the evaluation result. + /// + /// Specifies whether all or any of the provided features should be enabled in order to pass. + /// Specifies the evaluation for the provided features gate should be negated. + /// The names of the features that the attribute will represent. + public FeatureGateAttribute(RequirementType requirementType, bool negate, params string[] features) { if (features == null || features.Length == 0) { @@ -41,6 +62,8 @@ public FeatureGateAttribute(RequirementType requirementType, params string[] fea Features = features; RequirementType = requirementType; + + Negate = negate; } /// @@ -48,7 +71,7 @@ public FeatureGateAttribute(RequirementType requirementType, params string[] fea /// /// A set of enums representing the features that the attribute will represent. public FeatureGateAttribute(params object[] features) - : this(RequirementType.All, features) + : this(RequirementType.All, false, features) { } @@ -58,6 +81,27 @@ public FeatureGateAttribute(params object[] features) /// Specifies whether all or any of the provided features should be enabled in order to pass. /// A set of enums representing the features that the attribute will represent. public FeatureGateAttribute(RequirementType requirementType, params object[] features) + : this(requirementType, false, features) + { + } + + /// + /// Creates an attribute that can be used to gate actions or pages. The gate can be configured to negate the evaluation result. + /// + /// Specifies the evaluation for the provided features gate should be negated. + /// A set of enums representing the features that the attribute will represent. + public FeatureGateAttribute(bool negate, params object[] features) + : this(RequirementType.All, negate, features) + { + } + + /// + /// Creates an attribute that can be used to gate actions or pages. The gate can be configured to require all or any of the provided feature(s) to pass or negate the evaluation result. + /// + /// Specifies whether all or any of the provided features should be enabled in order to pass. + /// Specifies the evaluation for the provided features gate should be negated. + /// A set of enums representing the features that the attribute will represent. + public FeatureGateAttribute(RequirementType requirementType, bool negate, params object[] features) { if (features == null || features.Length == 0) { @@ -82,6 +126,8 @@ public FeatureGateAttribute(RequirementType requirementType, params object[] fea Features = fs; RequirementType = requirementType; + + Negate = negate; } /// @@ -94,6 +140,11 @@ public FeatureGateAttribute(RequirementType requirementType, params object[] fea /// public RequirementType RequirementType { get; } + /// + /// Negates the evaluation for whether or not a feature gate should activate. + /// + public bool Negate { get; } + /// /// Performs controller action pre-processing to ensure that any or all of the specified features are enabled. /// @@ -110,6 +161,11 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context ? await Features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) : await Features.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)); + if (Negate) + { + enabled = !enabled; + } + if (enabled) { await next().ConfigureAwait(false); @@ -138,6 +194,11 @@ public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext contex ? await Features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) : await Features.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)); + if (Negate) + { + enabled = !enabled; + } + if (enabled) { await next.Invoke().ConfigureAwait(false); diff --git a/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs b/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs index 79352588..68b7efc1 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs +++ b/tests/Tests.FeatureManagement.AspNetCore/FeatureManagementAspNetCore.cs @@ -97,9 +97,13 @@ public async Task GatesFeatures() HttpResponseMessage gateAllResponse = await testServer.CreateClient().GetAsync("gateAll"); HttpResponseMessage gateAnyResponse = await testServer.CreateClient().GetAsync("gateAny"); + HttpResponseMessage gateAllNegateResponse = await testServer.CreateClient().GetAsync("gateAllNegate"); + HttpResponseMessage gateAnyNegateResponse = await testServer.CreateClient().GetAsync("gateAnyNegate"); Assert.Equal(HttpStatusCode.OK, gateAllResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, gateAnyResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, gateAllNegateResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, gateAnyNegateResponse.StatusCode); // // Enable 1/2 features @@ -107,9 +111,13 @@ public async Task GatesFeatures() gateAllResponse = await testServer.CreateClient().GetAsync("gateAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("gateAny"); + gateAllNegateResponse = await testServer.CreateClient().GetAsync("gateAllNegate"); + gateAnyNegateResponse = await testServer.CreateClient().GetAsync("gateAnyNegate"); Assert.Equal(HttpStatusCode.NotFound, gateAllResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, gateAnyResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, gateAllNegateResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, gateAnyNegateResponse.StatusCode); // // Enable no @@ -117,9 +125,13 @@ public async Task GatesFeatures() gateAllResponse = await testServer.CreateClient().GetAsync("gateAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("gateAny"); + gateAllNegateResponse = await testServer.CreateClient().GetAsync("gateAllNegate"); + gateAnyNegateResponse = await testServer.CreateClient().GetAsync("gateAnyNegate"); Assert.Equal(HttpStatusCode.NotFound, gateAllResponse.StatusCode); Assert.Equal(HttpStatusCode.NotFound, gateAnyResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, gateAllNegateResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, gateAnyNegateResponse.StatusCode); } [Fact] @@ -153,9 +165,13 @@ public async Task GatesRazorPageFeatures() HttpResponseMessage gateAllResponse = await testServer.CreateClient().GetAsync("RazorTestAll"); HttpResponseMessage gateAnyResponse = await testServer.CreateClient().GetAsync("RazorTestAny"); + HttpResponseMessage gateAllNegateResponse = await testServer.CreateClient().GetAsync("RazorTestAllNegate"); + HttpResponseMessage gateAnyNegateResponse = await testServer.CreateClient().GetAsync("RazorTestAnyNegate"); Assert.Equal(HttpStatusCode.OK, gateAllResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, gateAnyResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, gateAllNegateResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, gateAnyNegateResponse.StatusCode); // // Enable 1/2 features @@ -163,9 +179,13 @@ public async Task GatesRazorPageFeatures() gateAllResponse = await testServer.CreateClient().GetAsync("RazorTestAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("RazorTestAny"); + gateAllNegateResponse = await testServer.CreateClient().GetAsync("RazorTestAllNegate"); + gateAnyNegateResponse = await testServer.CreateClient().GetAsync("RazorTestAnyNegate"); Assert.Equal(HttpStatusCode.NotFound, gateAllResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, gateAnyResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, gateAllNegateResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, gateAnyNegateResponse.StatusCode); // // Enable no @@ -173,9 +193,13 @@ public async Task GatesRazorPageFeatures() gateAllResponse = await testServer.CreateClient().GetAsync("RazorTestAll"); gateAnyResponse = await testServer.CreateClient().GetAsync("RazorTestAny"); + gateAllNegateResponse = await testServer.CreateClient().GetAsync("RazorTestAllNegate"); + gateAnyNegateResponse = await testServer.CreateClient().GetAsync("RazorTestAnyNegate"); Assert.Equal(HttpStatusCode.NotFound, gateAllResponse.StatusCode); Assert.Equal(HttpStatusCode.NotFound, gateAnyResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, gateAllNegateResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, gateAnyNegateResponse.StatusCode); } private static void DisableEndpointRouting(MvcOptions options) diff --git a/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml new file mode 100644 index 00000000..a4e91e45 --- /dev/null +++ b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml @@ -0,0 +1,2 @@ +@page +@model RazorTestAllNegateModel diff --git a/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml.cs b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml.cs new file mode 100644 index 00000000..ba9aff1c --- /dev/null +++ b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAllNegate.cshtml.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.FeatureManagement.Mvc; + +namespace Tests.FeatureManagement.AspNetCore.Pages +{ + [FeatureGate(negate: true, Features.ConditionalFeature, Features.ConditionalFeature2)] + public class RazorTestAllNegateModel : PageModel + { + public IActionResult OnGet() + { + return new OkResult(); + } + } +} diff --git a/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml new file mode 100644 index 00000000..d232c3fe --- /dev/null +++ b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml @@ -0,0 +1,4 @@ +@page +@model Tests.FeatureManagement.AspNetCore.Pages.RazorTestAnyNegateModel +@{ +} diff --git a/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml.cs b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml.cs new file mode 100644 index 00000000..09821913 --- /dev/null +++ b/tests/Tests.FeatureManagement.AspNetCore/Pages/RazorTestAnyNegate.cshtml.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.Mvc; + +namespace Tests.FeatureManagement.AspNetCore.Pages +{ + [FeatureGate(requirementType: RequirementType.Any, negate: true, Features.ConditionalFeature, Features.ConditionalFeature2)] + public class RazorTestAnyNegateModel : PageModel + { + public IActionResult OnGet() + { + return new OkResult(); + } + } +} diff --git a/tests/Tests.FeatureManagement.AspNetCore/TestController.cs b/tests/Tests.FeatureManagement.AspNetCore/TestController.cs index 2f4c8ce5..6fc000a5 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/TestController.cs +++ b/tests/Tests.FeatureManagement.AspNetCore/TestController.cs @@ -26,10 +26,26 @@ public IActionResult GateAll() [Route("/gateAny")] [HttpGet] - [FeatureGate(RequirementType.Any, Features.ConditionalFeature, Features.ConditionalFeature2)] + [FeatureGate(requirementType: RequirementType.Any, Features.ConditionalFeature, Features.ConditionalFeature2)] public IActionResult GateAny() { return Ok(); } + + [Route("/gateAllNegate")] + [HttpGet] + [FeatureGate(negate: true, Features.ConditionalFeature, Features.ConditionalFeature2)] + public IActionResult GateAllNegate() + { + return Ok(); + } + + [Route("/gateAnyNegate")] + [HttpGet] + [FeatureGate(requirementType: RequirementType.Any, negate: true, Features.ConditionalFeature, Features.ConditionalFeature2)] + public IActionResult GateAnyNegate() + { + return Ok(); + } } } From 76969fa89125d1075a25980081365a522966616e Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Tue, 17 Dec 2024 11:59:54 +0800 Subject: [PATCH 13/35] Add testcases for time window filter cache entry (#445) * add test for time window filter cache entry * check there is no cache entry before evaluation * track the count of timewindow calculation * use customized IMemoryCache --- .../FeatureManagementTest.cs | 13 +- .../RecurrenceEvaluation.cs | 192 ++++++++++++------ tests/Tests.FeatureManagement/TestCache.cs | 45 ++++ 3 files changed, 187 insertions(+), 63 deletions(-) create mode 100644 tests/Tests.FeatureManagement/TestCache.cs diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index 26c083db..fe2e2ac5 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -849,6 +849,13 @@ public async Task TimeWindow() Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Start", DateTimeOffset.UtcNow.AddDays(-2).ToString("r")); Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:End", DateTimeOffset.UtcNow.AddDays(-1).ToString("r")); Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:Type", "Weekly"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:0", "Monday"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:1", "Tuesday"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:2", "Wednesday"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:3", "Thursday"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:4", "Friday"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:5", "Saturday"); + Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Pattern:DaysOfWeek:6", "Sunday"); Environment.SetEnvironmentVariable("feature_management:feature_flags:6:conditions:client_filters:0:parameters:Recurrence:Range:Type", "NoEnd"); foreach (DayOfWeek day in Enum.GetValues(typeof(DayOfWeek))) @@ -874,11 +881,7 @@ public async Task TimeWindow() Assert.False(await featureManager.IsEnabledAsync(feature4)); Assert.True(await featureManager.IsEnabledAsync(feature5)); Assert.False(await featureManager.IsEnabledAsync(feature6)); - - for (int i = 0; i < 10; i++) - { - Assert.True(await featureManager.IsEnabledAsync(feature7)); - } + Assert.True(await featureManager.IsEnabledAsync(feature7)); } [Fact] diff --git a/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs b/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs index c65c1abb..4519456e 100644 --- a/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs +++ b/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs @@ -1617,17 +1617,17 @@ public void FindWeeklyClosestStartTest() [Fact] public async Task RecurrenceEvaluationThroughCacheTest() { - OnDemandClock mockedTimeProvider = new OnDemandClock(); + var mockedTimeProvider = new OnDemandClock(); - var mockedTimeWindowFilter = new TimeWindowFilter() + using (var cache = new TestCache()) { - Cache = new MemoryCache(new MemoryCacheOptions()), - SystemClock = mockedTimeProvider - }; + var mockedTimeWindowFilter = new TimeWindowFilter() + { + Cache = cache, + SystemClock = mockedTimeProvider + }; - var context = new FeatureFilterEvaluationContext() - { - Settings = new TimeWindowFilterSettings() + TimeWindowFilterSettings settings = new TimeWindowFilterSettings() { Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday End = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), @@ -1644,43 +1644,80 @@ public async Task RecurrenceEvaluationThroughCacheTest() EndDate = DateTimeOffset.Parse("2024-2-5T12:00:00+08:00") } } - } - }; + }; - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-2T23:00:00+08:00"); + var context = new FeatureFilterEvaluationContext() + { + Settings = settings + }; - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + DateTimeOffset? closestStart; + Assert.False(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(0, cache.CountOfEntryCreation); - for (int i = 0; i < 12; i++) - { - mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddHours(1); - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); - } + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-2T23:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-3T00:00:00+08:00"), closestStart); + Assert.Equal(1, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-3T11:59:59+08:00"); - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + for (int i = 0; i < 12; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddHours(1); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-3T00:00:00+08:00"), closestStart); + Assert.Equal(1, cache.CountOfEntryCreation); + } - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-3T12:00:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-3T11:59:59+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-3T00:00:00+08:00"), closestStart); + Assert.Equal(1, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-5T00:00:00+08:00"); - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-3T12:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-5T00:00:00+08:00"), closestStart); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-5T12:00:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-5T00:00:00+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-5T00:00:00+08:00"), closestStart); + Assert.Equal(2, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-7T00:00:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-5T12:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Null(closestStart); + Assert.Equal(3, cache.CountOfEntryCreation); - for (int i = 0; i < 10; i++) - { - mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddDays(1); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-7T00:00:00+08:00"); Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Null(closestStart); + Assert.Equal(3, cache.CountOfEntryCreation); + + for (int i = 0; i < 10; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddDays(1); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Null(closestStart); + Assert.Equal(3, cache.CountOfEntryCreation); + } } - context = new FeatureFilterEvaluationContext() + using (var cache = new TestCache()) { - Settings = new TimeWindowFilterSettings() + var mockedTimeWindowFilter = new TimeWindowFilter() + { + Cache = cache, + SystemClock = mockedTimeProvider + }; + + var settings = new TimeWindowFilterSettings() { Start = DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), // Thursday End = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"), @@ -1698,43 +1735,82 @@ public async Task RecurrenceEvaluationThroughCacheTest() NumberOfOccurrences = 2 } } - } - }; + }; - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-1-31T23:00:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + var context = new FeatureFilterEvaluationContext() + { + Settings = settings + }; - for (int i = 0; i < 12; i++) - { - mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddHours(1); - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); - } + DateTimeOffset? closestStart; + Assert.False(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(0, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-1T11:59:59+08:00"); - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-1-31T23:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), closestStart); + Assert.Equal(1, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + for (int i = 0; i < 12; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddHours(1); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), closestStart); + Assert.Equal(1, cache.CountOfEntryCreation); + } - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-2T00:00:00+08:00"); // Friday - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-1T11:59:59+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-1T00:00:00+08:00"), closestStart); + Assert.Equal(1, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"); // Sunday - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-1T12:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"), closestStart); + Assert.Equal(2, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T06:00:00+08:00"); - Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-2T00:00:00+08:00"); // Friday + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"), closestStart); + Assert.Equal(2, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T12:01:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"); // Sunday + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"), closestStart); + Assert.Equal(2, cache.CountOfEntryCreation); - mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-8T00:00:00+08:00"); - Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T06:00:00+08:00"); + Assert.True(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Equal(DateTimeOffset.Parse("2024-2-4T00:00:00+08:00"), closestStart); + Assert.Equal(2, cache.CountOfEntryCreation); - for (int i = 0; i < 10; i++) - { - mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddDays(1); + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-4T12:01:00+08:00"); Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Null(closestStart); + Assert.Equal(3, cache.CountOfEntryCreation); + + mockedTimeProvider.UtcNow = DateTimeOffset.Parse("2024-2-8T00:00:00+08:00"); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Null(closestStart); + Assert.Equal(3, cache.CountOfEntryCreation); + + for (int i = 0; i < 10; i++) + { + mockedTimeProvider.UtcNow = mockedTimeProvider.UtcNow.AddDays(1); + Assert.False(await mockedTimeWindowFilter.EvaluateAsync(context)); + Assert.True(cache.TryGetValue(settings, out closestStart)); + Assert.Null(closestStart); + Assert.Equal(3, cache.CountOfEntryCreation); + } } } } diff --git a/tests/Tests.FeatureManagement/TestCache.cs b/tests/Tests.FeatureManagement/TestCache.cs new file mode 100644 index 00000000..39a02655 --- /dev/null +++ b/tests/Tests.FeatureManagement/TestCache.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Caching.Memory; + +namespace Tests.FeatureManagement +{ + class TestCache : IMemoryCache + { + private readonly IMemoryCache _cache; + private int _countOfEntryCreation; + + public TestCache() + { + _cache = new MemoryCache(new MemoryCacheOptions()); + } + + public int CountOfEntryCreation + { + get => _countOfEntryCreation; + } + + public bool TryGetValue(object key, out object value) + { + return _cache.TryGetValue(key, out value); + } + + public ICacheEntry CreateEntry(object key) + { + _countOfEntryCreation += 1; + + return _cache.CreateEntry(key); + } + + public void Remove(object key) + { + _cache.Remove(key); + } + + public void Dispose() + { + _cache.Dispose(); + } + } +} From 9a4dd6582b72b067efec8a6148467f7babf6857c Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Mon, 20 Jan 2025 10:41:38 +0800 Subject: [PATCH 14/35] drop net 6 (#526) --- build/install-dotnet.ps1 | 6 +----- .../Microsoft.FeatureManagement.AspNetCore.csproj | 2 +- .../Tests.FeatureManagement.AspNetCore.csproj | 9 +-------- .../Tests.FeatureManagement.csproj | 9 +-------- 4 files changed, 4 insertions(+), 22 deletions(-) diff --git a/build/install-dotnet.ps1 b/build/install-dotnet.ps1 index 355cb882..6ef5b5f7 100644 --- a/build/install-dotnet.ps1 +++ b/build/install-dotnet.ps1 @@ -1,12 +1,8 @@ -# Installs .NET 6, .NET 7 and .NET 8 for CI/CD environment +# Installs .NET 8 and .NET 9 for CI/CD environment # see: https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-install-script#examples [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; -&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 6.0 - -&([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 7.0 - &([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 8.0 &([scriptblock]::Create((Invoke-WebRequest -UseBasicParsing 'https://dot.net/v1/dotnet-install.ps1'))) -Channel 9.0 diff --git a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj index 50a0e702..53e57010 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj +++ b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj @@ -12,7 +12,7 @@ - net6.0;net8.0;net9.0 + net8.0;net9.0 true false ..\..\build\Microsoft.FeatureManagement.snk diff --git a/tests/Tests.FeatureManagement.AspNetCore/Tests.FeatureManagement.AspNetCore.csproj b/tests/Tests.FeatureManagement.AspNetCore/Tests.FeatureManagement.AspNetCore.csproj index 8557c30c..e91b1be6 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/Tests.FeatureManagement.AspNetCore.csproj +++ b/tests/Tests.FeatureManagement.AspNetCore/Tests.FeatureManagement.AspNetCore.csproj @@ -1,7 +1,7 @@ - net6.0;net8.0;net9.0 + net8.0;net9.0 false 8.0 True @@ -20,13 +20,6 @@ - - - - - - - diff --git a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj index 3eee5035..4a915a4f 100644 --- a/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj +++ b/tests/Tests.FeatureManagement/Tests.FeatureManagement.csproj @@ -1,7 +1,7 @@ - net48;net6.0;net8.0;net9.0 + net48;net8.0;net9.0 false 8.0 True @@ -20,13 +20,6 @@ - - - - - - - From 71aa5f2a501eee5f84dda060d282da085033141a Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Wed, 22 Jan 2025 00:03:17 +0800 Subject: [PATCH 15/35] Avoid using boolean.ToString and enum.ToString (#525) * avoid using boolean.ToString and enum.ToString * update --- .../ApplicationInsightsEventPublisher.cs | 36 ++++++++++++++++++- .../FeatureManager.cs | 1 + 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/ApplicationInsightsEventPublisher.cs b/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/ApplicationInsightsEventPublisher.cs index 4699c543..594b04e5 100644 --- a/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/ApplicationInsightsEventPublisher.cs +++ b/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/ApplicationInsightsEventPublisher.cs @@ -51,7 +51,41 @@ private void HandleFeatureFlagEvent(ActivityEvent activityEvent) foreach (var tag in activityEvent.Tags) { - properties[tag.Key] = tag.Value?.ToString(); + // FeatureEvaluation event schema: https://github.com/microsoft/FeatureManagement/blob/main/Schema/FeatureEvaluationEvent/FeatureEvaluationEvent.v1.0.0.schema.json + if (tag.Value is VariantAssignmentReason reason) + { + switch (reason) + { + case VariantAssignmentReason.None: + properties[tag.Key] = "None"; + break; + case VariantAssignmentReason.DefaultWhenDisabled: + properties[tag.Key] = "DefaultWhenDisabled"; + break; + case VariantAssignmentReason.DefaultWhenEnabled: + properties[tag.Key] = "DefaultWhenEnabled"; + break; + case VariantAssignmentReason.User: + properties[tag.Key] = "User"; + break; + case VariantAssignmentReason.Group: + properties[tag.Key] = "Group"; + break; + case VariantAssignmentReason.Percentile: + properties[tag.Key] = "Percentile"; + break; + default: + throw new ArgumentOutOfRangeException(nameof(activityEvent), "The variant assignment reason is unrecognizable."); + } + } + else if (tag.Value is bool val) + { + properties[tag.Key] = val ? "True" : "False"; + } + else + { + properties[tag.Key] = tag.Value?.ToString(); + } } _telemetryClient.TrackEvent("FeatureEvaluation", properties); diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index dfe794ec..a066078f 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -380,6 +380,7 @@ private void AddEvaluationActivityEvent(EvaluationEvent evaluationEvent) Debug.Assert(evaluationEvent != null); Debug.Assert(evaluationEvent.FeatureDefinition != null); + // FeatureEvaluation event schema: https://github.com/microsoft/FeatureManagement/blob/main/Schema/FeatureEvaluationEvent/FeatureEvaluationEvent.v1.0.0.schema.json var tags = new ActivityTagsCollection() { { "FeatureName", evaluationEvent.FeatureDefinition.Name }, From 87d25004972bc66bd5a773da5732af2e0734c477 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Wed, 22 Jan 2025 11:01:14 +0800 Subject: [PATCH 16/35] Small fix for recurring time window testcase (#522) * add comments & correct testcase * update * update * update * update * update --- .../Recurrence/RecurrenceEvaluator.cs | 2 +- .../Recurrence/RecurrenceValidator.cs | 35 +++++-------- .../RecurrenceEvaluation.cs | 49 +++++++++++++++++-- 3 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs index 5e8b6872..200910ca 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceEvaluator.cs @@ -347,7 +347,7 @@ private static int CalculateWeeklyDayOffset(DayOfWeek day1, DayOfWeek day2) /// private static List SortDaysOfWeek(IEnumerable daysOfWeek, DayOfWeek firstDayOfWeek) { - List result = daysOfWeek.ToList(); + List result = daysOfWeek.Distinct().ToList(); // dedup result.Sort((x, y) => CalculateWeeklyDayOffset(x, firstDayOfWeek) diff --git a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs index 230ae674..54b31e16 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilters/Recurrence/RecurrenceValidator.cs @@ -341,49 +341,40 @@ private static bool TryValidateNumberOfOccurrences(TimeWindowFilterSettings sett private static bool IsDurationCompliantWithDaysOfWeek(TimeSpan duration, int interval, IEnumerable daysOfWeek, DayOfWeek firstDayOfWeek) { Debug.Assert(interval > 0); + Debug.Assert(daysOfWeek.Count() > 0); if (daysOfWeek.Count() == 1) { return true; } - DateTime firstDayOfThisWeek = DateTime.Today.AddDays( - DaysPerWeek - CalculateWeeklyDayOffset(DateTime.Today.DayOfWeek, firstDayOfWeek)); - List sortedDaysOfWeek = SortDaysOfWeek(daysOfWeek, firstDayOfWeek); - DateTime prev = DateTime.MinValue; + DayOfWeek firstDay = sortedDaysOfWeek.First(); // the closest occurrence day to the first day of week + + DayOfWeek prev = firstDay; TimeSpan minGap = TimeSpan.FromDays(DaysPerWeek); - foreach (DayOfWeek dayOfWeek in sortedDaysOfWeek) + for (int i = 1; i < sortedDaysOfWeek.Count(); i++) // start from the second day to calculate the gap { - DateTime date = firstDayOfThisWeek.AddDays( - CalculateWeeklyDayOffset(dayOfWeek, firstDayOfWeek)); + DayOfWeek dayOfWeek = sortedDaysOfWeek[i]; - if (prev != DateTime.MinValue) - { - TimeSpan gap = date - prev; + TimeSpan gap = TimeSpan.FromDays(CalculateWeeklyDayOffset(dayOfWeek, prev)); - if (gap < minGap) - { - minGap = gap; - } + if (gap < minGap) + { + minGap = gap; } - prev = date; + prev = dayOfWeek; } // // It may across weeks. Check the next week if the interval is one week. if (interval == 1) { - DateTime firstDayOfNextWeek = firstDayOfThisWeek.AddDays(DaysPerWeek); - - DateTime firstOccurrenceInNextWeek = firstDayOfNextWeek.AddDays( - CalculateWeeklyDayOffset(sortedDaysOfWeek.First(), firstDayOfWeek)); - - TimeSpan gap = firstOccurrenceInNextWeek - prev; + TimeSpan gap = TimeSpan.FromDays(CalculateWeeklyDayOffset(firstDay, prev)); if (gap < minGap) { @@ -413,7 +404,7 @@ private static int CalculateWeeklyDayOffset(DayOfWeek day1, DayOfWeek day2) /// private static List SortDaysOfWeek(IEnumerable daysOfWeek, DayOfWeek firstDayOfWeek) { - List result = daysOfWeek.ToList(); + List result = daysOfWeek.Distinct().ToList(); // dedup result.Sort((x, y) => CalculateWeeklyDayOffset(x, firstDayOfWeek) diff --git a/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs b/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs index 4519456e..498abf8a 100644 --- a/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs +++ b/tests/Tests.FeatureManagement/RecurrenceEvaluation.cs @@ -291,6 +291,7 @@ public void InvalidTimeWindowAcrossWeeksTest() { Type = RecurrencePatternType.Weekly, Interval = 1, + FirstDayOfWeek = DayOfWeek.Sunday, DaysOfWeek = new List() { DayOfWeek.Tuesday, DayOfWeek.Saturday } // The time window duration should be shorter than 3 days because the gap between Saturday in the previous week and Tuesday in this week is 3 days. }, Range = new RecurrenceRange() @@ -299,7 +300,7 @@ public void InvalidTimeWindowAcrossWeeksTest() // // The settings is valid. No exception should be thrown. - RecurrenceEvaluator.IsMatch(DateTimeOffset.UtcNow, settings); + Assert.True(RecurrenceValidator.TryValidateSettings(settings, out string paramName, out string errorMessage)); settings = new TimeWindowFilterSettings() { @@ -320,7 +321,49 @@ public void InvalidTimeWindowAcrossWeeksTest() // // The settings is valid. No exception should be thrown. - RecurrenceEvaluator.IsMatch(DateTimeOffset.UtcNow, settings); + Assert.True(RecurrenceValidator.TryValidateSettings(settings, out paramName, out errorMessage)); + + settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-15T00:00:00+08:00"), // Monday + End = DateTimeOffset.Parse("2024-1-17T00:00:00+08:00"), // Time window duration is 2 days. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + FirstDayOfWeek = DayOfWeek.Sunday, + DaysOfWeek = new List() { DayOfWeek.Monday, DayOfWeek.Saturday } + }, + Range = new RecurrenceRange() + } + }; + + // + // The settings is valid. No exception should be thrown. + Assert.True(RecurrenceValidator.TryValidateSettings(settings, out paramName, out errorMessage)); + + settings = new TimeWindowFilterSettings() + { + Start = DateTimeOffset.Parse("2024-1-15T00:00:00+08:00"), // Monday + End = DateTimeOffset.Parse("2024-1-17T00:00:01+08:00"), // Time window duration is more than 2 days. + Recurrence = new Recurrence() + { + Pattern = new RecurrencePattern() + { + Type = RecurrencePatternType.Weekly, + Interval = 1, + FirstDayOfWeek = DayOfWeek.Sunday, + DaysOfWeek = new List() { DayOfWeek.Monday, DayOfWeek.Saturday } + }, + Range = new RecurrenceRange() + } + }; + + Assert.False(RecurrenceValidator.TryValidateSettings(settings, out paramName, out errorMessage)); + Assert.Equal(ParamName.End, paramName); + Assert.Equal(ErrorMessage.TimeWindowDurationOutOfRange, errorMessage); settings = new TimeWindowFilterSettings() { @@ -339,7 +382,7 @@ public void InvalidTimeWindowAcrossWeeksTest() } }; - Assert.False(RecurrenceValidator.TryValidateSettings(settings, out string paramName, out string errorMessage)); + Assert.False(RecurrenceValidator.TryValidateSettings(settings, out paramName, out errorMessage)); Assert.Equal(ParamName.End, paramName); Assert.Equal(ErrorMessage.TimeWindowDurationOutOfRange, errorMessage); } From d3109782afd148473882ff82407745478a1bc1ea Mon Sep 17 00:00:00 2001 From: Cristiano Rodrigues Date: Mon, 30 Dec 2024 09:55:57 -0300 Subject: [PATCH 17/35] add feature flags support for endpoints --- .../FeatureFlagsEndpointFilterExtensions.cs | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs new file mode 100644 index 00000000..fffda28f --- /dev/null +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.FeatureManagement.FeatureFilters; +using System; +using System.Threading.Tasks; + +#if NET7_0_OR_GREATER +namespace Microsoft.FeatureManagement.AspNetCore; + +/// +/// Extension methods that provide feature management integration for ASP.NET Core application building. +/// +public static class FeatureFlagsEndpointFilterExtensions +{ + /// + /// Adds a feature flag filter to the endpoint. + /// + /// + /// + /// + /// + /// + public static TBuilder WithFeatureFlag(this TBuilder builder, + string featureName, + Func predicate) where TBuilder : IEndpointConventionBuilder + { + return builder.AddEndpointFilter(new FeatureFlagsEndpointFilter(featureName, predicate)); + } +} + +/// +/// An endpoint filter that requires a feature flag to be enabled. +/// +public class FeatureFlagsEndpointFilter : IEndpointFilter +{ + private readonly string _featureName; + private readonly Func _predicate; + /// + /// Creates a new instance of . + /// + /// + /// + public FeatureFlagsEndpointFilter(string featureName, Func predicate) + { + _featureName = featureName; + _predicate = predicate; + } + + /// + /// Invokes the feature flag filter. + /// + /// + /// + /// + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next) + { + var featureManager = context.HttpContext.RequestServices.GetRequiredService(); + if (featureManager is null) + return await next(context); + + var featureFlag = await featureManager.IsEnabledAsync(_featureName, _predicate); + return !featureFlag ? Results.NotFound() : await next(context); + } +} +#endif From cafde19acad26d6fbb2f625accd3683492864c14 Mon Sep 17 00:00:00 2001 From: Cristiano Rodrigues Date: Mon, 30 Dec 2024 11:05:05 -0300 Subject: [PATCH 18/35] update documentation for endpoint feature flags support and add tests for FeatureFlagsEndpointFilterExtensions --- .../FeatureFlagsEndpointFilterExtensions.cs | 50 ++++++-- .../FeatureFlagsEndpoint.cs | 111 ++++++++++++++++++ 2 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs index fffda28f..911e0275 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs @@ -9,18 +9,33 @@ namespace Microsoft.FeatureManagement.AspNetCore; /// -/// Extension methods that provide feature management integration for ASP.NET Core application building. +/// Extension methods that provide feature management integration for ASP.NET Core endpoint building. /// public static class FeatureFlagsEndpointFilterExtensions { /// - /// Adds a feature flag filter to the endpoint. + /// Adds a feature flag filter to the endpoint that controls access based on feature state. /// - /// - /// - /// - /// - /// + /// The endpoint convention builder. + /// The name of the feature flag to evaluate. + /// A function that provides the targeting context for feature evaluation. + /// The type of the endpoint convention builder. + /// The endpoint convention builder for chaining. + /// + /// This extension method enables feature flag control over endpoint access. When the feature is disabled, + /// requests to the endpoint will return a 404 Not Found response. The targeting context from the predicate + /// is used to evaluate the feature state for the current request. + /// + /// + /// + /// endpoints.MapGet("/api/feature", () => "Feature Enabled") + /// .WithFeatureFlag("MyFeature", () => new TargetingContext + /// { + /// UserId = "user123", + /// Groups = new[] { "beta-testers" } + /// }); + /// + /// public static TBuilder WithFeatureFlag(this TBuilder builder, string featureName, Func predicate) where TBuilder : IEndpointConventionBuilder @@ -39,8 +54,9 @@ public class FeatureFlagsEndpointFilter : IEndpointFilter /// /// Creates a new instance of . /// - /// - /// + /// The name of the feature flag to evaluate for this endpoint. + /// A function that provides the targeting context for feature evaluation. + /// Thrown when featureName or predicate is null. public FeatureFlagsEndpointFilter(string featureName, Func predicate) { _featureName = featureName; @@ -48,11 +64,19 @@ public FeatureFlagsEndpointFilter(string featureName, Func pre } /// - /// Invokes the feature flag filter. + /// Invokes the feature flag filter to control endpoint access based on feature state. /// - /// - /// - /// + /// The endpoint filter invocation context containing the current HTTP context. + /// The delegate representing the next filter in the pipeline. + /// + /// A NotFound result if the feature is disabled, otherwise continues the pipeline by calling the next delegate. + /// Returns a ValueTask containing the result object. + /// + /// + /// The filter retrieves the IFeatureManager from request services and evaluates the feature flag. + /// If the feature manager is not available, the filter allows the request to proceed. + /// For disabled features, returns a 404 Not Found response instead of executing the endpoint. + /// public async ValueTask InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) diff --git a/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs b/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs new file mode 100644 index 00000000..8599bf1b --- /dev/null +++ b/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs @@ -0,0 +1,111 @@ +#if NET7_0_OR_GREATER +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.FeatureManagement; +using Microsoft.FeatureManagement.AspNetCore; +using Microsoft.FeatureManagement.FeatureFilters; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace Tests.FeatureManagement.AspNetCore +{ + public class TestTargetingContextAccessor : ITargetingContextAccessor + { + public ValueTask GetContextAsync() + { + return new ValueTask(new TargetingContext + { + UserId = "testUser", + Groups = new[] { "testGroup" } + }); + } + } + + public class FeatureTestServer : IDisposable + { + private readonly IHost _host; + private readonly HttpClient _client; + private readonly bool _featureEnabled; + + public FeatureTestServer(bool featureEnabled = true) + { + _featureEnabled = featureEnabled; + _host = CreateHostBuilder().Build(); + _host.Start(); + _client = _host.GetTestServer().CreateClient(); + } + + private IHostBuilder CreateHostBuilder() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FeatureManagement:TestFeature"] = _featureEnabled.ToString().ToLower() + }) + .Build(); + + return Host.CreateDefaultBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddSingleton(configuration); + services.AddSingleton(); + services.AddFeatureManagement(); + services.AddRouting(); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/test", new Func(() => "Feature Enabled")) + .WithFeatureFlag("TestFeature", () => + new TargetingContext + { + UserId = "testUser", + Groups = new[] { "testGroup" } + }); + }); + }); + }); + } + + public HttpClient Client => _client; + + public void Dispose() + { + _host?.Dispose(); + _client?.Dispose(); + } + } + + public class FeatureFlagsEndpointFilterTests + { + [Fact] + public async Task WhenFeatureEnabled_ReturnsSuccess() + { + using var server = new FeatureTestServer(featureEnabled: true); + var response = await server.Client.GetAsync("/test"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenFeatureDisabled_ReturnsNotFound() + { + using var server = new FeatureTestServer(featureEnabled: false); + var response = await server.Client.GetAsync("/test"); + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + } + } +} +#endif From be2befd3eb4b6ca36201ffe9fb99dad78499a9fd Mon Sep 17 00:00:00 2001 From: Cristiano Rodrigues Date: Fri, 3 Jan 2025 11:12:26 -0300 Subject: [PATCH 19/35] remove predicate parameter and add ambient context for targeting --- .../FeatureFlagsEndpointFilterExtensions.cs | 35 ++---- .../FeatureFlagsEndpoint.cs | 111 ++++++++++++++---- 2 files changed, 101 insertions(+), 45 deletions(-) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs index 911e0275..cbbcd363 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs @@ -18,29 +18,24 @@ public static class FeatureFlagsEndpointFilterExtensions /// /// The endpoint convention builder. /// The name of the feature flag to evaluate. - /// A function that provides the targeting context for feature evaluation. /// The type of the endpoint convention builder. /// The endpoint convention builder for chaining. /// /// This extension method enables feature flag control over endpoint access. When the feature is disabled, - /// requests to the endpoint will return a 404 Not Found response. The targeting context from the predicate - /// is used to evaluate the feature state for the current request. + /// requests to the endpoint will return a 404 Not Found response. The targeting context is obtained + /// from the ITargetingContextAccessor registered in the service collection. /// /// /// /// endpoints.MapGet("/api/feature", () => "Feature Enabled") - /// .WithFeatureFlag("MyFeature", () => new TargetingContext - /// { - /// UserId = "user123", - /// Groups = new[] { "beta-testers" } - /// }); + /// .WithFeatureFlag("MyFeature"); /// /// - public static TBuilder WithFeatureFlag(this TBuilder builder, - string featureName, - Func predicate) where TBuilder : IEndpointConventionBuilder + public static TBuilder WithFeatureFlag( + this TBuilder builder, + string featureName) where TBuilder : IEndpointConventionBuilder { - return builder.AddEndpointFilter(new FeatureFlagsEndpointFilter(featureName, predicate)); + return builder.AddEndpointFilter(new FeatureFlagsEndpointFilter(featureName)); } } @@ -50,17 +45,14 @@ public static TBuilder WithFeatureFlag(this TBuilder builder, public class FeatureFlagsEndpointFilter : IEndpointFilter { private readonly string _featureName; - private readonly Func _predicate; + /// /// Creates a new instance of . /// /// The name of the feature flag to evaluate for this endpoint. - /// A function that provides the targeting context for feature evaluation. - /// Thrown when featureName or predicate is null. - public FeatureFlagsEndpointFilter(string featureName, Func predicate) + public FeatureFlagsEndpointFilter(string featureName) { _featureName = featureName; - _predicate = predicate; } /// @@ -72,11 +64,6 @@ public FeatureFlagsEndpointFilter(string featureName, Func pre /// A NotFound result if the feature is disabled, otherwise continues the pipeline by calling the next delegate. /// Returns a ValueTask containing the result object. /// - /// - /// The filter retrieves the IFeatureManager from request services and evaluates the feature flag. - /// If the feature manager is not available, the filter allows the request to proceed. - /// For disabled features, returns a 404 Not Found response instead of executing the endpoint. - /// public async ValueTask InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) @@ -85,8 +72,8 @@ public async ValueTask InvokeAsync( if (featureManager is null) return await next(context); - var featureFlag = await featureManager.IsEnabledAsync(_featureName, _predicate); - return !featureFlag ? Results.NotFound() : await next(context); + var featureFlag = await featureManager.IsEnabledAsync(_featureName); + return featureFlag ? await next(context) : Results.NotFound(); } } #endif diff --git a/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs b/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs index 8599bf1b..10f730d5 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs +++ b/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs @@ -18,13 +18,23 @@ namespace Tests.FeatureManagement.AspNetCore { public class TestTargetingContextAccessor : ITargetingContextAccessor { + private readonly string _userId; + private readonly string[] _groups; + + public TestTargetingContextAccessor(string userId = "testUser", string[] groups = null) + { + _userId = userId; + _groups = groups ?? new[] { "testGroup" }; + } + public ValueTask GetContextAsync() { - return new ValueTask(new TargetingContext + var context = new TargetingContext { - UserId = "testUser", - Groups = new[] { "testGroup" } - }); + UserId = _userId, + Groups = _groups + }; + return new ValueTask(context); } } @@ -32,11 +42,18 @@ public class FeatureTestServer : IDisposable { private readonly IHost _host; private readonly HttpClient _client; - private readonly bool _featureEnabled; + private readonly IDictionary _featureSettings; + private readonly ITargetingContextAccessor _targetingContextAccessor; - public FeatureTestServer(bool featureEnabled = true) + public FeatureTestServer( + IDictionary featureSettings = null, + ITargetingContextAccessor targetingContextAccessor = null) { - _featureEnabled = featureEnabled; + _featureSettings = featureSettings ?? new Dictionary + { + ["FeatureManagement:TestFeature"] = "true" + }; + _targetingContextAccessor = targetingContextAccessor ?? new TestTargetingContextAccessor(); _host = CreateHostBuilder().Build(); _host.Start(); _client = _host.GetTestServer().CreateClient(); @@ -45,10 +62,7 @@ public FeatureTestServer(bool featureEnabled = true) private IHostBuilder CreateHostBuilder() { var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["FeatureManagement:TestFeature"] = _featureEnabled.ToString().ToLower() - }) + .AddInMemoryCollection(_featureSettings) .Build(); return Host.CreateDefaultBuilder() @@ -59,22 +73,21 @@ private IHostBuilder CreateHostBuilder() .ConfigureServices(services => { services.AddSingleton(configuration); - services.AddSingleton(); - services.AddFeatureManagement(); + services.AddSingleton(_targetingContextAccessor); + services.AddFeatureManagement() + .AddFeatureFilter(); services.AddRouting(); }) .Configure(app => { app.UseRouting(); + app.UseMiddleware(); app.UseEndpoints(endpoints => { endpoints.MapGet("/test", new Func(() => "Feature Enabled")) - .WithFeatureFlag("TestFeature", () => - new TargetingContext - { - UserId = "testUser", - Groups = new[] { "testGroup" } - }); + .WithFeatureFlag("TestFeature"); + endpoints.MapGet("/test-targeting", new Func(() => "Feature With Targeting Enabled")) + .WithFeatureFlag("TestFeatureWithTargeting"); }); }); }); @@ -94,7 +107,12 @@ public class FeatureFlagsEndpointFilterTests [Fact] public async Task WhenFeatureEnabled_ReturnsSuccess() { - using var server = new FeatureTestServer(featureEnabled: true); + var settings = new Dictionary + { + ["FeatureManagement:TestFeature"] = "true" + }; + + using var server = new FeatureTestServer(featureSettings: settings); var response = await server.Client.GetAsync("/test"); Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); } @@ -102,10 +120,61 @@ public async Task WhenFeatureEnabled_ReturnsSuccess() [Fact] public async Task WhenFeatureDisabled_ReturnsNotFound() { - using var server = new FeatureTestServer(featureEnabled: false); + var settings = new Dictionary + { + ["FeatureManagement:TestFeature"] = "false" + }; + + using var server = new FeatureTestServer(featureSettings: settings); var response = await server.Client.GetAsync("/test"); Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); } + + [Fact] + public async Task WhenTargetingEnabled_AndUserInTarget_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeatureWithTargeting:EnabledFor:0:Name"] = "Targeting", + ["FeatureManagement:TestFeatureWithTargeting:EnabledFor:0:Parameters:Audience:Users:0"] = "targetUser", + ["FeatureManagement:TestFeature:EnabledFor:0:Parameters:Audience:Groups:0:Name"] = "targetGroup", + ["FeatureManagement:TestFeature:EnabledFor:0:Parameters:Audience:Groups:0:RolloutPercentage"] = "100", + }; + + var targetingAccessor = new TestTargetingContextAccessor( + userId: "targetUser", + groups: new[] { "targetGroup" } + ); + using var server = new FeatureTestServer( + featureSettings: settings, + targetingContextAccessor: targetingAccessor + ); + var response = await server.Client.GetAsync("/test-targeting"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenTargetingEnabled_AndUserNotInTarget_ReturnsNotFound() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeatureWithTargeting:EnabledFor:0:Name"] = "Targeting", + ["FeatureManagement:TestFeatureWithTargeting:EnabledFor:0:Parameters:Audience:Users:0"] = "targetUser", + ["FeatureManagement:TestFeature:EnabledFor:0:Parameters:Audience:Groups:0:Name"] = "targetGroup", + ["FeatureManagement:TestFeature:EnabledFor:0:Parameters:Audience:Groups:0:RolloutPercentage"] = "100", + }; + + var targetingAccessor = new TestTargetingContextAccessor( + userId: "nonTargetUser", + groups: new[] { "nonTargetGroup" } + ); + using var server = new FeatureTestServer( + featureSettings: settings, + targetingContextAccessor: targetingAccessor + ); + var response = await server.Client.GetAsync("/test-targeting"); + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + } } } #endif From 4675111849b8028e4639e0295d6e23f5ea209730 Mon Sep 17 00:00:00 2001 From: Cristiano Rodrigues Date: Wed, 15 Jan 2025 09:58:32 -0300 Subject: [PATCH 20/35] rename API to WithFeatureGate and drop .NET 6 support --- .../FeatureFlagsEndpointFilterExtensions.cs | 8 ++------ .../FeatureFlagsEndpoint.cs | 6 ++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs index cbbcd363..7a288d38 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs @@ -1,11 +1,8 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Microsoft.FeatureManagement.FeatureFilters; -using System; using System.Threading.Tasks; -#if NET7_0_OR_GREATER namespace Microsoft.FeatureManagement.AspNetCore; /// @@ -28,10 +25,10 @@ public static class FeatureFlagsEndpointFilterExtensions /// /// /// endpoints.MapGet("/api/feature", () => "Feature Enabled") - /// .WithFeatureFlag("MyFeature"); + /// .WithFeatureGate("MyFeature"); /// /// - public static TBuilder WithFeatureFlag( + public static TBuilder WithFeatureGate( this TBuilder builder, string featureName) where TBuilder : IEndpointConventionBuilder { @@ -76,4 +73,3 @@ public async ValueTask InvokeAsync( return featureFlag ? await next(context) : Results.NotFound(); } } -#endif diff --git a/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs b/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs index 10f730d5..abb4ae25 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs +++ b/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs @@ -1,4 +1,3 @@ -#if NET7_0_OR_GREATER using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; @@ -85,9 +84,9 @@ private IHostBuilder CreateHostBuilder() app.UseEndpoints(endpoints => { endpoints.MapGet("/test", new Func(() => "Feature Enabled")) - .WithFeatureFlag("TestFeature"); + .WithFeatureGate("TestFeature"); endpoints.MapGet("/test-targeting", new Func(() => "Feature With Targeting Enabled")) - .WithFeatureFlag("TestFeatureWithTargeting"); + .WithFeatureGate("TestFeatureWithTargeting"); }); }); }); @@ -177,4 +176,3 @@ public async Task WhenTargetingEnabled_AndUserNotInTarget_ReturnsNotFound() } } } -#endif From 6be4791e651a5262049ba0e6769a113cbac57347 Mon Sep 17 00:00:00 2001 From: Cristiano Rodrigues Date: Thu, 16 Jan 2025 11:42:07 -0300 Subject: [PATCH 21/35] adjust code style for compliance with coding standards --- .../FeatureFlagsEndpointFilterExtensions.cs | 121 +++++++++--------- 1 file changed, 64 insertions(+), 57 deletions(-) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs index 7a288d38..ef6fb333 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs @@ -1,75 +1,82 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using System; using System.Threading.Tasks; -namespace Microsoft.FeatureManagement.AspNetCore; - -/// -/// Extension methods that provide feature management integration for ASP.NET Core endpoint building. -/// -public static class FeatureFlagsEndpointFilterExtensions +namespace Microsoft.FeatureManagement.AspNetCore { - /// - /// Adds a feature flag filter to the endpoint that controls access based on feature state. - /// - /// The endpoint convention builder. - /// The name of the feature flag to evaluate. - /// The type of the endpoint convention builder. - /// The endpoint convention builder for chaining. - /// - /// This extension method enables feature flag control over endpoint access. When the feature is disabled, - /// requests to the endpoint will return a 404 Not Found response. The targeting context is obtained - /// from the ITargetingContextAccessor registered in the service collection. - /// - /// - /// - /// endpoints.MapGet("/api/feature", () => "Feature Enabled") - /// .WithFeatureGate("MyFeature"); - /// - /// - public static TBuilder WithFeatureGate( - this TBuilder builder, - string featureName) where TBuilder : IEndpointConventionBuilder - { - return builder.AddEndpointFilter(new FeatureFlagsEndpointFilter(featureName)); - } -} - -/// -/// An endpoint filter that requires a feature flag to be enabled. -/// -public class FeatureFlagsEndpointFilter : IEndpointFilter -{ - private readonly string _featureName; /// - /// Creates a new instance of . + /// Extension methods that provide feature management integration for ASP.NET Core endpoint building. /// - /// The name of the feature flag to evaluate for this endpoint. - public FeatureFlagsEndpointFilter(string featureName) + public static class FeatureFlagsEndpointFilterExtensions { - _featureName = featureName; + /// + /// Adds a feature flag filter to the endpoint that controls access based on feature state. + /// + /// The endpoint convention builder. + /// The name of the feature flag to evaluate. + /// The type of the endpoint convention builder. + /// The endpoint convention builder for chaining. + /// + /// This extension method enables feature flag control over endpoint access. When the feature is disabled, + /// requests to the endpoint will return a 404 Not Found response. The targeting context is obtained + /// from the ITargetingContextAccessor registered in the service collection. + /// + /// + /// + /// endpoints.MapGet("/api/feature", () => "Feature Enabled") + /// .WithFeatureGate("MyFeature"); + /// + /// + public static TBuilder WithFeatureGate(this TBuilder builder, string featureName) + where TBuilder : IEndpointConventionBuilder + { + return builder.AddEndpointFilter(new FeatureFlagsEndpointFilter(featureName)); + } } /// - /// Invokes the feature flag filter to control endpoint access based on feature state. + /// An endpoint filter that requires a feature flag to be enabled. /// - /// The endpoint filter invocation context containing the current HTTP context. - /// The delegate representing the next filter in the pipeline. - /// - /// A NotFound result if the feature is disabled, otherwise continues the pipeline by calling the next delegate. - /// Returns a ValueTask containing the result object. - /// - public async ValueTask InvokeAsync( - EndpointFilterInvocationContext context, - EndpointFilterDelegate next) + public class FeatureFlagsEndpointFilter : IEndpointFilter { - var featureManager = context.HttpContext.RequestServices.GetRequiredService(); - if (featureManager is null) - return await next(context); + public string FeatureName { get; } + + /// + /// Creates a new instance of . + /// + /// The name of the feature flag to evaluate for this endpoint. + public FeatureFlagsEndpointFilter(string featureName) + { + if (string.IsNullOrEmpty(featureName)) + { + throw new ArgumentNullException(nameof(featureName)); + } + + FeatureName = featureName; + } + + /// + /// Invokes the feature flag filter to control endpoint access based on feature state. + /// + /// The endpoint filter invocation context containing the current HTTP context. + /// The delegate representing the next filter in the pipeline. + /// + /// A if the feature is disabled, otherwise continues the pipeline by calling the next delegate. + /// Returns a ValueTask containing the result object. + /// + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + IFeatureManagerSnapshot fm = context.HttpContext.RequestServices.GetRequiredService(); + if (fm is null) + { + return await next(context); + } - var featureFlag = await featureManager.IsEnabledAsync(_featureName); - return featureFlag ? await next(context) : Results.NotFound(); + bool enabled = await fm.IsEnabledAsync(FeatureName); + return enabled ? await next(context) : Results.NotFound(); + } } } From 00be8e7789cdc2aa334bd0a0c38d51f7e69db066 Mon Sep 17 00:00:00 2001 From: Cristiano Rodrigues Date: Thu, 16 Jan 2025 14:14:17 -0300 Subject: [PATCH 22/35] Refactor feature management code: - Split `FeatureFlagsEndpointFilter` implementation and extension methods into separate files for better modularity. - Updated feature flag validation to use `IVariantFeatureManagerSnapshot` with cancellation token support. - Adjusted the filter's return logic to use `Results.NotFound()` when the feature is disabled. - Simplified the `WithFeatureGate` extension method by removing the generic `TBuilder` type. - Improved code organization and enhanced documentation clarity. --- .../FeatureFlagsEndpointFilterExtensions.cs | 48 +------------------ .../FeatureFlasEndpointFilter.cs | 47 ++++++++++++++++++ 2 files changed, 48 insertions(+), 47 deletions(-) create mode 100644 src/Microsoft.FeatureManagement.AspNetCore/FeatureFlasEndpointFilter.cs diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs index ef6fb333..20863b61 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs @@ -6,7 +6,6 @@ namespace Microsoft.FeatureManagement.AspNetCore { - /// /// Extension methods that provide feature management integration for ASP.NET Core endpoint building. /// @@ -17,7 +16,6 @@ public static class FeatureFlagsEndpointFilterExtensions /// /// The endpoint convention builder. /// The name of the feature flag to evaluate. - /// The type of the endpoint convention builder. /// The endpoint convention builder for chaining. /// /// This extension method enables feature flag control over endpoint access. When the feature is disabled, @@ -30,53 +28,9 @@ public static class FeatureFlagsEndpointFilterExtensions /// .WithFeatureGate("MyFeature"); /// /// - public static TBuilder WithFeatureGate(this TBuilder builder, string featureName) - where TBuilder : IEndpointConventionBuilder + public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, string featureName) { return builder.AddEndpointFilter(new FeatureFlagsEndpointFilter(featureName)); } } - - /// - /// An endpoint filter that requires a feature flag to be enabled. - /// - public class FeatureFlagsEndpointFilter : IEndpointFilter - { - public string FeatureName { get; } - - /// - /// Creates a new instance of . - /// - /// The name of the feature flag to evaluate for this endpoint. - public FeatureFlagsEndpointFilter(string featureName) - { - if (string.IsNullOrEmpty(featureName)) - { - throw new ArgumentNullException(nameof(featureName)); - } - - FeatureName = featureName; - } - - /// - /// Invokes the feature flag filter to control endpoint access based on feature state. - /// - /// The endpoint filter invocation context containing the current HTTP context. - /// The delegate representing the next filter in the pipeline. - /// - /// A if the feature is disabled, otherwise continues the pipeline by calling the next delegate. - /// Returns a ValueTask containing the result object. - /// - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) - { - IFeatureManagerSnapshot fm = context.HttpContext.RequestServices.GetRequiredService(); - if (fm is null) - { - return await next(context); - } - - bool enabled = await fm.IsEnabledAsync(FeatureName); - return enabled ? await next(context) : Results.NotFound(); - } - } } diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlasEndpointFilter.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlasEndpointFilter.cs new file mode 100644 index 00000000..b5983d64 --- /dev/null +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlasEndpointFilter.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Threading.Tasks; + +namespace Microsoft.FeatureManagement.AspNetCore +{ + + /// + /// An endpoint filter that requires a feature flag to be enabled. + /// + internal class FeatureFlagsEndpointFilter : IEndpointFilter + { + public string FeatureName { get; } + + /// + /// Creates a new instance of . + /// + /// The name of the feature flag to evaluate for this endpoint. + public FeatureFlagsEndpointFilter(string featureName) + { + if (string.IsNullOrEmpty(featureName)) + { + throw new ArgumentNullException(nameof(featureName)); + } + + FeatureName = featureName; + } + + /// + /// Invokes the feature flag filter to control endpoint access based on feature state. + /// + /// The endpoint filter invocation context containing the current HTTP context. + /// The delegate representing the next filter in the pipeline. + /// + /// A if the feature is disabled, otherwise continues the pipeline by calling the next delegate. + /// Returns a ValueTask containing the result object. + /// + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + IVariantFeatureManagerSnapshot fm = context.HttpContext.RequestServices.GetRequiredService(); + + return await fm.IsEnabledAsync(FeatureName, context.HttpContext.RequestAborted) ? await next(context) : Results.NotFound(); + } + } +} From e38752995317ac3455d70ce5f6ef6611c727ffc9 Mon Sep 17 00:00:00 2001 From: Cristiano Rodrigues Date: Fri, 17 Jan 2025 20:35:24 -0300 Subject: [PATCH 23/35] Add license header to source files --- .../FeatureFlagsEndpointFilterExtensions.cs | 3 +++ .../FeatureFlasEndpointFilter.cs | 3 +++ .../Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs | 3 +++ 3 files changed, 9 insertions(+) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs index 20863b61..fc21840f 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlasEndpointFilter.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlasEndpointFilter.cs index b5983d64..2e6a9957 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlasEndpointFilter.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlasEndpointFilter.cs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Extensions.DependencyInjection; diff --git a/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs b/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs index abb4ae25..81492927 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs +++ b/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs @@ -1,3 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; From 76e1c41d12246fa61bd5e5537ae2c866fc7ffe2e Mon Sep 17 00:00:00 2001 From: Cristiano Rodrigues Date: Wed, 22 Jan 2025 09:24:00 -0300 Subject: [PATCH 24/35] rename files and remove unused libraries --- ...eFlasEndpointFilter.cs => FeatureGateEndpointFilter.cs} | 6 +++--- ...xtensions.cs => FeatureGateEndpointFilterExtensions.cs} | 7 ++----- .../{FeatureFlagsEndpoint.cs => FeatureGateEndpoint.cs} | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) rename src/Microsoft.FeatureManagement.AspNetCore/{FeatureFlasEndpointFilter.cs => FeatureGateEndpointFilter.cs} (89%) rename src/Microsoft.FeatureManagement.AspNetCore/{FeatureFlagsEndpointFilterExtensions.cs => FeatureGateEndpointFilterExtensions.cs} (85%) rename tests/Tests.FeatureManagement.AspNetCore/{FeatureFlagsEndpoint.cs => FeatureGateEndpoint.cs} (99%) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlasEndpointFilter.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs similarity index 89% rename from src/Microsoft.FeatureManagement.AspNetCore/FeatureFlasEndpointFilter.cs rename to src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs index 2e6a9957..0c1ed79d 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlasEndpointFilter.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs @@ -13,15 +13,15 @@ namespace Microsoft.FeatureManagement.AspNetCore /// /// An endpoint filter that requires a feature flag to be enabled. /// - internal class FeatureFlagsEndpointFilter : IEndpointFilter + internal class FeatureGateEndpointFilter : IEndpointFilter { public string FeatureName { get; } /// - /// Creates a new instance of . + /// Creates a new instance of . /// /// The name of the feature flag to evaluate for this endpoint. - public FeatureFlagsEndpointFilter(string featureName) + public FeatureGateEndpointFilter(string featureName) { if (string.IsNullOrEmpty(featureName)) { diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs similarity index 85% rename from src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs rename to src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs index fc21840f..7ab2bcb2 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureFlagsEndpointFilterExtensions.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs @@ -3,16 +3,13 @@ // using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Threading.Tasks; namespace Microsoft.FeatureManagement.AspNetCore { /// /// Extension methods that provide feature management integration for ASP.NET Core endpoint building. /// - public static class FeatureFlagsEndpointFilterExtensions + public static class FeatureGateEndpointFilterExtensions { /// /// Adds a feature flag filter to the endpoint that controls access based on feature state. @@ -33,7 +30,7 @@ public static class FeatureFlagsEndpointFilterExtensions /// public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, string featureName) { - return builder.AddEndpointFilter(new FeatureFlagsEndpointFilter(featureName)); + return builder.AddEndpointFilter(new FeatureGateEndpointFilter(featureName)); } } } diff --git a/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs b/tests/Tests.FeatureManagement.AspNetCore/FeatureGateEndpoint.cs similarity index 99% rename from tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs rename to tests/Tests.FeatureManagement.AspNetCore/FeatureGateEndpoint.cs index 81492927..a8f20894 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/FeatureFlagsEndpoint.cs +++ b/tests/Tests.FeatureManagement.AspNetCore/FeatureGateEndpoint.cs @@ -104,7 +104,7 @@ public void Dispose() } } - public class FeatureFlagsEndpointFilterTests + public class FeatureGateEndpointFilterTests { [Fact] public async Task WhenFeatureEnabled_ReturnsSuccess() From 78b1f9ae70543a82fce95be93d2c60912c6cdfcd Mon Sep 17 00:00:00 2001 From: Cristiano Rodrigues Date: Mon, 27 Jan 2025 16:37:18 -0300 Subject: [PATCH 25/35] add support for multiple features evaluation - implement requirement types (all or any) - add negation capability for feature evaluation - extend endpoint filter extensions with new overloads - update tests to cover new functionality --- .../FeatureGateEndpointFilter.cs | 78 ++++- .../FeatureGateEndpointFilterExtensions.cs | 75 ++++- .../FeatureGateEndpoint.cs | 298 +++++++++++++++++- 3 files changed, 420 insertions(+), 31 deletions(-) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs index 0c1ed79d..895dc1a9 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs @@ -1,50 +1,94 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -// + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Extensions.DependencyInjection; using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; namespace Microsoft.FeatureManagement.AspNetCore { - /// - /// An endpoint filter that requires a feature flag to be enabled. + /// An endpoint filter that controls access based on feature flag states. /// - internal class FeatureGateEndpointFilter : IEndpointFilter + public sealed class FeatureGateEndpointFilter : IEndpointFilter { - public string FeatureName { get; } + /// + /// Gets the collection of feature flags to evaluate. + /// + public IReadOnlyCollection Features; + /// + /// Gets the type of requirement (All or Any) for feature evaluation. + /// + public RequirementType RequirementType; + /// + /// Gets whether the feature evaluation result should be negated. + /// + public bool Negate; /// - /// Creates a new instance of . + /// Initializes a new instance of the class. /// - /// The name of the feature flag to evaluate for this endpoint. - public FeatureGateEndpointFilter(string featureName) + /// The collection of feature flags to evaluate. + /// Thrown when features collection is null or empty. + public FeatureGateEndpointFilter(params string[] features) + : this(RequirementType.All, false, features) { - if (string.IsNullOrEmpty(featureName)) + } + + /// + /// Initializes a new instance of the class. + /// + /// The type of requirement for feature evaluation. + /// The collection of feature flags to evaluate. + /// Thrown when features collection is null or empty. + public FeatureGateEndpointFilter(RequirementType requirementType, params string[] features) + : this(requirementType, false, features) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The type of requirement for feature evaluation. + /// Whether to negate the feature evaluation result. + /// The collection of feature flags to evaluate. + /// Thrown when features collection is null or empty. + public FeatureGateEndpointFilter(RequirementType requirementType, bool negate, params string[] features) + { + if (features == null || features.Length == 0) { - throw new ArgumentNullException(nameof(featureName)); + throw new ArgumentNullException(nameof(features), "Features collection cannot be null or empty."); } - FeatureName = featureName; + Features = features.ToList().AsReadOnly(); + RequirementType = requirementType; + Negate = negate; } /// - /// Invokes the feature flag filter to control endpoint access based on feature state. + /// Invokes the feature flag filter to control endpoint access based on feature states. /// - /// The endpoint filter invocation context containing the current HTTP context. + /// The endpoint filter invocation context. /// The delegate representing the next filter in the pipeline. /// - /// A if the feature is disabled, otherwise continues the pipeline by calling the next delegate. - /// Returns a ValueTask containing the result object. + /// A result if access is denied, otherwise continues the pipeline. /// public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { - IVariantFeatureManagerSnapshot fm = context.HttpContext.RequestServices.GetRequiredService(); + IVariantFeatureManager fm = context.HttpContext.RequestServices.GetRequiredService(); + + bool enabled = RequirementType == RequirementType.All + ? await Features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) + : await Features.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)); + var isAllowed = Negate ? !enabled : enabled; - return await fm.IsEnabledAsync(FeatureName, context.HttpContext.RequestAborted) ? await next(context) : Results.NotFound(); + return isAllowed + ? await next(context).ConfigureAwait(false) + : Results.NotFound(); } } } diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs index 7ab2bcb2..2bac0288 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -// + using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -15,12 +15,11 @@ public static class FeatureGateEndpointFilterExtensions /// Adds a feature flag filter to the endpoint that controls access based on feature state. /// /// The endpoint convention builder. - /// The name of the feature flag to evaluate. + /// The name of the feature flag to evaluate. /// The endpoint convention builder for chaining. /// /// This extension method enables feature flag control over endpoint access. When the feature is disabled, - /// requests to the endpoint will return a 404 Not Found response. The targeting context is obtained - /// from the ITargetingContextAccessor registered in the service collection. + /// requests to the endpoint will return a 404 Not Found response. /// /// /// @@ -28,9 +27,73 @@ public static class FeatureGateEndpointFilterExtensions /// .WithFeatureGate("MyFeature"); /// /// - public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, string featureName) + public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, string feature) + { + return builder.AddEndpointFilter(new FeatureGateEndpointFilter(feature)); + } + + /// + /// Adds a feature flag filter to the endpoint that controls access based on multiple feature states. + /// All features must be enabled for access to be granted. + /// + /// The endpoint convention builder. + /// The collection of feature flags to evaluate. + /// The endpoint convention builder for chaining. + /// + /// When multiple features are specified, all features must be enabled for access to be granted. + /// + public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, params string[] features) + { + return builder.AddEndpointFilter(new FeatureGateEndpointFilter(features)); + } + + /// + /// Adds a feature flag filter to the endpoint with specified requirement type for multiple features. + /// + /// The endpoint convention builder. + /// The type of requirement for feature evaluation (All or Any). + /// The collection of feature flags to evaluate. + /// The endpoint convention builder for chaining. + /// + /// Use RequirementType.All to require all features to be enabled. + /// Use RequirementType.Any to require at least one feature to be enabled. + /// + public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, RequirementType requirementType, params string[] features) + { + return builder.AddEndpointFilter(new FeatureGateEndpointFilter(requirementType, features)); + } + + /// + /// Adds a feature flag filter to the endpoint with negation capability for multiple features. + /// + /// The endpoint convention builder. + /// Whether to negate the feature evaluation result. + /// The collection of feature flags to evaluate. + /// The endpoint convention builder for chaining. + /// + /// When negate is true, access is granted when features are disabled rather than enabled. + /// + public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, bool negate, params string[] features) + { + return builder.AddEndpointFilter(new FeatureGateEndpointFilter(RequirementType.All, negate, features)); + } + + /// + /// Adds a feature flag filter to the endpoint with full control over requirement type and negation. + /// + /// The endpoint convention builder. + /// The type of requirement for feature evaluation (All or Any). + /// Whether to negate the feature evaluation result. + /// The collection of feature flags to evaluate. + /// The endpoint convention builder for chaining. + /// + /// This method provides complete control over feature evaluation behavior: + /// - Use requirementType to specify if all or any features must be enabled + /// - Use negate to invert the evaluation result + /// + public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, RequirementType requirementType, bool negate, params string[] features) { - return builder.AddEndpointFilter(new FeatureGateEndpointFilter(featureName)); + return builder.AddEndpointFilter(new FeatureGateEndpointFilter(requirementType, negate, features)); } } } diff --git a/tests/Tests.FeatureManagement.AspNetCore/FeatureGateEndpoint.cs b/tests/Tests.FeatureManagement.AspNetCore/FeatureGateEndpoint.cs index a8f20894..91f4370b 100644 --- a/tests/Tests.FeatureManagement.AspNetCore/FeatureGateEndpoint.cs +++ b/tests/Tests.FeatureManagement.AspNetCore/FeatureGateEndpoint.cs @@ -3,6 +3,8 @@ // using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -46,21 +48,32 @@ public class FeatureTestServer : IDisposable private readonly HttpClient _client; private readonly IDictionary _featureSettings; private readonly ITargetingContextAccessor _targetingContextAccessor; + private readonly Action _endpointConfiguration; public FeatureTestServer( IDictionary featureSettings = null, - ITargetingContextAccessor targetingContextAccessor = null) + ITargetingContextAccessor targetingContextAccessor = null, + Action endpointConfiguration = null) { _featureSettings = featureSettings ?? new Dictionary { ["FeatureManagement:TestFeature"] = "true" }; _targetingContextAccessor = targetingContextAccessor ?? new TestTargetingContextAccessor(); + _endpointConfiguration = endpointConfiguration ?? DefaultEndpointConfiguration; _host = CreateHostBuilder().Build(); _host.Start(); _client = _host.GetTestServer().CreateClient(); } + private void DefaultEndpointConfiguration(IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/test", new Func(() => "Feature Enabled")) + .WithFeatureGate("TestFeature"); + endpoints.MapGet("/test-targeting", new Func(() => "Feature With Targeting Enabled")) + .WithFeatureGate("TestFeatureWithTargeting"); + } + private IHostBuilder CreateHostBuilder() { var configuration = new ConfigurationBuilder() @@ -84,13 +97,7 @@ private IHostBuilder CreateHostBuilder() { app.UseRouting(); app.UseMiddleware(); - app.UseEndpoints(endpoints => - { - endpoints.MapGet("/test", new Func(() => "Feature Enabled")) - .WithFeatureGate("TestFeature"); - endpoints.MapGet("/test-targeting", new Func(() => "Feature With Targeting Enabled")) - .WithFeatureGate("TestFeatureWithTargeting"); - }); + app.UseEndpoints(_endpointConfiguration); }); }); } @@ -132,6 +139,151 @@ public async Task WhenFeatureDisabled_ReturnsNotFound() Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); } + [Fact] + public async Task WhenMultipleFeatures_AllEnabled_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature1"] = "true", + ["FeatureManagement:TestFeature2"] = "true" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-multiple", new Func(() => "Multiple Features Enabled")) + .WithFeatureGate("TestFeature1", "TestFeature2"); + }); + + var response = await server.Client.GetAsync("/test-multiple"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenMultipleFeatures_OneDisabled_ReturnsNotFound() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature1"] = "true", + ["FeatureManagement:TestFeature2"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-multiple", new Func(() => "Multiple Features Enabled")) + .WithFeatureGate("TestFeature1", "TestFeature2"); + }); + + var response = await server.Client.GetAsync("/test-multiple"); + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task WhenRequirementTypeAny_OneEnabled_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature1"] = "true", + ["FeatureManagement:TestFeature2"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-any", new Func(() => "Any Feature Enabled")) + .WithFeatureGate(RequirementType.Any, "TestFeature1", "TestFeature2"); + }); + + var response = await server.Client.GetAsync("/test-any"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenRequirementTypeAny_AllDisabled_ReturnsNotFound() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature1"] = "false", + ["FeatureManagement:TestFeature2"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-any", new Func(() => "Any Feature Enabled")) + .WithFeatureGate(RequirementType.Any, "TestFeature1", "TestFeature2"); + }); + + var response = await server.Client.GetAsync("/test-any"); + Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task WhenNegated_FeatureDisabled_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-negated", new Func(() => "Negated Feature")) + .WithFeatureGate(true, "TestFeature"); + }); + + var response = await server.Client.GetAsync("/test-negated"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenNegated_FeatureEnabled_ReturnsNotFound() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-negated", new Func(() => "Negated Feature")) + .WithFeatureGate(RequirementType.All, true, "TestFeature"); + }); + + var response = await server.Client.GetAsync("/test-negated"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenNegatedWithMultipleFeatures_AllDisabled_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:TestFeature1"] = "false", + ["FeatureManagement:TestFeature2"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + endpoints.MapGet("/test-negated-multiple", new Func(() => "Negated Multiple Features")) + .WithFeatureGate(RequirementType.All, true, "TestFeature1", "TestFeature2"); + }); + + var response = await server.Client.GetAsync("/test-negated-multiple"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + [Fact] public async Task WhenTargetingEnabled_AndUserInTarget_ReturnsSuccess() { @@ -177,5 +329,135 @@ public async Task WhenTargetingEnabled_AndUserNotInTarget_ReturnsNotFound() var response = await server.Client.GetAsync("/test-targeting"); Assert.Equal(System.Net.HttpStatusCode.NotFound, response.StatusCode); } + + [Fact] + public async Task WhenGroupFeatureEnabled_AllEndpointsInGroup_ReturnSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:GroupFeature"] = "true" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + var group = endpoints.MapGroup("/api"); + group.WithFeatureGate("GroupFeature"); + group.MapGet("/endpoint1", new Func(() => "Endpoint 1")); + group.MapGet("/endpoint2", new Func(() => "Endpoint 2")); + }); + + var response1 = await server.Client.GetAsync("/api/endpoint1"); + var response2 = await server.Client.GetAsync("/api/endpoint2"); + + Assert.Equal(System.Net.HttpStatusCode.OK, response1.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.OK, response2.StatusCode); + } + + [Fact] + public async Task WhenGroupFeatureDisabled_AllEndpointsInGroup_ReturnNotFound() + { + var settings = new Dictionary + { + ["FeatureManagement:GroupFeature"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + var group = endpoints.MapGroup("/api"); + group.WithFeatureGate("GroupFeature"); + + group.MapGet("/endpoint1", new Func(() => "Endpoint 1")); + group.MapGet("/endpoint2", new Func(() => "Endpoint 2")); + }); + + var response1 = await server.Client.GetAsync("/api/endpoint1"); + var response2 = await server.Client.GetAsync("/api/endpoint2"); + + Assert.Equal(System.Net.HttpStatusCode.NotFound, response1.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.NotFound, response2.StatusCode); + } + + [Fact] + public async Task WhenNestedGroups_WithMultipleFeatures_ReturnsExpectedResults() + { + var settings = new Dictionary + { + ["FeatureManagement:ParentFeature"] = "true", + ["FeatureManagement:ChildFeature"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + var parentGroup = endpoints.MapGroup("/parent"); + parentGroup.WithFeatureGate("ParentFeature"); + + var childGroup = parentGroup.MapGroup("/child"); + childGroup.WithFeatureGate("ChildFeature"); + + parentGroup.MapGet("/endpoint", new Func(() => "Parent Endpoint")); + childGroup.MapGet("/endpoint", new Func(() => "Child Endpoint")); + }); + + var parentResponse = await server.Client.GetAsync("/parent/endpoint"); + var childResponse = await server.Client.GetAsync("/parent/child/endpoint"); + + Assert.Equal(System.Net.HttpStatusCode.OK, parentResponse.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.NotFound, childResponse.StatusCode); + } + + [Fact] + public async Task WhenGroupWithRequirementTypeAny_OneFeatureEnabled_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:Feature1"] = "true", + ["FeatureManagement:Feature2"] = "false" + }; + + using var server = new FeatureTestServer( + featureSettings: settings, + endpointConfiguration: endpoints => + { + var group = endpoints.MapGroup("/api"); + group.WithFeatureGate(RequirementType.Any, "Feature1", "Feature2"); + + group.MapGet("/endpoint", new Func(() => "Any Feature Endpoint")); + }); + + var response = await server.Client.GetAsync("/api/endpoint"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task WhenGroupWithTargeting_AndUserInTarget_ReturnsSuccess() + { + var settings = new Dictionary + { + ["FeatureManagement:GroupTargetFeature:EnabledFor:0:Name"] = "Targeting", + ["FeatureManagement:GroupTargetFeature:EnabledFor:0:Parameters:Audience:Users:0"] = "targetUser" + }; + + var targetingAccessor = new TestTargetingContextAccessor(userId: "targetUser"); + + using var server = new FeatureTestServer( + featureSettings: settings, + targetingContextAccessor: targetingAccessor, + endpointConfiguration: endpoints => + { + var group = endpoints.MapGroup("/api"); + group.WithFeatureGate("GroupTargetFeature"); + + group.MapGet("/targeted", new Func(() => "Targeted Endpoint")); + }); + + var response = await server.Client.GetAsync("/api/targeted"); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + } } } From e033a758e99d45813784e7f091b99ae287bdb473 Mon Sep 17 00:00:00 2001 From: Cristiano Rodrigues Date: Fri, 7 Feb 2025 10:30:06 -0300 Subject: [PATCH 26/35] update FeatureGateEndpointFilter for flexibility and consistency --- .../FeatureGateEndpointFilter.cs | 20 +++++++------ .../FeatureGateEndpointFilterExtensions.cs | 30 +++++-------------- 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs index 895dc1a9..d31a511e 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +// using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; @@ -19,15 +20,15 @@ public sealed class FeatureGateEndpointFilter : IEndpointFilter /// /// Gets the collection of feature flags to evaluate. /// - public IReadOnlyCollection Features; + public IEnumerable Features { get; } /// /// Gets the type of requirement (All or Any) for feature evaluation. /// - public RequirementType RequirementType; + public RequirementType RequirementType { get; } /// /// Gets whether the feature evaluation result should be negated. /// - public bool Negate; + public bool Negate { get; } /// /// Initializes a new instance of the class. @@ -40,9 +41,9 @@ public FeatureGateEndpointFilter(params string[] features) } /// - /// Initializes a new instance of the class. + /// Creates a new instance of the class. /// - /// The type of requirement for feature evaluation. + /// Specifies whether all or any of the provided features should be enabled in order to pass. /// The collection of feature flags to evaluate. /// Thrown when features collection is null or empty. public FeatureGateEndpointFilter(RequirementType requirementType, params string[] features) @@ -51,17 +52,17 @@ public FeatureGateEndpointFilter(RequirementType requirementType, params string[ } /// - /// Initializes a new instance of the class. + /// Creates a new instance of the class. /// - /// The type of requirement for feature evaluation. - /// Whether to negate the feature evaluation result. + /// Specifies whether all or any of the provided features should be enabled in order to pass. + /// Specifies whether the feature evaluation result should be negated. /// The collection of feature flags to evaluate. /// Thrown when features collection is null or empty. public FeatureGateEndpointFilter(RequirementType requirementType, bool negate, params string[] features) { if (features == null || features.Length == 0) { - throw new ArgumentNullException(nameof(features), "Features collection cannot be null or empty."); + throw new ArgumentNullException(nameof(features)); } Features = features.ToList().AsReadOnly(); @@ -84,6 +85,7 @@ public async ValueTask InvokeAsync(EndpointFilterInvocationContext conte bool enabled = RequirementType == RequirementType.All ? await Features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) : await Features.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)); + var isAllowed = Negate ? !enabled : enabled; return isAllowed diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs index 2bac0288..e3a366fe 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +// using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -12,14 +13,14 @@ namespace Microsoft.FeatureManagement.AspNetCore public static class FeatureGateEndpointFilterExtensions { /// - /// Adds a feature flag filter to the endpoint that controls access based on feature state. + /// Adds a feature gated filter to the endpoint that controls access based on whether a feature is enabled. /// /// The endpoint convention builder. /// The name of the feature flag to evaluate. /// The endpoint convention builder for chaining. /// - /// This extension method enables feature flag control over endpoint access. When the feature is disabled, - /// requests to the endpoint will return a 404 Not Found response. + /// This extension method enables feature flag control over endpoint access. + /// When the feature is disabled, requests to the endpoint will return a 404 Not Found response. /// /// /// @@ -39,9 +40,6 @@ public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventio /// The endpoint convention builder. /// The collection of feature flags to evaluate. /// The endpoint convention builder for chaining. - /// - /// When multiple features are specified, all features must be enabled for access to be granted. - /// public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, params string[] features) { return builder.AddEndpointFilter(new FeatureGateEndpointFilter(features)); @@ -51,13 +49,9 @@ public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventio /// Adds a feature flag filter to the endpoint with specified requirement type for multiple features. /// /// The endpoint convention builder. - /// The type of requirement for feature evaluation (All or Any). + /// Specifies whether all or any of the provided features should be enabled in order to pass. /// The collection of feature flags to evaluate. /// The endpoint convention builder for chaining. - /// - /// Use RequirementType.All to require all features to be enabled. - /// Use RequirementType.Any to require at least one feature to be enabled. - /// public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, RequirementType requirementType, params string[] features) { return builder.AddEndpointFilter(new FeatureGateEndpointFilter(requirementType, features)); @@ -67,12 +61,9 @@ public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventio /// Adds a feature flag filter to the endpoint with negation capability for multiple features. /// /// The endpoint convention builder. - /// Whether to negate the feature evaluation result. + /// Specifies whether the feature evaluation result should be negated. /// The collection of feature flags to evaluate. /// The endpoint convention builder for chaining. - /// - /// When negate is true, access is granted when features are disabled rather than enabled. - /// public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, bool negate, params string[] features) { return builder.AddEndpointFilter(new FeatureGateEndpointFilter(RequirementType.All, negate, features)); @@ -82,15 +73,10 @@ public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventio /// Adds a feature flag filter to the endpoint with full control over requirement type and negation. /// /// The endpoint convention builder. - /// The type of requirement for feature evaluation (All or Any). - /// Whether to negate the feature evaluation result. + /// Specifies whether all or any of the provided features should be enabled in order to pass. + /// Specifies whether the feature evaluation result should be negated. /// The collection of feature flags to evaluate. /// The endpoint convention builder for chaining. - /// - /// This method provides complete control over feature evaluation behavior: - /// - Use requirementType to specify if all or any features must be enabled - /// - Use negate to invert the evaluation result - /// public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, RequirementType requirementType, bool negate, params string[] features) { return builder.AddEndpointFilter(new FeatureGateEndpointFilter(requirementType, negate, features)); From d17365b94f2e1beb080c0698a683fdce6b508616 Mon Sep 17 00:00:00 2001 From: Cristiano Rodrigues Date: Fri, 7 Feb 2025 13:56:28 -0300 Subject: [PATCH 27/35] change featuregateendpointfilter class to internal --- .../FeatureGateEndpointFilter.cs | 2 +- .../FeatureGateEndpointFilterExtensions.cs | 21 ------------------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs index d31a511e..c09ff17b 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs @@ -15,7 +15,7 @@ namespace Microsoft.FeatureManagement.AspNetCore /// /// An endpoint filter that controls access based on feature flag states. /// - public sealed class FeatureGateEndpointFilter : IEndpointFilter + internal sealed class FeatureGateEndpointFilter : IEndpointFilter { /// /// Gets the collection of feature flags to evaluate. diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs index e3a366fe..9a7e8988 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs @@ -12,27 +12,6 @@ namespace Microsoft.FeatureManagement.AspNetCore /// public static class FeatureGateEndpointFilterExtensions { - /// - /// Adds a feature gated filter to the endpoint that controls access based on whether a feature is enabled. - /// - /// The endpoint convention builder. - /// The name of the feature flag to evaluate. - /// The endpoint convention builder for chaining. - /// - /// This extension method enables feature flag control over endpoint access. - /// When the feature is disabled, requests to the endpoint will return a 404 Not Found response. - /// - /// - /// - /// endpoints.MapGet("/api/feature", () => "Feature Enabled") - /// .WithFeatureGate("MyFeature"); - /// - /// - public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventionBuilder builder, string feature) - { - return builder.AddEndpointFilter(new FeatureGateEndpointFilter(feature)); - } - /// /// Adds a feature flag filter to the endpoint that controls access based on multiple feature states. /// All features must be enabled for access to be granted. From e4e29e1e6cbae8ddffc90ecec91d77a6602ba12e Mon Sep 17 00:00:00 2001 From: Cristiano Rodrigues Date: Fri, 7 Feb 2025 14:25:01 -0300 Subject: [PATCH 28/35] refactor featuregateendpointfilter for improved encapsulation --- .../FeatureGateEndpointFilter.cs | 26 ++++++++++--------- .../FeatureGateEndpointFilterExtensions.cs | 8 +++--- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs index c09ff17b..fef839f5 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilter.cs @@ -20,15 +20,17 @@ internal sealed class FeatureGateEndpointFilter : IEndpointFilter /// /// Gets the collection of feature flags to evaluate. /// - public IEnumerable Features { get; } + private readonly IEnumerable _features; + /// /// Gets the type of requirement (All or Any) for feature evaluation. /// - public RequirementType RequirementType { get; } + private readonly RequirementType _requirementType; + /// /// Gets whether the feature evaluation result should be negated. /// - public bool Negate { get; } + private readonly bool _negate; /// /// Initializes a new instance of the class. @@ -36,7 +38,7 @@ internal sealed class FeatureGateEndpointFilter : IEndpointFilter /// The collection of feature flags to evaluate. /// Thrown when features collection is null or empty. public FeatureGateEndpointFilter(params string[] features) - : this(RequirementType.All, false, features) + : this(RequirementType.All, negate: false, features) { } @@ -47,7 +49,7 @@ public FeatureGateEndpointFilter(params string[] features) /// The collection of feature flags to evaluate. /// Thrown when features collection is null or empty. public FeatureGateEndpointFilter(RequirementType requirementType, params string[] features) - : this(requirementType, false, features) + : this(requirementType, negate: false, features) { } @@ -65,9 +67,9 @@ public FeatureGateEndpointFilter(RequirementType requirementType, bool negate, p throw new ArgumentNullException(nameof(features)); } - Features = features.ToList().AsReadOnly(); - RequirementType = requirementType; - Negate = negate; + _features = features; + _requirementType = requirementType; + _negate = negate; } /// @@ -82,11 +84,11 @@ public async ValueTask InvokeAsync(EndpointFilterInvocationContext conte { IVariantFeatureManager fm = context.HttpContext.RequestServices.GetRequiredService(); - bool enabled = RequirementType == RequirementType.All - ? await Features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) - : await Features.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)); + bool enabled = _requirementType == RequirementType.All + ? await _features.All(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)) + : await _features.Any(async feature => await fm.IsEnabledAsync(feature).ConfigureAwait(false)); - var isAllowed = Negate ? !enabled : enabled; + bool isAllowed = _negate ? !enabled : enabled; return isAllowed ? await next(context).ConfigureAwait(false) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs index 9a7e8988..5bd76f55 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs +++ b/src/Microsoft.FeatureManagement.AspNetCore/FeatureGateEndpointFilterExtensions.cs @@ -13,7 +13,7 @@ namespace Microsoft.FeatureManagement.AspNetCore public static class FeatureGateEndpointFilterExtensions { /// - /// Adds a feature flag filter to the endpoint that controls access based on multiple feature states. + /// Adds a filter to the endpoint that gates access based on whether one or more features are enabled. /// All features must be enabled for access to be granted. /// /// The endpoint convention builder. @@ -25,7 +25,7 @@ public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventio } /// - /// Adds a feature flag filter to the endpoint with specified requirement type for multiple features. + /// Adds a filter to the endpoint with specified requirement type for multiple features. /// /// The endpoint convention builder. /// Specifies whether all or any of the provided features should be enabled in order to pass. @@ -37,7 +37,7 @@ public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventio } /// - /// Adds a feature flag filter to the endpoint with negation capability for multiple features. + /// Adds a filter to the endpoint with negation capability for multiple features. /// /// The endpoint convention builder. /// Specifies whether the feature evaluation result should be negated. @@ -49,7 +49,7 @@ public static IEndpointConventionBuilder WithFeatureGate(this IEndpointConventio } /// - /// Adds a feature flag filter to the endpoint with full control over requirement type and negation. + /// Adds a filter to the endpoint with full control over requirement type and negation. /// /// The endpoint convention builder. /// Specifies whether all or any of the provided features should be enabled in order to pass. From aeed650dac6995196ae67622f107955f81580cf0 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Mon, 10 Feb 2025 12:16:49 +0800 Subject: [PATCH 29/35] Do not use DI for console app example (#528) * not use DI for console app example * update console targeting example * simlify example --- examples/ConsoleApp/Program.cs | 55 +++++++++------------ examples/TargetingConsoleApp/Program.cs | 65 +++++++++++-------------- 2 files changed, 51 insertions(+), 69 deletions(-) diff --git a/examples/ConsoleApp/Program.cs b/examples/ConsoleApp/Program.cs index fb43a50e..ab3249da 100644 --- a/examples/ConsoleApp/Program.cs +++ b/examples/ConsoleApp/Program.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. // using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.FeatureManagement; // @@ -11,45 +10,35 @@ .AddJsonFile("appsettings.json") .Build(); -// -// Setup application services + feature management -IServiceCollection services = new ServiceCollection(); +var featureManager = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration)) +{ + FeatureFilters = new List { new AccountIdFilter() } +}; -services.AddSingleton(configuration) - .AddFeatureManagement() - .AddFeatureFilter(); +var accounts = new List() +{ + "abc", + "adef", + "abcdefghijklmnopqrstuvwxyz" +}; // -// Get the feature manager from application services -using (ServiceProvider serviceProvider = services.BuildServiceProvider()) +// Mimic work items in a task-driven console application +foreach (var account in accounts) { - IFeatureManager featureManager = serviceProvider.GetRequiredService(); - - var accounts = new List() - { - "abc", - "adef", - "abcdefghijklmnopqrstuvwxyz" - }; + const string FeatureName = "Beta"; // - // Mimic work items in a task-driven console application - foreach (var account in accounts) + // Check if feature enabled + // + var accountServiceContext = new AccountServiceContext { - const string FeatureName = "Beta"; - - // - // Check if feature enabled - // - var accountServiceContext = new AccountServiceContext - { - AccountId = account - }; + AccountId = account + }; - bool enabled = await featureManager.IsEnabledAsync(FeatureName, accountServiceContext); + bool enabled = await featureManager.IsEnabledAsync(FeatureName, accountServiceContext); - // - // Output results - Console.WriteLine($"The {FeatureName} feature is {(enabled ? "enabled" : "disabled")} for the '{account}' account."); - } + // + // Output results + Console.WriteLine($"The {FeatureName} feature is {(enabled ? "enabled" : "disabled")} for the '{account}' account."); } diff --git a/examples/TargetingConsoleApp/Program.cs b/examples/TargetingConsoleApp/Program.cs index 02c17542..ce944300 100644 --- a/examples/TargetingConsoleApp/Program.cs +++ b/examples/TargetingConsoleApp/Program.cs @@ -1,5 +1,7 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; using Microsoft.FeatureManagement; using Microsoft.FeatureManagement.FeatureFilters; using TargetingConsoleApp.Identity; @@ -10,48 +12,39 @@ .AddJsonFile("appsettings.json") .Build(); -// -// Setup application services + feature management -IServiceCollection services = new ServiceCollection(); - -services.AddSingleton(configuration) - .AddFeatureManagement(); +var featureManager = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration)) +{ + FeatureFilters = new List { new ContextualTargetingFilter() } +}; var userRepository = new InMemoryUserRepository(); // -// Get the feature manager from application services -using (ServiceProvider serviceProvider = services.BuildServiceProvider()) +// We'll simulate a task to run on behalf of each known user +// To do this we enumerate all the users in our user repository +IEnumerable userIds = InMemoryUserRepository.Users.Select(u => u.Id); + +// +// Mimic work items in a task-driven console application +foreach (string userId in userIds) { - IFeatureManager featureManager = serviceProvider.GetRequiredService(); + const string FeatureName = "Beta"; // - // We'll simulate a task to run on behalf of each known user - // To do this we enumerate all the users in our user repository - IEnumerable userIds = InMemoryUserRepository.Users.Select(u => u.Id); + // Get user + User user = await userRepository.GetUser(userId); // - // Mimic work items in a task-driven console application - foreach (string userId in userIds) + // Check if feature enabled + var targetingContext = new TargetingContext { - const string FeatureName = "Beta"; - - // - // Get user - User user = await userRepository.GetUser(userId); - - // - // Check if feature enabled - var targetingContext = new TargetingContext - { - UserId = user.Id, - Groups = user.Groups - }; - - bool enabled = await featureManager.IsEnabledAsync(FeatureName, targetingContext); - - // - // Output results - Console.WriteLine($"The {FeatureName} feature is {(enabled ? "enabled" : "disabled")} for the user '{userId}'."); - } + UserId = user.Id, + Groups = user.Groups + }; + + bool enabled = await featureManager.IsEnabledAsync(FeatureName, targetingContext); + + // + // Output results + Console.WriteLine($"The {FeatureName} feature is {(enabled ? "enabled" : "disabled")} for the user '{userId}'."); } From fa0e4574c757c3da0db118d76b7fe07372f10ecd Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Mon, 17 Feb 2025 13:52:14 +0800 Subject: [PATCH 30/35] fix lint (#530) --- examples/FeatureFlagDemo/Controllers/HomeController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/FeatureFlagDemo/Controllers/HomeController.cs b/examples/FeatureFlagDemo/Controllers/HomeController.cs index e7bfcd43..b87799fc 100644 --- a/examples/FeatureFlagDemo/Controllers/HomeController.cs +++ b/examples/FeatureFlagDemo/Controllers/HomeController.cs @@ -31,7 +31,7 @@ public async Task About() if (await _featureManager.IsEnabledAsync(MyFeatureFlags.CustomViewData)) { ViewData["Message"] = $"This is FANCY CONTENT you can see only if '{MyFeatureFlags.CustomViewData}' is enabled."; - }; + } return View(); } From 24bcf7be843cc9091981f079b2cb91d6de822b8f Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Fri, 28 Mar 2025 11:28:47 -0700 Subject: [PATCH 31/35] Adds cancellationtoken to the FeatureEvaluationContext- and respects CancellationToken throughout evaluation --- .../FeatureFilterEvaluationContext.cs | 6 +++++ .../FeatureManager.cs | 25 +++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs b/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs index 2591e667..6bceb0f7 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // using Microsoft.Extensions.Configuration; +using System.Threading; namespace Microsoft.FeatureManagement { @@ -25,5 +26,10 @@ public class FeatureFilterEvaluationContext /// The settings are made available for s that implement . /// public object Settings { get; set; } + + /// + /// A cancellation token that can be used to request cancellation of the feature evaluation operation. + /// + public CancellationToken CancellationToken { get; set; } = CancellationToken.None; } } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index dfe794ec..a1384735 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -472,6 +472,11 @@ private async ValueTask IsEnabledAsync(FeatureDefinition feature // For all enabling filters listed in the feature's state, evaluate them according to requirement type foreach (FeatureFilterConfiguration featureFilterConfiguration in featureDefinition.EnabledFor) { + if (cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + } + filterIndex++; // @@ -524,7 +529,8 @@ private async ValueTask IsEnabledAsync(FeatureDefinition feature var context = new FeatureFilterEvaluationContext() { FeatureName = featureDefinition.Name, - Parameters = featureFilterConfiguration.Parameters + Parameters = featureFilterConfiguration.Parameters, + CancellationToken = cancellationToken }; BindSettings(filter, context, filterIndex); @@ -611,6 +617,11 @@ private ValueTask AssignVariantAsync(EvaluationEvent evaluati { foreach (UserAllocation user in evaluationEvent.FeatureDefinition.Allocation.User) { + if (cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + } + if (TargetingEvaluator.IsTargeted(targetingContext.UserId, user.Users, _assignerOptions.IgnoreCase)) { if (string.IsNullOrEmpty(user.Variant)) @@ -637,6 +648,11 @@ private ValueTask AssignVariantAsync(EvaluationEvent evaluati { foreach (GroupAllocation group in evaluationEvent.FeatureDefinition.Allocation.Group) { + if (cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + } + if (TargetingEvaluator.IsTargeted(targetingContext.Groups, group.Groups, _assignerOptions.IgnoreCase)) { if (string.IsNullOrEmpty(group.Variant)) @@ -663,6 +679,11 @@ private ValueTask AssignVariantAsync(EvaluationEvent evaluati { foreach (PercentileAllocation percentile in evaluationEvent.FeatureDefinition.Allocation.Percentile) { + if (cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + } + if (TargetingEvaluator.IsTargeted( targetingContext, percentile.From, @@ -795,7 +816,7 @@ private bool IsMatchingName(Type filterType, string filterName) // // Feature filters can have namespaces in their alias // If a feature is configured to use a filter without a namespace such as 'MyFilter', then it can match 'MyOrg.MyProduct.MyFilter' or simply 'MyFilter' - // If a feature is configured to use a filter with a namespace such as 'MyOrg.MyProduct.MyFilter' then it can only match 'MyOrg.MyProduct.MyFilter' + // If a feature is configured to use a filter with a namespace such as 'MyOrg.MyProduct.MyFilter' then it can only match 'MyOrg.MyProduct.MyFilter' if (filterName.Contains('.')) { // From baf76647222101ac2dd712b6726b542da30f08b1 Mon Sep 17 00:00:00 2001 From: Alexander Batishchev Date: Tue, 6 May 2025 08:09:45 -0700 Subject: [PATCH 32/35] Fixed broken link to JSON schema in MicrosoftFeatureManagementFields.cs --- .../MicrosoftFeatureManagementFields.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.FeatureManagement/MicrosoftFeatureManagementFields.cs b/src/Microsoft.FeatureManagement/MicrosoftFeatureManagementFields.cs index bac65418..05c35f99 100644 --- a/src/Microsoft.FeatureManagement/MicrosoftFeatureManagementFields.cs +++ b/src/Microsoft.FeatureManagement/MicrosoftFeatureManagementFields.cs @@ -4,14 +4,14 @@ namespace Microsoft.FeatureManagement { - // - // Microsoft Feature Management schema: https://github.com/Azure/AppConfiguration/blob/main/docs/FeatureManagement/FeatureManagement.v1.0.0.schema.json + /// + /// See Microsoft Feature Management schema: https://github.com/microsoft/FeatureManagement/blob/main/Schema/FeatureManagement.v2.0.0.schema.json + /// internal static class MicrosoftFeatureManagementFields { public const string FeatureManagementSectionName = "feature_management"; public const string FeatureFlagsSectionName = "feature_flags"; - // // Microsoft feature flag keywords public const string Id = "id"; public const string Enabled = "enabled"; @@ -19,7 +19,6 @@ internal static class MicrosoftFeatureManagementFields public const string ClientFilters = "client_filters"; public const string RequirementType = "requirement_type"; - // // Allocation keywords public const string AllocationSectionName = "allocation"; public const string AllocationDefaultWhenDisabled = "default_when_disabled"; @@ -34,7 +33,6 @@ internal static class MicrosoftFeatureManagementFields public const string PercentileAllocationTo = "to"; public const string AllocationSeed = "seed"; - // // Client filter keywords public const string Name = "name"; public const string Parameters = "parameters"; From 20c12d2cb897b557d158670f86d0afd6554df905 Mon Sep 17 00:00:00 2001 From: Ross Grambo Date: Thu, 8 May 2025 16:19:40 -0700 Subject: [PATCH 33/35] Resolving comments --- .../FeatureFilterEvaluationContext.cs | 2 +- .../FeatureManager.cs | 20 ++++--------------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs b/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs index 6bceb0f7..91589852 100644 --- a/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs +++ b/src/Microsoft.FeatureManagement/FeatureFilterEvaluationContext.cs @@ -30,6 +30,6 @@ public class FeatureFilterEvaluationContext /// /// A cancellation token that can be used to request cancellation of the feature evaluation operation. /// - public CancellationToken CancellationToken { get; set; } = CancellationToken.None; + public CancellationToken CancellationToken { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index a1384735..4e0e426a 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -472,10 +472,7 @@ private async ValueTask IsEnabledAsync(FeatureDefinition feature // For all enabling filters listed in the feature's state, evaluate them according to requirement type foreach (FeatureFilterConfiguration featureFilterConfiguration in featureDefinition.EnabledFor) { - if (cancellationToken.IsCancellationRequested) - { - cancellationToken.ThrowIfCancellationRequested(); - } + cancellationToken.ThrowIfCancellationRequested(); filterIndex++; @@ -617,10 +614,7 @@ private ValueTask AssignVariantAsync(EvaluationEvent evaluati { foreach (UserAllocation user in evaluationEvent.FeatureDefinition.Allocation.User) { - if (cancellationToken.IsCancellationRequested) - { - cancellationToken.ThrowIfCancellationRequested(); - } + cancellationToken.ThrowIfCancellationRequested(); if (TargetingEvaluator.IsTargeted(targetingContext.UserId, user.Users, _assignerOptions.IgnoreCase)) { @@ -648,10 +642,7 @@ private ValueTask AssignVariantAsync(EvaluationEvent evaluati { foreach (GroupAllocation group in evaluationEvent.FeatureDefinition.Allocation.Group) { - if (cancellationToken.IsCancellationRequested) - { - cancellationToken.ThrowIfCancellationRequested(); - } + cancellationToken.ThrowIfCancellationRequested(); if (TargetingEvaluator.IsTargeted(targetingContext.Groups, group.Groups, _assignerOptions.IgnoreCase)) { @@ -679,10 +670,7 @@ private ValueTask AssignVariantAsync(EvaluationEvent evaluati { foreach (PercentileAllocation percentile in evaluationEvent.FeatureDefinition.Allocation.Percentile) { - if (cancellationToken.IsCancellationRequested) - { - cancellationToken.ThrowIfCancellationRequested(); - } + cancellationToken.ThrowIfCancellationRequested(); if (TargetingEvaluator.IsTargeted( targetingContext, From 58ae781b41a49794f8df2c2b7da25af468fe1e77 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Mon, 19 May 2025 16:34:04 -0700 Subject: [PATCH 34/35] Add gitattributes (#538) * add gitattributs and renormalize all files * fix eof --- .gitattributes | 3 ++ LICENSE | 42 ++++++++++++++-------------- README.md | 76 +++++++++++++++++++++++++------------------------- 3 files changed, 62 insertions(+), 59 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..538c95f5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# If there are abnormal line endings in any file, run "git add --renormalize ", +# review the changes, and commit them to fix the line endings. +* text=auto diff --git a/LICENSE b/LICENSE index 4b1ad51b..21071075 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ - MIT License - - Copyright (c) Microsoft Corporation. All rights reserved. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/README.md b/README.md index b4a2bc6c..b1dee8ab 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,38 @@ -# .NET Feature Management - -[![Microsoft.FeatureManagement](https://img.shields.io/nuget/v/Microsoft.FeatureManagement?label=Microsoft.FeatureManagement)](https://www.nuget.org/packages/Microsoft.FeatureManagement) -[![Microsoft.FeatureManagement.AspNetCore](https://img.shields.io/nuget/v/Microsoft.FeatureManagement.AspNetCore?label=Microsoft.FeatureManagement.AspNetCore)](https://www.nuget.org/packages/Microsoft.FeatureManagement.AspNetCore) - -Feature management provides a way to develop and expose application functionality based on features. Many applications have special requirements when a new feature is developed such as when the feature should be enabled and under what conditions. This library provides a way to define these relationships, and also integrates into common .NET code patterns to make exposing these features possible. - -## Get started - -[**Quickstart**](https://learn.microsoft.com/azure/azure-app-configuration/quickstart-feature-flag-dotnet): A quickstart guide is available to learn how to integrate feature flags from *Azure App Configuration* into your .NET applications. - -[**Feature Reference**](https://learn.microsoft.com/azure/azure-app-configuration/feature-management-dotnet-reference): This document provides a full feature rundown. - -[**API reference**](https://go.microsoft.com/fwlink/?linkid=2091700): This API reference details the API surface of the libraries contained within this repository. - -## Examples - -* [.NET Console App](./examples/ConsoleApp) -* [.NET Console App with Targeting](./examples/TargetingConsoleApp) -* [ASP.NET Core Web App (Razor Page)](./examples/RazorPages) -* [ASP.NET Core Web App (MVC)](./examples/FeatureFlagDemo) -* [Blazor Server App](./examples/BlazorServerApp) -* [ASP.NET Core Web App with Variants and Telemetry](./examples/VariantAndTelemetryDemo) -* [ASP.NET Core Web App with Variant Service](./examples/VariantServiceDemo) - -## Contributing - -This project welcomes contributions and suggestions. Most contributions require you to agree to a -Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us -the rights to use your contribution. For details, visit https://cla.microsoft.com. - -When you submit a pull request, a CLA-bot will automatically determine whether you need to provide -a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions -provided by the bot. You will only need to do this once across all repos using our CLA. - -This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). -For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or -contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. +# .NET Feature Management + +[![Microsoft.FeatureManagement](https://img.shields.io/nuget/v/Microsoft.FeatureManagement?label=Microsoft.FeatureManagement)](https://www.nuget.org/packages/Microsoft.FeatureManagement) +[![Microsoft.FeatureManagement.AspNetCore](https://img.shields.io/nuget/v/Microsoft.FeatureManagement.AspNetCore?label=Microsoft.FeatureManagement.AspNetCore)](https://www.nuget.org/packages/Microsoft.FeatureManagement.AspNetCore) + +Feature management provides a way to develop and expose application functionality based on features. Many applications have special requirements when a new feature is developed such as when the feature should be enabled and under what conditions. This library provides a way to define these relationships, and also integrates into common .NET code patterns to make exposing these features possible. + +## Get started + +[**Quickstart**](https://learn.microsoft.com/azure/azure-app-configuration/quickstart-feature-flag-dotnet): A quickstart guide is available to learn how to integrate feature flags from *Azure App Configuration* into your .NET applications. + +[**Feature Reference**](https://learn.microsoft.com/azure/azure-app-configuration/feature-management-dotnet-reference): This document provides a full feature rundown. + +[**API reference**](https://go.microsoft.com/fwlink/?linkid=2091700): This API reference details the API surface of the libraries contained within this repository. + +## Examples + +* [.NET Console App](./examples/ConsoleApp) +* [.NET Console App with Targeting](./examples/TargetingConsoleApp) +* [ASP.NET Core Web App (Razor Page)](./examples/RazorPages) +* [ASP.NET Core Web App (MVC)](./examples/FeatureFlagDemo) +* [Blazor Server App](./examples/BlazorServerApp) +* [ASP.NET Core Web App with Variants and Telemetry](./examples/VariantAndTelemetryDemo) +* [ASP.NET Core Web App with Variant Service](./examples/VariantServiceDemo) + +## Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. From 139296da6c6e405f5abc32e5326569a564d88aa4 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang <141655842+zhiyuanliang-ms@users.noreply.github.com> Date: Tue, 20 May 2025 15:59:24 +0800 Subject: [PATCH 35/35] version bump 4.1.0 (#540) --- .../Microsoft.FeatureManagement.AspNetCore.csproj | 3 +-- ...soft.FeatureManagement.Telemetry.ApplicationInsights.csproj | 3 +-- .../Microsoft.FeatureManagement.csproj | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj index c0c623df..ea246f2f 100644 --- a/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj +++ b/src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj @@ -5,9 +5,8 @@ 4 - 0 + 1 0 - -preview5 diff --git a/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj b/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj index 58d2e5a4..85bfe628 100644 --- a/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj +++ b/src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj @@ -4,9 +4,8 @@ 4 - 0 + 1 0 - -preview5 diff --git a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj index 9f09649d..31a47510 100644 --- a/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj +++ b/src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj @@ -5,9 +5,8 @@ 4 - 0 + 1 0 - -preview5